Files
solar-relay/solar_relay/config.py
T
franzz aae5b73581
Deploy Solar Relay / deploy (push) Successful in 56s
Swap to private solarweb access
2026-11-02 18:06:01 +01:00

209 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Private runtime configuration; credential fields are excluded from reprs."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
import math
from pathlib import Path
import re
import tomllib
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo
LEGACY_HISTORY_SETTINGS = {"access_key_id_env", "access_key_value_env", "lookback_days"}
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
token: str = field(default="", repr=False)
@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 = ""
username_env: str = "SOLARWEB_USERNAME"
password_env: str = "SOLARWEB_PASSWORD"
lag_seconds: int = 7200
check_interval_seconds: int = 3600
request_timeout_seconds: float = 45
username: str = field(default="", repr=False)
password: str = field(default="", repr=False)
@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__),
# Keep existing private configurations valid while using the full retention window.
"history": set(HistoryConfig.__dataclass_fields__) | LEGACY_HISTORY_SETTINGS,
}.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, str):
raise ValueError("upload.token must be a string")
if upload.token and any(ord(c) < 33 or ord(c) > 126 for c in upload.token):
raise ValueError("upload.token must be a bearer token without whitespace")
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(**{key: value for key, value in data.get("history", {}).items()
if key not in LEGACY_HISTORY_SETTINGS})
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 ("username", "password"):
if not isinstance(getattr(history, name), str):
raise ValueError(f"history.{name} must be a string")
if (history.username or history.password) and (not history.username.strip() or not history.password):
raise ValueError("Set both history.username and history.password, or leave both empty to use environment variables")
for name in ("username_env", "password_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 [("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}")
_number(history.request_timeout_seconds, "history.request_timeout_seconds", 0.1)
return Config(collector, inverter, upload, http, history)