More changes related to running on new kiosk.house.
[kiosk.git] / kiosk.py
1 #!/usr/bin/env python3
2
3 import collections
4 from datetime import datetime
5 import difflib
6 import gc
7 import linecache
8 import logging
9 import os
10 import re
11 import sys
12 from threading import Thread
13 import time
14 import traceback
15 import tracemalloc
16 from typing import Optional, List
17 from queue import Queue, Empty
18
19 import astral  # type: ignore
20 from astral.sun import sun  # type: ignore
21 import numpy as np
22 import pytz
23
24 import constants
25 import file_writer
26 import renderer
27 import renderer
28 import renderer_catalog
29 import chooser
30 import listen
31 import logging
32 import pvporcupine
33 import trigger_catalog
34 import utils
35
36 logger = logging.getLogger(__file__)
37
38
39 def thread_janitor() -> None:
40     tracemalloc.start()
41     tracemalloc_target = 0.0
42     gc_target = 0.0
43     gc.enable()
44
45     while True:
46         now = time.time()
47         if now > tracemalloc_target:
48             tracemalloc_target = now + 30.0
49             snapshot = tracemalloc.take_snapshot()
50             snapshot = snapshot.filter_traces((
51                 tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
52                 tracemalloc.Filter(False, "<unknown>"),
53             ))
54             key_type = 'lineno'
55             limit = 10
56             top_stats = snapshot.statistics(key_type)
57             print("janitor: Top %s lines" % limit)
58             for index, stat in enumerate(top_stats[:limit], 1):
59                 frame = stat.traceback[0]
60                 # replace "/path/to/module/file.py" with "module/file.py"
61                 filename = os.sep.join(frame.filename.split(os.sep)[-2:])
62                 print("janitor: #%s: %s:%s: %.1f KiB"
63                       % (index, filename, frame.lineno, stat.size / 1024))
64                 line = linecache.getline(frame.filename, frame.lineno).strip()
65                 if line:
66                     print('janitor:    %s' % line)
67
68             other = top_stats[limit:]
69             if other:
70                 size = sum(stat.size for stat in other)
71                 print("janitor: %s other: %.1f KiB" % (len(other), size / 1024))
72             total = sum(stat.size for stat in top_stats)
73             print("janitor: Total allocated size: %.1f KiB" % (total / 1024))
74         if now > gc_target:
75             print("janitor: Running gc operation")
76             gc_target = now + 60.0
77             gc.collect()
78         time.sleep(10.0)
79
80
81 def guess_page(command: str, page_chooser: chooser.chooser) -> str:
82     best_page = None
83     best_score = None
84     for page in page_chooser.get_page_list():
85         page = page.replace('(', ' ')
86         page = page.replace('_', ' ')
87         page = page.replace(')', ' ')
88         page = page.replace('.html', '')
89         page = page.replace('CNNNews', 'news')
90         page = page.replace('CNNTechnology', 'technology')
91         page = page.replace('gocostco', 'costco list')
92         page = page.replace('gohardware', 'hardware list')
93         page = page.replace('gohouse', 'house list honey do')
94         page = page.replace('gcal', 'google calendar events')
95         page = page.replace('mynorthwest', 'northwest news')
96         page = page.replace('myq', 'myq garage door status')
97         page = page.replace('gomenu', 'dinner menu')
98         page = page.replace('wsdot', 'traffic')
99         page = page.replace('gomenu', 'dinner menu')
100         page = page.replace('WSJNews', 'news')
101         page = page.replace('telma', 'telma cabin')
102         page = page.replace('WSJBusiness', 'business news')
103         page = re.sub(r'[0-9]+', '', page)
104         score = SequenceMatcher(None, command, page).ratio()
105         if best_score is None or score > best_score:
106             best_page = page
107     assert best_page is not None
108     return best_page
109
110
111 def process_command(command: str, page_history: List[str], page_chooser) -> str:
112     page = None
113     if 'hold' in command:
114         page = page_history[0]
115     elif 'back' in command:
116         page = page_history[1]
117     elif 'skip' in command:
118         while True:
119             (page, _) = page_chooser.choose_next_page()
120             if page != page_history[0]:
121                 break
122     elif 'weather' in command:
123         if 'telma' in command or 'cabin' in command:
124             page = 'weather-telma_3_10800.html'
125         elif 'stevens' in command:
126             page = 'weather-stevens_3_10800.html'
127         else:
128             page = 'weather-home_3_10800.html'
129     elif 'cabin' in command:
130         if 'list' in command:
131             page = 'Cabin-(gocabin)_2_3600.html'
132         else:
133             page = 'hidden/cabin_driveway.html'
134     elif 'news' in command or 'headlines' in command:
135         page = 'cnn-CNNNews_4_25900.html'
136     elif 'clock' in command or 'time' in command:
137         page = 'clock_10_none.html'
138     elif 'countdown' in command or 'countdowns' in command:
139         page = 'countdown_3_7200.html'
140     elif 'costco' in command:
141         page = 'Costco-(gocostco)_2_3600.html'
142     elif 'calendar' in command or 'events' in command:
143         page = 'gcal_3_86400.html'
144     elif 'countdown' in command or 'countdowns' in command:
145         page = 'countdown_3_7200.html'
146     elif 'grocery' in command or 'groceries' in command:
147         page = 'Grocery-(gogrocery)_2_3600.html'
148     elif 'hardware' in command:
149         page = 'Hardware-(gohardware)_2_3600.html'
150     elif 'garage' in command:
151         page = 'myq_4_300.html'
152     elif 'menu' in command:
153         page = 'Menu-(gomenu)_2_3600.html'
154     elif 'cron' in command or 'health' in command:
155         page = 'periodic-health_6_300.html'
156     elif 'photo' in command or 'picture' in command:
157         page = 'photo_23_3600.html'
158     elif 'quote' in command or 'quotation' in command or 'quotes' in command:
159         page = 'quotes_4_10800.html'
160     elif 'stevens' in command:
161         page = 'stevens-conditions_1_86400.html'
162     elif 'stock' in command or 'stocks' in command:
163         page = 'stock_3_86400.html'
164     elif 'twitter' in command:
165         page = 'twitter_10_3600.html'
166     elif 'traffic' in command:
167         page = 'wsdot-bridges_3_none.html'
168     elif 'front' in command and 'door' in command:
169         page = 'hidden/frontdoor.html'
170     elif 'driveway' in command:
171         page = 'hidden/driveway.html'
172     elif 'backyard' in command:
173         page = 'hidden/backyard.html'
174     else:
175         page = guess_page(command, page_chooser)
176     assert page is not None
177     return page
178
179
180 def thread_change_current(command_queue: Queue) -> None:
181     page_history = [ "", "" ]
182     swap_page_target = 0.0
183
184     def filter_news_during_dinnertime(page: str) -> bool:
185         now = datetime.now(tz=pytz.timezone("US/Pacific"))
186         is_dinnertime = now.hour >= 17 and now.hour <= 20
187         return not is_dinnertime or not (
188             "cnn" in page
189             or "news" in page
190             or "mynorthwest" in page
191             or "seattle" in page
192             or "stranger" in page
193             or "twitter" in page
194             or "wsj" in page
195         )
196     page_chooser = chooser.weighted_random_chooser_with_triggers(
197         trigger_catalog.get_triggers(), [filter_news_during_dinnertime]
198     )
199
200     while True:
201         now = time.time()
202
203         # Check for a verbal command.
204         command = None
205         try:
206             command = command_queue.get(block=False)
207         except Exception:
208             command = None
209             pass
210         if command is not None:
211             triggered = True
212             page = process_command(command, page_history, page_chooser)
213
214         # Else pick a page randomly.
215         else:
216             while True:
217                 (page, triggered) = page_chooser.choose_next_page()
218                 if triggered or page != page_history[0]:
219                     break
220
221         if triggered:
222             print("chooser[%s] - WE ARE TRIGGERED." % utils.timestamp())
223             if page != page_history[0] or (swap_page_target - now) < 10.0:
224                 print(
225                     "chooser[%s] - EMERGENCY PAGE %s LOAD NEEDED"
226                     % (utils.timestamp(), page)
227                 )
228 #                try:
229                 current = os.path.join(constants.pages_dir, "current.shtml")
230                 with open(current, "w") as f:
231                     emit_wrapped(f, page, override_refresh_sec = 40, command = command)
232                     print(f'Wrote {current}')
233
234                 page_history.insert(0, page)
235                 page_history = page_history[0:10]
236                 swap_page_target = now + 40
237 #                except:
238 #                    print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
239 #                    continue
240
241                 # Also notify XMLHTTP clients that they need to refresh now.
242                 emergency_file = os.path.join(constants.pages_dir, "reload_immediately.html")
243                 with open(emergency_file, "w") as f:
244                     f.write(f'Reload, suckers... you HAVE to see {page}!')
245                     print(f'Causing immediate page reload with {emergency_file}...')
246
247                 # Fix this hack... maybe read the webserver logs and see if it
248                 # actually was picked up?
249                 time.sleep(3.0)
250                 os.remove(emergency_file)
251                 print(f'...and removed {emergency_file}')
252
253         elif now >= swap_page_target:
254             assert page != page_history[0]
255             print("chooser[%s] - nominal choice of %s" % (utils.timestamp(), page))
256 #            try:
257             with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
258                 emit_wrapped(f, page)
259             page_history.insert(0, page)
260             page_history = page_history[0:10]
261             swap_page_target = now + constants.refresh_period_sec
262 #            except:
263 #                print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
264 #                continue
265         time.sleep(1)
266
267
268 def emit_wrapped(f,
269                  filename: str,
270                  *,
271                  override_refresh_sec: int = None,
272                  command: str = None) -> None:
273     def pick_background_color() -> str:
274         now = datetime.now(tz=pytz.timezone("US/Pacific"))
275         city = astral.LocationInfo(
276             "Bellevue", "USA", "US/Pacific", 47.610, -122.201
277         )
278         s = sun(city.observer, date=now, tzinfo=pytz.timezone("US/Pacific"))
279         sunrise_mod = utils.minute_number(s["sunrise"].hour, s["sunrise"].minute)
280         sunset_mod = utils.minute_number(s["sunset"].hour, s["sunset"].minute)
281         now_mod = utils.minute_number(now.hour, now.minute)
282         if now_mod < sunrise_mod or now_mod > (sunset_mod + 45):
283             return "E6B8B8"
284         elif now_mod < (sunrise_mod + 45) or now_mod > (sunset_mod + 120):
285             return "EECDCD"
286         else:
287             return "FFFFFF"
288
289     def get_refresh_period() -> float:
290         if override_refresh_sec is not None:
291             return float(override_refresh_sec * 1000.0)
292         now = datetime.now(tz=pytz.timezone("US/Pacific"))
293         if now.hour < 7:
294             return float(constants.refresh_period_night_sec * 1000.0)
295         else:
296             return float(constants.refresh_period_sec * 1000.0)
297
298     age = utils.describe_age_of_file_briefly(f"pages/{filename}")
299     bgcolor = pick_background_color()
300     if command is None:
301         pageid = filename
302     else:
303         pageid = f'"{command}" -> {filename}'
304
305     f.write(
306 """
307 <HEAD>
308   <TITLE>Kitchen Kiosk</TITLE>
309   <LINK rel="stylesheet" type="text/css" href="style.css">
310   <SCRIPT TYPE="text/javascript">
311
312   // Zoom the 'contents' div to fit without scrollbars and then make
313   // it visible.
314   function zoomScreen() {
315     z = 285;
316     do {
317       document.getElementById("content").style.zoom = z+"%";
318       var body = document.body;
319       var html = document.documentElement;
320       var height = Math.max(body.scrollHeight,
321                             body.offsetHeight,
322                             html.clientHeight,
323                             html.scrollHeight,
324                             html.offsetHeight);
325       var windowHeight = window.innerHeight;
326       var width = Math.max(body.scrollWidth,
327                            body.offsetWidth,
328                            html.clientWidth,
329                            html.scrollWidth,
330                            html.offsetWidth);
331       var windowWidth = window.innerWidth;
332       var heightRatio = height / windowHeight;
333       var widthRatio = width / windowWidth;
334
335       if (heightRatio <= 1.0 && widthRatio <= 1.0) {
336         break;
337       }
338       z -= 4;
339     } while(z >= 70);
340     document.getElementById("content").style.visibility = "visible";
341   }
342
343   // Load IMG tags with DATA-SRC attributes late.
344   function lateLoadImages() {
345     var image = document.getElementsByTagName('img');
346     for (var i = 0; i < image.length; i++) {
347       if (image[i].getAttribute('DATA-SRC')) {
348         image[i].setAttribute('SRC', image[i].getAttribute('DATA-SRC'));
349       }
350     }
351   }
352 """)
353     f.write(
354 """
355   // Operate the clock at the top of the page.
356   function runClock() {
357     var today = new Date();
358     var h = today.getHours();
359     var ampm = h >= 12 ? 'pm' : 'am';
360     h = h %% 12;
361     h = h ? h : 12; // the hour '0' should be '12'
362     var m = maybeAddZero(today.getMinutes());
363     var colon = ":";
364     if (today.getSeconds() %% 2 == 0) {
365       colon = "<FONT STYLE='color: #%s; font-size: 4vmin; font-weight: bold'>:</FONT>";
366     }
367     document.getElementById("time").innerHTML = h + colon + m + ampm;
368     document.getElementById("date").innerHTML = today.toDateString();
369     var t = setTimeout(function(){runClock()}, 1000);
370   }
371 """ % bgcolor)
372     f.write(
373 """
374   // Helper method for running the clock.
375   function maybeAddZero(x) {
376     return (x < 10) ? "0" + x : x;
377   }
378
379   // Do something on page load.
380   function addLoadEvent(func) {
381     var oldonload = window.onload;
382     if (typeof window.onload != 'function') {
383       window.onload = func;
384     } else {
385       window.onload = function() {
386         if (oldonload) {
387           oldonload();
388         }
389         func();
390       }
391     }
392   }
393
394   // Sleep thread helper.
395   const sleep = (milliseconds) => {
396     return new Promise(resolve => setTimeout(resolve, milliseconds))
397   }
398
399   var loadedDate = new Date();
400
401   addLoadEvent(zoomScreen);
402   addLoadEvent(runClock);
403   addLoadEvent(lateLoadImages);
404 """)
405
406     f.write(
407 """
408   // Runs the countdown line at the bottom and is responsible for
409   // normal page reloads caused by the expiration of a timer.
410   (function countdown() {
411     setTimeout(
412       function() {
413         var now = new Date();
414         var deltaMs = now.getTime() - loadedDate.getTime();
415         var totalMs = %d;
416         var remainingMs = (totalMs - deltaMs);
417
418         if (remainingMs > 0) {
419           var hr = document.getElementById("countdown");
420           var width = (remainingMs / (totalMs - 5000)) * 100.0;
421           if (width <= 100) {
422             hr.style.visibility = "visible";
423             hr.style.width = " ".concat(width, "%%");
424             hr.style.backgroundColor = "maroon";
425           }
426         } else {
427           // Reload unconditionally after 22 sec.
428           window.location.reload();
429         }
430
431         // Brief sleep before doing it all over again.
432         sleep(50).then(() => {
433           countdown();
434         });
435       }, 50)
436   })();
437 """ % get_refresh_period())
438     f.write(
439 """
440   // Periodically checks for emergency reload events.
441   (function poll() {
442     setTimeout(
443       function() {
444         var xhr = new XMLHttpRequest();
445         xhr.open('GET',
446                  '%s/reload_immediately.html');
447         xhr.onload =
448           function() {
449             if (xhr.status === 200) {
450               window.location.reload();
451             } else {
452               sleep(500).then(() => {
453                 poll();
454               });
455             }
456           };
457         xhr.send();
458       }, 500);
459   })();
460   </SCRIPT>
461 </HEAD>
462 """ % constants.root_url)
463     f.write(f'<BODY BGCOLOR="#{bgcolor}">')
464     f.write(
465 """
466     <TABLE style="height:100%; width:100%" BORDER=0>
467     <TR HEIGHT=30>
468         <TD ALIGN="left">
469             <DIV id="date">&nbsp;</DIV>
470         </TD>
471         <TD ALIGN="center"><FONT COLOR=#bbbbbb>
472             <DIV id="info"></DIV></FONT>
473         </TD>
474         <TD ALIGN="right">
475             <DIV id="time">&nbsp;</DIV>
476         </TD>
477     </TR>
478     <TR STYLE="vertical-align:top">
479         <TD COLSPAN=3>
480             <DIV ID="content" STYLE="zoom: 1; visibility: hidden;">
481               <!-- BEGIN main page contents. -->
482 """)
483     f.write(f'<!--#include virtual="{filename}"-->')
484     f.write(
485 """
486             <!-- END main page contents. -->
487             </DIV>
488             <BR>
489             <DIV STYLE="position: absolute; top:1030px; width:99%">
490             <P ALIGN="right">
491 """)
492     f.write(f'<FONT SIZE=2 COLOR=#bbbbbb>{pageid} @ {age} ago.</FONT>')
493     f.write(
494 """
495             </P>
496             <HR id="countdown" STYLE="width:0px;
497                                       text-align:left;
498                                       margin:0;
499                                       border:none;
500                                       border-width:0;
501                                       height:5px;
502                                       visibility:hidden;
503                                       background-color:#ffffff;">
504             </DIV>
505         </TD>
506     </TR>
507     </TABLE>
508 </BODY>""")
509
510
511 def thread_invoke_renderers() -> None:
512     render_times: Dict[str, np.array] = {}
513     render_counts: collections.Counter = collections.Counter()
514     last_render: Dict[str, datetime] = {}
515
516     while True:
517         print(f'renderer[{utils.timestamp()}]: invoking all overdue renderers in catalog...')
518         for r in renderer_catalog.get_renderers():
519             name = r.get_name()
520             now = time.time()
521             try:
522                 r.render()
523             except Exception as e:
524                 traceback.print_exc(file=sys.stdout)
525                 logger.exception(e)
526                 print(
527                     f"renderer[{utils.timestamp()}] Unknown exception ({e}) in {name}, swallowing it."
528                 )
529
530             # Increment the count of render operations per renderer.
531             render_counts[name] += 1
532
533             # Keep track of the last time we invoked each renderer.
534             last_render[name] = datetime.now(tz=pytz.timezone("US/Pacific"))
535
536             # Record how long each render operation takes and warn if very long.
537             delta = time.time() - now
538             times = render_times.get(name, np.array([]))
539             times = np.insert(times, 0, delta)
540             render_times[name] = times
541             if delta > 1.0:
542                 hdr = f'renderer[{utils.timestamp()}]:'
543                 print(
544 f'''
545 {hdr} Warning: {name}'s rendering took {delta:5.2f}s.
546 {hdr} FYI: {name}'s render times: p25={np.percentile(times, 25):5.2f}, p50={np.percentile(times, 50):5.2f}, p75={np.percentile(times, 75):5.2f}, p90={np.percentile(times, 90):5.2f}, p99={np.percentile(times, 99):5.2f}
547 ''')
548
549         # Render a page about internal stats of renderers.
550         print(f'renderer[{utils.timestamp()}]: Updating internal statistics page.')
551         with file_writer.file_writer(constants.internal_stats_pagename) as f:
552             f.write(
553 f'''
554 <CENTER>
555 <TABLE BORDER=0 WIDTH=95%>
556     <TR>
557     <TH><B>Renderer Name</B></TH>
558     <TH><B>Last Run</B></TH>
559     <TH><B>Num Invocations</B></TH>
560     <TH><B>Render Latency</B></TH>
561     </TR>
562 ''')
563             for n, r in enumerate(renderer_catalog.get_renderers()):
564                 if n % 2 == 0:
565                     style = 'style="margin: 0; padding: 0; background: #c6b0b0;"'
566                 else:
567                     style = 'style="margin: 0; padding: 0; background: #eeeeee;"'
568                 name = r.get_name()
569                 last = last_render.get(name, None)
570                 if last is None:
571                     last = 'never'
572                 else:
573                     last = last.strftime('%Y/%m/%d %I:%M:%S%P')
574                 count = render_counts.get(name, 0)
575                 latency = render_times.get(name, np.array([]))
576                 p25 = p50 = p75 = p90 = p99 = 'N/A'
577                 try:
578                     p25 = np.percentile(latency, 25)
579                     p50 = np.percentile(latency, 50)
580                     p75 = np.percentile(latency, 75)
581                     p90 = np.percentile(latency, 90)
582                     p99 = np.percentile(latency, 99)
583                 except IndexError:
584                     pass
585                 f.write(
586 f'''
587     <TR>
588     <TD {style}>{name}&nbsp;</TD>
589     <TD {style}>&nbsp;{last}&nbsp;</TD>
590     <TD {style}><CENTER>&nbsp;{count}&nbsp;</CENTER></TD>
591     <TD {style}>&nbsp;p25={p25:5.2f}, p50={p50:5.2f}, p75={p75:5.2f}, p90={p90:5.2f}, p99={p99:5.2f}</TD>
592     </TR>
593 ''')
594             f.write('</TABLE>')
595
596         print(
597             f"renderer[{utils.timestamp()}]: " +
598             f"thread having a little break for {constants.render_period_sec}s..."
599         )
600         time.sleep(constants.render_period_sec)
601
602
603 if __name__ == "__main__":
604     logging.basicConfig()
605     command_queue: Queue = Queue()
606     changer_thread: Optional[Thread] = None
607     renderer_thread: Optional[Thread] = None
608     janitor_thread: Optional[Thread] = None
609     hotword_thread: Optional[Thread] = None
610     while True:
611         if hotword_thread is None or not hotword_thread.is_alive():
612             keyword_paths = [pvporcupine.KEYWORD_PATHS[x] for x in ["bumblebee"]]
613             sensitivities = [0.7] * len(keyword_paths)
614             listener = listen.HotwordListener(
615                 command_queue,
616                 keyword_paths,
617                 sensitivities,
618             )
619             hotword_thread = Thread(target=listener.listen_forever, args=())
620             hotword_thread.start()
621         if changer_thread is None or not changer_thread.is_alive():
622             print(
623                 f"MAIN[{utils.timestamp()}] - (Re?)initializing chooser thread... (wtf?!)"
624             )
625             changer_thread = Thread(target=thread_change_current, args=(command_queue,))
626             changer_thread.start()
627         if renderer_thread is None or not renderer_thread.is_alive():
628             print(
629                 f"MAIN[{utils.timestamp()}] - (Re?)initializing render thread... (wtf?!)"
630             )
631             renderer_thread = Thread(target=thread_invoke_renderers, args=())
632             renderer_thread.start()
633         if janitor_thread is None or not janitor_thread.is_alive():
634             print(
635                 f"MAIN[{utils.timestamp()}] - (Re?)initializing janitor thread... (wtf?!)"
636             )
637             janitor_thread = Thread(target=thread_janitor, args=())
638             janitor_thread.start()
639         time.sleep(60)