Fix cameras, improve weather, delegate health renderer to a helper,
authorScott <[email protected]>
Sun, 5 Dec 2021 04:35:41 +0000 (20:35 -0800)
committerScott <[email protected]>
Sun, 5 Dec 2021 04:35:41 +0000 (20:35 -0800)
etc...

camera_trigger.py
gkeep_renderer.py
health_renderer.py
kiosk.py
renderer_catalog.py
stranger_renderer.py
urbanist_renderer.py
weather_renderer.py

index 41dc809c300b844b4289acabc2ed3cbe1638e845..8582889d285ab49750299facf60ab242a793effd 100644 (file)
@@ -1,13 +1,14 @@
 #!/usr/bin/env python3
 
-from datetime import datetime
-import glob
+import logging
 import os
 import time
 from typing import List, Tuple, Optional
 
 import trigger
-import utils
+
+
+logger = logging.getLogger(__file__)
 
 
 class any_camera_trigger(trigger.trigger):
@@ -58,7 +59,7 @@ class any_camera_trigger(trigger.trigger):
                 ts = os.stat(filename).st_ctime
                 age = now - ts
                 if ts != self.last_trigger_timestamp[camera] and age < 10:
-                    print(f'Camera: {camera}, age {age}')
+                    logger.info(f'{camera} is triggered; {filename} touched {age}s ago (@{ts}')
                     self.last_trigger_timestamp[camera] = ts
                     num_cameras_with_recent_triggers += 1
 
@@ -83,26 +84,27 @@ class any_camera_trigger(trigger.trigger):
                         self.triggers_in_the_past_seven_min[camera] <= 4
                         or num_cameras_with_recent_triggers > 1
                     ):
+                        logger.info(f'{camera} has {self.triggers_in_the_past_seven_min[camera]} triggers in the past 7d.')
+                        logger.info(f'{num_cameras_with_recent_triggers} cameras are triggered right now.')
+
                         age = now - self.last_trigger_timestamp[camera]
                         priority = self.choose_priority(camera, int(age))
-                        print(
-                            f"{utils.timestamp()}: *** {camera}[{priority}] CAMERA TRIGGER ***"
-                        )
+                        logger.info(f'*** CAMERA TRIGGER (hidden/{camera}.html @ {priority}) ***')
                         triggers.append(
                             (
-                                f"hidden/{camera}.html",
+                                f"hidden/unwrapped_{camera}.html",
                                 priority,
                             )
                         )
                     else:
-                        print(f"{utils.timestamp()}: Camera {camera} too spammy, squelching it")
+                        logger.info(f'{camera} is too spammy; {self.triggers_in_the_past_seven_min[camera]} events in the past 7m.  Ignoring it.')
         except Exception as e:
-            print(e)
-            pass
+            logger.exception(e)
 
         if len(triggers) == 0:
             return None
         else:
+            logger.info('There are active camera triggers!')
             return triggers
 
 
index b639ed2794ad6afff3f73a1a9bc29f678f3227a9..443abc10e8e4c89a1037c3dc88a0fa9e234e0eb1 100644 (file)
@@ -69,12 +69,9 @@ class gkeep_renderer(renderer.abstaining_renderer):
 
                 individual_lines = contents.split("\n")
                 num_lines = len(individual_lines)
-                max_length = 0
                 contents = ""
                 for x in individual_lines:
                     length = len(x)
-                    if length > max_length:
-                        max_length = length
                     leading_spaces = len(x) - len(x.lstrip(" "))
                     leading_spaces //= 2
                     leading_spaces = int(leading_spaces)
