from __future__ import annotations import colorsys import logging import re import tempfile from pathlib import Path import rrdtool from .data import Sample RRD_ERROR = rrdtool.OperationalError LOG = logging.getLogger(__name__) STEP = 600 HEARTBEAT = 1500 SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$") SCHEMAS = { "outdoor": {"temperature": "GAUGE:-60:70", "min_temp": "GAUGE:-60:70", "max_temp": "GAUGE:-60:70", "humidity": "GAUGE:0:100", "pressure": "GAUGE:800:1200"}, "pressure": {"pressure": "GAUGE:800:1200"}, "wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"}, "rain": {"sum_rain_24": "GAUGE:0:1000", "sum_rain_1": "GAUGE:0:1000"}, "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"} 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) ) class RRDStore: def __init__(self, folder: Path, width: int = 900, height: int = 420): 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"netatmo_{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(): self._ensure_data_sources(path, schema) 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) @staticmethod def _ensure_data_sources(path: Path, schema: dict[str, str]) -> None: """Add newly introduced data sources without discarding RRD history.""" info = rrdtool.info(str(path)) for field, definition in schema.items(): if f"ds[{field}].index" in info: continue data_type, limits = definition.split(":", 1) LOG.info("Adding %s data source to %s", field, path) rrdtool.tune(str(path), f"DS:{field}:{data_type}:{HEARTBEAT}:{limits}") 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 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(path), "--template", ":".join(fields), f"{sample.timestamp}:{':'.join(values)}", ) return True 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", "--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,-"] if name == "wind": drawings = self._wind_drawings() elif name == "rain": rain_field = "sum_rain_24" if period == "year" else "sum_rain_1" rain_label = "Rain per day (mm)" if period == "year" else "Rain per hour (mm)" drawings = [f"AREA:{rain_field}#3498DB80:{rain_label}", f"LINE2:{rain_field}#2471A3"] elif name == "pressure": drawings = ["AREA:pressure_floor#00000000", "AREA:pressure_above_floor#77787280:Pressure (mbar):STACK", "LINE1:pressure#111111"] elif name == "outdoor": drawings = [] if period == "year" else [ "AREA:temperature#E74C3C70:Temperature (C)", "LINE1:temperature#A93226", ] drawings += ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)", "LINE1:humidity_scaled#27AE60:Humidity (% / 2.5)"] else: 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"] # 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] = [] # 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}", ] result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"] return result