ser2netweb 3.0.0__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.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,530 @@
1
+ """Modbus framing helpers for the RTU<->TCP gateway (pure stdlib, no deps).
2
+
3
+ A Modbus *gateway* lets Modbus/TCP masters on the network talk to Modbus/RTU
4
+ slaves on a serial bus. The two encodings share the same PDU (function code +
5
+ data); they differ only in the envelope:
6
+
7
+ RTU ADU: [unit][PDU][CRC16-lo][CRC16-hi]
8
+ TCP ADU: [txn-hi][txn-lo][0][0][len-hi][len-lo][unit][PDU] (MBAP header)
9
+
10
+ So gatewaying is: strip one envelope, apply the other, preserving unit id and
11
+ (for TCP) the transaction id. These helpers are deliberately side-effect free so
12
+ they can be unit-tested without any serial hardware.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import struct
18
+ from typing import NamedTuple, Optional
19
+
20
+ MBAP_LEN = 7 # txn(2) + proto(2) + length(2) + unit(1)
21
+ MAX_PDU = 253 # Modbus PDU max (256-byte ADU minus addr+CRC)
22
+ MAX_ADU = MAX_PDU + 3 # an RTU frame at most: unit + PDU + CRC
23
+
24
+ READ_HOLDING = 3 # read holding registers
25
+ READ_INPUT = 4 # read input registers
26
+
27
+
28
+ def crc16(data: bytes) -> int:
29
+ """Modbus RTU CRC-16 (polynomial 0xA001, init 0xFFFF), returned as an int."""
30
+ crc = 0xFFFF
31
+ for byte in data:
32
+ crc ^= byte
33
+ for _ in range(8):
34
+ if crc & 1:
35
+ crc = (crc >> 1) ^ 0xA001
36
+ else:
37
+ crc >>= 1
38
+ return crc & 0xFFFF
39
+
40
+
41
+ def rtu_wrap(unit: int, pdu: bytes) -> bytes:
42
+ """Build an RTU ADU: unit + PDU + CRC16 (little-endian, as Modbus requires)."""
43
+ body = bytes([unit & 0xFF]) + pdu
44
+ crc = crc16(body)
45
+ return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF])
46
+
47
+
48
+ def rtu_unwrap(frame: bytes) -> tuple[int, bytes]:
49
+ """Validate an RTU ADU and return (unit, PDU). Raises ValueError on a short
50
+ frame or a CRC mismatch."""
51
+ if len(frame) < 4: # unit + at least 1 PDU byte + 2 CRC
52
+ raise ValueError("RTU frame too short")
53
+ body, crc_lo, crc_hi = frame[:-2], frame[-2], frame[-1]
54
+ if crc16(body) != (crc_lo | (crc_hi << 8)):
55
+ raise ValueError("RTU CRC mismatch")
56
+ return body[0], body[1:]
57
+
58
+
59
+ # The length of a slave's reply (Modbus Application Protocol v1.1b3), counted as an RTU
60
+ # frame (unit + PDU + CRC): fixed for these function codes ...
61
+ _FIXED_REPLY = {5: 8, 6: 8, 7: 5, 11: 8, 15: 8, 16: 8, 22: 10}
62
+ # ... and 5 + the byte count that follows the function code for these (FC1-4 reads,
63
+ # 12 comm event log, 17 report server id, 20/21 file records, 23 read/write registers)
64
+ _COUNTED_REPLY = frozenset({1, 2, 3, 4, 12, 17, 20, 21, 23})
65
+ _READ_FIFO_QUEUE = 24 # 6 + a two-byte byte count
66
+
67
+
68
+ def rtu_reply_length(head: bytes | bytearray) -> Optional[int]:
69
+ """The length of the RTU reply that starts with ``head`` (its unit id first), from the
70
+ function code and byte count at its start, as libmodbus frames replies: the whole
71
+ frame's length once ``head`` holds them, else the least it can be (read that many
72
+ bytes, then ask again). None when the function code does not tell (FC8 diagnostics,
73
+ FC43, user-defined and unknown codes): such a reply ends where the line goes quiet."""
74
+ if len(head) < 2:
75
+ return 4 # unit + function + CRC: no frame is shorter
76
+ function = head[1]
77
+ if function & 0x80: # an exception: unit, function | 0x80, exception code, CRC
78
+ return 5
79
+ if function in _FIXED_REPLY:
80
+ return _FIXED_REPLY[function]
81
+ if function in _COUNTED_REPLY:
82
+ return 5 + head[2] if len(head) > 2 else 5
83
+ if function == _READ_FIFO_QUEUE:
84
+ return 6 + int.from_bytes(head[2:4], "big") if len(head) > 3 else 6
85
+ return None
86
+
87
+
88
+ # write requests whose reply is their echo: the whole request, or its first 5 bytes
89
+ # (function, start address, quantity)
90
+ _ECHO_REPLY = frozenset({5, 6, 22})
91
+ _ECHO_HEAD_REPLY = frozenset({15, 16})
92
+
93
+
94
+ def reply_mismatch(request: bytes, reply: bytes) -> str:
95
+ """Why the PDU ``reply`` is not the answer to the PDU ``request``, or "" when it can be.
96
+ It has to be an exception for the request's function, or carry that function and: for
97
+ a read of bits or registers (FC1-4, FC23) the byte count the quantity read gives; for
98
+ a write, its echo (FC5, FC6, FC22: the whole request; FC15, FC16: its first 5 bytes).
99
+ Other functions are matched by function code only.
100
+
101
+ A read's reply carries no address, so the reply to an earlier read of the same unit,
102
+ function and quantity passes: only waiting for the line to go quiet after a request
103
+ that got no reply in time keeps such a late reply apart (the gateway does)."""
104
+ function = request[0] if request else 0
105
+ if not reply:
106
+ return "an empty reply"
107
+ if reply[0] == (function | 0x80) & 0xFF:
108
+ return ""
109
+ if reply[0] != function:
110
+ return f"an FC{reply[0]} reply to FC{function}"
111
+ if function in (1, 2, 3, 4, 23) and len(request) >= 5:
112
+ quantity = int.from_bytes(request[3:5], "big")
113
+ want = (quantity + 7) // 8 if function in (1, 2) else 2 * quantity
114
+ got = reply[1] if len(reply) > 1 else 0
115
+ if got != want:
116
+ unit = "bit" if function in (1, 2) else "register"
117
+ return f"{got} data bytes for {quantity} {unit}(s) of FC{function}"
118
+ elif function in _ECHO_REPLY or function in _ECHO_HEAD_REPLY:
119
+ echo = bytes(request) if function in _ECHO_REPLY else bytes(request[:5])
120
+ if bytes(reply) != echo:
121
+ return f"an FC{function} reply that is not the request's echo ({bytes(reply).hex(' ')})"
122
+ return ""
123
+
124
+
125
+ class ReplyScan(NamedTuple):
126
+ span: Optional[tuple[int, int]] # buf[start:end] is the reply (None: not found)
127
+ pending: bool # a frame from the unit has begun and may still be completed
128
+ started: bool # a frame from the unit began in buf at all
129
+ rejected: str = "" # why the last whole frame from the unit was not the reply
130
+
131
+
132
+ def find_reply(buf: bytes | bytearray, unit: int, request: bytes, *,
133
+ quiet: bool = False) -> ReplyScan:
134
+ """Look for the reply to ``request`` (a PDU sent to ``unit``) in ``buf``, the bytes read
135
+ off the line since it was sent. They may begin with bytes no request is waiting for
136
+ (the late tail of an earlier reply, noise), which are skipped.
137
+
138
+ A reply starts with ``unit`` and the request's function code (or its exception code),
139
+ is as long as its header says (rtu_reply_length), passes its CRC and answers the
140
+ request (reply_mismatch); a whole frame that answers something else is skipped too.
141
+ ``quiet``: the line has gone quiet since buf's last byte, so a frame whose length its
142
+ header does not give, or that fails its CRC at that length (a slave that miscounts),
143
+ may end with buf: it is then tried as a whole too."""
144
+ function = request[0] if request else 0
145
+ starts = (function, (function | 0x80) & 0xFF)
146
+ pending = started = False
147
+ rejected = ""
148
+ for start in range(len(buf) - 1):
149
+ if buf[start] != unit or buf[start + 1] not in starts:
150
+ continue
151
+ started = True
152
+ length = rtu_reply_length(buf[start:start + 4])
153
+ ends = []
154
+ if length is None:
155
+ pending = True # it ends where the line goes quiet
156
+ elif length <= MAX_ADU:
157
+ if start + length <= len(buf):
158
+ ends.append(start + length)
159
+ else:
160
+ pending = True
161
+ if quiet and len(buf) - start <= MAX_ADU and len(buf) not in ends:
162
+ ends.append(len(buf))
163
+ for end in ends:
164
+ try:
165
+ _unit, pdu = rtu_unwrap(bytes(buf[start:end]))
166
+ except ValueError:
167
+ continue
168
+ why = reply_mismatch(request, pdu)
169
+ if not why:
170
+ return ReplyScan((start, end), pending, started, rejected)
171
+ rejected = why
172
+ return ReplyScan(None, pending, started, rejected)
173
+
174
+
175
+ def build_tcp_adu(txn: int, unit: int, pdu: bytes) -> bytes:
176
+ """Build a Modbus/TCP ADU (MBAP header + PDU)."""
177
+ length = len(pdu) + 1 # unit id + PDU
178
+ return (bytes([(txn >> 8) & 0xFF, txn & 0xFF, 0, 0,
179
+ (length >> 8) & 0xFF, length & 0xFF, unit & 0xFF]) + pdu)
180
+
181
+
182
+ def take_tcp_adu(buf: bytearray) -> tuple[int, int, bytes] | None:
183
+ """Pop one complete Modbus/TCP ADU from a streaming buffer.
184
+
185
+ Returns (txn, unit, pdu) and removes the consumed bytes from ``buf``; returns
186
+ None if a full frame isn't buffered yet. Raises ValueError on a framing error
187
+ (non-zero protocol id, or a length that leaves no function code or goes past the
188
+ largest PDU) so the caller can drop the peer.
189
+ """
190
+ if len(buf) < MBAP_LEN:
191
+ return None
192
+ txn = (buf[0] << 8) | buf[1]
193
+ proto = (buf[2] << 8) | buf[3]
194
+ length = (buf[4] << 8) | buf[5]
195
+ unit = buf[6]
196
+ if proto != 0:
197
+ raise ValueError("not Modbus/TCP (protocol id != 0)")
198
+ if not (2 <= length <= MAX_PDU + 1): # unit id + at least a function code
199
+ raise ValueError(f"invalid MBAP length {length}")
200
+ total = MBAP_LEN + (length - 1) # length counts unit id, already in the header
201
+ if len(buf) < total:
202
+ return None
203
+ pdu = bytes(buf[MBAP_LEN:total])
204
+ del buf[:total]
205
+ return txn, unit, pdu
206
+
207
+
208
+ def exception_pdu(function: int, code: int) -> bytes:
209
+ """A Modbus exception PDU: function|0x80 followed by the exception code.
210
+ Code 0x0B = gateway target device failed to respond (RTU timeout), 0x0A = gateway
211
+ path unavailable (the serial port is down)."""
212
+ return bytes([(function | 0x80) & 0xFF, code & 0xFF])
213
+
214
+
215
+ ILLEGAL_FUNCTION = 0x01 # exception code: the function is not allowed here
216
+ ILLEGAL_DATA_ADDRESS = 0x02 # exception code: an address the slave does not have
217
+ ILLEGAL_DATA_VALUE = 0x03 # exception code: a value (or quantity) it does not take
218
+ GATEWAY_PATH_UNAVAILABLE = 0x0A # exception code: no path to the slaves (the port is down)
219
+ GATEWAY_TARGET_FAILED = 0x0B # exception code for a non-responding RTU slave
220
+
221
+ # Requests that only read (Modbus Application Protocol v1.1b3): what a read-only gateway
222
+ # still forwards. FC1-4 read bits/registers; 7 read exception status, 11/12 comm event
223
+ # counter/log, 17 report server id, 20 read file record, 24 read FIFO queue.
224
+ _READ_FUNCTIONS = frozenset({1, 2, 3, 4, 7, 11, 12, 17, 20, 24})
225
+ DIAGNOSTICS = 8 # FC8: some sub-functions restart a slave or silence it
226
+ # ... so only the ones that return data: query data echo, diagnostic register, counters
227
+ _READ_DIAGNOSTICS = frozenset({0x00, 0x02, *range(0x0B, 0x13)})
228
+ ENCAPSULATED_INTERFACE = 43 # FC43: only MEI type 14 (read device identification) reads
229
+ READ_DEVICE_ID = 0x0E
230
+
231
+
232
+ def is_read_request(pdu: bytes) -> bool:
233
+ """True if a request PDU only reads. False for writes (FC5/6/15/16, 21-23), state-changing
234
+ diagnostics (restart, listen-only, clear counters), other FC43 types, the user-defined
235
+ and every unknown function code, and an empty PDU: anything not known to be a read."""
236
+ if not pdu:
237
+ return False
238
+ function = pdu[0]
239
+ if function in _READ_FUNCTIONS:
240
+ return True
241
+ if function == DIAGNOSTICS:
242
+ return len(pdu) >= 3 and int.from_bytes(pdu[1:3], "big") in _READ_DIAGNOSTICS
243
+ if function == ENCAPSULATED_INTERFACE:
244
+ return len(pdu) >= 2 and pdu[1] == READ_DEVICE_ID
245
+ return False
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Register polling helpers (Modbus master: read registers, decode values)
250
+ # ---------------------------------------------------------------------------
251
+ def read_pdu(function: int, address: int, count: int) -> bytes:
252
+ """Build a read-registers request PDU (function 3 or 4)."""
253
+ return bytes([function & 0xFF, (address >> 8) & 0xFF, address & 0xFF,
254
+ (count >> 8) & 0xFF, count & 0xFF])
255
+
256
+
257
+ def response_data(pdu: bytes, function: int) -> bytes:
258
+ """Return the data bytes of a read-response PDU. Raises ValueError on a Modbus
259
+ exception PDU (function|0x80) or a malformed/short frame."""
260
+ if not pdu:
261
+ raise ValueError("empty PDU")
262
+ if pdu[0] == ((function | 0x80) & 0xFF):
263
+ code = pdu[1] if len(pdu) > 1 else 0
264
+ raise ValueError(f"modbus exception 0x{code:02x}")
265
+ if pdu[0] != function or len(pdu) < 2:
266
+ raise ValueError("unexpected response function")
267
+ byte_count = pdu[1]
268
+ data = pdu[2:2 + byte_count]
269
+ if len(data) != byte_count:
270
+ raise ValueError("short response data")
271
+ return data
272
+
273
+
274
+ _DTYPE_REGS = {"uint16": 1, "int16": 1, "uint32": 2, "int32": 2, "float32": 2,
275
+ "int64": 4, "uint64": 4, "float64": 4}
276
+ DTYPES = tuple(_DTYPE_REGS)
277
+ _DTYPE_FMT = {"uint16": ">H", "int16": ">h", "uint32": ">I", "int32": ">i", "float32": ">f",
278
+ "int64": ">q", "uint64": ">Q", "float64": ">d"}
279
+ MAX_READ_REGISTERS = 125 # FC3/FC4: the most registers one read may ask for
280
+
281
+ # How a device lays out a value's bytes in its registers, one letter per byte, A the most
282
+ # significant: ABCD is the Modbus convention (each register high byte first, the high
283
+ # register first); CDAB has the registers the other way round ("word swap", common for
284
+ # 32-bit values), BADC the bytes of each register swapped, DCBA both (little-endian). For a
285
+ # 64-bit value the same rule holds for its four registers (CDAB: the lowest one first).
286
+ WORD_ORDERS = ("ABCD", "CDAB", "BADC", "DCBA")
287
+
288
+
289
+ def dtype_registers(dtype: str) -> int:
290
+ if dtype not in _DTYPE_REGS:
291
+ raise ValueError(f"unknown dtype {dtype!r}")
292
+ return _DTYPE_REGS[dtype]
293
+
294
+
295
+ def word_order(data: bytes, order: str) -> bytes:
296
+ """Register bytes laid out as ``order`` (see WORD_ORDERS) rearranged to ABCD, or ABCD
297
+ bytes to ``order``: the same rearrangement works both ways."""
298
+ if order == "ABCD":
299
+ return bytes(data)
300
+ if order == "DCBA":
301
+ return bytes(data[::-1])
302
+ words = [bytes(data[i:i + 2]) for i in range(0, len(data), 2)]
303
+ if order == "CDAB":
304
+ return b"".join(reversed(words))
305
+ if order == "BADC":
306
+ return b"".join(w[::-1] for w in words)
307
+ raise ValueError(f"unknown word order {order!r}")
308
+
309
+
310
+ def decode_value(data: bytes, dtype: str, scale: float = 1.0, order: str = "ABCD"):
311
+ """Decode the first `dtype` value from register bytes laid out as `order` (big-endian,
312
+ high register first by default), multiplied by `scale`. Integers stay integers when
313
+ scale is 1; floats/scaled values are floats."""
314
+ need = dtype_registers(dtype) * 2
315
+ chunk = data[:need]
316
+ if len(chunk) < need:
317
+ raise ValueError("not enough data for dtype")
318
+ value = struct.unpack(_DTYPE_FMT[dtype], word_order(chunk, order))[0]
319
+ if scale and scale != 1.0:
320
+ return value * scale
321
+ return value
322
+
323
+
324
+ def decode_string(data: bytes, order: str = "ABCD") -> str:
325
+ """Text held in registers, two characters each: the first in the high byte (ABCD, CDAB)
326
+ or in the low byte (BADC, DCBA, as some devices store it). It ends at the first NUL;
327
+ trailing spaces (padding) are dropped, and bytes that are not UTF-8 read as U+FFFD."""
328
+ raw = word_order(data, "BADC") if order in ("BADC", "DCBA") else bytes(data)
329
+ return raw.split(b"\0", 1)[0].decode("utf-8", "replace").rstrip(" ")
330
+
331
+
332
+ def register_bit(data: bytes, bit: int) -> bool:
333
+ """One bit of the first register in ``data`` (0 = the least significant)."""
334
+ if len(data) < 2:
335
+ raise ValueError("not enough data for a register")
336
+ return bool(int.from_bytes(data[:2], "big") >> bit & 1)
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Bit reads and writes (Modbus master: FC1/2 read bits, FC5/6/15/16 write)
341
+ # ---------------------------------------------------------------------------
342
+ READ_COILS = 1 # read coils (read/write bits)
343
+ READ_DISCRETE_INPUTS = 2 # read discrete inputs (read-only bits)
344
+ WRITE_SINGLE_COIL = 5
345
+ WRITE_SINGLE_REGISTER = 6
346
+ WRITE_MULTIPLE_COILS = 15
347
+ WRITE_MULTIPLE_REGISTERS = 16
348
+
349
+ # quantity limits from the Modbus spec: they keep every PDU within MAX_PDU
350
+ MAX_READ_BITS = 2000 # FC1/FC2
351
+ MAX_WRITE_COILS = 1968 # FC15
352
+ MAX_WRITE_REGISTERS = 123 # FC16
353
+
354
+ _WRITE_FUNCTIONS = (WRITE_SINGLE_COIL, WRITE_SINGLE_REGISTER,
355
+ WRITE_MULTIPLE_COILS, WRITE_MULTIPLE_REGISTERS)
356
+
357
+
358
+ def _check_int(value: object, lo: int, hi: int, what: str) -> int:
359
+ """Return ``value`` if it is an int in lo..hi, else raise ValueError. bool is
360
+ rejected too: True as an address or register value is a caller bug, not 1."""
361
+ if isinstance(value, bool) or not isinstance(value, int) or not lo <= value <= hi:
362
+ raise ValueError(f"{what} must be an integer {lo}..{hi}, got {value!r}")
363
+ return value
364
+
365
+
366
+ def _check_span(address: int, count: int) -> None:
367
+ """Validate a start address and that address..address+count-1 stays inside the
368
+ 16-bit address space (the PDU can't express anything past 65535)."""
369
+ _check_int(address, 0, 0xFFFF, "address")
370
+ if address + count > 0x10000:
371
+ raise ValueError(f"address {address} + quantity {count} runs past address 65535")
372
+
373
+
374
+ def _coil_state(value: object, what: str) -> bool:
375
+ """A coil state must be a bool (or 0/1): a stray truthy value such as the string
376
+ "0" must never switch an output ON."""
377
+ if not isinstance(value, int) or value not in (0, 1):
378
+ raise ValueError(f"{what} must be True/False (or 1/0), got {value!r}")
379
+ return bool(value)
380
+
381
+
382
+ def read_bits_pdu(function: int, address: int, count: int) -> bytes:
383
+ """Build a read-coils (FC1) or read-discrete-inputs (FC2) request PDU; same layout
384
+ as read_pdu, with a quantity of 1..2000 bits. Raises ValueError on a bad argument."""
385
+ if function not in (READ_COILS, READ_DISCRETE_INPUTS):
386
+ raise ValueError(f"read_bits_pdu needs function {READ_COILS} or "
387
+ f"{READ_DISCRETE_INPUTS}, got {function!r}")
388
+ _check_int(count, 1, MAX_READ_BITS, "bit count")
389
+ _check_span(address, count)
390
+ return read_pdu(function, address, count)
391
+
392
+
393
+ def decode_bits(data: bytes, count: int) -> list[bool]:
394
+ """Unpack ``count`` bit states from FC1/FC2 response data (see response_data):
395
+ bit 0 (the LSB) of the first byte is the first, lowest-addressed bit. Raises
396
+ ValueError if ``data`` holds fewer than ceil(count / 8) bytes."""
397
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
398
+ raise ValueError(f"bit count must be a non-negative integer, got {count!r}")
399
+ need = (count + 7) // 8
400
+ if len(data) < need:
401
+ raise ValueError(f"not enough data for {count} bits ({len(data)} of {need} bytes)")
402
+ return [bool((data[i // 8] >> (i % 8)) & 1) for i in range(count)]
403
+
404
+
405
+ def write_single_coil_pdu(address: int, on: bool) -> bytes:
406
+ """Build a write-single-coil (FC5) request PDU: ON is sent as 0xFF00, OFF as 0x0000."""
407
+ _check_int(address, 0, 0xFFFF, "address")
408
+ value = 0xFF00 if _coil_state(on, "coil state") else 0x0000
409
+ return bytes([WRITE_SINGLE_COIL, address >> 8, address & 0xFF, value >> 8, value & 0xFF])
410
+
411
+
412
+ def write_single_register_pdu(address: int, raw: int) -> bytes:
413
+ """Build a write-single-register (FC6) request PDU for a raw 16-bit value 0..65535
414
+ (use encode_value for signed/scaled values)."""
415
+ _check_int(address, 0, 0xFFFF, "address")
416
+ _check_int(raw, 0, 0xFFFF, "register value")
417
+ return bytes([WRITE_SINGLE_REGISTER, address >> 8, address & 0xFF, raw >> 8, raw & 0xFF])
418
+
419
+
420
+ def write_multiple_coils_pdu(address: int, values: list[bool]) -> bytes:
421
+ """Build a write-multiple-coils (FC15) request PDU for 1..1968 coils starting at
422
+ ``address``: quantity, byte count, then the states packed LSB-first."""
423
+ states = [_coil_state(v, f"coil state [{i}]") for i, v in enumerate(values)]
424
+ count = _check_int(len(states), 1, MAX_WRITE_COILS, "coil count")
425
+ _check_span(address, count)
426
+ packed = bytearray((count + 7) // 8)
427
+ for i, on in enumerate(states):
428
+ if on:
429
+ packed[i // 8] |= 1 << (i % 8)
430
+ return bytes([WRITE_MULTIPLE_COILS, address >> 8, address & 0xFF,
431
+ count >> 8, count & 0xFF, len(packed)]) + bytes(packed)
432
+
433
+
434
+ def write_multiple_registers_pdu(address: int, registers: list[int]) -> bytes:
435
+ """Build a write-multiple-registers (FC16) request PDU for 1..123 raw 16-bit
436
+ register values starting at ``address``."""
437
+ regs = [_check_int(r, 0, 0xFFFF, f"register value [{i}]") for i, r in enumerate(registers)]
438
+ count = _check_int(len(regs), 1, MAX_WRITE_REGISTERS, "register count")
439
+ _check_span(address, count)
440
+ data = b"".join(r.to_bytes(2, "big") for r in regs)
441
+ return bytes([WRITE_MULTIPLE_REGISTERS, address >> 8, address & 0xFF,
442
+ count >> 8, count & 0xFF, len(data)]) + data
443
+
444
+
445
+ BROADCAST = 0 # unit 0: every slave on the line carries out a write, and none answers
446
+
447
+
448
+ def broadcast_reply(pdu: bytes) -> Optional[bytes]:
449
+ """What a gateway answers a master for a broadcast (unit 0), which no slave answers:
450
+ for a well-formed write, the reply each slave would give it as a unicast, its echo
451
+ (FC5, FC6, FC22: the whole request; FC15, FC16: its first 5 bytes, as
452
+ check_write_response expects). None for anything else."""
453
+ function = pdu[0] if pdu else 0
454
+ if function in (WRITE_SINGLE_COIL, WRITE_SINGLE_REGISTER) and len(pdu) == 5:
455
+ return bytes(pdu)
456
+ if function == 22 and len(pdu) == 7: # mask write register
457
+ return bytes(pdu)
458
+ if (function in (WRITE_MULTIPLE_COILS, WRITE_MULTIPLE_REGISTERS) and len(pdu) >= 6
459
+ and len(pdu) == 6 + pdu[5]):
460
+ return bytes(pdu[:5])
461
+ return None
462
+
463
+
464
+ def check_write_response(pdu: bytes, request: bytes) -> None:
465
+ """Check a slave's reply to a write ``request`` PDU; returns None if the write was
466
+ acknowledged. FC5/FC6 replies echo the request exactly, FC15/FC16 replies echo its
467
+ function + start address + quantity (the first 5 bytes). Raises ValueError on a
468
+ Modbus exception PDU (function|0x80), a short reply or any mismatch."""
469
+ if len(request) < 5 or request[0] not in _WRITE_FUNCTIONS:
470
+ raise ValueError("request is not a Modbus write PDU (FC5/6/15/16)")
471
+ function = request[0]
472
+ if not pdu:
473
+ raise ValueError("empty PDU")
474
+ if pdu[0] == function | 0x80:
475
+ code = pdu[1] if len(pdu) > 1 else 0
476
+ raise ValueError(f"modbus exception 0x{code:02x}")
477
+ if pdu[0] != function:
478
+ raise ValueError(f"unexpected response function 0x{pdu[0]:02x}")
479
+ single = function in (WRITE_SINGLE_COIL, WRITE_SINGLE_REGISTER)
480
+ expected = bytes(request) if single else bytes(request[:5])
481
+ if len(pdu) < len(expected):
482
+ raise ValueError(f"short write response ({len(pdu)} of {len(expected)} bytes)")
483
+ if bytes(pdu) != expected:
484
+ raise ValueError(f"write response {bytes(pdu).hex(' ')} does not match "
485
+ f"the request ({expected.hex(' ')})")
486
+
487
+
488
+ _INT_RANGE = {"uint16": (0, 0xFFFF), "int16": (-0x8000, 0x7FFF),
489
+ "uint32": (0, 0xFFFFFFFF), "int32": (-0x80000000, 0x7FFFFFFF),
490
+ "uint64": (0, (1 << 64) - 1), "int64": (-(1 << 63), (1 << 63) - 1)}
491
+
492
+
493
+ def encode_value(value: float, dtype: str, scale: float = 1.0,
494
+ order: str = "ABCD") -> list[int]:
495
+ """Inverse of decode_value: the register words that hold ``value`` as ``dtype``, laid
496
+ out as ``order`` (big-endian with the high word first by default).
497
+
498
+ The raw value is value / scale (a scale of 0 or 1 means unscaled, exactly as in
499
+ decode_value). float32 / float64 store it as is; integer dtypes round it to the
500
+ nearest integer (ties to even, like round()). Raises ValueError for a non-number, NaN
501
+ or infinity, or a raw value outside the dtype's range."""
502
+ dtype_registers(dtype) # ValueError for an unknown dtype
503
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
504
+ raise ValueError(f"value must be a number, got {value!r}")
505
+ if isinstance(value, float) and not math.isfinite(value):
506
+ raise ValueError(f"value must be a finite number, got {value!r}")
507
+ raw: int | float = value
508
+ if scale and scale != 1.0:
509
+ if not math.isfinite(scale):
510
+ raise ValueError(f"scale must be a finite number, got {scale!r}")
511
+ try:
512
+ raw = value / scale
513
+ except OverflowError: # an int too large to become a float
514
+ raise ValueError(f"value {value!r} out of range for {dtype}") from None
515
+ if not math.isfinite(raw):
516
+ raise ValueError(f"value {value!r} / scale {scale!r} out of range for {dtype}")
517
+ if dtype in ("float32", "float64"):
518
+ try: # float() first: a huge int then fails with OverflowError, not struct.error
519
+ packed = struct.pack(_DTYPE_FMT[dtype], float(raw))
520
+ except OverflowError:
521
+ raise ValueError(f"value {value!r} out of range for {dtype}") from None
522
+ else:
523
+ lo, hi = _INT_RANGE[dtype]
524
+ number = raw if isinstance(raw, int) else round(raw)
525
+ if not lo <= number <= hi:
526
+ raise ValueError(f"value {value!r} (raw {number}) out of range for {dtype} "
527
+ f"({lo}..{hi})")
528
+ packed = struct.pack(_DTYPE_FMT[dtype], number)
529
+ packed = word_order(packed, order)
530
+ return [int.from_bytes(packed[i:i + 2], "big") for i in range(0, len(packed), 2)]