diff --git a/.env.example b/.env.example index a2096cd..ebe448a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ HUE_BRIDGE_HOST=192.168.1.2 -HUE_APPLICATION_KEY=replace-with-your-hue-application-key +HUE_APPLICATION_KEY=130987023498cb710109387401983427 HUE_VERIFY_TLS=false COLLECT_INTERVAL_SECONDS=30 LOG_LEVEL=INFO -PUBLISH_ADDRESS=0.0.0.0 +LISTEN_HOST=0.0.0.0 +LISTEN_PORT=8000 +DATA_DIR=data diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index da666ab..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM debian:bookworm-slim - -RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 python3-requests python3-rrdtool fonts-dejavu-core ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -COPY hue_collector ./hue_collector - -EXPOSE 8000 -CMD ["python3", "-m", "hue_collector"] diff --git a/README.md b/README.md index 4ea105e..8137b64 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ -# Lightweight Hue history +# Hue RRD Flask dashboard -A small service for Raspberry Pi that reads the Philips Hue v2 API, stores history in -fixed-size [RRDtool](https://oss.oetiker.ch/rrdtool/) databases, and serves its own web -dashboard. It does not require Grafana, VictoriaMetrics, Node.js, or a browser-side -charting library. +A lightweight Flask application for Raspberry Pi that reads the Philips Hue v2 API, +stores history in fixed-size RRDtool databases, and serves a local web dashboard. The dashboard contains: - One graph for every room and zone. Unavailable, off, and on lights form a stacked percentage area, with average intensity of shining lights drawn as a line. -- Temperature and illuminance graphs for every available Hue sensor. +- Temperature and illuminance graphs for every Hue sensor. - 6-hour, 24-hour, 7-day, and 30-day time ranges. -RRD storage is bounded automatically: raw samples are retained for 7 days, +RRD storage remains bounded automatically: raw samples are retained for 7 days, approximately five-minute averages for 90 days, and hourly averages for two years. -## Configure Hue +## Install on Raspberry Pi OS or Debian -Find the bridge IP in the Hue app under **Settings → My Hue system → System -information**. Then create an application key while physically near the bridge: +Install the native RRDtool binding and Python dependencies: + +```bash +sudo apt update +sudo apt install python3 python3-flask python3-requests python3-rrdtool fonts-dejavu-core +``` + +Create the Hue application key if you do not have one yet. Press the bridge link +button immediately before running: ```bash curl -k -X POST https://BRIDGE_IP/api \ @@ -26,52 +31,72 @@ curl -k -X POST https://BRIDGE_IP/api \ -d '{"devicetype":"local-hue-collector"}' ``` -Press the bridge link button immediately before running the command. The returned -`username` is the application key. +The returned `username` is the application key. -## Run with Docker Compose +Configure and run the Flask application from the project directory: ```bash cp .env.example .env # Edit .env with the bridge IP and application key. -docker compose up --build -d --remove-orphans -``` - -Open `http://PI_ADDRESS:8000`. RRD data persists in the `hue-rrd-data` Docker volume. -The `--remove-orphans` option removes containers from the previous -VictoriaMetrics/Grafana version; their old named volumes are not deleted. - -By default, port 8000 binds to all interfaces. To bind only to the Pi's LAN interface, -put its exact address in `.env`, for example: - -```env -PUBLISH_ADDRESS=192.168.178.42 -``` - -## Run directly on Raspberry Pi OS/Debian - -This avoids Docker overhead entirely: - -```bash -sudo apt update -sudo apt install python3 python3-requests python3-rrdtool fonts-dejavu-core -mkdir -p "$HOME/.local/share/hue-collector" -export HUE_BRIDGE_HOST=192.168.178.2 -export HUE_APPLICATION_KEY=your-key -export DATA_DIR="$HOME/.local/share/hue-collector" +set -a +. ./.env +set +a python3 -m hue_collector ``` -The page is served on port 8000. For continuous operation, run it with systemd or use -the Compose setup with `restart: unless-stopped`. +Open `http://PI_ADDRESS:8000`. The RRD files are written to `DATA_DIR`, which defaults +to the project's `data/` directory. -TLS verification is disabled by default because Hue bridges normally use a -self-signed certificate. Set `HUE_VERIFY_TLS=true` if the bridge has a trusted -certificate. The health check is available at `/healthz`. +## Run continuously with systemd + +Install the project and environment file: + +```bash +sudo install -d /opt/hue-collector +sudo cp -r hue_collector /opt/hue-collector/ +sudo cp .env /etc/hue-collector +sudo chmod 600 /etc/hue-collector +sudo cp hue-collector.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now hue-collector +``` + +The included unit uses a restricted dynamic service account and stores RRD files in +`/var/lib/hue-collector`. Inspect it with: + +```bash +systemctl status hue-collector +journalctl -u hue-collector -f +``` + +After updating the application files, restart it with: + +```bash +sudo systemctl restart hue-collector +``` + +## Configuration + +- `HUE_BRIDGE_HOST`: Hue bridge IP or hostname; required. +- `HUE_APPLICATION_KEY`: Hue v2 application key; required. +- `HUE_VERIFY_TLS`: verify the bridge certificate; defaults to `false` because Hue + bridges normally use a self-signed certificate. +- `COLLECT_INTERVAL_SECONDS`: sampling interval; defaults to 30 seconds. Changing it + after RRD files have been created does not change those existing files' step. +- `LISTEN_HOST`: Flask bind address; defaults to `0.0.0.0` for LAN access. +- `LISTEN_PORT`: dashboard port; defaults to `8000`. +- `DATA_DIR`: RRD directory; defaults to `data`. + +The application is intended for a trusted home network and does not provide +authentication or TLS. Restrict access with the Pi firewall if the network is not +trusted. ## Development -The collector tests do not require RRDtool: +On a Debian-based machine, install `python3-rrdtool` through apt. Flask and Requests +can alternatively be installed from `requirements.txt`. When using a virtual +environment, create it with `--system-site-packages` so it can see the apt-installed +RRDtool module. ```bash python3 -m unittest discover -s tests diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 64bfe78..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - hue-collector: - build: . - restart: unless-stopped - environment: - HUE_BRIDGE_HOST: ${HUE_BRIDGE_HOST:-} - HUE_APPLICATION_KEY: ${HUE_APPLICATION_KEY:-} - HUE_VERIFY_TLS: ${HUE_VERIFY_TLS:-false} - COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-30} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - DATA_DIR: /data - volumes: - - hue-rrd-data:/data - ports: - - "${PUBLISH_ADDRESS:-0.0.0.0}:8000:8000" - healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"] - interval: 30s - timeout: 5s - retries: 3 - -volumes: - hue-rrd-data: diff --git a/hue-collector.service b/hue-collector.service new file mode 100644 index 0000000..874ad4c --- /dev/null +++ b/hue-collector.service @@ -0,0 +1,21 @@ +[Unit] +Description=Philips Hue RRD collector and dashboard +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +DynamicUser=yes +StateDirectory=hue-collector +WorkingDirectory=/srv/service_hue_collector +EnvironmentFile=/srv/service_hue_collector +Environment=DATA_DIR=/var/lib/hue-collector +ExecStart=/var/lib/hue-collector/.venv/bin/python3 -m hue_collector +Restart=on-failure +RestartSec=5 +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/hue_collector/app.py b/hue_collector/app.py index 2493630..b0e642e 100644 --- a/hue_collector/app.py +++ b/hue_collector/app.py @@ -1,17 +1,35 @@ 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 io import BytesIO + +from flask import Flask, Response, abort, render_template_string, request, send_file, url_for from .collector import Collector, HueClient, Settings from .rrd_store import RRDStore LOG = logging.getLogger("hue-collector") +PERIODS = ("6h", "24h", "7d", "30d") + +PAGE = ''' + +Hue history

