diff --git a/.env.example b/.env.example
deleted file mode 100644
index 181e932..0000000
--- a/.env.example
+++ /dev/null
@@ -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
diff --git a/.gitignore b/.gitignore
index a593af5..40f0aa5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,6 @@ __pycache__/
*.py[cod]
.pytest_cache/
.env
+config.yaml
rrd/
netatmo_tokens.json
diff --git a/README.md b/README.md
index aa608b2..8331131 100644
--- a/README.md
+++ b/README.md
@@ -26,17 +26,15 @@ but may require the RRDtool development headers and a C compiler.
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
-cp .env.example .env
+cp config.example.yaml config.yaml
```
Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
-to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured
-for that application, then perform the initial `read_station` authorization:
+to the private `config.yaml`. 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
```
@@ -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
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
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.
-Export the file and run Flask:
+Run the service; host, port, and URL prefix all come from `config.yaml`:
```sh
-set -a
-. ./.env
-set +a
-.venv/bin/flask --app wsgi run --host 0.0.0.0
+.venv/bin/python wsgi.py
```
-Open . Keep one application worker because the polling
+With the example settings, open . Keep one application worker because the polling
scheduler runs inside the service process. Alternatively set
-`START_COLLECTOR=false` in web workers and invoke this from a system timer:
+`collector.enabled: false` in web workers and invoke this from a system timer:
```sh
.venv/bin/flask --app wsgi collect-now
```
-`RRD_FOLDER` configures the database directory (default `./rrd`). The module
-variables in `.env.example` allow the five Netatmo display names to be changed.
-Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint
+`storage.rrd_folder` configures the database directory (default `./rrd`). The
+`modules` section allows the five Netatmo display names to be changed.
+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,
open 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
failure, HTTP status, endpoint, and duration without credentials. Collector,
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
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
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..11646ac
--- /dev/null
+++ b/config.example.yaml
@@ -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
diff --git a/netatmo_service/app.py b/netatmo_service/app.py
index eb038e5..2d9cac8 100644
--- a/netatmo_service/app.py
+++ b/netatmo_service/app.py
@@ -10,7 +10,7 @@ from flask import Flask, Response, abort, jsonify, render_template, url_for
from werkzeug.exceptions import HTTPException
from .collector import Collector
-from .config import Config
+from .config import default_config, load_config
from .netatmo import NetatmoClient
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
@@ -85,9 +85,9 @@ def configure_logging(app: Flask) -> None:
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.config.from_object(Config)
+ app.config.from_mapping(load_config(config_path) if test_config is None else default_config())
if test_config:
app.config.update(test_config)
diff --git a/netatmo_service/config.py b/netatmo_service/config.py
index dd4f6e8..dba3b6d 100644
--- a/netatmo_service/config.py
+++ b/netatmo_service/config.py
@@ -1,51 +1,91 @@
from __future__ import annotations
-import os
from pathlib import Path
+from typing import Any
-
-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"}
+import yaml
def _prefix(value: str) -> str:
- value = value.strip()
- if not value or value == "/":
- return ""
- return "/" + value.strip("/")
+ value = str(value).strip()
+ return "" if not value or value == "/" else "/" + value.strip("/")
-class Config:
- URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
- RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().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)
+def _path(value: str | Path, base: Path) -> Path:
+ path = Path(value).expanduser()
+ return (base / path).resolve() if not path.is_absolute() else path.resolve()
- 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"
+
+def default_config(base: Path | None = None) -> dict[str, Any]:
+ base = (base or Path.cwd()).resolve()
+ rrd = base / "rrd"
+ return {
+ "HOST": "0.0.0.0", "PORT": 5000, "URL_PREFIX": "", "RRD_FOLDER": rrd,
+ "LOG_FILE": rrd / "netatmo_service.log", "LOG_LEVEL": "INFO",
+ "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")
+
+ sections = {name: raw.get(name, {}) for name in
+ ("server", "storage", "logging", "collector", "graphs", "netatmo", "modules")}
+ for name, section in sections.items():
+ if not isinstance(section, dict):
+ 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
)
-
- 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")
+ for name in ("outdoor", "wind", "bedroom", "study", "living"):
+ result[f"MODULE_{name.upper()}"] = str(modules.get(name, result[f"MODULE_{name.upper()}"]))
+ return result
diff --git a/requirements.txt b/requirements.txt
index f16dce0..a063b5f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
Flask>=3.1,<4
+PyYAML>=6.0,<7
rrdtool-bindings # if python 3.14
rrdtool # if python 3.12
diff --git a/scripts/get_netatmo_token.py b/scripts/get_netatmo_token.py
index 361740b..72aa26e 100644
--- a/scripts/get_netatmo_token.py
+++ b/scripts/get_netatmo_token.py
@@ -4,7 +4,6 @@
from __future__ import annotations
import argparse
-import os
import secrets
import sys
import urllib.parse
@@ -14,6 +13,7 @@ from pathlib import Path
# Allow direct execution from a source checkout without installing the package.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from netatmo_service.config import load_config
from netatmo_service.netatmo import NetatmoClient, NetatmoError
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
@@ -21,16 +21,7 @@ AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--client-id", default=os.getenv("NETATMO_CLIENT_ID"))
- 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("--config", type=Path, default=Path("config.yaml"))
parser.add_argument("--no-browser", action="store_true")
return parser.parse_args()
@@ -51,15 +42,20 @@ def authorization_code(value: str, expected_state: str) -> str:
def main() -> int:
args = arguments()
- if not args.client_id or not args.client_secret:
- print("Set NETATMO_CLIENT_ID and NETATMO_CLIENT_SECRET first.", file=sys.stderr)
+ try:
+ 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
state = secrets.token_urlsafe(24)
url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode(
{
- "client_id": args.client_id,
- "redirect_uri": args.redirect_uri,
+ "client_id": config["NETATMO_CLIENT_ID"],
+ "redirect_uri": config["NETATMO_REDIRECT_URI"],
"scope": "read_station",
"state": state,
"response_type": "code",
@@ -73,23 +69,13 @@ def main() -> int:
try:
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.exchange_code(code, args.redirect_uri)
+ client.exchange_code(code, config["NETATMO_REDIRECT_URI"])
except (EOFError, ValueError, NetatmoError) as exc:
print(f"Token setup failed: {exc}", file=sys.stderr)
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
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..f131c8e
--- /dev/null
+++ b/tests/test_config.py
@@ -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"
diff --git a/wsgi.py b/wsgi.py
index fb87011..12256dc 100644
--- a/wsgi.py
+++ b/wsgi.py
@@ -3,5 +3,4 @@ from netatmo_service import create_app
app = create_app()
if __name__ == "__main__":
- app.run(host="0.0.0.0", port=30225)
-
+ app.run(host=app.config["HOST"], port=app.config["PORT"])