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)