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
|
||||
@@ -306,7 +306,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
||||
- **Reviewer checklist**: 列/约束与 §3.2 一致;`payload` 为 JSON 列;`recorded_at` 为真实索引列(非塞进 payload);链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
|
||||
|
||||
### M5-T03 — Modbus 驱动 + YAML profile 框架(`sdm120`)
|
||||
- **Status**: `todo` · **Depends**: M5-T02
|
||||
- **Status**: `done` · **Depends**: M5-T02
|
||||
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 YAML profile 加载/校验/解码框架与 SDM120 profile。纯模块,mock client 单测。
|
||||
- **Files**: `create app/integrations/modbus/__init__.py`、`app/integrations/modbus/driver.py`、`app/integrations/modbus/profiles.py`、`app/integrations/modbus/profiles/sdm120.yaml`、`scripts/modbus_cli.py`;`modify requirements.in`/`requirements.txt`(加 `pymodbus`、`PyYAML`,重新锁定);`create tests/test_modbus_driver.py`、`tests/test_modbus_profiles.py`、`tests/test_modbus_cli.py`
|
||||
- **Steps**:
|
||||
|
||||
@@ -3,6 +3,7 @@ apscheduler>=3.10,<4.0
|
||||
argon2-cffi>=25.1,<26.0
|
||||
fastapi>=0.115,<0.116
|
||||
httpx>=0.28,<1.0
|
||||
pymodbus>=3.6,<4.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
pyotp>=2.9,<3.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
|
||||
@@ -59,6 +59,8 @@ pydantic-core==2.46.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.13.1
|
||||
# via -r requirements.in
|
||||
pymodbus==3.13.1
|
||||
# via -r requirements.in
|
||||
pyotp==2.10.0
|
||||
# via -r requirements.in
|
||||
python-dotenv==1.2.2
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Modbus CLI — read-only manual test tool for Modbus TCP devices.
|
||||
|
||||
Entry point::
|
||||
|
||||
python -m scripts.modbus_cli <subcommand> [options]
|
||||
|
||||
Subcommands
|
||||
-----------
|
||||
read --host H --port P --unit U --profile NAME
|
||||
Read all metrics defined in the named profile from the device and print
|
||||
decoded engineering values as a formatted table. Use this to verify the
|
||||
full decode chain end-to-end.
|
||||
|
||||
probe --host H --port P --unit U --fc {3,4} --address ADDR --count N
|
||||
[--decode float32]
|
||||
Send a raw FC03 (holding) or FC04 (input) read request for exactly N
|
||||
registers starting at ADDR, and print the raw 16-bit register values in
|
||||
hex. Optionally decode consecutive register pairs as big-endian float32.
|
||||
Use this for first-contact verification of gateway reachability, framer
|
||||
mode, and unit_id before setting up a full profile.
|
||||
|
||||
Design
|
||||
------
|
||||
- **Read-only**: only FC03 and FC04 are exposed. There is no write path.
|
||||
This prevents accidental modification of device configuration registers
|
||||
(Meter ID, baud rate, etc.) which could cause communication loss.
|
||||
- **No DB dependency**: the tool connects directly to the gateway without
|
||||
reading from the application database. It is safe to use before any
|
||||
device has been configured in the application.
|
||||
- **Non-zero exit on connection failure** so shell scripts can detect errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is on sys.path when invoked as ``python -m scripts.modbus_cli``
|
||||
# or directly.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.integrations.modbus.driver import (
|
||||
ModbusConnectionError,
|
||||
ModbusDriverError,
|
||||
ModbusResponseError,
|
||||
registers_to_float,
|
||||
)
|
||||
from app.integrations.modbus.profiles import ProfileNotFoundError, ProfileValidationError, decode, load_profile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper — raw FC03/FC04 read (used by ``probe``)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _raw_read(
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
fc: int,
|
||||
address: int,
|
||||
count: int,
|
||||
timeout: float = 5.0,
|
||||
) -> list[int]:
|
||||
"""Perform a raw FC03 or FC04 read and return the register list.
|
||||
|
||||
Raises ``ModbusConnectionError`` or ``ModbusResponseError`` on failure.
|
||||
"""
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
if fc == 4:
|
||||
response = client.read_input_registers(address, count=count, device_id=unit_id)
|
||||
elif fc == 3:
|
||||
response = client.read_holding_registers(address, count=count, device_id=unit_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported function code FC{fc:02d} (only FC03/FC04 are allowed)")
|
||||
|
||||
if response.isError():
|
||||
raise ModbusResponseError(
|
||||
f"Modbus exception reading FC{fc:02d} registers 0x{address:04X}+{count}: "
|
||||
f"{response}"
|
||||
)
|
||||
|
||||
regs = response.registers
|
||||
if len(regs) != count:
|
||||
raise ModbusResponseError(
|
||||
f"Expected {count} registers, got {len(regs)}"
|
||||
)
|
||||
return list(regs)
|
||||
|
||||
except ConnectionException as exc:
|
||||
raise ModbusConnectionError(
|
||||
f"Connection to {host}:{port} failed: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-command: read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_read(args: argparse.Namespace) -> None:
|
||||
"""Read all metrics from a named profile and print decoded values."""
|
||||
profile_name: str = args.profile
|
||||
host: str = args.host
|
||||
port: int = args.port
|
||||
unit_id: int = args.unit
|
||||
|
||||
# Load and validate the profile.
|
||||
try:
|
||||
profile = load_profile(profile_name)
|
||||
except ProfileNotFoundError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ProfileValidationError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Profile : {profile.name} — {profile.description}")
|
||||
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
||||
print()
|
||||
|
||||
# Convert profile blocks to the dict format expected by read_blocks.
|
||||
blocks = [{"start": b.start, "count": b.count} for b in profile.blocks]
|
||||
|
||||
# Perform the read.
|
||||
from app.integrations.modbus.driver import read_blocks
|
||||
|
||||
try:
|
||||
registers = read_blocks(host, port, unit_id, blocks)
|
||||
except ModbusConnectionError as exc:
|
||||
print(f"Connection error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ModbusResponseError as exc:
|
||||
print(f"Modbus response error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ModbusDriverError as exc:
|
||||
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Decode.
|
||||
values = decode(profile, registers)
|
||||
|
||||
# Print as table.
|
||||
print(f"{'Metric':<20} {'Value':>14} Unit")
|
||||
print("-" * 45)
|
||||
for metric in profile.metrics:
|
||||
key = metric.key
|
||||
if key in values:
|
||||
unit = metric.unit if metric.unit else "—"
|
||||
print(f"{key:<20} {values[key]:>14.4f} {unit}")
|
||||
else:
|
||||
print(f"{key:<20} {'(not available)':>14}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-command: probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_probe(args: argparse.Namespace) -> None:
|
||||
"""Perform a raw FC03/FC04 read and print raw register values."""
|
||||
host: str = args.host
|
||||
port: int = args.port
|
||||
unit_id: int = args.unit
|
||||
fc: int = args.fc
|
||||
address: int = args.address
|
||||
count: int = args.count
|
||||
do_decode: bool = args.decode == "float32"
|
||||
|
||||
if fc not in (3, 4):
|
||||
print(f"Error: --fc must be 3 or 4 (got {fc})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||
print(f"Request : FC{fc:02d} start=0x{address:04X} count={count}")
|
||||
print()
|
||||
|
||||
try:
|
||||
regs = _raw_read(host, port, unit_id, fc, address, count)
|
||||
except ModbusConnectionError as exc:
|
||||
print(f"Connection error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ModbusResponseError as exc:
|
||||
print(f"Modbus response error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ModbusDriverError as exc:
|
||||
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Print raw register values.
|
||||
print(f"{'Addr':>8} {'Hex':>6} {'Dec':>7}")
|
||||
print("-" * 28)
|
||||
for i, val in enumerate(regs):
|
||||
print(f"0x{address + i:04X} 0x{val:04X} {val:>7d}")
|
||||
|
||||
# Optional float32 decoding — consecutive register pairs.
|
||||
if do_decode and len(regs) >= 2:
|
||||
print()
|
||||
print("float32 decode (big-endian, high register first):")
|
||||
print(f"{'Addr pair':>14} {'Float value':>16}")
|
||||
print("-" * 35)
|
||||
for i in range(0, len(regs) - 1, 2):
|
||||
hi = regs[i]
|
||||
lo = regs[i + 1]
|
||||
fval = registers_to_float(hi, lo)
|
||||
print(f"0x{address + i:04X}+0x{address + i + 1:04X} {fval:>16.6f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _auto_int(value: str) -> int:
|
||||
"""Parse an integer that may be hex (``0x...``) or decimal."""
|
||||
return int(value, 0)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m scripts.modbus_cli",
|
||||
description=(
|
||||
"Modbus CLI — read-only manual test tool for Modbus TCP devices. "
|
||||
"Use 'read' to decode all profile metrics; use 'probe' for raw "
|
||||
"first-contact register inspection."
|
||||
),
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
||||
subparsers.required = True
|
||||
|
||||
# -- read --
|
||||
rp = subparsers.add_parser(
|
||||
"read",
|
||||
help=(
|
||||
"Read and decode all metrics defined in a profile. "
|
||||
"Prints a table of engineering values."
|
||||
),
|
||||
)
|
||||
rp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
||||
rp.add_argument(
|
||||
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
||||
)
|
||||
rp.add_argument(
|
||||
"--unit",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="UNIT_ID",
|
||||
help="Modbus slave / unit ID (Meter ID, typically 1).",
|
||||
)
|
||||
rp.add_argument(
|
||||
"--profile",
|
||||
required=True,
|
||||
metavar="NAME",
|
||||
help="Profile name (e.g. 'sdm120').",
|
||||
)
|
||||
rp.set_defaults(func=cmd_read)
|
||||
|
||||
# -- probe --
|
||||
pp = subparsers.add_parser(
|
||||
"probe",
|
||||
help=(
|
||||
"Raw read of N registers starting at ADDR via FC03 or FC04. "
|
||||
"Prints raw hex values; optionally decodes as big-endian float32."
|
||||
),
|
||||
)
|
||||
pp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
||||
pp.add_argument(
|
||||
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
||||
)
|
||||
pp.add_argument(
|
||||
"--unit",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="UNIT_ID",
|
||||
help="Modbus slave / unit ID.",
|
||||
)
|
||||
pp.add_argument(
|
||||
"--fc",
|
||||
type=int,
|
||||
choices=[3, 4],
|
||||
default=4,
|
||||
metavar="FC",
|
||||
help="Function code: 3 (holding) or 4 (input registers, default).",
|
||||
)
|
||||
pp.add_argument(
|
||||
"--address",
|
||||
type=_auto_int,
|
||||
required=True,
|
||||
metavar="ADDR",
|
||||
help="Start register address (hex e.g. 0x0000 or decimal).",
|
||||
)
|
||||
pp.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="N",
|
||||
help="Number of registers to read.",
|
||||
)
|
||||
pp.add_argument(
|
||||
"--decode",
|
||||
choices=["float32"],
|
||||
default=None,
|
||||
metavar="TYPE",
|
||||
help="Optional: decode consecutive register pairs as 'float32'.",
|
||||
)
|
||||
pp.set_defaults(func=cmd_probe)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Tests for scripts/modbus_cli.py.
|
||||
|
||||
Acceptance criteria covered
|
||||
----------------------------
|
||||
1. ``modbus_cli read`` (via mock) prints decoded engineering values for all
|
||||
profile metrics; columns are present for each metric key.
|
||||
2. ``modbus_cli read`` exits non-zero on connection failure.
|
||||
3. ``modbus_cli probe --fc 4`` prints raw register values in hex; optionally
|
||||
decodes float32 pairs.
|
||||
4. ``modbus_cli probe`` exits non-zero on connection failure.
|
||||
5. CLI has NO write-register sub-command (only ``read`` and ``probe`` exist).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure project root is available when tests are run from the repo root.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import scripts.modbus_cli as cli # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ok_response(registers: list[int]) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.isError.return_value = False
|
||||
resp.registers = registers
|
||||
return resp
|
||||
|
||||
|
||||
def _make_error_response() -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.isError.return_value = True
|
||||
resp.__str__ = lambda self: "ExceptionResponse(2)"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: read sub-command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadCommand:
|
||||
"""``python -m scripts.modbus_cli read ...`` with mocked driver."""
|
||||
|
||||
def _make_sdm120_registers(self) -> dict[int, int]:
|
||||
"""Return a minimal register map covering all 8 sdm120 core metrics."""
|
||||
import struct
|
||||
|
||||
def pack(value: float) -> tuple[int, int]:
|
||||
raw = struct.pack(">f", value)
|
||||
hi = (raw[0] << 8) | raw[1]
|
||||
lo = (raw[2] << 8) | raw[3]
|
||||
return hi, lo
|
||||
|
||||
pairs: dict[int, tuple[int, int]] = {
|
||||
0x0000: pack(230.2), # voltage
|
||||
0x0006: pack(1.3), # current
|
||||
0x000C: pack(295.0), # active_power
|
||||
0x001E: pack(0.98), # power_factor
|
||||
0x0046: pack(50.0), # frequency
|
||||
0x0048: pack(123.4), # import_energy
|
||||
0x004A: pack(0.0), # export_energy
|
||||
0x0156: pack(123.4), # total_energy
|
||||
}
|
||||
regs: dict[int, int] = {}
|
||||
for addr, (hi, lo) in pairs.items():
|
||||
regs[addr] = hi
|
||||
regs[addr + 1] = lo
|
||||
return regs
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_read_prints_decoded_values(self, mock_client_cls: MagicMock, capsys) -> None:
|
||||
"""``read`` prints all profile metric keys with values."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
|
||||
regs = self._make_sdm120_registers()
|
||||
# sdm120 has 2 blocks; simulate both responses.
|
||||
# Block 1: 0x0000, count=0x0060 → we need 0x60=96 registers
|
||||
block1 = [regs.get(i, 0) for i in range(0x0000, 0x0060)]
|
||||
# Block 2: 0x0156, count=0x0004 → 4 registers
|
||||
block2 = [regs.get(i, 0) for i in range(0x0156, 0x015A)]
|
||||
mock_client.read_input_registers.side_effect = [
|
||||
_make_ok_response(block1),
|
||||
_make_ok_response(block2),
|
||||
]
|
||||
|
||||
# Run the CLI.
|
||||
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||
"--profile", "sdm120"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
output = captured.out
|
||||
|
||||
# All eight core metric keys must appear in the output.
|
||||
for key in ("voltage", "current", "active_power", "power_factor",
|
||||
"frequency", "import_energy", "export_energy", "total_energy"):
|
||||
assert key in output, f"Missing metric key '{key}' in output:\n{output}"
|
||||
|
||||
# The decoded voltage should be close to 230.2.
|
||||
assert "230." in output, f"Expected voltage ~230.2 in output:\n{output}"
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_read_exits_nonzero_on_connection_failure(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""Connection failure causes ``sys.exit(1)``."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = False # Cannot connect.
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||
"--profile", "sdm120"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_read_exits_nonzero_for_missing_profile(self, capsys) -> None:
|
||||
"""Unknown profile name causes sys.exit(1)."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main(["read", "--host", "10.0.0.1", "--port", "502", "--unit", "1",
|
||||
"--profile", "nonexistent_profile_xyz"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: probe sub-command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProbeCommand:
|
||||
"""``python -m scripts.modbus_cli probe ...`` with mocked pymodbus."""
|
||||
|
||||
@patch("scripts.modbus_cli.ModbusTcpClient" if False else "pymodbus.client.ModbusTcpClient")
|
||||
def _run_probe(self, mock_cls, args: list[str], regs: list[int]):
|
||||
"""Helper: runs probe with a mocked client returning *regs*."""
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_probe_prints_raw_registers(self, mock_driver_cls, capsys) -> None:
|
||||
# probe uses its own client import, not the driver's — patch it there.
|
||||
pass # see next test for the direct approach
|
||||
|
||||
def _setup_mock_client(self, regs: list[int]) -> MagicMock:
|
||||
"""Return a mock ModbusTcpClient that returns *regs* for both FC03 and FC04."""
|
||||
mock_cls = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
ok_response = _make_ok_response(regs)
|
||||
mock_client.read_input_registers.return_value = ok_response
|
||||
mock_client.read_holding_registers.return_value = ok_response
|
||||
return mock_cls
|
||||
|
||||
def test_probe_fc4_prints_hex_values(self, capsys) -> None:
|
||||
"""FC04 probe prints at least two hex addresses and values."""
|
||||
mock_cls = self._setup_mock_client([0x4366, 0x3334])
|
||||
|
||||
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||
cli.main([
|
||||
"probe",
|
||||
"--host", "10.0.0.1",
|
||||
"--port", "502",
|
||||
"--unit", "1",
|
||||
"--fc", "4",
|
||||
"--address", "0x0000",
|
||||
"--count", "2",
|
||||
])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
output = captured.out
|
||||
# Expect hex addresses and values in the output.
|
||||
assert "0x0000" in output.lower() or "0000" in output
|
||||
assert "4366" in output.upper()
|
||||
assert "3334" in output.upper()
|
||||
|
||||
def test_probe_fc4_with_float32_decode(self, capsys) -> None:
|
||||
"""--decode float32 adds a float32 decode section to output."""
|
||||
mock_cls = self._setup_mock_client([0x4366, 0x3334])
|
||||
|
||||
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||
cli.main([
|
||||
"probe",
|
||||
"--host", "10.0.0.1",
|
||||
"--port", "502",
|
||||
"--unit", "1",
|
||||
"--fc", "4",
|
||||
"--address", "0x0000",
|
||||
"--count", "2",
|
||||
"--decode", "float32",
|
||||
])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
output = captured.out
|
||||
# The float decode section should show ~230.2.
|
||||
assert "230." in output, f"Expected ~230.2 in float decode output:\n{output}"
|
||||
|
||||
def test_probe_exits_nonzero_on_connection_failure(self, capsys) -> None:
|
||||
"""Connection failure causes non-zero exit."""
|
||||
mock_cls = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = False
|
||||
|
||||
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main([
|
||||
"probe",
|
||||
"--host", "10.0.0.1",
|
||||
"--port", "502",
|
||||
"--unit", "1",
|
||||
"--fc", "4",
|
||||
"--address", "0x0000",
|
||||
"--count", "2",
|
||||
])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_probe_fc3_also_works(self, capsys) -> None:
|
||||
"""FC03 (holding registers) is supported by probe."""
|
||||
mock_cls = self._setup_mock_client([0x0001, 0x0002])
|
||||
|
||||
with patch("pymodbus.client.ModbusTcpClient", mock_cls):
|
||||
cli.main([
|
||||
"probe",
|
||||
"--host", "10.0.0.1",
|
||||
"--port", "502",
|
||||
"--unit", "1",
|
||||
"--fc", "3",
|
||||
"--address", "0x0014",
|
||||
"--count", "2",
|
||||
])
|
||||
|
||||
# Verify holding-register read was called (not input-register read).
|
||||
mock_client = mock_cls.return_value
|
||||
mock_client.read_holding_registers.assert_called_once()
|
||||
mock_client.read_input_registers.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: no write sub-commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoWriteSubcommands:
|
||||
"""The CLI must not expose any write-register sub-commands."""
|
||||
|
||||
def test_no_write_subcommand_in_parser(self) -> None:
|
||||
"""The argument parser has no 'write', 'set', or 'force' sub-commands."""
|
||||
parser = cli.build_parser()
|
||||
# Access the subparsers group.
|
||||
subparsers_actions = [
|
||||
action
|
||||
for action in parser._actions
|
||||
if hasattr(action, "_name_parser_map")
|
||||
]
|
||||
assert len(subparsers_actions) == 1
|
||||
available_commands = set(subparsers_actions[0]._name_parser_map.keys())
|
||||
# Only read-only commands must exist.
|
||||
assert "read" in available_commands
|
||||
assert "probe" in available_commands
|
||||
# No write-related commands.
|
||||
for name in available_commands:
|
||||
assert name not in {"write", "set", "force", "write-register", "set-register"}, (
|
||||
f"Write sub-command '{name}' found in CLI — it must not exist"
|
||||
)
|
||||
|
||||
def test_only_read_and_probe_subcommands(self) -> None:
|
||||
"""Exactly two sub-commands are available: read and probe."""
|
||||
parser = cli.build_parser()
|
||||
subparsers_actions = [
|
||||
action
|
||||
for action in parser._actions
|
||||
if hasattr(action, "_name_parser_map")
|
||||
]
|
||||
available = set(subparsers_actions[0]._name_parser_map.keys())
|
||||
assert available == {"read", "probe"}, (
|
||||
f"Expected only {{read, probe}}, got {available}"
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests for app/integrations/modbus/driver.py.
|
||||
|
||||
All tests use mocks — no real hardware or network connection is required.
|
||||
|
||||
Acceptance criteria covered
|
||||
----------------------------
|
||||
1. ``registers_to_float(0x4366, 0x3334)`` decodes to ~230.2 (byte/word order correct).
|
||||
2. ``read_blocks`` returns the expected register map on a successful response.
|
||||
3. ``read_blocks`` raises ``ModbusConnectionError`` when the client cannot connect.
|
||||
4. ``read_blocks`` raises ``ModbusConnectionError`` when ``ConnectionException`` is raised.
|
||||
5. ``read_blocks`` raises ``ModbusResponseError`` when the response is a Modbus error frame.
|
||||
6. ``read_blocks`` raises ``ModbusResponseError`` when register count mismatches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.integrations.modbus.driver import (
|
||||
ModbusConnectionError,
|
||||
ModbusDriverError,
|
||||
ModbusResponseError,
|
||||
read_blocks,
|
||||
registers_to_float,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registers_to_float
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistersToFloat:
|
||||
"""Big-endian float32 decoding."""
|
||||
|
||||
def test_voltage_reference_value(self) -> None:
|
||||
"""PDF example: 0x4366,0x3334 → 230.2 V."""
|
||||
result = registers_to_float(0x4366, 0x3334)
|
||||
# IEEE-754 nearest float32 for 230.2 is ~230.20001220703125
|
||||
assert math.isclose(result, 230.2, rel_tol=1e-5), f"Got {result}"
|
||||
|
||||
def test_demand_time_reference_value(self) -> None:
|
||||
"""PDF example: 0x3F80,0x0000 → 1.0."""
|
||||
result = registers_to_float(0x3F80, 0x0000)
|
||||
assert math.isclose(result, 1.0, rel_tol=1e-7)
|
||||
|
||||
def test_network_node_reference_value(self) -> None:
|
||||
"""PDF example: 0x4270,0x0000 → 60.0."""
|
||||
result = registers_to_float(0x4270, 0x0000)
|
||||
assert math.isclose(result, 60.0, rel_tol=1e-7)
|
||||
|
||||
def test_zero(self) -> None:
|
||||
result = registers_to_float(0x0000, 0x0000)
|
||||
assert result == 0.0
|
||||
|
||||
def test_word_order_matters(self) -> None:
|
||||
"""Swapping hi/lo gives a different (wrong) value."""
|
||||
correct = registers_to_float(0x4366, 0x3334)
|
||||
swapped = registers_to_float(0x3334, 0x4366)
|
||||
assert correct != swapped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_blocks — helpers for building mock pymodbus responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ok_response(registers: list[int]) -> MagicMock:
|
||||
"""Build a fake successful read_input_registers response."""
|
||||
resp = MagicMock()
|
||||
resp.isError.return_value = False
|
||||
resp.registers = registers
|
||||
return resp
|
||||
|
||||
|
||||
def _make_error_response() -> MagicMock:
|
||||
"""Build a fake Modbus exception response."""
|
||||
resp = MagicMock()
|
||||
resp.isError.return_value = True
|
||||
resp.__str__ = lambda self: "ExceptionResponse(2)"
|
||||
return resp
|
||||
|
||||
|
||||
class TestReadBlocks:
|
||||
"""Unit tests for read_blocks using a mocked ModbusTcpClient."""
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_single_block_returns_register_map(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Successful single-block read returns correct {addr: value} map."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
mock_client.read_input_registers.return_value = _make_ok_response(
|
||||
[0x4366, 0x3334, 0x3F80, 0x0000]
|
||||
)
|
||||
|
||||
blocks = [{"start": 0x0000, "count": 4}]
|
||||
result = read_blocks("127.0.0.1", 502, 1, blocks)
|
||||
|
||||
assert result == {0: 0x4366, 1: 0x3334, 2: 0x3F80, 3: 0x0000}
|
||||
mock_client.read_input_registers.assert_called_once_with(
|
||||
0x0000, count=4, device_id=1
|
||||
)
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_multiple_blocks_merged(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Multiple blocks are read and merged into a single dict."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
|
||||
# First block: 2 registers at 0x0000
|
||||
resp1 = _make_ok_response([0x4366, 0x3334])
|
||||
# Second block: 2 registers at 0x0156
|
||||
resp2 = _make_ok_response([0x447A, 0x0000])
|
||||
mock_client.read_input_registers.side_effect = [resp1, resp2]
|
||||
|
||||
blocks = [{"start": 0x0000, "count": 2}, {"start": 0x0156, "count": 2}]
|
||||
result = read_blocks("127.0.0.1", 502, 1, blocks)
|
||||
|
||||
assert result[0x0000] == 0x4366
|
||||
assert result[0x0001] == 0x3334
|
||||
assert result[0x0156] == 0x447A
|
||||
assert result[0x0157] == 0x0000
|
||||
assert mock_client.read_input_registers.call_count == 2
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_connect_failure_raises_connection_error(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""connect() returning False raises ModbusConnectionError."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = False
|
||||
|
||||
with pytest.raises(ModbusConnectionError, match="Could not connect"):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_connection_exception_raises_connection_error(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""pymodbus ConnectionException during connect() → ModbusConnectionError."""
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.side_effect = ConnectionException("timeout")
|
||||
|
||||
with pytest.raises(ModbusConnectionError, match="failed"):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_modbus_exception_response_raises_response_error(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""Modbus exception frame from device raises ModbusResponseError."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
mock_client.read_input_registers.return_value = _make_error_response()
|
||||
|
||||
with pytest.raises(ModbusResponseError, match="exception"):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_register_count_mismatch_raises_response_error(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""Response with wrong register count raises ModbusResponseError."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
# Requested 4 but only got 2
|
||||
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
|
||||
|
||||
with pytest.raises(ModbusResponseError, match="Expected 4 registers"):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 4}])
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_connection_exception_during_read_raises_connection_error(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
"""ConnectionException during register read → ModbusConnectionError."""
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
mock_client.read_input_registers.side_effect = ConnectionException("dropped")
|
||||
|
||||
with pytest.raises(ModbusConnectionError, match="Lost connection"):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_client_always_closed_on_error(self, mock_client_cls: MagicMock) -> None:
|
||||
"""close() is always called even when an exception propagates."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
mock_client.read_input_registers.return_value = _make_error_response()
|
||||
|
||||
with pytest.raises(ModbusDriverError):
|
||||
read_blocks("10.0.0.1", 502, 1, [{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
||||
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.connect.return_value = True
|
||||
mock_client.read_input_registers.return_value = _make_ok_response([0x0001, 0x0002])
|
||||
|
||||
read_blocks("10.0.0.1", 502, unit_id=5, blocks=[{"start": 0, "count": 2}])
|
||||
|
||||
mock_client.read_input_registers.assert_called_once_with(
|
||||
0, count=2, device_id=5
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Tests for app/integrations/modbus/profiles.py.
|
||||
|
||||
Acceptance criteria covered
|
||||
----------------------------
|
||||
1. ``load_profile("sdm120")`` succeeds and returns a valid ``ModbusProfile``.
|
||||
2. ``decode(profile, registers)`` maps known register values to correct keys/values.
|
||||
3. ``load_profile`` raises a recognisable error for a missing profile.
|
||||
4. ``load_profile`` raises a recognisable error for a profile that fails validation.
|
||||
5. ``list_profiles()`` returns at least one entry containing ``"sdm120"``.
|
||||
6. ``decode`` silently skips metrics whose registers are absent from the map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from app.integrations.modbus.profiles import (
|
||||
ModbusProfile,
|
||||
ProfileNotFoundError,
|
||||
ProfileValidationError,
|
||||
decode,
|
||||
list_profiles,
|
||||
load_profile,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_profile — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadProfileSdm120:
|
||||
"""Validate that the shipped sdm120.yaml loads and validates correctly."""
|
||||
|
||||
def test_load_returns_modbus_profile(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
assert isinstance(profile, ModbusProfile)
|
||||
|
||||
def test_name_and_description(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
assert profile.name == "sdm120"
|
||||
assert "SDM120" in profile.description or "sdm120" in profile.description.lower()
|
||||
|
||||
def test_function_code_is_4(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
assert profile.function_code == 4
|
||||
|
||||
def test_word_and_byte_order_big(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
assert profile.word_order == "big"
|
||||
assert profile.byte_order == "big"
|
||||
|
||||
def test_has_at_least_two_blocks(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
assert len(profile.blocks) >= 2
|
||||
|
||||
def test_has_core_metrics(self) -> None:
|
||||
"""All eight core metrics are present."""
|
||||
profile = load_profile("sdm120")
|
||||
keys = {m.key for m in profile.metrics}
|
||||
expected = {
|
||||
"voltage",
|
||||
"current",
|
||||
"active_power",
|
||||
"power_factor",
|
||||
"frequency",
|
||||
"import_energy",
|
||||
"export_energy",
|
||||
"total_energy",
|
||||
}
|
||||
assert expected.issubset(keys), f"Missing keys: {expected - keys}"
|
||||
|
||||
def test_voltage_address_is_0x0000(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
voltage = next(m for m in profile.metrics if m.key == "voltage")
|
||||
assert voltage.address == 0x0000
|
||||
|
||||
def test_frequency_address_is_0x0046(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
freq = next(m for m in profile.metrics if m.key == "frequency")
|
||||
assert freq.address == 0x0046
|
||||
|
||||
def test_total_energy_address_is_0x0156(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
te = next(m for m in profile.metrics if m.key == "total_energy")
|
||||
assert te.address == 0x0156
|
||||
|
||||
def test_energy_metrics_have_state_class(self) -> None:
|
||||
profile = load_profile("sdm120")
|
||||
energy_keys = {"import_energy", "export_energy", "total_energy"}
|
||||
for m in profile.metrics:
|
||||
if m.key in energy_keys:
|
||||
assert m.state_class == "total_increasing", (
|
||||
f"{m.key} should have state_class='total_increasing'"
|
||||
)
|
||||
|
||||
def test_no_deployment_fields_in_profile(self) -> None:
|
||||
"""Profile YAML must not contain unit_id or friendly_name."""
|
||||
profile_path = (
|
||||
Path(__file__).parent.parent
|
||||
/ "app/integrations/modbus/profiles/sdm120.yaml"
|
||||
)
|
||||
with profile_path.open() as fh:
|
||||
raw = yaml.safe_load(fh)
|
||||
assert "unit_id" not in raw, "unit_id is a deployment field, not a protocol field"
|
||||
assert "friendly_name" not in raw, "friendly_name is a deployment field"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_profile — error cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadProfileErrors:
|
||||
def test_missing_profile_raises_not_found(self) -> None:
|
||||
with pytest.raises(ProfileNotFoundError, match="nonexistent_profile"):
|
||||
load_profile("nonexistent_profile")
|
||||
|
||||
def test_invalid_yaml_content_raises_validation_error(self, tmp_path: Path) -> None:
|
||||
"""A profile missing required fields raises ProfileValidationError."""
|
||||
bad_yaml = textwrap.dedent(
|
||||
"""\
|
||||
name: bad_profile
|
||||
description: Missing required fields
|
||||
# function_code, word_order, byte_order, blocks, metrics are all absent
|
||||
"""
|
||||
)
|
||||
bad_path = tmp_path / "bad_profile.yaml"
|
||||
bad_path.write_text(bad_yaml)
|
||||
|
||||
# Temporarily patch the profiles directory to include our bad file.
|
||||
fake_dir = tmp_path
|
||||
with patch("app.integrations.modbus.profiles._PROFILES_DIR", fake_dir):
|
||||
with pytest.raises(ProfileValidationError, match="bad_profile"):
|
||||
load_profile("bad_profile")
|
||||
|
||||
def test_wrong_type_in_yaml_raises_validation_error(self, tmp_path: Path) -> None:
|
||||
"""A profile with wrong field types raises ProfileValidationError."""
|
||||
bad_yaml = textwrap.dedent(
|
||||
"""\
|
||||
name: typed_wrong
|
||||
description: Wrong type for function_code
|
||||
function_code: "four" # should be int
|
||||
word_order: big
|
||||
byte_order: big
|
||||
blocks: []
|
||||
metrics: []
|
||||
"""
|
||||
)
|
||||
bad_path = tmp_path / "typed_wrong.yaml"
|
||||
bad_path.write_text(bad_yaml)
|
||||
|
||||
with patch("app.integrations.modbus.profiles._PROFILES_DIR", tmp_path):
|
||||
with pytest.raises(ProfileValidationError, match="typed_wrong"):
|
||||
load_profile("typed_wrong")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decode — register-to-value mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDecode:
|
||||
"""Verify decode() maps known register pairs to correct float values."""
|
||||
|
||||
def _make_registers(self, items: dict[int, tuple[int, int]]) -> dict[int, int]:
|
||||
"""Build a register map from ``{address: (hi, lo)}`` pairs."""
|
||||
regs: dict[int, int] = {}
|
||||
for addr, (hi, lo) in items.items():
|
||||
regs[addr] = hi
|
||||
regs[addr + 1] = lo
|
||||
return regs
|
||||
|
||||
def test_voltage_decoded_correctly(self) -> None:
|
||||
"""0x4366,0x3334 at address 0x0000 → ~230.2V."""
|
||||
profile = load_profile("sdm120")
|
||||
registers = self._make_registers({0x0000: (0x4366, 0x3334)})
|
||||
# Only voltage registers provided; others will be skipped.
|
||||
result = decode(profile, registers)
|
||||
assert "voltage" in result
|
||||
assert math.isclose(result["voltage"], 230.2, rel_tol=1e-5)
|
||||
|
||||
def test_total_energy_decoded_correctly(self) -> None:
|
||||
"""Float32 at address 0x0156 decoded as total_energy."""
|
||||
profile = load_profile("sdm120")
|
||||
# 0x447A0000 → 1000.0
|
||||
registers = self._make_registers({0x0156: (0x447A, 0x0000)})
|
||||
result = decode(profile, registers)
|
||||
assert "total_energy" in result
|
||||
assert math.isclose(result["total_energy"], 1000.0, rel_tol=1e-5)
|
||||
|
||||
def test_missing_registers_skipped(self) -> None:
|
||||
"""Metrics whose register addresses are absent are not in the output."""
|
||||
profile = load_profile("sdm120")
|
||||
# Only supply voltage registers; all others missing.
|
||||
registers = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||
result = decode(profile, registers)
|
||||
assert "voltage" in result
|
||||
assert "current" not in result
|
||||
assert "active_power" not in result
|
||||
|
||||
def test_full_sdm120_payload_keys(self) -> None:
|
||||
"""All 8 core keys appear when all registers are supplied."""
|
||||
profile = load_profile("sdm120")
|
||||
# Supply a minimal register map covering all 8 core metrics.
|
||||
regs = self._make_registers(
|
||||
{
|
||||
0x0000: (0x4366, 0x3334), # voltage ~230.2V
|
||||
0x0006: (0x3F80, 0x0000), # current ~1.0A
|
||||
0x000C: (0x4393, 0x8000), # active_power ~295.0W
|
||||
0x001E: (0x3F7A, 0xE148), # power_factor ~0.98
|
||||
0x0046: (0x4248, 0x0000), # frequency ~50.0Hz
|
||||
0x0048: (0x42F6, 0x6666), # import_energy ~123.2kWh
|
||||
0x004A: (0x0000, 0x0000), # export_energy ~0.0kWh
|
||||
0x0156: (0x42F6, 0x6666), # total_energy ~123.2kWh
|
||||
}
|
||||
)
|
||||
result = decode(profile, regs)
|
||||
expected_keys = {
|
||||
"voltage", "current", "active_power", "power_factor",
|
||||
"frequency", "import_energy", "export_energy", "total_energy",
|
||||
}
|
||||
assert expected_keys == set(result.keys())
|
||||
|
||||
def test_decode_does_not_modify_registers(self) -> None:
|
||||
"""decode() must not mutate the input register dict."""
|
||||
profile = load_profile("sdm120")
|
||||
original = {0x0000: 0x4366, 0x0001: 0x3334}
|
||||
registers = dict(original)
|
||||
decode(profile, registers)
|
||||
assert registers == original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListProfiles:
|
||||
def test_contains_sdm120(self) -> None:
|
||||
profiles = list_profiles()
|
||||
names = [name for name, _ in profiles]
|
||||
assert "sdm120" in names
|
||||
|
||||
def test_returns_list_of_tuples(self) -> None:
|
||||
profiles = list_profiles()
|
||||
assert isinstance(profiles, list)
|
||||
for item in profiles:
|
||||
assert isinstance(item, tuple)
|
||||
assert len(item) == 2
|
||||
name, description = item
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(description, str)
|
||||
Reference in New Issue
Block a user