130 lines
6.5 KiB
Python
130 lines
6.5 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import html
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||
|
|
|
||
|
|
from .collector import Collector, HueClient, Settings
|
||
|
|
from .rrd_store import RRDStore
|
||
|
|
|
||
|
|
LOG = logging.getLogger("hue-collector")
|
||
|
|
|
||
|
|
|
||
|
|
class State:
|
||
|
|
def __init__(self):
|
||
|
|
self.snapshot = {"groups": [], "sensors": []}
|
||
|
|
self.last_success: float | None = None
|
||
|
|
self.error: str | None = None
|
||
|
|
self.lock = threading.Lock()
|
||
|
|
|
||
|
|
|
||
|
|
def collection_loop(collector: Collector, store: RRDStore, state: State, interval: int) -> None:
|
||
|
|
while True:
|
||
|
|
started = time.monotonic()
|
||
|
|
try:
|
||
|
|
snapshot = collector.collect()
|
||
|
|
store.update(snapshot)
|
||
|
|
with state.lock:
|
||
|
|
state.snapshot, state.last_success, state.error = snapshot, time.time(), None
|
||
|
|
except Exception as exc:
|
||
|
|
LOG.exception("Hue collection failed")
|
||
|
|
with state.lock:
|
||
|
|
state.error = str(exc)
|
||
|
|
time.sleep(max(1, interval - (time.monotonic() - started)))
|
||
|
|
|
||
|
|
|
||
|
|
def page(state: State, period: str) -> bytes:
|
||
|
|
with state.lock:
|
||
|
|
snapshot = state.snapshot
|
||
|
|
last_success = state.last_success
|
||
|
|
error = state.error
|
||
|
|
status = f"Last update: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_success))}" if last_success else "Waiting for first collection"
|
||
|
|
if error:
|
||
|
|
status += f" — Error: {html.escape(error)}"
|
||
|
|
controls = " ".join(
|
||
|
|
f'<a class="{("active" if value == period else "")}" href="/?range={value}">{value}</a>'
|
||
|
|
for value in ("6h", "24h", "7d", "30d")
|
||
|
|
)
|
||
|
|
group_cards = []
|
||
|
|
for item in sorted(snapshot["groups"], key=lambda value: (value["type"], value["name"].lower())):
|
||
|
|
query = urlencode({"kind": "group", "id": item["id"], "range": period})
|
||
|
|
group_cards.append(
|
||
|
|
f'<section><h2>{html.escape(item["type"].title())}: {html.escape(item["name"])}</h2>'
|
||
|
|
f'<img loading="lazy" src="/graph?{query}" alt="Light history"></section>'
|
||
|
|
)
|
||
|
|
sensor_cards = []
|
||
|
|
for item in sorted(snapshot["sensors"], key=lambda value: (value["type"], value["name"].lower())):
|
||
|
|
query = urlencode({"kind": "sensor", "id": item["id"], "range": period})
|
||
|
|
value = "unavailable" if item["value"] is None else f'{item["value"]:.1f} {item["unit"]}'
|
||
|
|
location = item["room"] or item["zones"] or "No room"
|
||
|
|
sensor_cards.append(
|
||
|
|
f'<section><h2>{html.escape(item["name"])}</h2><p>{html.escape(location)} · {html.escape(value)}</p>'
|
||
|
|
f'<img loading="lazy" src="/graph?{query}" alt="Sensor history"></section>'
|
||
|
|
)
|
||
|
|
document = f'''<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="60">
|
||
|
|
<title>Hue history</title><style>
|
||
|
|
:root{{color-scheme:dark;font-family:system-ui,sans-serif}}body{{margin:auto;max-width:1200px;padding:1rem;background:#0b1220;color:#e5e7eb}}
|
||
|
|
header{{display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap}}nav a{{padding:.45rem .7rem;color:#93c5fd;text-decoration:none}}
|
||
|
|
nav a.active{{background:#1d4ed8;color:white;border-radius:.35rem}}.status,p{{color:#9ca3af}}section{{background:#111827;border:1px solid #374151;border-radius:.6rem;padding:.75rem;margin:1rem 0}}
|
||
|
|
h1,h2{{margin:.25rem 0 .5rem}}h2{{font-size:1.1rem}}p{{margin:.25rem 0}}img{{display:block;width:100%;height:auto}}
|
||
|
|
</style></head><body><header><div><h1>Philips Hue</h1><div class="status">{status}</div></div><nav>{controls}</nav></header>
|
||
|
|
<h1>Rooms and zones</h1>{''.join(group_cards) or '<p>No groups collected yet.</p>'}
|
||
|
|
<h1>Sensors</h1>{''.join(sensor_cards) or '<p>No sensors collected yet.</p>'}</body></html>'''
|
||
|
|
return document.encode()
|
||
|
|
|
||
|
|
|
||
|
|
def handler_for(state: State, store: RRDStore):
|
||
|
|
class Handler(BaseHTTPRequestHandler):
|
||
|
|
def do_GET(self):
|
||
|
|
parsed = urlparse(self.path)
|
||
|
|
query = parse_qs(parsed.query)
|
||
|
|
if parsed.path == "/":
|
||
|
|
body, status, content_type = page(state, query.get("range", ["24h"])[0]), 200, "text/html; charset=utf-8"
|
||
|
|
elif parsed.path == "/healthz":
|
||
|
|
with state.lock:
|
||
|
|
healthy = state.last_success is not None
|
||
|
|
body, status, content_type = (b"ok\n", 200, "text/plain") if healthy else (b"not ready\n", 503, "text/plain")
|
||
|
|
elif parsed.path == "/graph":
|
||
|
|
try:
|
||
|
|
kind, item_id = query["kind"][0], query["id"][0]
|
||
|
|
if kind not in {"group", "sensor"}:
|
||
|
|
raise ValueError("unknown graph kind")
|
||
|
|
with state.lock:
|
||
|
|
collection = state.snapshot["groups" if kind == "group" else "sensors"]
|
||
|
|
item = next(value for value in collection if value["id"] == item_id)
|
||
|
|
body = store.graph(kind, item, query.get("range", ["24h"])[0])
|
||
|
|
status, content_type = 200, "image/png"
|
||
|
|
except (KeyError, ValueError, StopIteration, FileNotFoundError) as exc:
|
||
|
|
body, status, content_type = f"graph not found: {exc}\n".encode(), 404, "text/plain"
|
||
|
|
except Exception as exc:
|
||
|
|
LOG.exception("Graph rendering failed")
|
||
|
|
body, status, content_type = f"graph error: {exc}\n".encode(), 500, "text/plain"
|
||
|
|
else:
|
||
|
|
body, status, content_type = b"not found\n", 404, "text/plain"
|
||
|
|
self.send_response(status)
|
||
|
|
self.send_header("Content-Type", content_type)
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
|
||
|
|
def log_message(self, fmt, *args):
|
||
|
|
LOG.debug(fmt, *args)
|
||
|
|
|
||
|
|
return Handler
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
|
||
|
|
settings = Settings.from_env()
|
||
|
|
state = State()
|
||
|
|
store = RRDStore(settings.data_dir, settings.interval)
|
||
|
|
collector = Collector(HueClient(settings))
|
||
|
|
threading.Thread(target=collection_loop, args=(collector, store, state, settings.interval), daemon=True).start()
|
||
|
|
LOG.info("Serving Hue dashboard on http://%s:%s", settings.listen_host, settings.listen_port)
|
||
|
|
ThreadingHTTPServer((settings.listen_host, settings.listen_port), handler_for(state, store)).serve_forever()
|