+++ /dev/null
-#!/usr/bin/env python3
-
-from datetime import datetime
-import gc
-import linecache
-import os
-import re
-import sys
-from threading import Thread
-import time
-import traceback
-import tracemalloc
-from typing import Optional, List
-from queue import Queue
-
-import astral # type: ignore
-from astral.sun import sun # type: ignore
-import pytz
-
-import datetime_utils
-import file_utils
-
-import kiosk_constants as constants
-import renderer_catalog
-import chooser
-import listen
-import logging
-import pvporcupine
-import trigger_catalog
-
-
-def thread_janitor() -> None:
- tracemalloc.start()
- tracemalloc_target = 0.0
- gc_target = 0.0
- gc.enable()
-
- while True:
- now = time.time()
- if now > tracemalloc_target:
- tracemalloc_target = now + 30.0
- snapshot = tracemalloc.take_snapshot()
- snapshot = snapshot.filter_traces((
- tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
- tracemalloc.Filter(False, "<unknown>"),
- ))
- key_type = 'lineno'
- limit = 10
- top_stats = snapshot.statistics(key_type)
- print("janitor: Top %s lines" % limit)
- for index, stat in enumerate(top_stats[:limit], 1):
- frame = stat.traceback[0]
- # replace "/path/to/module/file.py" with "module/file.py"
- filename = os.sep.join(frame.filename.split(os.sep)[-2:])
- print("janitor: #%s: %s:%s: %.1f KiB"
- % (index, filename, frame.lineno, stat.size / 1024))
- line = linecache.getline(frame.filename, frame.lineno).strip()
- if line:
- print('janitor: %s' % line)
-
- other = top_stats[limit:]
- if other:
- size = sum(stat.size for stat in other)
- print("janitor: %s other: %.1f KiB" % (len(other), size / 1024))
- total = sum(stat.size for stat in top_stats)
- print("janitor: Total allocated size: %.1f KiB" % (total / 1024))
- if now > gc_target:
- print("janitor: Running gc operation")
- gc_target = now + 60.0
- gc.collect()
- time.sleep(10.0)
-
-
-def guess_page(command: str, page_chooser: chooser.chooser) -> str:
- best_page = None
- best_score = None
- for page in page_chooser.get_page_list():
- page = page.replace('(', ' ')
- page = page.replace('_', ' ')
- page = page.replace(')', ' ')
- page = page.replace('.html', '')
- page = page.replace('CNNNews', 'news')
- page = page.replace('CNNTechnology', 'technology')
- page = page.replace('gocostco', 'costco list')
- page = page.replace('gohardware', 'hardware list')
- page = page.replace('gohouse', 'house list honey do')
- page = page.replace('gcal', 'google calendar events')
- page = page.replace('mynorthwest', 'northwest news')
- page = page.replace('myq', 'myq garage door status')
- page = page.replace('gomenu', 'dinner menu')
- page = page.replace('wsdot', 'traffic')
- page = page.replace('gomenu', 'dinner menu')
- page = page.replace('WSJNews', 'news')
- page = page.replace('telma', 'telma cabin')
- page = page.replace('WSJBusiness', 'business news')
- page = re.sub(r'[0-9]+', '', page)
- score = SequenceMatcher(None, command, page).ratio()
- if best_score is None or score > best_score:
- best_page = page
- assert best_page is not None
- return best_page
-
-
-def process_command(command: str, page_history: List[str]) -> str:
- page = None
- if 'hold' in command:
- page = page_history[0]
- elif 'back' in command:
- page = page_history[1]
- elif 'skip' in command:
- while True:
- (page, _) = page_chooser.choose_next_page()
- if page != page_history[0]:
- break
- elif 'weather' in command:
- if 'telma' in command or 'cabin' in command:
- page = 'weather-telma_3_10800.html'
- elif 'stevens' in command:
- page = 'weather-stevens_3_10800.html'
- else:
- page = 'weather-home_3_10800.html'
- elif 'cabin' in command:
- if 'list' in command:
- page = 'Cabin-(gocabin)_2_3600.html'
- else:
- page = 'hidden/cabin_driveway.html'
- elif 'news' in command or 'headlines' in command:
- page = 'cnn-CNNNews_4_25900.html'
- elif 'clock' in command or 'time' in command:
- page = 'clock_10_none.html'
- elif 'countdown' in command or 'countdowns' in command:
- page = 'countdown_3_7200.html'
- elif 'costco' in command:
- page = 'Costco-(gocostco)_2_3600.html'
- elif 'calendar' in command or 'events' in command:
- page = 'gcal_3_86400.html'
- elif 'countdown' in command or 'countdowns' in command:
- page = 'countdown_3_7200.html'
- elif 'grocery' in command or 'groceries' in command:
- page = 'Grocery-(gogrocery)_2_3600.html'
- elif 'hardware' in command:
- page = 'Hardware-(gohardware)_2_3600.html'
- elif 'garage' in command:
- page = 'myq_4_300.html'
- elif 'menu' in command:
- page = 'Menu-(gomenu)_2_3600.html'
- elif 'cron' in command or 'health' in command:
- page = 'periodic-health_6_300.html'
- elif 'photo' in command or 'picture' in command:
- page = 'photo_23_3600.html'
- elif 'quote' in command or 'quotation' in command or 'quotes' in command:
- page = 'quotes_4_10800.html'
- elif 'stevens' in command:
- page = 'stevens-conditions_1_86400.html'
- elif 'stock' in command or 'stocks' in command:
- page = 'stock_3_86400.html'
- elif 'twitter' in command:
- page = 'twitter_10_3600.html'
- elif 'traffic' in command:
- page = 'wsdot-bridges_3_none.html'
- elif 'front' in command and 'door' in command:
- page = 'hidden/frontdoor.html'
- elif 'driveway' in command:
- page = 'hidden/driveway.html'
- elif 'backyard' in command:
- page = 'hidden/backyard.html'
- else:
- page = guess_page(command, page_chooser)
- assert page is not None
- return page
-
-
-def thread_change_current(command_queue: Queue) -> None:
- page_history = [ "", "" ]
- swap_page_target = 0.0
-
- def filter_news_during_dinnertime(page: str) -> bool:
- now = datetime.now()
- is_dinnertime = now.hour >= 17 and now.hour <= 20
- print(f"is dinnertime = {is_dinnertime}")
- print(f'page = {page}')
- return not is_dinnertime or not (
- "cnn" in page
- or "news" in page
- or "mynorthwest" in page
- or "seattle" in page
- or "stranger" in page
- or "twitter" in page
- or "wsj" in page
- )
- page_chooser = chooser.weighted_random_chooser_with_triggers(
- trigger_catalog.get_triggers(), [filter_news_during_dinnertime]
- )
-
- while True:
- now = time.time()
-
- # Check for a verbal command.
- command = None
- try:
- command = command_queue.get(block=False)
- except Exception:
- command = None
- pass
- if command is not None:
- triggered = True
- page = process_command(command, page_history)
-
- # Else pick a page randomly.
- else:
- while True:
- (page, triggered) = page_chooser.choose_next_page()
- if triggered or page != page_history[0]:
- break
-
- if triggered:
- print("chooser[%s] - WE ARE TRIGGERED." % datetime_utils.timestamp())
- if page != page_history[0] or (swap_page_target - now < 10.0):
- print(
- "chooser[%s] - EMERGENCY PAGE %s LOAD NEEDED"
- % (datetime_utils.timestamp(), page)
- )
- try:
- with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
- emit_wrapped(f, page, override_refresh_sec = 40, command = command)
- page_history.insert(0, page)
- page_history = page_history[0:10]
- swap_page_target = now + 40
- except:
- print("chooser[%s] - page does not exist?!" % (datetime_utils.timestamp()))
- continue
-
- # Also notify XMLHTTP clients that they need to refresh now.
- path = os.path.join(constants.pages_dir, "reload_immediately.html")
- with open(path, "w") as f:
- f.write("Reload, suckers!")
-
- # Fix this hack... maybe read the webserver logs and see if it
- # actually was picked up?
- time.sleep(0.75)
- os.remove(path)
-
- elif now >= swap_page_target:
- assert page != page_history[0]
- print("chooser[%s] - nominal choice of %s" % (datetime_utils.timestamp(), page))
- try:
- with open(os.path.join(constants.pages_dir, "current.shtml"), "w") as f:
- emit_wrapped(f, page)
- page_history.insert(0, page)
- page_history = page_history[0:10]
- swap_page_target = now + constants.refresh_period_sec
- except:
- print("chooser[%s] - page does not exist?!" % (datetime_utils.timestamp()))
- continue
- time.sleep(1)
-
-
-def emit_wrapped(f,
- filename: str,
- *,
- override_refresh_sec: int = None,
- command: str = None) -> None:
- def pick_background_color() -> str:
- now = datetime.now(tz=pytz.timezone("US/Pacific"))
- city = astral.LocationInfo(
- "Bellevue", "USA", "US/Pacific", 47.610, -122.201
- )
- s = sun(city.observer, date=now, tzinfo=pytz.timezone("US/Pacific"))
- sunrise_mod = datetime_utils.minute_number(s["sunrise"].hour, s["sunrise"].minute)
- sunset_mod = datetime_utils.minute_number(s["sunset"].hour, s["sunset"].minute)
- now_mod = datetime_utils.minute_number(now.hour, now.minute)
- if now_mod < sunrise_mod or now_mod > (sunset_mod + 45):
- return "E6B8B8"
- elif now_mod < (sunrise_mod + 45) or now_mod > (sunset_mod + 120):
- return "EECDCD"
- else:
- return "FFFFFF"
-
- def get_refresh_period() -> float:
- if override_refresh_sec is not None:
- return float(override_refresh_sec * 1000.0)
- now = datetime.now()
- if now.hour < 7:
- return float(constants.refresh_period_night_sec * 1000.0)
- else:
- return float(constants.refresh_period_sec * 1000.0)
-
- age = file_utils.describe_file_ctime(f"pages/{filename}")
- bgcolor = pick_background_color()
- if command is None:
- pageid = filename
- else:
- pageid = f'"{command}" -> {filename}'
-
- f.write(
- """
-<HEAD>
- <TITLE>Kitchen Kiosk</TITLE>
- <LINK rel="stylesheet" type="text/css" href="style.css">
- <SCRIPT TYPE="text/javascript">
-
- // Zoom the 'contents' div to fit without scrollbars and then make
- // it visible.
- function zoomScreen() {
- z = 285;
- do {
- document.getElementById("content").style.zoom = z+"%%";
- var body = document.body;
- var html = document.documentElement;
- var height = Math.max(body.scrollHeight,
- body.offsetHeight,
- html.clientHeight,
- html.scrollHeight,
- html.offsetHeight);
- var windowHeight = window.innerHeight;
- var width = Math.max(body.scrollWidth,
- body.offsetWidth,
- html.clientWidth,
- html.scrollWidth,
- html.offsetWidth);
- var windowWidth = window.innerWidth;
- var heightRatio = height / windowHeight;
- var widthRatio = width / windowWidth;
-
- if (heightRatio <= 1.0 && widthRatio <= 1.0) {
- break;
- }
- z -= 4;
- } while(z >= 70);
- document.getElementById("content").style.visibility = "visible";
- }
-
- // Load IMG tags with DATA-SRC attributes late.
- function lateLoadImages() {
- var image = document.getElementsByTagName('img');
- for (var i = 0; i < image.length; i++) {
- if (image[i].getAttribute('DATA-SRC')) {
- image[i].setAttribute('SRC', image[i].getAttribute('DATA-SRC'));
- }
- }
- }
-
- // Operate the clock at the top of the page.
- function runClock() {
- var today = new Date();
- var h = today.getHours();
- var ampm = h >= 12 ? 'pm' : 'am';
- h = h %% 12;
- h = h ? h : 12; // the hour '0' should be '12'
- var m = maybeAddZero(today.getMinutes());
- var colon = ":";
- if (today.getSeconds() %% 2 == 0) {
- colon = "<FONT STYLE='color: #%s; font-size: 4vmin; font-weight: bold'>:</FONT>";
- }
- document.getElementById("time").innerHTML = h + colon + m + ampm;
- document.getElementById("date").innerHTML = today.toDateString();
- var t = setTimeout(function(){runClock()}, 1000);
- }
-
- // Helper method for running the clock.
- function maybeAddZero(x) {
- return (x < 10) ? "0" + x : x;
- }
-
- // Do something on page load.
- function addLoadEvent(func) {
- var oldonload = window.onload;
- if (typeof window.onload != 'function') {
- window.onload = func;
- } else {
- window.onload = function() {
- if (oldonload) {
- oldonload();
- }
- func();
- }
- }
- }
-
- // Sleep thread helper.
- const sleep = (milliseconds) => {
- return new Promise(resolve => setTimeout(resolve, milliseconds))
- }
-
- var loadedDate = new Date();
-
- addLoadEvent(zoomScreen);
- addLoadEvent(runClock);
- addLoadEvent(lateLoadImages);
-
- // Runs the countdown line at the bottom and is responsible for
- // normal page reloads caused by the expiration of a timer.
- (function countdown() {
- setTimeout(
- function() {
- var now = new Date();
- var deltaMs = now.getTime() - loadedDate.getTime();
- var totalMs = %d;
- var remainingMs = (totalMs - deltaMs);
-
- if (remainingMs > 0) {
- var hr = document.getElementById("countdown");
- var width = (remainingMs / (totalMs - 5000)) * 100.0;
- if (width <= 100) {
- hr.style.visibility = "visible";
- hr.style.width = " ".concat(width, "%%");
- hr.style.backgroundColor = "maroon";
- }
- } else {
- // Reload unconditionally after 22 sec.
- window.location.reload();
- }
-
- // Brief sleep before doing it all over again.
- sleep(50).then(() => {
- countdown();
- });
- }, 50)
- })();
-
- // Periodically checks for emergency reload events.
- (function poll() {
- setTimeout(
- function() {
- var xhr = new XMLHttpRequest();
- xhr.open('GET',
- 'http://%s/kiosk/pages/reload_immediately.html');
- xhr.onload =
- function() {
- if (xhr.status === 200) {
- window.location.reload();
- } else {
- sleep(500).then(() => {
- poll();
- });
- }
- };
- xhr.send();
- }, 500);
- })();
-</SCRIPT>
-</HEAD>
-<BODY BGCOLOR="#%s">
- <TABLE style="height:100%%; width:100%%" BORDER=0>
- <TR HEIGHT=30>
- <TD ALIGN="left">
- <DIV id="date"> </DIV>
- </TD>
- <TD ALIGN="center"><FONT COLOR=#bbbbbb>
- <DIV id="info"></DIV></FONT>
- </TD>
- <TD ALIGN="right">
- <DIV id="time"> </DIV>
- </TD>
- </TR>
- <TR STYLE="vertical-align:top">
- <TD COLSPAN=3>
- <DIV ID="content" STYLE="zoom: 1; visibility: hidden;">
- <!-- BEGIN main page contents. -->
-<!--#include virtual=\"%s\"-->
- <!-- END main page contents. -->
- </DIV>
- <BR>
- <DIV STYLE="position: absolute; top:1030px; width:99%%">
- <P ALIGN="right">
- <FONT SIZE=2 COLOR=#bbbbbb>%s @ %s ago.</FONT>
- </P>
- <HR id="countdown" STYLE="width:0px;
- text-align:left;
- margin:0;
- border:none;
- border-width:0;
- height:5px;
- visibility:hidden;
- background-color:#ffffff;">
- </DIV>
- </TD>
- </TR>
- </TABLE>
-</BODY>"""
- % (
- bgcolor,
- get_refresh_period(),
- constants.hostname,
- bgcolor,
- filename,
- pageid,
- age,
- )
- )
-
-
-def thread_invoke_renderers() -> None:
- while True:
- print(f"renderer[{datetime_utils.timestamp()}]: invoking all renderers in catalog...")
- for r in renderer_catalog.get_renderers():
- now = time.time()
- try:
- r.render()
- except Exception as e:
- traceback.print_exc(file=sys.stdout)
- print(
- f"renderer[{datetime_utils.timestamp()}] unknown exception ({e}) in {r.get_name()}, swallowing it."
- )
- delta = time.time() - now
- if delta > 1.0:
- print(
- f"renderer[{datetime_utils.timestamp()}]: Warning: {r.get_name()}'s rendering took {delta:5.2f}s."
- )
- print(
- f"renderer[{datetime_utils.timestamp()}]: thread having a little break for {constants.render_period_sec}s..."
- )
- time.sleep(constants.render_period_sec)
-
-
-if __name__ == "__main__":
- logging.basicConfig()
- command_queue: Queue = Queue()
- changer_thread: Optional[Thread] = None
- renderer_thread: Optional[Thread] = None
- janitor_thread: Optional[Thread] = None
- hotword_thread: Optional[Thread] = None
- while True:
- if hotword_thread is None or not hotword_thread.is_alive():
- keyword_paths = [pvporcupine.KEYWORD_PATHS[x] for x in ["bumblebee"]]
- sensitivities = [0.7] * len(keyword_paths)
- listener = listen.HotwordListener(
- command_queue,
- keyword_paths,
- sensitivities,
- )
- hotword_thread = Thread(target=listener.listen_forever, args=())
- hotword_thread.start()
- if changer_thread is None or not changer_thread.is_alive():
- print(
- f"MAIN[{datetime_utils.timestamp()}] - (Re?)initializing chooser thread... (wtf?!)"
- )
- changer_thread = Thread(target=thread_change_current, args=(command_queue,))
- changer_thread.start()
- if renderer_thread is None or not renderer_thread.is_alive():
- print(
- f"MAIN[{datetime_utils.timestamp()}] - (Re?)initializing render thread... (wtf?!)"
- )
- renderer_thread = Thread(target=thread_invoke_renderers, args=())
- renderer_thread.start()
- if janitor_thread is None or not janitor_thread.is_alive():
- print(
- f"MAIN[{datetime_utils.timestamp()}] - (Re?)initializing janitor thread... (wtf?!)"
- )
- janitor_thread = Thread(target=thread_janitor, args=())
- janitor_thread.start()
- time.sleep(60)
+++ /dev/null
-#!/usr/bin/env python3
-
-from aiohttp import ClientSession
-import asyncio
-import datetime
-from dateutil.parser import parse
-import pymyq # type: ignore
-from typing import Dict, Optional
-
-from pyutils.datetimes import datetime_utils
-
-import kiosk_constants
-import file_writer
-import renderer
-import kiosk_secrets as secrets
-
-
-class garage_door_renderer(renderer.abstaining_renderer):
- def __init__(self, name_to_timeout_dict: Dict[str, int]) -> None:
- super().__init__(name_to_timeout_dict)
- self.doors: Optional[Dict] = None
- self.last_update: Optional[datetime.datetime] = None
-
- def debug_prefix(self) -> str:
- return "myq"
-
- def periodic_render(self, key: str) -> bool:
- if key == "Poll MyQ":
- self.last_update = datetime.datetime.now()
- return asyncio.run(self.poll_myq())
- elif key == "Update Page":
- return self.update_page()
- else:
- raise Exception("Unknown operaiton")
-
- async def poll_myq(self) -> bool:
- async with ClientSession() as websession:
- myq = await pymyq.login(
- secrets.myq_username, secrets.myq_password, websession
- )
- self.doors = myq.devices
- assert(self.doors is not None)
- return len(self.doors) > 0
-
- def update_page(self) -> bool:
- with file_writer.file_writer(kiosk_constants.myq_pagename) as f:
- f.write(
- f"""
-<H1>Garage Door Status</H1>
-<!-- Last updated at {self.last_update} -->
-<HR>
-<TABLE BORDER=0 WIDTH=99%>
- <TR>
-"""
- )
- html = self.do_door("Near House")
- if html is None:
- return False
- f.write(html)
-
- html = self.do_door("Middle Door")
- if html is None:
- return False
- f.write(html)
- f.write(
- """
- </TR>
-</TABLE>"""
- )
- return True
-
- def get_state_icon(self, state: str) -> str:
- if state == "open":
- return "/kiosk/images/garage_open.png"
- elif state == "closed":
- return "/kiosk/images/garage_closed.png"
- elif state == "opening":
- return "/kiosk/images/garage_opening.png"
- elif state == "closing":
- return "/kiosk/images/garage_closing.png"
- else:
- return str(state) + ", an unknown state for the door."
-
- def do_door(self, name: str) -> Optional[str]:
- if self.doors is None:
- return None
- for key in self.doors:
- door = self.doors[key]
- if door.name == name:
- j = self.doors[key].device_json
- state = j["state"]["door_state"]
-
- # "last_update": "2020-07-04T18:11:34.2981419Z"
- raw = j["state"]["last_update"]
- ts = parse(raw)
- tz_info = ts.tzinfo
- now = datetime.datetime.now(tz_info)
- delta = (now - ts).total_seconds()
- now = datetime.datetime.now()
- is_night = now.hour <= 7 or now.hour >= 21
- duration = datetime_utils.describe_duration_briefly(int(delta))
- width = 0
- if is_night and door.state == "open":
- color = "border-color: #ff0000;"
- width = 15
- else:
- color = ""
- width = 0
- return f"""
-<TD WIDTH=49%>
- <CENTER>
- <FONT STYLE="font-size:26pt">{name}<BR>
- <IMG SRC="{self.get_state_icon(state)}"
- HEIGHT=250
- STYLE="border-style: solid; border-width: {width}px; {color}">
- <BR>
- <B>{state}</B></FONT><BR>
- for {duration}
- </CENTER>
-</TD>"""
- return None
-
-
-# Test
-#x = garage_door_renderer({"Test": 1})
-#x.periodic_render("Poll MyQ")
-#x.periodic_render("Update Page")
--- /dev/null
+#!/usr/bin/env python3
+
+import datetime
+import logging
+import json
+from dateutil.parser import parse
+from typing import Dict, Optional
+
+import requests
+from pyutils.datetimes import datetime_utils
+
+import file_writer
+import globals
+import kiosk_constants
+import kiosk_secrets as secrets
+import renderer
+
+
+logger = logging.getLogger(__name__)
+
+
+class garage_door_renderer(renderer.abstaining_renderer):
+ def __init__(self, name_to_timeout_dict: Dict[str, int]) -> None:
+ super().__init__(name_to_timeout_dict)
+ self.last_update: Optional[datetime.datetime] = None
+ self.doors = {
+ "cover.ratgdo_middle_door_door": {"state": "unknown"},
+ "cover.ratgdo_near_house_door": {"state": "unknown"},
+ "cover.ratgdo_shop_door": {"state": "unknown"},
+ }
+
+ def debug_prefix(self) -> str:
+ return "ratago"
+
+ def periodic_render(self, key: str) -> bool:
+ if key == "Poll Home Assistant":
+ return self.poll_home_assistant()
+ elif key == "Update Page":
+ return self.update_page()
+ else:
+ raise Exception("Unknown operaiton")
+
+ def poll_home_assistant(self) -> bool:
+ key = secrets.HOMEASSISTANT_API_KEY
+ headers = {
+ "Authorization": f"Bearer {key}",
+ "Content-Type": "application/json",
+ }
+ for door in self.doors.keys():
+ try:
+ r = requests.get(
+ f"https://home.acknak.org/api/states/{door}",
+ headers=headers,
+ timeout=3.0,
+ )
+ if r.ok:
+ j = json.loads(r.content.decode())
+ logger.debug(j)
+ self.doors[door] = j
+ else:
+ logger.warning("Unable to get state of garage door {door}, using 'unknown'")
+ except Exception:
+ logger.exception("Unable to get state of garage door {door}, using 'unknown'")
+ self.last_update = datetime_utils.now_pacific()
+ return True
+
+ def update_page(self) -> bool:
+ with file_writer.file_writer(kiosk_constants.ratago_pagename) as f:
+ f.write(
+ f"""
+<H1>Garage Door Status</H1>
+<!-- Last updated at {self.last_update} -->
+<HR>
+<TABLE BORDER=0 WIDTH=99%>
+ <TR>
+"""
+ )
+
+ html = self.do_door("cover.ratago_near_house_door")
+ if html is None:
+ return False
+ f.write(html)
+
+ html = self.do_door("cover.ratago_middle_door_door")
+ if html is None:
+ return False
+ f.write(html)
+
+ html = self.do_door("cover.ratago_shop_door")
+ if html is None:
+ return False
+ f.write(html)
+ f.write(
+ """
+ </TR>
+</TABLE>"""
+ )
+ return True
+
+ def get_state_icon(self, state: str) -> str:
+ if state == "open":
+ return "/kiosk/images/garage_open.png"
+ elif state == "closed":
+ return "/kiosk/images/garage_closed.png"
+ elif state == "opening":
+ return "/kiosk/images/garage_opening.png"
+ elif state == "closing":
+ return "/kiosk/images/garage_closing.png"
+ else:
+ return str(state) + ", an unknown state for the door."
+
+ def do_door(self, name: str) -> Optional[str]:
+ friendly_door_names = {
+ "cover.ratgdo_middle_door_door": "Middle Door",
+ "cover.ratgdo_near_house_door": "Near House Door",
+ "cover.ratgdo_shop_door": "Workshop Door",
+ }
+ if self.doors is None:
+ return None
+
+ friendly_name = friendly_door_names.get(name, "unknown")
+ attributes = self.doors.get(name, {"state": "unknown"})
+ state = attributes.get("state", "unknown").lower()
+ since = attributes.get("last_changed", "unknown").lower()
+
+ # "last_update": "2020-07-04T18:11:34.2981419Z"
+ ts = parse(since)
+ tz_info = ts.tzinfo
+ now = datetime.datetime.now(tz_info)
+ delta = (now - ts).total_seconds()
+ duration = datetime_utils.describe_duration_briefly(int(delta))
+
+ now = datetime.datetime.now()
+ is_night = now.hour <= 7 or now.hour >= 21
+ width = 0
+ if is_night and state == "open":
+ color = "border-color: #ff0000;"
+ width = 15
+ globals.put("ratago_triggered", True)
+ else:
+ color = ""
+ width = 0
+ globals.put("ratago_triggered", False)
+ return f"""
+<TD WIDTH=32%>
+ <CENTER>
+ <FONT STYLE="font-size:26pt">{friendly_name}<BR>
+ <IMG SRC="{self.get_state_icon(state)}"
+ HEIGHT=250
+ STYLE="border-style: solid; border-width: {width}px; {color}">
+ <BR>
+ <B>{state}</B></FONT><BR>
+ for {duration}
+ </CENTER>
+</TD>"""
+
+
+# Test
+#x = garage_door_renderer({"Test": 1})
+#x.periodic_render("Poll MyQ")
+#x.periodic_render("Update Page")