This commit is contained in:
2026-08-30 11:01:19 +02:00
parent a34cf42f0e
commit fcae3569fc
17 changed files with 679 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
# Registered Netatmo application: GetNetatmoData v2
NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152
NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8
NETATMO_REFRESH_TOKEN=
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
# NETATMO_ACCESS_TOKEN=
# NETATMO_DEVICE_ID=
RRD_FOLDER=./rrd
POLL_INTERVAL=600
START_COLLECTOR=true
# Override these if the names in the Netatmo app differ.
MODULE_OUTDOOR=Outdoor
MODULE_WIND=Wind
MODULE_BEDROOM=Bedroom
MODULE_STUDY=Study
MODULE_LIVING=Living
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.pytest_cache/
.env
rrd/
+80 -1
View File
@@ -1,2 +1,81 @@
# service_netatmo
# GetNetatmoData v2
A small Flask service that polls Netatmo's Weather API every ten minutes, stores
the readings in RRDtool databases, and serves a responsive graph dashboard.
## What it stores
The service creates `outdoor.rrd`, `wind.rrd`, `bedroom.rrd`, `study.rrd`, and
`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 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.
## Prerequisites and setup
Install the RRDtool Python binding and native library using your operating
system package manager (for example `apt install python3-rrdtool` on Debian or
Ubuntu), then set up the Python application. If your distribution does not
provide the binding, `pip install -r requirements.txt` installs its PyPI package
but may require the RRDtool development headers and a C compiler.
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
```
Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
and a refresh token to `.env`. Netatmo's OAuth authorization step must be used to
obtain the initial refresh token with the `read_station` scope. Secrets are read
from environment variables and are never stored in an RRD.
Export the file and run Flask:
```sh
set -a
. ./.env
set +a
.venv/bin/flask --app wsgi run --host 0.0.0.0
```
Open <http://localhost:5000/>. Keep one application worker because the polling
scheduler runs inside the service process. Alternatively set
`START_COLLECTOR=false` in web workers and invoke this from a system timer:
```sh
.venv/bin/flask --app wsgi collect-now
```
`RRD_FOLDER` configures the database directory (default `./rrd`). The module
variables in `.env.example` allow the five Netatmo display names to be changed.
## HTTP API
```text
GET /last/<rrd-name>/<data-point>
GET /graph/<rrd-name>/<period>
GET /graph/<rrd-name><period> # compact compatibility form
GET /health
```
Periods are `day`, `2weeks`, and `year`. Examples:
```sh
curl http://localhost:5000/last/outdoor/humidity
curl --output wind.png http://localhost:5000/graph/wind/2weeks
```
Data points are:
- `outdoor`: `min_temp`, `max_temp`, `humidity`, `pressure`
- `wind`: `gust`, `average`, `angle`
- `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity`
The service creates missing RRDs at startup but deliberately does not alter an
existing RRD schema. If a schema is changed in code, migrate or archive the old
files before restarting.
+6
View File
@@ -0,0 +1,6 @@
"""Netatmo to RRDtool web service."""
from .app import create_app
__all__ = ["create_app"]
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import logging
import os
from flask import Flask, Response, abort, jsonify, render_template
from .collector import Collector
from .config import Config
from .netatmo import NetatmoClient
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
def create_app(test_config: dict | None = None) -> Flask:
app = Flask(__name__)
app.config.from_object(Config)
if test_config:
app.config.update(test_config)
logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO"))
store = app.config.get("RRD_STORE") or RRDStore(
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
)
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}
collector = Collector(client, store, names, app.config["POLL_INTERVAL"])
app.extensions["rrd_store"] = store
app.extensions["netatmo_collector"] = collector
# Flask's debug reloader imports twice. Only its serving child starts a poller.
should_start = app.config["START_COLLECTOR"] and client.configured
if should_start and (not app.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true"):
collector.start()
elif app.config["START_COLLECTOR"] and not client.configured:
app.logger.warning("Collector disabled: configure NETATMO_ACCESS_TOKEN or OAuth refresh credentials")
@app.get("/")
def dashboard():
return render_template("dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS))
@app.get("/health")
def health():
return jsonify(status="ok", collector_configured=client.configured)
@app.get("/last/<rrd_name>/<data_point>")
def last(rrd_name: str, data_point: str):
try:
return jsonify(store.last(rrd_name.lower(), data_point.lower()))
except KeyError:
abort(404, description="Unknown RRD or data point")
except (OSError, RuntimeError, RRD_ERROR):
app.logger.exception("Could not read RRD")
abort(503, description="RRD data is unavailable")
@app.get("/graph/<rrd_name>/<period>")
def graph(rrd_name: str, period: str):
try:
image = store.graph(rrd_name.lower(), ALIASES.get(period.lower(), period.lower()))
return Response(image, mimetype="image/png", headers={"Cache-Control": "no-cache, max-age=0"})
except KeyError:
abort(404, description="Unknown RRD or period")
except (OSError, RuntimeError, RRD_ERROR):
app.logger.exception("Could not render graph")
abort(503, description="RRD graph is unavailable")
# Compatibility with the compact form requested as /graph/[rrdname][period].
@app.get("/graph/<rrd_and_period>")
def compact_graph(rrd_and_period: str):
for name in SCHEMAS:
if rrd_and_period.startswith(name):
return graph(name, rrd_and_period[len(name):].lstrip("-_"))
abort(404, description="Unknown RRD or period")
@app.cli.command("collect-now")
def collect_now():
"""Fetch Netatmo and update every RRD immediately."""
count = collector.collect_once()
print(f"Updated {count} RRDs")
return app
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import logging
import threading
import time
from .data import extract_samples
LOG = logging.getLogger(__name__)
class Collector:
def __init__(self, client, store, module_names: dict[str, str], interval: int = 600):
self.client = client
self.store = store
self.module_names = module_names
self.interval = interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def collect_once(self) -> int:
samples = extract_samples(self.client.stations_data(), self.module_names)
for name, sample in samples.items():
try:
self.store.update(name, sample)
except Exception:
# One stale/duplicate module must not discard the other modules.
LOG.exception("Could not update %s RRD", name)
LOG.info("Collected %d Netatmo modules", len(samples))
return len(samples)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._loop, name="netatmo-collector", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _loop(self) -> None:
# Collect immediately, then align roughly to the configured cadence.
while not self._stop.is_set():
started = time.monotonic()
try:
self.collect_once()
except Exception:
LOG.exception("Netatmo collection failed")
remaining = max(1, self.interval - (time.monotonic() - started))
self._stop.wait(remaining)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from pathlib import Path
def _bool(name: str, default: bool) -> bool:
value = os.getenv(name)
return default if value is None else value.lower() in {"1", "true", "yes", "on"}
class Config:
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900"))
GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240"))
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600"))
START_COLLECTOR = _bool("START_COLLECTOR", True)
NETATMO_CLIENT_ID = os.getenv("NETATMO_CLIENT_ID", "")
NETATMO_CLIENT_SECRET = os.getenv("NETATMO_CLIENT_SECRET", "")
NETATMO_REFRESH_TOKEN = os.getenv("NETATMO_REFRESH_TOKEN", "")
NETATMO_ACCESS_TOKEN = os.getenv("NETATMO_ACCESS_TOKEN", "")
NETATMO_DEVICE_ID = os.getenv("NETATMO_DEVICE_ID", "")
NETATMO_TOKEN_URL = os.getenv("NETATMO_TOKEN_URL", "https://api.netatmo.com/oauth2/token")
NETATMO_STATIONS_URL = os.getenv(
"NETATMO_STATIONS_URL", "https://api.netatmo.com/api/getstationsdata"
)
MODULE_OUTDOOR = os.getenv("MODULE_OUTDOOR", "Outdoor")
MODULE_WIND = os.getenv("MODULE_WIND", "Wind")
MODULE_BEDROOM = os.getenv("MODULE_BEDROOM", "Bedroom")
MODULE_STUDY = os.getenv("MODULE_STUDY", "Study")
MODULE_LIVING = os.getenv("MODULE_LIVING", "Living")
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Sample:
timestamp: int
values: dict[str, float | None]
def _dashboard(item: dict[str, Any]) -> dict[str, Any]:
return item.get("dashboard_data") or {}
def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
"""Translate a getstationsdata response into the service's five RRD samples."""
devices = payload.get("body", {}).get("devices", [])
if not devices:
raise ValueError("Netatmo response contains no weather station")
device = devices[0]
items = [device, *device.get("modules", [])]
by_name = {item.get("module_name"): item for item in items}
result: dict[str, Sample] = {}
def sample(key: str, fields: dict[str, str]) -> None:
item = by_name.get(names[key])
if not item or not _dashboard(item):
return
data = _dashboard(item)
result[key] = Sample(
int(data["time_utc"]),
{target: _number(data.get(source)) for target, source in fields.items()},
)
sample("bedroom", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
sample("study", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
sample("living", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"})
outdoor = by_name.get(names["outdoor"])
if outdoor and _dashboard(outdoor):
data = _dashboard(outdoor)
# Pressure is measured by the main indoor station; associate it with the
# outdoor/weather graph while preserving the outdoor module timestamp.
pressure = _dashboard(device).get("Pressure")
result["outdoor"] = Sample(
int(data["time_utc"]),
{
"min_temp": _number(data.get("min_temp", data.get("Temperature"))),
"max_temp": _number(data.get("max_temp", data.get("Temperature"))),
"humidity": _number(data.get("Humidity")),
"pressure": _number(pressure),
},
)
return result
def _number(value: Any) -> float | None:
if value is None:
return None
return float(value)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
import logging
import time
import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger(__name__)
class NetatmoError(RuntimeError):
pass
class NetatmoClient:
"""Small dependency-free Netatmo OAuth and Weather API client."""
def __init__(self, config):
self.client_id = config["NETATMO_CLIENT_ID"]
self.client_secret = config["NETATMO_CLIENT_SECRET"]
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
self.access_token = config["NETATMO_ACCESS_TOKEN"]
self.device_id = config["NETATMO_DEVICE_ID"]
self.token_url = config["NETATMO_TOKEN_URL"]
self.stations_url = config["NETATMO_STATIONS_URL"]
self._expires_at = 0.0
@property
def configured(self) -> bool:
return bool(self.access_token or (self.client_id and self.client_secret and self.refresh_token))
def _request(self, request: urllib.request.Request) -> dict:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except (urllib.error.URLError, urllib.error.HTTPError, ValueError) as exc:
detail = getattr(exc, "read", lambda: b"")().decode(errors="replace")
raise NetatmoError(f"Netatmo request failed: {exc}; {detail}") from exc
def _refresh(self) -> None:
data = urllib.parse.urlencode(
{
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
}
).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
self.access_token = result["access_token"]
# Netatmo may rotate refresh tokens; retain the new value for this process.
self.refresh_token = result.get("refresh_token", self.refresh_token)
self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60
def stations_data(self) -> dict:
if not self.configured:
raise NetatmoError("Netatmo credentials are not configured")
if not self.access_token or (self.refresh_token and time.time() >= self._expires_at):
self._refresh()
query = {"get_favorites": "false"}
if self.device_id:
query["device_id"] = self.device_id
url = f"{self.stations_url}?{urllib.parse.urlencode(query)}"
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.access_token}"})
try:
return self._request(request)
except NetatmoError:
# A statically supplied access token may expire. Refresh once when possible.
if not self.refresh_token:
raise
LOG.info("Access token rejected; refreshing and retrying once")
self._refresh()
request.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request(request)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import colorsys
import re
import tempfile
from pathlib import Path
import rrdtool
from .data import Sample
RRD_ERROR = rrdtool.OperationalError
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"},
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
"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"}
class RRDStore:
def __init__(self, folder: Path, width: int = 900, height: int = 240):
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"{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():
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)
def update(self, name: str, sample: Sample) -> None:
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(self.path(name)),
"--template", ":".join(fields),
f"{sample.timestamp}:{':'.join(values)}",
)
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"]
definitions = [f"DEF:{field}={self.path(name)}:{field}:AVERAGE" for field in SCHEMAS[name]]
if name == "wind":
drawings = self._wind_drawings()
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"]
else:
drawings = ["LINE2:temperature#E74C3C:Temperature (C)", "LINE1:co2#8E44AD:CO2 (ppm)", "LINE1:humidity#27AE60:Humidity (%):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] = []
# 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"]
result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"]
return result
+15
View File
@@ -0,0 +1,15 @@
:root { color-scheme: dark; --ink:#eef3ec; --muted:#a7b3a8; --panel:#17221e; --accent:#b6e36f; }
* { 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; }
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; }
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; }
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; }
@media (max-width:700px) { body{padding:1rem} header{align-items:start;flex-direction:column} nav{width:100%} button{flex:1} }
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Netatmo weather graphs</title>
<link rel="stylesheet" href="{{ url_for('static', filename='dashboard.css') }}">
</head>
<body>
<header>
<div><span class="eyebrow">GetNetatmoData v2</span><h1>Weather at home</h1></div>
<nav aria-label="Graph period">
<button data-period="day" class="active">1 day</button>
<button data-period="2weeks">2 weeks</button>
<button data-period="year">1 year</button>
</nav>
</header>
<main>
{% for name in rrd_names %}
<section class="card">
<h2>{{ name|title }}</h2>
<img src="{{ url_for('graph', rrd_name=name, period='day') }}" alt="{{ name|title }} measurements" data-name="{{ name }}">
</section>
{% endfor %}
</main>
<script>
const buttons = document.querySelectorAll('button[data-period]');
buttons.forEach(button => button.addEventListener('click', () => {
buttons.forEach(item => item.classList.toggle('active', item === button));
document.querySelectorAll('img[data-name]').forEach(image => {
image.src = `/graph/${image.dataset.name}/${button.dataset.period}?t=${Date.now()}`;
});
}));
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
payload = {'body': {'devices': [
{'_id': '70:ee: 50: 71: 5b: 0a', 'date_setup': 1602695090, 'last_setup': 1602695090, 'type': 'NAMain', 'last_status_store': 1788072621, 'module_name': 'Study', 'firmware': 300, 'wifi_status': 78, 'reachable': True, 'co2_calibrating': False, 'data_type': ['Temperature', 'CO2', 'Humidity', 'Noise', 'Pressure'
], 'place': {'altitude': 0, 'city': 'Almere', 'country': 'NL', 'timezone': 'Europe/Amsterdam', 'location': [
5.164732,
52.39723
]
}, 'station_name': 'V20 (Study)', 'home_id': '5f872fb2d36a4d0cf16b0cf2', 'home_name': 'V20', 'dashboard_data': {'time_utc': 1788072617, 'Temperature': 23.4, 'CO2': 489, 'Humidity': 62, 'Noise': 31, 'Pressure': 1004.8, 'AbsolutePressure': 1004.8, 'min_temp': 23.4, 'max_temp': 23.7, 'date_max_temp': 1788040841, 'date_min_temp': 1788067176, 'temp_trend': 'stable', 'pressure_trend': 'up'
}, 'modules': [
{'_id': '02: 00: 00: 71: 39: 50', 'type': 'NAModule1', 'module_name': 'Outdoor', 'last_setup': 1602695092, 'data_type': ['Temperature', 'Humidity'
], 'battery_percent': 52, 'reachable': True, 'firmware': 53, 'last_message': 1788072615, 'last_seen': 1788072595, 'rf_status': 87, 'battery_vp': 5138, 'dashboard_data': {'time_utc': 1788072595, 'Temperature': 18.3, 'Humidity': 99, 'min_temp': 18, 'max_temp': 18.7, 'date_max_temp': 1788040834, 'date_min_temp': 1788065597, 'temp_trend': 'stable'
}
},
{'_id': '06: 00: 00: 04:b6:9e', 'type': 'NAModule2', 'module_name': 'Wind', 'last_setup': 1602699740, 'data_type': ['Wind'
], 'battery_percent': 57, 'reachable': True, 'firmware': 27, 'last_message': 1788072615, 'last_seen': 1788072615, 'rf_status': 78, 'battery_vp': 5114, 'dashboard_data': {'time_utc': 1788072615, 'WindStrength': 3, 'WindAngle': 236, 'GustStrength': 7, 'GustAngle': 270, 'max_wind_str': 30, 'max_wind_angle': 151, 'date_max_wind_str': 1788042148
}
},
{'_id': '03: 00: 00: 08:ed:bc', 'type': 'NAModule4', 'module_name': 'Living', 'last_setup': 1640268236, 'data_type': ['Temperature', 'CO2', 'Humidity'
], 'battery_percent': 61, 'reachable': True, 'firmware': 53, 'last_message': 1788072615, 'last_seen': 1788072615, 'rf_status': 74, 'battery_vp': 5303, 'dashboard_data': {'time_utc': 1788072615, 'Temperature': 22.2, 'CO2': 595, 'Humidity': 72, 'min_temp': 22.1, 'max_temp': 22.4, 'date_max_temp': 1788040815, 'date_min_temp': 1788063527, 'temp_trend': 'stable'
}
},
{'_id': '03: 00: 00: 0b: 75: 6a', 'type': 'NAModule4', 'module_name': 'Bedroom', 'last_setup': 1642857008, 'data_type': ['Temperature', 'CO2', 'Humidity'
], 'battery_percent': 6, 'reachable': False, 'firmware': 53, 'last_message': 1781334508, 'last_seen': 1781329791, 'rf_status': 92, 'battery_vp': 4304
},
{'_id': '05: 00: 00: 0c:7e: 50', 'type': 'NAModule3', 'module_name': 'Rain', 'last_setup': 1758887130, 'data_type': ['Rain'
], 'battery_percent': 74, 'reachable': True, 'firmware': 14, 'last_message': 1788072615, 'last_seen': 1788072615, 'rf_status': 82, 'battery_vp': 5420, 'dashboard_data': {'time_utc': 1788072615, 'Rain': 0.107, 'sum_rain_1': 1.9200000000000002, 'sum_rain_24': 9.8
}
}
]
}
], 'user': {'mail': 'ignace.suy@gmail.com', 'administrative': {'lang': 'en', 'reg_locale': 'en-NL', 'country': '0', 'unit': 0, 'windunit': 0, 'pressureunit': 0, 'feel_like_algo': 0
}
}
}, 'status': 'ok', 'time_exec': 0.20278406143188477, 'time_server': 1788072902
}
+17
View File
@@ -0,0 +1,17 @@
create a flask-service that collects data from the netatmo cloud api every 10 mins and puts that data
into rrd graphs.
Example of the data coming from netatmo's service is in payload_dict.py.
I have registered this app at netatmo as 'GetNetatmoData v2'
Create the following rrd's, each for 1 day, 2 weeks, 1 year:
- Outdoor: min and max temp, humidity, pressure
- Wind: gusts as line, average as bars. the (rainbow) color of the bar represent the wind-angle
- Bedroom: average temp, co2, humidity
- Study: average temp, co2, humidity
- Living: average temp, co2, humidity
rrdfolder must be configurable.
The flask-service:
- has a route to allow to query the last available data: .../last/[rrd-name]/[name of data-point]
- has a route to get the rrd-graph ...graph/[rrdname][period]
create a html page that display all graphs and allows to switch between periods.
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.1,<4
rrdtool-bindings
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from netatmo_service import create_app
from netatmo_service.data import extract_samples
class FakeClient:
configured = False
class FakeStore:
def ensure_all(self): pass
def last(self, name, field): return {"rrd": name, "data_point": field, "timestamp": 1, "value": 2.0}
def graph(self, name, period):
if name == "missing": raise KeyError(name)
return b"\x89PNG\r\n"
def app():
return create_app({"TESTING": True, "START_COLLECTOR": False, "RRD_STORE": FakeStore(), "NETATMO_CLIENT": FakeClient()})
def test_routes():
client = app().test_client()
assert client.get("/").status_code == 200
assert client.get("/last/outdoor/humidity").json["value"] == 2.0
assert client.get("/graph/wind/2weeks").mimetype == "image/png"
assert client.get("/graph/windyear").status_code == 200
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")}
samples = extract_samples(namespace["payload"], names)
assert samples["outdoor"].values["pressure"] == 1004.8
assert samples["wind"].values["angle"] == 236.0
assert "bedroom" not in samples # unreachable module has no dashboard_data
+7
View File
@@ -0,0 +1,7 @@
from netatmo_service import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)