2026-06-22 13:03:50 +02:00
|
|
|
"""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()
|
|
|
|
|
|
2026-06-29 18:20:35 +02:00
|
|
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
|
|
|
|
def test_default_function_code_uses_fc04_input_registers(
|
|
|
|
|
self, mock_client_cls: MagicMock
|
|
|
|
|
) -> None:
|
|
|
|
|
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
|
|
|
|
|
|
|
|
|
|
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
|
|
|
|
|
mock_client.read_holding_registers.assert_not_called()
|
|
|
|
|
|
|
|
|
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
|
|
|
|
def test_function_code_3_uses_fc03_holding_registers(
|
|
|
|
|
self, mock_client_cls: MagicMock
|
|
|
|
|
) -> None:
|
|
|
|
|
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
|
mock_client.connect.return_value = True
|
|
|
|
|
mock_client.read_holding_registers.return_value = _make_ok_response(
|
|
|
|
|
[0x4366, 0x3334, 0x3F80, 0x0000]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
blocks = [{"start": 0x2000, "count": 4}]
|
|
|
|
|
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
|
|
|
|
|
|
|
|
|
|
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
|
|
|
|
|
mock_client.read_holding_registers.assert_called_once_with(
|
|
|
|
|
0x2000, count=4, device_id=1
|
|
|
|
|
)
|
|
|
|
|
mock_client.read_input_registers.assert_not_called()
|
|
|
|
|
|
|
|
|
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
|
|
|
|
def test_invalid_function_code_raises_before_connecting(
|
|
|
|
|
self, mock_client_cls: MagicMock
|
|
|
|
|
) -> None:
|
|
|
|
|
"""An unsupported function code is rejected without opening a connection."""
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
|
|
|
|
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
|
|
|
|
|
|
|
|
|
|
# No client should have been constructed or connected for a bad FC.
|
|
|
|
|
mock_client_cls.assert_not_called()
|
|
|
|
|
mock_client.connect.assert_not_called()
|
|
|
|
|
|
2026-06-22 13:03:50 +02:00
|
|
|
@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
|
|
|
|
|
)
|