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 = '''
+ +No groups collected yet.
{% endfor %} +{{ item.room or item.zones or 'No room' }} · {% if item.value is none %}unavailable{% else %}{{ '%.1f'|format(item.value) }} {{ item.unit }}{% endif %}
+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(location)} · {html.escape(value)}
' - f'No groups collected yet.
'} -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/