removed grafana and victoria
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
hue-response.json
|
||||||
|
*.rrd
|
||||||
|
|||||||
+6
-6
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
global:
|
|
||||||
scrape_interval: 30s
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: hue
|
|
||||||
static_configs:
|
|
||||||
- targets: [hue-collector:8000]
|
|
||||||
|
|
||||||
+5
-34
@@ -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,4 +1,3 @@
|
|||||||
from .collector import main
|
from .app import main
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
requests==2.32.5
|
|
||||||
|
|
||||||
+13
-16
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user