Philips Hue

{{ status }}{% if error %} — {{ error }}{% endif %}
+
+

Rooms and zones

{% for item in groups %}

{{ item.type|title }}: {{ item.name }}

+Light history
+{% else %}

No groups collected yet.

{% endfor %} +

Sensors

{% for item in sensors %}

{{ item.name }}

+

{{ item.room or item.zones or 'No room' }} · {% if item.value is none %}unavailable{% else %}{{ '%.1f'|format(item.value) }} {{ item.unit }}{% endif %}

+Sensor history
+{% else %}

No sensors collected yet.

{% endfor %}''' class State: @@ -37,93 +55,57 @@ def collection_loop(collector: Collector, store: RRDStore, state: State, interva 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 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() -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) + @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) - def log_message(self, fmt, *args): - LOG.debug(fmt, *args) + @app.get("/graph//.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) - return Handler + @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 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() + app = create_app(settings) 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() + app.run(host=settings.listen_host, port=settings.listen_port, debug=False, use_reloader=False) diff --git a/hue_collector/collector.py b/hue_collector/collector.py index 834f69e..7cb4a53 100644 --- a/hue_collector/collector.py +++ b/hue_collector/collector.py @@ -16,7 +16,7 @@ class Settings: interval: int = 30 listen_host: str = "0.0.0.0" listen_port: int = 8000 - data_dir: str = "/data" + data_dir: str = "data" @classmethod def from_env(cls) -> "Settings": @@ -31,7 +31,7 @@ class Settings: interval=int(os.environ.get("COLLECT_INTERVAL_SECONDS", "30")), listen_host=os.environ.get("LISTEN_HOST", "0.0.0.0"), listen_port=int(os.environ.get("LISTEN_PORT", "8000")), - data_dir=os.environ.get("DATA_DIR", "/data"), + data_dir=os.environ.get("DATA_DIR", "data"), ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7dedeaa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Install the native RRDtool binding with: apt install python3-rrdtool +Flask>=2.2,<4 +requests>=2,<3