Files
solar-relay/solar_relay/config.py
T
franzz ec75ac15b0
Deploy Solar Relay / deploy (push) Failing after 4s
Initial commit
2026-11-01 22:33:32 +01:00

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