introduced config.yaml

This commit is contained in:
2026-08-30 12:25:57 +02:00
parent bb80bb953c
commit 20fa7cb67d
10 changed files with 191 additions and 123 deletions
-30
View File
@@ -1,30 +0,0 @@
# Registered Netatmo application: GetNetatmoData v2
# 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/w
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
# NETATMO_ACCESS_TOKEN=
# NETATMO_DEVICE_ID=
RRD_FOLDER=/var/lib/rrd
LOG_FILE=/var/lib/rrd/netatmo_service.log
LOG_LEVEL=INFO
LOG_MAX_BYTES=5242880
LOG_BACKUP_COUNT=5
LOG_CONSOLE=true
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
+1
View File
@@ -2,5 +2,6 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.env .env
config.yaml
rrd/ rrd/
netatmo_tokens.json netatmo_tokens.json
+16 -21
View File
@@ -26,17 +26,15 @@ but may require the RRDtool development headers and a C compiler.
```sh ```sh
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
cp .env.example .env cp config.example.yaml config.yaml
``` ```
Add the client ID and secret for the Netatmo application **GetNetatmoData v2** Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured to the private `config.yaml`. Ensure `netatmo.redirect_uri` exactly matches a
for that application, then perform the initial `read_station` authorization: redirect URI configured for that application, then perform the initial
`read_station` authorization:
```sh ```sh
set -a
. ./.env
set +a
.venv/bin/python scripts/get_netatmo_token.py .venv/bin/python scripts/get_netatmo_token.py
``` ```
@@ -45,43 +43,40 @@ from the browser's address bar back into the prompt. This works even if the loca
callback page itself cannot be reached. The script exchanges the authorization callback page itself cannot be reached. The script exchanges the authorization
code and writes the token file with mode `0600`. code and writes the token file with mode `0600`.
The service loads `NETATMO_TOKEN_FILE` at startup. Before an access token expires, 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 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 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 in YAML. Client credentials remain in the private configuration and are never written to
the token file or an RRD. the token file or an RRD.
Export the file and run Flask: Run the service; host, port, and URL prefix all come from `config.yaml`:
```sh ```sh
set -a .venv/bin/python wsgi.py
. ./.env
set +a
.venv/bin/flask --app wsgi run --host 0.0.0.0
``` ```
Open <http://localhost:5000/>. Keep one application worker because the polling With the example settings, open <http://localhost:30225/w/>. Keep one application worker because the polling
scheduler runs inside the service process. Alternatively set scheduler runs inside the service process. Alternatively set
`START_COLLECTOR=false` in web workers and invoke this from a system timer: `collector.enabled: false` in web workers and invoke this from a system timer:
```sh ```sh
.venv/bin/flask --app wsgi collect-now .venv/bin/flask --app wsgi collect-now
``` ```
`RRD_FOLDER` configures the database directory (default `./rrd`). The module `storage.rrd_folder` configures the database directory (default `./rrd`). The
variables in `.env.example` allow the five Netatmo display names to be changed. `modules` section allows the five Netatmo display names to be changed.
Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint Set `server.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, below `/w`; leave it empty to serve from the site root. When a prefix is set,
open <http://localhost:5000/w/> instead. open <http://localhost:5000/w/> instead.
`LOG_FILE` selects a rotating application log (by default `logging.file` selects a rotating application log (by default
`RRD_FOLDER/netatmo_service.log`). Every Netatmo HTTP attempt records success or `RRD_FOLDER/netatmo_service.log`). Every Netatmo HTTP attempt records success or
failure, HTTP status, endpoint, and duration without credentials. Collector, failure, HTTP status, endpoint, and duration without credentials. Collector,
Flask, uncaught main-thread, and uncaught worker-thread exceptions are written to Flask, uncaught main-thread, and uncaught worker-thread exceptions are written to
the same log. `LOG_MAX_BYTES` defaults to 5 MiB and `LOG_BACKUP_COUNT` to five. the same log. `logging.max_bytes` defaults to 5 MiB and `backup_count` to five.
The service account must have write permission on the log directory. Console The service account must have write permission on the log directory. Console
logging remains enabled by default so `python wsgi.py` displays its listening logging remains enabled by default so `python wsgi.py` displays its listening
address; set `LOG_CONSOLE=false` to use only the logfile. address; set `logging.console: false` to use only the logfile.
## HTTP API ## HTTP API
+41
View File
@@ -0,0 +1,41 @@
# Copy this file to config.yaml. Keep config.yaml private; it contains secrets.
server:
host: 127.0.0.1
port: 30225
url_prefix: /w
storage:
rrd_folder: /var/lib/rrd
logging:
file: /var/lib/rrd/netatmo_service.log
level: INFO
max_bytes: 5242880
backup_count: 5
console: true
collector:
enabled: true
interval_seconds: 600
graphs:
width: 900
height: 240
netatmo:
client_id: 6a93db3d7cdb0e40ea0c2655
client_secret: c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8
redirect_uri: http://www.example.com/w
token_file: /var/lib/rrd/netatmo_tokens.json
device_id: ""
refresh_token: ""
access_token: ""
token_url: https://api.netatmo.com/oauth2/token
stations_url: https://api.netatmo.com/api/getstationsdata
modules:
outdoor: Outdoor
wind: Wind
bedroom: Bedroom
study: Study
living: Living
+3 -3
View File
@@ -10,7 +10,7 @@ from flask import Flask, Response, abort, jsonify, render_template, url_for
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from .collector import Collector from .collector import Collector
from .config import Config from .config import default_config, load_config
from .netatmo import NetatmoClient from .netatmo import NetatmoClient
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
@@ -85,9 +85,9 @@ def configure_logging(app: Flask) -> None:
threading.excepthook = uncaught_thread_exception threading.excepthook = uncaught_thread_exception
def create_app(test_config: dict | None = None) -> Flask: def create_app(test_config: dict | None = None, config_path=None) -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_mapping(load_config(config_path) if test_config is None else default_config())
if test_config: if test_config:
app.config.update(test_config) app.config.update(test_config)
+79 -39
View File
@@ -1,51 +1,91 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Any
import yaml
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"}
def _prefix(value: str) -> str: def _prefix(value: str) -> str:
value = value.strip() value = str(value).strip()
if not value or value == "/": return "" if not value or value == "/" else "/" + value.strip("/")
return ""
return "/" + value.strip("/")
class Config: def _path(value: str | Path, base: Path) -> Path:
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", "")) path = Path(value).expanduser()
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve() return (base / path).resolve() if not path.is_absolute() else path.resolve()
LOG_FILE = Path(
os.getenv("LOG_FILE", str(RRD_FOLDER / "netatmo_service.log"))
).expanduser().resolve()
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", str(5 * 1024 * 1024)))
LOG_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", "5"))
LOG_CONSOLE = _bool("LOG_CONSOLE", True)
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"))
START_COLLECTOR = _bool("START_COLLECTOR", True)
NETATMO_CLIENT_ID = os.getenv("NETATMO_CLIENT_ID", "")
NETATMO_CLIENT_SECRET = os.getenv("NETATMO_CLIENT_SECRET", "") def default_config(base: Path | None = None) -> dict[str, Any]:
NETATMO_REFRESH_TOKEN = os.getenv("NETATMO_REFRESH_TOKEN", "") base = (base or Path.cwd()).resolve()
NETATMO_ACCESS_TOKEN = os.getenv("NETATMO_ACCESS_TOKEN", "") rrd = base / "rrd"
NETATMO_DEVICE_ID = os.getenv("NETATMO_DEVICE_ID", "") return {
NETATMO_TOKEN_URL = os.getenv("NETATMO_TOKEN_URL", "https://api.netatmo.com/oauth2/token") "HOST": "0.0.0.0", "PORT": 5000, "URL_PREFIX": "", "RRD_FOLDER": rrd,
NETATMO_STATIONS_URL = os.getenv( "LOG_FILE": rrd / "netatmo_service.log", "LOG_LEVEL": "INFO",
"NETATMO_STATIONS_URL", "https://api.netatmo.com/api/getstationsdata" "LOG_MAX_BYTES": 5242880, "LOG_BACKUP_COUNT": 5, "LOG_CONSOLE": True,
"NETATMO_TOKEN_FILE": rrd / "netatmo_tokens.json",
"NETATMO_REDIRECT_URI": "http://localhost:5000/",
"GRAPH_WIDTH": 900, "GRAPH_HEIGHT": 240, "POLL_INTERVAL": 600,
"START_COLLECTOR": True, "NETATMO_CLIENT_ID": "", "NETATMO_CLIENT_SECRET": "",
"NETATMO_REFRESH_TOKEN": "", "NETATMO_ACCESS_TOKEN": "", "NETATMO_DEVICE_ID": "",
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
"MODULE_OUTDOOR": "Outdoor", "MODULE_WIND": "Wind", "MODULE_BEDROOM": "Bedroom",
"MODULE_STUDY": "Study", "MODULE_LIVING": "Living",
}
def load_config(filename: str | Path | None = None) -> dict[str, Any]:
"""Load and normalize the service's single YAML configuration file."""
filename = filename or "config.yaml"
config_path = Path(filename).expanduser().resolve()
if not config_path.is_file():
raise RuntimeError(
f"Configuration file not found: {config_path}. "
"Copy config.example.yaml to config.yaml and edit it."
) )
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(f"Cannot load YAML configuration {config_path}: {exc}") from exc
if not isinstance(raw, dict):
raise RuntimeError(f"YAML configuration {config_path} must contain a mapping")
MODULE_OUTDOOR = os.getenv("MODULE_OUTDOOR", "Outdoor") sections = {name: raw.get(name, {}) for name in
MODULE_WIND = os.getenv("MODULE_WIND", "Wind") ("server", "storage", "logging", "collector", "graphs", "netatmo", "modules")}
MODULE_BEDROOM = os.getenv("MODULE_BEDROOM", "Bedroom") for name, section in sections.items():
MODULE_STUDY = os.getenv("MODULE_STUDY", "Study") if not isinstance(section, dict):
MODULE_LIVING = os.getenv("MODULE_LIVING", "Living") raise RuntimeError(f"YAML section '{name}' must be a mapping")
base, result = config_path.parent, default_config(config_path.parent)
server, storage, log = sections["server"], sections["storage"], sections["logging"]
collector, graphs = sections["collector"], sections["graphs"]
netatmo, modules = sections["netatmo"], sections["modules"]
result.update({
"HOST": str(server.get("host", result["HOST"])),
"PORT": int(server.get("port", result["PORT"])),
"URL_PREFIX": _prefix(server.get("url_prefix", result["URL_PREFIX"])),
"GRAPH_WIDTH": int(graphs.get("width", result["GRAPH_WIDTH"])),
"GRAPH_HEIGHT": int(graphs.get("height", result["GRAPH_HEIGHT"])),
"POLL_INTERVAL": int(collector.get("interval_seconds", result["POLL_INTERVAL"])),
"START_COLLECTOR": bool(collector.get("enabled", result["START_COLLECTOR"])),
"LOG_LEVEL": str(log.get("level", result["LOG_LEVEL"])).upper(),
"LOG_MAX_BYTES": int(log.get("max_bytes", result["LOG_MAX_BYTES"])),
"LOG_BACKUP_COUNT": int(log.get("backup_count", result["LOG_BACKUP_COUNT"])),
"LOG_CONSOLE": bool(log.get("console", result["LOG_CONSOLE"])),
"NETATMO_CLIENT_ID": str(netatmo.get("client_id", "")),
"NETATMO_CLIENT_SECRET": str(netatmo.get("client_secret", "")),
"NETATMO_REFRESH_TOKEN": str(netatmo.get("refresh_token", "")),
"NETATMO_ACCESS_TOKEN": str(netatmo.get("access_token", "")),
"NETATMO_DEVICE_ID": str(netatmo.get("device_id", "")),
"NETATMO_REDIRECT_URI": str(netatmo.get("redirect_uri", result["NETATMO_REDIRECT_URI"])),
"NETATMO_TOKEN_URL": str(netatmo.get("token_url", result["NETATMO_TOKEN_URL"])),
"NETATMO_STATIONS_URL": str(netatmo.get("stations_url", result["NETATMO_STATIONS_URL"])),
})
result["RRD_FOLDER"] = _path(storage.get("rrd_folder", "rrd"), base)
result["LOG_FILE"] = _path(log.get("file", result["RRD_FOLDER"] / "netatmo_service.log"), base)
result["NETATMO_TOKEN_FILE"] = _path(
netatmo.get("token_file", result["RRD_FOLDER"] / "netatmo_tokens.json"), base
)
for name in ("outdoor", "wind", "bedroom", "study", "living"):
result[f"MODULE_{name.upper()}"] = str(modules.get(name, result[f"MODULE_{name.upper()}"]))
return result
+1
View File
@@ -1,3 +1,4 @@
Flask>=3.1,<4 Flask>=3.1,<4
PyYAML>=6.0,<7
rrdtool-bindings # if python 3.14 rrdtool-bindings # if python 3.14
rrdtool # if python 3.12 rrdtool # if python 3.12
+13 -27
View File
@@ -4,7 +4,6 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import secrets import secrets
import sys import sys
import urllib.parse import urllib.parse
@@ -14,6 +13,7 @@ from pathlib import Path
# Allow direct execution from a source checkout without installing the package. # Allow direct execution from a source checkout without installing the package.
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from netatmo_service.config import load_config
from netatmo_service.netatmo import NetatmoClient, NetatmoError from netatmo_service.netatmo import NetatmoClient, NetatmoError
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize" AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
@@ -21,16 +21,7 @@ AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
def arguments() -> argparse.Namespace: def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--client-id", default=os.getenv("NETATMO_CLIENT_ID")) parser.add_argument("--config", type=Path, default=Path("config.yaml"))
parser.add_argument("--client-secret", default=os.getenv("NETATMO_CLIENT_SECRET"))
parser.add_argument(
"--redirect-uri", default=os.getenv("NETATMO_REDIRECT_URI", "http://localhost:8080")
)
parser.add_argument(
"--token-file",
type=Path,
default=Path(os.getenv("NETATMO_TOKEN_FILE", "./rrd/netatmo_tokens.json")),
)
parser.add_argument("--no-browser", action="store_true") parser.add_argument("--no-browser", action="store_true")
return parser.parse_args() return parser.parse_args()
@@ -51,15 +42,20 @@ def authorization_code(value: str, expected_state: str) -> str:
def main() -> int: def main() -> int:
args = arguments() args = arguments()
if not args.client_id or not args.client_secret: try:
print("Set NETATMO_CLIENT_ID and NETATMO_CLIENT_SECRET first.", file=sys.stderr) config = load_config(args.config)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
if not config["NETATMO_CLIENT_ID"] or not config["NETATMO_CLIENT_SECRET"]:
print("Set netatmo.client_id and netatmo.client_secret in config.yaml first.", file=sys.stderr)
return 2 return 2
state = secrets.token_urlsafe(24) state = secrets.token_urlsafe(24)
url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode( url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode(
{ {
"client_id": args.client_id, "client_id": config["NETATMO_CLIENT_ID"],
"redirect_uri": args.redirect_uri, "redirect_uri": config["NETATMO_REDIRECT_URI"],
"scope": "read_station", "scope": "read_station",
"state": state, "state": state,
"response_type": "code", "response_type": "code",
@@ -73,23 +69,13 @@ def main() -> int:
try: try:
code = authorization_code(input("> "), state) code = authorization_code(input("> "), state)
config = {
"NETATMO_CLIENT_ID": args.client_id,
"NETATMO_CLIENT_SECRET": args.client_secret,
"NETATMO_REFRESH_TOKEN": "",
"NETATMO_ACCESS_TOKEN": "",
"NETATMO_TOKEN_FILE": args.token_file.expanduser().resolve(),
"NETATMO_DEVICE_ID": "",
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
}
client = NetatmoClient(config) client = NetatmoClient(config)
client.exchange_code(code, args.redirect_uri) client.exchange_code(code, config["NETATMO_REDIRECT_URI"])
except (EOFError, ValueError, NetatmoError) as exc: except (EOFError, ValueError, NetatmoError) as exc:
print(f"Token setup failed: {exc}", file=sys.stderr) print(f"Token setup failed: {exc}", file=sys.stderr)
return 1 return 1
print(f"Tokens saved to {args.token_file} with owner-only permissions.") print(f"Tokens saved to {config['NETATMO_TOKEN_FILE']} with owner-only permissions.")
return 0 return 0
+35
View File
@@ -0,0 +1,35 @@
from netatmo_service.config import load_config
def test_yaml_config_is_loaded_and_relative_paths_are_resolved(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text(
"""
server:
host: 127.0.0.1
port: 12345
url_prefix: weather/
storage:
rrd_folder: data
logging:
file: logs/service.log
collector:
enabled: false
netatmo:
client_id: test-client
client_secret: test-secret
token_file: secrets/tokens.json
modules:
study: Office
""",
encoding="utf-8",
)
config = load_config(config_file)
assert config["HOST"] == "127.0.0.1"
assert config["PORT"] == 12345
assert config["URL_PREFIX"] == "/weather"
assert config["RRD_FOLDER"] == tmp_path / "data"
assert config["LOG_FILE"] == tmp_path / "logs/service.log"
assert config["NETATMO_TOKEN_FILE"] == tmp_path / "secrets/tokens.json"
assert config["START_COLLECTOR"] is False
assert config["MODULE_STUDY"] == "Office"
+1 -2
View File
@@ -3,5 +3,4 @@ from netatmo_service import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=30225) app.run(host=app.config["HOST"], port=app.config["PORT"])