")
+ 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
+
+
+
+
+
+ {% for name in rrd_names %}
+
+ {{ name|title }}
+
+
+ {% 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)
+