"""Modbus profile loader, validator, and decoder. A *profile* is a YAML file that describes the protocol-level knowledge for a particular Modbus device model: - Which registers to read (``blocks`` for efficient bulk reads). - How to decode each quantity (``metrics``: register address, type, unit, …). - Home Assistant metadata per metric (``device_class``, ``ha_component``, …). Profiles live in ``app/integrations/modbus/profiles/.yaml`` and are located at runtime relative to *this file* (not CWD), so they work whether the package is installed as a wheel, run in-process, or invoked via ``python -m scripts.modbus_cli``. Design notes ------------ - **Purely data + functions** — no abstract base classes or inheritance. - ``ModbusProfile`` and ``MetricSpec`` are Pydantic models: they validate on construction and raise ``pydantic.ValidationError`` for malformed YAML. - ``decode`` uses ``driver.registers_to_float`` for float32 quantities. Unsupported types are silently skipped with a warning (future-proofing for int16/uint16 etc.). """ from __future__ import annotations import logging from pathlib import Path from typing import Optional import yaml from pydantic import BaseModel, ValidationError from app.integrations.modbus.driver import registers_to_float logger = logging.getLogger(__name__) # Directory containing the YAML profiles, located relative to *this* file. _PROFILES_DIR = Path(__file__).parent / "profiles" # --------------------------------------------------------------------------- # Pydantic models for profile validation # --------------------------------------------------------------------------- class MetricSpec(BaseModel): """Specification for a single measurable quantity within a profile.""" key: str """Stable identifier used as the key in decoded payloads (e.g. ``"voltage"``).""" address: int """Starting Modbus register address (0-based, e.g. ``0x0000`` for voltage).""" type: str """Encoding type — currently only ``"float32"`` is supported.""" unit: str """Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``). Empty string for dimensionless.""" device_class: str """Home Assistant device class (e.g. ``"voltage"``, ``"power"``, ``"energy"``).""" ha_component: str """Home Assistant component type (e.g. ``"sensor"``).""" state_class: Optional[str] = None """Home Assistant state class, if applicable (e.g. ``"total_increasing"``).""" class BlockSpec(BaseModel): """A contiguous register block for bulk reading.""" start: int """First register address in the block.""" count: int """Number of 16-bit registers to read.""" class ModbusProfile(BaseModel): """Complete description of a Modbus device's protocol-level knowledge.""" name: str """Short identifier matching the YAML file name (e.g. ``"sdm120"``).""" description: str """Human-readable description of the device.""" function_code: int """Modbus function code for reading measurements (4 = FC04 input registers).""" word_order: str """Word (register) order: ``"big"`` means high register first.""" byte_order: str """Byte order within each register: ``"big"`` means standard network order.""" blocks: list[BlockSpec] """Register blocks to read in bulk, reducing Modbus transaction count.""" metrics: list[MetricSpec] """List of individual measurable quantities and their decoding rules.""" # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- class ProfileNotFoundError(FileNotFoundError): """Raised when the requested profile YAML file does not exist.""" class ProfileValidationError(ValueError): """Raised when a profile YAML file fails Pydantic validation.""" def load_profile(name: str) -> ModbusProfile: """Load and validate a Modbus device profile by name. The profile file is expected at ``app/integrations/modbus/profiles/.yaml``. Parameters ---------- name: Profile name without extension (e.g. ``"sdm120"``). Returns ------- ModbusProfile Validated profile model. Raises ------ ProfileNotFoundError If ``profiles/.yaml`` does not exist. ProfileValidationError If the YAML is syntactically valid but the content fails schema validation (missing required fields, wrong types, etc.). """ path = _PROFILES_DIR / f"{name}.yaml" if not path.exists(): raise ProfileNotFoundError( f"Modbus profile '{name}' not found (looked for {path})" ) with path.open("r", encoding="utf-8") as fh: raw = yaml.safe_load(fh) try: profile = ModbusProfile.model_validate(raw) except ValidationError as exc: raise ProfileValidationError( f"Profile '{name}' failed validation: {exc}" ) from exc return profile def list_profiles() -> list[tuple[str, str]]: """Return ``(name, description)`` pairs for all available profiles. Scans ``app/integrations/modbus/profiles/*.yaml``, loads each, and returns a sorted list of ``(name, description)`` tuples. Profiles that fail to load are skipped with a warning. """ results: list[tuple[str, str]] = [] if not _PROFILES_DIR.exists(): return results for path in sorted(_PROFILES_DIR.glob("*.yaml")): name = path.stem try: profile = load_profile(name) results.append((profile.name, profile.description)) except (ProfileNotFoundError, ProfileValidationError, Exception) as exc: logger.warning("Skipping malformed profile '%s': %s", name, exc) return results def decode(profile: ModbusProfile, registers: dict[int, int]) -> dict[str, float]: """Decode raw register values into a mapping of ``{key: float_value}``. For each metric in *profile*, the function looks up the two registers at ``address`` and ``address + 1`` in *registers* and decodes them as a big-endian float32 (high register first). Metrics whose registers are absent from *registers* are skipped (not an error — this can happen if a block was not requested or a device does not populate that register). Metrics with unsupported ``type`` values are also skipped. Parameters ---------- profile: Validated ``ModbusProfile`` describing the device. registers: Mapping of ``{register_address: 16-bit_value}`` as returned by ``driver.read_blocks``. Returns ------- dict[str, float] Decoded engineering values keyed by each metric's ``key`` field. """ result: dict[str, float] = {} for metric in profile.metrics: if metric.type == "float32": hi_addr = metric.address lo_addr = metric.address + 1 if hi_addr not in registers or lo_addr not in registers: logger.debug( "Skipping metric '%s': registers 0x%04X/0x%04X not available", metric.key, hi_addr, lo_addr, ) continue value = registers_to_float(registers[hi_addr], registers[lo_addr]) result[metric.key] = value else: logger.warning( "Metric '%s' has unsupported type '%s', skipping", metric.key, metric.type, ) return result