diff --git a/.env.example b/.env.example
index 1ad6028..6e71c5d 100644
--- a/.env.example
+++ b/.env.example
@@ -1,12 +1,19 @@
# Registered Netatmo application: GetNetatmoData v2
-NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152
-NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8
+# Mount every route below this path. Leave empty to serve from /.
+URL_PREFIX=/w
+
+NETATMO_CLIENT_ID=6a93db3d7cdb0e40ea0c2655
+NETATMO_CLIENT_SECRET=c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8
NETATMO_REFRESH_TOKEN=
+# Rotated OAuth tokens are persisted here with mode 0600.
+NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json
+# Must exactly match a redirect URI configured for the Netatmo application.
+NETATMO_REDIRECT_URI=http://www.suy.nl
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
# NETATMO_ACCESS_TOKEN=
# NETATMO_DEVICE_ID=
-RRD_FOLDER=./rrd
+RRD_FOLDER=/var/lib/rrd
POLL_INTERVAL=600
START_COLLECTOR=true
@@ -16,4 +23,3 @@ MODULE_WIND=Wind
MODULE_BEDROOM=Bedroom
MODULE_STUDY=Study
MODULE_LIVING=Living
-
diff --git a/.gitignore b/.gitignore
index 2ad2f35..a593af5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,4 @@ __pycache__/
.pytest_cache/
.env
rrd/
-
+netatmo_tokens.json
diff --git a/README.md b/README.md
index fcd71ca..1358dbd 100644
--- a/README.md
+++ b/README.md
@@ -30,9 +30,26 @@ 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.
+to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured
+for that application, then perform the initial `read_station` authorization:
+
+```sh
+set -a
+. ./.env
+set +a
+.venv/bin/python scripts/get_netatmo_token.py
+```
+
+The helper opens Netatmo's consent page. After approval, paste the complete URL
+from the browser's address bar back into the prompt. This works even if the local
+callback page itself cannot be reached. The script exchanges the authorization
+code and writes the token file with mode `0600`.
+
+The service loads `NETATMO_TOKEN_FILE` at startup. Before an access token expires,
+it refreshes it and atomically saves both the new access token and any rotated
+refresh token. Values in the token file take precedence over initial token values
+in `.env`. Client credentials remain environment-only and are never written to
+the token file or an RRD.
Export the file and run Flask:
@@ -53,6 +70,9 @@ scheduler runs inside the service process. Alternatively set
`RRD_FOLDER` configures the database directory (default `./rrd`). The module
variables in `.env.example` allow the five Netatmo display names to be changed.
+Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint
+below `/w`; leave it empty to serve from the site root. When a prefix is set,
+open instead.
## HTTP API
diff --git a/netatmo_service/app.py b/netatmo_service/app.py
index 516be5e..220d4ed 100644
--- a/netatmo_service/app.py
+++ b/netatmo_service/app.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import os
-from flask import Flask, Response, abort, jsonify, render_template
+from flask import Flask, Response, abort, jsonify, render_template, url_for
from .collector import Collector
from .config import Config
@@ -11,12 +11,34 @@ from .netatmo import NetatmoClient
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
+class PrefixMiddleware:
+ """Mount a WSGI application below a fixed URL path."""
+
+ def __init__(self, application, prefix: str):
+ self.application = application
+ self.prefix = prefix
+
+ def __call__(self, environ, start_response):
+ path = environ.get("PATH_INFO", "")
+ if path == self.prefix or path.startswith(f"{self.prefix}/"):
+ environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + self.prefix
+ environ["PATH_INFO"] = path[len(self.prefix):] or "/"
+ return self.application(environ, start_response)
+ start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
+ return [b"Not Found\n"]
+
+
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)
+ prefix = app.config["URL_PREFIX"]
+ app.config["APPLICATION_ROOT"] = prefix or "/"
+ if prefix:
+ app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix)
+
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"]
@@ -37,7 +59,12 @@ def create_app(test_config: dict | None = None) -> Flask:
@app.get("/")
def dashboard():
- return render_template("dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS))
+ return render_template(
+ "dashboard.html",
+ rrd_names=list(SCHEMAS),
+ periods=list(PERIODS),
+ graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"),
+ )
@app.get("/health")
def health():
diff --git a/netatmo_service/config.py b/netatmo_service/config.py
index 938031c..bf0f2d4 100644
--- a/netatmo_service/config.py
+++ b/netatmo_service/config.py
@@ -9,8 +9,19 @@ def _bool(name: str, default: bool) -> bool:
return default if value is None else value.lower() in {"1", "true", "yes", "on"}
+def _prefix(value: str) -> str:
+ value = value.strip()
+ if not value or value == "/":
+ return ""
+ return "/" + value.strip("/")
+
+
class Config:
+ URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
+ NETATMO_TOKEN_FILE = Path(
+ os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json"))
+ ).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"))
diff --git a/netatmo_service/netatmo.py b/netatmo_service/netatmo.py
index c67ce3f..917babb 100644
--- a/netatmo_service/netatmo.py
+++ b/netatmo_service/netatmo.py
@@ -2,6 +2,9 @@ from __future__ import annotations
import json
import logging
+import os
+from pathlib import Path
+import tempfile
import time
import urllib.error
import urllib.parse
@@ -14,6 +17,43 @@ class NetatmoError(RuntimeError):
pass
+class TokenStore:
+ """Persist OAuth tokens atomically in a file readable only by its owner."""
+
+ def __init__(self, path: Path | str):
+ self.path = Path(path)
+
+ def load(self) -> dict:
+ if not self.path.exists():
+ return {}
+ try:
+ with self.path.open(encoding="utf-8") as token_file:
+ data = json.load(token_file)
+ except (OSError, ValueError) as exc:
+ raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc
+ return data if isinstance(data, dict) else {}
+
+ def save(self, tokens: dict) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{self.path.name}.", dir=self.path.parent
+ )
+ try:
+ os.fchmod(descriptor, 0o600)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as token_file:
+ json.dump(tokens, token_file, indent=2, sort_keys=True)
+ token_file.write("\n")
+ token_file.flush()
+ os.fsync(token_file.fileno())
+ os.replace(temporary_name, self.path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
class NetatmoClient:
"""Small dependency-free Netatmo OAuth and Weather API client."""
@@ -22,10 +62,14 @@ class NetatmoClient:
self.client_secret = config["NETATMO_CLIENT_SECRET"]
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
self.access_token = config["NETATMO_ACCESS_TOKEN"]
+ self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"])
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
+ stored = self.token_store.load()
+ self.access_token = stored.get("access_token", self.access_token)
+ self.refresh_token = stored.get("refresh_token", self.refresh_token)
+ self._expires_at = float(stored.get("expires_at", 0))
@property
def configured(self) -> bool:
@@ -49,10 +93,37 @@ class NetatmoClient:
}
).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
+ self._accept_tokens(result)
+
+ def exchange_code(self, code: str, redirect_uri: str) -> dict:
+ """Exchange a first-time OAuth authorization code and persist its tokens."""
+ data = urllib.parse.urlencode(
+ {
+ "grant_type": "authorization_code",
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ "code": code,
+ "redirect_uri": redirect_uri,
+ }
+ ).encode()
+ result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
+ self._accept_tokens(result)
+ return result
+
+ def _accept_tokens(self, result: dict) -> None:
+ if "access_token" not in result:
+ raise NetatmoError("Netatmo token response contains no access token")
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
+ persisted = {
+ "access_token": self.access_token,
+ "refresh_token": self.refresh_token,
+ "expires_at": self._expires_at,
+ }
+ if result.get("scope"):
+ persisted["scope"] = result["scope"]
+ self.token_store.save(persisted)
def stations_data(self) -> dict:
if not self.configured:
@@ -74,4 +145,3 @@ class NetatmoClient:
self._refresh()
request.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request(request)
-
diff --git a/netatmo_service/templates/dashboard.html b/netatmo_service/templates/dashboard.html
index 8e3f4e7..4a69a39 100644
--- a/netatmo_service/templates/dashboard.html
+++ b/netatmo_service/templates/dashboard.html
@@ -24,14 +24,17 @@
{% endfor %}