added rain, updated graph styles

This commit is contained in:
2026-09-03 22:26:40 +02:00
parent 67d8b50333
commit 77bf334fce
9 changed files with 128 additions and 35 deletions
+11 -5
View File
@@ -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 9001100 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 <http://localhost:5000/w/> 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
+2 -1
View File
@@ -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
+7 -1
View File
@@ -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
+3 -3
View File
@@ -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
+13 -2
View File
@@ -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)
+63 -11
View File
@@ -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
+9 -10
View File
@@ -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} }
+17 -1
View File
@@ -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):
+3 -1
View File
@@ -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