1st POC
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import rrdtool
|
||||
|
||||
from .data import Sample
|
||||
|
||||
RRD_ERROR = rrdtool.OperationalError
|
||||
|
||||
STEP = 600
|
||||
HEARTBEAT = 1500
|
||||
SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
SCHEMAS = {
|
||||
"outdoor": {"min_temp": "GAUGE:-60:70", "max_temp": "GAUGE:-60:70", "humidity": "GAUGE:0:100", "pressure": "GAUGE:800:1200"},
|
||||
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
|
||||
"bedroom": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||
"study": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||
"living": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||
}
|
||||
|
||||
PERIODS = {"day": ("1 day", "-1d"), "2weeks": ("2 weeks", "-14d"), "year": ("1 year", "-1y")}
|
||||
ALIASES = {"1day": "day", "14days": "2weeks", "2week": "2weeks", "1year": "year"}
|
||||
|
||||
|
||||
class RRDStore:
|
||||
def __init__(self, folder: Path, width: int = 900, height: int = 240):
|
||||
self.folder = Path(folder)
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def path(self, name: str) -> Path:
|
||||
if name not in SCHEMAS:
|
||||
raise KeyError(name)
|
||||
return self.folder / f"{name}.rrd"
|
||||
|
||||
def ensure_all(self) -> None:
|
||||
self.folder.mkdir(parents=True, exist_ok=True)
|
||||
for name, schema in SCHEMAS.items():
|
||||
path = self.path(name)
|
||||
if path.exists():
|
||||
continue
|
||||
# Leave room for a station that has been temporarily offline when the
|
||||
# RRD is first created; RRDtool rejects samples older than --start.
|
||||
args = ["--step", str(STEP), "--start", "now-1d"]
|
||||
args += [f"DS:{field}:{definition.split(':', 1)[0]}:{HEARTBEAT}:{definition.split(':', 1)[1]}" for field, definition in schema.items()]
|
||||
# 10-minute points for 2 weeks; 6-hour averages for over a year.
|
||||
args += ["RRA:AVERAGE:0.5:1:2016", "RRA:MIN:0.5:1:2016", "RRA:MAX:0.5:1:2016",
|
||||
"RRA:AVERAGE:0.5:36:1464", "RRA:MIN:0.5:36:1464", "RRA:MAX:0.5:36:1464"]
|
||||
rrdtool.create(str(path), *args)
|
||||
|
||||
def update(self, name: str, sample: Sample) -> None:
|
||||
schema = SCHEMAS[name]
|
||||
fields = list(schema)
|
||||
values = ["U" if sample.values.get(field) is None else str(sample.values[field]) for field in fields]
|
||||
rrdtool.update(
|
||||
str(self.path(name)),
|
||||
"--template", ":".join(fields),
|
||||
f"{sample.timestamp}:{':'.join(values)}",
|
||||
)
|
||||
|
||||
def last(self, name: str, field: str) -> dict:
|
||||
if name not in SCHEMAS or field not in SCHEMAS[name]:
|
||||
raise KeyError(f"{name}/{field}")
|
||||
latest = rrdtool.lastupdate(str(self.path(name)))
|
||||
value = latest["ds"][field]
|
||||
return {
|
||||
"rrd": name,
|
||||
"data_point": field,
|
||||
"timestamp": int(latest["date"].timestamp()),
|
||||
"value": None if value is None else float(value),
|
||||
}
|
||||
|
||||
def graph(self, name: str, period: str) -> bytes:
|
||||
period = ALIASES.get(period, period)
|
||||
if name not in SCHEMAS or period not in PERIODS:
|
||||
raise KeyError(f"{name}/{period}")
|
||||
title, start = PERIODS[period]
|
||||
common = ["--imgformat", "PNG", "--start", start, "--end", "now",
|
||||
"--width", str(self.width), "--height", str(self.height), "--title", f"{name.title()} - {title}",
|
||||
"--slope-mode", "--watermark", "GetNetatmoData v2"]
|
||||
definitions = [f"DEF:{field}={self.path(name)}:{field}:AVERAGE" for field in SCHEMAS[name]]
|
||||
if name == "wind":
|
||||
drawings = self._wind_drawings()
|
||||
elif name == "outdoor":
|
||||
drawings = ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)",
|
||||
"LINE1:humidity#27AE60:Humidity (%)", "LINE1:pressure#8E44AD:Pressure (hPa):dashes"]
|
||||
else:
|
||||
drawings = ["LINE2:temperature#E74C3C:Temperature (C)", "LINE1:co2#8E44AD:CO2 (ppm)", "LINE1:humidity#27AE60:Humidity (%):dashes"]
|
||||
# The Python binding returns graph metadata, not the encoded image, so
|
||||
# render to an isolated temporary file and return its contents.
|
||||
with tempfile.TemporaryDirectory(prefix="netatmo-graph-") as folder:
|
||||
image_path = Path(folder) / f"{name}-{period}.png"
|
||||
rrdtool.graph(str(image_path), *(common + definitions + drawings))
|
||||
return image_path.read_bytes()
|
||||
|
||||
@staticmethod
|
||||
def _wind_drawings() -> list[str]:
|
||||
result: list[str] = []
|
||||
# Split average wind into 12 angle bands. Each AREA starts at zero and
|
||||
# only exists for its direction, producing rainbow-colored bars/areas.
|
||||
for index in range(12):
|
||||
low, high = index * 30, (index + 1) * 30
|
||||
color = "#%02X%02X%02X80" % tuple(round(channel * 255) for channel in colorsys.hsv_to_rgb(index / 12, .85, .9))
|
||||
result += [f"CDEF:dir{index}=angle,{low},GE,angle,{high},LT,*,average,UNKN,IF", f"AREA:dir{index}{color}:{low:03d}-{high:03d} degrees"]
|
||||
result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"]
|
||||
return result
|
||||
Reference in New Issue
Block a user