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.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- 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)]
|