@@ -107,9 +104,9 @@ class gkeep_renderer(renderer.abstaining_renderer):
 <HR style="border-top:3px solid white;">
 """
                             )
-                    if num_lines >= 12 and max_length < 120:
+                    if num_lines >= 12:
                         logger.debug(
-                            f"{num_lines} lines (max={max_length} chars): two columns"
+                            f"{num_lines} lines: two column mode"
                         )
                         f.write('<TABLE BORDER=0 WIDTH=100%><TR valign="top">')
                         f.write(
@@ -129,7 +126,7 @@ class gkeep_renderer(renderer.abstaining_renderer):
                         f.write("</UL></FONT></TD></TR></TABLE></DIV>\n")
                     else:
                         logger.debug(
-                            f"{num_lines} lines (max={max_length} chars): one column"
+                            f"{num_lines} lines: one column mode"
                         )
                         f.write(f"<FONT><UL>{contents}</UL></FONT>")
                     f.write("</DIV>")
index 5416af2fb1bebb847af55f8522b9edfb27232624..cfa6a8a2dfd0f47ff6777a7e09fab95c53b97721 100644 (file)
@@ -1,14 +1,11 @@
 #!/usr/bin/env python3
 
 import logging
-import os
-import time
+import subprocess
 from typing import Dict
 
-import constants
 import file_writer
 import renderer
-import utils
 
 
 logger = logging.getLogger(__file__)
@@ -20,141 +17,12 @@ class periodic_health_renderer(renderer.abstaining_renderer):
 
     def periodic_render(self, key: str) -> bool:
         with file_writer.file_writer("periodic-health_6_300.html") as f:
-            timestamps = "/timestamps/"
-            days = constants.seconds_per_day
-            hours = constants.seconds_per_hour
-            mins = constants.seconds_per_minute
-            minutes = mins
-            limits = {
-                timestamps + "last_http_probe_wannabe_house": mins * 10,
-                timestamps + "last_http_probe_meerkat_cabin": mins * 10,
-                timestamps + "last_http_probe_dns_house": mins * 10,
-                timestamps + "last_http_probe_rpi_cabin": mins * 10,
-                timestamps + "last_http_probe_rpi_house": mins * 10,
-                timestamps + "last_http_probe_therm_house": mins * 10,
-                timestamps + "last_rsnapshot_hourly": hours * 24,
-                timestamps + "last_rsnapshot_daily": days * 3,
-                timestamps + "last_rsnapshot_weekly": days * 14,
-                timestamps + "last_rsnapshot_monthly": days * 70,
-                timestamps + "last_zfssnapshot_hourly": hours * 5,
-                timestamps + "last_zfssnapshot_daily": hours * 36,
-                timestamps + "last_zfssnapshot_weekly": days * 9,
-                timestamps + "last_zfssnapshot_monthly": days * 70,
-                timestamps + "last_zfssnapshot_cleanup": hours * 24,
-                timestamps + "last_zfs_scrub": days * 9,
-                timestamps + "last_backup_zfs_scrub": days * 9,
-                timestamps + "last_cabin_zfs_scrub": days * 9,
-                timestamps + "last_zfsxfer_backup.house": hours * 36,
-                timestamps + "last_zfsxfer_ski.dyn.guru.org": days * 7,
-                timestamps + "last_photos_sync": hours * 8,
-                timestamps + "last_disk_selftest_short": days * 14,
-                timestamps + "last_disk_selftest_long": days * 31,
-                timestamps + "last_backup_disk_selftest_short": days * 14,
-                timestamps + "last_backup_disk_selftest_long": days * 31,
-                timestamps + "last_cabin_disk_selftest_short": days * 14,
-                timestamps + "last_cabin_disk_selftest_long": days * 31,
-                timestamps + "last_cabin_rpi_ping": mins * 20,
-                timestamps + "last_healthy_wifi": mins * 10,
-                timestamps + "last_healthy_network": mins * 10,
-                timestamps + "last_scott_sync": days * 2,
-            }
-            self.write_header(f)
-
-            now = time.time()
-            n = 0
-            for filepath, limit_sec in sorted(limits.items()):
-                ts = os.stat(filepath).st_mtime
-                age = now - ts
-                logger.debug(f"{filepath} -- age: {age}, limit {limit_sec}")
-                if age < limits[filepath]:
-                    # OK
-                    f.write(
-                        '<TD BGCOLOR="#007010" HEIGHT=100 WIDTH=33% STYLE="text-size:70%; vertical-align: middle;">\n'
-                    )
-                    txt_color="#ffffff"
-                else:
-                    # BAD!
-                    f.write(
-                        '<TD BGCOLOR="#990000" HEIGHT=100 WIDTH=33% CLASS="invalid" STYLE="text-size:70%; vertical-align:middle;">\n'
-                    )
-                    txt_color="#000000"
-                f.write(f"  <CENTER><FONT SIZE=-1 COLOR={txt_color}>\n")
-
-                name = filepath.replace(timestamps, "")
-                name = name.replace("last_", "")
-                name = name.replace("_", "&nbsp;")
-                duration = utils.describe_duration_briefly(int(age))
-
-                logger.debug(f"{name} is {duration} old.")
-                f.write(f"{name}<BR>\n<B>{duration}</B> old.\n")
-                f.write("</FONT></CENTER>\n</TD>\n\n")
-                n += 1
-                if n % 3 == 0:
-                    f.write("</TR>\n<TR>\n<!-- ------------------- -->\n")
-            self.write_footer(f)
+            command = "/home/pi/bin/cronhealth.py --kiosk_mode"
+            p = subprocess.Popen(command, shell=True, bufsize=0, stdout=subprocess.PIPE)
+            for line in iter(p.stdout.readline, b''):
+                f.write(line.decode("utf-8"))
+            p.stdout.close()
         return True
 
-    def write_header(self, f: file_writer.file_writer) -> None:
-        f.write(
-            """
-<HTML>
-<HEAD>
-<STYLE>
-@-webkit-keyframes invalid {
-  from { background-color: #ff6400; }
-  to { background-color: #ff0000; }
-  padding-right: 25px;
-  padding-left: 25px;
-}
-@-moz-keyframes invalid {
-  from { background-color: #ff6400; }
-  to { background-color: #ff0000; }
-  padding-right: 25px;
-  padding-left: 25px;
-}
-@-o-keyframes invalid {
-  from { background-color: #ff6400; }
-  to { background-color: #ff0000; }
-  padding-right: 25px;
-  padding-left: 25px;
-}
-@keyframes invalid {
-  from { background-color: #ff6400; }
-  to { background-color: #ff0000; }
-  padding-right: 25px;
-  padding-left: 25px;
-}
-.invalid {
-  -webkit-animation: invalid 1s infinite; /* Safari 4+ */
-  -moz-animation:    invalid 1s infinite; /* Fx 5+ */
-  -o-animation:      invalid 1s infinite; /* Opera 12+ */
-  animation:         invalid 1s infinite; /* IE 10+ */
-}
-</STYLE>
-<meta http-equiv="cache-control" content="max-age=0" />
-<meta http-equiv="cache-control" content="no-cache" />
-<meta http-equiv="expires" content="0" />
-<meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />
-<meta http-equiv="pragma" content="no-cache" />
-</HEAD>
-<BODY>
-<H1>Periodic Cronjob Health Report</H1>
-<HR>
-<CENTER>
-<TABLE BORDER=0 WIDTH=99% style="font-size:16pt">
-<TR>
-"""
-        )
-
-    def write_footer(self, f: file_writer.file_writer) -> None:
-        f.write(
-            """
-</TR>
-</TABLE>
-</BODY>
-</HTML>"""
-        )
-
-
 #test = periodic_health_renderer({"Test", 123})
 #test.periodic_render("Test")
index 514fbac69ea64f32a08070a4d1232881fe8e290a..99d40c30b7f7ebbf427d54d5adef02f7bdf7dc29 100755 (executable)
--- a/kiosk.py
+++ b/kiosk.py
@@ -102,7 +102,7 @@ def guess_page(command: str, page_chooser: chooser.chooser) -> str:
         page = page.replace('mynorthwest', 'northwest news')
         page = page.replace('myq', 'myq garage door status')
         page = page.replace('gomenu', 'dinner menu')
-        page = page.replace('wsdot', 'traffic')
+        page = page.replace('gmaps-seattle-unwrapped', 'traffic')
         page = page.replace('gomenu', 'dinner menu')
         page = page.replace('WSJNews', 'news')
         page = page.replace('telma', 'telma cabin')
@@ -111,6 +111,7 @@ def guess_page(command: str, page_chooser: chooser.chooser) -> str:
         logger.debug(f'normalize_page output: {page}')
         return page
 
+    logger.info(f'No exact match for f{command}; trying to guess...')
     best_page = None
     best_score = None
     for page in page_chooser.get_page_list():
@@ -118,12 +119,15 @@ def guess_page(command: str, page_chooser: chooser.chooser) -> str:
         score = SequenceMatcher(None, command, npage).ratio()
         if best_score is None or score > best_score:
             best_page = page
+            best_score = score
     assert best_page is not None
+    logger.info(f'Best guess for f{command} => {best_page} (score = {best_score})')
     return best_page
 
 
 def process_command(command: str, page_history: List[str], page_chooser) -> str:
-    logger.debug(f'Parsing verbal command: {command}')
+    command = command.lower()
+    logger.info(f'Parsing verbal command: {command}')
     page = None
     if 'hold' in command:
         page = page_history[0]
@@ -186,7 +190,7 @@ def process_command(command: str, page_history: List[str], page_chooser) -> str:
     elif 'twitter' in command:
         page = 'twitter_10_3600.html'
     elif 'traffic' in command:
-        page = 'wsdot-bridges_3_none.html'
+        page = 'gmaps-seattle-unwrapped_5_none.html'
     elif 'front' in command and 'door' in command:
         page = 'hidden/frontdoor.html'
     elif 'driveway' in command:
@@ -196,7 +200,7 @@ def process_command(command: str, page_history: List[str], page_chooser) -> str:
     else:
         page = guess_page(command, page_chooser)
     assert page is not None
-    logger.debug(f'Chose page {page}')
+    logger.info(f'Parsed to {page}')
     return page
 
 
@@ -256,7 +260,7 @@ def thread_change_current(command_queue: Queue) -> None:
                 # Set current.shtml to the right page.
                 try:
                     with open(current_file, "w") as f:
-                        emit_wrapped(
+                        emit(
                             f,
                             page,
                             override_refresh_sec = constants.emergency_refresh_period_sec,
@@ -275,7 +279,7 @@ def thread_change_current(command_queue: Queue) -> None:
 
                 # Fix this hack... maybe read the webserver logs and see if it
                 # actually was picked up?
-                time.sleep(0.95)
+                time.sleep(0.999)
                 os.remove(emergency_file)
                 logger.debug(f'chooser: ...and removed {emergency_file}.')
 
@@ -287,7 +291,7 @@ def thread_change_current(command_queue: Queue) -> None:
             swap_page_target = now + constants.refresh_period_sec
             try:
                 with open(current_file, "w") as f:
-                    emit_wrapped(f, page)
+                    emit(f, page)
                 logger.debug(f'chooser: Wrote {current_file}.')
             except Exception as e:
                 logger.exception(e)
@@ -298,6 +302,23 @@ def thread_change_current(command_queue: Queue) -> None:
         time.sleep(0.5)
 
 
+def emit(f,
+         filename: str,
+         *,
+         override_refresh_sec: int = None,
+         command: str = None):
+    if 'unwrapped' not in filename:
+        logger.debug(f'Emitting {filename} wrapped.')
+        emit_wrapped(f, filename, override_refresh_sec=override_refresh_sec, command=command)
+    else:
+        logger.debug(f'Emitting {filename} raw.')
+        emit_raw(f, filename)
+
+
+def emit_raw(f, filename: str):
+    f.write(f'<!--#include virtual="{filename}"-->')
+
+
 def emit_wrapped(f,
                  filename: str,
                  *,
@@ -583,9 +604,7 @@ def renderer_update_internal_stats_page(
                 p75 = np.percentile(latency, 75)
                 p90 = np.percentile(latency, 90)
                 p99 = np.percentile(latency, 99)
-            except IndexError:
-                pass
-            f.write(
+                f.write(
 f'''
     <TR>
     <TD {style}>{name}&nbsp;</TD>
@@ -594,8 +613,10 @@ f'''
     <TD {style}>&nbsp;p25={p25:5.2f}, p50={p50:5.2f}, p75={p75:5.2f}, p90={p90:5.2f}, p99={p99:5.2f}</TD>
     </TR>
 '''
-            )
-            f.write('</TABLE>')
+                )
+            except IndexError:
+                pass
+        f.write('</TABLE>')
 
 
 def thread_invoke_renderers() -> None:
@@ -603,6 +624,9 @@ def thread_invoke_renderers() -> None:
     render_counts: collections.Counter = collections.Counter()
     last_render: Dict[str, datetime] = {}
 
+    # Touch the internal render page now to signal that we're alive.
+    renderer_update_internal_stats_page(last_render, render_counts, render_times)
+
     # Main renderer loop
     while True:
         logger.info(
index 4c27000d508bed92ba31794c070930af6f6bd17f..00a583445d931e5ff91577ee4d28377a71af7827 100644 (file)
@@ -36,9 +36,9 @@ oauth = gdata_oauth.OAuth(secrets.google_client_secret)
 # frequency in the renderer thread of ~once a minute.  It just means that
 # everytime it check these will be stale and happen.
 __registry = [
-    stranger_renderer.stranger_events_renderer(
-        {"Fetch Events": (hours * 12), "Shuffle Events": (always)}
-    ),
+#    stranger_renderer.stranger_events_renderer(
+#        {"Fetch Events": (hours * 12), "Shuffle Events": (always)}
+#    ),
     myq_renderer.garage_door_renderer(
         {"Poll MyQ": (minutes * 5), "Update Page": (always)}
     ),
@@ -48,7 +48,7 @@ __registry = [
             "Shuffle News": (always),
         },
         'bellevuewa.gov',
-        [ '/calendar/events.xml' ],
+        ['/calendar/events.xml'],
         'Bellevue City Calendar'
     ),
     bellevue_reporter_rss_renderer.bellevue_reporter_rss_renderer(
@@ -61,7 +61,7 @@ __registry = [
         {'Fetch News': (hours * 2), 'Shuffle News': (always)},
         'www.theurbanist.org',
         ['/feed/'],
-        'TheUrbanist',
+        'The Urbanist',
     ),
     mynorthwest_rss_renderer.mynorthwest_rss_renderer(
         {"Fetch News": (hours * 1), "Shuffle News": (always)},
index fb34d2f45c8f8d50e5ef71fbb7b5de8d12d23782..0c566d93e3123a5e18f52a5960475140b84ac66c 100644 (file)
@@ -23,6 +23,7 @@ class stranger_events_renderer(renderer.abstaining_renderer):
         super().__init__(name_to_timeout_dict)
         self.feed_site = "everout.com"
         self.events = grab_bag.grab_bag()
+        self.pfilter = profanity_filter.ProfanityFilter()
 
     def debug_prefix(self) -> str:
         return "stranger"
@@ -92,11 +93,7 @@ class stranger_events_renderer(renderer.abstaining_renderer):
 
     def fetch_events(self) -> bool:
         self.events.clear()
-        feed_uris = [
-            "/seattle/events/?page=1",
-            "/seattle/events/?page=2",
-            "/seattle/events/?page=3",
-        ]
+        feed_uris = []
         now = datetime.datetime.now()
         ts = now + datetime.timedelta(1)
         tomorrow = datetime.datetime.strftime(ts, "%Y-%m-%d")
@@ -116,7 +113,6 @@ class stranger_events_renderer(renderer.abstaining_renderer):
             feed_uris.append(f"/seattle/events/?start-date={next_sun}&page=1")
             feed_uris.append(f"/seattle/events/?start-date={next_sun}&page=2")
 
-        filter = profanity_filter.ProfanityFilter()
         for uri in feed_uris:
             try:
                 logger.debug("fetching 'https://%s%s'" % (self.feed_site, uri))
@@ -135,7 +131,7 @@ class stranger_events_renderer(renderer.abstaining_renderer):
             soup = BeautifulSoup(raw, "html.parser")
             for x in soup.find_all("div", class_="row event list-item mb-3 py-3"):
                 text = x.get_text()
-                if filter.contains_bad_word(text):
+                if self.pfilter.contains_bad_word(text):
                     continue
                 raw_str = str(x)
                 raw_str = raw_str.replace(
index 437e3e6076d3054d4709d4d5b6a25f37c9305838..15d0c06ac4c16bb66f29c115dc2ad68dd837a02a 100644 (file)
@@ -1,7 +1,6 @@
 #!/usr/bin/env python3
 
 import datetime
-import re
 from typing import Dict, List, Optional
 import xml
 
@@ -12,7 +11,7 @@ import generic_news_rss_renderer as gnrss
 
 # https://www.theurbanist.org/feed/
 class urbanist_renderer(gnrss.generic_news_rss_renderer):
-    """Read the TheUrbanist feed."""
+    """Read the The Urbanist feed."""
 
     def __init__(
         self,
@@ -44,7 +43,7 @@ class urbanist_renderer(gnrss.generic_news_rss_renderer):
     def item_is_interesting_for_headlines(
         self, title: str, description: str, item: xml.etree.ElementTree.Element
     ) -> bool:
-        return 'the urbanist' not in description.lower()
+        return self.find_pubdate(item) is not None and 'urbanist' not in title.lower()
 
     def do_details(self) -> bool:
         return True
@@ -52,7 +51,7 @@ class urbanist_renderer(gnrss.generic_news_rss_renderer):
     def item_is_interesting_for_article(
         self, title: str, description: str, item: xml.etree.ElementTree.Element
     ) -> bool:
-        return len(description) > 20
+        return len(description) > 30
 
 
 # Test
index f10f1ceb7d132f0e92965e8368b60d404f63409b..4a5663cfb612469b3890a2cb0a5be68b6a639a1d 100644 (file)
@@ -10,7 +10,6 @@ import urllib.parse
 import file_writer
 import renderer
 import kiosk_secrets as secrets
-import random
 
 
 class weather_renderer(renderer.abstaining_renderer):
@@ -20,63 +19,9 @@ class weather_renderer(renderer.abstaining_renderer):
         super().__init__(name_to_timeout_dict)
         self.file_prefix = file_prefix
 
-    def debug_prefix(self) -> str:
-        return f"weather({self.file_prefix})"
-
     def periodic_render(self, key: str) -> bool:
         return self.fetch_weather()
 
-    def describe_time(self, index: int) -> str:
-        if index <= 1:
-            return "overnight"
-        elif index <= 3:
-            return "morning"
-        elif index <= 5:
-            return "afternoon"
-        else:
-            return "evening"
-
-    def describe_wind(self, mph: float) -> str:
-        if mph <= 0.3:
-            return "calm"
-        elif mph <= 5.0:
-            return "light"
-        elif mph < 15.0:
-            return "breezy"
-        elif mph <= 25.0:
-            return "gusty"
-        else:
-            return "heavy"
-
-    def describe_magnitude(self, mm: float) -> str:
-        if mm < 2.0:
-            return "light"
-        elif mm < 10.0:
-            return "moderate"
-        else:
-            return "heavy"
-
-    def describe_precip(self, rain: float, snow: float) -> str:
-        if rain == 0.0 and snow == 0.0:
-            return "no precipitation"
-        magnitude = rain + snow
-        if rain > 0 and snow > 0:
-            return f"a {self.describe_magnitude(magnitude)} mix of rain and snow"
-        elif rain > 0:
-            return f"{self.describe_magnitude(magnitude)} rain"
-        elif snow > 0:
-            return f"{self.describe_magnitude(magnitude)} snow"
-        return "rain"
-
-    def fix_caps(self, s: str) -> str:
-        r = ""
-        s = s.lower()
-        for x in s.split("."):
-            x = x.strip()
-            r += x.capitalize() + ". "
-        r = r.replace(". .", ".")
-        return r
-
     def pick_icon(
         self, conditions: List[str], rain: List[float], snow: List[float]
     ) -> str:
@@ -135,89 +80,6 @@ class weather_renderer(renderer.abstaining_renderer):
             return "partlysunny.gif"
         return "clear.gif"
 
-    def describe_weather(
-        self,
-        high: float,
-        low: float,
-        wind: List[float],
-        conditions: List[str],
-        rain: List[float],
-        snow: List[float],
-    ) -> str:
-        # High temp: 65
-        # Low temp: 44
-        #             -onight------  -morning----- -afternoon--  -evening----
-        #             12a-3a  3a-6a  6a-9a  9a-12p 12p-3p 3p-6p  6p-9p 9p-12p
-        # Wind:       [12.1   3.06   3.47   4.12   3.69   3.31   2.73  2.1]
-        # Conditions: [Clouds Clouds Clouds Clouds Clouds Clouds Clear Clear]
-        # Rain:       [0.4    0.2    0      0      0      0      0     0]
-        # Snow:       [0      0      0      0      0      0      0     0]
-        high = int(high)
-        low = int(low)
-        count = min(len(wind), len(conditions), len(rain), len(snow))
-        descr = ""
-
-        lcondition = ""
-        lwind = ""
-        lprecip = ""
-        ltime = ""
-        for x in range(0, count):
-            time = self.describe_time(x)
-            current = ""
-            chunks = 0
-
-            txt = conditions[x]
-            if txt == "Clouds":
-                txt = "cloudy"
-            elif txt == "Rain":
-                txt = "rainy"
-
-            if txt != lcondition:
-                if txt != "Snow" and txt != "Rain":
-                    current += txt
-                    chunks += 1
-                lcondition = txt
-
-            txt = self.describe_wind(wind[x])
-            if txt != lwind:
-                if len(current) > 0:
-                    current += " with "
-                current += txt + " winds"
-                lwind = txt
-                chunks += 1
-
-            txt = self.describe_precip(rain[x], snow[x])
-            if txt != lprecip:
-                if len(current) > 0:
-                    if chunks > 1:
-                        current += " and "
-                    else:
-                        current += " with "
-                chunks += 1
-                current += txt
-                lprecip = txt
-
-            if len(current):
-                if ltime != time:
-                    if random.randint(0, 3) == 0:
-                        if time != "overnight":
-                            descr += current + " in the " + time + ". "
-                        descr += current + " overnight. "
-                    else:
-                        if time != "overnight":
-                            descr += "In the "
-                        descr += time + ", " + current + ". "
-                else:
-                    current = current.replace("cloudy", "clouds")
-                    descr += current + " developing. "
-                ltime = time
-        if ltime == "overnight" or ltime == "morning":
-            descr += "Conditions continuing the rest of the day. "
-        descr = descr.replace("with breezy winds", "and breezy")
-        descr = descr.replace("Clear developing", "Skies clearing")
-        descr = self.fix_caps(descr)
-        return descr
-
     def fetch_weather(self) -> bool:
         if self.file_prefix == "stevens":
             text_location = "Stevens Pass, WA"
@@ -254,15 +116,58 @@ class weather_renderer(renderer.abstaining_renderer):
         #     "dt_txt":"2017-01-30 18:00:00"
         #     },
         #     {"dt":1485810000,....
-        with file_writer.file_writer("weather-%s_3_10800.html" % self.file_prefix) as f:
-            f.write(
-                f"""
-<h1>Weather at {text_location}:</h1>
+
+        with file_writer.file_writer(f"weather-{self.file_prefix}_3_10800.html") as f:
+            f.write(f"""
+<h1>Upcoming weather at {text_location}:</h1>
 <hr>
+<script src="/kiosk/Chart.js"></script>""")
+            f.write("""
+<script>
+function makePrecipChart(name, xValues, yValues) {
+  const config = {
+    type: 'line',
+    data: {
+      labels: xValues,
+      datasets: [
+        {
+          fill: true,
+          lineTension: 0,
+          backgroundColor: "rgba(0,0,255,0.1)",
+          borderColor: "rgba(0,0,255,0.9)",
+          data: yValues
+        }
+      ]
+    },
+    options: {
+      plugins: {
+        legend: {
+          display: false,
+        },
+      },
+      scales: {
+        x: {
+          grid: {
+            display: false,
+          }
+        },
+        y: {
+          display: false,
+          beginAtZero: true,
+          min: 0.0,
+          max: 10.0,
+          grid: {
+            display: false,
+          },
+        }
+      }
+    },
+  };
+  return new Chart(name, config);
+}
+</script>
 <center>
-<table width=99% cellspacing=10 border=0>
-    <tr>"""
-            )
+""")
             count = parsed_json["cnt"]
 
             ts = {}
@@ -272,31 +177,32 @@ class weather_renderer(renderer.abstaining_renderer):
             conditions: Dict[str, List[str]] = {}
             rain: Dict[str, List[float]] = {}
             snow: Dict[str, List[float]] = {}
+            precip: Dict[str, List[float]] = {}
+
             for x in range(0, count):
                 data = parsed_json["list"][x]
                 dt = data["dt_txt"]  # 2019-10-07 18:00:00
-                date = dt.split(" ")[0]
-                time = dt.split(" ")[1]
+                (date, time) = dt.split(' ')
                 wind[date] = []
                 conditions[date] = []
-                highs[date] = -99999
-                lows[date] = +99999
+                highs[date] = None
+                lows[date] = None
                 rain[date] = []
                 snow[date] = []
+                precip[date] = []
                 ts[date] = 0
 
             for x in range(0, count):
                 data = parsed_json["list"][x]
                 dt = data["dt_txt"]  # 2019-10-07 18:00:00
-                date = dt.split(" ")[0]
-                time = dt.split(" ")[1]
+                (date, time) = dt.split(' ')
                 _ = data["dt"]
                 if _ > ts[date]:
                     ts[date] = _
                 temp = data["main"]["temp"]
-                if highs[date] < temp:
+                if highs[date] is None or highs[date] < temp:
                     highs[date] = temp
-                if temp < lows[date]:
+                if lows[date] is None or temp < lows[date]:
                     lows[date] = temp
                 wind[date].append(data["wind"]["speed"])
                 conditions[date].append(data["weather"][0]["main"])
@@ -331,43 +237,42 @@ class weather_renderer(renderer.abstaining_renderer):
                 #  },
                 #  u'wind': {u'speed': 6.31, u'deg': 10.09}}
 
-            # Next 5 half-days
-            # for x in xrange(0, 5):
-            #    fcast = parsed_json['forecast']['txt_forecast']['forecastday'][x]
-            #    text = fcast['fcttext']
-            #    text = re.subn(r' ([0-9]+)F', r' \1&deg;F', text)[0]
-            #    f.write('<td style="vertical-align:top;font-size:75%%"><P STYLE="padding:8px;">%s</P></td>' % text)
-            # f.write('</tr></table>')
-            # f.close()
-            # return True
-
-            # f.write("<table border=0 cellspacing=10>\n")
-            days_seen = {}
+            days_seen = set()
             for date in sorted(highs.keys()):
-                today = datetime.fromtimestamp(ts[date])
-                formatted_date = today.strftime("%a %e %b")
+                day = datetime.fromtimestamp(ts[date])
+                formatted_date = day.strftime("%a %e %b")
                 if formatted_date in days_seen:
                     continue
-                days_seen[formatted_date] = True
-            num_days = len(list(days_seen.keys()))
+                days_seen.add(formatted_date)
+            total = len(days_seen)
+
+            days_seen = set()
+            for n, date in enumerate(sorted(highs.keys())):
+                if n % 3 == 0:
+                    if n > 0:
+                        f.write("</div>")
+                    f.write('<div STYLE="overflow:hidden; width:100%; height:500px">')
+                    remaining = total - n
+                    if remaining >= 3:
+                        width = "33%"
+                    else:
+                        width = f'{100/remaining}%'
 
-            days_seen = {}
-            for date in sorted(highs.keys()):
-                precip = 0.0
-                for _ in rain[date]:
-                    precip += _
-                for _ in snow[date]:
-                    precip += _
+                precip[date] = []
+                aggregate_precip = 0.0
+                for r, s in zip(rain[date], snow[date]):
+                    aggregate = r + s
+                    aggregate_precip += aggregate
+                    precip[date].append(aggregate)
 
-                today = datetime.fromtimestamp(ts[date])
-                formatted_date = today.strftime("%a %e %b")
+                day = datetime.fromtimestamp(ts[date])
+                formatted_date = day.strftime("%a %e %b")
                 if formatted_date in days_seen:
                     continue
-                days_seen[formatted_date] = True
+                days_seen.add(formatted_date)
                 f.write(
-                    '<td width=%d%% style="vertical-align:top;">\n' % (100 / num_days)
+                    f'<div style="width:{width}; height:500px; float:left"><table>\n'
                 )
-                f.write("<table border=0>\n")
 
                 # Date
                 f.write(
@@ -376,7 +281,7 @@ class weather_renderer(renderer.abstaining_renderer):
                     + "</font></center></b></td></tr>\n"
                 )
 
-                # Icon
+                # Conditions icon
                 f.write(
                     '  <tr><td colspan=3 height=100><center><img src="/kiosk/images/weather/%s" height=125></center></td></tr>\n'
                     % self.pick_icon(conditions[date], rain[date], snow[date])
@@ -386,46 +291,59 @@ class weather_renderer(renderer.abstaining_renderer):
                 color = "#000099"
                 if lows[date] <= 32.5:
                     color = "#009999"
-                f.write(
-                    '  <tr><td width=33%% align=left><font color="%s"><b>%d&deg;F&nbsp;&nbsp;</b></font></td>\n'
-                    % (color, int(lows[date]))
-                )
-
-                # Total precip
-                precip *= 0.0393701
-                if precip > 0.025:
-                    f.write(
-                        '      <td width=33%%><center><b><font style="background-color:#dfdfff; color:#003355">%3.1f"</font></b></center></td>\n'
-                        % precip
-                    )
+                f.write(f'''
+<tr>
+  <td width=33% align=left>
+    <font color="{color}">
+      <b>{int(lows[date])}&deg;F&nbsp;&nbsp;</b>
+    </font>
+  </td>
+''')
+
+                # Total aggregate_precip in inches
+                aggregate_precip *= 0.0393701
+                if aggregate_precip > 0.025:
+                    f.write(f'''
+  <td width=33%>
+    <center>
+      <font style="background-color:#dfdfff; color:#003355">
+        <b>{aggregate_precip:3.1f}"</b>
+      </font>
+    </center>
+  </td>
+''')
                 else:
                     f.write("      <td width=33%>&nbsp;</td>\n")
 
-                # High temp
+                # High temp + precip chart
                 color = "#800000"
                 if highs[date] >= 80:
                     color = "#AA0000"
-                f.write(
-                    '      <td align=right><font color="%s"><b>&nbsp;&nbsp;%d&deg;F</b></font></td></tr>\n'
-                    % (color, int(highs[date]))
-                )
-
-                # Text "description"
-                f.write(
-                    '<tr><td colspan=3 style="vertical-align:top;font-size:75%%">%s</td></tr>\n'
-                    % self.describe_weather(
-                        highs[date],
-                        lows[date],
-                        wind[date],
-                        conditions[date],
-                        rain[date],
-                        snow[date],
-                    )
-                )
-                f.write("</table>\n</td>\n")
-            f.write("</tr></table></center>")
+                f.write(f'''
+  <td align=right>
+    <font color="{color}">
+      <b>&nbsp;&nbsp;{int(highs[date])}&deg;F</b>
+    </font>
+  </td>
+</tr>
+<tr>
+  <td colspan=3 style="vertical-align:top;">
+    <canvas id="myChart{n}" style="width:100%;max-width:300px"></canvas>
+  </td>
+</tr>
+<script>
+var yValues{n} = ''')
+                f.write(precip[date].__repr__())
+                f.write(f''';
+var xValues{n} = [ 3, 6, 9, 12, 15, 18, 21, 24 ];
+makePrecipChart("myChart{n}", xValues{n}, yValues{n});
+</script>
+</table>
+</div>
+''')
+            f.write("</div></center>")
         return True
 
 
-# x = weather_renderer({"Stevens": 1000}, "stevens")
-# x.periodic_render("Stevens")
+#x = weather_renderer({"Stevens": 1000}, "stevens")
+#x.periodic_render("Stevens")