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(item["type"].title())}: {html.escape(item["name"])}

' f'Light history
' ) 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'

{html.escape(item["name"])}

{html.escape(location)} · {html.escape(value)}

' f'Sensor history
' ) document = f''' Hue history

Philips Hue

{status}

Rooms and zones

{''.join(group_cards) or '

No groups collected yet.

'}

Sensors

{''.join(sensor_cards) or '

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()