Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 37 additions & 46 deletions modpoll/modbus_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
116 changes: 116 additions & 0 deletions modpoll/register_decode.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 6 additions & 6 deletions tests/test_bit_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions tests/test_register_decode.py
Original file line number Diff line number Diff line change
@@ -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
Loading