removed grafana and victoria

This commit is contained in:
2026-09-06 15:31:57 +02:00
parent 2da3685ad2
commit b7f98c2ccf
15 changed files with 407 additions and 379 deletions
+2 -1
View File
@@ -2,4 +2,5 @@
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.py[cod] *.py[cod]
hue-response.json
*.rrd
+6 -6
View File
@@ -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 WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY hue_collector ./hue_collector COPY hue_collector ./hue_collector
USER 65532:65532
EXPOSE 8000 EXPOSE 8000
CMD ["python", "-m", "hue_collector"] CMD ["python3", "-m", "hue_collector"]
+52 -42
View File
@@ -1,27 +1,24 @@
# Hue collector # Lightweight Hue history
A local-only monitoring stack for a Philips Hue bridge. The Python service reads the A small service for Raspberry Pi that reads the Philips Hue v2 API, stores history in
Hue v2 API, exposes Prometheus metrics, VictoriaMetrics stores them, and Grafana ships fixed-size [RRDtool](https://oss.oetiker.ch/rrdtool/) databases, and serves its own web
with a provisioned dashboard. 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; - One graph for every room and zone. Unavailable, off, and on lights form a stacked
percentages shining, off, and unavailable; plus average brightness of the shining percentage area, with average intensity of shining lights drawn as a line.
lights. - Temperature and illuminance graphs for every available Hue sensor.
- For every temperature and illuminance sensor: availability and its latest reading, - 6-hour, 24-hour, 7-day, and 30-day time ranges.
labelled with its room and zones.
A device is available when its Hue `zigbee_connectivity` resource is connected. A RRD storage is bounded automatically: raw samples are retained for 7 days,
light is shining when it is available and reports `on=true`. If a device has no approximately five-minute averages for 90 days, and hourly averages for two years.
connectivity resource (for example some bridge-owned resources), it is considered
available.
## Setup ## Configure Hue
1. Find your bridge IP in the Hue app under **Settings → My Hue system → System Find the bridge IP in the Hue app under **Settings → My Hue system → System
information**. information**. Then create an application key while physically near the bridge:
2. Create an application key while physically near the bridge:
```bash ```bash
curl -k -X POST https://BRIDGE_IP/api \ curl -k -X POST https://BRIDGE_IP/api \
@@ -29,40 +26,53 @@ available.
-d '{"devicetype":"local-hue-collector"}' -d '{"devicetype":"local-hue-collector"}'
``` ```
Press the bridge link button immediately before running the command. Copy the Press the bridge link button immediately before running the command. The returned
returned `username`; that is the application key. `username` is the application key.
3. Configure and start the stack:
## Run with Docker Compose
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with the bridge IP and application key. # Edit .env with the bridge IP and application key.
docker compose up --build -d docker compose up --build -d --remove-orphans
``` ```
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.
Useful local endpoints: 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.
- Collector metrics: <http://localhost:8000/metrics> By default, port 8000 binds to all interfaces. To bind only to the Pi's LAN interface,
- VictoriaMetrics UI: <http://localhost:8428/vmui/> put its exact address in `.env`, for example:
- Grafana: <http://localhost:3000>
By default, published ports bind to `0.0.0.0`, making them reachable through the ```env
host's `192.168.178.x` address. Docker cannot bind to a wildcard subnet such as PUBLISH_ADDRESS=192.168.178.42
`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 ## Run directly on Raspberry Pi OS/Debian
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 This avoids Docker overhead entirely:
trusted certificate.
```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 ## Development
The collector tests do not require RRDtool:
```bash ```bash
python -m venv .venv python3 -m unittest discover -s tests
. .venv/bin/activate
pip install -r requirements.txt
python -m unittest discover -s tests
HUE_BRIDGE_HOST=... HUE_APPLICATION_KEY=... python -m hue_collector
``` ```
-80
View File
@@ -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
}
@@ -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
@@ -1,10 +0,0 @@
apiVersion: 1
datasources:
- name: VictoriaMetrics
uid: victoriametrics
type: prometheus
access: proxy
url: http://victoriametrics:8428
isDefault: true
editable: false
-8
View File
@@ -1,8 +0,0 @@
global:
scrape_interval: 30s
scrape_configs:
- job_name: hue
static_configs:
- targets: [hue-collector:8000]
+5 -34
View File
@@ -8,45 +8,16 @@ services:
HUE_VERIFY_TLS: ${HUE_VERIFY_TLS:-false} HUE_VERIFY_TLS: ${HUE_VERIFY_TLS:-false}
COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-30} COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-30}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
DATA_DIR: /data
volumes:
- hue-rrd-data:/data
ports: ports:
- "${PUBLISH_ADDRESS:-0.0.0.0}:8000:8000" - "${PUBLISH_ADDRESS:-0.0.0.0}:8000:8000"
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 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: volumes:
- victoria-metrics-data:/victoria-metrics-data hue-rrd-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:
+1 -2
View File
@@ -1,4 +1,3 @@
from .collector import main from .app import main
main() main()
+129
View File
@@ -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'<a class="{("active" if value == period else "")}" href="/?range={value}">{value}</a>'
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'<section><h2>{html.escape(item["type"].title())}: {html.escape(item["name"])}</h2>'
f'<img loading="lazy" src="/graph?{query}" alt="Light history"></section>'
)
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'<section><h2>{html.escape(item["name"])}</h2><p>{html.escape(location)} · {html.escape(value)}</p>'
f'<img loading="lazy" src="/graph?{query}" alt="Sensor history"></section>'
)
document = f'''<!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}}img{{display:block;width:100%;height:auto}}
</style></head><body><header><div><h1>Philips Hue</h1><div class="status">{status}</div></div><nav>{controls}</nav></header>
<h1>Rooms and zones</h1>{''.join(group_cards) or '<p>No groups collected yet.</p>'}
<h1>Sensors</h1>{''.join(sensor_cards) or '<p>No sensors collected yet.</p>'}</body></html>'''
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()
+35 -158
View File
@@ -1,38 +1,22 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
import os import os
import ssl
import threading
import time
from dataclasses import dataclass from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
import requests import requests
import urllib3 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) @dataclass(frozen=True)
class Settings: class Settings:
host: str host: str
application_key: str application_key: str
verify_tls: bool = False verify_tls: bool = False
interval: float = 30 interval: int = 30
listen_host: str = "0.0.0.0" listen_host: str = "0.0.0.0"
listen_port: int = 8000 listen_port: int = 8000
data_dir: str = "/data"
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
@@ -44,9 +28,10 @@ class Settings:
host=host.removeprefix("https://").removeprefix("http://").rstrip("/"), host=host.removeprefix("https://").removeprefix("http://").rstrip("/"),
application_key=key, application_key=key,
verify_tls=os.environ.get("HUE_VERIFY_TLS", "false").lower() in {"1", "true", "yes"}, 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_host=os.environ.get("LISTEN_HOST", "0.0.0.0"),
listen_port=int(os.environ.get("LISTEN_PORT", "8000")), 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): def __init__(self, client: HueClient):
self.client = client 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} resources = {kind: self.client.get(kind) for kind in self.RESOURCE_TYPES}
devices = {item["id"]: item for item in resources["device"]} devices = {item["id"]: item for item in resources["device"]}
connected = { connected = {
@@ -84,57 +69,42 @@ class Collector:
light_by_device = self._by_owner(resources["light"]) light_by_device = self._by_owner(resources["light"])
room_for_device, zones_for_device = self._locations(resources["room"], resources["zone"]) room_for_device, zones_for_device = self._locations(resources["room"], resources["zone"])
lines = [ groups: list[dict[str, Any]] = []
"# 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",
]
for kind in ("room", "zone"): for kind in ("room", "zone"):
for group in resources[kind]: for group in resources[kind]:
all_lights = [ all_lights = [light for child in group.get("children", []) for light in light_by_device.get(child.get("rid"), [])]
light available = [
for child in group.get("children", []) 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", [])
for light in light_by_device.get(child.get("rid"), []) for light in light_by_device.get(child.get("rid"), [])
if connected.get(child.get("rid"), True) if connected.get(child.get("rid"), True)
] ]
shining = [light for light in lights if light.get("on", {}).get("on", False)] shining = [light for light in available 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))
brightness = [light.get("dimming", {}).get("brightness") for light in shining] brightness = [light.get("dimming", {}).get("brightness") for light in shining]
brightness = [value for value in brightness if value is not None] 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)) sensors: list[dict[str, Any]] = []
return "\n".join(lines) + "\n" 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 @staticmethod
def _by_owner(items: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: 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] = {} room_for_device: dict[str, str] = {}
zones_for_device: dict[str, list[str]] = {} zones_for_device: dict[str, list[str]] = {}
for room in rooms: for room in rooms:
name = room.get("metadata", {}).get("name", room["id"])
for child in room.get("children", []): 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: for zone in zones:
name = zone.get("metadata", {}).get("name", zone["id"])
for child in zone.get("children", []): 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()} 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()
+95
View File
@@ -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)
-2
View File
@@ -1,2 +0,0 @@
requests==2.32.5
+13 -16
View File
@@ -5,11 +5,7 @@ from hue_collector.collector import Collector
class FakeClient: class FakeClient:
data = { data = {
"device": [ "device": [{"id": "d1", "metadata": {"name": "Motion"}}, {"id": "d2", "metadata": {"name": "Lamp"}}, {"id": "d3", "metadata": {"name": "Offline lamp"}}],
{"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"}]}], "room": [{"id": "r1", "metadata": {"name": "Office"}, "children": [{"rid": "d1"}, {"rid": "d2"}, {"rid": "d3"}]}],
"zone": [{"id": "z1", "metadata": {"name": "Downstairs"}, "children": [{"rid": "d2"}]}], "zone": [{"id": "z1", "metadata": {"name": "Downstairs"}, "children": [{"rid": "d2"}]}],
"light": [ "light": [
@@ -31,20 +27,21 @@ class FakeClient:
class CollectorTests(unittest.TestCase): class CollectorTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.metrics = Collector(FakeClient()).collect() self.snapshot = Collector(FakeClient()).collect()
def test_group_counts_only_connected_lights(self): def test_group_light_state(self):
self.assertIn('hue_group_lights_available{group="Office",group_id="r1",group_type="room"} 1', self.metrics) office = next(item for item in self.snapshot["groups"] if item["name"] == "Office")
self.assertIn('hue_group_lights_off{group="Office",group_id="r1",group_type="room"} 0', self.metrics) self.assertEqual(office["available"], 1)
self.assertIn('hue_group_lights_unavailable{group="Office",group_id="r1",group_type="room"} 1', self.metrics) self.assertEqual(office["on"], 1)
self.assertIn('hue_group_lights_shining_percent{group="Office",group_id="r1",group_type="room"} 100.0', self.metrics) self.assertEqual(office["off"], 0)
self.assertIn('hue_group_lights_off_percent{group="Office",group_id="r1",group_type="room"} 0.0', self.metrics) self.assertEqual(office["unavailable"], 1)
self.assertIn('hue_group_lights_unavailable_percent{group="Office",group_id="r1",group_type="room"} 50.0', self.metrics) self.assertEqual(office["brightness"], 60)
self.assertIn('hue_group_average_brightness_percent{group="Office",group_id="r1",group_type="room"} 60.0', self.metrics)
def test_sensor_values_have_location(self): 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) temperature = next(item for item in self.snapshot["sensors"] if item["type"] == "temperature")
self.assertIn('hue_light_level_lux{room="Office",sensor="Motion",sensor_id="s1",sensor_type="light_level",zones=""} 123.4', self.metrics) 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__": if __name__ == "__main__":
+60
View File
@@ -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()