96 lines
4.2 KiB
Python
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)
|