Files
wago-modbustcp-simulator-py/src/wago_sim/pdu.py
T
2026-10-03 09:46:11 +02:00

392 lines
13 KiB
Python

"""Modbus PDU (Protocol Data Unit) codec.
Encodes/decodes request and response PDUs for the function codes
supported by the WAGO 750-362 coupler:
FC1 Read Coils (max 2000 bits)
FC2 Read Discrete Inputs (max 2000 bits)
FC3 Read Holding Registers (max 125 words)
FC4 Read Input Registers (max 125 words)
FC5 Write Single Coil
FC6 Write Single Register
FC11 Get Comm Event Count
FC15 Write Multiple Coils (max 800 bits)
FC16 Write Multiple Registers (max 100 words)
FC22 Mask Write Register
FC23 Read/Write Multiple Registers (125 read / 100 write)
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Function codes supported by the WAGO 750-362 (manual §11.2.2)
FC_READ_COILS = 0x01
FC_READ_DISCRETE_INPUTS = 0x02
FC_READ_HOLDING_REGISTERS = 0x03
FC_READ_INPUT_REGISTERS = 0x04
FC_WRITE_SINGLE_COIL = 0x05
FC_WRITE_SINGLE_REGISTER = 0x06
FC_GET_COMM_EVENT_COUNT = 0x0B
FC_WRITE_MULTIPLE_COILS = 0x0F
FC_WRITE_MULTIPLE_REGISTERS = 0x10
FC_MASK_WRITE_REGISTER = 0x16
FC_READ_WRITE_MULTIPLE_REGISTERS = 0x17
SUPPORTED_FUNCTION_CODES = frozenset(
{
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_GET_COMM_EVENT_COUNT,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_MASK_WRITE_REGISTER,
FC_READ_WRITE_MULTIPLE_REGISTERS,
}
)
# Access limits per 750-362 (statistics counters TooManyRegisters/TooManyBits)
MAX_READ_BITS = 2000
MAX_WRITE_BITS = 800
MAX_READ_REGISTERS = 125
MAX_WRITE_REGISTERS = 100
# Exception codes (manual §11.2.3, Table 83)
EXC_ILLEGAL_FUNCTION = 0x01
EXC_ILLEGAL_DATA_ADDRESS = 0x02
EXC_ILLEGAL_DATA_VALUE = 0x03
EXC_SLAVE_DEVICE_FAILURE = 0x04
EXC_ACKNOWLEDGE = 0x05
EXC_SERVER_BUSY = 0x06
COIL_ON = 0xFF00
COIL_OFF = 0x0000
class PduError(ValueError):
"""Raised for structurally invalid PDUs."""
@dataclass(slots=True, frozen=True)
class ReadRequest:
"""FC1/2/3/4 common request shape."""
function_code: int
address: int
count: int
@dataclass(slots=True, frozen=True)
class WriteSingleCoilRequest:
function_code: int = FC_WRITE_SINGLE_COIL
address: int = 0
value: bool = False
@dataclass(slots=True, frozen=True)
class WriteSingleRegisterRequest:
function_code: int = FC_WRITE_SINGLE_REGISTER
address: int = 0
value: int = 0
@dataclass(slots=True, frozen=True)
class WriteMultipleCoilsRequest:
function_code: int = FC_WRITE_MULTIPLE_COILS
address: int = 0
values: tuple[bool, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class WriteMultipleRegistersRequest:
function_code: int = FC_WRITE_MULTIPLE_REGISTERS
address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class MaskWriteRegisterRequest:
function_code: int = FC_MASK_WRITE_REGISTER
address: int = 0
and_mask: int = 0xFFFF
or_mask: int = 0x0000
@dataclass(slots=True, frozen=True)
class ReadWriteMultipleRegistersRequest:
function_code: int = FC_READ_WRITE_MULTIPLE_REGISTERS
read_address: int = 0
read_count: int = 0
write_address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class GetCommEventCountRequest:
function_code: int = FC_GET_COMM_EVENT_COUNT
Request = (
ReadRequest
| WriteSingleCoilRequest
| WriteSingleRegisterRequest
| WriteMultipleCoilsRequest
| WriteMultipleRegistersRequest
| MaskWriteRegisterRequest
| ReadWriteMultipleRegistersRequest
| GetCommEventCountRequest
)
class ReadResponse:
"""Response for FC1/2 (bits) or FC3/4 (words)."""
__slots__ = ("function_code", "bits", "registers")
def __init__(self, function_code: int, bits: list[bool] | None = None,
registers: list[int] | None = None) -> None:
self.function_code = function_code
self.bits = bits if bits is not None else []
self.registers = registers if registers is not None else []
def encode(self) -> bytes:
if self.function_code in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS):
payload = self._pack_bits(self.bits)
return bytes([self.function_code, len(payload)]) + payload
data = b"".join(v.to_bytes(2, "big") for v in self.registers)
byte_count = len(data)
if byte_count != 2 * len(self.registers):
raise PduError("register count mismatch")
return bytes([self.function_code, byte_count]) + data
@staticmethod
def _pack_bits(bits: list[bool]) -> bytes:
out = bytearray((len(bits) + 7) // 8)
for i, bit in enumerate(bits):
if bit:
out[i // 8] |= 1 << (i % 8)
return bytes(out)
class WriteSingleCoilResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: bool) -> None:
self.function_code = FC_WRITE_SINGLE_COIL
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ (COIL_ON if self.value else COIL_OFF).to_bytes(2, "big")
)
class WriteSingleRegisterResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: int) -> None:
self.function_code = FC_WRITE_SINGLE_REGISTER
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.value.to_bytes(2, "big")
)
class WriteMultipleResponse:
__slots__ = ("function_code", "address", "count")
def __init__(self, function_code: int, address: int, count: int) -> None:
self.function_code = function_code
self.address = address
self.count = count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.count.to_bytes(2, "big")
)
class MaskWriteRegisterResponse:
__slots__ = ("function_code", "address", "and_mask", "or_mask")
def __init__(self, address: int, and_mask: int, or_mask: int) -> None:
self.function_code = FC_MASK_WRITE_REGISTER
self.address = address
self.and_mask = and_mask
self.or_mask = or_mask
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.and_mask.to_bytes(2, "big")
+ self.or_mask.to_bytes(2, "big")
)
class GetCommEventCountResponse:
__slots__ = ("function_code", "status", "event_count")
def __init__(self, status: int = 0, event_count: int = 0) -> None:
self.function_code = FC_GET_COMM_EVENT_COUNT
self.status = status
self.event_count = event_count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.status.to_bytes(2, "big")
+ self.event_count.to_bytes(2, "big")
)
class ExceptionResponse:
__slots__ = ("function_code", "exception_code")
def __init__(self, function_code: int, exception_code: int) -> None:
self.function_code = function_code
self.exception_code = exception_code
def encode(self) -> bytes:
return bytes([self.function_code | 0x80, self.exception_code])
Response = (
ReadResponse
| WriteSingleCoilResponse
| WriteSingleRegisterResponse
| WriteMultipleResponse
| MaskWriteRegisterResponse
| GetCommEventCountResponse
| ExceptionResponse
)
def decode_request(pdu: bytes) -> Request:
"""Decode a request PDU.
Raises :class:`PduError` for structurally invalid PDUs
(the caller then answers with exception 0x03).
Unsupported function codes raise :class:`UnsupportedFunction`,
a subclass, so the server can reply 0x01 while counting BadFunction.
"""
if not pdu:
raise PduError("empty PDU")
fc = pdu[0]
if fc not in SUPPORTED_FUNCTION_CODES:
raise UnsupportedFunction(fc)
body = pdu[1:]
if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS, FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS):
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
max_count = (
MAX_READ_BITS if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS)
else MAX_READ_REGISTERS
)
if count < 1 or count > max_count:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
return ReadRequest(fc, address, count)
if fc == FC_WRITE_SINGLE_COIL:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
raw = int.from_bytes(body[2:4], "big")
if raw not in (COIL_ON, COIL_OFF):
raise IllegalDataValue(f"FC{fc}: invalid coil value: {raw:#06x}")
return WriteSingleCoilRequest(fc, address, raw == COIL_ON)
if fc == FC_WRITE_SINGLE_REGISTER:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
value = int.from_bytes(body[2:4], "big")
return WriteSingleRegisterRequest(fc, address, value)
if fc == FC_GET_COMM_EVENT_COUNT:
if body:
raise PduError(f"FC{fc}: no data bytes expected, got {len(body)}")
return GetCommEventCountRequest(fc)
if fc == FC_WRITE_MULTIPLE_COILS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_BITS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != (count + 7) // 8 or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = [
bool(body[5 + i // 8] & (1 << (i % 8))) for i in range(count)
]
return WriteMultipleCoilsRequest(fc, address, tuple(values))
if fc == FC_WRITE_MULTIPLE_REGISTERS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != 2 * count or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[5 + 2 * i:7 + 2 * i], "big") for i in range(count)
)
return WriteMultipleRegistersRequest(fc, address, values)
if fc == FC_MASK_WRITE_REGISTER:
if len(body) != 6:
raise PduError(f"FC{fc}: expected 6 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
and_mask = int.from_bytes(body[2:4], "big")
or_mask = int.from_bytes(body[4:6], "big")
return MaskWriteRegisterRequest(fc, address, and_mask, or_mask)
if fc == FC_READ_WRITE_MULTIPLE_REGISTERS:
if len(body) < 10:
raise PduError(f"FC{fc}: too short: {len(body)}")
read_address = int.from_bytes(body[0:2], "big")
read_count = int.from_bytes(body[2:4], "big")
write_address = int.from_bytes(body[4:6], "big")
write_count = int.from_bytes(body[6:8], "big")
byte_count = body[8] # 1-byte field per Modbus spec
if read_count < 1 or read_count > MAX_READ_REGISTERS:
raise IllegalDataValue(f"FC{fc}: read count out of range: {read_count}")
if write_count < 1 or write_count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: write count out of range: {write_count}")
if byte_count != 2 * write_count or len(body) != 9 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[9 + 2 * i:11 + 2 * i], "big")
for i in range(write_count)
)
return ReadWriteMultipleRegistersRequest(
fc, read_address, read_count, write_address, values
)
raise UnsupportedFunction(fc) # pragma: no cover
class UnsupportedFunction(PduError):
"""Function code not implemented by the 750-362."""
def __init__(self, function_code: int) -> None:
self.function_code = function_code
super().__init__(f"unsupported function code: {function_code:#04x}")
class IllegalDataValue(PduError):
"""Field value outside allowed range (exception 0x03 path)."""