Changes ;)
[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                     with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
230                         emit_wrapped(f, page, override_refresh_sec = 40, command = command)
231                     page_history.insert(0, page)
232                     page_history = page_history[0:10]
233                     swap_page_target = now + 40
234                 except:
235                     print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
236                     continue
237
238                 # Also notify XMLHTTP clients that they need to refresh now.
239                 path = os.path.join(constants.pages_dir, "reload_immediately.html")
240                 with open(path, "w") as f:
241                     f.write("Reload, suckers!")
242
243                 # Fix this hack... maybe read the webserver logs and see if it
244                 # actually was picked up?
245                 time.sleep(0.75)
246                 os.remove(path)
247
248         elif now >= swap_page_target:
249             assert page != page_history[0]
250             print("chooser[%s] - nominal choice of %s" % (utils.timestamp(), page))
251             try:
252                 with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
253                     emit_wrapped(f, page)
254                 page_history.insert(0, page)
255                 page_history = page_history[0:10]
256                 swap_page_target = now + constants.refresh_period_sec
257             except:
258                 print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
259                 continue
260         time.sleep(1)
261
262
263 def emit_wrapped(f,
264                  filename: str,
265                  *,
266                  override_refresh_sec: int = None,
267                  command: str = None) -> None:
268     def pick_background_color() -> str:
269         now = datetime.now(tz=pytz.timezone("US/Pacific"))
270         city = astral.LocationInfo(
271             "Bellevue", "USA", "US/Pacific", 47.610, -122.201
272         )
273         s = sun(city.observer, date=now, tzinfo=pytz.timezone("US/Pacific"))
274         sunrise_mod = utils.minute_number(s["sunrise"].hour, s["sunrise"].minute)
275         sunset_mod = utils.minute_number(s["sunset"].hour, s["sunset"].minute)
276         now_mod = utils.minute_number(now.hour, now.minute)
277         if now_mod < sunrise_mod or now_mod > (sunset_mod + 45):
278             return "E6B8B8"
279         elif now_mod < (sunrise_mod + 45) or now_mod > (sunset_mod + 120):
280             return "EECDCD"
281         else:
282             return "FFFFFF"
283
284     def get_refresh_period() -> float:
285         if override_refresh_sec is not None:
286             return float(override_refresh_sec * 1000.0)
287         now = datetime.now(tz=pytz.timezone("US/Pacific"))
288         if now.hour < 7:
289             return float(constants.refresh_period_night_sec * 1000.0)
290         else:
291             return float(constants.refresh_period_sec * 1000.0)
292
293     age = utils.describe_age_of_file_briefly(f"pages/{filename}")
294     bgcolor = pick_background_color()
295     if command is None:
296         pageid = filename
297     else:
298         pageid = f'"{command}" -> {filename}'
299
300     f.write(
301         """
302 <HEAD>
303   <TITLE>Kitchen Kiosk</TITLE>
304   <LINK rel="stylesheet" type="text/css" href="style.css">
305   <SCRIPT TYPE="text/javascript">
306
307   // Zoom the 'contents' div to fit without scrollbars and then make
308   // it visible.
309   function zoomScreen() {
310     z = 285;
311     do {
312       document.getElementById("content").style.zoom = z+"%%";
313       var body = document.body;
314       var html = document.documentElement;
315       var height = Math.max(body.scrollHeight,
316                             body.offsetHeight,
317                             html.clientHeight,
318                             html.scrollHeight,
319                             html.offsetHeight);
320       var windowHeight = window.innerHeight;
321       var width = Math.max(body.scrollWidth,
322                            body.offsetWidth,
323                            html.clientWidth,
324                            html.scrollWidth,
325                            html.offsetWidth);
326       var windowWidth = window.innerWidth;
327       var heightRatio = height / windowHeight;
328       var widthRatio = width / windowWidth;
329
330       if (heightRatio <= 1.0 && widthRatio <= 1.0) {
331         break;
332       }
333       z -= 4;
334     } while(z >= 70);
335     document.getElementById("content").style.visibility = "visible";
336   }
337
338   // Load IMG tags with DATA-SRC attributes late.
339   function lateLoadImages() {
340     var image = document.getElementsByTagName('img');
341     for (var i = 0; i < image.length; i++) {
342       if (image[i].getAttribute('DATA-SRC')) {
343         image[i].setAttribute('SRC', image[i].getAttribute('DATA-SRC'));
344       }
345     }
346   }
347
348   // Operate the clock at the top of the page.
349   function runClock() {
350     var today = new Date();
351     var h = today.getHours();
352     var ampm = h >= 12 ? 'pm' : 'am';
353     h = h %% 12;
354     h = h ? h : 12; // the hour '0' should be '12'
355     var m = maybeAddZero(today.getMinutes());
356     var colon = ":";
357     if (today.getSeconds() %% 2 == 0) {
358       colon = "<FONT STYLE='color: #%s; font-size: 4vmin; font-weight: bold'>:</FONT>";
359     }
360     document.getElementById("time").innerHTML = h + colon + m + ampm;
361     document.getElementById("date").innerHTML = today.toDateString();
362     var t = setTimeout(function(){runClock()}, 1000);
363   }
364
365   // Helper method for running the clock.
366   function maybeAddZero(x) {
367     return (x < 10) ? "0" + x : x;
368   }
369
370   // Do something on page load.
371   function addLoadEvent(func) {
372     var oldonload = window.onload;
373     if (typeof window.onload != 'function') {
374       window.onload = func;
375     } else {
376       window.onload = function() {
377         if (oldonload) {
378           oldonload();
379         }
380         func();
381       }
382     }
383   }
384
385   // Sleep thread helper.
386   const sleep = (milliseconds) => {
387     return new Promise(resolve => setTimeout(resolve, milliseconds))
388   }
389
390   var loadedDate = new Date();
391
392   addLoadEvent(zoomScreen);
393   addLoadEvent(runClock);
394   addLoadEvent(lateLoadImages);
395
396   // Runs the countdown line at the bottom and is responsible for
397   // normal page reloads caused by the expiration of a timer.
398   (function countdown() {
399     setTimeout(
400       function() {
401         var now = new Date();
402         var deltaMs = now.getTime() - loadedDate.getTime();
403         var totalMs = %d;
404         var remainingMs = (totalMs - deltaMs);
405
406         if (remainingMs > 0) {
407           var hr = document.getElementById("countdown");
408           var width = (remainingMs / (totalMs - 5000)) * 100.0;
409           if (width <= 100) {
410             hr.style.visibility = "visible";
411             hr.style.width = " ".concat(width, "%%");
412             hr.style.backgroundColor = "maroon";
413           }
414         } else {
415           // Reload unconditionally after 22 sec.
416           window.location.reload();
417         }
418
419         // Brief sleep before doing it all over again.
420         sleep(50).then(() => {
421           countdown();
422         });
423       }, 50)
424   })();
425
426   // Periodically checks for emergency reload events.
427   (function poll() {
428     setTimeout(
429       function() {
430         var xhr = new XMLHttpRequest();
431         xhr.open('GET',
432                  'http://%s/kiosk/pages/reload_immediately.html');
433         xhr.onload =
434           function() {
435             if (xhr.status === 200) {
436               window.location.reload();
437             } else {
438               sleep(500).then(() => {
439                 poll();
440               });
441             }
442           };
443         xhr.send();
444       }, 500);
445   })();
446 </SCRIPT>
447 </HEAD>
448 <BODY BGCOLOR="#%s">
449     <TABLE style="height:100%%; width:100%%" BORDER=0>
450     <TR HEIGHT=30>
451         <TD ALIGN="left">
452             <DIV id="date">&nbsp;</DIV>
453         </TD>
454         <TD ALIGN="center"><FONT COLOR=#bbbbbb>
455             <DIV id="info"></DIV></FONT>
456         </TD>
457         <TD ALIGN="right">
458             <DIV id="time">&nbsp;</DIV>
459         </TD>
460     </TR>
461     <TR STYLE="vertical-align:top">
462         <TD COLSPAN=3>
463             <DIV ID="content" STYLE="zoom: 1; visibility: hidden;">
464               <!-- BEGIN main page contents. -->
465 <!--#include virtual=\"%s\"-->
466               <!-- END main page contents. -->
467             </DIV>
468             <BR>
469             <DIV STYLE="position: absolute; top:1030px; width:99%%">
470             <P ALIGN="right">
471               <FONT SIZE=2 COLOR=#bbbbbb>%s @ %s ago.</FONT>
472             </P>
473             <HR id="countdown" STYLE="width:0px;
474                                       text-align:left;
475                                       margin:0;
476                                       border:none;
477                                       border-width:0;
478                                       height:5px;
479                                       visibility:hidden;
480                                       background-color:#ffffff;">
481             </DIV>
482         </TD>
483     </TR>
484     </TABLE>
485 </BODY>"""
486         % (
487             bgcolor,
488             get_refresh_period(),
489             constants.hostname,
490             bgcolor,
491             filename,
492             pageid,
493             age,
494         )
495     )
496
497
498 def thread_invoke_renderers() -> None:
499     render_times: Dict[str, np.array] = {}
500     render_counts: collections.Counter = collections.Counter()
501     last_render: Dict[str, datetime] = {}
502
503     while True:
504         print(f'renderer[{utils.timestamp()}]: invoking all overdue renderers in catalog...')
505         for r in renderer_catalog.get_renderers():
506             name = r.get_name()
507             now = time.time()
508             try:
509                 r.render()
510             except Exception as e:
511                 traceback.print_exc(file=sys.stdout)
512                 logger.exception(e)
513                 print(
514                     f"renderer[{utils.timestamp()}] Unknown exception ({e}) in {name}, swallowing it."
515                 )
516
517             # Increment the count of render operations per renderer.
518             render_counts[name] += 1
519
520             # Keep track of the last time we invoked each renderer.
521             last_render[name] = datetime.now(tz=pytz.timezone("US/Pacific"))
522
523             # Record how long each render operation takes and warn if very long.
524             delta = time.time() - now
525             times = render_times.get(name, np.array([]))
526             times = np.insert(times, 0, delta)
527             render_times[name] = times
528             if delta > 1.0:
529                 hdr = f'renderer[{utils.timestamp()}]:'
530                 print(
531 f'''
532 {hdr} Warning: {name}'s rendering took {delta:5.2f}s.
533 {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}
534 ''')
535
536         # Render a page about internal stats of renderers.
537         print(f'renderer[{utils.timestamp()}]: Updating internal statistics page.')
538         with file_writer.file_writer(constants.internal_stats_pagename) as f:
539             f.write(
540 f'''
541 <TABLE BORDER=0>
542     <TR>
543     <TH>Renderer Name</TH>
544     <TH>Last Run</TH>
545     <TH>Num Invocations</TH>
546     <TH>Render Latency</TH>
547     </TR>
548 ''')
549             for n, r in enumerate(renderer_catalog.get_renderers()):
550                 if n % 2 == 0:
551                     style = 'style="margin: 0; padding: 0; background: #c6b0b0;"'
552                 else:
553                     style = 'style="margin: 0; padding: 0; background: #eeeeee;"'
554                 name = r.get_name()
555                 last = last_render.get(name, None)
556                 if last is None:
557                     last = 'never'
558                 else:
559                     last = last.strftime('%Y/%m/%d %I:%M:%S%P')
560                 count = render_counts.get(name, 0)
561                 latency = render_times.get(name, np.array([]))
562                 p25 = p50 = p75 = p90 = p99 = 'N/A'
563                 try:
564                     p25 = np.percentile(latency, 25)
565                     p50 = np.percentile(latency, 50)
566                     p75 = np.percentile(latency, 75)
567                     p90 = np.percentile(latency, 90)
568                     p99 = np.percentile(latency, 99)
569                 except IndexError:
570                     pass
571                 f.write(
572 f'''
573     <TR>
574     <TD {style}>{name}</TD>
575     <TD {style}>{last}</TD>
576     <TD {style}><CENTER>{count}</CENTER></TD>
577     <TD {style}>p25={p25:5.2f}, p50={p50:5.2f}, p75={p75:5.2f}, p90={p90:5.2f}, p99={p99:5.2f}</TD>
578     </TR>
579 ''')
580             f.write('</TABLE>')
581
582         print(
583             f"renderer[{utils.timestamp()}]: " +
584             f"thread having a little break for {constants.render_period_sec}s..."
585         )
586         time.sleep(constants.render_period_sec)
587
588
589 if __name__ == "__main__":
590     logging.basicConfig()
591     command_queue: Queue = Queue()
592     changer_thread: Optional[Thread] = None
593     renderer_thread: Optional[Thread] = None
594     janitor_thread: Optional[Thread] = None
595     hotword_thread: Optional[Thread] = None
596     while True:
597         if hotword_thread is None or not hotword_thread.is_alive():
598             keyword_paths = [pvporcupine.KEYWORD_PATHS[x] for x in ["bumblebee"]]
599             sensitivities = [0.7] * len(keyword_paths)
600             listener = listen.HotwordListener(
601                 command_queue,
602                 keyword_paths,
603                 sensitivities,
604             )
605             hotword_thread = Thread(target=listener.listen_forever, args=())
606             hotword_thread.start()
607         if changer_thread is None or not changer_thread.is_alive():
608             print(
609                 f"MAIN[{utils.timestamp()}] - (Re?)initializing chooser thread... (wtf?!)"
610             )
611             changer_thread = Thread(target=thread_change_current, args=(command_queue,))
612             changer_thread.start()
613         if renderer_thread is None or not renderer_thread.is_alive():
614             print(
615                 f"MAIN[{utils.timestamp()}] - (Re?)initializing render thread... (wtf?!)"
616             )
617             renderer_thread = Thread(target=thread_invoke_renderers, args=())
618             renderer_thread.start()
619         if janitor_thread is None or not janitor_thread.is_alive():
620             print(
621                 f"MAIN[{utils.timestamp()}] - (Re?)initializing janitor thread... (wtf?!)"
622             )
623             janitor_thread = Thread(target=thread_janitor, args=())
624             janitor_thread.start()
625         time.sleep(60)