Files

284 lines
12 KiB
Python
Raw Permalink Normal View History

"""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_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()
@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
)