"""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", "baudrate"} and value <= 0: raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.") if field.name == "sample_interval_s" and value < 0: raise SourceProfileError("Config field 'sample_interval_s' must not be negative.") 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)