🎉 Initial commit
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
"""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)."""
|
||||
Reference in New Issue
Block a user