232 lines
7.4 KiB
Python
232 lines
7.4 KiB
Python
"""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/<name>.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/<name>.yaml``.
|
||
|
|
|
||
|
|
Parameters
|
||
|
|
----------
|
||
|
|
name:
|
||
|
|
Profile name without extension (e.g. ``"sdm120"``).
|
||
|
|
|
||
|
|
Returns
|
||
|
|
-------
|
||
|
|
ModbusProfile
|
||
|
|
Validated profile model.
|
||
|
|
|
||
|
|
Raises
|
||
|
|
------
|
||
|
|
ProfileNotFoundError
|
||
|
|
If ``profiles/<name>.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
|