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,1465 @@
1
+ """Modbus RTU<->TCP gateway runner.
2
+
3
+ A drop-in alternative to MappingRunner, selected when a mapping's network
4
+ protocol is ``modbus``. It listens as a Modbus/TCP server: each connected master
5
+ sends Modbus/TCP requests, which the gateway converts to Modbus/RTU, forwards on
6
+ the shared serial bus (one transaction at a time, since RTU is a single-master
7
+ bus), reads the slave's RTU reply, and returns it to that master as Modbus/TCP —
8
+ echoing the transaction id so concurrent masters don't get crossed wires. A request
9
+ whose slave doesn't answer in time is answered with exception 0x0B (gateway target
10
+ failed to respond); while the serial port is down, every request is answered with 0x0A
11
+ (gateway path unavailable) at once, and so is one that was on the bus when the port went
12
+ away. A broadcast (unit 0) write goes on the bus and, after
13
+ the turnaround delay, gets the echo a slave would have sent; a read of unit 0 can't get a
14
+ reply and is answered 0x0B at once, without touching the bus.
15
+
16
+ Shares the lifecycle shape (and the H3/H4 fixes) of MappingRunner: the same
17
+ supervised serial port (serial_link.SerialLink: reopened, and its USB identity
18
+ re-resolved, whenever it is lost) plus an asyncio TCP server, both torn down
19
+ cleanly on stop(). Masters are admitted by the same policy as bridge clients
20
+ (access.AccessPolicy: allow-list, max connections, priority IPs, kick_old_user);
21
+ a read-only gateway answers every request that is not known to be a read with
22
+ exception 01 and never puts it on the bus, and refuses MQTT/Sparkplug writes as well.
23
+ A banner, open/close strings and closeon don't apply: bytes outside Modbus frames
24
+ would break the framing.
25
+
26
+ Edge mode (MQTT enabled, poll points) reads the points off the bus on an interval, unit by
27
+ unit, and keeps track of each unit's health (_poll_unit): a unit that gives no reply to any
28
+ of its points in UNIT_OFFLINE_AFTER cycles in a row, or whose serial port stays gone that
29
+ long, is offline (logged once; a Sparkplug B node sends its DDEATH) and is polled with one
30
+ point per cycle until it answers; then all its points are read again (Sparkplug B: a DBIRTH
31
+ with those values). Requests from Modbus/TCP masters are not affected. Plain MQTT publishes
32
+ each value as text (1 / 0 for a bit), or with mqtt.value_format "json" as {"value", "ts"
33
+ (the read time, ms since the epoch), "quality": "good"}; then a point whose read fails, or
34
+ whose unit goes offline, is published once as {"value": null, "quality": "bad"} until it
35
+ reads again (encode_value).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import contextlib
41
+ import functools
42
+ import json
43
+ import math
44
+ import re
45
+ import time
46
+ from collections import deque
47
+ from dataclasses import dataclass, field
48
+ from typing import TYPE_CHECKING, Any, Callable, Optional
49
+
50
+ from ..config import (MappingConfig, ModbusPoint, modbus_response_timeout, modbus_unit_map,
51
+ point_order, point_scale, poll_block_gap)
52
+ from . import modbus, netguard, sockopts, tls
53
+ from .access import AccessPolicy, connection_order, server_ssl
54
+ from .bridge import (
55
+ FROM_DEVICE, TO_DEVICE, RunnerStatus, Tracer, describe_disconnect, fmt_duration,
56
+ log_ignored_settings, open_tracer,
57
+ )
58
+ from .mqtt_pub import shown
59
+ from .serial_link import SerialLink
60
+
61
+ if TYPE_CHECKING:
62
+ from .mqtt_pub import MqttPublisher
63
+ from .sparkplug_node import SparkplugNode
64
+
65
+ READ_CHUNK = 512
66
+ # An RTU reply whose length its header does not give (FC8, FC43, user-defined codes), or
67
+ # that fails its CRC at that length, ends once the line has been quiet this long (or for
68
+ # 3.5 characters, if that is longer): well above the latency of USB serial adapters (16 ms
69
+ # by default on FTDI chips) and the 15.6 ms clock of the event loop on Windows.
70
+ SILENCE_S = 0.05
71
+ # After a request that got no reply in time (or bytes after its reply), the line has to
72
+ # be quiet this long (at most the response timeout) before the next request goes out: a
73
+ # reply that comes late is dropped then, not taken for the next request's.
74
+ GUARD_S = 0.25
75
+ # After a broadcast (unit 0), which no slave answers: the time the slaves get to carry it
76
+ # out before the next request goes out (the Modbus serial line specification's turnaround
77
+ # delay, 100-200 ms; at most the response timeout).
78
+ TURNAROUND_S = 0.2
79
+ # Log lines about one slave's replies (cut short, not matching): at most one per this many
80
+ # seconds per kind and unit; the ones in between are counted into the next.
81
+ NOTE_EVERY_S = 60.0
82
+ # Edge mode: a unit none of whose points has answered in this many poll cycles in a row (or
83
+ # while the serial port is gone) is offline, and polled with one point per cycle from then on.
84
+ UNIT_OFFLINE_AFTER = 3
85
+ # Writes from MQTT / Sparkplug B: one task per mapping writes them in the order they came,
86
+ # one at a time (a write and its read-back take their turns on the bus with the poll and
87
+ # the masters, whatever the number of commands). At most this many wait for the bus; a
88
+ # command beyond them is refused (busy).
89
+ WRITE_QUEUE_MAX = 32
90
+ # A write that waited this long for its turn is not written any more (expired): the bus is
91
+ # too slow for the commands coming in, and whoever sent it has likely given up on it.
92
+ WRITE_EXPIRE_S = 10.0
93
+ # A plain /set larger than this is refused unread (a backstop: a value is a few bytes).
94
+ SET_MAX_BYTES = 64 * 1024
95
+ # stop(): the write on the bus gets this long at most (and no longer than its reply can
96
+ # take) to finish before it is cancelled; the ones still waiting are answered at once.
97
+ WRITE_STOP_WAIT_S = 2.0
98
+ # The mapping form's test read (read_now): at most this many points, for at most this long.
99
+ READ_NOW_MAX = 32
100
+ READ_NOW_S = 10.0
101
+
102
+
103
+ def _next_timer(idle_s: float, deadline: Optional[float]) -> Optional[float]:
104
+ """Seconds until a master's idle / max-connection-time timer fires (None = none)."""
105
+ waits = [idle_s] if idle_s > 0 else []
106
+ if deadline is not None:
107
+ waits.append(max(0.0, deadline - time.monotonic()))
108
+ return min(waits) if waits else None
109
+
110
+
111
+ @dataclass(eq=False)
112
+ class _Master:
113
+ """A connected Modbus/TCP master (identity-hashed, so it can live in a set)."""
114
+ writer: asyncio.StreamWriter
115
+ peer: str
116
+ priority: bool = False # admitted from priority_client_ips
117
+ seq: int = field(default_factory=connection_order) # access.Client
118
+ connected_at: float = field(default_factory=time.monotonic)
119
+ close_reason: str = "" # why the gateway dropped it (first cause wins)
120
+ refused: int = 0 # requests refused because the mapping is read-only
121
+ closing: bool = False # kicked: listed until its handler is done
122
+
123
+ def kick(self, reason: str = "kicked") -> None:
124
+ self.closing = True
125
+ self.close_reason = self.close_reason or reason
126
+ with contextlib.suppress(Exception):
127
+ self.writer.transport.abort()
128
+
129
+
130
+ @dataclass(eq=False)
131
+ class _Write:
132
+ """A write from MQTT / Sparkplug B, waiting for the bus (or on it)."""
133
+ point: ModbusPoint
134
+ value: Any
135
+ done: Optional[Callable[[Optional[str], Any], None]] = None # (error, value read back)
136
+ refused: Optional[str] = None # refused when it came: answered in its turn, not written
137
+ source: str = "" # where it came from, for the log: "MQTT <topic>", ...
138
+ queued: float = field(default_factory=time.monotonic)
139
+ stage: str = "queued" # sent: its request is on the line; written: acknowledged
140
+
141
+
142
+ class NoReply(ValueError):
143
+ """A poll point got no reply: its unit did not answer in time (or not usably), or the
144
+ serial port is down (exception 0x0B / 0x0A, which the gateway makes up then)."""
145
+
146
+
147
+ @dataclass(eq=False)
148
+ class _Unit:
149
+ """A polled Modbus unit (slave address) and how it is doing (see _poll_unit)."""
150
+ unit: int
151
+ points: list[ModbusPoint] = field(default_factory=list)
152
+ state: str = "unknown" # unknown (not polled yet) | online | offline
153
+ cause: str = "" # offline: "no reply" (the unit) or "port down" (the port)
154
+ misses: int = 0 # poll cycles in a row in which none of its points answered,
155
+ miss_cause: str = "" # ... all for this cause (another one starts the count over)
156
+ since: float = 0.0 # time.time() when it went online / offline
157
+ errors: int = 0 # point reads that failed, in this run
158
+ probe: int = 0 # offline: which point the next probe reads (each in turn)
159
+ blocks: Optional[list] = None # block reads: its points as _Blocks (None: one by one)
160
+
161
+ def as_dict(self) -> dict:
162
+ """For RunnerStatus.units (the dashboard, the REST API, /metrics, notifications)."""
163
+ return {"unit": self.unit, "state": self.state, "cause": self.cause,
164
+ "since": int(self.since) if self.since else None, "errors": self.errors}
165
+
166
+
167
+ @dataclass(eq=False)
168
+ class _Block:
169
+ """Points of one unit and function read with one request (block reads)."""
170
+ fn: int
171
+ start: int # the first register (coil, input) read
172
+ count: int # how many
173
+ points: list = field(default_factory=list)
174
+ single: bool = False # its points are read one at a time (the slave refused it)
175
+
176
+
177
+ @dataclass
178
+ class _Reads:
179
+ """What one round of point reads gave."""
180
+ values: dict = field(default_factory=dict)
181
+ stamps: dict = field(default_factory=dict) # when each value was read (ms since the epoch)
182
+ failed: list = field(default_factory=list) # (point, error)
183
+ answered: bool = False # the unit answered at least once (a value or an exception)
184
+
185
+ def drop(self, name: str) -> None:
186
+ """A point written after it was read here: what was read before the write is out
187
+ of date (the write's read-back has reported the point already)."""
188
+ self.values.pop(name, None)
189
+ self.stamps.pop(name, None)
190
+ self.failed = [(p, e) for p, e in self.failed if p.name != name]
191
+
192
+
193
+ class ModbusGatewayRunner:
194
+ def __init__(self, mapping: MappingConfig, logger=None, trace_dir: Optional[str] = None,
195
+ state_dir: Optional[str] = None):
196
+ self.mapping = mapping
197
+ self.trace_dir = trace_dir
198
+ self.state_dir = state_dir # the data folder: what a Sparkplug node keeps across runs
199
+ self._tracer: Optional[Tracer] = None
200
+ self.status = RunnerStatus(state="stopped")
201
+ self.log = logger
202
+ self._serial_task: Optional[asyncio.Task] = None
203
+ self._server: Optional[netguard.Listener] = None
204
+ self._stop = asyncio.Event()
205
+ # the RTU bus; is_open / ready say whether requests can reach it right now
206
+ self._link = SerialLink(mapping.serial, self.status, self._stop, self._log,
207
+ owner=mapping.name)
208
+ self._bus_lock = asyncio.Lock() # RTU is single-master: one txn at a time
209
+ self._clients: set[_Master] = set()
210
+ self._monitors: set = set() # browser console observers (read the bus)
211
+ self._traffic: set = set() # live hex view observers
212
+ self._mqtt: Optional[MqttPublisher] = None # optional MQTT publisher for register polling
213
+ self._sparkplug: Optional[SparkplugNode] = None # payload format sparkplug_b
214
+ self._writes: deque[_Write] = deque() # MQTT writes waiting for the bus, in order
215
+ self._writer: Optional[asyncio.Task] = None # writes them, one at a time
216
+ self._closing = False # stop() has begun: no more MQTT writes are taken
217
+ self._poll_task: Optional[asyncio.Task] = None
218
+ self._publish_error = "" # the last publishing failure logged by the poll loop
219
+ # plain MQTT: the command topic of each writable point, matched exactly (a point
220
+ # name is never read back out of a topic)
221
+ base = mapping.mqtt.base_topic.rstrip("/")
222
+ self._set_points: dict[str, ModbusPoint] = {
223
+ f"{base}/{p.name}/set": p for p in mapping.modbus_poll.points if p.writable}
224
+ self._access = AccessPolicy(mapping.network)
225
+ self._refusal_log = netguard.RefusalLog(self._log) # refused connections
226
+ # one RTU character on the line (start bit, 8 data bits, parity or a 2nd stop bit,
227
+ # stop bit), and the silence that ends a reply its header does not size
228
+ baud = max(1, mapping.serial.baudrate)
229
+ self._char_s = 11 / baud
230
+ self._gap = max(SILENCE_S, 3.5 * self._char_s)
231
+ self._resp_timeout = modbus_response_timeout(mapping.options)
232
+ # the unit ids masters send that go to another RTU unit (their replies keep the id)
233
+ self._unit_map = modbus_unit_map(mapping.options)
234
+ self._notes: dict[tuple[str, int], tuple[float, int]] = {} # see _note
235
+ # the line may still carry a reply to an earlier request (see _settle): since when
236
+ # (loop time; None: it does not), and the last request that got none in time, as
237
+ # (unit, PDU)
238
+ self._dirty: Optional[float] = None
239
+ self._unanswered: Optional[tuple[int, bytes]] = None
240
+ # edge mode: the polled units, in the order their first point is listed (a point on
241
+ # unit 0, the broadcast address, is never read: no slave answers a broadcast), and the
242
+ # points whose last read failed (True: that was logged)
243
+ self._units: dict[int, _Unit] = {}
244
+ for p in mapping.modbus_poll.points:
245
+ if p.unit != modbus.BROADCAST and _polled(p):
246
+ self._units.setdefault(p.unit, _Unit(p.unit)).points.append(p)
247
+ self._block_gap = poll_block_gap(mapping.modbus_poll)
248
+ if self._block_gap is not None:
249
+ for u in self._units.values():
250
+ u.blocks = plan_blocks(u.points, self._block_gap)
251
+ self._failing: dict[str, bool] = {}
252
+ # each polled point's last read, for the status (RunnerStatus.points)
253
+ self._last: dict[str, dict] = {
254
+ p.name: {"name": p.name, "unit": u.unit, "value": None, "ts": None, "error": ""}
255
+ for u in self._units.values() for p in u.points}
256
+ self._bad: set[str] = set() # plain JSON values: points last published as "bad"
257
+ self._cycle: Optional[_Reads] = None # the reads of the unit being polled (see _Reads.drop)
258
+
259
+ def _log(self, msg: str) -> None:
260
+ if self.log:
261
+ self.log(msg)
262
+
263
+ @property
264
+ def tracer(self) -> Optional[Tracer]:
265
+ """The trace writer while the mapping runs with tracing on."""
266
+ return self._tracer
267
+
268
+ @property
269
+ def listener(self) -> Optional[netguard.Listener]:
270
+ """The Modbus TCP listener while the mapping runs, else None."""
271
+ return self._server
272
+
273
+ # ---- console monitors (watch the raw bus) ----
274
+ def add_monitor(self, mon) -> None:
275
+ self._monitors.add(mon)
276
+
277
+ def discard_monitor(self, mon) -> None:
278
+ self._monitors.discard(mon)
279
+
280
+ def _feed_monitors(self, data: bytes) -> None:
281
+ for mon in list(self._monitors):
282
+ mon.feed(data)
283
+
284
+ def add_traffic_monitor(self, mon) -> None:
285
+ self._traffic.add(mon)
286
+
287
+ def discard_traffic_monitor(self, mon) -> None:
288
+ self._traffic.discard(mon)
289
+
290
+ def _feed_traffic(self, direction: int, data: bytes) -> None:
291
+ for mon in list(self._traffic):
292
+ mon.feed_traffic(direction, data)
293
+
294
+ def wake_serial(self) -> None:
295
+ """A serial device was plugged in (PortWatcher): try the port now if it is waiting
296
+ to be opened again."""
297
+ self._link.wake()
298
+
299
+ async def serial_write(self, data: bytes) -> bool:
300
+ # the modbus console is observe-only (injecting raw bytes would corrupt
301
+ # the request/response framing on the shared bus)
302
+ return False
303
+
304
+ # ---------------- lifecycle ----------------
305
+ async def start(self) -> None:
306
+ self._stop.clear()
307
+ self._closing = False
308
+ net = self.mapping.network
309
+ # the listener first (as MappingRunner.start): a start that fails there has opened
310
+ # neither the serial port nor a broker connection, so it can be tried again
311
+ self._server = await netguard.start_server(
312
+ self._on_client, netguard.listen_hosts(net), net.port, ssl=server_ssl(net),
313
+ allowed=self._access.allowed, log=self._log)
314
+ self._log(f"modbus gateway listening on {netguard.listen_where(net, self._server.sockets)}"
315
+ f" ({'TLS, ' if net.tls else ''}{sockopts.describe(net)})")
316
+ if self._stop.is_set(): # stopped while the listener came up: it found none to close,
317
+ await self.stop() # and no serial port, MQTT client or polling is started
318
+ return
319
+ try:
320
+ self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
321
+ self.status.state = "running" # the serial side reports its own from here on
322
+ self.status.last_error = ""
323
+ # the port is read per transaction; the link only watches for it going away
324
+ self._serial_task = asyncio.create_task(
325
+ self._link.run(self._link.hold_open), name=f"modbus-serial:{self.mapping.name}")
326
+ self._log_policy()
327
+ log_ignored_settings(self.mapping, self._log)
328
+ # optional edge mode: poll Modbus registers off the bus and publish to MQTT
329
+ # (plain topics, or as a Sparkplug B edge node); writable points take values back
330
+ if self.mapping.mqtt.enabled and self.mapping.modbus_poll.points:
331
+ mq = self.mapping.mqtt
332
+ if mq.payload_format == "sparkplug_b":
333
+ from .sparkplug_node import SparkplugNode
334
+ self._sparkplug = SparkplugNode(self.mapping, self._log, functools.partial(
335
+ self._write_soon, source="Sparkplug DCMD"), state_dir=self.state_dir)
336
+ self._mqtt = self._sparkplug.publisher()
337
+ else:
338
+ from .mqtt_pub import MqttPublisher
339
+ self._mqtt = MqttPublisher(
340
+ mq, logger=self._log,
341
+ subscriptions=dict.fromkeys(self._set_points, self._on_set_message),
342
+ accept_retained=bool(mq.apply_retained_set),
343
+ on_connect=self._publish_unit_states if mq.unit_status else None)
344
+ self.status.mqtt = self._mqtt.state
345
+ self._mqtt.connect()
346
+ self._report_units()
347
+ self._poll_task = asyncio.create_task(
348
+ self._poll_loop(), name=f"modbus-poll:{self.mapping.name}")
349
+ except BaseException:
350
+ await self.stop()
351
+ raise
352
+
353
+ async def stop(self) -> None:
354
+ self._closing = True # MQTT / Sparkplug writes are refused from now on
355
+ self._stop.set()
356
+ if self._server is not None:
357
+ self._server.close()
358
+ for c in list(self._clients):
359
+ c.kick("gateway stopped")
360
+ for mon in list(self._monitors) + list(self._traffic):
361
+ mon.close()
362
+ self._monitors.clear()
363
+ self._traffic.clear()
364
+ if self._poll_task is not None:
365
+ self._poll_task.cancel()
366
+ try:
367
+ await self._poll_task
368
+ except asyncio.CancelledError:
369
+ pass
370
+ except Exception as e: # it had died: say why, and still send NDEATH etc. below
371
+ self._log(f"polling had stopped: {e!r}")
372
+ self._poll_task = None
373
+ await self._end_writes() # (their answers go out before the MQTT connection closes)
374
+ if self._sparkplug is not None:
375
+ sparkplug, self._sparkplug = self._sparkplug, None
376
+ self._mqtt = None
377
+ await sparkplug.aclose() # NDEATH, then disconnect
378
+ elif self._mqtt is not None:
379
+ mqtt, self._mqtt = self._mqtt, None
380
+ await mqtt.aclose()
381
+ self.status.mqtt = {}
382
+ if self._server is not None:
383
+ with contextlib.suppress(Exception):
384
+ await asyncio.wait_for(self._server.wait_closed(), timeout=5.0)
385
+ self._server = None
386
+ if self._serial_task:
387
+ self._serial_task.cancel()
388
+ with contextlib.suppress(asyncio.CancelledError):
389
+ await self._serial_task
390
+ self._serial_task = None
391
+ await self._link.close()
392
+ if self._tracer:
393
+ await self._tracer.aclose()
394
+ self._tracer = None
395
+ self.status.state = "stopped"
396
+ self.status.client_count = 0
397
+ self.status.clients = []
398
+ self.status.units = []
399
+ self.status.points = []
400
+ self._log("modbus gateway stopped")
401
+
402
+ # ---------------- one RTU transaction on the shared bus ----------------
403
+ async def _transaction(self, unit: int, pdu: bytes, job: Optional[_Write] = None) -> bytes:
404
+ """One request on the RTU bus and its reply (or the exception the gateway answers in
405
+ its place); ``job``: the MQTT write it is for (marked sent once it is on the line)."""
406
+ function = pdu[0] if pdu else 0
407
+ if self._link.down: # the port is gone (reconnecting): answer now, don't queue
408
+ return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
409
+ if unit == modbus.BROADCAST and modbus.is_read_request(pdu):
410
+ # no slave answers a broadcast, so a read of unit 0 can't get a reply: nothing
411
+ # goes on the bus, and the master gets at once what it got after the timeout
412
+ return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
413
+ if not self._link.is_open:
414
+ # the first open at start-up is still under way: wait a little for it, outside
415
+ # the bus lock, so queued requests wait together and not one timeout each
416
+ with contextlib.suppress(asyncio.TimeoutError):
417
+ await asyncio.wait_for(self._link.ready.wait(), timeout=self._resp_timeout)
418
+ async with self._bus_lock:
419
+ reader, writer = self._link.reader, self._link.writer
420
+ if reader is None or writer is None: # not open (yet), or lost meanwhile
421
+ return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
422
+ rtu_req = modbus.rtu_wrap(unit, pdu)
423
+ loop = asyncio.get_running_loop()
424
+ try:
425
+ await self._settle(reader) # drop what the line holds, then send
426
+ self._feed_monitors(b"\xbb tx " + rtu_req)
427
+ if self._tracer:
428
+ self._tracer.write("net>ser", rtu_req)
429
+ self._feed_traffic(TO_DEVICE, rtu_req)
430
+ writer.write(rtu_req)
431
+ if job is not None:
432
+ job.stage = "sent"
433
+ await writer.drain()
434
+ self.status.bytes_out += len(rtu_req)
435
+ # drain() returns once the OS has the request, which then still takes
436
+ # its time on the line: the response timeout starts after that
437
+ sent = loop.time() + len(rtu_req) * self._char_s
438
+ if unit == modbus.BROADCAST:
439
+ await self._turnaround(reader, sent)
440
+ reply = modbus.broadcast_reply(pdu)
441
+ if reply is not None: # a write: what each slave would have answered
442
+ return reply
443
+ return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
444
+ rpdu = await self._read_reply(reader, unit, pdu, sent)
445
+ except asyncio.TimeoutError: # (an OSError too, since Python 3.11)
446
+ return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
447
+ except (ConnectionError, OSError): # the port went away meanwhile
448
+ return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
449
+ if rpdu is None: # no reply from that slave in time
450
+ return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
451
+ return rpdu
452
+
453
+ async def _read_point(self, p):
454
+ """One poll point's current value off the bus (bool for coils / discrete inputs and
455
+ a register's bit, text for a string, a number for the other registers). Raises
456
+ NoReply if the unit did not answer (or the port is down), ValueError for an answer
457
+ that is not a value (an exception, ...)."""
458
+ if p.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS):
459
+ rpdu = await self._transaction(p.unit, modbus.read_bits_pdu(p.fn, p.address, 1))
460
+ self._check_answered(rpdu, p)
461
+ return modbus.decode_bits(modbus.response_data(rpdu, p.fn), 1)[0]
462
+ rpdu = await self._transaction(p.unit, modbus.read_pdu(p.fn, p.address, _registers(p)))
463
+ self._check_answered(rpdu, p)
464
+ return decode_point(p, modbus.response_data(rpdu, p.fn))
465
+
466
+ def _check_answered(self, rpdu: bytes, p) -> None:
467
+ """Raise NoReply for the exceptions the gateway answers itself when no reply came:
468
+ 0x0B (none in time, or none that could be used) and 0x0A (the port is down)."""
469
+ if len(rpdu) == 2 and rpdu[0] & 0x80:
470
+ if rpdu[1] == modbus.GATEWAY_TARGET_FAILED:
471
+ raise NoReply(f"no reply from unit {p.unit} within {self._resp_timeout:g} s")
472
+ if rpdu[1] == modbus.GATEWAY_PATH_UNAVAILABLE:
473
+ raise NoReply("the serial port is down")
474
+
475
+ def _publish_values(self, values: dict, stamps: Optional[dict] = None,
476
+ force: bool = False) -> None:
477
+ """Publish values read off the bus; ``stamps``: when each was read (ms since the
478
+ epoch), which Sparkplug B reports as the metric's timestamp; ``force``: Sparkplug B
479
+ sends them in a DDATA even if they did not change (after a DCMD)."""
480
+ if self._sparkplug is not None:
481
+ self._sparkplug.update(values, stamps, force=values.keys() if force else ())
482
+ elif self._mqtt is not None:
483
+ fmt = self.mapping.mqtt.value_format
484
+ for name, v in values.items():
485
+ self._bad.discard(name)
486
+ self._mqtt.publish_value(name, encode_value(
487
+ v, fmt, (stamps or {}).get(name) or _now_ms()))
488
+
489
+ def _publish_bad(self, names) -> None:
490
+ """Plain MQTT, JSON values: points whose read failed, or whose unit stopped
491
+ answering, are published once as quality "bad" (value null), until they read
492
+ again (text values: nothing is published for them)."""
493
+ if (self._sparkplug is not None or self._mqtt is None
494
+ or self.mapping.mqtt.value_format != "json"):
495
+ return
496
+ now = _now_ms()
497
+ for name in names:
498
+ if name not in self._bad:
499
+ self._bad.add(name)
500
+ self._mqtt.publish_value(name, encode_value(None, "json", now, good=False))
501
+
502
+ async def _poll_loop(self) -> None:
503
+ """Edge mode: read the configured points off the RTU bus on an interval, unit by
504
+ unit, and publish the decoded values (to <base_topic>/<point name>, or as Sparkplug
505
+ B); see _poll_unit."""
506
+ poll = self.mapping.modbus_poll
507
+ while not self._stop.is_set():
508
+ lost = [] # units that went offline in this cycle because the port is gone
509
+ failed = "" # the cycle's publishing failure (logged once, not every interval)
510
+ for u in list(self._units.values()):
511
+ if self._stop.is_set():
512
+ break
513
+ try:
514
+ if await self._poll_unit(u) == "port down":
515
+ lost.append(u.unit)
516
+ except Exception as e: # nor a value the publisher can't take
517
+ failed = failed or f"poll: publishing failed: {e!r}"
518
+ self._report_units()
519
+ if failed and failed != self._publish_error:
520
+ self._log(failed)
521
+ self._publish_error = failed
522
+ if lost:
523
+ self._log(f"{_units_text(lost)}: offline, the serial port has been down for "
524
+ f"{UNIT_OFFLINE_AFTER} polls")
525
+ try:
526
+ await asyncio.wait_for(self._stop.wait(), timeout=poll.interval_s)
527
+ break
528
+ except asyncio.TimeoutError:
529
+ pass
530
+
531
+ async def _poll_unit(self, u: _Unit) -> Optional[str]:
532
+ """Read one unit's points and publish the values. A unit that gave no reply to any of
533
+ them in UNIT_OFFLINE_AFTER cycles in a row, or whose port stayed down that long, goes
534
+ offline: a Sparkplug B node sends its DDEATH and drops its values, and from then on
535
+ each cycle reads one of its points (each in turn), so a dead unit holds the bus for
536
+ one response timeout per cycle, not one per point. When it answers, its other points
537
+ are read too and it is online again (Sparkplug B: a DBIRTH with those values). A slave
538
+ exception is an answer: the unit is there. Only changes of state are logged; a point
539
+ that keeps failing on a unit that answers is logged when it starts failing (at most
540
+ once a minute) and when it reads again. Returns the cause if the unit went offline
541
+ in this cycle."""
542
+ if self._link.down: # nothing to read (the link logs the outage)
543
+ return self._unit_missed(u, "port down")
544
+ # what is read here is published once all of the unit's points are read; a write in
545
+ # between drops the value of its point that was read before it (write_point)
546
+ reads = self._cycle = _Reads()
547
+ try:
548
+ if u.state == "offline":
549
+ probe = u.points[u.probe % len(u.points)]
550
+ u.probe += 1
551
+ await self._read_points([probe], reads)
552
+ if reads.answered:
553
+ await self._read_unit(u, reads, done=probe)
554
+ else:
555
+ await self._read_unit(u, reads)
556
+ finally:
557
+ self._cycle = None
558
+ u.errors += len(reads.failed)
559
+ self._remember(reads.values, reads.stamps, reads.failed)
560
+ self._forget([p.name for p, _e in reads.failed])
561
+ if not reads.answered:
562
+ return self._unit_missed(u, "port down" if self._link.down else "no reply")
563
+ back = u.state == "offline"
564
+ u.misses, u.miss_cause = 0, ""
565
+ if u.state != "online":
566
+ if back:
567
+ self._log(f"unit {u.unit} answers again (offline for "
568
+ f"{fmt_duration(time.time() - u.since)})")
569
+ u.state, u.cause, u.since = "online", "", time.time()
570
+ self._unit_state_changed(u)
571
+ for p, e in reads.failed:
572
+ if p.name not in self._failing:
573
+ self._failing[p.name] = self._note(f"poll {p.name}", p.unit,
574
+ f"poll '{p.name}' (unit {p.unit}): {e}")
575
+ for name in reads.values:
576
+ if self._failing.pop(name, False):
577
+ self._log(f"poll '{name}' reads again")
578
+ self._publish_bad([p.name for p, _e in reads.failed])
579
+ if back and self._sparkplug is not None:
580
+ self._sparkplug.device_online(u.unit, reads.values, reads.stamps)
581
+ elif reads.values:
582
+ self._publish_values(reads.values, reads.stamps)
583
+ return None
584
+
585
+ async def _read_unit(self, u: _Unit, reads: _Reads, done=None) -> None:
586
+ """Read ``u``'s points (but ``done``, read already): one request per point, or with
587
+ block reads one per block of neighbouring points (a block holding ``done`` is read
588
+ all the same)."""
589
+ if u.blocks is None:
590
+ await self._read_points([p for p in u.points if p is not done], reads)
591
+ return
592
+ for b in u.blocks:
593
+ if self._stop.is_set():
594
+ break
595
+ if b.single or len(b.points) == 1:
596
+ await self._read_points([p for p in b.points if p is not done], reads)
597
+ else:
598
+ await self._read_block(u, b, reads)
599
+
600
+ async def _read_block(self, u: _Unit, b: _Block, reads: _Reads) -> None:
601
+ """One request for the points of block ``b``. No reply is no reply for each of them
602
+ (the unit's health counts it as for single reads); an exception 02 or 03 (an address
603
+ or a quantity the slave does not take) makes the block single from now on, and its
604
+ points are read one at a time right away; another exception fails them all."""
605
+ bits = b.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
606
+ pdu = (modbus.read_bits_pdu(b.fn, b.start, b.count) if bits
607
+ else modbus.read_pdu(b.fn, b.start, b.count))
608
+ rpdu = await self._transaction(u.unit, pdu)
609
+ try:
610
+ self._check_answered(rpdu, b.points[0])
611
+ data = modbus.response_data(rpdu, b.fn)
612
+ states = modbus.decode_bits(data, b.count) if bits else []
613
+ except NoReply as e:
614
+ reads.failed += [(p, e) for p in b.points]
615
+ return
616
+ except ValueError as e: # an answer, but not the data asked for
617
+ reads.answered = True
618
+ code = rpdu[1] if len(rpdu) == 2 and rpdu[0] & 0x80 else None
619
+ if code not in (modbus.ILLEGAL_DATA_ADDRESS, modbus.ILLEGAL_DATA_VALUE):
620
+ reads.failed += [(p, e) for p in b.points]
621
+ return
622
+ b.single = True
623
+ what = "coils or inputs" if bits else "registers"
624
+ self._log(f"unit {u.unit}: block read of {b.count} {what} from {b.start} (FC{b.fn}) "
625
+ f"refused ({e}); its {len(b.points)} points are read one at a time "
626
+ "from now on")
627
+ await self._read_points(b.points, reads)
628
+ return
629
+ stamp = _now_ms()
630
+ reads.answered = True
631
+ for p in b.points:
632
+ try:
633
+ reads.values[p.name] = (states[p.address - b.start] if bits
634
+ else decode_point(p, data, p.address - b.start))
635
+ reads.stamps[p.name] = stamp
636
+ except Exception as e: # (one bad point must not stop the others)
637
+ reads.failed.append((p, e))
638
+
639
+ async def _read_points(self, points, reads: _Reads) -> None:
640
+ for p in points:
641
+ if self._stop.is_set():
642
+ break
643
+ try:
644
+ reads.values[p.name] = await self._read_point(p)
645
+ reads.stamps[p.name] = _now_ms()
646
+ reads.answered = True
647
+ except NoReply as e:
648
+ reads.failed.append((p, e))
649
+ except Exception as e: # an answer, but not a value; one bad point must not stop the loop
650
+ reads.failed.append((p, e))
651
+ reads.answered = True
652
+
653
+ def _remember(self, values: dict, stamps: dict, failed=()) -> None:
654
+ """The points' last reads, for the status: the values read (and when), and why the
655
+ failed ones failed (their last value stays)."""
656
+ for name, v in values.items():
657
+ if (last := self._last.get(name)) is not None:
658
+ last.update(value=_json_value(v), ts=stamps.get(name), error="")
659
+ for p, e in failed:
660
+ if (last := self._last.get(p.name)) is not None:
661
+ last["error"] = str(e) or type(e).__name__
662
+
663
+ def _report_units(self) -> None:
664
+ self.status.units = [u.as_dict() for u in self._units.values()]
665
+ self.status.points = list(self._last.values())
666
+
667
+ async def read_now(self, points) -> list[tuple[Any, Any, str]]:
668
+ """The mapping form's test read: ``points`` (as typed in the form, maybe not saved)
669
+ read once now, each with a request of its own that takes its turn on the bus with
670
+ the poll, the writes and the masters; nothing is published. At most READ_NOW_MAX
671
+ points, for at most READ_NOW_S. Returns (point, value, error) for each."""
672
+ out: list[tuple[Any, Any, str]] = []
673
+ end = time.monotonic() + READ_NOW_S
674
+ for p in points[:READ_NOW_MAX]:
675
+ if p.unit == modbus.BROADCAST or not _polled(p):
676
+ out.append((p, None, "not read: unit 0 (broadcast) or write-only"))
677
+ elif time.monotonic() > end:
678
+ out.append((p, None, f"not read: the test read stops after {READ_NOW_S:g} s"))
679
+ else:
680
+ try:
681
+ out.append((p, await self._read_point(p), ""))
682
+ except Exception as e: # (shown next to the point)
683
+ out.append((p, None, str(e) or type(e).__name__))
684
+ return out
685
+
686
+ def _unit_state_changed(self, u: _Unit) -> None:
687
+ if self._sparkplug is None and self.mapping.mqtt.unit_status:
688
+ self._publish_unit_state(u)
689
+
690
+ def _publish_unit_state(self, u: _Unit) -> None:
691
+ """Plain MQTT with unit status on: <base_topic>/unit<N>/status, retained."""
692
+ if self._mqtt is not None and u.state != "unknown":
693
+ base = self.mapping.mqtt.base_topic.rstrip("/")
694
+ self._mqtt.publish_raw(f"{base}/unit{u.unit}/status", u.state.encode(), retain=True)
695
+
696
+ def _publish_unit_states(self) -> None:
697
+ """After each (re)connect: the units' states again (a change while the broker was
698
+ away was not delivered)."""
699
+ for u in self._units.values():
700
+ self._publish_unit_state(u)
701
+
702
+ def _unit_missed(self, u: _Unit, cause: str) -> Optional[str]:
703
+ """A poll cycle in which ``u`` gave no reply, because of ``cause``; it goes offline
704
+ after UNIT_OFFLINE_AFTER such cycles in a row. A unit that already stopped answering
705
+ stays offline for that reason while the port is down."""
706
+ if cause != u.miss_cause:
707
+ u.misses, u.miss_cause = 0, cause
708
+ u.misses += 1
709
+ if u.misses < UNIT_OFFLINE_AFTER or (u.state == "offline"
710
+ and (u.cause == cause or cause == "port down")):
711
+ return None
712
+ went = u.state != "offline"
713
+ u.state, u.cause = "offline", cause
714
+ if went:
715
+ u.since = time.time()
716
+ if self._sparkplug is not None:
717
+ self._sparkplug.device_offline(u.unit)
718
+ self._publish_bad([p.name for p in u.points])
719
+ self._unit_state_changed(u)
720
+ if cause == "no reply":
721
+ n = len(u.points)
722
+ self._log(f"unit {u.unit}: offline, no reply to "
723
+ f"{'its point' if n == 1 else f'any of its {n} points'} in "
724
+ f"{UNIT_OFFLINE_AFTER} polls in a row; polling one point per cycle until "
725
+ "it answers")
726
+ return cause if went else None
727
+
728
+ def _forget(self, names: list) -> None:
729
+ """Points whose read failed: their last value is not reported as current again."""
730
+ if names and self._sparkplug is not None:
731
+ self._sparkplug.forget(names)
732
+
733
+ # ---------------- writes from MQTT (plain <topic>/<point>/set, Sparkplug DCMD) ----
734
+ async def write_point(self, point, value, job: Optional[_Write] = None,
735
+ source: str = "") -> Any:
736
+ """Write one value to one writable point, and nothing beyond it: a coil takes a
737
+ bool (FC5), a holding register a number, encoded as the point's own type (FC6 for
738
+ a 16-bit type, FC16 for the two registers of a 32-bit one). Check the slave's
739
+ reply, then read the point back and publish it; returns that value (None if it was
740
+ not read back). A point on unit 0 is a broadcast, carried out by every slave and
741
+ answered by none, so it is not read back, nor is a write-only point ("poll":
742
+ false). A read-only mapping writes nothing, nor does a value outside the point's
743
+ min / max / allowed (checked here, at write time, for every command path). The
744
+ mapping's log names the write and where it came from (`source`, else the job's)."""
745
+ if self.mapping.network.read_only:
746
+ raise ValueError("mapping is read-only")
747
+ if not point.writable:
748
+ raise ValueError(f"point {point.name!r} is not writable")
749
+ if point.fn == modbus.READ_COILS:
750
+ # only a bool: never a list (several coils), a number (a Sparkplug Int 2) or
751
+ # text (a Sparkplug String "false")
752
+ if not isinstance(value, bool):
753
+ raise ValueError(f"{point.name!r} is a coil: it takes true or false, "
754
+ f"not {shown(value)}")
755
+ check_limits(point, value)
756
+ pdu = modbus.write_single_coil_pdu(point.address, value)
757
+ raw = ""
758
+ else:
759
+ if point.dtype not in modbus.DTYPES: # (a stored point the edits refuse)
760
+ what = "a bit of a register" if point.dtype == "bool" else shown(point.dtype)
761
+ raise ValueError(f"{point.name!r} is {what}: MQTT can't write it")
762
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
763
+ raise ValueError(f"{point.name!r} takes a number, not {shown(value)}")
764
+ check_limits(point, value)
765
+ regs = modbus.encode_value(value, point.dtype, point_scale(point) or 1.0,
766
+ point_order(point))
767
+ pdu = (modbus.write_single_register_pdu(point.address, regs[0]) if len(regs) == 1
768
+ else modbus.write_multiple_registers_pdu(point.address, regs))
769
+ raw = _raw_text(value, regs, point)
770
+ rpdu = await self._transaction(point.unit, pdu, job)
771
+ self._check_answered(rpdu, point) # (NoReply: the unit did not answer)
772
+ modbus.check_write_response(rpdu, pdu)
773
+ if job is not None:
774
+ job.stage = "written"
775
+ if self._cycle is not None: # a poll that read the point before the write is under way
776
+ self._cycle.drop(point.name)
777
+ source = source or (job.source if job is not None else "")
778
+ via = f", from {source}" if source else ""
779
+ if point.unit == modbus.BROADCAST:
780
+ self._log(f"wrote '{point.name}' = {value!r} (broadcast to unit 0, FC{pdu[0]}{raw}"
781
+ f"{via}; not read back)")
782
+ return None
783
+ if not _polled(point):
784
+ self._log(f"wrote '{point.name}' = {value!r} (unit {point.unit}, FC{pdu[0]}{raw}"
785
+ f"{via}; write-only, not read back)")
786
+ return None
787
+ self._log(f"wrote '{point.name}' = {value!r} (unit {point.unit}, FC{pdu[0]}{raw}{via})")
788
+ if self._closing: # the mapping is stopping: no read-back
789
+ return None
790
+ return await self._read_back(point)
791
+
792
+ async def _read_back(self, point) -> Any:
793
+ """After a write: the point's value, read and published (Sparkplug B: in a DDATA
794
+ even if it did not change, so the host learns how the DCMD went); None if it can't
795
+ be read."""
796
+ try:
797
+ value = await self._read_point(point)
798
+ except Exception:
799
+ return None
800
+ stamps = {point.name: _now_ms()}
801
+ self._remember({point.name: value}, stamps)
802
+ self._publish_values({point.name: value}, stamps, force=True)
803
+ return value
804
+
805
+ def _write_soon(self, point, value, done=None, refused: Optional[str] = None,
806
+ source: str = "") -> None:
807
+ """Queue a write from MQTT (called on the loop): the writes are made one at a time,
808
+ in the order they came (see WRITE_QUEUE_MAX). ``done`` gets (None or the error,
809
+ the value read back or None) once it is over; a write ``refused`` when it came (the
810
+ error) is answered in its turn as well, so the answers keep the commands' order."""
811
+ w = _Write(point, value, done, refused, source=source)
812
+ if self._closing:
813
+ self._note("write stopping", point.unit, f"write to '{point.name}' refused: the "
814
+ "mapping is stopping")
815
+ self._finish(w, "mapping stopped")
816
+ return
817
+ if len(self._writes) >= WRITE_QUEUE_MAX:
818
+ self._note("write busy", point.unit, f"write to '{point.name}' refused: "
819
+ f"{WRITE_QUEUE_MAX} writes are waiting for the bus already (busy)")
820
+ self._finish(w, "busy")
821
+ return
822
+ self._writes.append(w)
823
+ if self._writer is None or self._writer.done():
824
+ self._writer = asyncio.create_task(self._run_writes(),
825
+ name=f"modbus-write:{self.mapping.name}")
826
+
827
+ async def _run_writes(self) -> None:
828
+ """The mapping's writer: the queued writes, one at a time, in the order they came
829
+ (until the mapping stops: _end_writes answers the rest)."""
830
+ while self._writes and not self._closing:
831
+ w = self._writes.popleft()
832
+ try:
833
+ err, value = await self._run_write(w)
834
+ except asyncio.CancelledError: # stop() did not wait any longer for it
835
+ if w.stage == "written": # (only its read-back was cut short)
836
+ self._finish(w, None)
837
+ raise
838
+ outcome = "sent, outcome unknown" if w.stage == "sent" else "not sent"
839
+ self._log(f"write '{w.point.name}' = {shown(w.value)} cancelled (mapping "
840
+ f"stopped): {outcome}")
841
+ self._finish(w, f"cancelled: mapping stopped ({outcome})")
842
+ raise
843
+ self._finish(w, err, value)
844
+
845
+ async def _end_writes(self) -> None:
846
+ """stop(): the write on the bus may finish (for as long as its reply can take, at
847
+ most WRITE_STOP_WAIT_S), else it is cancelled, and its answer says whether its
848
+ request had gone out; then the writes still waiting are answered "cancelled:
849
+ mapping stopped" (none of them was sent). In that order, so the answers keep the
850
+ order of the commands."""
851
+ task, self._writer = self._writer, None
852
+ if task is not None and not task.done():
853
+ await asyncio.wait({task}, timeout=min(self._resp_timeout + GUARD_S,
854
+ WRITE_STOP_WAIT_S))
855
+ task.cancel()
856
+ with contextlib.suppress(asyncio.CancelledError):
857
+ await task
858
+ waiting, self._writes = list(self._writes), deque()
859
+ for w in waiting:
860
+ self._finish(w, w.refused or "cancelled: mapping stopped")
861
+ dropped = [w.point.name for w in waiting if w.refused is None]
862
+ if dropped:
863
+ more = f" and {len(dropped) - 5} more" if len(dropped) > 5 else ""
864
+ self._log(f"{len(dropped)} MQTT write(s) cancelled before they were sent, the "
865
+ f"mapping stopped: {_names(dropped[:5])}{more}")
866
+
867
+ async def _run_write(self, w: _Write) -> tuple[Optional[str], Any]:
868
+ if w.refused is not None:
869
+ await self._report(w.point)
870
+ return w.refused, None
871
+ waited = time.monotonic() - w.queued
872
+ if waited > WRITE_EXPIRE_S:
873
+ self._note("write expired", w.point.unit, f"write '{w.point.name}' = "
874
+ f"{shown(w.value)} not made: it waited {waited:.1f} s for the bus "
875
+ "(expired)")
876
+ return f"expired: waited {waited:.1f} s for the bus", None
877
+ try:
878
+ return None, await self.write_point(w.point, w.value, w, source=w.source)
879
+ except Exception as e:
880
+ err = str(e) or type(e).__name__
881
+ self._log(f"write '{w.point.name}' = {shown(w.value)} failed: {err}")
882
+ if not isinstance(e, NoReply):
883
+ await self._report(w.point)
884
+ return err, None
885
+
886
+ async def _report(self, point) -> None:
887
+ """Sparkplug B, a DCMD that wrote nothing (refused, or the slave's exception): the
888
+ point's current value in a DDATA all the same, as after a write, so the host sees
889
+ it was not taken. Not for a unit that did not answer (it goes offline: DDEATH)."""
890
+ if (self._sparkplug is not None and not self._closing
891
+ and point.unit != modbus.BROADCAST and _polled(point)):
892
+ await self._read_back(point)
893
+
894
+ def _finish(self, w: _Write, err: Optional[str], value: Any = None) -> None:
895
+ if w.done is not None:
896
+ try:
897
+ w.done(err, value)
898
+ except Exception as e: # (an answer that can't be sent must not stop the writer)
899
+ self._log(f"write '{w.point.name}': the answer was not sent: {e}")
900
+
901
+ def _on_set_message(self, topic: str, payload: bytes) -> None:
902
+ """Plain MQTT: a value for one writable point on its <base_topic>/<point>/set; the
903
+ outcome goes to .../set/result (ok, or error: …), at QoS 1 or the mapping's QoS. A
904
+ JSON request {"value": …, "id": …} gets a JSON answer {"id": …, "ok": true,
905
+ "value": <read back>} / {"id": …, "ok": false, "error": "…"} (see
906
+ parse_write_request)."""
907
+ point = self._set_points.get(topic)
908
+ if point is None or self._mqtt is None:
909
+ return
910
+ mqtt, result = self._mqtt, topic + "/result"
911
+ # at QoS 1 at least, as the /set subscription: paho keeps it over a reconnect
912
+ qos = max(self.mapping.mqtt.qos if isinstance(self.mapping.mqtt.qos, int) else 0, 1)
913
+ request: Optional[dict] = {} if payload[:64].lstrip()[:1] == b"{" else None
914
+
915
+ def answer(err: Optional[str], value: Any = None) -> None:
916
+ if request is None: # plain text: ok, or error: …
917
+ out = b"ok" if err is None else f"error: {err}".encode()
918
+ else: # a JSON request: its id (if it had one), and the value read back
919
+ reply = dict(request, ok=err is None)
920
+ if err is None:
921
+ reply["value"] = _json_value(value)
922
+ else:
923
+ reply["error"] = err
924
+ out = json.dumps(reply, separators=(",", ":")).encode()
925
+ mqtt.publish_raw(result, out, qos=qos)
926
+ if len(payload) > SET_MAX_BYTES:
927
+ too_large = f"too large: {len(payload)} bytes (at most {SET_MAX_BYTES})"
928
+ self._note("write too large", point.unit, f"write '{point.name}' from MQTT "
929
+ f"refused: {too_large}")
930
+ self._write_soon(point, None, answer, refused=too_large)
931
+ return
932
+ try:
933
+ if request is None:
934
+ value = parse_write_payload(payload, point)
935
+ else:
936
+ value = parse_write_request(payload, point, request)
937
+ except ValueError as e:
938
+ self._write_soon(point, None, answer, refused=str(e))
939
+ return
940
+ if self.mapping.network.read_only:
941
+ self._note("write read-only", point.unit,
942
+ f"write '{point.name}' from MQTT refused: the mapping is read-only")
943
+ self._write_soon(point, None, answer, refused="mapping is read-only")
944
+ return
945
+ self._write_soon(point, value, answer, source=f"MQTT {topic}")
946
+
947
+ # ---------------- reading the RTU line ----------------
948
+ async def _settle(self, reader) -> None:
949
+ """Before a request goes out: drop what the line holds, bytes no request is waiting
950
+ for. After a request that got no reply in time (or bytes after its reply), first
951
+ make sure the line has been quiet for the guard time since then, so that a reply
952
+ that comes late is dropped here and not taken for this request's (a read's reply
953
+ carries no address), and say which slave answered late. A line that never goes
954
+ quiet holds the request back by at most a whole frame's time more."""
955
+ loop = asyncio.get_running_loop()
956
+ since, self._dirty = self._dirty, None
957
+ late, self._unanswered = self._unanswered, None
958
+ guard = 0.0 if since is None else max(min(GUARD_S, self._resp_timeout),
959
+ 3.5 * self._char_s)
960
+ last = loop.time() if since is None else since # the last sign of life on the line
961
+ end = loop.time() + guard + modbus.MAX_ADU * self._char_s
962
+ got = bytearray()
963
+ while (left := end - loop.time()) > 0:
964
+ wait = max(0.001, last + guard - loop.time()) # 1 ms: take what is buffered
965
+ try:
966
+ chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=min(wait, left))
967
+ except asyncio.TimeoutError:
968
+ break
969
+ if not chunk:
970
+ raise ConnectionError("serial EOF")
971
+ got += chunk
972
+ last = loop.time()
973
+ if not got:
974
+ return
975
+ self._dropped(got)
976
+ if late is not None and modbus.find_reply(got, *late, quiet=True).span is not None:
977
+ unit, request = late
978
+ self._note("late", unit, f"unit {unit} answered FC{request[0]} after the response "
979
+ f"timeout ({self._resp_timeout:g} s); the late reply was dropped. Raise "
980
+ "the mapping's response timeout if the slave needs longer")
981
+ else:
982
+ self._note("stray", -1, f"dropped {len(got)} byte(s) on the serial line that no "
983
+ "request was waiting for")
984
+
985
+ async def _read_reply(self, reader, unit: int, request: bytes, sent: float) -> Optional[bytes]:
986
+ """The PDU of the reply from ``unit`` to ``request``, which has gone out on the line
987
+ at ``sent`` (loop time), or None if none came in time.
988
+
989
+ The reply has to begin within the response timeout and may then take as long as a
990
+ whole frame needs. It is complete as soon as it is as long as its header says (or
991
+ when the line goes quiet, for a reply whose header does not say), not after a fixed
992
+ pause, and has to answer the request (function, byte count, echo): see
993
+ modbus.find_reply. Bytes around it, and frames that answer something else, are
994
+ dropped; the next request then waits for a quiet line first (_settle)."""
995
+ loop = asyncio.get_running_loop()
996
+ deadline = sent + self._resp_timeout
997
+ finish = deadline + modbus.MAX_ADU * self._char_s + self._gap
998
+ buf = bytearray()
999
+ dropped = 0 # bytes cut off the front of buf (no reply is that long)
1000
+ quiet = False
1001
+ while True:
1002
+ scan = modbus.find_reply(buf, unit, request, quiet=quiet)
1003
+ if scan.span is not None:
1004
+ start, end = scan.span
1005
+ self._dropped(buf[:start])
1006
+ self._delivered(bytes(buf[start:end]))
1007
+ self._dropped(buf[end:])
1008
+ if dropped or end < len(buf):
1009
+ # bytes after the reply: more may be on its way (bytes only in front of
1010
+ # it, such as an adapter's echo of the request, are no reason to wait)
1011
+ self._dirty = loop.time()
1012
+ return bytes(buf[start + 1:end - 2])
1013
+ left = (finish if scan.pending else deadline) - loop.time()
1014
+ if left <= 0:
1015
+ break
1016
+ if buf and not quiet:
1017
+ left = min(left, self._gap) # then try what came as a whole frame
1018
+ try:
1019
+ chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=left)
1020
+ except asyncio.TimeoutError:
1021
+ quiet = bool(buf)
1022
+ continue
1023
+ if not chunk:
1024
+ raise ConnectionError("serial EOF")
1025
+ buf += chunk
1026
+ quiet = False
1027
+ if len(buf) > 2 * modbus.MAX_ADU: # a reply can't begin before the last MAX_ADU
1028
+ self._dropped(buf[:-modbus.MAX_ADU])
1029
+ dropped += len(buf) - modbus.MAX_ADU
1030
+ del buf[:-modbus.MAX_ADU]
1031
+ self._dirty = loop.time()
1032
+ self._unanswered = (unit, request)
1033
+ if buf or dropped:
1034
+ self._dropped(buf)
1035
+ what = scan.rejected or ("a frame from it that is cut short or fails its CRC"
1036
+ if scan.started else "none of them answers it")
1037
+ self._note("bad", unit, f"unit {unit}: no usable reply to FC{request[0]} within "
1038
+ f"{self._resp_timeout:g} s; dropped {dropped + len(buf)} byte(s) "
1039
+ f"({what})")
1040
+ return None
1041
+
1042
+ async def _turnaround(self, reader, sent: float) -> None:
1043
+ """After a broadcast, which no slave answers: keep the bus until the request is out
1044
+ (``sent``) and the slaves have had the turnaround time to carry it out. What comes
1045
+ meanwhile is dropped."""
1046
+ loop = asyncio.get_running_loop()
1047
+ end = sent + min(TURNAROUND_S, self._resp_timeout)
1048
+ got = bytearray()
1049
+ while (left := end - loop.time()) > 0:
1050
+ try:
1051
+ chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=left)
1052
+ except asyncio.TimeoutError:
1053
+ break
1054
+ if not chunk:
1055
+ raise ConnectionError("serial EOF")
1056
+ got += chunk
1057
+ if got:
1058
+ self._dropped(got)
1059
+ self._dirty = loop.time()
1060
+ self._note("stray", modbus.BROADCAST, f"dropped {len(got)} byte(s) that came after "
1061
+ "a broadcast to unit 0, which no slave may answer")
1062
+
1063
+ def _delivered(self, frame: bytes) -> None:
1064
+ self.status.bytes_in += len(frame)
1065
+ self._feed_monitors(b"\xaa rx " + frame)
1066
+ if self._tracer:
1067
+ self._tracer.write("ser>net", frame)
1068
+ self._feed_traffic(FROM_DEVICE, frame)
1069
+
1070
+ def _dropped(self, data) -> None:
1071
+ """Bytes read off the line that are not a reply: counted, and shown in the trace
1072
+ (as ser>drop), the console and the hex view like any others."""
1073
+ if not data:
1074
+ return
1075
+ data = bytes(data)
1076
+ self.status.bytes_in += len(data)
1077
+ self._feed_monitors(b"\xaa rx (dropped) " + data)
1078
+ if self._tracer:
1079
+ self._tracer.write("ser>drop", data)
1080
+ self._feed_traffic(FROM_DEVICE, data)
1081
+
1082
+ def _note(self, kind: str, unit: int, message: str) -> bool:
1083
+ """Log a note about the replies on the line, at most once per NOTE_EVERY_S for one
1084
+ kind and unit: a slave that keeps failing every request must not flood the log.
1085
+ Returns whether it was logged (or only counted)."""
1086
+ now = time.monotonic()
1087
+ last, held = self._notes.get((kind, unit), (None, 0))
1088
+ if last is not None and now - last < NOTE_EVERY_S:
1089
+ self._notes[(kind, unit)] = (last, held + 1)
1090
+ return False
1091
+ self._notes[(kind, unit)] = (now, 0)
1092
+ more = f" (and {held} more since the last such line)" if held else ""
1093
+ self._log(message + more)
1094
+ return True
1095
+
1096
+ # ---------------- network side ----------------
1097
+ def _log_policy(self) -> None:
1098
+ """Log at start which of the mapping's options a Modbus gateway treats differently:
1099
+ the ones it does not use, and what read-only means here."""
1100
+ opts = self.mapping.options
1101
+ unused = [name for name, value in (("banner", opts.banner), ("openstr", opts.openstr),
1102
+ ("closestr", opts.closestr), ("closeon", opts.closeon))
1103
+ if value]
1104
+ if unused:
1105
+ self._log(f"not used by a Modbus gateway: {', '.join(unused)} "
1106
+ "(bytes outside Modbus frames would break the framing)")
1107
+ if self.mapping.network.read_only:
1108
+ self._log("read-only: requests from Modbus/TCP masters that are not reads get "
1109
+ "exception 01 (illegal function) and never reach the bus; MQTT/Sparkplug "
1110
+ "writes to writable points are refused too")
1111
+ umap = self.mapping.options.modbus_unit_map
1112
+ if self._unit_map:
1113
+ self._log("unit map: requests from masters for " + ", ".join(
1114
+ f"unit {sent} go to unit {unit}" for sent, unit in self._unit_map.items()))
1115
+ if not isinstance(umap, dict) or len(umap) != len(self._unit_map):
1116
+ self._log(f"warning: unit map {shown(umap)}: entries that are not a unit id 0-255 "
1117
+ "to a unit 1-255 are ignored")
1118
+ poll = self.mapping.modbus_poll
1119
+ if poll.broadcast_points:
1120
+ self._log(f"warning: poll point(s) {_names(poll.broadcast_points)} on unit 0, the "
1121
+ "broadcast address: never read (no slave answers a broadcast); a write "
1122
+ "to one goes to every slave on the line and is not read back")
1123
+ if poll.reserved_unit_points:
1124
+ self._log(f"warning: poll point(s) {_names(poll.reserved_unit_points)} on units "
1125
+ "248-255, which a Modbus serial line reserves (slave addresses are 1-247)")
1126
+ if self._block_gap is not None and self.mapping.mqtt.enabled:
1127
+ blocks = [b for u in self._units.values() for b in (u.blocks or [])]
1128
+ self._log(f"block reads: {sum(len(b.points) for b in blocks)} point(s) with "
1129
+ f"{len(blocks)} request(s) per poll (at most {self._block_gap} unused "
1130
+ "register(s) or coil(s) within a block)")
1131
+ for p in poll.points:
1132
+ unusable = [key for key in ("min", "max", "allowed")
1133
+ if getattr(p, key) is not None and _limit(p, key) is None]
1134
+ if p.writable and unusable:
1135
+ self._log(f"warning: point {p.name!r}: {' and '.join(unusable)} ignored (not a "
1136
+ "number, or for allowed not a list of values of the point's type)")
1137
+ if point_scale(p) is not p.scale:
1138
+ self._log(f"warning: point {p.name!r}: scale {shown(p.scale)} ignored (not a "
1139
+ "number): its values are not scaled")
1140
+ if point_order(p) != p.word_order:
1141
+ self._log(f"warning: point {p.name!r}: word_order {shown(p.word_order)} ignored "
1142
+ "(not ABCD, CDAB, BADC or DCBA): read as ABCD")
1143
+ mq = self.mapping.mqtt
1144
+ missing = [what for what, have in (("TLS", mq.tls), ("a username", mq.username))
1145
+ if not have]
1146
+ if self.mapping.accepts_mqtt_writes and missing:
1147
+ self._log("warning: writable points take MQTT commands over a broker connection "
1148
+ f"without {' or '.join(missing)}: any client the broker lets publish to "
1149
+ "the command topics can write them; restrict those topics with the "
1150
+ "broker's access rules (ACLs)")
1151
+
1152
+ def _refuse(self, master: _Master, pdu: bytes) -> bytes:
1153
+ """Answer a request a read-only gateway does not forward: exception 01 (illegal
1154
+ function). The first one per master connection is logged, the rest are counted."""
1155
+ function = pdu[0] if pdu else 0
1156
+ master.refused += 1
1157
+ if master.refused == 1:
1158
+ self._log(f"read-only: refused FC{function} from {master.peer} with exception 01 "
1159
+ "(further refusals for this master are only counted)")
1160
+ return modbus.exception_pdu(function, modbus.ILLEGAL_FUNCTION)
1161
+
1162
+ def _refresh_clients(self) -> None:
1163
+ self.status.set_clients(self._clients)
1164
+
1165
+ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
1166
+ if self._stop.is_set(): # accepted while the gateway stops
1167
+ writer.close()
1168
+ return
1169
+ peer_ip, peer_port = netguard.peer_address(writer)
1170
+ peer = netguard.client_label(peer_ip, peer_port)
1171
+ net = self.mapping.network
1172
+
1173
+ # allow-list, then max connections (a priority master / kick_old_user makes room)
1174
+ admission = self._access.admit(peer_ip, peer, self._clients, self._refusal_log)
1175
+ if not admission.admitted:
1176
+ with contextlib.suppress(Exception):
1177
+ writer.close()
1178
+ return
1179
+
1180
+ if err := sockopts.apply_keepalive(writer, net):
1181
+ self._log(f"TCP keepalive not enabled for {peer}: {err}")
1182
+ master = _Master(writer, peer, priority=admission.priority)
1183
+ self._clients.add(master)
1184
+ self._refresh_clients()
1185
+ tag = (" [priority]" if master.priority else "") + tls.client_certificate(writer)
1186
+ self._log(f"modbus master connected: {peer}{tag} ({len(self._clients)} total)")
1187
+ opts = self.mapping.options
1188
+ deadline = (time.monotonic() + opts.max_connection_time_s
1189
+ if opts.max_connection_time_s > 0 else None)
1190
+ reason = "closed by master"
1191
+ buf = bytearray()
1192
+ try:
1193
+ while not self._stop.is_set():
1194
+ try:
1195
+ data = await asyncio.wait_for(
1196
+ reader.read(READ_CHUNK), timeout=_next_timer(opts.idle_timeout_s, deadline))
1197
+ except asyncio.TimeoutError as e:
1198
+ if getattr(e, "errno", None) is not None:
1199
+ raise # a socket error (e.g. keepalive ETIMEDOUT), not our timer
1200
+ if deadline is not None and time.monotonic() >= deadline:
1201
+ reason = ("max connection time reached "
1202
+ f"({fmt_duration(opts.max_connection_time_s)})")
1203
+ else:
1204
+ reason = f"idle timeout ({fmt_duration(opts.idle_timeout_s)} without traffic)"
1205
+ break
1206
+ if not data:
1207
+ break
1208
+ buf += data
1209
+ while True:
1210
+ try:
1211
+ frame = modbus.take_tcp_adu(buf)
1212
+ except ValueError as e:
1213
+ self._log(f"bad Modbus/TCP framing from {peer}: {e}")
1214
+ reason = "bad Modbus/TCP framing"
1215
+ return
1216
+ if frame is None:
1217
+ break
1218
+ txn, unit, pdu = frame
1219
+ if net.read_only and not modbus.is_read_request(pdu):
1220
+ rpdu = self._refuse(master, pdu)
1221
+ else:
1222
+ rpdu = await self._transaction(self._unit_map.get(unit, unit), pdu)
1223
+ writer.write(modbus.build_tcp_adu(txn, unit, rpdu))
1224
+ await writer.drain()
1225
+ except (ConnectionError, asyncio.IncompleteReadError, OSError) as e:
1226
+ reason = describe_disconnect(e)
1227
+ finally:
1228
+ self._clients.discard(master)
1229
+ self._refresh_clients()
1230
+ with contextlib.suppress(Exception):
1231
+ writer.close()
1232
+ reason = master.close_reason or reason
1233
+ if self._stop.is_set():
1234
+ reason = "gateway stopped"
1235
+ held = fmt_duration(time.monotonic() - master.connected_at)
1236
+ refused = f", {master.refused} request(s) refused (read-only)" if master.refused else ""
1237
+ self._log(f"modbus master disconnected: {peer} after {held} — {reason}{refused} "
1238
+ f"({len(self._clients)} total)")
1239
+
1240
+
1241
+ def _now_ms() -> int:
1242
+ return int(time.time() * 1000)
1243
+
1244
+
1245
+ def encode_value(value, fmt: str, ts: int, good: bool = True) -> bytes:
1246
+ """A polled value as a plain MQTT payload: text (1 / 0 for a bit; also for a value_format
1247
+ this version doesn't know), or for "json" {"value", "ts" (ms since the epoch),
1248
+ "quality"}: a bit is true / false, a NaN or infinite reading null; ``good`` False: the
1249
+ read failed (value null, quality "bad")."""
1250
+ if fmt != "json":
1251
+ return (("1" if value else "0") if isinstance(value, bool) else str(value)).encode()
1252
+ if not good or (isinstance(value, float) and not math.isfinite(value)):
1253
+ value = None
1254
+ return json.dumps({"value": value, "ts": ts, "quality": "good" if good else "bad"},
1255
+ separators=(",", ":")).encode()
1256
+
1257
+
1258
+ def _polled(point) -> bool:
1259
+ """Whether a point is read (False: "poll": false, a write-only point)."""
1260
+ return point.poll is not False
1261
+
1262
+
1263
+ def _limit(point, key: str):
1264
+ """A point's min / max / allowed as the gateway can use it, or None (none set, or a
1265
+ stored one that can't be used: the edits refuse such values, see
1266
+ ModbusPoll.validate_input)."""
1267
+ value = getattr(point, key)
1268
+ if key == "allowed":
1269
+ coil = point.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
1270
+ ok = isinstance(value, list) and value and all(
1271
+ isinstance(v, bool) if coil else _finite(v) for v in value)
1272
+ return value if ok else None
1273
+ return value if _finite(value) and point.fn not in (
1274
+ modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS) else None
1275
+
1276
+
1277
+ def _finite(v) -> bool:
1278
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
1279
+
1280
+
1281
+ def _num(v) -> str:
1282
+ return f"{v:g}" if isinstance(v, float) else str(v)
1283
+
1284
+
1285
+ def check_limits(point, value) -> None:
1286
+ """A value MQTT writes to ``point``, against the point's allowed / min / max (in its own
1287
+ units, before the scale is undone). Raises ValueError."""
1288
+ allowed = _limit(point, "allowed")
1289
+ if allowed is not None and not any(
1290
+ (value is v) if isinstance(v, bool) else (not isinstance(value, bool) and value == v)
1291
+ for v in allowed):
1292
+ listed = ", ".join(_num(v) if not isinstance(v, bool) else str(v).lower()
1293
+ for v in allowed[:8]) + (", …" if len(allowed) > 8 else "")
1294
+ shown_value = str(value).lower() if isinstance(value, bool) else _num(value)
1295
+ raise ValueError(f"{shown_value} is not one of the values allowed for "
1296
+ f"{point.name!r} ({listed})")
1297
+ if isinstance(value, bool):
1298
+ return
1299
+ low, high = _limit(point, "min"), _limit(point, "max")
1300
+ if low is not None and high is not None and not low <= value <= high:
1301
+ raise ValueError(f"{_num(value)} is outside the range {_num(low)}..{_num(high)} of "
1302
+ f"{point.name!r}")
1303
+ if low is not None and value < low:
1304
+ raise ValueError(f"{_num(value)} is below the minimum {_num(low)} of {point.name!r}")
1305
+ if high is not None and value > high:
1306
+ raise ValueError(f"{_num(value)} is above the maximum {_num(high)} of {point.name!r}")
1307
+
1308
+
1309
+ def _raw_text(value, regs: list[int], point) -> str:
1310
+ """For the log: the raw register value written, when it is not the value itself (an
1311
+ integer type rounds a fraction, a scale divides it)."""
1312
+ raw = modbus.decode_value(b"".join(r.to_bytes(2, "big") for r in regs), point.dtype,
1313
+ order=point_order(point))
1314
+ scale = point_scale(point)
1315
+ scaled = bool(scale) and scale != 1.0
1316
+ if not scaled and (point.dtype in ("float32", "float64") or raw == value):
1317
+ return ""
1318
+ return f", raw value {_num(raw)}"
1319
+
1320
+
1321
+ def _registers(p) -> int:
1322
+ """How many registers a register point (fn 3, 4) reads: its string's length, one for a
1323
+ bit of a register, else its type's. ValueError for a stored type the edits refuse."""
1324
+ if p.dtype == "string":
1325
+ return p.length
1326
+ if p.dtype == "bool":
1327
+ return 1
1328
+ return modbus.dtype_registers(p.dtype)
1329
+
1330
+
1331
+ def plan_blocks(points, gap: int) -> list[_Block]:
1332
+ """Block reads: a unit's (polled) points as the requests that read them — per function
1333
+ code, in address order, a point joins the block before it when at most ``gap`` unused
1334
+ registers (coils, inputs) lie between them and the block stays within one read (125
1335
+ registers, 2000 bits); points may overlap. A point whose address or size can't be used
1336
+ (a stored value the edits refuse) is a block of its own, read alone as before."""
1337
+ blocks: list[_Block] = []
1338
+ by_fn: dict[Any, list] = {}
1339
+ for p in points:
1340
+ by_fn.setdefault(p.fn, []).append(p)
1341
+ for fn, pts in by_fn.items():
1342
+ bits = fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
1343
+ cap = modbus.MAX_READ_BITS if bits else modbus.MAX_READ_REGISTERS
1344
+ usable = []
1345
+ for p in pts:
1346
+ try:
1347
+ size = 1 if bits else _registers(p)
1348
+ ok = (isinstance(p.address, int) and not isinstance(p.address, bool)
1349
+ and 0 <= p.address and p.address + size <= 0x10000
1350
+ and isinstance(size, int) and 1 <= size <= cap)
1351
+ except (TypeError, ValueError):
1352
+ ok = False
1353
+ if ok:
1354
+ usable.append((p.address, size, p))
1355
+ else:
1356
+ blocks.append(_Block(fn, 0, 0, [p], single=True))
1357
+ current: Optional[_Block] = None
1358
+ for address, size, p in sorted(usable, key=lambda t: t[0]):
1359
+ end = address + size
1360
+ if (current is not None and address <= current.start + current.count + gap
1361
+ and max(current.count, end - current.start) <= cap):
1362
+ current.count = max(current.count, end - current.start)
1363
+ current.points.append(p)
1364
+ else:
1365
+ current = _Block(fn, address, size, [p])
1366
+ blocks.append(current)
1367
+ return blocks
1368
+
1369
+
1370
+ def decode_point(p, data: bytes, offset: int = 0):
1371
+ """A register point's value from read data (response_data), its registers starting at
1372
+ ``offset`` registers in: a bit of a register, a string, or a number as the point's type,
1373
+ word order and scale."""
1374
+ data = data[offset * 2:]
1375
+ if p.dtype == "bool":
1376
+ return modbus.register_bit(data, p.bit)
1377
+ if p.dtype == "string":
1378
+ if len(data) < p.length * 2:
1379
+ raise ValueError("not enough data for the string")
1380
+ return modbus.decode_string(data[:p.length * 2], point_order(p))
1381
+ return modbus.decode_value(data, p.dtype, point_scale(p), point_order(p))
1382
+
1383
+
1384
+ def _units_text(units: list[int]) -> str:
1385
+ return (f"unit {units[0]}" if len(units) == 1
1386
+ else "units " + ", ".join(str(u) for u in units))
1387
+
1388
+
1389
+ def _names(names: list[str]) -> str:
1390
+ return ", ".join(repr(n) for n in names)
1391
+
1392
+
1393
+ # a holding register's value: a decimal integer, 0x hex, or a decimal fraction / exponent
1394
+ _NUMBER = re.compile(r"[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
1395
+ _NUMBER_MAX_LEN = 64
1396
+
1397
+
1398
+ def parse_write_payload(payload: bytes, point):
1399
+ """The value of a plain-MQTT write to ``point``: one coil state (1/0, true/false or
1400
+ on/off), or one number for a holding register (decimal, 0x hex, or a decimal fraction
1401
+ or exponent; write_point encodes it as the point's type). Anything else, a JSON list
1402
+ included, raises ValueError: a command writes the point it was sent to, nothing more."""
1403
+ text = payload.decode("utf-8", "replace").strip()
1404
+ if point.fn == modbus.READ_COILS:
1405
+ return _coil(text)
1406
+ if len(text) > _NUMBER_MAX_LEN or not _NUMBER.fullmatch(text):
1407
+ raise ValueError(f"not a number: {shown(text)}")
1408
+ if "x" in text or "X" in text:
1409
+ return int(text, 16)
1410
+ return float(text) if any(c in text for c in ".eE") else int(text)
1411
+
1412
+
1413
+ def _coil(text: str) -> bool:
1414
+ t = text.lower()
1415
+ if t in ("1", "true", "on"):
1416
+ return True
1417
+ if t in ("0", "false", "off"):
1418
+ return False
1419
+ raise ValueError(f"a coil takes one state: 1/0, true/false or on/off, not {shown(text)}")
1420
+
1421
+
1422
+ _ID_MAX_LEN = 64
1423
+
1424
+
1425
+ def parse_write_request(payload: bytes, point, request: dict):
1426
+ """The value of a plain-MQTT write sent as JSON, {"value": …, "id": …}: one coil state
1427
+ (true / false, or 1 / 0) or one JSON number; "id" (optional: a string of at most 64
1428
+ characters or an integer) is put into ``request`` as soon as it is read, so that even
1429
+ the answer to a request refused here carries it. Raises ValueError."""
1430
+ def no_constant(name: str):
1431
+ raise ValueError(f"{name} is not a number")
1432
+ try:
1433
+ doc = json.loads(payload.decode("utf-8"), parse_constant=no_constant)
1434
+ except (ValueError, RecursionError): # (UnicodeDecodeError too; and deep nesting)
1435
+ raise ValueError(f"not a JSON request: {shown(payload)}") from None
1436
+ if not isinstance(doc, dict):
1437
+ raise ValueError('a JSON request is an object: {"value": …, "id": …}')
1438
+ if "id" in doc:
1439
+ rid = doc["id"]
1440
+ if not ((isinstance(rid, str) and len(rid) <= _ID_MAX_LEN)
1441
+ or (isinstance(rid, int) and not isinstance(rid, bool)
1442
+ and abs(rid) < 1 << 63)):
1443
+ raise ValueError(f"the id must be a string of at most {_ID_MAX_LEN} characters "
1444
+ f"or an integer, not {shown(rid)}")
1445
+ request["id"] = rid
1446
+ unknown = sorted(str(k) for k in doc if k not in ("value", "id"))
1447
+ if unknown:
1448
+ raise ValueError(f"unknown key(s) in the request: {shown(', '.join(unknown))}")
1449
+ if "value" not in doc:
1450
+ raise ValueError('the request has no "value"')
1451
+ value = doc["value"]
1452
+ if point.fn == modbus.READ_COILS:
1453
+ if isinstance(value, bool):
1454
+ return value
1455
+ if isinstance(value, int) and value in (0, 1):
1456
+ return value == 1
1457
+ raise ValueError(f"a coil takes true / false or 1 / 0, not {shown(value)}")
1458
+ return value # write_point takes a number (and refuses anything else)
1459
+
1460
+
1461
+ def _json_value(value):
1462
+ """A value read back, for a JSON answer: a NaN or infinite reading is null."""
1463
+ if isinstance(value, float) and not math.isfinite(value):
1464
+ return None
1465
+ return value