2026-08-30 11:01:19 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import colorsys
|
2026-08-30 13:38:55 +02:00
|
|
|
import logging
|
2026-08-30 11:01:19 +02:00
|
|
|
import re
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import rrdtool
|
|
|
|
|
|
|
|
|
|
from .data import Sample
|
|
|
|
|
|
|
|
|
|
RRD_ERROR = rrdtool.OperationalError
|
2026-08-30 13:38:55 +02:00
|
|
|
LOG = logging.getLogger(__name__)
|
2026-08-30 11:01:19 +02:00
|
|
|
|
|
|
|
|
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"},
|
2026-09-03 22:26:40 +02:00
|
|
|
"pressure": {"pressure": "GAUGE:800:1200"},
|
2026-08-30 11:01:19 +02:00
|
|
|
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
|
2026-09-03 22:26:40 +02:00
|
|
|
"rain": {"sum_rain_24": "GAUGE:0:1000"},
|
2026-08-30 11:01:19 +02:00
|
|
|
"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"}
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 22:26:40 +02:00
|
|
|
def rainbow(angle: float) -> tuple[int, int, int]:
|
|
|
|
|
"""Map a compass angle to an RGB rainbow color."""
|
|
|
|
|
hue = (angle - 180) / 360
|
|
|
|
|
hue %= 1.0
|
|
|
|
|
return tuple(
|
|
|
|
|
round(channel * 255)
|
|
|
|
|
for channel in colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 11:01:19 +02:00
|
|
|
class RRDStore:
|
2026-09-03 22:26:40 +02:00
|
|
|
def __init__(self, folder: Path, width: int = 900, height: int = 420):
|
2026-08-30 11:01:19 +02:00
|
|
|
self.folder = Path(folder)
|
|
|
|
|
self.width = width
|
|
|
|
|
self.height = height
|
|
|
|
|
|
|
|
|
|
def path(self, name: str) -> Path:
|
|
|
|
|
if name not in SCHEMAS:
|
|
|
|
|
raise KeyError(name)
|
2026-08-30 12:30:07 +02:00
|
|
|
return self.folder / f"netatmo_{name}.rrd"
|
2026-08-30 11:01:19 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-30 13:38:55 +02:00
|
|
|
def update(self, name: str, sample: Sample) -> bool:
|
|
|
|
|
"""Store at most one sample per RRD step; return whether it was written."""
|
|
|
|
|
path = self.path(name)
|
|
|
|
|
last_timestamp = int(rrdtool.last(str(path)))
|
|
|
|
|
if sample.timestamp // STEP <= last_timestamp // STEP:
|
|
|
|
|
LOG.info(
|
|
|
|
|
"Skipping %s RRD update: sample interval already stored "
|
|
|
|
|
"(sample_timestamp=%d last_timestamp=%d)",
|
|
|
|
|
name, sample.timestamp, last_timestamp,
|
|
|
|
|
)
|
|
|
|
|
return False
|
2026-08-30 11:01:19 +02:00
|
|
|
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(
|
2026-08-30 13:38:55 +02:00
|
|
|
str(path),
|
2026-08-30 11:01:19 +02:00
|
|
|
"--template", ":".join(fields),
|
|
|
|
|
f"{sample.timestamp}:{':'.join(values)}",
|
|
|
|
|
)
|
2026-08-30 13:38:55 +02:00
|
|
|
return True
|
2026-08-30 11:01:19 +02:00
|
|
|
|
|
|
|
|
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}",
|
2026-09-03 22:26:40 +02:00
|
|
|
"--slope-mode", "--watermark", "GetNetatmoData v2",
|
|
|
|
|
"--color", "BACK#F4F1E8", "--color", "CANVAS#FAF8F2",
|
|
|
|
|
"--color", "FONT#111111", "--color", "AXIS#111111",
|
|
|
|
|
"--color", "FRAME#111111", "--color", "ARROW#111111",
|
|
|
|
|
"--color", "GRID#D8D3C7", "--color", "MGRID#AAA397"]
|
|
|
|
|
if name == "pressure":
|
|
|
|
|
common += ["--lower-limit", "900", "--upper-limit", "1100", "--rigid",
|
|
|
|
|
"--vertical-label", "Pressure (mbar)", "--units-exponent", "0"]
|
|
|
|
|
elif name in {"outdoor", "bedroom", "study", "living"}:
|
|
|
|
|
common += ["--lower-limit", "0", "--upper-limit", "40", "--rigid",
|
|
|
|
|
"--vertical-label", "Temperature (C) / humidity (% / 2.5)"]
|
|
|
|
|
if name != "outdoor":
|
|
|
|
|
# CO2 is divided by 50 onto the 0-40 plotting range; the right
|
|
|
|
|
# axis converts those positions back to their 0-2000 ppm labels.
|
|
|
|
|
common += ["--right-axis", "50:0", "--right-axis-label", "CO2 (ppm)",
|
|
|
|
|
"--right-axis-format", "%.0lf",
|
|
|
|
|
"--y-grid", "4:1",
|
|
|
|
|
"--units-exponent", "0"]
|
|
|
|
|
# MAX retains each day's highest cumulative rain reading in the coarser
|
|
|
|
|
# archive, rather than averaging away the total when the counter resets.
|
|
|
|
|
consolidation = "MAX" if name == "rain" else "AVERAGE"
|
|
|
|
|
definitions = [f"DEF:{field}={self.path(name)}:{field}:{consolidation}" for field in SCHEMAS[name]]
|
|
|
|
|
if "humidity" in SCHEMAS[name]:
|
|
|
|
|
definitions.append("CDEF:humidity_scaled=humidity,2.5,/")
|
|
|
|
|
if "co2" in SCHEMAS[name]:
|
|
|
|
|
definitions.append("CDEF:co2_scaled=co2,50,/")
|
|
|
|
|
if name == "pressure":
|
|
|
|
|
definitions += ["CDEF:pressure_floor=pressure,UN,UNKN,900,IF",
|
|
|
|
|
"CDEF:pressure_above_floor=pressure,900,-"]
|
2026-08-30 11:01:19 +02:00
|
|
|
if name == "wind":
|
|
|
|
|
drawings = self._wind_drawings()
|
2026-09-03 22:26:40 +02:00
|
|
|
elif name == "rain":
|
|
|
|
|
drawings = ["AREA:sum_rain_24#3498DB80:Rain today (mm)",
|
|
|
|
|
"LINE2:sum_rain_24#2471A3:Rain today (mm)"]
|
|
|
|
|
elif name == "pressure":
|
|
|
|
|
drawings = ["AREA:pressure_floor#00000000",
|
|
|
|
|
"AREA:pressure_above_floor#77787280:Pressure (mbar):STACK",
|
|
|
|
|
"LINE1:pressure#111111"]
|
2026-08-30 11:01:19 +02:00
|
|
|
elif name == "outdoor":
|
|
|
|
|
drawings = ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)",
|
2026-09-03 22:26:40 +02:00
|
|
|
"LINE1:humidity_scaled#27AE60:Humidity (% / 2.5)"]
|
2026-08-30 11:01:19 +02:00
|
|
|
else:
|
2026-09-03 22:26:40 +02:00
|
|
|
drawings = ["AREA:temperature#E74C3C70:Temperature (C)",
|
|
|
|
|
"LINE1:temperature#A93226",
|
|
|
|
|
"LINE2:co2_scaled#8E44AD:CO2 (ppm, right axis)",
|
|
|
|
|
"LINE2:humidity_scaled#27AE60:Humidity (% / 2.5):dashes"]
|
2026-08-30 11:01:19 +02:00
|
|
|
# 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] = []
|
2026-09-03 22:26:40 +02:00
|
|
|
# Give every compass degree its own color. The AREA entries deliberately
|
|
|
|
|
# have no labels, keeping the 360-color key out of the graph legend.
|
|
|
|
|
for angle in range(360):
|
|
|
|
|
color = "#%02X%02X%02X80" % rainbow(angle)
|
|
|
|
|
upper_comparison = "LE" if angle == 359 else "LT"
|
|
|
|
|
result += [
|
|
|
|
|
f"CDEF:dir{angle}=angle,{angle},GE,angle,{angle + 1},{upper_comparison},*,average,UNKN,IF",
|
|
|
|
|
f"AREA:dir{angle}{color}",
|
|
|
|
|
]
|
2026-08-30 11:01:19 +02:00
|
|
|
result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"]
|
|
|
|
|
return result
|