196 lines
9.3 KiB
Python
196 lines
9.3 KiB
Python
"""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)
|