as flask without docker

This commit is contained in:
2026-09-06 15:53:58 +02:00
parent b7f98c2ccf
commit 1e18920ec6
8 changed files with 164 additions and 165 deletions
+4 -2
View File
@@ -1,6 +1,8 @@
HUE_BRIDGE_HOST=192.168.1.2 HUE_BRIDGE_HOST=192.168.1.2
HUE_APPLICATION_KEY=replace-with-your-hue-application-key HUE_APPLICATION_KEY=130987023498cb710109387401983427
HUE_VERIFY_TLS=false HUE_VERIFY_TLS=false
COLLECT_INTERVAL_SECONDS=30 COLLECT_INTERVAL_SECONDS=30
LOG_LEVEL=INFO LOG_LEVEL=INFO
PUBLISH_ADDRESS=0.0.0.0 LISTEN_HOST=0.0.0.0
LISTEN_PORT=8000
DATA_DIR=data
-11
View File
@@ -1,11 +0,0 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-requests python3-rrdtool fonts-dejavu-core ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY hue_collector ./hue_collector
EXPOSE 8000
CMD ["python3", "-m", "hue_collector"]
+69 -44
View File
@@ -1,24 +1,29 @@
# Lightweight Hue history # Hue RRD Flask dashboard
A small service for Raspberry Pi that reads the Philips Hue v2 API, stores history in A lightweight Flask application for Raspberry Pi that reads the Philips Hue v2 API,
fixed-size [RRDtool](https://oss.oetiker.ch/rrdtool/) databases, and serves its own web stores history in fixed-size RRDtool databases, and serves a local web dashboard.
dashboard. It does not require Grafana, VictoriaMetrics, Node.js, or a browser-side
charting library.
The dashboard contains: The dashboard contains:
- One graph for every room and zone. Unavailable, off, and on lights form a stacked - 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. percentage area, with average intensity of shining lights drawn as a line.
- Temperature and illuminance graphs for every available Hue sensor. - Temperature and illuminance graphs for every Hue sensor.
- 6-hour, 24-hour, 7-day, and 30-day time ranges. - 6-hour, 24-hour, 7-day, and 30-day time ranges.
RRD storage is bounded automatically: raw samples are retained for 7 days, RRD storage remains bounded automatically: raw samples are retained for 7 days,
approximately five-minute averages for 90 days, and hourly averages for two years. approximately five-minute averages for 90 days, and hourly averages for two years.
## Configure Hue ## Install on Raspberry Pi OS or Debian
Find the bridge IP in the Hue app under **Settings → My Hue system → System Install the native RRDtool binding and Python dependencies:
information**. Then create an application key while physically near the bridge:
```bash
sudo apt update
sudo apt install python3 python3-flask python3-requests python3-rrdtool fonts-dejavu-core
```
Create the Hue application key if you do not have one yet. Press the bridge link
button immediately before running:
```bash ```bash
curl -k -X POST https://BRIDGE_IP/api \ curl -k -X POST https://BRIDGE_IP/api \
@@ -26,52 +31,72 @@ curl -k -X POST https://BRIDGE_IP/api \
-d '{"devicetype":"local-hue-collector"}' -d '{"devicetype":"local-hue-collector"}'
``` ```
Press the bridge link button immediately before running the command. The returned The returned `username` is the application key.
`username` is the application key.
## Run with Docker Compose Configure and run the Flask application from the project directory:
```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 --remove-orphans set -a
``` . ./.env
set +a
Open `http://PI_ADDRESS:8000`. RRD data persists in the `hue-rrd-data` Docker volume.
The `--remove-orphans` option removes containers from the previous
VictoriaMetrics/Grafana version; their old named volumes are not deleted.
By default, port 8000 binds to all interfaces. To bind only to the Pi's LAN interface,
put its exact address in `.env`, for example:
```env
PUBLISH_ADDRESS=192.168.178.42
```
## Run directly on Raspberry Pi OS/Debian
This avoids Docker overhead entirely:
```bash
sudo apt update
sudo apt install python3 python3-requests python3-rrdtool fonts-dejavu-core
mkdir -p "$HOME/.local/share/hue-collector"
export HUE_BRIDGE_HOST=192.168.178.2
export HUE_APPLICATION_KEY=your-key
export DATA_DIR="$HOME/.local/share/hue-collector"
python3 -m hue_collector python3 -m hue_collector
``` ```
The page is served on port 8000. For continuous operation, run it with systemd or use Open `http://PI_ADDRESS:8000`. The RRD files are written to `DATA_DIR`, which defaults
the Compose setup with `restart: unless-stopped`. to the project's `data/` directory.
TLS verification is disabled by default because Hue bridges normally use a ## Run continuously with systemd
self-signed certificate. Set `HUE_VERIFY_TLS=true` if the bridge has a trusted
certificate. The health check is available at `/healthz`. Install the project and environment file:
```bash
sudo install -d /opt/hue-collector
sudo cp -r hue_collector /opt/hue-collector/
sudo cp .env /etc/hue-collector
sudo chmod 600 /etc/hue-collector
sudo cp hue-collector.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now hue-collector
```
The included unit uses a restricted dynamic service account and stores RRD files in
`/var/lib/hue-collector`. Inspect it with:
```bash
systemctl status hue-collector
journalctl -u hue-collector -f
```
After updating the application files, restart it with:
```bash
sudo systemctl restart hue-collector
```
## Configuration
- `HUE_BRIDGE_HOST`: Hue bridge IP or hostname; required.
- `HUE_APPLICATION_KEY`: Hue v2 application key; required.
- `HUE_VERIFY_TLS`: verify the bridge certificate; defaults to `false` because Hue
bridges normally use a self-signed certificate.
- `COLLECT_INTERVAL_SECONDS`: sampling interval; defaults to 30 seconds. Changing it
after RRD files have been created does not change those existing files' step.
- `LISTEN_HOST`: Flask bind address; defaults to `0.0.0.0` for LAN access.
- `LISTEN_PORT`: dashboard port; defaults to `8000`.
- `DATA_DIR`: RRD directory; defaults to `data`.
The application is intended for a trusted home network and does not provide
authentication or TLS. Restrict access with the Pi firewall if the network is not
trusted.
## Development ## Development
The collector tests do not require RRDtool: On a Debian-based machine, install `python3-rrdtool` through apt. Flask and Requests
can alternatively be installed from `requirements.txt`. When using a virtual
environment, create it with `--system-site-packages` so it can see the apt-installed
RRDtool module.
```bash ```bash
python3 -m unittest discover -s tests python3 -m unittest discover -s tests
-23
View File
@@ -1,23 +0,0 @@
services:
hue-collector:
build: .
restart: unless-stopped
environment:
HUE_BRIDGE_HOST: ${HUE_BRIDGE_HOST:-}
HUE_APPLICATION_KEY: ${HUE_APPLICATION_KEY:-}
HUE_VERIFY_TLS: ${HUE_VERIFY_TLS:-false}
COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-30}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
DATA_DIR: /data
volumes:
- hue-rrd-data:/data
ports:
- "${PUBLISH_ADDRESS:-0.0.0.0}:8000:8000"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
interval: 30s
timeout: 5s
retries: 3
volumes:
hue-rrd-data:
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Philips Hue RRD collector and dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
DynamicUser=yes
StateDirectory=hue-collector
WorkingDirectory=/srv/service_hue_collector
EnvironmentFile=/srv/service_hue_collector
Environment=DATA_DIR=/var/lib/hue-collector
ExecStart=/var/lib/hue-collector/.venv/bin/python3 -m hue_collector
Restart=on-failure
RestartSec=5
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
+65 -83
View File
@@ -1,17 +1,35 @@
from __future__ import annotations from __future__ import annotations
import html
import logging import logging
import os import os
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from io import BytesIO
from urllib.parse import parse_qs, urlencode, urlparse
from flask import Flask, Response, abort, render_template_string, request, send_file, url_for
from .collector import Collector, HueClient, Settings from .collector import Collector, HueClient, Settings
from .rrd_store import RRDStore from .rrd_store import RRDStore
LOG = logging.getLogger("hue-collector") LOG = logging.getLogger("hue-collector")
PERIODS = ("6h", "24h", "7d", "30d")
PAGE = '''<!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}.error{color:#fca5a5}img{display:block;width:100%;height:auto}
</style></head><body><header><div><h1>Philips Hue</h1><div class="status">{{ status }}{% if error %} — <span class="error">{{ error }}</span>{% endif %}</div></div>
<nav>{% for value in periods %}<a class="{{ 'active' if value == period else '' }}" href="{{ url_for('dashboard', range=value) }}">{{ value }}</a>{% endfor %}</nav></header>
<h1>Rooms and zones</h1>{% for item in groups %}<section><h2>{{ item.type|title }}: {{ item.name }}</h2>
<img loading="lazy" src="{{ url_for('graph', kind='group', item_id=item.id, range=period) }}" alt="Light history"></section>
{% else %}<p>No groups collected yet.</p>{% endfor %}
<h1>Sensors</h1>{% for item in sensors %}<section><h2>{{ item.name }}</h2>
<p>{{ item.room or item.zones or 'No room' }} · {% if item.value is none %}unavailable{% else %}{{ '%.1f'|format(item.value) }} {{ item.unit }}{% endif %}</p>
<img loading="lazy" src="{{ url_for('graph', kind='sensor', item_id=item.id, range=period) }}" alt="Sensor history"></section>
{% else %}<p>No sensors collected yet.</p>{% endfor %}</body></html>'''
class State: class State:
@@ -37,93 +55,57 @@ def collection_loop(collector: Collector, store: RRDStore, state: State, interva
time.sleep(max(1, interval - (time.monotonic() - started))) time.sleep(max(1, interval - (time.monotonic() - started)))
def page(state: State, period: str) -> bytes: def create_app(settings: Settings | None = None, *, start_collector: bool = True) -> Flask:
with state.lock: settings = settings or Settings.from_env()
snapshot = state.snapshot app = Flask(__name__)
last_success = state.last_success state = State()
error = state.error store = RRDStore(settings.data_dir, settings.interval)
status = f"Last update: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_success))}" if last_success else "Waiting for first collection" app.extensions["hue_state"] = state
if error: app.extensions["hue_rrd_store"] = store
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()
if start_collector:
collector = Collector(HueClient(settings))
threading.Thread(target=collection_loop, args=(collector, store, state, settings.interval), daemon=True).start()
def handler_for(state: State, store: RRDStore): @app.get("/")
class Handler(BaseHTTPRequestHandler): def dashboard():
def do_GET(self): period = request.args.get("range", "24h")
parsed = urlparse(self.path) if period not in PERIODS:
query = parse_qs(parsed.query) period = "24h"
if parsed.path == "/": with state.lock:
body, status, content_type = page(state, query.get("range", ["24h"])[0]), 200, "text/html; charset=utf-8" groups = sorted(state.snapshot["groups"], key=lambda item: (item["type"], item["name"].lower()))
elif parsed.path == "/healthz": sensors = sorted(state.snapshot["sensors"], key=lambda item: (item["type"], item["name"].lower()))
with state.lock: last_success, error = state.last_success, state.error
healthy = state.last_success is not None status = time.strftime("Last update: %Y-%m-%d %H:%M:%S", time.localtime(last_success)) if last_success else "Waiting for first collection"
body, status, content_type = (b"ok\n", 200, "text/plain") if healthy else (b"not ready\n", 503, "text/plain") return render_template_string(PAGE, groups=groups, sensors=sensors, status=status, error=error, periods=PERIODS, period=period)
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): @app.get("/graph/<kind>/<item_id>.png")
LOG.debug(fmt, *args) def graph(kind: str, item_id: str):
if kind not in {"group", "sensor"}:
abort(404)
with state.lock:
collection = state.snapshot["groups" if kind == "group" else "sensors"]
item = next((value for value in collection if value["id"] == item_id), None)
if item is None:
abort(404)
try:
image = store.graph(kind, item, request.args.get("range", "24h"))
except (ValueError, FileNotFoundError):
abort(404)
return send_file(BytesIO(image), mimetype="image/png", max_age=30)
return Handler @app.get("/healthz")
def health():
with state.lock:
healthy = state.last_success is not None
return Response("ok\n" if healthy else "not ready\n", status=200 if healthy else 503, mimetype="text/plain")
return app
def main() -> None: def main() -> None:
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
settings = Settings.from_env() settings = Settings.from_env()
state = State() app = create_app(settings)
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) LOG.info("Serving Hue dashboard on http://%s:%s", settings.listen_host, settings.listen_port)
ThreadingHTTPServer((settings.listen_host, settings.listen_port), handler_for(state, store)).serve_forever() app.run(host=settings.listen_host, port=settings.listen_port, debug=False, use_reloader=False)
+2 -2
View File
@@ -16,7 +16,7 @@ class Settings:
interval: int = 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" data_dir: str = "data"
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
@@ -31,7 +31,7 @@ class Settings:
interval=int(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"), data_dir=os.environ.get("DATA_DIR", "data"),
) )
+3
View File
@@ -0,0 +1,3 @@
# Install the native RRDtool binding with: apt install python3-rrdtool
Flask>=2.2,<4
requests>=2,<3