172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
"""Registry and configuration helpers for meter-source integrations.
|
|
|
|
The registry is deliberately I/O-free. Workers and HTTP handlers use these
|
|
helpers to share one config contract without opening a broker or serial port.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
SECRET_MASK = ""
|
|
|
|
|
|
class SourceProfileError(ValueError):
|
|
"""Raised when a source kind or its configuration is invalid."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceConfigField:
|
|
"""One source configuration field and its public metadata."""
|
|
|
|
name: str
|
|
value_type: type
|
|
default: Any = None
|
|
required: bool = False
|
|
secret: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeterSourceProfile:
|
|
"""A supported source kind's config, capabilities, and channel units."""
|
|
|
|
kind: str
|
|
fields: tuple[SourceConfigField, ...]
|
|
capabilities: frozenset[str]
|
|
allowed_units: frozenset[str]
|
|
|
|
|
|
DSMR_MQTT_PROFILE = MeterSourceProfile(
|
|
kind="dsmr_mqtt",
|
|
fields=(
|
|
SourceConfigField("broker_host", str, default=""),
|
|
SourceConfigField("broker_port", int, default=1883),
|
|
SourceConfigField("username", str, default="", secret=True),
|
|
SourceConfigField("password", str, default="", secret=True),
|
|
SourceConfigField("tls_enabled", bool, default=False),
|
|
SourceConfigField("topic", str, default="dsmr/json"),
|
|
SourceConfigField("tariff_topic", str, default="dsmr/meter-stats/electricity_tariff"),
|
|
SourceConfigField("sample_interval_s", int, default=10),
|
|
),
|
|
capabilities=frozenset({"discover", "mqtt_subscribe", "tariff"}),
|
|
allowed_units=frozenset({"kWh"}),
|
|
)
|
|
|
|
WARMTELINK_SERIAL_PROFILE = MeterSourceProfile(
|
|
kind="warmtelink_serial",
|
|
fields=(
|
|
SourceConfigField("path", str, required=True),
|
|
SourceConfigField("baudrate", int, default=115200),
|
|
SourceConfigField("data_bits", int, default=7),
|
|
SourceConfigField("parity", str, default="N"),
|
|
SourceConfigField("stop_bits", int, default=1),
|
|
),
|
|
capabilities=frozenset({"discover", "read_only_serial"}),
|
|
allowed_units=frozenset({"GJ", "m³"}),
|
|
)
|
|
|
|
SOURCE_PROFILES: dict[str, MeterSourceProfile] = {
|
|
DSMR_MQTT_PROFILE.kind: DSMR_MQTT_PROFILE,
|
|
WARMTELINK_SERIAL_PROFILE.kind: WARMTELINK_SERIAL_PROFILE,
|
|
}
|
|
|
|
|
|
def get_source_profile(kind: str) -> MeterSourceProfile:
|
|
"""Return the profile for *kind*, or raise a stable validation error."""
|
|
try:
|
|
return SOURCE_PROFILES[kind]
|
|
except KeyError as exc:
|
|
raise SourceProfileError(f"Unsupported meter source kind: {kind!r}") from exc
|
|
|
|
|
|
def list_source_profiles() -> list[MeterSourceProfile]:
|
|
"""Return profiles in deterministic kind order for a future API/UI."""
|
|
return [SOURCE_PROFILES[kind] for kind in sorted(SOURCE_PROFILES)]
|
|
|
|
|
|
def _check_type(field: SourceConfigField, value: Any) -> None:
|
|
# bool is a subclass of int; accept it only for explicitly boolean fields.
|
|
if type(value) is not field.value_type:
|
|
raise SourceProfileError(
|
|
f"Config field {field.name!r} must be a {field.value_type.__name__}."
|
|
)
|
|
|
|
|
|
def _validate_field_value(kind: str, field: SourceConfigField, value: Any) -> None:
|
|
_check_type(field, value)
|
|
if field.name == "path" and not value.startswith("/dev/"):
|
|
raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.")
|
|
if field.name in {"broker_port", "sample_interval_s", "baudrate"} and value <= 0:
|
|
raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.")
|
|
if field.name == "data_bits" and value != 7:
|
|
raise SourceProfileError("warmtelink_serial data_bits must be 7.")
|
|
if field.name == "parity" and value != "N":
|
|
raise SourceProfileError("warmtelink_serial parity must be 'N'.")
|
|
if field.name == "stop_bits" and value != 1:
|
|
raise SourceProfileError("warmtelink_serial stop_bits must be 1.")
|
|
|
|
|
|
def validate_source_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate a complete config and return it with profile defaults filled.
|
|
|
|
Unknown keys are rejected to make configuration additions explicit. Secret
|
|
masking is intentionally not interpreted here: callers must merge a PATCH
|
|
with its stored config first.
|
|
"""
|
|
profile = get_source_profile(kind)
|
|
if not isinstance(config, dict):
|
|
raise SourceProfileError("Source config must be an object.")
|
|
fields = {field.name: field for field in profile.fields}
|
|
unknown = set(config) - set(fields)
|
|
if unknown:
|
|
raise SourceProfileError(f"Unknown {kind} config field(s): {sorted(unknown)!r}")
|
|
|
|
validated: dict[str, Any] = {}
|
|
for field in profile.fields:
|
|
if field.name in config:
|
|
value = config[field.name]
|
|
elif field.required:
|
|
raise SourceProfileError(f"Missing required {kind} config field: {field.name!r}")
|
|
else:
|
|
value = field.default
|
|
_validate_field_value(kind, field, value)
|
|
validated[field.name] = value
|
|
return validated
|
|
|
|
|
|
def sanitize_source_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate and return a response-safe config with secrets masked."""
|
|
profile = get_source_profile(kind)
|
|
sanitized = validate_source_config(kind, config)
|
|
for field in profile.fields:
|
|
if field.secret:
|
|
sanitized[field.name] = SECRET_MASK
|
|
return sanitized
|
|
|
|
|
|
def merge_source_config(kind: str, current: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
"""Merge a partial PATCH into stored config, retaining masked secrets.
|
|
|
|
An empty secret value is the public response mask and therefore means
|
|
"keep the old value". New sources use :func:`validate_source_config`
|
|
instead, so an explicitly empty secret can still be initially configured.
|
|
"""
|
|
profile = get_source_profile(kind)
|
|
current_validated = validate_source_config(kind, current)
|
|
if not isinstance(patch, dict):
|
|
raise SourceProfileError("Source config patch must be an object.")
|
|
fields = {field.name: field for field in profile.fields}
|
|
unknown = set(patch) - set(fields)
|
|
if unknown:
|
|
raise SourceProfileError(f"Unknown {kind} config field(s): {sorted(unknown)!r}")
|
|
|
|
merged = dict(current_validated)
|
|
for name, value in patch.items():
|
|
field = fields[name]
|
|
if field.secret and value == SECRET_MASK:
|
|
continue
|
|
merged[name] = value
|
|
return validate_source_config(kind, merged)
|