moat-lib-modbus 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moat/lib/modbus/__init__.py +68 -0
- moat/lib/modbus/_crc.py +36 -0
- moat/lib/modbus/errors.py +49 -0
- moat/lib/modbus/framer.py +385 -0
- moat/lib/modbus/pdu.py +814 -0
- moat_lib_modbus-0.0.2.dist-info/METADATA +65 -0
- moat_lib_modbus-0.0.2.dist-info/RECORD +10 -0
- moat_lib_modbus-0.0.2.dist-info/WHEEL +5 -0
- moat_lib_modbus-0.0.2.dist-info/licenses/LICENSE.txt +14 -0
- moat_lib_modbus-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sans-IO Modbus protocol core.
|
|
3
|
+
|
|
4
|
+
This library implements the minimal Modbus protocol subset used by
|
|
5
|
+
``moat.modbus`` -- PDU encoding/decoding for function codes 1, 2, 3,
|
|
6
|
+
4, 5, 6, 15, 16, plus TCP (MBAP) and RTU framers with a role flag.
|
|
7
|
+
|
|
8
|
+
It is strictly **sans-IO**: no sockets, no serial ports, no sleeping,
|
|
9
|
+
no waiting, no task spawning. Bytes in -> PDUs out, and vice-versa.
|
|
10
|
+
The only mutable state permitted is a framer's internal byte
|
|
11
|
+
accumulator.
|
|
12
|
+
|
|
13
|
+
Stdlib only -- no ``pymodbus``, no ``anyio``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._crc import crc16
|
|
19
|
+
from .errors import ExcCodes, ModbusIOError
|
|
20
|
+
from .framer import FramerRTU, FramerTCP
|
|
21
|
+
from .pdu import (
|
|
22
|
+
PDU,
|
|
23
|
+
ExceptionResponse,
|
|
24
|
+
ReadCoilsRequest,
|
|
25
|
+
ReadCoilsResponse,
|
|
26
|
+
ReadDiscreteInputsRequest,
|
|
27
|
+
ReadDiscreteInputsResponse,
|
|
28
|
+
ReadHoldingRegistersRequest,
|
|
29
|
+
ReadHoldingRegistersResponse,
|
|
30
|
+
ReadInputRegistersRequest,
|
|
31
|
+
ReadInputRegistersResponse,
|
|
32
|
+
WriteMultipleCoilsRequest,
|
|
33
|
+
WriteMultipleCoilsResponse,
|
|
34
|
+
WriteMultipleRegistersRequest,
|
|
35
|
+
WriteMultipleRegistersResponse,
|
|
36
|
+
WriteSingleCoilRequest,
|
|
37
|
+
WriteSingleCoilResponse,
|
|
38
|
+
WriteSingleRegisterRequest,
|
|
39
|
+
WriteSingleRegisterResponse,
|
|
40
|
+
decode_pdu,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"PDU",
|
|
45
|
+
"ExcCodes",
|
|
46
|
+
"ExceptionResponse",
|
|
47
|
+
"FramerRTU",
|
|
48
|
+
"FramerTCP",
|
|
49
|
+
"ModbusIOError",
|
|
50
|
+
"ReadCoilsRequest",
|
|
51
|
+
"ReadCoilsResponse",
|
|
52
|
+
"ReadDiscreteInputsRequest",
|
|
53
|
+
"ReadDiscreteInputsResponse",
|
|
54
|
+
"ReadHoldingRegistersRequest",
|
|
55
|
+
"ReadHoldingRegistersResponse",
|
|
56
|
+
"ReadInputRegistersRequest",
|
|
57
|
+
"ReadInputRegistersResponse",
|
|
58
|
+
"WriteMultipleCoilsRequest",
|
|
59
|
+
"WriteMultipleCoilsResponse",
|
|
60
|
+
"WriteMultipleRegistersRequest",
|
|
61
|
+
"WriteMultipleRegistersResponse",
|
|
62
|
+
"WriteSingleCoilRequest",
|
|
63
|
+
"WriteSingleCoilResponse",
|
|
64
|
+
"WriteSingleRegisterRequest",
|
|
65
|
+
"WriteSingleRegisterResponse",
|
|
66
|
+
"crc16",
|
|
67
|
+
"decode_pdu",
|
|
68
|
+
]
|
moat/lib/modbus/_crc.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CRC-16/Modbus checksum.
|
|
3
|
+
|
|
4
|
+
Implements :func:`crc16` per the Modbus specification: polynomial
|
|
5
|
+
``0xA001`` (bit-reversed ``0x8005``), init ``0xFFFF``, no final XOR,
|
|
6
|
+
LSB-first processing, byte-wise loop (no lookup table).
|
|
7
|
+
|
|
8
|
+
The CRC is appended to RTU frames as two bytes, low byte first.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def crc16(data: bytes) -> int:
|
|
15
|
+
"""Compute the CRC-16/Modbus checksum of *data*.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
data: The bytes to checksum.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The 16-bit CRC value (0--65535).
|
|
22
|
+
|
|
23
|
+
The algorithm uses polynomial ``0xA001`` (reverse of ``0x8005``),
|
|
24
|
+
initial value ``0xFFFF``, and no final XOR. Bytes are processed
|
|
25
|
+
LSB-first. The result is emitted low byte first when appended to
|
|
26
|
+
a frame.
|
|
27
|
+
"""
|
|
28
|
+
crc = 0xFFFF
|
|
29
|
+
for byte in data:
|
|
30
|
+
crc ^= byte
|
|
31
|
+
for _ in range(8):
|
|
32
|
+
if crc & 1:
|
|
33
|
+
crc = (crc >> 1) ^ 0xA001
|
|
34
|
+
else:
|
|
35
|
+
crc >>= 1
|
|
36
|
+
return crc & 0xFFFF
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modbus exception codes and I/O error.
|
|
3
|
+
|
|
4
|
+
Defines :class:`ExcCodes` as an :class:`enum.IntEnum` covering the
|
|
5
|
+
standard Modbus exception codes, plus two source-compatibility
|
|
6
|
+
aliases used by the legacy ``moat.modbus`` code, and
|
|
7
|
+
:class:`ModbusIOError` replacing pymodbus'
|
|
8
|
+
``pymodbus.exceptions.ModbusIOException``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExcCodes(enum.IntEnum):
|
|
17
|
+
"""Standard Modbus exception codes.
|
|
18
|
+
|
|
19
|
+
The aliases :attr:`DEVICE_FAILURE` and :attr:`GATEWAY_NO_RESPONSE`
|
|
20
|
+
are kept for source compatibility with the old ``moat.modbus`` code
|
|
21
|
+
that referenced pymodbus' ``ExcCodes.DEVICE_FAILURE`` /
|
|
22
|
+
``ExcCodes.GATEWAY_NO_RESPONSE``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
ILLEGAL_FUNCTION = 1
|
|
26
|
+
ILLEGAL_DATA_ADDRESS = 2
|
|
27
|
+
ILLEGAL_DATA_VALUE = 3
|
|
28
|
+
SERVER_DEVICE_FAILURE = 4
|
|
29
|
+
ACKNOWLEDGE = 5
|
|
30
|
+
SERVER_DEVICE_BUSY = 6
|
|
31
|
+
NEGATIVE_ACKNOWLEDGE = 7
|
|
32
|
+
MEMORY_PARITY_ERROR = 8
|
|
33
|
+
GATEWAY_PATH_UNAVAILABLE = 10
|
|
34
|
+
GATEWAY_TARGET_NO_RESPONSE = 11
|
|
35
|
+
|
|
36
|
+
# Source-compatibility aliases.
|
|
37
|
+
DEVICE_FAILURE = 4
|
|
38
|
+
"""Alias for :attr:`~ExcCodes.SERVER_DEVICE_FAILURE`."""
|
|
39
|
+
|
|
40
|
+
GATEWAY_NO_RESPONSE = 11
|
|
41
|
+
"""Alias for :attr:`~ExcCodes.GATEWAY_TARGET_NO_RESPONSE`."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ModbusIOError(Exception):
|
|
45
|
+
"""Generic Modbus I/O error.
|
|
46
|
+
|
|
47
|
+
Replaces pymodbus' ``ModbusIOException``. Raised when a frame
|
|
48
|
+
cannot be decoded or a protocol violation is detected.
|
|
49
|
+
"""
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modbus TCP (MBAP) and RTU framers.
|
|
3
|
+
|
|
4
|
+
Provides :class:`FramerTCP` and :class:`FramerRTU`, each constructed
|
|
5
|
+
with a required *role* flag:
|
|
6
|
+
|
|
7
|
+
- ``True`` / ``"server"`` -- decode incoming bytes as **requests**.
|
|
8
|
+
- ``False`` / ``"client"`` -- decode incoming bytes as **responses**.
|
|
9
|
+
- ``"monitor"`` -- (**RTU only**) passive bus sniffer: decode alternating
|
|
10
|
+
request/response frames, starting with a request. Sending is
|
|
11
|
+
prohibited; see :class:`FramerRTU`.
|
|
12
|
+
|
|
13
|
+
Both share the contract::
|
|
14
|
+
|
|
15
|
+
buildFrame(msg) -> bytes
|
|
16
|
+
handleFrame(data, ...) -> (used: int, pdu: PDU | None)
|
|
17
|
+
resetFrame()
|
|
18
|
+
|
|
19
|
+
where ``used == 0`` signals "frame incomplete, feed more bytes".
|
|
20
|
+
|
|
21
|
+
Internally each framer holds a private byte accumulator (the only
|
|
22
|
+
mutable state permitted under the sans-IO rule) and delegates PDU
|
|
23
|
+
decoding to :func:`moat.lib.modbus.pdu.decode_pdu` with the role flag.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import struct
|
|
29
|
+
|
|
30
|
+
from ._crc import crc16
|
|
31
|
+
from .errors import ModbusIOError
|
|
32
|
+
from .pdu import PDU, decode_pdu
|
|
33
|
+
|
|
34
|
+
import typing
|
|
35
|
+
|
|
36
|
+
_ROLE_ALIASES: dict[object, bool] = {
|
|
37
|
+
True: True,
|
|
38
|
+
False: False,
|
|
39
|
+
"server": True,
|
|
40
|
+
"client": False,
|
|
41
|
+
"Server": True,
|
|
42
|
+
"Client": False,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_role(role: typing.Any) -> bool:
|
|
47
|
+
"""Resolve a role flag to a boolean (True = server)."""
|
|
48
|
+
if isinstance(role, bool):
|
|
49
|
+
return role
|
|
50
|
+
if isinstance(role, str):
|
|
51
|
+
r = _ROLE_ALIASES.get(role)
|
|
52
|
+
if r is not None:
|
|
53
|
+
return r
|
|
54
|
+
raise ValueError(f"Invalid role: {role!r}; use True/False or 'server'/'client'")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# MBAP header: 7 bytes
|
|
58
|
+
# transaction_id (2) + protocol_id (2) + length (2) + unit_id (1)
|
|
59
|
+
_MBAP_HEADER_SIZE = 7
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _FramerBase:
|
|
63
|
+
"""Common base for Modbus framers.
|
|
64
|
+
|
|
65
|
+
Owns the receive byte accumulator -- the only mutable state permitted
|
|
66
|
+
under the sans-IO rule -- and exposes the parts of the framer contract
|
|
67
|
+
that are independent of the wire format: :attr:`pending` and
|
|
68
|
+
:meth:`resetFrame`. Subclasses provide :meth:`buildFrame` and
|
|
69
|
+
:meth:`handleFrame`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_buffer: bytearray
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._buffer = bytearray()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def pending(self) -> int:
|
|
79
|
+
"""Number of buffered bytes awaiting more data to complete a frame.
|
|
80
|
+
|
|
81
|
+
Non-zero means a partial frame is in progress; the caller may use
|
|
82
|
+
this to decide whether to arm an inter-frame (idle-gap) timeout.
|
|
83
|
+
"""
|
|
84
|
+
return len(self._buffer)
|
|
85
|
+
|
|
86
|
+
def resetFrame(self) -> None:
|
|
87
|
+
"""Clear the internal byte accumulator."""
|
|
88
|
+
self._buffer = bytearray()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FramerTCP(_FramerBase):
|
|
92
|
+
"""Modbus TCP framer (MBAP).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
role: ``True`` / ``"server"`` to decode incoming bytes as
|
|
96
|
+
requests; ``False`` / ``"client"`` to decode as responses.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, role: typing.Any) -> None:
|
|
100
|
+
if isinstance(role, str) and role.lower() == "monitor":
|
|
101
|
+
raise ValueError("Monitor mode is only supported for RTU (FramerRTU)")
|
|
102
|
+
self.role_is_server: bool = _resolve_role(role)
|
|
103
|
+
self.is_monitor: bool = False
|
|
104
|
+
super().__init__()
|
|
105
|
+
|
|
106
|
+
def buildFrame(self, msg: PDU) -> bytes:
|
|
107
|
+
"""Build an MBAP-framed TCP message from *msg*."""
|
|
108
|
+
pdu = msg.encode_frame()
|
|
109
|
+
unit_id = getattr(msg, "unit_id", 0)
|
|
110
|
+
tid = getattr(msg, "transaction_id", 0)
|
|
111
|
+
length = len(pdu) + 1 # PDU + unit_id byte
|
|
112
|
+
header = struct.pack(">HHHBB", tid, 0, length, unit_id, 0)
|
|
113
|
+
# Actually MBAP is: tid(2) + proto_id(2) + length(2) + unit_id(1)
|
|
114
|
+
# length includes unit_id + PDU
|
|
115
|
+
header = struct.pack(">HHHB", tid, 0, length, unit_id)
|
|
116
|
+
return header + pdu
|
|
117
|
+
|
|
118
|
+
def handleFrame(self, data: bytes, *_args: typing.Any) -> tuple[int, PDU | None]:
|
|
119
|
+
"""Try to parse a complete frame from *data*.
|
|
120
|
+
|
|
121
|
+
Returns ``(used, pdu)`` where ``used`` is the number of bytes
|
|
122
|
+
consumed and ``pdu`` is the decoded PDU (or ``None`` if the
|
|
123
|
+
frame is incomplete). ``used == 0`` means "need more bytes".
|
|
124
|
+
"""
|
|
125
|
+
self._buffer.extend(data)
|
|
126
|
+
|
|
127
|
+
if len(self._buffer) < _MBAP_HEADER_SIZE:
|
|
128
|
+
return 0, None
|
|
129
|
+
|
|
130
|
+
tid, proto_id, length, unit_id = struct.unpack(
|
|
131
|
+
">HHHB", bytes(self._buffer[:_MBAP_HEADER_SIZE])
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if proto_id != 0:
|
|
135
|
+
raise ModbusIOError(f"Invalid MBAP protocol id: {proto_id}")
|
|
136
|
+
|
|
137
|
+
total_len = _MBAP_HEADER_SIZE + (length - 1) # length includes unit_id
|
|
138
|
+
if len(self._buffer) < total_len:
|
|
139
|
+
return 0, None
|
|
140
|
+
|
|
141
|
+
pdu_bytes = bytes(self._buffer[_MBAP_HEADER_SIZE:total_len])
|
|
142
|
+
# Consume the frame
|
|
143
|
+
self._buffer = self._buffer[total_len:]
|
|
144
|
+
|
|
145
|
+
pdu = decode_pdu(pdu_bytes, server=self.role_is_server)
|
|
146
|
+
pdu.transaction_id = tid
|
|
147
|
+
pdu.unit_id = unit_id
|
|
148
|
+
return total_len, pdu
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class FramerRTU(_FramerBase):
|
|
152
|
+
"""Modbus RTU framer (serial).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
role: ``True`` / ``"server"`` to decode incoming bytes as
|
|
156
|
+
requests; ``False`` / ``"client"`` to decode as responses;
|
|
157
|
+
``"monitor"`` for passive bus sniffing (RTU only).
|
|
158
|
+
|
|
159
|
+
**Monitor mode** (``role="monitor"``) turns the framer into a passive
|
|
160
|
+
bus sniffer. Sending is prohibited: :meth:`buildFrame` raises
|
|
161
|
+
:class:`~moat.lib.modbus.errors.ModbusIOError`. Received frames are
|
|
162
|
+
decoded as an alternating request/response sequence, starting with a
|
|
163
|
+
request: each successfully decoded frame flips the expectation, so a
|
|
164
|
+
request is followed by a response, then the next request, and so on.
|
|
165
|
+
|
|
166
|
+
A Modbus RTU bus is strict master/slave: after a request the next
|
|
167
|
+
frame is either the slave's reply or -- if the slave stays silent --
|
|
168
|
+
nothing. Because the framer is sans-IO it cannot measure the idle
|
|
169
|
+
gap itself; the *caller* must signal "no reply / stalled frame" by
|
|
170
|
+
calling :meth:`resetFrame`, which clears the accumulator and returns
|
|
171
|
+
the framer to expecting a request. In monitor mode :meth:`resetFrame`
|
|
172
|
+
therefore always resets the phase to "expecting a request", regardless
|
|
173
|
+
of which frame was pending. Do **not** call :meth:`resetFrame` after
|
|
174
|
+
a successfully decoded frame -- only on an inter-frame timeout.
|
|
175
|
+
|
|
176
|
+
The current phase is queryable via :attr:`expecting_request`.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, role: typing.Any) -> None:
|
|
180
|
+
if isinstance(role, str) and role.lower() == "monitor":
|
|
181
|
+
self.is_monitor: bool = True
|
|
182
|
+
# Initial phase: expect a request (server-like decoding).
|
|
183
|
+
self.role_is_server: bool = True
|
|
184
|
+
self._expect_request: bool = True
|
|
185
|
+
else:
|
|
186
|
+
self.is_monitor = False
|
|
187
|
+
self.role_is_server = _resolve_role(role)
|
|
188
|
+
self._expect_request = self.role_is_server
|
|
189
|
+
super().__init__()
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def expecting_request(self) -> bool:
|
|
193
|
+
"""Whether the next frame is decoded as a request.
|
|
194
|
+
|
|
195
|
+
In monitor mode this tracks the alternating request/response
|
|
196
|
+
phase; for a fixed-role framer it mirrors the constructor role
|
|
197
|
+
flag (a server expects requests, a client expects responses).
|
|
198
|
+
"""
|
|
199
|
+
return self._expect_request
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def _decoding_server(self) -> bool:
|
|
203
|
+
"""Effective role flag for the current decode (monitor-aware)."""
|
|
204
|
+
return self._expect_request if self.is_monitor else self.role_is_server
|
|
205
|
+
|
|
206
|
+
def buildFrame(self, msg: PDU) -> bytes:
|
|
207
|
+
"""Build an RTU-framed message from *msg*.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ModbusIOError: in monitor mode (sending is prohibited).
|
|
211
|
+
"""
|
|
212
|
+
if self.is_monitor:
|
|
213
|
+
raise ModbusIOError("Monitor mode: sending is prohibited")
|
|
214
|
+
pdu = msg.encode_frame()
|
|
215
|
+
unit_id = getattr(msg, "unit_id", 0)
|
|
216
|
+
frame = bytes([unit_id]) + pdu
|
|
217
|
+
crc = crc16(frame)
|
|
218
|
+
return frame + crc.to_bytes(2, "little")
|
|
219
|
+
|
|
220
|
+
def handleFrame(self, data: bytes, *_args: typing.Any) -> tuple[int, PDU | None]:
|
|
221
|
+
"""Try to parse a complete RTU frame from *data*.
|
|
222
|
+
|
|
223
|
+
RTU frames have no length prefix; a frame consists of
|
|
224
|
+
``address(1) + PDU + CRC(2)``. We attempt to decode the PDU
|
|
225
|
+
greedily: read the FC byte, determine the expected PDU size
|
|
226
|
+
from the function code and (for reads) the byte-count field,
|
|
227
|
+
validate the CRC, and return the PDU.
|
|
228
|
+
|
|
229
|
+
Returns ``(used, pdu)`` where ``used == 0`` means "incomplete".
|
|
230
|
+
If the CRC is invalid, the bad bytes are consumed and scanning
|
|
231
|
+
resumes.
|
|
232
|
+
"""
|
|
233
|
+
self._buffer.extend(data)
|
|
234
|
+
|
|
235
|
+
# Scan through the buffer trying to find a valid frame.
|
|
236
|
+
# On CRC mismatch, advance by one byte and retry.
|
|
237
|
+
offset = 0
|
|
238
|
+
while offset < len(self._buffer):
|
|
239
|
+
remaining = bytes(self._buffer[offset:])
|
|
240
|
+
|
|
241
|
+
# Need at least: address(1) + FC(1) + CRC(2) = 4 bytes
|
|
242
|
+
if len(remaining) < 4:
|
|
243
|
+
# Not enough data; keep what's left in the buffer
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
fc = remaining[1] # noqa: F841
|
|
247
|
+
|
|
248
|
+
# Estimate PDU payload size based on FC byte and role
|
|
249
|
+
pdu_size = _estimate_pdu_size(remaining, self._decoding_server)
|
|
250
|
+
if pdu_size is None:
|
|
251
|
+
# Unknown FC -- can't determine size, advance one byte
|
|
252
|
+
offset += 1
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
frame_len = 1 + pdu_size + 2 # addr + PDU + CRC
|
|
256
|
+
|
|
257
|
+
# Cap frame_len to a reasonable maximum to prevent
|
|
258
|
+
# a corrupted byte_count from stalling the scanner.
|
|
259
|
+
if frame_len > 256:
|
|
260
|
+
# Unreasonable frame size: likely garbage, advance
|
|
261
|
+
offset += 1
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
if len(remaining) < frame_len:
|
|
265
|
+
# Not enough data yet for this frame; wait for more
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
frame = remaining[:frame_len]
|
|
269
|
+
payload = frame[:-2]
|
|
270
|
+
crc_recv = int.from_bytes(frame[-2:], "little")
|
|
271
|
+
crc_calc = crc16(payload)
|
|
272
|
+
|
|
273
|
+
if crc_recv != crc_calc:
|
|
274
|
+
# CRC mismatch: advance one byte and retry
|
|
275
|
+
offset += 1
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# CRC valid: decode the PDU
|
|
279
|
+
unit_id = frame[0]
|
|
280
|
+
pdu_bytes = frame[1:-2]
|
|
281
|
+
|
|
282
|
+
# Consume everything up to and including this frame
|
|
283
|
+
consumed = offset + frame_len
|
|
284
|
+
self._buffer = self._buffer[consumed:]
|
|
285
|
+
|
|
286
|
+
pdu = decode_pdu(pdu_bytes, server=self._decoding_server)
|
|
287
|
+
pdu.unit_id = unit_id
|
|
288
|
+
pdu.transaction_id = 0 # RTU has no transaction ID
|
|
289
|
+
if self.is_monitor:
|
|
290
|
+
# Alternate the phase: a decoded frame is followed by its
|
|
291
|
+
# counterpart (request -> response -> request ...).
|
|
292
|
+
self._expect_request = not self._expect_request
|
|
293
|
+
return consumed, pdu
|
|
294
|
+
|
|
295
|
+
# If we scanned through some bad bytes, consume them
|
|
296
|
+
if offset > 0:
|
|
297
|
+
self._buffer = self._buffer[offset:]
|
|
298
|
+
return offset, None
|
|
299
|
+
|
|
300
|
+
return 0, None
|
|
301
|
+
|
|
302
|
+
def resetFrame(self) -> None:
|
|
303
|
+
"""Clear the internal byte accumulator.
|
|
304
|
+
|
|
305
|
+
In monitor mode this also returns the framer to expecting a
|
|
306
|
+
request: a timeout means the pending frame (request or reply)
|
|
307
|
+
did not arrive, so the next frame must be a new request.
|
|
308
|
+
"""
|
|
309
|
+
super().resetFrame()
|
|
310
|
+
if self.is_monitor:
|
|
311
|
+
self._expect_request = True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _estimate_pdu_size(buf: bytes, server: bool) -> int | None:
|
|
315
|
+
"""Estimate the PDU payload size (excluding FC byte) from the buffer.
|
|
316
|
+
|
|
317
|
+
Returns the number of PDU bytes (including the FC byte), or None
|
|
318
|
+
if the buffer is too short to determine the size.
|
|
319
|
+
|
|
320
|
+
The buffer must contain at least: address(1) + FC(1) + ...
|
|
321
|
+
"""
|
|
322
|
+
if len(buf) < 2:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
fc = buf[1]
|
|
326
|
+
|
|
327
|
+
# Exception response: FC | 0x80 + exception_code(1)
|
|
328
|
+
if fc & 0x80:
|
|
329
|
+
return 2 # FC + exception_code
|
|
330
|
+
|
|
331
|
+
# For requests (server=True) and responses (server=False),
|
|
332
|
+
# the PDU sizes differ.
|
|
333
|
+
if server:
|
|
334
|
+
# Requests
|
|
335
|
+
if fc in (1, 2, 3, 4):
|
|
336
|
+
# Read requests: FC(1) + addr(2) + count(2) = 5
|
|
337
|
+
return 5
|
|
338
|
+
elif fc == 5:
|
|
339
|
+
# Write single coil: FC(1) + addr(2) + value(2) = 5
|
|
340
|
+
return 5
|
|
341
|
+
elif fc == 6:
|
|
342
|
+
# Write single register: FC(1) + addr(2) + value(2) = 5
|
|
343
|
+
return 5
|
|
344
|
+
elif fc == 15:
|
|
345
|
+
# Write multiple coils: FC(1) + addr(2) + count(2) + bc(1) + data(bc)
|
|
346
|
+
if len(buf) < 7:
|
|
347
|
+
return None
|
|
348
|
+
byte_count = buf[6]
|
|
349
|
+
return 6 + byte_count
|
|
350
|
+
elif fc == 16:
|
|
351
|
+
# Write multiple registers: FC(1) + addr(2) + count(2) + bc(1) + data(bc)
|
|
352
|
+
if len(buf) < 7:
|
|
353
|
+
return None
|
|
354
|
+
byte_count = buf[6]
|
|
355
|
+
return 6 + byte_count
|
|
356
|
+
else:
|
|
357
|
+
return None
|
|
358
|
+
else:
|
|
359
|
+
# Responses
|
|
360
|
+
if fc in (1, 2):
|
|
361
|
+
# Read coils/discrete response: FC(1) + byte_count(1) + data(bc)
|
|
362
|
+
if len(buf) < 3:
|
|
363
|
+
return None
|
|
364
|
+
byte_count = buf[2]
|
|
365
|
+
return 2 + byte_count
|
|
366
|
+
elif fc in (3, 4):
|
|
367
|
+
# Read registers response: FC(1) + byte_count(1) + data(bc)
|
|
368
|
+
if len(buf) < 3:
|
|
369
|
+
return None
|
|
370
|
+
byte_count = buf[2]
|
|
371
|
+
return 2 + byte_count
|
|
372
|
+
elif fc == 5:
|
|
373
|
+
# Write single coil response: FC(1) + addr(2) + value(2) = 5
|
|
374
|
+
return 5
|
|
375
|
+
elif fc == 6:
|
|
376
|
+
# Write single register response: FC(1) + addr(2) + value(2) = 5
|
|
377
|
+
return 5
|
|
378
|
+
elif fc == 15:
|
|
379
|
+
# Write multiple coils response: FC(1) + addr(2) + count(2) = 5
|
|
380
|
+
return 5
|
|
381
|
+
elif fc == 16:
|
|
382
|
+
# Write multiple registers response: FC(1) + addr(2) + count(2) = 5
|
|
383
|
+
return 5
|
|
384
|
+
else:
|
|
385
|
+
return None
|