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.
@@ -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
+ ]
@@ -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