Files
service_hue_collector/hue_collector/app.py
T

112 lines
5.5 KiB
Python
Raw Normal View History

2026-09-06 15:31:57 +02:00
from __future__ import annotations
import logging
import os
import threading
import time
2026-09-06 15:53:58 +02:00
from io import BytesIO
from flask import Flask, Response, abort, render_template_string, request, send_file, url_for
2026-09-06 15:31:57 +02:00
from .collector import Collector, HueClient, Settings
from .rrd_store import RRDStore
LOG = logging.getLogger("hue-collector")
2026-09-06 15:53:58 +02:00
PERIODS = ("6h", "24h", "7d", "30d")
PAGE = '''<!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}.error{color:#fca5a5}img{display:block;width:100%;height:auto}
</style></head><body><header><div><h1>Philips Hue</h1><div class="status">{{ status }}{% if error %} — <span class="error">{{ error }}</span>{% endif %}</div></div>
<nav>{% for value in periods %}<a class="{{ 'active' if value == period else '' }}" href="{{ url_for('dashboard', range=value) }}">{{ value }}</a>{% endfor %}</nav></header>
<h1>Rooms and zones</h1>{% for item in groups %}<section><h2>{{ item.type|title }}: {{ item.name }}</h2>
<img loading="lazy" src="{{ url_for('graph', kind='group', item_id=item.id, range=period) }}" alt="Light history"></section>
{% else %}<p>No groups collected yet.</p>{% endfor %}
<h1>Sensors</h1>{% for item in sensors %}<section><h2>{{ item.name }}</h2>
<p>{{ item.room or item.zones or 'No room' }} · {% if item.value is none %}unavailable{% else %}{{ '%.1f'|format(item.value) }} {{ item.unit }}{% endif %}</p>
<img loading="lazy" src="{{ url_for('graph', kind='sensor', item_id=item.id, range=period) }}" alt="Sensor history"></section>
{% else %}<p>No sensors collected yet.</p>{% endfor %}</body></html>'''
2026-09-06 15:31:57 +02:00
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)))
2026-09-06 15:53:58 +02:00
def create_app(settings: Settings | None = None, *, start_collector: bool = True) -> Flask:
settings = settings or Settings.from_env()
app = Flask(__name__)
state = State()
store = RRDStore(settings.data_dir, settings.interval)
app.extensions["hue_state"] = state
app.extensions["hue_rrd_store"] = store
if start_collector:
collector = Collector(HueClient(settings))
threading.Thread(target=collection_loop, args=(collector, store, state, settings.interval), daemon=True).start()
@app.get("/")
def dashboard():
period = request.args.get("range", "24h")
if period not in PERIODS:
period = "24h"
with state.lock:
groups = sorted(state.snapshot["groups"], key=lambda item: (item["type"], item["name"].lower()))
sensors = sorted(state.snapshot["sensors"], key=lambda item: (item["type"], item["name"].lower()))
last_success, error = state.last_success, state.error
status = time.strftime("Last update: %Y-%m-%d %H:%M:%S", time.localtime(last_success)) if last_success else "Waiting for first collection"
return render_template_string(PAGE, groups=groups, sensors=sensors, status=status, error=error, periods=PERIODS, period=period)
@app.get("/graph/<kind>/<item_id>.png")
def graph(kind: str, item_id: str):
if kind not in {"group", "sensor"}:
abort(404)
with state.lock:
collection = state.snapshot["groups" if kind == "group" else "sensors"]
item = next((value for value in collection if value["id"] == item_id), None)
if item is None:
abort(404)
try:
image = store.graph(kind, item, request.args.get("range", "24h"))
except (ValueError, FileNotFoundError):
abort(404)
return send_file(BytesIO(image), mimetype="image/png", max_age=30)
@app.get("/healthz")
def health():
with state.lock:
healthy = state.last_success is not None
return Response("ok\n" if healthy else "not ready\n", status=200 if healthy else 503, mimetype="text/plain")
return app
2026-09-06 15:31:57 +02:00
def main() -> None:
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
settings = Settings.from_env()
2026-09-06 15:53:58 +02:00
app = create_app(settings)
2026-09-06 15:31:57 +02:00
LOG.info("Serving Hue dashboard on http://%s:%s", settings.listen_host, settings.listen_port)
2026-09-06 15:53:58 +02:00
app.run(host=settings.listen_host, port=settings.listen_port, debug=False, use_reloader=False)