M5-T03: add Modbus TCP driver, YAML profile framework, sdm120, modbus_cli

This commit is contained in:
2026-06-22 13:03:50 +02:00
parent d9c82e39fb
commit c89cea4953
11 changed files with 1631 additions and 1 deletions
+289
View File
@@ -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}"
)
+232
View File
@@ -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
)
+259
View File
@@ -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)