diff --git a/modpoll/modbus_task.py b/modpoll/modbus_task.py index 7909e6e..3851898 100644 --- a/modpoll/modbus_task.py +++ b/modpoll/modbus_task.py @@ -7,13 +7,12 @@ import requests from prettytable import PrettyTable from pymodbus.client import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient -from pymodbus.constants import Endian from pymodbus.exceptions import ModbusException -from pymodbus.payload import BinaryPayloadDecoder from pymodbus.framer.ascii import FramerAscii as ModbusAsciiFramer from pymodbus.framer.rtu import FramerRTU as ModbusRtuFramer from pymodbus.framer.socket import FramerSocket as ModbusSocketFramer +from .register_decode import Endian, RegisterDecoder from .utils import on_threading_event, delay_thread from .mqtt_task import MqttHandler @@ -23,6 +22,17 @@ CONFIG_POLL_COL_MIN = 5 CONFIG_REF_COL_MIN = 5 +_ENDIAN_MAP = { + "BE_BE": (Endian.BIG, Endian.BIG), + "LE_BE": (Endian.LITTLE, Endian.BIG), + "LE_LE": (Endian.LITTLE, Endian.LITTLE), + "BE_LE": (Endian.BIG, Endian.LITTLE), +} + + +def _call_with_device_id(method, *args, device_id: int, **kwargs): + return method(*args, device_id=device_id, **kwargs) + class Device: def __init__(self, device_name: str, device_id: int): @@ -63,13 +73,12 @@ def poll(self, master) -> bool: data = None def _call_read(method): - # Prefer keyword-only signature (pymodbus 3.9+), fall back to positional for tests/fakes. - try: - return method( - self.start_address, count=self.size, slave=self.device.devid - ) - except TypeError: - return method(self.start_address, self.size, self.device.devid) + return _call_with_device_id( + method, + self.start_address, + count=self.size, + device_id=self.device.devid, + ) if self.fc == 1: result = _call_read(master.read_coils) @@ -139,41 +148,17 @@ def _call_read(method): return False def _get_decoder(self, data): - if "BE_BE" == self.endian.upper(): - return ( - BinaryPayloadDecoder.fromRegisters( - data, byteorder=Endian.BIG, wordorder=Endian.BIG - ) - if self.fc not in (1, 2) - else BinaryPayloadDecoder.fromCoils(data, byteorder=Endian.BIG) - ) - elif "LE_BE" == self.endian.upper(): - return ( - BinaryPayloadDecoder.fromRegisters( - data, byteorder=Endian.LITTLE, wordorder=Endian.BIG - ) - if self.fc not in (1, 2) - else BinaryPayloadDecoder.fromCoils(data, byteorder=Endian.LITTLE) - ) - elif "LE_LE" == self.endian.upper(): - return ( - BinaryPayloadDecoder.fromRegisters( - data, byteorder=Endian.LITTLE, wordorder=Endian.LITTLE - ) - if self.fc not in (1, 2) - else BinaryPayloadDecoder.fromCoils(data, byteorder=Endian.LITTLE) - ) - else: - return ( - BinaryPayloadDecoder.fromRegisters( - data, byteorder=Endian.BIG, wordorder=Endian.LITTLE - ) - if self.fc not in (1, 2) - else BinaryPayloadDecoder.fromCoils(data, byteorder=Endian.BIG) - ) + byteorder, wordorder = _ENDIAN_MAP.get( + self.endian.upper(), (Endian.BIG, Endian.LITTLE) + ) + if self.fc in (1, 2): + return RegisterDecoder.from_coils(data, byteorder=byteorder) + return RegisterDecoder.from_registers( + data, byteorder=byteorder, wordorder=wordorder + ) def _decode_and_update_reference( - self, ref: "Reference", decoder: BinaryPayloadDecoder + self, ref: "Reference", decoder: RegisterDecoder ): if ref.dtype == "bool" and ref.bit is not None: # Bit references read a 16-bit register and extract one bit. @@ -515,8 +500,11 @@ def write_coil(self, device_name: str, address: int, value) -> bool: try: if not self.connect(): return False - result = self.modbus_client.write_coil( - address, value, unit=dev.devid + result = _call_with_device_id( + self.modbus_client.write_coil, + address, + value, + device_id=dev.devid, ) return not result.isError() except ModbusException as e: @@ -533,8 +521,11 @@ def write_register(self, device_name, address: int, value) -> bool: try: if not self.connect(): return False - result = self.modbus_client.write_register( - address, value, unit=dev.devid + result = _call_with_device_id( + self.modbus_client.write_register, + address, + value, + device_id=dev.devid, ) return not result.isError() except ModbusException as e: diff --git a/modpoll/register_decode.py b/modpoll/register_decode.py new file mode 100644 index 0000000..efe43e1 --- /dev/null +++ b/modpoll/register_decode.py @@ -0,0 +1,116 @@ +"""Register and coil payload decoding (replaces removed pymodbus.payload). + +Derived from pymodbus 3.9.2 BinaryPayloadDecoder. +""" + +from __future__ import annotations + +from array import array +from struct import pack, unpack + +from pymodbus.pdu.utils import pack_bitstring, unpack_bitstring + + +class Endian: + BIG = ">" + LITTLE = "<" + + +class RegisterDecoder: + """Decode Modbus register/coil payloads with configurable byte and word order.""" + + def __init__(self, payload: bytes, byteorder=Endian.LITTLE, wordorder=Endian.BIG): + self._payload = payload + self._pointer = 0 + self._byteorder = byteorder + self._wordorder = wordorder + + @classmethod + def from_registers( + cls, + registers: list[int], + byteorder=Endian.LITTLE, + wordorder=Endian.BIG, + ) -> RegisterDecoder: + payload = pack(f"!{len(registers)}H", *registers) + return cls(payload, byteorder, wordorder) + + @classmethod + def from_coils( + cls, + coils: list[bool], + byteorder=Endian.LITTLE, + ) -> RegisterDecoder: + payload = b"" + if padding := len(coils) % 8: + coils = [False] * padding + coils + for chunk in (coils[i : i + 8] for i in range(0, len(coils), 8)): + payload += pack_bitstring(chunk[::-1]) + return cls(payload, byteorder) + + def _unpack_words(self, handle: bytes) -> bytes: + if Endian.LITTLE in {self._byteorder, self._wordorder}: + handle_array = array("H", handle) + if self._byteorder == Endian.LITTLE: + handle_array.byteswap() + if self._wordorder == Endian.LITTLE: + handle_array.reverse() + handle = handle_array.tobytes() + return handle + + def decode_16bit_uint(self) -> int: + self._pointer += 2 + handle = self._payload[self._pointer - 2 : self._pointer] + return unpack(self._byteorder + "H", handle)[0] + + def decode_16bit_int(self) -> int: + self._pointer += 2 + handle = self._payload[self._pointer - 2 : self._pointer] + return unpack(self._byteorder + "h", handle)[0] + + def decode_32bit_uint(self) -> int: + self._pointer += 4 + handle = self._unpack_words(self._payload[self._pointer - 4 : self._pointer]) + return unpack("!I", handle)[0] + + def decode_32bit_int(self) -> int: + self._pointer += 4 + handle = self._unpack_words(self._payload[self._pointer - 4 : self._pointer]) + return unpack("!i", handle)[0] + + def decode_64bit_uint(self) -> int: + self._pointer += 8 + handle = self._unpack_words(self._payload[self._pointer - 8 : self._pointer]) + return unpack("!Q", handle)[0] + + def decode_64bit_int(self) -> int: + self._pointer += 8 + handle = self._unpack_words(self._payload[self._pointer - 8 : self._pointer]) + return unpack("!q", handle)[0] + + def decode_16bit_float(self) -> float: + self._pointer += 2 + handle = self._unpack_words(self._payload[self._pointer - 2 : self._pointer]) + return unpack("!e", handle)[0] + + def decode_32bit_float(self) -> float: + self._pointer += 4 + handle = self._unpack_words(self._payload[self._pointer - 4 : self._pointer]) + return unpack("!f", handle)[0] + + def decode_64bit_float(self) -> float: + self._pointer += 8 + handle = self._unpack_words(self._payload[self._pointer - 8 : self._pointer]) + return unpack("!d", handle)[0] + + def decode_bits(self, package_len: int = 1) -> list[bool]: + self._pointer += package_len + handle = self._payload[self._pointer - 1 : self._pointer] + return unpack_bitstring(handle) + + def decode_string(self, size: int = 1) -> bytes: + self._pointer += size + return self._payload[self._pointer - size : self._pointer] + + def skip_bytes(self, nbytes: int) -> None: + self._pointer += nbytes diff --git a/pyproject.toml b/pyproject.toml index c3becfa..e8850b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ packages = [{ include = "modpoll" }] [tool.poetry.dependencies] python = ">=3.10,<4.0" -pymodbus = "~3.9.0" +pymodbus = ">=3.10,<4.0" paho-mqtt = "^2.1.0" prettytable = "^3.9.0" requests = "^2.32.5" diff --git a/requirements.txt b/requirements.txt index f1b350b..55f4187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ charset-normalizer==3.4.4 ; python_version >= "3.10" and python_version < "4.0" idna==3.11 ; python_version >= "3.10" and python_version < "4.0" paho-mqtt==2.1.0 ; python_version >= "3.10" and python_version < "4.0" prettytable==3.17.0 ; python_version >= "3.10" and python_version < "4.0" -pymodbus==3.9.2 ; python_version >= "3.10" and python_version < "4.0" +pymodbus==3.13.0 ; python_version >= "3.10" and python_version < "4.0" requests==2.32.5 ; python_version >= "3.10" and python_version < "4.0" urllib3==2.6.0 ; python_version >= "3.10" and python_version < "4.0" wcwidth==0.2.14 ; python_version >= "3.10" and python_version < "4.0" diff --git a/tests/test_bit_reference.py b/tests/test_bit_reference.py index 14b60ac..4dc4ee2 100644 --- a/tests/test_bit_reference.py +++ b/tests/test_bit_reference.py @@ -20,8 +20,8 @@ class FakeMaster: def __init__(self): self.called = None - def read_discrete_inputs(self, start_address, size, slave=None): - self.called = ("di", start_address, size) + def read_discrete_inputs(self, address, *, count=1, device_id=1): + self.called = ("di", address, count) return FakeResult(bits=[1, 0]) def read_coils(self, *args, **kwargs): # pragma: no cover - should not be used @@ -47,8 +47,8 @@ class FakeMaster: def __init__(self): self.called = None - def read_input_registers(self, start_address, size, slave=None): - self.called = ("ir", start_address, size) + def read_input_registers(self, address, *, count=1, device_id=1): + self.called = ("ir", address, count) return FakeResult(registers=[0x8001]) # 1000 0000 0000 0001 master = FakeMaster() @@ -71,8 +71,8 @@ class FakeMaster: def __init__(self): self.called = None - def read_input_registers(self, start_address, size, slave=None): - self.called = ("ir", start_address, size) + def read_input_registers(self, address, *, count=1, device_id=1): + self.called = ("ir", address, count) return FakeResult(registers=[0x8001]) # raw register value master = FakeMaster() diff --git a/tests/test_register_decode.py b/tests/test_register_decode.py new file mode 100644 index 0000000..677329c --- /dev/null +++ b/tests/test_register_decode.py @@ -0,0 +1,27 @@ +from modpoll.modbus_task import Device, Poller, Reference + + +class FakeResult: + def __init__(self, *, bits=None, registers=None): + self.bits = bits + self.registers = registers + + def isError(self): + return False + + +def test_poller_decodes_le_be_bit_and_float16(): + device = Device("dev", 1) + poller = Poller(device, 3, 40000, 2, "LE_BE") + ref_msb = Reference(device, "bit15", "40000:15", "bool", "r", None, None) + ref_float = Reference(device, "current", "40001", "float16", "r", None, None) + poller.add_readable_reference(ref_msb) + poller.add_readable_reference(ref_float) + + class FakeMaster: + def read_holding_registers(self, *args, **kwargs): + return FakeResult(registers=[0x8001, 0x4228]) + + assert poller.poll(FakeMaster()) is True + assert ref_msb.val is False + assert ref_float.val == 0.03326416015625