diff --git a/.gitignore b/.gitignore index d8644e5..5a74c64 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ .pytest_cache/ *.py[cod] - +hue-response.json +*.rrd diff --git a/Dockerfile b/Dockerfile index a05ebf4..da666ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.13-slim +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 requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt COPY hue_collector ./hue_collector -USER 65532:65532 EXPOSE 8000 -CMD ["python", "-m", "hue_collector"] - +CMD ["python3", "-m", "hue_collector"] diff --git a/README.md b/README.md index e3bedb1..4ea105e 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,78 @@ -# Hue collector +# Lightweight Hue history -A local-only monitoring stack for a Philips Hue bridge. The Python service reads the -Hue v2 API, exposes Prometheus metrics, VictoriaMetrics stores them, and Grafana ships -with a provisioned 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. -## Metrics +The dashboard contains: -- For every room and zone: available, shining, off, and unavailable light counts; - percentages shining, off, and unavailable; plus average brightness of the shining - lights. -- For every temperature and illuminance sensor: availability and its latest reading, - labelled with its room and zones. +- 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. +- 6-hour, 24-hour, 7-day, and 30-day time ranges. -A device is available when its Hue `zigbee_connectivity` resource is connected. A -light is shining when it is available and reports `on=true`. If a device has no -connectivity resource (for example some bridge-owned resources), it is considered -available. +RRD storage is bounded automatically: raw samples are retained for 7 days, +approximately five-minute averages for 90 days, and hourly averages for two years. -## Setup +## Configure Hue -1. Find your bridge IP in the Hue app under **Settings → My Hue system → System - information**. -2. Create an application key while physically near the bridge: +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: - ```bash - curl -k -X POST https://BRIDGE_IP/api \ - -H 'Content-Type: application/json' \ - -d '{"devicetype":"local-hue-collector"}' - ``` +```bash +curl -k -X POST https://BRIDGE_IP/api \ + -H 'Content-Type: application/json' \ + -d '{"devicetype":"local-hue-collector"}' +``` - Press the bridge link button immediately before running the command. Copy the - returned `username`; that is the application key. -3. Configure and start the stack: +Press the bridge link button immediately before running the command. The returned +`username` is the application key. - ```bash - cp .env.example .env - # Edit .env with the bridge IP and application key. - docker compose up --build -d - ``` -4. Open Grafana at `http://HOST_LAN_IP:3000` and sign in with `admin` / `admin`. The - dashboard is in the **Hue** folder. Change this development password if the port - is reachable by anyone else on your network. +## Run with Docker Compose -Useful local endpoints: +```bash +cp .env.example .env +# Edit .env with the bridge IP and application key. +docker compose up --build -d --remove-orphans +``` -- Collector metrics: -- VictoriaMetrics UI: -- Grafana: +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, published ports bind to `0.0.0.0`, making them reachable through the -host's `192.168.178.x` address. Docker cannot bind to a wildcard subnet such as -`192.168.178.*`; to listen only on the LAN interface, set `PUBLISH_ADDRESS` in `.env` -to the host's exact address, for example `192.168.178.42`. Ensure your host firewall -permits access only from trusted networks. Named Docker volumes retain metrics and -Grafana state across restarts. TLS verification is off by default because Hue bridges -normally use a self-signed certificate; set `HUE_VERIFY_TLS=true` if yours has a -trusted certificate. +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" +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`. + +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`. ## Development +The collector tests do not require RRDtool: + ```bash -python -m venv .venv -. .venv/bin/activate -pip install -r requirements.txt -python -m unittest discover -s tests -HUE_BRIDGE_HOST=... HUE_APPLICATION_KEY=... python -m hue_collector +python3 -m unittest discover -s tests ``` diff --git a/config/grafana/dashboards/hue.json b/config/grafana/dashboards/hue.json deleted file mode 100644 index d533cd9..0000000 --- a/config/grafana/dashboards/hue.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "annotations": {"list": []}, - "editable": true, - "graphTooltip": 1, - "panels": [ - { - "type": "timeseries", "title": "$group_type: $group", "id": 1, - "repeat": "group", "repeatDirection": "v", - "gridPos": {"h": 10, "w": 24, "x": 0, "y": 0}, - "fieldConfig": { - "defaults": { - "decimals": 0, - "min": 0, - "custom": {"drawStyle": "bars", "barAlignment": 0, "fillOpacity": 80, "lineWidth": 1, "stacking": {"mode": "normal", "group": "lights"}} - }, - "overrides": [ - {"matcher": {"id": "byName", "options": "Unavailable"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#8b0000"}}]}, - {"matcher": {"id": "byName", "options": "Off"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#4b5563"}}]}, - {"matcher": {"id": "byName", "options": "On"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#facc15"}}]}, - {"matcher": {"id": "byName", "options": "Intensity"}, "properties": [ - {"id": "unit", "value": "percent"}, {"id": "max", "value": 100}, - {"id": "custom.axisPlacement", "value": "right"}, {"id": "custom.drawStyle", "value": "line"}, - {"id": "custom.fillOpacity", "value": 0}, {"id": "custom.lineWidth", "value": 3}, - {"id": "custom.stacking", "value": {"mode": "none", "group": "intensity"}}, - {"id": "color", "value": {"mode": "fixed", "fixedColor": "#22c55e"}} - ]} - ] - }, - "targets": [ - {"expr": "hue_group_lights_unavailable{group_type=\"$group_type\", group=~\"$group\"}", "legendFormat": "Unavailable", "refId": "A"}, - {"expr": "hue_group_lights_off{group_type=\"$group_type\", group=~\"$group\"}", "legendFormat": "Off", "refId": "B"}, - {"expr": "hue_group_lights_shining{group_type=\"$group_type\", group=~\"$group\"}", "legendFormat": "On", "refId": "C"}, - {"expr": "hue_group_average_brightness_percent{group_type=\"$group_type\", group=~\"$group\"}", "legendFormat": "Intensity", "refId": "D"} - ] - }, - { - "type": "timeseries", "title": "Temperature", "id": 3, - "gridPos": {"h": 8, "w": 12, "x": 0, "y": 10}, - "fieldConfig": {"defaults": {"unit": "celsius"}, "overrides": []}, - "targets": [{"expr": "hue_temperature_celsius", "legendFormat": "{{sensor}} ({{room}})", "refId": "A"}] - }, - { - "type": "timeseries", "title": "Light intensity", "id": 4, - "gridPos": {"h": 8, "w": 12, "x": 12, "y": 10}, - "fieldConfig": {"defaults": {"unit": "lux"}, "overrides": []}, - "targets": [{"expr": "hue_light_level_lux", "legendFormat": "{{sensor}} ({{room}})", "refId": "A"}] - }, - { - "type": "table", "title": "Unavailable sensors", "id": 5, - "gridPos": {"h": 7, "w": 24, "x": 0, "y": 18}, - "targets": [{"expr": "hue_sensor_available == 0", "format": "table", "instant": true, "refId": "A"}] - } - ], - "refresh": "30s", - "schemaVersion": 42, - "tags": ["hue"], - "templating": {"list": [ - { - "name": "group_type", "label": "Show", "type": "custom", - "query": "room,zone", "options": [ - {"selected": true, "text": "room", "value": "room"}, - {"selected": false, "text": "zone", "value": "zone"} - ], - "current": {"selected": true, "text": "room", "value": "room"} - }, - { - "name": "group", "label": "Room / zone", "type": "query", - "datasource": {"type": "prometheus", "uid": "victoriametrics"}, - "definition": "label_values(hue_group_lights_available{group_type=\"$group_type\"}, group)", - "query": {"query": "label_values(hue_group_lights_available{group_type=\"$group_type\"}, group)", "refId": "variable-group"}, - "refresh": 1, "sort": 1, "multi": true, "includeAll": true, - "allValue": ".+", "current": {"selected": true, "text": "All", "value": "$__all"} - } - ]}, - "time": {"from": "now-24h", "to": "now"}, - "timezone": "browser", - "title": "Philips Hue", - "uid": "philips-hue-local", - "version": 2 -} diff --git a/config/grafana/provisioning/dashboards/default.yml b/config/grafana/provisioning/dashboards/default.yml deleted file mode 100644 index 2da265d..0000000 --- a/config/grafana/provisioning/dashboards/default.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: 1 -providers: - - name: Hue - orgId: 1 - folder: Hue - type: file - disableDeletion: true - updateIntervalSeconds: 30 - options: - path: /var/lib/grafana/dashboards - diff --git a/config/grafana/provisioning/datasources/victoriametrics.yml b/config/grafana/provisioning/datasources/victoriametrics.yml deleted file mode 100644 index ba0fbab..0000000 --- a/config/grafana/provisioning/datasources/victoriametrics.yml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: 1 -datasources: - - name: VictoriaMetrics - uid: victoriametrics - type: prometheus - access: proxy - url: http://victoriametrics:8428 - isDefault: true - editable: false - diff --git a/config/victoriametrics/scrape.yml b/config/victoriametrics/scrape.yml deleted file mode 100644 index 33c1bd8..0000000 --- a/config/victoriametrics/scrape.yml +++ /dev/null @@ -1,8 +0,0 @@ -global: - scrape_interval: 30s - -scrape_configs: - - job_name: hue - static_configs: - - targets: [hue-collector:8000] - diff --git a/docker-compose.yml b/docker-compose.yml index ba4054b..64bfe78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,45 +8,16 @@ services: 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", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"] + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"] interval: 30s timeout: 5s retries: 3 - victoriametrics: - image: victoriametrics/victoria-metrics:v1.126.0 - restart: unless-stopped - command: - - -storageDataPath=/victoria-metrics-data - - -promscrape.config=/etc/victoriametrics/scrape.yml - - -retentionPeriod=1y - volumes: - - victoria-metrics-data:/victoria-metrics-data - - ./config/victoriametrics/scrape.yml:/etc/victoriametrics/scrape.yml:ro - ports: - - "${PUBLISH_ADDRESS:-0.0.0.0}:8428:8428" - depends_on: - - hue-collector - - grafana: - image: grafana/grafana:12.3.3 - restart: unless-stopped - environment: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin - GF_USERS_ALLOW_SIGN_UP: "false" - volumes: - - grafana-data:/var/lib/grafana - - ./config/grafana/provisioning:/etc/grafana/provisioning:ro - - ./config/grafana/dashboards:/var/lib/grafana/dashboards:ro - ports: - - "${PUBLISH_ADDRESS:-0.0.0.0}:3000:3000" - depends_on: - - victoriametrics - volumes: - victoria-metrics-data: - grafana-data: + hue-rrd-data: diff --git a/hue_collector/__main__.py b/hue_collector/__main__.py index 5de860a..b982e5d 100644 --- a/hue_collector/__main__.py +++ b/hue_collector/__main__.py @@ -1,4 +1,3 @@ -from .collector import main +from .app import main main() - diff --git a/hue_collector/app.py b/hue_collector/app.py new file mode 100644 index 0000000..2493630 --- /dev/null +++ b/hue_collector/app.py @@ -0,0 +1,129 @@ +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() diff --git a/hue_collector/collector.py b/hue_collector/collector.py index acf927a..834f69e 100644 --- a/hue_collector/collector.py +++ b/hue_collector/collector.py @@ -1,38 +1,22 @@ from __future__ import annotations -import json -import logging import os -import ssl -import threading -import time from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any import requests import urllib3 -LOG = logging.getLogger("hue-collector") - - -def _label(value: Any) -> str: - return str(value).replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"') - - -def _sample(name: str, value: float | int, **labels: str) -> str: - rendered = ",".join(f'{key}="{_label(val)}"' for key, val in sorted(labels.items())) - return f"{name}{{{rendered}}} {value}" if rendered else f"{name} {value}" - @dataclass(frozen=True) class Settings: host: str application_key: str verify_tls: bool = False - interval: float = 30 + interval: int = 30 listen_host: str = "0.0.0.0" listen_port: int = 8000 + data_dir: str = "/data" @classmethod def from_env(cls) -> "Settings": @@ -44,9 +28,10 @@ class Settings: host=host.removeprefix("https://").removeprefix("http://").rstrip("/"), application_key=key, verify_tls=os.environ.get("HUE_VERIFY_TLS", "false").lower() in {"1", "true", "yes"}, - interval=float(os.environ.get("COLLECT_INTERVAL_SECONDS", "30")), + 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"), ) @@ -74,7 +59,7 @@ class Collector: def __init__(self, client: HueClient): self.client = client - def collect(self) -> str: + def collect(self) -> dict[str, list[dict[str, Any]]]: resources = {kind: self.client.get(kind) for kind in self.RESOURCE_TYPES} devices = {item["id"]: item for item in resources["device"]} connected = { @@ -84,57 +69,42 @@ class Collector: light_by_device = self._by_owner(resources["light"]) room_for_device, zones_for_device = self._locations(resources["room"], resources["zone"]) - lines = [ - "# HELP hue_collection_success Whether the most recent Hue collection succeeded.", - "# TYPE hue_collection_success gauge", - "hue_collection_success 1", - "# HELP hue_group_lights_available Number of connected lights in a room or zone.", - "# TYPE hue_group_lights_available gauge", - "# HELP hue_group_lights_shining Number of connected lights that are on.", - "# TYPE hue_group_lights_shining gauge", - "# HELP hue_group_lights_off Number of connected lights that are off.", - "# TYPE hue_group_lights_off gauge", - "# HELP hue_group_lights_unavailable Number of configured lights that are disconnected.", - "# TYPE hue_group_lights_unavailable gauge", - "# HELP hue_group_lights_shining_percent Percentage of connected lights that are on.", - "# TYPE hue_group_lights_shining_percent gauge", - "# HELP hue_group_lights_off_percent Percentage of connected lights that are off.", - "# TYPE hue_group_lights_off_percent gauge", - "# HELP hue_group_lights_unavailable_percent Percentage of configured lights that are disconnected.", - "# TYPE hue_group_lights_unavailable_percent gauge", - "# HELP hue_group_average_brightness_percent Average brightness of connected lights that are on.", - "# TYPE hue_group_average_brightness_percent gauge", - ] + groups: list[dict[str, Any]] = [] for kind in ("room", "zone"): for group in resources[kind]: - all_lights = [ - light - for child in group.get("children", []) - for light in light_by_device.get(child.get("rid"), []) - ] - lights = [ - light - for child in group.get("children", []) + all_lights = [light for child in group.get("children", []) for light in light_by_device.get(child.get("rid"), [])] + available = [ + light for child in group.get("children", []) for light in light_by_device.get(child.get("rid"), []) if connected.get(child.get("rid"), True) ] - shining = [light for light in lights if light.get("on", {}).get("on", False)] - off = len(lights) - len(shining) - unavailable = len(all_lights) - len(lights) - labels = {"group_type": kind, "group": group.get("metadata", {}).get("name", group["id"]), "group_id": group["id"]} - lines.append(_sample("hue_group_lights_available", len(lights), **labels)) - lines.append(_sample("hue_group_lights_shining", len(shining), **labels)) - lines.append(_sample("hue_group_lights_off", off, **labels)) - lines.append(_sample("hue_group_lights_unavailable", unavailable, **labels)) - lines.append(_sample("hue_group_lights_shining_percent", 100 * len(shining) / len(lights) if lights else 0, **labels)) - lines.append(_sample("hue_group_lights_off_percent", 100 * off / len(lights) if lights else 0, **labels)) - lines.append(_sample("hue_group_lights_unavailable_percent", 100 * unavailable / len(all_lights) if all_lights else 0, **labels)) + shining = [light for light in available if light.get("on", {}).get("on", False)] brightness = [light.get("dimming", {}).get("brightness") for light in shining] brightness = [value for value in brightness if value is not None] - lines.append(_sample("hue_group_average_brightness_percent", sum(brightness) / len(brightness) if brightness else 0, **labels)) + groups.append({ + "id": group["id"], "type": kind, + "name": group.get("metadata", {}).get("name", group["id"]), + "available": len(available), "on": len(shining), + "off": len(available) - len(shining), + "unavailable": len(all_lights) - len(available), + "brightness": sum(brightness) / len(brightness) if brightness else 0.0, + }) - lines.extend(self._sensor_metrics(resources, devices, connected, room_for_device, zones_for_device)) - return "\n".join(lines) + "\n" + sensors: list[dict[str, Any]] = [] + for resource_type, field, unit in (("temperature", "temperature", "°C"), ("light_level", "light_level", "lux")): + for sensor in resources[resource_type]: + device_id = sensor.get("owner", {}).get("rid", "") + device = devices.get(device_id, {}) + value = sensor.get(resource_type, {}).get(field) + valid = not (resource_type == "light_level" and not sensor.get("light_level", {}).get("light_level_valid", True)) + available = connected.get(device_id, True) + sensors.append({ + "id": sensor["id"], "type": resource_type, + "name": sensor.get("metadata", {}).get("name") or device.get("metadata", {}).get("name", sensor["id"]), + "room": room_for_device.get(device_id, ""), "zones": zones_for_device.get(device_id, ""), + "available": available, "value": value if available and valid else None, "unit": unit, + }) + return {"groups": groups, "sensors": sensors} @staticmethod def _by_owner(items: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: @@ -148,102 +118,9 @@ class Collector: room_for_device: dict[str, str] = {} zones_for_device: dict[str, list[str]] = {} for room in rooms: - name = room.get("metadata", {}).get("name", room["id"]) for child in room.get("children", []): - room_for_device[child.get("rid", "")] = name + room_for_device[child.get("rid", "")] = room.get("metadata", {}).get("name", room["id"]) for zone in zones: - name = zone.get("metadata", {}).get("name", zone["id"]) for child in zone.get("children", []): - zones_for_device.setdefault(child.get("rid", ""), []).append(name) + zones_for_device.setdefault(child.get("rid", ""), []).append(zone.get("metadata", {}).get("name", zone["id"])) return room_for_device, {key: ", ".join(sorted(value)) for key, value in zones_for_device.items()} - - def _sensor_metrics(self, resources, devices, connected, room_for_device, zones_for_device) -> list[str]: - lines = [ - "# HELP hue_sensor_available Whether the sensor's Hue device is connected.", - "# TYPE hue_sensor_available gauge", - "# HELP hue_temperature_celsius Temperature measured by a Hue sensor.", - "# TYPE hue_temperature_celsius gauge", - "# HELP hue_light_level_lux Illuminance measured by a Hue sensor.", - "# TYPE hue_light_level_lux gauge", - ] - for resource_type, metric, field in ( - ("temperature", "hue_temperature_celsius", "temperature"), - ("light_level", "hue_light_level_lux", "light_level"), - ): - for sensor in resources[resource_type]: - device_id = sensor.get("owner", {}).get("rid", "") - device = devices.get(device_id, {}) - labels = { - "sensor": sensor.get("metadata", {}).get("name") or device.get("metadata", {}).get("name", sensor["id"]), - "sensor_id": sensor["id"], - "sensor_type": resource_type, - "room": room_for_device.get(device_id, ""), - "zones": zones_for_device.get(device_id, ""), - } - available = connected.get(device_id, True) - lines.append(_sample("hue_sensor_available", int(available), **labels)) - value = sensor.get(resource_type, {}).get(field) - if resource_type == "light_level" and not sensor.get("light_level", {}).get("light_level_valid", True): - value = None - if available and value is not None: - lines.append(_sample(metric, value, **labels)) - return lines - - -class State: - def __init__(self): - self.metrics = "hue_collection_success 0\n" - self.healthy = False - self.lock = threading.Lock() - - -def run_collection_loop(collector: Collector, state: State, interval: float) -> None: - while True: - try: - metrics = collector.collect() - with state.lock: - state.metrics, state.healthy = metrics, True - except Exception: - LOG.exception("Hue collection failed") - with state.lock: - state.metrics = "hue_collection_success 0\n" - time.sleep(interval) - - -def handler_for(state: State): - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/metrics": - with state.lock: - body, status = state.metrics.encode(), 200 - content_type = "text/plain; version=0.0.4; charset=utf-8" - elif self.path == "/healthz": - with state.lock: - healthy = state.healthy - body, status, content_type = (b"ok\n", 200, "text/plain") if healthy else (b"not ready\n", 503, "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() - collector = Collector(HueClient(settings)) - threading.Thread(target=run_collection_loop, args=(collector, state, settings.interval), daemon=True).start() - LOG.info("Serving metrics on %s:%s", settings.listen_host, settings.listen_port) - ThreadingHTTPServer((settings.listen_host, settings.listen_port), handler_for(state)).serve_forever() - - -if __name__ == "__main__": - main() diff --git a/hue_collector/rrd_store.py b/hue_collector/rrd_store.py new file mode 100644 index 0000000..ebdf883 --- /dev/null +++ b/hue_collector/rrd_store.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +import rrdtool + +SAFE_ID = re.compile(r"^[A-Za-z0-9-]+$") + + +class RRDStore: + def __init__(self, data_dir: str, step: int): + self.root = Path(data_dir) + self.root.mkdir(parents=True, exist_ok=True) + self.step = step + self.heartbeat = max(step * 3, 120) + + def update(self, snapshot: dict[str, list[dict[str, Any]]]) -> None: + for group in snapshot["groups"]: + path = self.path("group", group["id"]) + self._ensure_group(path) + rrdtool.update(str(path), "N:%s:%s:%s:%s:%s" % ( + group["available"], group["on"], group["off"], group["unavailable"], group["brightness"] + )) + for sensor in snapshot["sensors"]: + path = self.path("sensor", sensor["id"]) + self._ensure_sensor(path) + value = "U" if sensor["value"] is None else sensor["value"] + rrdtool.update(str(path), f"N:{value}:{int(sensor['available'])}") + + def path(self, kind: str, item_id: str) -> Path: + if kind not in {"group", "sensor"} or not SAFE_ID.fullmatch(item_id): + raise ValueError("Invalid RRD identifier") + return self.root / f"{kind}-{item_id}.rrd" + + def _archives(self) -> list[str]: + five_minutes = max(1, round(300 / self.step)) + one_hour = max(1, round(3600 / self.step)) + return [ + f"RRA:AVERAGE:0.5:1:{max(1, round(7 * 86400 / self.step))}", + f"RRA:AVERAGE:0.5:{five_minutes}:{90 * 288}", + f"RRA:AVERAGE:0.5:{one_hour}:{2 * 365 * 24}", + ] + + def _ensure_group(self, path: Path) -> None: + if not path.exists(): + rrdtool.create( + str(path), "--step", str(self.step), + f"DS:available:GAUGE:{self.heartbeat}:0:U", f"DS:on:GAUGE:{self.heartbeat}:0:U", + f"DS:off:GAUGE:{self.heartbeat}:0:U", f"DS:unavailable:GAUGE:{self.heartbeat}:0:U", + f"DS:brightness:GAUGE:{self.heartbeat}:0:100", *self._archives(), + ) + + def _ensure_sensor(self, path: Path) -> None: + if not path.exists(): + rrdtool.create( + str(path), "--step", str(self.step), f"DS:value:GAUGE:{self.heartbeat}:U:U", + f"DS:available:GAUGE:{self.heartbeat}:0:1", *self._archives(), + ) + + def graph(self, kind: str, item: dict[str, Any], period: str) -> bytes: + start = {"6h": "-6h", "24h": "-24h", "7d": "-7d", "30d": "-30d"}.get(period, "-24h") + path = self.path(kind, item["id"]) + if not path.exists(): + raise FileNotFoundError(path) + handle, output = tempfile.mkstemp(suffix=".png") + os.close(handle) + try: + args = [ + output, "--start", start, "--end", "now", "--width", "900", "--height", "260", + "--font", "DEFAULT:0:DejaVu Sans", "--color", "BACK#111827", "--color", "CANVAS#111827", + "--color", "FONT#d1d5db", "--color", "GRID#374151", "--color", "MGRID#4b5563", + "--border", "0", "--slope-mode", + ] + escaped = str(path).replace(":", "\\:") + if kind == "group": + args += [ + "--vertical-label", "% of lights / intensity", "--lower-limit", "0", "--upper-limit", "100", "--rigid", + f"DEF:u={escaped}:unavailable:AVERAGE", f"DEF:o={escaped}:off:AVERAGE", + f"DEF:n={escaped}:on:AVERAGE", f"DEF:b={escaped}:brightness:AVERAGE", + "CDEF:t=u,o,+,n,+", "CDEF:up=t,0,EQ,0,u,t,/,100,*,IF", + "CDEF:op=t,0,EQ,0,o,t,/,100,*,IF", "CDEF:np=t,0,EQ,0,n,t,/,100,*,IF", + "AREA:up#8b0000:Unavailable", "AREA:op#4b5563:Off:STACK", "AREA:np#facc15:On:STACK", + "LINE2:b#22c55e:Average intensity", + ] + else: + label = "Temperature (°C)" if item["type"] == "temperature" else "Illuminance (lux)" + args += ["--vertical-label", label, f"DEF:v={escaped}:value:AVERAGE", "LINE2:v#38bdf8:Value"] + rrdtool.graph(*args) + return Path(output).read_bytes() + finally: + Path(output).unlink(missing_ok=True) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 832f734..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests==2.32.5 - diff --git a/tests/test_collector.py b/tests/test_collector.py index 208656e..49fa6fc 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -5,11 +5,7 @@ from hue_collector.collector import Collector class FakeClient: data = { - "device": [ - {"id": "d1", "metadata": {"name": "Motion"}}, - {"id": "d2", "metadata": {"name": "Lamp"}}, - {"id": "d3", "metadata": {"name": "Offline lamp"}}, - ], + "device": [{"id": "d1", "metadata": {"name": "Motion"}}, {"id": "d2", "metadata": {"name": "Lamp"}}, {"id": "d3", "metadata": {"name": "Offline lamp"}}], "room": [{"id": "r1", "metadata": {"name": "Office"}, "children": [{"rid": "d1"}, {"rid": "d2"}, {"rid": "d3"}]}], "zone": [{"id": "z1", "metadata": {"name": "Downstairs"}, "children": [{"rid": "d2"}]}], "light": [ @@ -31,20 +27,21 @@ class FakeClient: class CollectorTests(unittest.TestCase): def setUp(self): - self.metrics = Collector(FakeClient()).collect() + self.snapshot = Collector(FakeClient()).collect() - def test_group_counts_only_connected_lights(self): - self.assertIn('hue_group_lights_available{group="Office",group_id="r1",group_type="room"} 1', self.metrics) - self.assertIn('hue_group_lights_off{group="Office",group_id="r1",group_type="room"} 0', self.metrics) - self.assertIn('hue_group_lights_unavailable{group="Office",group_id="r1",group_type="room"} 1', self.metrics) - self.assertIn('hue_group_lights_shining_percent{group="Office",group_id="r1",group_type="room"} 100.0', self.metrics) - self.assertIn('hue_group_lights_off_percent{group="Office",group_id="r1",group_type="room"} 0.0', self.metrics) - self.assertIn('hue_group_lights_unavailable_percent{group="Office",group_id="r1",group_type="room"} 50.0', self.metrics) - self.assertIn('hue_group_average_brightness_percent{group="Office",group_id="r1",group_type="room"} 60.0', self.metrics) + def test_group_light_state(self): + office = next(item for item in self.snapshot["groups"] if item["name"] == "Office") + self.assertEqual(office["available"], 1) + self.assertEqual(office["on"], 1) + self.assertEqual(office["off"], 0) + self.assertEqual(office["unavailable"], 1) + self.assertEqual(office["brightness"], 60) def test_sensor_values_have_location(self): - self.assertIn('hue_temperature_celsius{room="Office",sensor="Motion",sensor_id="t1",sensor_type="temperature",zones=""} 21.5', self.metrics) - self.assertIn('hue_light_level_lux{room="Office",sensor="Motion",sensor_id="s1",sensor_type="light_level",zones=""} 123.4', self.metrics) + temperature = next(item for item in self.snapshot["sensors"] if item["type"] == "temperature") + light = next(item for item in self.snapshot["sensors"] if item["type"] == "light_level") + self.assertEqual((temperature["name"], temperature["room"], temperature["value"]), ("Motion", "Office", 21.5)) + self.assertEqual((light["name"], light["room"], light["value"]), ("Motion", "Office", 123.4)) if __name__ == "__main__": diff --git a/tests/test_rrd_store.py b/tests/test_rrd_store.py new file mode 100644 index 0000000..633181e --- /dev/null +++ b/tests/test_rrd_store.py @@ -0,0 +1,60 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path + + +class FakeRRD(types.ModuleType): + def __init__(self): + super().__init__("rrdtool") + self.created = [] + self.updated = [] + self.graphed = [] + + def create(self, *args): + self.created.append(args) + Path(args[0]).touch() + + def update(self, *args): + self.updated.append(args) + + def graph(self, *args): + self.graphed.append(args) + Path(args[0]).write_bytes(b"png") + + +fake_rrd = FakeRRD() +sys.modules.setdefault("rrdtool", fake_rrd) + +from hue_collector.rrd_store import RRDStore # noqa: E402 + + +class RRDStoreTests(unittest.TestCase): + def setUp(self): + fake_rrd.created.clear() + fake_rrd.updated.clear() + fake_rrd.graphed.clear() + self.tempdir = tempfile.TemporaryDirectory() + self.store = RRDStore(self.tempdir.name, 30) + + def tearDown(self): + self.tempdir.cleanup() + + def test_create_update_and_group_graph(self): + group = {"id": "r-1", "type": "room", "available": 2, "on": 1, "off": 1, "unavailable": 1, "brightness": 42.5} + self.store.update({"groups": [group], "sensors": []}) + self.assertEqual(len(fake_rrd.created), 1) + self.assertTrue(fake_rrd.updated[0][1].endswith(":2:1:1:1:42.5")) + self.assertEqual(self.store.graph("group", group, "24h"), b"png") + graph_args = fake_rrd.graphed[0] + self.assertIn("AREA:up#8b0000:Unavailable", graph_args) + self.assertIn("LINE2:b#22c55e:Average intensity", graph_args) + + def test_rejects_unsafe_identifier(self): + with self.assertRaises(ValueError): + self.store.path("group", "../escape") + + +if __name__ == "__main__": + unittest.main()