Working on voice command logic.
[kiosk.git] / kiosk.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
20 import constants
21 import renderer
22 import renderer
23 import renderer_catalog
24 import chooser
25 import listen
26 import logging
27 import pvporcupine
28 import trigger_catalog
29 import utils
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         return not is_dinnertime or not (
181             "cnn" in page
182             or "news" in page
183             or "mynorthwest" in page
184             or "seattle" in page
185             or "stranger" in page
186             or "twitter" in page
187             or "wsj" in page
188         )
189     page_chooser = chooser.weighted_random_chooser_with_triggers(
190         trigger_catalog.get_triggers(), [filter_news_during_dinnertime]
191     )
192
193     while True:
194         now = time.time()
195
196         # Check for a verbal command.
197         command = None
198         try:
199             command = command_queue.get(block=False)
200         except Exception:
201             command = None
202             pass
203         if command is not None:
204             triggered = True
205             page = process_command(command, page_history)
206
207         # Else pick a page randomly.
208         else:
209             while True:
210                 (page, triggered) = page_chooser.choose_next_page()
211                 if triggered or page != page_history[0]:
212                     break
213
214         if triggered:
215             print("chooser[%s] - WE ARE TRIGGERED." % utils.timestamp())
216             if page != page_history[0] or (swap_page_target - now < 10.0):
217                 print(
218                     "chooser[%s] - EMERGENCY PAGE %s LOAD NEEDED"
219                     % (utils.timestamp(), page)
220                 )
221                 try:
222                     with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
223                         emit_wrapped(f, page, override_refresh_sec = 40, command = command)
224                     page_history.insert(0, page)
225                     page_history = page_history[0:10]
226                     swap_page_target = now + 40
227                 except:
228                     print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
229                     continue
230
231                 # Also notify XMLHTTP clients that they need to refresh now.
232                 path = os.path.join(constants.pages_dir, "reload_immediately.html")
233                 with open(path, "w") as f:
234                     f.write("Reload, suckers!")
235
236                 # Fix this hack... maybe read the webserver logs and see if it
237                 # actually was picked up?
238                 time.sleep(0.75)
239                 os.remove(path)
240
241         elif now >= swap_page_target:
242             assert page != page_history[0]
243             print("chooser[%s] - nominal choice of %s" % (utils.timestamp(), page))
244             try:
245                 with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
246                     emit_wrapped(f, page)
247                 page_history.insert(0, page)
248                 page_history = page_history[0:10]
249                 swap_page_target = now + constants.refresh_period_sec
250             except:
251                 print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
252                 continue
253         time.sleep(1)
254
255
256 def emit_wrapped(f,
257                  filename: str,
258                  *,
259                  override_refresh_sec: int = None,
260                  command: str = None) -> None:
261     def pick_background_color() -> str:
262         city = astral.LocationInfo(
263             "Bellevue", "USA", "US/Pacific", 47.610, -122.201
264         )
265         s = sun(city.observer, date=self.dt, tzinfo=pytz.timezone("US/Pacific"))
266         sunrise_mod = utils.minute_number(s["sunrise"].hour, s["sunrise"].minute)
267         sunset_mod = utils.minute_number(s["sunset"].hour, s["sunset"].minute)
268         now = datetime.now()
269         now_mod = utils.minute_number(now.hour, now.minute)
270         if now_mod < sunrise_mod or now_mod > (sunset_mod + 45):
271             return "E6B8B8"
272         elif now_mod < (sunrise_mod + 45) or now_mod > (sunset_mod + 120):
273             return "EECDCD"
274         else:
275             return "FFFFFF"
276
277     def get_refresh_period() -> float:
278         if override_refresh_sec is not None:
279             return float(override_refresh_sec * 1000.0)
280         now = datetime.now()
281         if now.hour < 7:
282             return float(constants.refresh_period_night_sec * 1000.0)
283         else:
284             return float(constants.refresh_period_sec * 1000.0)
285
286     age = utils.describe_age_of_file_briefly(f"pages/{filename}")
287     bgcolor = pick_background_color()
288     if command is None:
289         pageid = filename
290     else:
291         pageid = f'"{command}" -> {filename}'
292
293     f.write(
294         """
295 <HEAD>
296   <TITLE>Kitchen Kiosk</TITLE>
297   <LINK rel="stylesheet" type="text/css" href="style.css">
298   <SCRIPT TYPE="text/javascript">
299
300   // Zoom the 'contents' div to fit without scrollbars and then make
301   // it visible.
302   function zoomScreen() {
303     z = 285;
304     do {
305       document.getElementById("content").style.zoom = z+"%%";
306       var body = document.body;
307       var html = document.documentElement;
308       var height = Math.max(body.scrollHeight,
309                             body.offsetHeight,
310                             html.clientHeight,
311                             html.scrollHeight,
312                             html.offsetHeight);
313       var windowHeight = window.innerHeight;
314       var width = Math.max(body.scrollWidth,
315                            body.offsetWidth,
316                            html.clientWidth,
317                            html.scrollWidth,
318                            html.offsetWidth);
319       var windowWidth = window.innerWidth;
320       var heightRatio = height / windowHeight;
321       var widthRatio = width / windowWidth;
322
323       if (heightRatio <= 1.0 && widthRatio <= 1.0) {
324         break;
325       }
326       z -= 4;
327     } while(z >= 70);
328     document.getElementById("content").style.visibility = "visible";
329   }
330
331   // Load IMG tags with DATA-SRC attributes late.
332   function lateLoadImages() {
333     var image = document.getElementsByTagName('img');
334     for (var i = 0; i < image.length; i++) {
335       if (image[i].getAttribute('DATA-SRC')) {
336         image[i].setAttribute('SRC', image[i].getAttribute('DATA-SRC'));
337       }
338     }
339   }
340
341   // Operate the clock at the top of the page.
342   function runClock() {
343     var today = new Date();
344     var h = today.getHours();
345     var ampm = h >= 12 ? 'pm' : 'am';
346     h = h %% 12;
347     h = h ? h : 12; // the hour '0' should be '12'
348     var m = maybeAddZero(today.getMinutes());
349     var colon = ":";
350     if (today.getSeconds() %% 2 == 0) {
351       colon = "<FONT STYLE='color: #%s; font-size: 4vmin; font-weight: bold'>:</FONT>";
352     }
353     document.getElementById("time").innerHTML = h + colon + m + ampm;
354     document.getElementById("date").innerHTML = today.toDateString();
355     var t = setTimeout(function(){runClock()}, 1000);
356   }
357
358   // Helper method for running the clock.
359   function maybeAddZero(x) {
360     return (x < 10) ? "0" + x : x;
361   }
362
363   // Do something on page load.
364   function addLoadEvent(func) {
365     var oldonload = window.onload;
366     if (typeof window.onload != 'function') {
367       window.onload = func;
368     } else {
369       window.onload = function() {
370         if (oldonload) {
371           oldonload();
372         }
373         func();
374       }
375     }
376   }
377
378   // Sleep thread helper.
379   const sleep = (milliseconds) => {
380     return new Promise(resolve => setTimeout(resolve, milliseconds))
381   }
382
383   var loadedDate = new Date();
384
385   addLoadEvent(zoomScreen);
386   addLoadEvent(runClock);
387   addLoadEvent(lateLoadImages);
388
389   // Runs the countdown line at the bottom and is responsible for
390   // normal page reloads caused by the expiration of a timer.
391   (function countdown() {
392     setTimeout(
393       function() {
394         var now = new Date();
395         var deltaMs = now.getTime() - loadedDate.getTime();
396         var totalMs = %d;
397         var remainingMs = (totalMs - deltaMs);
398
399         if (remainingMs > 0) {
400           var hr = document.getElementById("countdown");
401           var width = (remainingMs / (totalMs - 5000)) * 100.0;
402           if (width <= 100) {
403             hr.style.visibility = "visible";
404             hr.style.width = " ".concat(width, "%%");
405             hr.style.backgroundColor = "maroon";
406           }
407         } else {
408           // Reload unconditionally after 22 sec.
409           window.location.reload();
410         }
411
412         // Brief sleep before doing it all over again.
413         sleep(50).then(() => {
414           countdown();
415         });
416       }, 50)
417   })();
418
419   // Periodically checks for emergency reload events.
420   (function poll() {
421     setTimeout(
422       function() {
423         var xhr = new XMLHttpRequest();
424         xhr.open('GET',
425                  'http://%s/kiosk/pages/reload_immediately.html');
426         xhr.onload =
427           function() {
428             if (xhr.status === 200) {
429               window.location.reload();
430             } else {
431               sleep(500).then(() => {
432                 poll();
433               });
434             }
435           };
436         xhr.send();
437       }, 500);
438   })();
439 </SCRIPT>
440 </HEAD>
441 <BODY BGCOLOR="#%s">
442     <TABLE style="height:100%%; width:100%%" BORDER=0>
443     <TR HEIGHT=30>
444         <TD ALIGN="left">
445             <DIV id="date">&nbsp;</DIV>
446         </TD>
447         <TD ALIGN="center"><FONT COLOR=#bbbbbb>
448             <DIV id="info"></DIV></FONT>
449         </TD>
450         <TD ALIGN="right">
451             <DIV id="time">&nbsp;</DIV>
452         </TD>
453     </TR>
454     <TR STYLE="vertical-align:top">
455         <TD COLSPAN=3>
456             <DIV ID="content" STYLE="zoom: 1; visibility: hidden;">
457               <!-- BEGIN main page contents. -->
458 <!--#include virtual=\"%s\"-->
459               <!-- END main page contents. -->
460             </DIV>
461             <BR>
462             <DIV STYLE="position: absolute; top:1030px; width:99%%">
463             <P ALIGN="right">
464               <FONT SIZE=2 COLOR=#bbbbbb>%s @ %s ago.</FONT>
465             </P>
466             <HR id="countdown" STYLE="width:0px;
467                                       text-align:left;
468                                       margin:0;
469                                       border:none;
470                                       border-width:0;
471                                       height:5px;
472                                       visibility:hidden;
473                                       background-color:#ffffff;">
474             </DIV>
475         </TD>
476     </TR>
477     </TABLE>
478 </BODY>"""
479         % (
480             bgcolor,
481             get_refresh_period(),
482             constants.hostname,
483             bgcolor,
484             filename,
485             pageid,
486             age,
487         )
488     )
489
490
491 def thread_invoke_renderers() -> None:
492     while True:
493         print(f"renderer[{utils.timestamp()}]: invoking all renderers in catalog...")
494         for r in renderer_catalog.get_renderers():
495             now = time.time()
496             try:
497                 r.render()
498             except Exception as e:
499                 traceback.print_exc()
500                 print(
501                     f"renderer[{utils.timestamp()}] unknown exception in {r.get_name()}, swallowing it."
502                 )
503             delta = time.time() - now
504             if delta > 1.0:
505                 print(
506                     f"renderer[{utils.timestamp()}]: Warning: {r.get_name()}'s rendering took {delta:5.2f}s."
507                 )
508         print(
509             f"renderer[{utils.timestamp()}]: thread having a little break for {constants.render_period_sec}s..."
510         )
511         time.sleep(constants.render_period_sec)
512
513
514 if __name__ == "__main__":
515     logging.basicConfig()
516     command_queue: Queue = Queue()
517     changer_thread: Optional[Thread] = None
518     renderer_thread: Optional[Thread] = None
519     janitor_thread: Optional[Thread] = None
520     hotword_thread: Optional[Thread] = None
521     while True:
522         if hotword_thread is None or not hotword_thread.is_alive():
523             keyword_paths = [pvporcupine.KEYWORD_PATHS[x] for x in ["bumblebee"]]
524             sensitivities = [0.7] * len(keyword_paths)
525             listener = listen.HotwordListener(
526                 command_queue,
527                 keyword_paths,
528                 sensitivities,
529             )
530             hotword_thread = Thread(target=listener.listen_forever, args=())
531             hotword_thread.start()
532         if changer_thread is None or not changer_thread.is_alive():
533             print(
534                 f"MAIN[{utils.timestamp()}] - (Re?)initializing chooser thread... (wtf?!)"
535             )
536             changer_thread = Thread(target=thread_change_current, args=(command_queue,))
537             changer_thread.start()
538         if renderer_thread is None or not renderer_thread.is_alive():
539             print(
540                 f"MAIN[{utils.timestamp()}] - (Re?)initializing render thread... (wtf?!)"
541             )
542             renderer_thread = Thread(target=thread_invoke_renderers, args=())
543             renderer_thread.start()
544         if janitor_thread is None or not janitor_thread.is_alive():
545             print(
546                 f"MAIN[{utils.timestamp()}] - (Re?)initializing janitor thread... (wtf?!)"
547             )
548             janitor_thread = Thread(target=thread_janitor, args=())
549             janitor_thread.start()
550         time.sleep(60)