392 lines
13 KiB
Python
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).""" |