with out-temp

This commit is contained in:
2026-09-06 12:25:34 +02:00
parent 77bf334fce
commit dd8f7536d3
4 changed files with 35 additions and 12 deletions
+6 -5
View File
@@ -11,9 +11,10 @@ The service creates `netatmo_outdoor.rrd`, `netatmo_pressure.rrd`,
ten-minute archives for the last two weeks and six-hour archives covering a 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 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. 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 The rain database stores Netatmo's `sum_rain_1` and cumulative `sum_rain_24`
rain accumulated since midnight in millimetres. Its daily reset is preserved; readings. Day and two-week graphs show rain accumulated per hour; the year graph
MAX consolidation retains daily totals in the longer-term archive. shows rain accumulated per day. The midnight reset of `sum_rain_24` is preserved,
and MAX consolidation retains daily totals in the longer-term archive.
The Outdoor module does not contain a pressure sensor, so the dedicated pressure 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 graph reads it from the main station. Its Y axis is fixed at 9001100 mbar. A
@@ -143,10 +144,10 @@ curl --output wind.png http://localhost:5000/graph/wind/2weeks
Data points are: Data points are:
- `outdoor`: `min_temp`, `max_temp`, `humidity`, `pressure` - `outdoor`: `temperature`, `min_temp`, `max_temp`, `humidity`, `pressure`
- `pressure`: `pressure` - `pressure`: `pressure`
- `wind`: `gust`, `average`, `angle` - `wind`: `gust`, `average`, `angle`
- `rain`: `sum_rain_24` - `rain`: `sum_rain_24`, `sum_rain_1`
- `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity` - `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity`
The service creates missing RRDs at startup but deliberately does not alter an The service creates missing RRDs at startup but deliberately does not alter an
+2 -1
View File
@@ -41,7 +41,7 @@ def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"}) sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"})
# Netatmo resets this cumulative millimetre total at local midnight. Store # Netatmo resets this cumulative millimetre total at local midnight. Store
# it unchanged so historical graphs show the total accumulated each day. # it unchanged so historical graphs show the total accumulated each day.
sample("rain", {"sum_rain_24": "sum_rain_24"}) sample("rain", {"sum_rain_24": "sum_rain_24", "sum_rain_1": "sum_rain_1"})
# Pressure belongs to the main indoor station rather than a separate # Pressure belongs to the main indoor station rather than a separate
# module, so give it its own sample and use that station's timestamp. # module, so give it its own sample and use that station's timestamp.
@@ -61,6 +61,7 @@ def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
result["outdoor"] = Sample( result["outdoor"] = Sample(
int(data["time_utc"]), int(data["time_utc"]),
{ {
"temperature": _number(data.get("Temperature")),
"min_temp": _number(data.get("min_temp", data.get("Temperature"))), "min_temp": _number(data.get("min_temp", data.get("Temperature"))),
"max_temp": _number(data.get("max_temp", data.get("Temperature"))), "max_temp": _number(data.get("max_temp", data.get("Temperature"))),
"humidity": _number(data.get("Humidity")), "humidity": _number(data.get("Humidity")),
+24 -5
View File
@@ -18,10 +18,10 @@ HEARTBEAT = 1500
SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$") SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
SCHEMAS = { SCHEMAS = {
"outdoor": {"min_temp": "GAUGE:-60:70", "max_temp": "GAUGE:-60:70", "humidity": "GAUGE:0:100", "pressure": "GAUGE:800:1200"}, "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"}, "pressure": {"pressure": "GAUGE:800:1200"},
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"}, "wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
"rain": {"sum_rain_24": "GAUGE:0:1000"}, "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"}, "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"}, "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"}, "living": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
@@ -57,6 +57,7 @@ class RRDStore:
for name, schema in SCHEMAS.items(): for name, schema in SCHEMAS.items():
path = self.path(name) path = self.path(name)
if path.exists(): if path.exists():
self._ensure_data_sources(path, schema)
continue continue
# Leave room for a station that has been temporarily offline when the # Leave room for a station that has been temporarily offline when the
# RRD is first created; RRDtool rejects samples older than --start. # RRD is first created; RRDtool rejects samples older than --start.
@@ -67,6 +68,17 @@ class RRDStore:
"RRA:AVERAGE:0.5:36:1464", "RRA:MIN:0.5:36:1464", "RRA:MAX:0.5:36:1464"] "RRA:AVERAGE:0.5:36:1464", "RRA:MIN:0.5:36:1464", "RRA:MAX:0.5:36:1464"]
rrdtool.create(str(path), *args) 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: def update(self, name: str, sample: Sample) -> bool:
"""Store at most one sample per RRD step; return whether it was written.""" """Store at most one sample per RRD step; return whether it was written."""
path = self.path(name) path = self.path(name)
@@ -139,14 +151,21 @@ class RRDStore:
if name == "wind": if name == "wind":
drawings = self._wind_drawings() drawings = self._wind_drawings()
elif name == "rain": elif name == "rain":
drawings = ["AREA:sum_rain_24#3498DB80:Rain today (mm)", rain_field = "sum_rain_24" if period == "year" else "sum_rain_1"
"LINE2:sum_rain_24#2471A3:Rain today (mm)"] 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": elif name == "pressure":
drawings = ["AREA:pressure_floor#00000000", drawings = ["AREA:pressure_floor#00000000",
"AREA:pressure_above_floor#77787280:Pressure (mbar):STACK", "AREA:pressure_above_floor#77787280:Pressure (mbar):STACK",
"LINE1:pressure#111111"] "LINE1:pressure#111111"]
elif name == "outdoor": elif name == "outdoor":
drawings = ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)", 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)"] "LINE1:humidity_scaled#27AE60:Humidity (% / 2.5)"]
else: else:
drawings = ["AREA:temperature#E74C3C70:Temperature (C)", drawings = ["AREA:temperature#E74C3C70:Temperature (C)",
+2
View File
@@ -53,9 +53,11 @@ def test_extract_sample_payload():
names = {name: name.title() for name in ("outdoor", "wind", "rain", "bedroom", "study", "living")} names = {name: name.title() for name in ("outdoor", "wind", "rain", "bedroom", "study", "living")}
samples = extract_samples(namespace["payload"], names) samples = extract_samples(namespace["payload"], names)
assert samples["outdoor"].values["pressure"] == 1004.8 assert samples["outdoor"].values["pressure"] == 1004.8
assert samples["outdoor"].values["temperature"] == 18.3
assert samples["pressure"].values["pressure"] == 1004.8 assert samples["pressure"].values["pressure"] == 1004.8
assert samples["wind"].values["angle"] == 236.0 assert samples["wind"].values["angle"] == 236.0
assert samples["rain"].values["sum_rain_24"] == 9.8 assert samples["rain"].values["sum_rain_24"] == 9.8
assert samples["rain"].values["sum_rain_1"] == 1.9200000000000002
assert "bedroom" not in samples # unreachable module has no dashboard_data assert "bedroom" not in samples # unreachable module has no dashboard_data