Files
2026-09-06 15:31:57 +02:00

96 lines
4.2 KiB
Python

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)