1st POC
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
rrd/
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Netatmo to RRDtool web service."""
|
||||||
|
|
||||||
|
from .app import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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} }
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.1,<4
|
||||||
|
rrdtool-bindings
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user