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'{value}'
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' {html.escape(location)} · {html.escape(value)}{html.escape(item["type"].title())}: {html.escape(item["name"])}
'
f'{html.escape(item["name"])}
No groups collected yet.
'}No sensors collected yet.
'}''' 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()