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