2c75dd09a50e76e895ba003e717600a3804ad97c
[kiosk.git] / kiosk.py
1 #!/usr/bin/env python3
2
3 from datetime import datetime
4 import gc
5 import linecache
6 import os
7 import sys
8 from threading import Thread
9 import time
10 import traceback
11 import tracemalloc
12 from typing import Optional
13
14 import constants
15 import renderer
16 import renderer
17 import renderer_catalog
18 import chooser
19 import logging
20 import trigger_catalog
21 import utils
22
23
24 def filter_news_during_dinnertime(page: str) -> bool:
25     now = datetime.now()
26     is_dinnertime = now.hour >= 17 and now.hour <= 20
27     return not is_dinnertime or not (
28         "cnn" in page
29         or "news" in page
30         or "mynorthwest" in page
31         or "seattle" in page
32         or "stranger" in page
33         or "twitter" in page
34         or "wsj" in page
35     )
36
37
38 def thread_janitor() -> None:
39     tracemalloc.start()
40     tracemalloc_target = 0.0
41     gc_target = 0.0
42     gc.enable()
43
44     while True:
45         now = time.time()
46         if now > tracemalloc_target:
47             tracemalloc_target = now + 30.0
48             snapshot = tracemalloc.take_snapshot()
49             snapshot = snapshot.filter_traces((
50                 tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
51                 tracemalloc.Filter(False, "<unknown>"),
52             ))
53             key_type = 'lineno'
54             limit = 10
55             top_stats = snapshot.statistics(key_type)
56             print("janitor: Top %s lines" % limit)
57             for index, stat in enumerate(top_stats[:limit], 1):
58                 frame = stat.traceback[0]
59                 # replace "/path/to/module/file.py" with "module/file.py"
60                 filename = os.sep.join(frame.filename.split(os.sep)[-2:])
61                 print("janitor: #%s: %s:%s: %.1f KiB"
62                       % (index, filename, frame.lineno, stat.size / 1024))
63                 line = linecache.getline(frame.filename, frame.lineno).strip()
64                 if line:
65                     print('janitor:    %s' % line)
66
67             other = top_stats[limit:]
68             if other:
69                 size = sum(stat.size for stat in other)
70                 print("janitor: %s other: %.1f KiB" % (len(other), size / 1024))
71             total = sum(stat.size for stat in top_stats)
72             print("janitor: Total allocated size: %.1f KiB" % (total / 1024))
73         if now > gc_target:
74             print("janitor: Running gc operation")
75             gc_target = now + 60.0
76             gc.collect()
77         time.sleep(10.0)
78
79
80 def thread_change_current() -> None:
81     page_chooser = chooser.weighted_random_chooser_with_triggers(
82         trigger_catalog.get_triggers(), [filter_news_during_dinnertime]
83     )
84     swap_page_target = 0.0
85     last_page = ""
86     while True:
87         now = time.time()
88         (page, triggered) = page_chooser.choose_next_page()
89
90         if triggered:
91             print("chooser[%s] - WE ARE TRIGGERED." % utils.timestamp())
92             if page != last_page:
93                 print(
94                     "chooser[%s] - EMERGENCY PAGE %s LOAD NEEDED"
95                     % (utils.timestamp(), page)
96                 )
97                 try:
98                     f = open(os.path.join(constants.pages_dir, "current.shtml"), "w")
99                     emit_wrapped(f, page)
100                     f.close()
101                 except:
102                     print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
103                     continue
104                 last_page = page
105                 swap_page_target = now + constants.refresh_period_sec
106
107                 # Also notify XMLHTTP clients that they need to refresh now.
108                 path = os.path.join(constants.pages_dir, "reload_immediately.html")
109                 f = open(path, "w")
110                 f.write("Reload, suckers!")
111                 f.close()
112
113                 # Fix this hack... maybe read the webserver logs and see if it
114                 # actually was picked up?
115                 time.sleep(0.750)
116                 os.remove(path)
117
118         elif now >= swap_page_target:
119             if page == last_page:
120                 print(
121                     (
122                         "chooser[%s] - nominal choice got the same page..."
123                         % (utils.timestamp())
124                     )
125                 )
126                 continue
127             print("chooser[%s] - nominal choice of %s" % (utils.timestamp(), page))
128             try:
129                 f = open(os.path.join(constants.pages_dir, "current.shtml"), "w")
130                 emit_wrapped(f, page)
131                 f.close()
132             except:
133                 print("chooser[%s] - page does not exist?!" % (utils.timestamp()))
134                 continue
135             last_page = page
136             swap_page_target = now + constants.refresh_period_sec
137         time.sleep(1)
138
139
140 def emit_wrapped(f, filename) -> None:
141     def pick_background_color() -> str:
142         now = datetime.now()
143         if now.hour <= 6 or now.hour >= 21:
144             return "E6B8B8"
145         elif now.hour == 7 or now.hour == 20:
146             return "EECDCD"
147         else:
148             return "FFFFFF"
149
150     def get_refresh_period() -> float:
151         now = datetime.now()
152         if now.hour < 7:
153             return constants.refresh_period_night_sec * 1000
154         else:
155             return constants.refresh_period_sec * 1000
156
157     age = utils.describe_age_of_file_briefly(f"pages/{filename}")
158     bgcolor = pick_background_color()
159     f.write(
160         """
161 <HEAD>
162   <TITLE>Kitchen Kiosk</TITLE>
163   <LINK rel="stylesheet" type="text/css" href="style.css">
164   <SCRIPT TYPE="text/javascript">
165
166   // Zoom the 'contents' div to fit without scrollbars and then make
167   // it visible.
168   function zoomScreen() {
169     z = 285;
170     do {
171       document.getElementById("content").style.zoom = z+"%%";
172       var body = document.body;
173       var html = document.documentElement;
174       var height = Math.max(body.scrollHeight,
175                             body.offsetHeight,
176                             html.clientHeight,
177                             html.scrollHeight,
178                             html.offsetHeight);
179       var windowHeight = window.innerHeight;
180       var width = Math.max(body.scrollWidth,
181                            body.offsetWidth,
182                            html.clientWidth,
183                            html.scrollWidth,
184                            html.offsetWidth);
185       var windowWidth = window.innerWidth;
186       var heightRatio = height / windowHeight;
187       var widthRatio = width / windowWidth;
188
189       if (heightRatio <= 1.0 && widthRatio <= 1.0) {
190         break;
191       }
192       z -= 4;
193     } while(z >= 70);
194     document.getElementById("content").style.visibility = "visible";
195   }
196
197   // Load IMG tags with DATA-SRC attributes late.
198   function lateLoadImages() {
199     var image = document.getElementsByTagName('img');
200     for (var i = 0; i < image.length; i++) {
201       if (image[i].getAttribute('DATA-SRC')) {
202         image[i].setAttribute('SRC', image[i].getAttribute('DATA-SRC'));
203       }
204     }
205   }
206
207   // Operate the clock at the top of the page.
208   function runClock() {
209     var today = new Date();
210     var h = today.getHours();
211     var ampm = h >= 12 ? 'pm' : 'am';
212     h = h %% 12;
213     h = h ? h : 12; // the hour '0' should be '12'
214     var m = maybeAddZero(today.getMinutes());
215     var colon = ":";
216     if (today.getSeconds() %% 2 == 0) {
217       colon = "<FONT STYLE='color: #%s; font-size: 4vmin; font-weight: bold'>:</FONT>";
218     }
219     document.getElementById("time").innerHTML = h + colon + m + ampm;
220     document.getElementById("date").innerHTML = today.toDateString();
221     var t = setTimeout(function(){runClock()}, 1000);
222   }
223
224   // Helper method for running the clock.
225   function maybeAddZero(x) {
226     return (x < 10) ? "0" + x : x;
227   }
228
229   // Do something on page load.
230   function addLoadEvent(func) {
231     var oldonload = window.onload;
232     if (typeof window.onload != 'function') {
233       window.onload = func;
234     } else {
235       window.onload = function() {
236         if (oldonload) {
237           oldonload();
238         }
239         func();
240       }
241     }
242   }
243
244   // Sleep thread helper.
245   const sleep = (milliseconds) => {
246     return new Promise(resolve => setTimeout(resolve, milliseconds))
247   }
248
249   var loadedDate = new Date();
250
251   addLoadEvent(zoomScreen);
252   addLoadEvent(runClock);
253   addLoadEvent(lateLoadImages);
254
255   // Runs the countdown line at the bottom and is responsible for
256   // normal page reloads caused by the expiration of a timer.
257   (function countdown() {
258     setTimeout(
259       function() {
260         var now = new Date();
261         var deltaMs = now.getTime() - loadedDate.getTime();
262         var totalMs = %d;
263         var remainingMs = (totalMs - deltaMs);
264
265         if (remainingMs > 0) {
266           var hr = document.getElementById("countdown");
267           var width = (remainingMs / (totalMs - 5000)) * 100.0;
268           if (width <= 100) {
269             hr.style.visibility = "visible";
270             hr.style.width = " ".concat(width, "%%");
271             hr.style.backgroundColor = "maroon";
272           }
273         } else {
274           // Reload unconditionally after 22 sec.
275           window.location.reload();
276         }
277
278         // Brief sleep before doing it all over again.
279         sleep(50).then(() => {
280           countdown();
281         });
282       }, 50)
283   })();
284
285   // Periodically checks for emergency reload events.
286   (function poll() {
287     setTimeout(
288       function() {
289         var xhr = new XMLHttpRequest();
290         xhr.open('GET',
291                  'http://%s/kiosk/pages/reload_immediately.html');
292         xhr.onload =
293           function() {
294             if (xhr.status === 200) {
295               window.location.reload();
296             } else {
297               sleep(500).then(() => {
298                 poll();
299               });
300             }
301           };
302         xhr.send();
303       }, 500);
304   })();
305 </SCRIPT>
306 </HEAD>
307 <BODY BGCOLOR="#%s">
308     <TABLE style="height:100%%; width:100%%" BORDER=0>
309     <TR HEIGHT=30>
310         <TD ALIGN="left">
311             <DIV id="date">&nbsp;</DIV>
312         </TD>
313         <TD ALIGN="center"><FONT COLOR=#bbbbbb>
314             <DIV id="info"></DIV></FONT>
315         </TD>
316         <TD ALIGN="right">
317             <DIV id="time">&nbsp;</DIV>
318         </TD>
319     </TR>
320     <TR STYLE="vertical-align:top">
321         <TD COLSPAN=3>
322             <DIV ID="content" STYLE="zoom: 1; visibility: hidden;">
323               <!-- BEGIN main page contents. -->
324 <!--#include virtual=\"%s\"-->
325               <!-- END main page contents. -->
326             </DIV>
327             <BR>
328             <DIV STYLE="position: absolute; top:1030px; width:99%%">
329             <P ALIGN="right">
330               <FONT SIZE=2 COLOR=#bbbbbb>%s @ %s ago.</FONT>
331             </P>
332             <HR id="countdown" STYLE="width:0px;
333                                       text-align:left;
334                                       margin:0;
335                                       border:none;
336                                       border-width:0;
337                                       height:5px;
338                                       visibility:hidden;
339                                       background-color:#ffffff;">
340             </DIV>
341         </TD>
342     </TR>
343     </TABLE>
344 </BODY>"""
345         % (
346             bgcolor,
347             get_refresh_period(),
348             constants.hostname,
349             bgcolor,
350             filename,
351             filename,
352             age,
353         )
354     )
355
356
357 def thread_invoke_renderers() -> None:
358     while True:
359         print(f"renderer[{utils.timestamp()}]: invoking all renderers in catalog...")
360         for r in renderer_catalog.get_renderers():
361             now = time.time()
362             try:
363                 r.render()
364             except Exception as e:
365                 traceback.print_exc()
366                 print(
367                     f"renderer[{utils.timestamp()}] unknown exception in {r.get_name()}, swallowing it."
368                 )
369             delta = time.time() - now
370             if delta > 1.0:
371                 print(
372                     f"renderer[{utils.timestamp()}]: Warning: {r.get_name()}'s rendering took {delta:5.2f}s."
373                 )
374         print(
375             f"renderer[{utils.timestamp()}]: thread having a little break for {constants.render_period_sec}s..."
376         )
377         time.sleep(constants.render_period_sec)
378
379
380 if __name__ == "__main__":
381     logging.basicConfig()
382     changer_thread: Optional[Thread] = None
383     renderer_thread: Optional[Thread] = None
384     janitor_thread: Optional[Thread] = None
385     while True:
386         if changer_thread is None or not changer_thread.is_alive():
387             print(
388                 f"MAIN[{utils.timestamp()}] - (Re?)initializing chooser thread... (wtf?!)"
389             )
390             changer_thread = Thread(target=thread_change_current, args=())
391             changer_thread.start()
392         if renderer_thread is None or not renderer_thread.is_alive():
393             print(
394                 f"MAIN[{utils.timestamp()}] - (Re?)initializing render thread... (wtf?!)"
395             )
396             renderer_thread = Thread(target=thread_invoke_renderers, args=())
397             renderer_thread.start()
398         if janitor_thread is None or not janitor_thread.is_alive():
399             print(
400                 f"MAIN[{utils.timestamp()}] - (Re?)initializing janitor thread... (wtf?!)"
401             )
402             janitor_thread = Thread(target=thread_janitor, args=())
403             janitor_thread.start()
404         time.sleep(60)