"""Configuration contains environment variable names, never upload credentials.""" from dataclasses import dataclass from datetime import datetime, timezone import math from pathlib import Path import re import tomllib from urllib.parse import urlsplit from zoneinfo import ZoneInfo def utc_text(value: datetime) -> str: if value.tzinfo is None: raise ValueError("A timestamp must include its timezone") return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def date_bound(value: str | None, timezone_name: str) -> str | None: """Date bounds are local midnights; timestamp bounds may specify an offset.""" if value is None: return None parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=ZoneInfo(timezone_name)) return utc_text(parsed) @dataclass(frozen=True) class CollectorConfig: source_id: str archive: Path timezone: str = "Europe/Zurich" poll_interval_seconds: float = 300 retention_days: int = 30 @dataclass(frozen=True) class InverterConfig: base_url: str = "http://fronius.local" meter_id: str = "0" boiler_pin: str = "pin1" request_timeout_seconds: float = 5 min_request_interval_seconds: float = 4 @dataclass(frozen=True) class UploadConfig: url: str = "" token_env: str = "SOLAR_RELAY_TOKEN" batch_size: int = 100 request_timeout_seconds: float = 10 retry_initial_seconds: float = 5 retry_max_seconds: float = 300 @dataclass(frozen=True) class HttpConfig: enabled: bool = True bind: str = "127.0.0.1" port: int = 8765 base_path: str = "/solar-relay" history_days: int = 14 @dataclass(frozen=True) class HistoryConfig: enabled: bool = False pv_system_id: str = "" access_key_id_env: str = "SOLARWEB_ACCESS_KEY_ID" access_key_value_env: str = "SOLARWEB_ACCESS_KEY_VALUE" lookback_days: int = 7 lag_seconds: int = 7200 check_interval_seconds: int = 3600 request_timeout_seconds: float = 10 @dataclass(frozen=True) class Config: collector: CollectorConfig inverter: InverterConfig upload: UploadConfig http: HttpConfig = HttpConfig() history: HistoryConfig = HistoryConfig() def _number(value: object, name: str, minimum: float) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{name} must be a number") if not math.isfinite(value) or value < minimum: raise ValueError(f"{name} must be at least {minimum}") return float(value) def _url(value: str, name: str, *, upload: bool = False) -> None: if not isinstance(value, str): raise ValueError(f"{name} must be a URL string") parsed = urlsplit(value) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise ValueError(f"{name} must be an HTTP(S) URL") if parsed.username or parsed.password or parsed.fragment: raise ValueError(f"{name} cannot contain credentials or a fragment") if upload and parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"): raise ValueError("The upload URL must use HTTPS (HTTP is allowed only on loopback for testing)") # Validate the port now, rather than failing inside the recorder loop. parsed.port def load_config(path: Path) -> Config: path = path.resolve() with path.open("rb") as handle: data = tomllib.load(handle) unknown = set(data) - {"collector", "inverter", "upload", "http", "history"} if unknown: raise ValueError(f"Unknown configuration sections: {', '.join(sorted(unknown))}") for section, keys in { "collector": set(CollectorConfig.__dataclass_fields__), "inverter": set(InverterConfig.__dataclass_fields__), "upload": set(UploadConfig.__dataclass_fields__), "http": set(HttpConfig.__dataclass_fields__), "history": set(HistoryConfig.__dataclass_fields__), }.items(): if not isinstance(data.get(section, {}), dict): raise ValueError(f"{section} must be a table") extra = set(data.get(section, {})) - keys if extra: raise ValueError(f"Unknown {section} settings: {', '.join(sorted(extra))}") values = data.get("collector", {}) source_id = values.get("source_id", "home-gen24") if not isinstance(source_id, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", source_id): raise ValueError("collector.source_id must contain 1–80 letters, digits, dots, underscores or hyphens") archive = Path(values.get("archive", "data/archive")).expanduser() if not archive.is_absolute(): archive = path.parent / archive timezone_name = values.get("timezone", "Europe/Zurich") ZoneInfo(timezone_name) interval = _number(values.get("poll_interval_seconds", 300), "collector.poll_interval_seconds", 10) if interval != 300: raise ValueError("collector.poll_interval_seconds must be 300 (one observation every five minutes)") retention = values.get("retention_days", 30) if isinstance(retention, bool) or not isinstance(retention, int) or retention < 1: raise ValueError("collector.retention_days must be a positive integer") collector = CollectorConfig(source_id, archive.resolve(), timezone_name, interval, retention) inverter = InverterConfig(**data.get("inverter", {})) _url(inverter.base_url, "inverter.base_url") parsed = urlsplit(inverter.base_url) if parsed.path not in ("", "/") or parsed.query: raise ValueError("inverter.base_url must be the inverter origin, without a path or query") if not isinstance(inverter.meter_id, str) or not inverter.meter_id.isdecimal(): raise ValueError('inverter.meter_id must be a string such as "0"') if not isinstance(inverter.boiler_pin, str) or not re.fullmatch(r"pin[1-4]", inverter.boiler_pin): raise ValueError("inverter.boiler_pin must be pin1, pin2, pin3 or pin4") _number(inverter.request_timeout_seconds, "inverter.request_timeout_seconds", 0.1) _number(inverter.min_request_interval_seconds, "inverter.min_request_interval_seconds", 4) upload = UploadConfig(**data.get("upload", {})) if not isinstance(upload.url, str): raise ValueError("upload.url must be a string") if upload.url: _url(upload.url, "upload.url", upload=True) if not isinstance(upload.token_env, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", upload.token_env): raise ValueError("upload.token_env must be an environment variable name") if isinstance(upload.batch_size, bool) or not isinstance(upload.batch_size, int) or not 1 <= upload.batch_size <= 500: raise ValueError("upload.batch_size must be an integer between 1 and 500") _number(upload.request_timeout_seconds, "upload.request_timeout_seconds", 0.1) _number(upload.retry_initial_seconds, "upload.retry_initial_seconds", 1) _number(upload.retry_max_seconds, "upload.retry_max_seconds", upload.retry_initial_seconds) http = HttpConfig(**data.get("http", {})) if not isinstance(http.enabled, bool): raise ValueError("http.enabled must be true or false") if http.bind != "127.0.0.1": raise ValueError("http.bind must be 127.0.0.1; expose the page through a reverse proxy") if isinstance(http.port, bool) or not isinstance(http.port, int) or not 1024 <= http.port <= 65535: raise ValueError("http.port must be an integer between 1024 and 65535") if not isinstance(http.base_path, str) or (http.base_path and not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", http.base_path)): raise ValueError("http.base_path must be empty or a URL path without a trailing slash") if isinstance(http.history_days, bool) or not isinstance(http.history_days, int) or not 1 <= http.history_days <= 90: raise ValueError("http.history_days must be an integer between 1 and 90") history = HistoryConfig(**data.get("history", {})) if not isinstance(history.enabled, bool): raise ValueError("history.enabled must be true or false") if not isinstance(history.pv_system_id, str) or (history.pv_system_id and not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", history.pv_system_id)): raise ValueError("history.pv_system_id must be a UUID") if history.enabled and not history.pv_system_id: raise ValueError("Set history.pv_system_id before enabling Solar.web recovery") for name in ("access_key_id_env", "access_key_value_env"): if not isinstance(getattr(history, name), str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", getattr(history, name)): raise ValueError(f"history.{name} must name an environment variable") for name, minimum in [("lookback_days", 1), ("lag_seconds", 3600), ("check_interval_seconds", 3600)]: value = getattr(history, name) if isinstance(value, bool) or not isinstance(value, int) or value < minimum: raise ValueError(f"history.{name} must be an integer of at least {minimum}") if history.lookback_days > 30 or (history.enabled and history.lookback_days > retention): raise ValueError("history.lookback_days must be at most 30 and no longer than retention_days when enabled") _number(history.request_timeout_seconds, "history.request_timeout_seconds", 0.1) return Config(collector, inverter, upload, http, history)