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