Initial commit: Netatmo MCP + REST bridge for DC environmental monitoring
Deployed on integr8 VM (pve01/700) at environment.mineracks.com. Polls Netatmo every 5 min, stores in SQLite, exposes via FastAPI REST + MCP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9efe05eca0
14
.env.example
Normal file
14
.env.example
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Copy to .env and fill in. Never commit the real .env.
|
||||||
|
|
||||||
|
# From https://dev.netatmo.com/apps -> your app
|
||||||
|
NETATMO_CLIENT_ID=
|
||||||
|
NETATMO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Generate ONCE via the app's "Token generator" panel with scope=read_station.
|
||||||
|
# After first successful refresh, /data/tokens.json takes over and you can
|
||||||
|
# leave this blank (or remove it) — Netatmo rotates the refresh token on
|
||||||
|
# every refresh call.
|
||||||
|
NETATMO_REFRESH_TOKEN=
|
||||||
|
|
||||||
|
# openssl rand -hex 32
|
||||||
|
NETATMO_MCP_API_KEY=
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
tokens.json
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
# Persistent state (sqlite + token cache) lives here. Mount a volume.
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
|
||||||
|
EXPOSE 8088
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8088"]
|
||||||
161
README.md
Normal file
161
README.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
# netatmo-mcp
|
||||||
|
|
||||||
|
Self-hosted bridge that exposes a Netatmo Weather Station to Claude (via MCP)
|
||||||
|
**and** to anything else (via REST), with a local SQLite store for history and
|
||||||
|
threshold-based alerts. Designed to run as a Docker container on Proxmox.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
One process serving three things on port `8088`:
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|-------------|-----------------------------------------------------------------|
|
||||||
|
| `/mcp/` | MCP server (Streamable HTTP transport). Add to Claude as a connector. |
|
||||||
|
| `/api/...` | REST API — same data, for n8n / Grafana / cron / curl. |
|
||||||
|
| `/health` | Unauthenticated liveness + poller status. |
|
||||||
|
|
||||||
|
A background poller hits Netatmo every 5 minutes (configurable), stores every
|
||||||
|
new reading in SQLite, and evaluates any alerts you've configured.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
- **Current readings** for every station and module (`current_readings` tool / `GET /api/current`)
|
||||||
|
- **Local historical queries** at native ~5min resolution (`history` tool / `GET /api/history`)
|
||||||
|
- **Direct Netatmo historical pulls** for longer windows or pre-aggregated scales — 1hour, 1day, 1week, 1month (`history_from_netatmo` tool)
|
||||||
|
- **Threshold alerts** on any metric — e.g. CO₂ > 1000 ppm, temp > 28 °C (`create_alert` tool / `POST /api/alerts`)
|
||||||
|
- **Multi-station / multi-module** — handled natively; everything is keyed by `module_id`
|
||||||
|
|
||||||
|
## One-time Netatmo setup
|
||||||
|
|
||||||
|
1. Go to **https://dev.netatmo.com/apps** and create an app. Note the
|
||||||
|
**client_id** and **client_secret**.
|
||||||
|
2. On the app page, scroll to **Token generator**. Tick the `read_station`
|
||||||
|
scope and click "Generate token". Copy the **refresh token** (you don't
|
||||||
|
need the access token — it only lasts 3 hours).
|
||||||
|
3. Make sure your Netatmo account has the data centre station added.
|
||||||
|
|
||||||
|
> **Important:** Netatmo rotates the refresh token on every refresh call. This
|
||||||
|
> service persists the rotated token to `/data/tokens.json` so it survives
|
||||||
|
> restarts. Don't share that file or the refresh chain breaks.
|
||||||
|
|
||||||
|
## Deploy on Proxmox
|
||||||
|
|
||||||
|
On your Ubuntu VM with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <this repo> netatmo-mcp && cd netatmo-mcp
|
||||||
|
cp .env.example .env
|
||||||
|
# fill in NETATMO_CLIENT_ID, NETATMO_CLIENT_SECRET, NETATMO_REFRESH_TOKEN,
|
||||||
|
# and NETATMO_MCP_API_KEY=$(openssl rand -hex 32)
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `Poller starting` and, within a minute or so, `Refreshing
|
||||||
|
Netatmo access token` followed by the first poll. Hit `http://<vm>:8088/health`
|
||||||
|
to confirm.
|
||||||
|
|
||||||
|
After ~10 minutes there'll be a few rows in the DB and you can query history.
|
||||||
|
|
||||||
|
### Putting it behind your reverse proxy
|
||||||
|
|
||||||
|
For Claude (web/app) connectors you need a **public HTTPS URL**. If you're on
|
||||||
|
Caddy:
|
||||||
|
|
||||||
|
```
|
||||||
|
netatmo.mineracks.ai {
|
||||||
|
reverse_proxy <vm-ip>:8088
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or stick it on Cloudflare Tunnel — same deal. Bearer auth is enforced at the
|
||||||
|
app, so exposing it is fine as long as your `NETATMO_MCP_API_KEY` is strong.
|
||||||
|
|
||||||
|
## Connecting Claude
|
||||||
|
|
||||||
|
In **claude.ai → Settings → Connectors → Add custom connector**:
|
||||||
|
|
||||||
|
- **Name:** Netatmo Datacentre
|
||||||
|
- **URL:** `https://netatmo.mineracks.ai/mcp/`
|
||||||
|
- **Auth:** Bearer token → paste your `NETATMO_MCP_API_KEY`
|
||||||
|
|
||||||
|
For **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
on macOS) or Claude Code (`~/.claude/settings.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"netatmo": {
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "https://netatmo.mineracks.ai/mcp/",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer YOUR_NETATMO_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then ask Claude: "What's the current CO2 in the data centre?" or "Plot the
|
||||||
|
last 48 hours of temperature for the rack module."
|
||||||
|
|
||||||
|
## Using the REST API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEY=your_api_key
|
||||||
|
BASE=https://netatmo.mineracks.ai
|
||||||
|
|
||||||
|
# All modules (find your module_id here)
|
||||||
|
curl -H "Authorization: Bearer $KEY" $BASE/api/modules
|
||||||
|
|
||||||
|
# Current snapshot
|
||||||
|
curl -H "Authorization: Bearer $KEY" $BASE/api/current
|
||||||
|
|
||||||
|
# Last 24h of CO2 for a specific module
|
||||||
|
curl -H "Authorization: Bearer $KEY" \
|
||||||
|
"$BASE/api/history?module_id=02:00:00:aa:bb:cc&metric=CO2&hours=24"
|
||||||
|
|
||||||
|
# Create an alert: CO2 > 1000 ppm on the rack module
|
||||||
|
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"dc_co2_high","module_id":"02:00:00:aa:bb:cc","metric":"CO2","op":">","threshold":1000}' \
|
||||||
|
$BASE/api/alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metrics reference
|
||||||
|
|
||||||
|
The fields exposed depend on module type:
|
||||||
|
|
||||||
|
| Module | Metrics |
|
||||||
|
|------------------|----------------------------------------------------------------|
|
||||||
|
| Indoor (NAMain / NAModule4) | `Temperature`, `Humidity`, `CO2`, `Noise`, `Pressure` |
|
||||||
|
| Outdoor (NAModule1) | `Temperature`, `Humidity` |
|
||||||
|
| Rain (NAModule3) | `Rain`, `sum_rain_1`, `sum_rain_24` |
|
||||||
|
| Wind (NAModule2) | `WindStrength`, `WindAngle`, `GustStrength`, `GustAngle` |
|
||||||
|
|
||||||
|
For a data centre you almost certainly care about indoor `Temperature`,
|
||||||
|
`Humidity`, and `CO2` — the noise sensor is also a surprisingly useful
|
||||||
|
"is the AC running differently?" canary.
|
||||||
|
|
||||||
|
## Files / state
|
||||||
|
|
||||||
|
- `./data/netatmo.db` — SQLite history + alerts. Back it up.
|
||||||
|
- `./data/tokens.json` — rotating refresh token. **Do not delete** without
|
||||||
|
generating a new bootstrap refresh token.
|
||||||
|
|
||||||
|
## Notes & gotchas
|
||||||
|
|
||||||
|
- **Netatmo rate limits:** 50 requests / 10 seconds and 500 / hour per user.
|
||||||
|
At 5-minute poll interval you use ~12/hour, so you're fine — but don't
|
||||||
|
drop the interval below 60s.
|
||||||
|
- **First poll is slow** because it does a token refresh. Subsequent polls
|
||||||
|
reuse the access token until it nears expiry.
|
||||||
|
- **History granularity:** the local store records whatever Netatmo returns,
|
||||||
|
which is roughly one point per metric per ~5 min. For longer windows
|
||||||
|
(weeks/months) use `history_from_netatmo` with `scale="1day"` to get
|
||||||
|
Netatmo's pre-aggregated values.
|
||||||
|
- **Alerts are evaluated each poll**, so worst-case detection latency =
|
||||||
|
`POLL_INTERVAL_SEC`. To wire alerts to email/Slack/ntfy, point an n8n
|
||||||
|
workflow at `GET /api/alert-events`, or extend `Poller._tick` to POST
|
||||||
|
to a webhook.
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
223
app/netatmo.py
Normal file
223
app/netatmo.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Netatmo Weather Station API client.
|
||||||
|
|
||||||
|
Handles OAuth refresh-token flow (the only sane long-lived auth path) and
|
||||||
|
exposes the two endpoints we actually need: getstationsdata (live snapshot)
|
||||||
|
and getmeasure (historical time series).
|
||||||
|
|
||||||
|
Token state is persisted to disk so refreshes survive restarts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NETATMO_BASE = "https://api.netatmo.com"
|
||||||
|
TOKEN_URL = f"{NETATMO_BASE}/oauth2/token"
|
||||||
|
STATIONS_URL = f"{NETATMO_BASE}/api/getstationsdata"
|
||||||
|
MEASURE_URL = f"{NETATMO_BASE}/api/getmeasure"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenState:
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
expires_at: float # unix ts
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "TokenState | None":
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return cls(**json.loads(path.read_text()))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Could not load token state: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
path.write_text(json.dumps(asdict(self), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
class NetatmoClient:
|
||||||
|
"""Thin async client around the Weather Station endpoints."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
token_path: Path,
|
||||||
|
initial_refresh_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client_id = client_id
|
||||||
|
self._client_secret = client_secret
|
||||||
|
self._token_path = token_path
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
state = TokenState.load(token_path)
|
||||||
|
if state is None:
|
||||||
|
if not initial_refresh_token:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No token state at {token_path} and no NETATMO_REFRESH_TOKEN provided. "
|
||||||
|
"Generate one via the dev portal Token Generator and set the env var."
|
||||||
|
)
|
||||||
|
# Bootstrap with whatever we were given; first refresh will fix expiry.
|
||||||
|
state = TokenState(
|
||||||
|
access_token="",
|
||||||
|
refresh_token=initial_refresh_token,
|
||||||
|
expires_at=0,
|
||||||
|
)
|
||||||
|
state.save(token_path)
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
# ---- auth -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
"""Exchange refresh_token for a new access_token. Netatmo rotates the refresh
|
||||||
|
token on every call — we MUST persist the new one or we'll be locked out."""
|
||||||
|
log.info("Refreshing Netatmo access token")
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
r = await c.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": self._state.refresh_token,
|
||||||
|
"client_id": self._client_id,
|
||||||
|
"client_secret": self._client_secret,
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"Token refresh failed {r.status_code}: {r.text}")
|
||||||
|
body = r.json()
|
||||||
|
self._state = TokenState(
|
||||||
|
access_token=body["access_token"],
|
||||||
|
refresh_token=body.get("refresh_token", self._state.refresh_token),
|
||||||
|
# Refresh 60s early to be safe.
|
||||||
|
expires_at=time.time() + int(body.get("expires_in", 10800)) - 60,
|
||||||
|
)
|
||||||
|
self._state.save(self._token_path)
|
||||||
|
|
||||||
|
async def _ensure_token(self) -> str:
|
||||||
|
async with self._lock:
|
||||||
|
if not self._state.access_token or time.time() >= self._state.expires_at:
|
||||||
|
await self._refresh()
|
||||||
|
return self._state.access_token
|
||||||
|
|
||||||
|
async def _get(self, url: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
token = await self._ensure_token()
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
r = await c.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if r.status_code == 403:
|
||||||
|
# Token might have been invalidated — force a refresh and retry once.
|
||||||
|
log.warning("403 from Netatmo; forcing token refresh and retrying")
|
||||||
|
async with self._lock:
|
||||||
|
await self._refresh()
|
||||||
|
token = self._state.access_token
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
r = await c.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"Netatmo {url} -> {r.status_code}: {r.text}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
# ---- public API -------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_stations_data(self) -> dict[str, Any]:
|
||||||
|
"""Live snapshot: every station + module the account can see, with the
|
||||||
|
most recent dashboard_data values."""
|
||||||
|
return await self._get(STATIONS_URL, {"get_favorites": "false"})
|
||||||
|
|
||||||
|
async def get_measure(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
module_id: str | None,
|
||||||
|
scale: str,
|
||||||
|
types: list[str],
|
||||||
|
date_begin: int | None = None,
|
||||||
|
date_end: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
optimize: bool = False,
|
||||||
|
real_time: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Historical time-series.
|
||||||
|
|
||||||
|
scale: '30min','1hour','3hours','1day','1week','1month' (or 'max' for raw 5-min)
|
||||||
|
types: e.g. ['Temperature','Humidity','CO2','Noise','Pressure',
|
||||||
|
'min_temp','max_temp','date_min_temp','date_max_temp']
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"scale": scale,
|
||||||
|
"type": ",".join(types),
|
||||||
|
"optimize": "true" if optimize else "false",
|
||||||
|
"real_time": "true" if real_time else "false",
|
||||||
|
}
|
||||||
|
if module_id:
|
||||||
|
params["module_id"] = module_id
|
||||||
|
if date_begin is not None:
|
||||||
|
params["date_begin"] = date_begin
|
||||||
|
if date_end is not None:
|
||||||
|
params["date_end"] = date_end
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = limit
|
||||||
|
return await self._get(MEASURE_URL, params)
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_stations(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Reshape getstationsdata into a flat list of {station, module} sensor records.
|
||||||
|
|
||||||
|
Netatmo's response nests modules under devices and puts the indoor module's
|
||||||
|
readings on the device itself, which is awkward. We normalise it.
|
||||||
|
"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for dev in raw.get("body", {}).get("devices", []):
|
||||||
|
station_name = dev.get("station_name") or dev.get("home_name") or dev["_id"]
|
||||||
|
# The base station is itself a module ("NAMain") with dashboard_data on the device.
|
||||||
|
out.append({
|
||||||
|
"station_id": dev["_id"],
|
||||||
|
"station_name": station_name,
|
||||||
|
"module_id": dev["_id"],
|
||||||
|
"module_name": dev.get("module_name", "Indoor"),
|
||||||
|
"module_type": dev.get("type", "NAMain"),
|
||||||
|
"data_types": dev.get("data_type", []),
|
||||||
|
"last_seen": dev.get("last_status_store"),
|
||||||
|
"reachable": dev.get("reachable", True),
|
||||||
|
"battery_percent": None, # base station is mains powered
|
||||||
|
"rf_status": None,
|
||||||
|
"wifi_status": dev.get("wifi_status"),
|
||||||
|
"place": dev.get("place", {}),
|
||||||
|
"dashboard": dev.get("dashboard_data", {}),
|
||||||
|
})
|
||||||
|
for mod in dev.get("modules", []):
|
||||||
|
out.append({
|
||||||
|
"station_id": dev["_id"],
|
||||||
|
"station_name": station_name,
|
||||||
|
"module_id": mod["_id"],
|
||||||
|
"module_name": mod.get("module_name", mod["_id"]),
|
||||||
|
"module_type": mod.get("type"),
|
||||||
|
"data_types": mod.get("data_type", []),
|
||||||
|
"last_seen": mod.get("last_seen") or mod.get("last_message"),
|
||||||
|
"reachable": mod.get("reachable", True),
|
||||||
|
"battery_percent": mod.get("battery_percent"),
|
||||||
|
"rf_status": mod.get("rf_status"),
|
||||||
|
"wifi_status": None,
|
||||||
|
"place": dev.get("place", {}),
|
||||||
|
"dashboard": mod.get("dashboard_data", {}),
|
||||||
|
})
|
||||||
|
return out
|
||||||
99
app/poller.py
Normal file
99
app/poller.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Background poller. Pulls live snapshot every N seconds and persists any
|
||||||
|
unseen readings. Runs in the same asyncio loop as the FastAPI app.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .netatmo import NetatmoClient, flatten_stations
|
||||||
|
from .store import Store
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Metrics we care about — anything else in dashboard_data is metadata.
|
||||||
|
NUMERIC_METRICS = {
|
||||||
|
"Temperature", "Humidity", "CO2", "Noise", "Pressure", "AbsolutePressure",
|
||||||
|
"Rain", "sum_rain_1", "sum_rain_24", "WindStrength", "WindAngle",
|
||||||
|
"GustStrength", "GustAngle", "min_temp", "max_temp", "health_idx",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Poller:
|
||||||
|
def __init__(self, client: NetatmoClient, store: Store, interval_sec: int = 300):
|
||||||
|
self.client = client
|
||||||
|
self.store = store
|
||||||
|
self.interval = interval_sec
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._stop = asyncio.Event()
|
||||||
|
self.last_run_ts: int | None = None
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self.last_inserted: int = 0
|
||||||
|
|
||||||
|
async def _tick(self) -> None:
|
||||||
|
raw = await self.client.get_stations_data()
|
||||||
|
modules = flatten_stations(raw)
|
||||||
|
self.store.upsert_modules(modules)
|
||||||
|
|
||||||
|
rows: list[tuple[str, str, str, int, float]] = []
|
||||||
|
latest: dict[tuple[str, str], float] = {}
|
||||||
|
for m in modules:
|
||||||
|
d = m.get("dashboard") or {}
|
||||||
|
ts = d.get("time_utc")
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
for k, v in d.items():
|
||||||
|
if k not in NUMERIC_METRICS:
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
rows.append((m["station_id"], m["module_id"], k, int(ts), float(v)))
|
||||||
|
latest[(m["module_id"], k)] = float(v)
|
||||||
|
|
||||||
|
inserted = self.store.insert_readings(rows) if rows else 0
|
||||||
|
self.last_inserted = inserted
|
||||||
|
|
||||||
|
# Evaluate alerts against the freshest snapshot.
|
||||||
|
import time as _t
|
||||||
|
fired = self.store.evaluate_alerts(int(_t.time()), latest)
|
||||||
|
for f in fired:
|
||||||
|
log.warning("ALERT %s: %s.%s = %s %s %s",
|
||||||
|
f["name"], f["module_id"], f["metric"],
|
||||||
|
f["value"], f["op"], f["threshold"])
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
log.info("Poller starting (interval=%ss)", self.interval)
|
||||||
|
while not self._stop.is_set():
|
||||||
|
import time as _t
|
||||||
|
try:
|
||||||
|
await self._tick()
|
||||||
|
self.last_run_ts = int(_t.time())
|
||||||
|
self.last_error = None
|
||||||
|
except Exception as e:
|
||||||
|
self.last_error = str(e)
|
||||||
|
log.exception("Poll failed")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._task is None or self._task.done():
|
||||||
|
self._stop.clear()
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._task:
|
||||||
|
await self._task
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"running": self._task is not None and not self._task.done(),
|
||||||
|
"interval_sec": self.interval,
|
||||||
|
"last_run_ts": self.last_run_ts,
|
||||||
|
"last_inserted": self.last_inserted,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
}
|
||||||
276
app/server.py
Normal file
276
app/server.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
Combined service: FastAPI REST API + MCP server (Streamable HTTP transport),
|
||||||
|
sharing one process and one Netatmo client.
|
||||||
|
|
||||||
|
REST is at /api/...
|
||||||
|
MCP is at /mcp/ (Streamable HTTP)
|
||||||
|
Health at /health
|
||||||
|
|
||||||
|
Auth: a single bearer token (env NETATMO_MCP_API_KEY) protects both surfaces.
|
||||||
|
Set it to a long random string and pass it as `Authorization: Bearer <token>`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.fastmcp.server import TransportSecuritySettings
|
||||||
|
|
||||||
|
from .netatmo import NetatmoClient
|
||||||
|
from .poller import Poller
|
||||||
|
from .store import Store
|
||||||
|
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---- config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
API_KEY = os.getenv("NETATMO_MCP_API_KEY")
|
||||||
|
if not API_KEY:
|
||||||
|
raise RuntimeError("NETATMO_MCP_API_KEY env var is required")
|
||||||
|
|
||||||
|
CLIENT_ID = os.environ["NETATMO_CLIENT_ID"]
|
||||||
|
CLIENT_SECRET = os.environ["NETATMO_CLIENT_SECRET"]
|
||||||
|
INITIAL_REFRESH = os.environ.get("NETATMO_REFRESH_TOKEN")
|
||||||
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL_SEC", "300"))
|
||||||
|
|
||||||
|
store = Store(DATA_DIR / "netatmo.db")
|
||||||
|
client = NetatmoClient(
|
||||||
|
client_id=CLIENT_ID,
|
||||||
|
client_secret=CLIENT_SECRET,
|
||||||
|
token_path=DATA_DIR / "tokens.json",
|
||||||
|
initial_refresh_token=INITIAL_REFRESH,
|
||||||
|
)
|
||||||
|
poller = Poller(client, store, interval_sec=POLL_INTERVAL)
|
||||||
|
|
||||||
|
# ---- MCP server -------------------------------------------------------------
|
||||||
|
# stateless_http=True so the same instance can serve many clients; json_response=True
|
||||||
|
# returns a single JSON response per call rather than an SSE stream — simpler for
|
||||||
|
# tools that just return data.
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="netatmo-datacentre",
|
||||||
|
stateless_http=True,
|
||||||
|
json_response=True,
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=["environment.mineracks.com", "100.90.183.109:8088", "localhost:8088"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_modules() -> list[dict[str, Any]]:
|
||||||
|
"""List every Netatmo station and module known to this server, including
|
||||||
|
module_id (use this for history queries), module_type, and what data_types
|
||||||
|
each module reports."""
|
||||||
|
return store.list_modules()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def current_readings(module_id: Optional[str] = None) -> list[dict[str, Any]]:
|
||||||
|
"""Get the most recent reading for every metric. If module_id is given,
|
||||||
|
restrict to that one module. Each row has: station_name, module_name,
|
||||||
|
module_id, metric, value, ts (unix seconds)."""
|
||||||
|
return store.latest(module_id=module_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def history(
|
||||||
|
module_id: str,
|
||||||
|
metric: str,
|
||||||
|
hours: int = 24,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return locally-stored historical readings for one metric on one module.
|
||||||
|
|
||||||
|
metric: e.g. 'Temperature', 'Humidity', 'CO2', 'Noise', 'Pressure'.
|
||||||
|
hours: look back this many hours from now (default 24).
|
||||||
|
limit: max points returned (default 5000).
|
||||||
|
"""
|
||||||
|
now = int(time.time())
|
||||||
|
rows = store.history(module_id, metric, since=now - hours * 3600,
|
||||||
|
until=now, limit=limit)
|
||||||
|
agg = store.aggregate(module_id, metric, since=now - hours * 3600, until=now)
|
||||||
|
return {"module_id": module_id, "metric": metric, "hours": hours,
|
||||||
|
"points": rows, "summary": agg}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def history_from_netatmo(
|
||||||
|
device_id: str,
|
||||||
|
module_id: Optional[str],
|
||||||
|
scale: str = "1hour",
|
||||||
|
metrics: Optional[list[str]] = None,
|
||||||
|
hours: int = 168,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch history directly from Netatmo's getmeasure endpoint — useful when
|
||||||
|
you want a longer window than the local store has, or pre-aggregated values.
|
||||||
|
|
||||||
|
scale: '30min','1hour','3hours','1day','1week','1month'
|
||||||
|
metrics: defaults to ['Temperature','Humidity','CO2'] for indoor modules.
|
||||||
|
"""
|
||||||
|
metrics = metrics or ["Temperature", "Humidity", "CO2"]
|
||||||
|
now = int(time.time())
|
||||||
|
raw = await client.get_measure(
|
||||||
|
device_id=device_id,
|
||||||
|
module_id=module_id if module_id and module_id != device_id else None,
|
||||||
|
scale=scale,
|
||||||
|
types=metrics,
|
||||||
|
date_begin=now - hours * 3600,
|
||||||
|
date_end=now,
|
||||||
|
)
|
||||||
|
# Reshape Netatmo's awkward {beg_time, step_time, value: [[v1,v2,...]]} format.
|
||||||
|
body = raw.get("body", {})
|
||||||
|
series: list[dict[str, Any]] = []
|
||||||
|
for ts_str, values in body.items():
|
||||||
|
try:
|
||||||
|
ts = int(ts_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
point = {"ts": ts}
|
||||||
|
for i, m in enumerate(metrics):
|
||||||
|
if i < len(values):
|
||||||
|
point[m] = values[i]
|
||||||
|
series.append(point)
|
||||||
|
series.sort(key=lambda p: p["ts"])
|
||||||
|
return {"device_id": device_id, "module_id": module_id, "scale": scale,
|
||||||
|
"metrics": metrics, "points": series}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_alerts() -> list[dict[str, Any]]:
|
||||||
|
"""List all configured threshold alerts and their current state."""
|
||||||
|
return store.list_alerts()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_alert(
|
||||||
|
name: str, module_id: str, metric: str, op: str, threshold: float,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a threshold alert. op must be one of '>', '<', '>=', '<='.
|
||||||
|
Example: create_alert('dc_co2_high', 'aa:bb:..', 'CO2', '>', 1000)."""
|
||||||
|
aid = store.create_alert(name, module_id, metric, op, threshold, notes)
|
||||||
|
return {"id": aid, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def delete_alert(alert_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete an alert by id."""
|
||||||
|
return {"deleted": store.delete_alert(alert_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def recent_alert_events(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
"""Recent alert state-change events (triggered / cleared)."""
|
||||||
|
return store.recent_alert_events(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def poller_status() -> dict[str, Any]:
|
||||||
|
"""How is the background poller doing? Useful for diagnosing stale data."""
|
||||||
|
return poller.status()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- FastAPI REST -----------------------------------------------------------
|
||||||
|
|
||||||
|
def require_key(authorization: str = Header(default="")) -> None:
|
||||||
|
if not authorization.startswith("Bearer ") or authorization[7:] != API_KEY:
|
||||||
|
raise HTTPException(401, "Invalid or missing bearer token")
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
poller.start()
|
||||||
|
# Critical: MCP session manager must be running for the mounted app to work.
|
||||||
|
async with mcp.session_manager.run():
|
||||||
|
yield
|
||||||
|
await poller.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Netatmo MCP/API", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, Any]:
|
||||||
|
return {"ok": True, "poller": poller.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/modules", dependencies=[Depends(require_key)])
|
||||||
|
def api_modules():
|
||||||
|
return store.list_modules()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/current", dependencies=[Depends(require_key)])
|
||||||
|
def api_current(module_id: Optional[str] = None):
|
||||||
|
return store.latest(module_id=module_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/history", dependencies=[Depends(require_key)])
|
||||||
|
def api_history(
|
||||||
|
module_id: str = Query(...),
|
||||||
|
metric: str = Query(...),
|
||||||
|
hours: int = Query(24, ge=1, le=24 * 365),
|
||||||
|
limit: int = Query(5000, ge=1, le=50000),
|
||||||
|
):
|
||||||
|
now = int(time.time())
|
||||||
|
return {
|
||||||
|
"module_id": module_id, "metric": metric, "hours": hours,
|
||||||
|
"points": store.history(module_id, metric, since=now - hours * 3600,
|
||||||
|
until=now, limit=limit),
|
||||||
|
"summary": store.aggregate(module_id, metric, since=now - hours * 3600,
|
||||||
|
until=now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/alerts", dependencies=[Depends(require_key)])
|
||||||
|
def api_alerts():
|
||||||
|
return store.list_alerts()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/alerts", dependencies=[Depends(require_key)])
|
||||||
|
def api_create_alert(payload: dict[str, Any]):
|
||||||
|
aid = store.create_alert(
|
||||||
|
name=payload["name"], module_id=payload["module_id"],
|
||||||
|
metric=payload["metric"], op=payload["op"],
|
||||||
|
threshold=float(payload["threshold"]), notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
return {"id": aid}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/alerts/{alert_id}", dependencies=[Depends(require_key)])
|
||||||
|
def api_delete_alert(alert_id: int):
|
||||||
|
return {"deleted": store.delete_alert(alert_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/alert-events", dependencies=[Depends(require_key)])
|
||||||
|
def api_alert_events(limit: int = 50):
|
||||||
|
return store.recent_alert_events(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
# Mount the MCP Streamable HTTP app at /mcp.
|
||||||
|
# Note: MCP clients should use bearer auth too — we wrap with a tiny middleware.
|
||||||
|
mcp_app = mcp.streamable_http_app()
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def mcp_auth(request, call_next):
|
||||||
|
if request.url.path.startswith("/mcp"):
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if not auth.startswith("Bearer ") or auth[7:] != API_KEY:
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/mcp", mcp_app)
|
||||||
235
app/store.py
Normal file
235
app/store.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
SQLite-backed store for historical sensor readings and alert state.
|
||||||
|
|
||||||
|
We poll the live snapshot every N seconds (default 5min — Netatmo only updates
|
||||||
|
that often anyway) and write one row per (station, module, metric, ts).
|
||||||
|
This gives us our own historical series independent of Netatmo's retention,
|
||||||
|
plus fast local queries for Claude.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
station_id TEXT NOT NULL,
|
||||||
|
module_id TEXT NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL, -- unix seconds
|
||||||
|
value REAL NOT NULL,
|
||||||
|
PRIMARY KEY (station_id, module_id, metric, ts)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_readings_ts ON readings(ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_readings_lookup ON readings(module_id, metric, ts);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
module_id TEXT PRIMARY KEY,
|
||||||
|
station_id TEXT NOT NULL,
|
||||||
|
station_name TEXT,
|
||||||
|
module_name TEXT,
|
||||||
|
module_type TEXT,
|
||||||
|
data_types TEXT, -- json array
|
||||||
|
last_seen INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
module_id TEXT NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
op TEXT NOT NULL, -- '>', '<', '>=', '<='
|
||||||
|
threshold REAL NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_triggered INTEGER,
|
||||||
|
last_value REAL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
alert_id INTEGER NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
state TEXT NOT NULL, -- 'triggered' | 'cleared'
|
||||||
|
FOREIGN KEY(alert_id) REFERENCES alerts(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
with self._conn() as c:
|
||||||
|
c.executescript(SCHEMA)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn(self):
|
||||||
|
conn = sqlite3.connect(self.path, timeout=10)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ---- writes -----------------------------------------------------------
|
||||||
|
|
||||||
|
def upsert_modules(self, modules: Iterable[dict[str, Any]]) -> None:
|
||||||
|
import json
|
||||||
|
with self._lock, self._conn() as c:
|
||||||
|
for m in modules:
|
||||||
|
c.execute(
|
||||||
|
"""INSERT INTO modules(module_id, station_id, station_name,
|
||||||
|
module_name, module_type, data_types, last_seen)
|
||||||
|
VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(module_id) DO UPDATE SET
|
||||||
|
station_id=excluded.station_id,
|
||||||
|
station_name=excluded.station_name,
|
||||||
|
module_name=excluded.module_name,
|
||||||
|
module_type=excluded.module_type,
|
||||||
|
data_types=excluded.data_types,
|
||||||
|
last_seen=excluded.last_seen""",
|
||||||
|
(
|
||||||
|
m["module_id"], m["station_id"], m.get("station_name"),
|
||||||
|
m.get("module_name"), m.get("module_type"),
|
||||||
|
json.dumps(m.get("data_types", [])), m.get("last_seen"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert_readings(self, rows: Iterable[tuple[str, str, str, int, float]]) -> int:
|
||||||
|
"""rows: (station_id, module_id, metric, ts, value)"""
|
||||||
|
with self._lock, self._conn() as c:
|
||||||
|
cur = c.executemany(
|
||||||
|
"INSERT OR IGNORE INTO readings VALUES (?,?,?,?,?)",
|
||||||
|
list(rows),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
# ---- reads ------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_modules(self) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
return [dict(r) for r in c.execute("SELECT * FROM modules ORDER BY station_name, module_name")]
|
||||||
|
|
||||||
|
def latest(self, module_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
sql = """
|
||||||
|
SELECT r.station_id, r.module_id, r.metric, r.ts, r.value,
|
||||||
|
m.station_name, m.module_name
|
||||||
|
FROM readings r
|
||||||
|
JOIN modules m ON m.module_id = r.module_id
|
||||||
|
JOIN (SELECT module_id, metric, MAX(ts) AS mts
|
||||||
|
FROM readings GROUP BY module_id, metric) lx
|
||||||
|
ON lx.module_id = r.module_id AND lx.metric = r.metric AND lx.mts = r.ts
|
||||||
|
"""
|
||||||
|
params: list[Any] = []
|
||||||
|
if module_id:
|
||||||
|
sql += " WHERE r.module_id = ?"
|
||||||
|
params.append(module_id)
|
||||||
|
sql += " ORDER BY m.station_name, m.module_name, r.metric"
|
||||||
|
with self._conn() as c:
|
||||||
|
return [dict(r) for r in c.execute(sql, params)]
|
||||||
|
|
||||||
|
def history(
|
||||||
|
self, module_id: str, metric: str, since: int, until: int | None = None,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
until = until or int(time.time())
|
||||||
|
with self._conn() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"""SELECT ts, value FROM readings
|
||||||
|
WHERE module_id=? AND metric=? AND ts BETWEEN ? AND ?
|
||||||
|
ORDER BY ts ASC LIMIT ?""",
|
||||||
|
(module_id, metric, since, until, limit),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def aggregate(
|
||||||
|
self, module_id: str, metric: str, since: int, until: int | None = None,
|
||||||
|
) -> dict[str, float | int | None]:
|
||||||
|
until = until or int(time.time())
|
||||||
|
with self._conn() as c:
|
||||||
|
r = c.execute(
|
||||||
|
"""SELECT COUNT(*) n, MIN(value) lo, MAX(value) hi, AVG(value) avg
|
||||||
|
FROM readings WHERE module_id=? AND metric=? AND ts BETWEEN ? AND ?""",
|
||||||
|
(module_id, metric, since, until),
|
||||||
|
).fetchone()
|
||||||
|
return {"count": r["n"], "min": r["lo"], "max": r["hi"], "avg": r["avg"]}
|
||||||
|
|
||||||
|
# ---- alerts -----------------------------------------------------------
|
||||||
|
|
||||||
|
def create_alert(
|
||||||
|
self, name: str, module_id: str, metric: str, op: str, threshold: float,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
if op not in (">", "<", ">=", "<="):
|
||||||
|
raise ValueError(f"Invalid operator {op!r}")
|
||||||
|
with self._lock, self._conn() as c:
|
||||||
|
cur = c.execute(
|
||||||
|
"""INSERT INTO alerts(name, module_id, metric, op, threshold, notes)
|
||||||
|
VALUES(?,?,?,?,?,?)""",
|
||||||
|
(name, module_id, metric, op, threshold, notes),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def list_alerts(self) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
return [dict(r) for r in c.execute("SELECT * FROM alerts ORDER BY name")]
|
||||||
|
|
||||||
|
def delete_alert(self, alert_id: int) -> bool:
|
||||||
|
with self._lock, self._conn() as c:
|
||||||
|
cur = c.execute("DELETE FROM alerts WHERE id=?", (alert_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def evaluate_alerts(self, now: int, latest: dict[tuple[str, str], float]) -> list[dict[str, Any]]:
|
||||||
|
"""Check every enabled alert against the latest readings.
|
||||||
|
latest is keyed by (module_id, metric) -> value. Returns newly-fired events."""
|
||||||
|
fired: list[dict[str, Any]] = []
|
||||||
|
with self._lock, self._conn() as c:
|
||||||
|
alerts = list(c.execute("SELECT * FROM alerts WHERE enabled=1"))
|
||||||
|
for a in alerts:
|
||||||
|
v = latest.get((a["module_id"], a["metric"]))
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
op, t = a["op"], a["threshold"]
|
||||||
|
triggered = (
|
||||||
|
(op == ">" and v > t) or
|
||||||
|
(op == "<" and v < t) or
|
||||||
|
(op == ">=" and v >= t) or
|
||||||
|
(op == "<=" and v <= t)
|
||||||
|
)
|
||||||
|
prev_state = "triggered" if a["last_triggered"] else "cleared"
|
||||||
|
new_state = "triggered" if triggered else "cleared"
|
||||||
|
if new_state != prev_state:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO alert_events(alert_id, ts, value, state) VALUES (?,?,?,?)",
|
||||||
|
(a["id"], now, v, new_state),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"UPDATE alerts SET last_triggered=?, last_value=? WHERE id=?",
|
||||||
|
(now if triggered else None, v, a["id"]),
|
||||||
|
)
|
||||||
|
if triggered:
|
||||||
|
fired.append({
|
||||||
|
"alert_id": a["id"], "name": a["name"],
|
||||||
|
"module_id": a["module_id"], "metric": a["metric"],
|
||||||
|
"value": v, "op": op, "threshold": t, "ts": now,
|
||||||
|
})
|
||||||
|
return fired
|
||||||
|
|
||||||
|
def recent_alert_events(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"""SELECT e.*, a.name, a.module_id, a.metric, a.op, a.threshold
|
||||||
|
FROM alert_events e JOIN alerts a ON a.id = e.alert_id
|
||||||
|
ORDER BY e.ts DESC LIMIT ?""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
netatmo-mcp:
|
||||||
|
build: .
|
||||||
|
image: netatmo-mcp:latest
|
||||||
|
container_name: netatmo-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
environment:
|
||||||
|
# --- required ---
|
||||||
|
NETATMO_CLIENT_ID: "${NETATMO_CLIENT_ID}"
|
||||||
|
NETATMO_CLIENT_SECRET: "${NETATMO_CLIENT_SECRET}"
|
||||||
|
# Only required on first boot — afterwards tokens.json takes over.
|
||||||
|
NETATMO_REFRESH_TOKEN: "${NETATMO_REFRESH_TOKEN:-}"
|
||||||
|
# Long random string. Used for both REST and MCP auth.
|
||||||
|
NETATMO_MCP_API_KEY: "${NETATMO_MCP_API_KEY}"
|
||||||
|
|
||||||
|
# --- optional ---
|
||||||
|
POLL_INTERVAL_SEC: "300" # Netatmo updates roughly every 5 min anyway
|
||||||
|
LOG_LEVEL: "INFO"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8088/health').status==200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.32
|
||||||
|
httpx>=0.27
|
||||||
|
mcp>=1.2
|
||||||
|
pydantic>=2.7
|
||||||
Loading…
Reference in New Issue
Block a user