M5-T03: add Modbus TCP driver, YAML profile framework, sdm120, modbus_cli
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Modbus integration package — driver, profile loader, and decoder."""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||
|
||||
This module provides a single public function ``read_blocks`` that performs
|
||||
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
||||
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Only read function codes** (FC03/FC04) are exposed. There is no write path.
|
||||
- float32 decoding uses ``struct.unpack('>f', ...)`` directly rather than the
|
||||
pymodbus ``BinaryPayloadDecoder`` helper, which has had API churn across 3.x
|
||||
releases. Big-endian word order + big-endian byte order means the 4 raw bytes
|
||||
are already in standard network (big-endian) order.
|
||||
- ``ModbusTcpClient`` is used in synchronous mode (``connect()`` / ``close()``).
|
||||
The client is created fresh per call; this keeps the driver stateless and
|
||||
avoids threading concerns at the cost of one TCP handshake per read cycle.
|
||||
APScheduler jobs call this from a thread pool, so stateless is safer.
|
||||
- pymodbus 3.13.x uses ``device_id=`` as the slave-address keyword argument
|
||||
(renamed from ``slave=`` in earlier 3.x releases).
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
``ModbusDriverError``
|
||||
Base exception for all driver-level errors.
|
||||
``ModbusConnectionError``
|
||||
Raised when the TCP connection to the gateway cannot be established or is
|
||||
lost during the read.
|
||||
``ModbusResponseError``
|
||||
Raised when the gateway responds with a Modbus exception frame or when the
|
||||
returned register count does not match the requested count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from pymodbus.exceptions import ConnectionException, ModbusException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusDriverError(RuntimeError):
|
||||
"""Base class for all Modbus driver errors."""
|
||||
|
||||
|
||||
class ModbusConnectionError(ModbusDriverError):
|
||||
"""Cannot connect to (or lost connection with) the Modbus TCP gateway."""
|
||||
|
||||
|
||||
class ModbusResponseError(ModbusDriverError):
|
||||
"""Modbus gateway returned an exception frame or an unexpected response."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block definition type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A single read block: ``{"start": int, "count": int}``.
|
||||
Block = dict[str, int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_blocks(
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
blocks: Sequence[Block],
|
||||
*,
|
||||
timeout: float = 3.0,
|
||||
) -> dict[int, int]:
|
||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
host:
|
||||
Hostname or IP address of the Modbus TCP gateway.
|
||||
port:
|
||||
TCP port (typically 502).
|
||||
unit_id:
|
||||
Modbus slave / unit address (the device's Meter ID for SDM120).
|
||||
blocks:
|
||||
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||
contiguous register ranges to read. ``count`` is the number of
|
||||
16-bit registers (not bytes).
|
||||
timeout:
|
||||
TCP connect/read timeout in seconds (default 3 s).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[int, int]
|
||||
Flat mapping of ``{register_address: 16-bit_register_value}`` for
|
||||
every register returned across all blocks.
|
||||
|
||||
Raises
|
||||
------
|
||||
ModbusConnectionError
|
||||
If the TCP connection to the gateway fails.
|
||||
ModbusResponseError
|
||||
If the gateway returns a Modbus exception frame or an unexpected
|
||||
number of registers.
|
||||
"""
|
||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||
try:
|
||||
connected = client.connect()
|
||||
if not connected:
|
||||
raise ModbusConnectionError(
|
||||
f"Could not connect to Modbus gateway at {host}:{port}"
|
||||
)
|
||||
|
||||
registers: dict[int, int] = {}
|
||||
for block in blocks:
|
||||
start: int = block["start"]
|
||||
count: int = block["count"]
|
||||
_read_block(client, unit_id, start, count, registers)
|
||||
|
||||
return registers
|
||||
|
||||
except ConnectionException as exc:
|
||||
raise ModbusConnectionError(
|
||||
f"Connection to Modbus gateway {host}:{port} failed: {exc}"
|
||||
) from exc
|
||||
except ModbusException as exc:
|
||||
raise ModbusResponseError(f"Modbus protocol error: {exc}") from exc
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _read_block(
|
||||
client: ModbusTcpClient,
|
||||
unit_id: int,
|
||||
start: int,
|
||||
count: int,
|
||||
result: dict[int, int],
|
||||
) -> None:
|
||||
"""Read one block and merge into *result*. Raises on any error."""
|
||||
try:
|
||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||
except ConnectionException as exc:
|
||||
raise ModbusConnectionError(
|
||||
f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}"
|
||||
) from exc
|
||||
|
||||
if response.isError():
|
||||
raise ModbusResponseError(
|
||||
f"Modbus exception reading registers 0x{start:04X}+{count}: {response}"
|
||||
)
|
||||
|
||||
regs = response.registers
|
||||
if len(regs) != count:
|
||||
raise ModbusResponseError(
|
||||
f"Expected {count} registers from 0x{start:04X}, got {len(regs)}"
|
||||
)
|
||||
|
||||
for offset, value in enumerate(regs):
|
||||
result[start + offset] = value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def registers_to_float(hi_reg: int, lo_reg: int) -> float:
|
||||
"""Decode two 16-bit registers to a big-endian IEEE-754 float32.
|
||||
|
||||
The SDM120 (and most Modbus float devices) use big-endian word order:
|
||||
the high 16-bit register comes first, then the low register. Combined
|
||||
with big-endian byte order within each register, the raw 4-byte sequence
|
||||
is standard network-byte-order (big-endian) float32.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hi_reg:
|
||||
The first (higher-address-range, most-significant) 16-bit register.
|
||||
lo_reg:
|
||||
The second (lower-address-range, least-significant) 16-bit register.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
The decoded IEEE-754 single-precision floating-point value.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> registers_to_float(0x4366, 0x3334)
|
||||
230.20001220703125
|
||||
"""
|
||||
raw = struct.pack(">HH", hi_reg, lo_reg)
|
||||
return struct.unpack(">f", raw)[0]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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
|
||||
@@ -0,0 +1,74 @@
|
||||
name: sdm120
|
||||
description: Eastron SDM120 single-phase energy meter
|
||||
function_code: 4 # input registers (FC04)
|
||||
word_order: big # high register first (big-endian word order)
|
||||
byte_order: big # high byte first within each register
|
||||
|
||||
blocks:
|
||||
# Registers 30001–30095 (0x0000–0x005F): voltage through max export demand
|
||||
- { start: 0x0000, count: 0x0060 }
|
||||
# Registers 30343–30346 (0x0156–0x0159): total active/reactive energy
|
||||
- { start: 0x0156, count: 0x0004 }
|
||||
|
||||
metrics:
|
||||
# Core measurements — addresses from SDM120 Modbus Protocol §4 (FC04 input registers)
|
||||
# Each float32 occupies two consecutive 16-bit registers; address = Hex start in §4.
|
||||
|
||||
- key: voltage
|
||||
address: 0x0000 # register 30001 — Voltage (V)
|
||||
type: float32
|
||||
unit: "V"
|
||||
device_class: voltage
|
||||
ha_component: sensor
|
||||
|
||||
- key: current
|
||||
address: 0x0006 # register 30007 — Current (A)
|
||||
type: float32
|
||||
unit: "A"
|
||||
device_class: current
|
||||
ha_component: sensor
|
||||
|
||||
- key: active_power
|
||||
address: 0x000C # register 30013 — Active power (W)
|
||||
type: float32
|
||||
unit: "W"
|
||||
device_class: power
|
||||
ha_component: sensor
|
||||
|
||||
- key: power_factor
|
||||
address: 0x001E # register 30031 — Power factor (dimensionless)
|
||||
type: float32
|
||||
unit: ""
|
||||
device_class: power_factor
|
||||
ha_component: sensor
|
||||
|
||||
- key: frequency
|
||||
address: 0x0046 # register 30071 — Frequency (Hz)
|
||||
type: float32
|
||||
unit: "Hz"
|
||||
device_class: frequency
|
||||
ha_component: sensor
|
||||
|
||||
- key: import_energy
|
||||
address: 0x0048 # register 30073 — Import active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
|
||||
- key: export_energy
|
||||
address: 0x004A # register 30075 — Export active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
|
||||
- key: total_energy
|
||||
address: 0x0156 # register 30343 — Total active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
Reference in New Issue
Block a user