From fcae3569fc07b11648c444d51269826a60cc3ba0 Mon Sep 17 00:00:00 2001 From: Ignace Date: Sun, 30 Aug 2026 11:01:19 +0200 Subject: [PATCH] 1st POC --- .env.example | 19 ++++ .gitignore | 6 ++ README.md | 81 ++++++++++++++++- netatmo_service/__init__.py | 6 ++ netatmo_service/app.py | 81 +++++++++++++++++ netatmo_service/collector.py | 51 +++++++++++ netatmo_service/config.py | 33 +++++++ netatmo_service/data.py | 65 +++++++++++++ netatmo_service/netatmo.py | 77 ++++++++++++++++ netatmo_service/rrd.py | 111 +++++++++++++++++++++++ netatmo_service/static/dashboard.css | 15 +++ netatmo_service/templates/dashboard.html | 37 ++++++++ payload_dict.py | 34 +++++++ prompt.txt | 17 ++++ requirements.txt | 2 + tests/test_service.py | 38 ++++++++ wsgi.py | 7 ++ 17 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 netatmo_service/__init__.py create mode 100644 netatmo_service/app.py create mode 100644 netatmo_service/collector.py create mode 100644 netatmo_service/config.py create mode 100644 netatmo_service/data.py create mode 100644 netatmo_service/netatmo.py create mode 100644 netatmo_service/rrd.py create mode 100644 netatmo_service/static/dashboard.css create mode 100644 netatmo_service/templates/dashboard.html create mode 100644 payload_dict.py create mode 100644 prompt.txt create mode 100644 requirements.txt create mode 100644 tests/test_service.py create mode 100644 wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ad6028 --- /dev/null +++ b/.env.example @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ad2f35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env +rrd/ + diff --git a/README.md b/README.md index aa49a0e..fcd71ca 100644 --- a/README.md +++ b/README.md @@ -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 . 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// +GET /graph// +GET /graph/ # 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. diff --git a/netatmo_service/__init__.py b/netatmo_service/__init__.py new file mode 100644 index 0000000..4e9967a --- /dev/null +++ b/netatmo_service/__init__.py @@ -0,0 +1,6 @@ +"""Netatmo to RRDtool web service.""" + +from .app import create_app + +__all__ = ["create_app"] + diff --git a/netatmo_service/app.py b/netatmo_service/app.py new file mode 100644 index 0000000..516be5e --- /dev/null +++ b/netatmo_service/app.py @@ -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//") + 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//") + 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/") + 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 diff --git a/netatmo_service/collector.py b/netatmo_service/collector.py new file mode 100644 index 0000000..778fd3a --- /dev/null +++ b/netatmo_service/collector.py @@ -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) + diff --git a/netatmo_service/config.py b/netatmo_service/config.py new file mode 100644 index 0000000..938031c --- /dev/null +++ b/netatmo_service/config.py @@ -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") diff --git a/netatmo_service/data.py b/netatmo_service/data.py new file mode 100644 index 0000000..b6bace2 --- /dev/null +++ b/netatmo_service/data.py @@ -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) + diff --git a/netatmo_service/netatmo.py b/netatmo_service/netatmo.py new file mode 100644 index 0000000..c67ce3f --- /dev/null +++ b/netatmo_service/netatmo.py @@ -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) + diff --git a/netatmo_service/rrd.py b/netatmo_service/rrd.py new file mode 100644 index 0000000..e774234 --- /dev/null +++ b/netatmo_service/rrd.py @@ -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 diff --git a/netatmo_service/static/dashboard.css b/netatmo_service/static/dashboard.css new file mode 100644 index 0000000..4766674 --- /dev/null +++ b/netatmo_service/static/dashboard.css @@ -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} } + diff --git a/netatmo_service/templates/dashboard.html b/netatmo_service/templates/dashboard.html new file mode 100644 index 0000000..8e3f4e7 --- /dev/null +++ b/netatmo_service/templates/dashboard.html @@ -0,0 +1,37 @@ + + + + + + Netatmo weather graphs + + + +
+
GetNetatmoData v2

Weather at home

+ +
+
+ {% for name in rrd_names %} +
+

{{ name|title }}

+ {{ name|title }} measurements +
+ {% endfor %} +
+ + + + diff --git a/payload_dict.py b/payload_dict.py new file mode 100644 index 0000000..8999e05 --- /dev/null +++ b/payload_dict.py @@ -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 +} \ No newline at end of file diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..f766e16 --- /dev/null +++ b/prompt.txt @@ -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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c0465d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=3.1,<4 +rrdtool-bindings diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..03faac8 --- /dev/null +++ b/tests/test_service.py @@ -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 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..9e64d8a --- /dev/null +++ b/wsgi.py @@ -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) +