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 }}{ % i f error %} — <span class="error">{{ error }}</span>{ % e ndif %}</div></div>
<nav>{ % f or value in periods %}<a class="{{ 'active' if value == period else '' }}" href="{{ url_for('dashboard', range=value) }}">{{ value }}</a>{ % e ndfor %}</nav></header>
<h1>Rooms and zones</h1>{ % f or 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>
{ % e lse %}<p>No groups collected yet.</p>{ % e ndfor %}
<h1>Sensors</h1>{ % f or item in sensors %}<section><h2>{{ item.name }}</h2>
<p>{{ item.room or item.zones or 'No room' }} · { % i f item.value is none %}unavailable{ % e lse %}{{ ' %.1f '|format(item.value) }} {{ item.unit }}{ % e ndif %}</p>
<img loading="lazy" src="{{ url_for('graph', kind='sensor', item_id=item.id, range=period) }}" alt="Sensor history"></section>
{ % e lse %}<p>No sensors collected yet.</p>{ % e ndfor %}</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 )