From 77bf334fce6239d7d4a6bc4e87a652f12ddc3dbf Mon Sep 17 00:00:00 2001 From: Ignace Date: Thu, 3 Sep 2026 22:26:40 +0200 Subject: [PATCH] added rain, updated graph styles --- README.md | 16 ++++-- config.example.yaml | 3 +- netatmo_service/app.py | 8 ++- netatmo_service/config.py | 6 +-- netatmo_service/data.py | 15 +++++- netatmo_service/rrd.py | 74 +++++++++++++++++++++++----- netatmo_service/static/dashboard.css | 19 ++++--- tests/test_rrd.py | 18 ++++++- tests/test_service.py | 4 +- 9 files changed, 128 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 38840b5..4b5110e 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,19 @@ the readings in RRDtool databases, and serves a responsive graph dashboard. ## What it stores -The service creates `netatmo_outdoor.rrd`, `netatmo_wind.rrd`, +The service creates `netatmo_outdoor.rrd`, `netatmo_pressure.rrd`, +`netatmo_wind.rrd`, `netatmo_rain.rrd`, `netatmo_bedroom.rrd`, `netatmo_study.rrd`, and `netatmo_living.rrd`. Each has ten-minute archives for the last two weeks and six-hour archives covering a year. Day, two-week, and year PNG graphs are rendered on demand. The wind graph splits average wind into 12 colored direction bands and draws gusts as a line. +The rain graph stores Netatmo's cumulative `sum_rain_24` reading and shows the +rain accumulated since midnight in millimetres. Its daily reset is preserved; +MAX consolidation retains daily totals in the longer-term archive. -The Outdoor module does not contain a pressure sensor, so `outdoor.rrd` combines -its temperature/humidity with pressure from the main station. A module without -`dashboard_data` is skipped until it becomes reachable again. +The Outdoor module does not contain a pressure sensor, so the dedicated pressure +graph reads it from the main station. Its Y axis is fixed at 900–1100 mbar. A +module without `dashboard_data` is skipped until it becomes reachable again. ## Prerequisites and setup @@ -71,7 +75,7 @@ scheduler runs inside the service process. Alternatively set ``` `storage.rrd_folder` configures the database directory (default `./rrd`). The -`modules` section allows the five Netatmo display names to be changed. +`modules` section allows the Netatmo display names to be changed. Set `server.url_prefix: /w` to mount the dashboard, static assets, and every API endpoint below `/w`; leave it empty to serve from the site root. When a prefix is set, open instead. @@ -140,7 +144,9 @@ curl --output wind.png http://localhost:5000/graph/wind/2weeks Data points are: - `outdoor`: `min_temp`, `max_temp`, `humidity`, `pressure` +- `pressure`: `pressure` - `wind`: `gust`, `average`, `angle` +- `rain`: `sum_rain_24` - `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity` The service creates missing RRDs at startup but deliberately does not alter an diff --git a/config.example.yaml b/config.example.yaml index 7379369..56b8c8e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,7 +21,7 @@ collector: graphs: width: 900 - height: 240 + height: 420 netatmo: client_id: REPLACE_ME @@ -37,6 +37,7 @@ netatmo: modules: outdoor: Outdoor wind: Wind + rain: Rain bedroom: Bedroom study: Study living: Living diff --git a/netatmo_service/app.py b/netatmo_service/app.py index 2d9cac8..ee9c96e 100644 --- a/netatmo_service/app.py +++ b/netatmo_service/app.py @@ -103,7 +103,13 @@ def create_app(test_config: dict | None = None, config_path=None) -> Flask: ) store.ensure_all() client = app.config.get("NETATMO_CLIENT") or NetatmoClient(app.config) - names = {key: app.config[f"MODULE_{key.upper()}"] for key in SCHEMAS} + # Pressure is read directly from the main station and has no configurable + # module display name. + names = { + key: app.config[f"MODULE_{key.upper()}"] + for key in SCHEMAS + if key != "pressure" + } collector = Collector(client, store, names, app.config["POLL_INTERVAL"]) app.extensions["rrd_store"] = store app.extensions["netatmo_collector"] = collector diff --git a/netatmo_service/config.py b/netatmo_service/config.py index 0472af5..c8207de 100644 --- a/netatmo_service/config.py +++ b/netatmo_service/config.py @@ -25,12 +25,12 @@ def default_config(base: Path | None = None) -> dict[str, Any]: "LOG_MAX_BYTES": 5242880, "LOG_BACKUP_COUNT": 5, "LOG_CONSOLE": True, "NETATMO_TOKEN_FILE": rrd / "netatmo_tokens.json", "NETATMO_REDIRECT_URI": "http://localhost:5000/", - "GRAPH_WIDTH": 900, "GRAPH_HEIGHT": 240, "POLL_INTERVAL": 600, + "GRAPH_WIDTH": 900, "GRAPH_HEIGHT": 420, "POLL_INTERVAL": 600, "START_COLLECTOR": True, "NETATMO_CLIENT_ID": "", "NETATMO_CLIENT_SECRET": "", "NETATMO_REFRESH_TOKEN": "", "NETATMO_ACCESS_TOKEN": "", "NETATMO_DEVICE_ID": "", "NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token", "NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata", - "MODULE_OUTDOOR": "Outdoor", "MODULE_WIND": "Wind", "MODULE_BEDROOM": "Bedroom", + "MODULE_OUTDOOR": "Outdoor", "MODULE_WIND": "Wind", "MODULE_RAIN": "Rain", "MODULE_BEDROOM": "Bedroom", "MODULE_STUDY": "Study", "MODULE_LIVING": "Living", } @@ -87,6 +87,6 @@ def load_config(filename: str | Path | None = None) -> dict[str, Any]: result["NETATMO_TOKEN_FILE"] = _path( netatmo.get("token_file", result["RRD_FOLDER"] / "netatmo_tokens.json"), base ) - for name in ("outdoor", "wind", "bedroom", "study", "living"): + for name in ("outdoor", "wind", "rain", "bedroom", "study", "living"): result[f"MODULE_{name.upper()}"] = str(modules.get(name, result[f"MODULE_{name.upper()}"])) return result diff --git a/netatmo_service/data.py b/netatmo_service/data.py index b6bace2..ae649eb 100644 --- a/netatmo_service/data.py +++ b/netatmo_service/data.py @@ -15,7 +15,7 @@ def _dashboard(item: dict[str, Any]) -> dict[str, Any]: def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]: - """Translate a getstationsdata response into the service's five RRD samples.""" + """Translate a getstationsdata response into the service's RRD samples.""" devices = payload.get("body", {}).get("devices", []) if not devices: raise ValueError("Netatmo response contains no weather station") @@ -39,6 +39,18 @@ def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]: sample("study", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"}) sample("living", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"}) sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"}) + # Netatmo resets this cumulative millimetre total at local midnight. Store + # it unchanged so historical graphs show the total accumulated each day. + sample("rain", {"sum_rain_24": "sum_rain_24"}) + + # Pressure belongs to the main indoor station rather than a separate + # module, so give it its own sample and use that station's timestamp. + station_data = _dashboard(device) + if station_data: + result["pressure"] = Sample( + int(station_data["time_utc"]), + {"pressure": _number(station_data.get("Pressure"))}, + ) outdoor = by_name.get(names["outdoor"]) if outdoor and _dashboard(outdoor): @@ -62,4 +74,3 @@ def _number(value: Any) -> float | None: if value is None: return None return float(value) - diff --git a/netatmo_service/rrd.py b/netatmo_service/rrd.py index 6328528..d7e509d 100644 --- a/netatmo_service/rrd.py +++ b/netatmo_service/rrd.py @@ -19,7 +19,9 @@ 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"}, + "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"}, "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"}, @@ -29,8 +31,18 @@ PERIODS = {"day": ("1 day", "-1d"), "2weeks": ("2 weeks", "-14d"), "year": ("1 y 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 = 240): + def __init__(self, folder: Path, width: int = 900, height: int = 420): self.folder = Path(folder) self.width = width self.height = height @@ -95,15 +107,52 @@ class RRDStore: 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]] + "--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": + 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"] 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"] + "LINE1:humidity_scaled#27AE60:Humidity (% / 2.5)"] else: - drawings = ["LINE2:temperature#E74C3C:Temperature (C)", "LINE1:co2#8E44AD:CO2 (ppm)", "LINE1:humidity#27AE60:Humidity (%):dashes"] + 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: @@ -114,11 +163,14 @@ class RRDStore: @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"] + # 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 diff --git a/netatmo_service/static/dashboard.css b/netatmo_service/static/dashboard.css index 4766674..51655da 100644 --- a/netatmo_service/static/dashboard.css +++ b/netatmo_service/static/dashboard.css @@ -1,15 +1,14 @@ -:root { color-scheme: dark; --ink:#eef3ec; --muted:#a7b3a8; --panel:#17221e; --accent:#b6e36f; } +:root { color-scheme: light; --ink:#111; --muted:#4f4c45; --panel:#faf8f2; --accent:#111; --paper:#f4f1e8; } * { box-sizing:border-box; } -body { margin:0; padding:2rem; background:#0c1411; color:var(--ink); font:16px/1.5 system-ui,sans-serif; } -header { max-width:1200px; margin:auto auto 2rem; display:flex; align-items:end; justify-content:space-between; gap:1.5rem; } +body { margin:0; padding:2rem; background:var(--paper); color:var(--ink); font:16px/1.5 system-ui,sans-serif; } +header { max-width:1000px; margin:auto auto 2rem; display:flex; align-items:end; justify-content:space-between; gap:1.5rem; } h1 { margin:.1rem 0 0; font-size:clamp(2rem,5vw,4rem); letter-spacing:-.05em; } -.eyebrow { color:var(--accent); text-transform:uppercase; letter-spacing:.15em; font-size:.75rem; } -nav { display:flex; padding:.3rem; border:1px solid #304139; border-radius:999px; background:#111b17; } +.eyebrow { color:var(--muted); text-transform:uppercase; letter-spacing:.15em; font-size:.75rem; } +nav { display:flex; padding:.3rem; border:1px solid #bdb7aa; border-radius:999px; background:#e9e5db; } button { border:0; border-radius:999px; padding:.65rem 1rem; color:var(--muted); background:transparent; cursor:pointer; } -button.active { color:#172012; background:var(--accent); } -main { max-width:1200px; margin:auto; display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,480px),1fr)); gap:1rem; } -.card { overflow:hidden; padding:1rem; border:1px solid #26362f; border-radius:1rem; background:var(--panel); box-shadow:0 18px 45px #0004; } +button.active { color:#f8f5ed; background:var(--accent); } +main { max-width:1000px; margin:auto; display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; } +.card { overflow:hidden; padding:1rem; border:1px solid #c9c3b7; border-radius:1rem; background:var(--panel); box-shadow:0 12px 30px #3d382b18; } h2 { margin:0 0 .75rem; font-size:1rem; color:var(--muted); font-weight:600; } -img { display:block; width:100%; min-height:180px; object-fit:contain; background:white; border-radius:.45rem; } +img { display:block; width:100%; height:auto; object-fit:contain; background:#faf8f2; border-radius:.45rem; } @media (max-width:700px) { body{padding:1rem} header{align-items:start;flex-direction:column} nav{width:100%} button{flex:1} } - diff --git a/tests/test_rrd.py b/tests/test_rrd.py index 10889f7..b9a05f9 100644 --- a/tests/test_rrd.py +++ b/tests/test_rrd.py @@ -1,11 +1,27 @@ from netatmo_service.data import Sample -from netatmo_service.rrd import RRDStore +from netatmo_service.rrd import RRDStore, rainbow + + +def test_wind_rainbow_uses_compass_angle(): + assert rainbow(0) == (0, 255, 255) + assert rainbow(90) == (128, 0, 255) + assert rainbow(180) == (255, 0, 0) + assert rainbow(270) == (128, 255, 0) + + +def test_each_wind_angle_has_an_unlabelled_color(): + drawings = RRDStore._wind_drawings() + assert len(drawings) == 722 + assert sum(item.startswith("AREA:dir") for item in drawings) == 360 + assert not any("degrees" in item for item in drawings) def test_rrd_files_have_netatmo_prefix(tmp_path): store = RRDStore(tmp_path) assert store.path("outdoor") == tmp_path / "netatmo_outdoor.rrd" assert store.path("wind") == tmp_path / "netatmo_wind.rrd" + assert store.path("rain") == tmp_path / "netatmo_rain.rrd" + assert store.path("pressure") == tmp_path / "netatmo_pressure.rrd" def test_rrd_is_updated_only_once_per_collection_interval(tmp_path): diff --git a/tests/test_service.py b/tests/test_service.py index e75733b..781629b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -50,10 +50,12 @@ def test_url_prefix_mounts_all_routes(): def test_extract_sample_payload(): namespace = {} exec(Path("payload_dict.py").read_text(), namespace) - names = {name: name.title() for name in ("outdoor", "wind", "bedroom", "study", "living")} + names = {name: name.title() for name in ("outdoor", "wind", "rain", "bedroom", "study", "living")} samples = extract_samples(namespace["payload"], names) assert samples["outdoor"].values["pressure"] == 1004.8 + assert samples["pressure"].values["pressure"] == 1004.8 assert samples["wind"].values["angle"] == 236.0 + assert samples["rain"].values["sum_rain_24"] == 9.8 assert "bedroom" not in samples # unreachable module has no dashboard_data