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,1928 @@
1
+ """Per-mapping bridge runner: TCP listener + shared serial endpoint + clients.
2
+
3
+ One ``MappingRunner`` per configured mapping owns:
4
+ - an asyncio TCP server bound to the mapping's IP:port,
5
+ - a supervised serial endpoint (serial_link.SerialLink: opened and kept open,
6
+ auto-reconnecting),
7
+ - a set of connected ``_ClientConn`` objects.
8
+
9
+ Data model: a single serial port is shared by up to ``max_connections`` TCP
10
+ clients. Bytes read from serial are broadcast to every client (each through its
11
+ own protocol session); bytes from any client are written to the shared serial
12
+ port. Each client has its OWN bounded outbound queue + writer task, so a slow
13
+ client cannot stall serial reads for the others (per-client backpressure
14
+ isolation). Network->serial backpressure is handled by awaiting the serial
15
+ writer's drain() before reading more from that client.
16
+
17
+ Every TCP data socket (accepted or connect-out) gets TCP keepalive (sockopts.py), so
18
+ a peer that vanished without a FIN/RST is detected and its slot freed instead of
19
+ staying ESTABLISHED forever; each disconnect is logged with its reason.
20
+
21
+ A UDP mapping can't slow a sender down: its datagrams wait in a bounded queue
22
+ (UDP_BACKLOG_MAX) that is written to the port the same way, and a datagram that finds it
23
+ full is dropped and counted. The device's output goes to the last allowed sender, or
24
+ always to a configured reply address.
25
+
26
+ Traffic counters (RunnerStatus) count at the device, once: bytes_in what was read from
27
+ it, bytes_out what was handed to it, bytes_dropped what the network sent for it that
28
+ was not written (the port was not open, or the UDP queue was full).
29
+
30
+ What a telnet / RFC 2217 client sends is carried out in order (ProtocolSession.
31
+ from_network_parts): its data, a serial BREAK it asks for (IAC BRK) and the commands that
32
+ act on the port (RFC 2217 SET-* / PURGE-DATA), each after the data before it was written
33
+ and, for a BREAK or a setting, has left the port (at most SETTLE_MAX_S). A BREAK holds the
34
+ line in the break condition for BREAK_S without blocking the event loop (pyserial's
35
+ send_break() sleeps in the calling thread); a read-only mapping sends none.
36
+
37
+ With MQTT, the device's lines are published; with mqtt.tx_enabled each message on
38
+ <base_topic>/tx is written to the port as it is, in one piece between what TCP clients
39
+ send, from a queue of at most MQTT_TX_MAX_BYTES, and answered on <base_topic>/tx/result.
40
+ options.poll_string is sent every poll_interval_s while the port is open (by default not
41
+ while a TCP client is connected).
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import concurrent.futures
47
+ import contextlib
48
+ import errno
49
+ import io
50
+ import ipaddress
51
+ import math
52
+ import os
53
+ import queue
54
+ import re
55
+ import socket
56
+ import sys
57
+ import threading
58
+ import time
59
+ from dataclasses import dataclass, field
60
+ from typing import TYPE_CHECKING, Optional
61
+
62
+ # asyncio.TaskGroup and exception groups are native on Python 3.11+. On 3.10 we
63
+ # use the pure-Python backports (bundled in vendor/wheels: taskgroup +
64
+ # exceptiongroup), which preserve TaskGroup semantics exactly. BaseExceptionGroup
65
+ # is a builtin on 3.11+, so it is only imported on the backport path.
66
+ if sys.version_info >= (3, 11):
67
+ from asyncio import TaskGroup
68
+ else: # pragma: no cover - exercised only on Python 3.10
69
+ from taskgroup import TaskGroup
70
+ from exceptiongroup import BaseExceptionGroup
71
+
72
+ from .. import i18n, storage
73
+ from ..config import (
74
+ MQTT_TX_MAX_BYTES, MappingConfig, canonical_ip, host_port, mapping_file, mqtt_topic_problem,
75
+ poll_bytes, udp_peer_address, udp_peer_timeout, udp_targets,
76
+ )
77
+ from . import netguard, portdiag, serial_io, sockopts, tls
78
+ from .netguard import LogEvery as _LogEvery
79
+ from .access import AccessPolicy, connection_order, server_ssl
80
+ from .protocols import make_session
81
+ from .protocols.base import BREAK, ProtocolError, ProtocolSession
82
+ from .serial_link import (
83
+ Backoff, FailureLog, NoteLog, SerialLink, close_stream, explain, fmt_duration, sent_within,
84
+ )
85
+
86
+ if TYPE_CHECKING:
87
+ from .mqtt_pub import MqttPublisher
88
+
89
+ READ_CHUNK = 4096
90
+ # directions for traffic monitors (the live hex view): bytes read from the serial
91
+ # device / bytes written to it (serial<->serial bridge: port A -> B / B -> A)
92
+ FROM_DEVICE = 0
93
+ TO_DEVICE = 1
94
+ CLIENT_QUEUE_MAX = 2048 # outbound chunks buffered per client before it's dropped
95
+ # UDP can't make a sender wait, so a UDP mapping's datagrams wait for its serial port in a
96
+ # queue of at most this many bytes, written like a TCP client's data (drain() keeps the
97
+ # port's own buffer at its high-water mark). A datagram that would go beyond it is dropped
98
+ # and counted (bytes_dropped): a sender faster than the line costs neither memory nor
99
+ # event-loop time. 64 KiB is that high-water mark too, about 68 s of line time at 9600
100
+ # baud and 6 s at 115200; the largest datagram fits.
101
+ UDP_BACKLOG_MAX = 64 * 1024
102
+ # a client connection's graceful close (and the flush before a closeon close) gets this
103
+ # long; a peer that keeps the connection up but reads nothing is then cut off, instead of
104
+ # holding one of the mapping's connection slots for as long as TCP takes to give up
105
+ TCP_CLOSE_TIMEOUT_S = 5.0
106
+ # a serial BREAK (telnet IAC BRK, the web console): the line is held in the break condition
107
+ # this long, as pyserial's send_break() and most terminal programs do
108
+ BREAK_S = 0.25
109
+ # before a BREAK or an RFC 2217 setting change, the data the client sent ahead of it gets
110
+ # up to this long to leave the port (its line time, twice, plus 0.2 s): a port that holds
111
+ # its output (flow control, a driver that never reports an empty queue) delays the command
112
+ # no more than this, since an RFC 2217 client waits about 3 s for its answer
113
+ SETTLE_MAX_S = 2.0
114
+ # errno/winerror values for "the peer stopped answering" (keepalive or retransmission
115
+ # timeout): ETIMEDOUT, plus on Windows WSAETIMEDOUT, WSAENETRESET and the overlapped-I/O
116
+ # ERROR_SEM_TIMEOUT.
117
+ _PEER_TIMEOUT_ERRNOS = {errno.ETIMEDOUT, 10060, 10052, 121}
118
+
119
+
120
+ class _Disconnect(ConnectionError):
121
+ """Ends a client session for a known reason (the message is logged)."""
122
+
123
+
124
+ async def _close_client(writer: asyncio.StreamWriter) -> None:
125
+ """Close a client's connection gracefully (what is queued for it goes out first), and
126
+ abort it if that takes longer than TCP_CLOSE_TIMEOUT_S."""
127
+ with contextlib.suppress(Exception):
128
+ writer.close()
129
+ closed = asyncio.ensure_future(writer.wait_closed())
130
+ closed.add_done_callback(lambda f: f.cancelled() or f.exception())
131
+ # asyncio.wait, not wait_for: the timeout must not cancel the stream's close future
132
+ done, _pending = await asyncio.wait((closed,), timeout=TCP_CLOSE_TIMEOUT_S)
133
+ if not done:
134
+ with contextlib.suppress(Exception):
135
+ writer.transport.abort()
136
+
137
+
138
+ def describe_disconnect(exc: BaseException) -> str:
139
+ """Short, operator-facing reason a TCP client connection ended."""
140
+ if isinstance(exc, (_Disconnect, ProtocolError)):
141
+ return str(exc)
142
+ code = getattr(exc, "winerror", None) or getattr(exc, "errno", None)
143
+ if code in _PEER_TIMEOUT_ERRNOS:
144
+ return "peer stopped responding (TCP timeout: unreachable, or not reading its data)"
145
+ if isinstance(exc, ConnectionResetError):
146
+ # Windows reports a failed keepalive as WSAECONNRESET too, so don't blame the peer
147
+ if sys.platform == "win32":
148
+ return "connection reset (by the peer, or TCP keepalive found it unreachable)"
149
+ return "connection reset by peer"
150
+ if isinstance(exc, asyncio.IncompleteReadError):
151
+ return "closed by peer"
152
+ return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
153
+
154
+
155
+ def _iter_leaf_exceptions(eg: BaseExceptionGroup):
156
+ """Yield the individual (non-group) exceptions from a possibly-nested group."""
157
+ for exc in eg.exceptions:
158
+ if isinstance(exc, BaseExceptionGroup):
159
+ yield from _iter_leaf_exceptions(exc)
160
+ else:
161
+ yield exc
162
+
163
+
164
+ def trace_path(trace_dir: str, mapping: MappingConfig) -> str:
165
+ """Where a mapping's trace goes: <trace_dir>/<mapping id>-<file name>. The id
166
+ prefix keeps two mappings with the same file name apart (and makes a mapping's
167
+ files easy to find); the name is already sanitised by config.trace_file_name.
168
+ Raises ValueError if that would not be a file in trace_dir (config.mapping_file)."""
169
+ return mapping_file(trace_dir, mapping.id, f"-{mapping.options.trace_both}")
170
+
171
+
172
+ def open_tracer(mapping: MappingConfig, trace_dir: Optional[str], log,
173
+ status: Optional["RunnerStatus"] = None) -> Optional["Tracer"]:
174
+ """The mapping's tracer when tracing is on (None when off or unavailable). `status`:
175
+ the runner's, whose trace_error the tracer keeps up to date."""
176
+ if not mapping.options.trace_both:
177
+ return None
178
+ if not trace_dir:
179
+ log("trace not written: no data directory to keep it in")
180
+ return None
181
+ try:
182
+ path = trace_path(trace_dir, mapping)
183
+ except ValueError as e:
184
+ log(f"trace not written: {e}")
185
+ return None
186
+ tracer = Tracer(path, mapping.options, log=log, status=status)
187
+ if not tracer.error: # else it said why, and keeps trying
188
+ log(f"tracing traffic to {tracer.path}")
189
+ return tracer
190
+
191
+
192
+ def remove_files(paths) -> dict[str, Optional[OSError]]:
193
+ """Delete these files: path -> None once it is gone (also when it was gone already),
194
+ else the OSError (e.g. Windows: the file is open for a download)."""
195
+ out: dict[str, Optional[OSError]] = {}
196
+ for path in paths:
197
+ try:
198
+ os.remove(path)
199
+ out[path] = None
200
+ except FileNotFoundError:
201
+ out[path] = None
202
+ except OSError as e:
203
+ out[path] = e
204
+ return out
205
+
206
+
207
+ class _Job:
208
+ """Something a Tracer's writer thread does in turn with the lines queued before it."""
209
+
210
+ def __init__(self, fn) -> None:
211
+ self.fn = fn
212
+ self.future: concurrent.futures.Future = concurrent.futures.Future()
213
+
214
+ def run(self) -> None:
215
+ try:
216
+ self.future.set_result(self.fn())
217
+ except BaseException as e: # for the waiter
218
+ self.future.set_exception(e)
219
+
220
+
221
+ class Tracer:
222
+ """Append a mapping's traffic to its trace file (optional hex dump + timestamp)
223
+ without blocking the event loop.
224
+
225
+ ``write()`` only queues the chunk (with its arrival time); a writer thread formats,
226
+ writes and flushes in batches and rotates the file once it passes ``max_bytes``
227
+ (<file>.1 ... <file>.<keep>; keep=0 begins a new file; rotated files past keep, left
228
+ when it was lowered, are deleted when the trace starts). If the disk can't keep
229
+ up, at most QUEUE_MAX_BYTES wait in memory: newer chunks are dropped and the
230
+ trace says how many bytes are missing.
231
+
232
+ When the file can't be opened or written (the traces folder was removed, the disk is
233
+ full, no file descriptor is left, another program holds the file), the trace pauses
234
+ instead of ending: `error` says why (also the runner's status.trace_error), write()
235
+ only counts the bytes, the writer tries again every RETRY_S seconds (making the folder
236
+ again), and once it writes again the trace says how many bytes are missing. The log
237
+ says so at most once per LOG_EVERY_S. So does a disk almost full: below
238
+ storage.floor() free space (checked at each open and after every SPACE_CHECK_BYTES
239
+ written) the trace pauses before the disk fills up, and goes on once there is 10%
240
+ more again."""
241
+
242
+ QUEUE_MAX_BYTES = 8 * 1024 * 1024
243
+ BATCH = 256
244
+ RETRY_S = 5.0
245
+ LOG_EVERY_S = 600.0
246
+ SPACE_CHECK_BYTES = 8 * 1024 * 1024
247
+
248
+ def __init__(self, path: str, options, log=None,
249
+ status: Optional["RunnerStatus"] = None) -> None:
250
+ self.path = path
251
+ self._hex = options.trace_hexdump
252
+ self._ts = options.trace_timestamp
253
+ self._max_bytes = max(1, int(options.trace_max_mb * 1024 * 1024))
254
+ self._keep = int(options.trace_keep)
255
+ self._log = log or (lambda _m: None)
256
+ self._log_failure = _LogEvery(self._log, self.LOG_EVERY_S)
257
+ self._log_rotation = _LogEvery(self._log, self.LOG_EVERY_S)
258
+ self._log_resumed = _LogEvery(self._log, self.LOG_EVERY_S)
259
+ self._status = status
260
+ self._q: queue.SimpleQueue = queue.SimpleQueue()
261
+ self._lock = threading.Lock()
262
+ self._queued = 0 # bytes waiting for the writer
263
+ self._dropped = 0 # bytes dropped since the last line written
264
+ self._drop_reason = ""
265
+ self._closing = False # aclose() was called: nothing more is queued
266
+ self._done = False # the writer has ended (remove_files then returns None)
267
+ self._resumed = False # opened again after an error: logged once it writes
268
+ self._unchecked = 0 # bytes written since the free space was checked
269
+ self._begun: Optional[float] = None # when the traffic in the file begins (None: empty)
270
+ self._low_space = False # paused for the free space (goes on with some to spare)
271
+ self.error = "" # why the trace is not written now ("" while it is)
272
+ self._fh: Optional[io.BufferedWriter] = None
273
+ try:
274
+ self._reopen()
275
+ except OSError as e:
276
+ self._failed(e)
277
+ self._thread = threading.Thread(target=self._run, daemon=True,
278
+ name=f"trace:{os.path.basename(path)}")
279
+ self._thread.start()
280
+
281
+ @property
282
+ def active(self) -> bool:
283
+ """The trace is on (it may be paused, see `error`), not closed."""
284
+ return not self._closing
285
+
286
+ def write(self, direction: str, data: bytes) -> None:
287
+ if not data or self._closing:
288
+ return
289
+ with self._lock:
290
+ if self.error: # paused: only counted (the trace says so once it goes on)
291
+ self._dropped += len(data)
292
+ return
293
+ if self._queued + len(data) > self.QUEUE_MAX_BYTES:
294
+ self._dropped += len(data)
295
+ self._drop_reason = "the trace file could not keep up"
296
+ return
297
+ self._queued += len(data)
298
+ self._q.put((time.time(), direction, bytes(data)))
299
+
300
+ def remove_files(self, paths) -> Optional[concurrent.futures.Future]:
301
+ """Delete some of this trace's files in the writer thread, in turn with the lines
302
+ queued before (so never in the middle of a rotation). The file it writes is
303
+ closed first and begun again with the next traffic: a deleted trace is never
304
+ written on (on POSIX an open file that was deleted keeps its disk space, on
305
+ Windows it can't be deleted). The future's result: as remove_files(). None once
306
+ the writer has ended: they are files like any other then."""
307
+ job = _Job(lambda: self._remove(paths))
308
+ with self._lock:
309
+ if self._done:
310
+ return None
311
+ self._q.put(job)
312
+ return job.future
313
+
314
+ def _prune_rotated(self) -> None:
315
+ """Delete this trace's rotated files past the number kept (<file>.N, N > keep):
316
+ left when keep was lowered, no rotation would ever reach them. Logged."""
317
+ folder, base = os.path.split(os.path.abspath(self.path))
318
+ try:
319
+ names = os.listdir(folder)
320
+ except OSError:
321
+ return
322
+ extra = []
323
+ for name in names:
324
+ number = name[len(base) + 1:] if name.startswith(base + ".") else ""
325
+ if number.isascii() and number.isdigit() and int(number) > self._keep:
326
+ extra.append(os.path.join(folder, name))
327
+ gone = [p for p, e in remove_files(extra).items() if e is None]
328
+ if gone:
329
+ self._log(f"trace: deleted {len(gone)} rotated file(s) of {self.path} past the "
330
+ f"{self._keep} kept")
331
+
332
+ def renew_if_older(self, begun_before: float) -> Optional[concurrent.futures.Future]:
333
+ """Have the writer begin a new file (rotating this one, as at its size) if the file
334
+ it writes holds traffic from before `begun_before`, in turn with the lines queued
335
+ before: the retention by age (trace_max_age_days) can then delete it. The future's
336
+ result: whether it did. None once the writer has ended."""
337
+ job = _Job(lambda: self._renew(begun_before))
338
+ with self._lock:
339
+ if self._done:
340
+ return None
341
+ self._q.put(job)
342
+ return job.future
343
+
344
+ def _renew(self, begun_before: float) -> bool:
345
+ if self._fh is None or self._begun is None or self._begun >= begun_before:
346
+ return False
347
+ self._rotate()
348
+ return True
349
+
350
+ def _remove(self, paths) -> dict[str, Optional[OSError]]:
351
+ mine = os.path.normcase(os.path.abspath(self.path))
352
+ if any(os.path.normcase(os.path.abspath(p)) == mine for p in paths):
353
+ self._close_file()
354
+ return remove_files(paths)
355
+
356
+ def _format(self, ts: float, direction: str, data: bytes) -> bytes:
357
+ prefix = time.strftime("%Y-%m-%d %H:%M:%S ", time.localtime(ts)) if self._ts else ""
358
+ if self._hex:
359
+ return f"{prefix}{direction} [{len(data)}] {data.hex(' ')}\n".encode()
360
+ return prefix.encode() + direction.encode() + b" " + data + b"\n"
361
+
362
+ def _lines(self, ts: float, direction: str, data: bytes) -> list:
363
+ """The trace lines of one chunk, each with the traffic bytes it stands for: what
364
+ was not traced before it (if anything), then the chunk."""
365
+ with self._lock:
366
+ self._queued -= len(data)
367
+ dropped, self._dropped = self._dropped, 0
368
+ reason = self._drop_reason
369
+ lines = []
370
+ if dropped:
371
+ lines.append((self._format(ts, "!!", f"{dropped} bytes not traced "
372
+ f"({reason})".encode()), dropped))
373
+ lines.append((self._format(ts, direction, data), len(data)))
374
+ return lines
375
+
376
+ def _run(self) -> None:
377
+ self._prune_rotated()
378
+ stop = False
379
+ while not stop:
380
+ try: # paused: wake up to try the file again
381
+ first = self._q.get(timeout=self.RETRY_S) if self.error else self._q.get()
382
+ except queue.Empty:
383
+ try:
384
+ self._reopen()
385
+ except OSError as e:
386
+ self._failed(e)
387
+ continue
388
+ batch = [first]
389
+ while len(batch) < self.BATCH:
390
+ try:
391
+ batch.append(self._q.get_nowait())
392
+ except queue.Empty:
393
+ break
394
+ out: list = []
395
+ try:
396
+ for item in batch:
397
+ if item is None:
398
+ stop = True
399
+ elif isinstance(item, _Job):
400
+ self._append(out)
401
+ out = []
402
+ item.run()
403
+ else:
404
+ out.extend(self._lines(*item))
405
+ self._append(out)
406
+ except Exception as e: # never end the writer: the trace would stop unnoticed
407
+ self._failed(e)
408
+ with self._lock:
409
+ self._done = True
410
+ self._close_file()
411
+ self._set_error("")
412
+ while True: # a job queued as the writer ended: the file is closed now
413
+ try:
414
+ item = self._q.get_nowait()
415
+ except queue.Empty:
416
+ break
417
+ if isinstance(item, _Job):
418
+ item.run()
419
+
420
+ def _append(self, lines: list) -> None:
421
+ """Write a batch of lines; rotate as soon as the file reaches its size. When that
422
+ fails, the trace pauses and the lines not written are counted as not traced."""
423
+ if not lines:
424
+ return
425
+ done = 0 # lines written
426
+ try:
427
+ fh = self._fh if self._fh is not None else self._reopen()
428
+ size = fh.tell()
429
+ start = 0
430
+ for i, (line, _n) in enumerate(lines):
431
+ size += len(line)
432
+ if size >= self._max_bytes:
433
+ self._write(lines[start:i + 1])
434
+ done = start = i + 1
435
+ self._rotate()
436
+ size = 0
437
+ if start < len(lines):
438
+ self._write(lines[start:])
439
+ done = len(lines)
440
+ except Exception as e: # OSError mostly; ValueError for a file closed under it
441
+ self._failed(e, lines[done:])
442
+ return
443
+ if self._resumed:
444
+ self._resumed = False
445
+ self._log_resumed(f"trace written again ({self.path})")
446
+
447
+ def _write(self, lines: list) -> None:
448
+ if self._fh is None:
449
+ raise OSError(f"{self.path} is not open")
450
+ data = b"".join(line for line, _n in lines)
451
+ if self._begun is None:
452
+ self._begun = time.time()
453
+ self._unchecked += len(data)
454
+ if self._unchecked >= self.SPACE_CHECK_BYTES:
455
+ self._unchecked = 0
456
+ storage.check_space(os.path.dirname(os.path.abspath(self.path)))
457
+ self._fh.write(data)
458
+ self._fh.flush()
459
+
460
+ def _rotate(self) -> None:
461
+ """Move the file to <file>.1 (the older ones up), or with keep=0 delete it, and
462
+ begin a new one. Never cut in place, so a download still reading the file gets
463
+ all of it (POSIX; on Windows a file open for a download can't be renamed or
464
+ deleted: the trace goes on in the same file until it can). Raises OSError when
465
+ the new file can't be opened."""
466
+ fh, self._fh = self._fh, None
467
+ if fh is not None:
468
+ fh.close()
469
+ try:
470
+ if self._keep > 0:
471
+ for i in range(self._keep, 0, -1):
472
+ src = self.path if i == 1 else f"{self.path}.{i - 1}"
473
+ if os.path.exists(src):
474
+ os.replace(src, f"{self.path}.{i}")
475
+ else:
476
+ with contextlib.suppress(FileNotFoundError):
477
+ os.remove(self.path)
478
+ except OSError as e: # e.g. Windows: a rotated file is open for download
479
+ self._log_rotation(f"trace rotation failed ({self.path}): {e}; "
480
+ "continuing in the same file")
481
+ self._reopen()
482
+
483
+ def _reopen(self) -> io.BufferedWriter:
484
+ """Open the file to append, making its folder again (it may have been removed).
485
+ Raises OSError (storage.LowSpace: the disk is almost full)."""
486
+ folder = os.path.dirname(os.path.abspath(self.path))
487
+ os.makedirs(folder, mode=0o700, exist_ok=True)
488
+ storage.check_space(folder, resuming=self._low_space)
489
+ self._fh = open(self.path, "ab")
490
+ self._unchecked, self._low_space = 0, False
491
+ self._begun = self._first_time() if self._fh.tell() else None
492
+ if self.error:
493
+ self._resumed = True
494
+ self._set_error("")
495
+ return self._fh
496
+
497
+ def _first_time(self) -> float:
498
+ """When the traffic in the (not empty) file begins: the time of its first line
499
+ (trace_timestamp), else when it was last written (no earlier time is known)."""
500
+ try:
501
+ with open(self.path, "rb") as fh:
502
+ head = fh.read(19).decode("ascii")
503
+ return time.mktime(time.strptime(head, "%Y-%m-%d %H:%M:%S"))
504
+ except (OSError, ValueError, OverflowError):
505
+ try:
506
+ return os.path.getmtime(self.path)
507
+ except OSError:
508
+ return time.time()
509
+
510
+ def _close_file(self) -> None:
511
+ fh, self._fh = self._fh, None
512
+ if fh is not None:
513
+ with contextlib.suppress(Exception):
514
+ fh.close()
515
+
516
+ def _set_error(self, text: str) -> None:
517
+ with self._lock:
518
+ self.error = text
519
+ if self._status is not None:
520
+ self._status.trace_error = text
521
+
522
+ def _failed(self, e: BaseException, lost=()) -> None:
523
+ """Opening or writing the file failed: close it, count the lines `lost` as not
524
+ traced and pause until the file can be opened again."""
525
+ self._close_file()
526
+ reason = (e.strerror if isinstance(e, OSError) and e.strerror else str(e)) \
527
+ or type(e).__name__
528
+ self._low_space = isinstance(e, storage.LowSpace)
529
+ with self._lock:
530
+ self._dropped += sum(n for _line, n in lost)
531
+ self._drop_reason = ("the disk was almost full" if self._low_space else
532
+ "the trace file could not be written")
533
+ self._set_error(reason)
534
+ self._log_failure(f"trace not written ({self.path}): {reason}; trying again every "
535
+ f"{self.RETRY_S:g} s (the trace will say how much is missing)")
536
+
537
+ async def aclose(self) -> None:
538
+ """Flush what is queued, close the file. Waits for the writer off the loop."""
539
+ if self._closing:
540
+ return
541
+ self._closing = True
542
+ self._q.put(None)
543
+ await asyncio.to_thread(self._thread.join, 10)
544
+
545
+
546
+ @dataclass
547
+ class RunnerStatus:
548
+ state: str = "stopped" # stopped|running|reconnecting|device-missing|error
549
+ device: str = ""
550
+ client_count: int = 0
551
+ # connected peers, in the order they connected: each {"peer": "ip:port",
552
+ # "connected_at": time.monotonic() then, "priority": bool}
553
+ clients: list[dict] = field(default_factory=list)
554
+ # counted at the device, once (not per client, no telnet/RFC2217 control bytes);
555
+ # serial<->serial bridge: in = port A -> B, out = port B -> A
556
+ bytes_in: int = 0 # read from the serial device
557
+ bytes_out: int = 0 # handed to the serial device (from clients, datagrams, the console)
558
+ bytes_dropped: int = 0 # sent for the device but not written: the port was not open,
559
+ # or a UDP datagram found UDP_BACKLOG_MAX bytes waiting
560
+ reconnects: int = 0 # serial port: attempts to open it again (failed opens + losses)
561
+ open_failures: int = 0 # of which: opens that failed
562
+ device_lost: int = 0 # times the open port went away
563
+ hangups: int = 0 # of those: hung up and reopened at once (SerialLink)
564
+ dropped_clients: int = 0 # clients dropped because their output queue overflowed
565
+ queue_overflows: int = 0 # total serial->net chunks lost to queue overflow
566
+ last_error: str = ""
567
+ # state "error" because the listener could not take its address: the supervisor tries
568
+ # again at this time.monotonic() (0: no retry pending); as_dict() shows it as
569
+ # retrying / next_retry_s
570
+ retry_at: float = 0.0
571
+ # a connect-out mapping's connection to its remote server ("" for any other mapping,
572
+ # and while stopped): "connecting" (no attempt has failed since the last one that
573
+ # worked, or the link was just closed), "up", or "down" (the last attempt failed:
574
+ # link_error says why). `state` keeps reporting the serial side.
575
+ link: str = ""
576
+ link_error: str = ""
577
+ link_since: float = 0.0 # time.time() of the last change of `link`
578
+ connect_failures: int = 0 # connect-out attempts that failed
579
+ # why the serial port can't be opened, when that can be told (portdiag.Note: the group
580
+ # the device needs, the mapping that has it open, ...), or, with state "error", why the
581
+ # system refused the listener's port and what to do (engine/bindhint.py); last_error
582
+ # keeps the error itself
583
+ hint: Optional[portdiag.Note] = None
584
+ # what to know about the serial port(s) open now (portdiag.Note): a setting a port did
585
+ # not take at open (RTS / DTR, RS-485 mode); as_dict() has them (and hint) as text
586
+ warnings: tuple = ()
587
+ trace_error: str = "" # why the mapping's trace is not written now (bridge.Tracer)
588
+ # the serial devices open now, for the Ports page's "in use" (both ports of a
589
+ # serial<->serial bridge, whose `device` reads "A <-> B"); not part of as_dict()
590
+ devices: tuple[str, ...] = ()
591
+ # what the device sends (watch_traffic): time.time() of the last byte seen from it,
592
+ # and, by time.monotonic(), since when it has sent nothing (from the port's opening)
593
+ # and since when what was sent to it has had no answer
594
+ last_rx: float = 0.0
595
+ _seen: tuple[int, int] = (0, 0)
596
+ _quiet_since: Optional[float] = None
597
+ _unanswered_since: Optional[float] = None
598
+
599
+ def set_link(self, link: str, error: str = "") -> None:
600
+ if link != self.link:
601
+ self.link, self.link_since = link, time.time()
602
+ self.link_error = error
603
+ # Modbus gateway in edge mode: each polled unit, {"unit", "state" (unknown | online |
604
+ # offline), "cause" (offline: "no reply" | "port down"), "since" (unix time of the last
605
+ # change, None before one), "errors" (failed point reads)}
606
+ units: list[dict] = field(default_factory=list)
607
+ # ... and each polled point's last read: {"name", "unit", "value" (the last value read,
608
+ # None before one), "ts" (when, ms since the epoch), "error" (why the last read failed;
609
+ # "" when it gave the value)}
610
+ points: list[dict] = field(default_factory=list)
611
+ # the MQTT publisher's connection to its broker while the mapping runs (empty: MQTT
612
+ # off): MqttPublisher.state — "state" (connecting | connected | down | off), "broker",
613
+ # "since", "error", "dropped"
614
+ mqtt: dict = field(default_factory=dict)
615
+
616
+ def set_clients(self, clients) -> None:
617
+ """Publish the connected clients (access.Client: peer, seq, connected_at, priority),
618
+ first connected first."""
619
+ self.client_count = len(clients)
620
+ self.clients = [{"peer": c.peer, "connected_at": c.connected_at, "priority": c.priority}
621
+ for c in sorted(clients, key=lambda c: c.seq)]
622
+
623
+ def watch_traffic(self, now: float) -> None:
624
+ """Compare the counters with the last look (as_dict, every POLL_S at least: the
625
+ notifier's): new bytes from the device end its silence; bytes to it with no reply
626
+ since begin a wait for one. Precise to that interval, which the no-data alarm
627
+ (at least NO_DATA_MIN_S) allows, and nothing is added to the data path."""
628
+ seen, self._seen = self._seen, (self.bytes_in, self.bytes_out)
629
+ if self.state != "running":
630
+ self._quiet_since = self._unanswered_since = None
631
+ return
632
+ if self.bytes_in != seen[0]:
633
+ self.last_rx = time.time()
634
+ self._quiet_since = now
635
+ self._unanswered_since = None
636
+ elif self._quiet_since is None: # the port opened (or a first look): from now
637
+ self._quiet_since = now
638
+ if self.bytes_out != seen[1] and self.bytes_in == seen[0] and \
639
+ self._unanswered_since is None:
640
+ self._unanswered_since = now
641
+
642
+ def as_dict(self) -> dict:
643
+ now = time.monotonic()
644
+ self.watch_traffic(now)
645
+ # connected_at (Unix time, whole seconds): the dashboard counts the duration on from
646
+ # it. Worked out from the monotonic time each time, so it stays the same from one
647
+ # refresh to the next, and a clock step moves it along with the server's clock
648
+ wall = time.time() - now
649
+ clients = [
650
+ {"peer": c["peer"], "duration": fmt_duration(now - c["connected_at"]),
651
+ "connected_s": max(0, int(now - c["connected_at"])), # the same, as a number
652
+ "priority": c.get("priority", False),
653
+ "connected_at": float(round(wall + c["connected_at"]))}
654
+ for c in self.clients
655
+ ]
656
+ return {
657
+ "state": self.state,
658
+ "device": self.device,
659
+ "client_count": self.client_count,
660
+ "clients": clients,
661
+ "bytes_in": self.bytes_in,
662
+ "bytes_out": self.bytes_out,
663
+ "bytes_dropped": self.bytes_dropped,
664
+ "reconnects": self.reconnects,
665
+ "open_failures": self.open_failures,
666
+ "device_lost": self.device_lost,
667
+ "hangups": self.hangups,
668
+ "dropped_clients": self.dropped_clients,
669
+ "queue_overflows": self.queue_overflows,
670
+ "last_error": self.last_error,
671
+ "retrying": bool(self.retry_at),
672
+ "next_retry_s": (max(0, math.ceil(self.retry_at - time.monotonic()))
673
+ if self.retry_at else 0),
674
+ "link": self.link,
675
+ "link_error": self.link_error,
676
+ "link_since": int(self.link_since),
677
+ "connect_failures": self.connect_failures,
678
+ # in the web UI's language on the dashboard (English for the REST API)
679
+ "hint": self.hint.text(i18n.gettext) if self.hint else "",
680
+ "warnings": portdiag.texts(self.warnings, i18n.gettext),
681
+ "trace_error": self.trace_error,
682
+ # the device: when it last sent something (None: not yet), how long it has sent
683
+ # nothing and how long what was sent to it has had no answer (s; None: not
684
+ # running / nothing waits for an answer)
685
+ "last_rx": self.last_rx or None,
686
+ "rx_idle_s": _since(self._quiet_since, now),
687
+ "reply_wait_s": _since(self._unanswered_since, now),
688
+ "units": [dict(u) for u in self.units],
689
+ "points": [dict(p) for p in self.points],
690
+ "mqtt": dict(self.mqtt),
691
+ }
692
+
693
+
694
+ def _since(mark: Optional[float], now: float) -> Optional[float]:
695
+ return None if mark is None else round(now - mark, 1)
696
+
697
+
698
+ def log_ignored_settings(mapping: MappingConfig, log) -> None:
699
+ """At a runner's start: each setting of the mapping that does nothing, in English
700
+ (MappingConfig.ignored_settings; the edit form shows the same)."""
701
+ with i18n.english():
702
+ for text in mapping.ignored_settings():
703
+ log(f"warning: {text}")
704
+ _BANNER_ESCAPE = re.compile(r"\\([\\dNpIrn])")
705
+
706
+
707
+ def _render_banner(template: str, mapping: MappingConfig, device: str, peer: str) -> bytes:
708
+ """The connect banner: \\d the device, \\N the mapping's name, \\p its port, \\I the
709
+ client's IP, \\r \\n a line break, \\\\ a backslash (C:\\\\data stays C:\\data); any other
710
+ backslash is kept. One pass, so what an escape puts in is not read again."""
711
+ values = {"d": device, "N": mapping.name, "p": str(mapping.network.port), "I": peer,
712
+ "r": "\r", "n": "\n", "\\": "\\"}
713
+ return _BANNER_ESCAPE.sub(lambda m: values[m.group(1)], template).encode("utf-8", "replace")
714
+
715
+
716
+ class _ClientConn:
717
+ def __init__(self, runner: "MappingRunner", reader: asyncio.StreamReader,
718
+ writer: asyncio.StreamWriter, peer_ip: str, peer_port: int = 0,
719
+ priority: bool = False):
720
+ self.runner = runner
721
+ self.reader = reader
722
+ self.writer = writer
723
+ self.peer_ip = peer_ip
724
+ self.peer_port = peer_port
725
+ self.peer = netguard.client_label(peer_ip, peer_port)
726
+ self.priority = priority
727
+ self.seq = connection_order()
728
+ self.connected_at = time.monotonic()
729
+ self._queue_max = runner.mapping.network.client_queue_max or CLIENT_QUEUE_MAX
730
+ # None = graceful-close sentinel (see request_graceful_close)
731
+ self._out: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=self._queue_max)
732
+ self._session: Optional[ProtocolSession] = None
733
+ # what the client sends that its protocol session refuses or ignores (malformed
734
+ # RFC 2217 requests, ...): logged at most once a minute per client
735
+ self._protocol_log = _LogEvery(lambda msg: runner._log(f"client {self.peer}: {msg}"))
736
+ self._last_activity = time.monotonic()
737
+ self._closing = False
738
+ self.close_reason = "" # why the session ended (first cause wins; for the log)
739
+ self._unsettled = 0 # bytes written to the port since its last BREAK / setting change
740
+
741
+ @property
742
+ def closing(self) -> bool:
743
+ """Kicked, dropped or ending: no longer one of the mapping's connections (it stays
744
+ in the runner's set until its handler is done)."""
745
+ return self._closing
746
+
747
+ def _log_unexpected(self, exc: BaseException) -> None:
748
+ self.runner._log(f"client {self.peer} pump error: {type(exc).__name__}: {exc}")
749
+
750
+ # called synchronously from the runner's serial read loop (broadcast)
751
+ def feed_from_serial(self, data: bytes) -> None:
752
+ if self._closing or self._session is None:
753
+ return
754
+ try:
755
+ net = self._session.from_serial(data)
756
+ except Exception as e:
757
+ self.runner._log(f"protocol error (from_serial) for {self.peer}: "
758
+ f"{type(e).__name__}: {e}")
759
+ return
760
+ if not net:
761
+ return
762
+ try:
763
+ self._out.put_nowait(net)
764
+ except asyncio.QueueFull:
765
+ # slow client: protect the others by dropping this one. This loses the
766
+ # current chunk for THIS client only — surface it instead of hiding it.
767
+ self._closing = True
768
+ self.close_reason = self.close_reason or "dropped: output queue full (slow client)"
769
+ self.runner.status.queue_overflows += 1
770
+ self.runner.status.dropped_clients += 1
771
+ self.runner._log(f"dropping slow client {self.peer}: output queue full "
772
+ f"({self._queue_max} chunks) — data lost for this client")
773
+ with contextlib.suppress(Exception):
774
+ self.writer.transport.abort()
775
+
776
+ def serial_changed(self, serial) -> None:
777
+ """The runner's serial port was reopened (the new pyserial object) or closed
778
+ (None): an RFC 2217 session follows it. A reopened port that refuses the client's
779
+ settings ends the session (ProtocolError says which)."""
780
+ session = self._session
781
+ if session is None or self._closing:
782
+ return
783
+ try:
784
+ session.serial_changed(serial)
785
+ except ProtocolError as e:
786
+ self.kick(str(e))
787
+ return
788
+ ctrl = session.take_net_out() # e.g. the modem lines as they are now
789
+ if ctrl:
790
+ with contextlib.suppress(asyncio.QueueFull):
791
+ self._out.put_nowait(ctrl)
792
+
793
+ async def run(self) -> None:
794
+ m = self.runner.mapping
795
+ # Create the protocol session. RFC2217 needs the live serial instance.
796
+ ser = self.runner.serial_instance
797
+ if ((m.network.protocol == "rfc2217" and ser is None)
798
+ or (self.runner.drops_clients and not self.runner._link.is_open)):
799
+ # an RFC 2217 session drives the port, so it starts only while the port is open,
800
+ # as does any session with disconnect_without_device (a connect-out link can
801
+ # come up just after the port went away)
802
+ self._closing = True
803
+ self.close_reason = "the serial port is not open"
804
+ await _close_client(self.writer)
805
+ return
806
+ self._session = make_session(m.network.protocol, ser,
807
+ poll_interval=m.options.rfc2217_poll_modem_interval_s,
808
+ read_only=m.network.read_only, log=self._protocol_log,
809
+ signature=m.options.rfc2217_signature)
810
+
811
+ # initial bytes: protocol negotiation, then banner
812
+ init = bytearray(self._session.initial_net_bytes())
813
+ if m.options.banner:
814
+ init += _render_banner(m.options.banner, m, self.runner.status.device, self.peer_ip)
815
+
816
+ try:
817
+ if init: # (a client that resets right after connecting fails this write)
818
+ self.writer.write(bytes(init))
819
+ await self.writer.drain()
820
+ async with TaskGroup() as tg:
821
+ tg.create_task(self._pump_net_to_serial())
822
+ tg.create_task(self._pump_serial_to_net())
823
+ if self._session.poll_interval:
824
+ tg.create_task(self._pump_poll())
825
+ if m.options.idle_timeout_s > 0:
826
+ tg.create_task(self._idle_guard(m.options.idle_timeout_s))
827
+ if m.options.max_connection_time_s > 0:
828
+ tg.create_task(self._lifetime_guard(m.options.max_connection_time_s))
829
+ except (ConnectionError, OSError) as e: # the client left before the pumps started
830
+ if not self.close_reason:
831
+ self.close_reason = describe_disconnect(e)
832
+ except BaseExceptionGroup as eg:
833
+ # Mirror the original `except*` split: connection-reset paths are a
834
+ # normal disconnect (swallow them); log every other Exception; let
835
+ # anything that is not an Exception (e.g. CancelledError) propagate.
836
+ _normal = (ConnectionError, asyncio.IncompleteReadError, OSError)
837
+ normal, rest = eg.split(_normal)
838
+ if normal is not None and not self.close_reason:
839
+ self.close_reason = describe_disconnect(next(_iter_leaf_exceptions(normal)))
840
+ if rest is not None:
841
+ unexpected, base_only = rest.split(Exception)
842
+ if unexpected is not None:
843
+ for exc in _iter_leaf_exceptions(unexpected):
844
+ self._log_unexpected(exc)
845
+ if base_only is not None:
846
+ raise base_only from None
847
+ finally:
848
+ self._closing = True
849
+ await _close_client(self.writer)
850
+ self._release_break()
851
+
852
+ def _release_break(self) -> None:
853
+ """An RFC 2217 client that set BREAK on (SET-CONTROL) and left without turning it
854
+ off: turn it off, so the line is not held in the break condition for whoever
855
+ comes next."""
856
+ left_on = getattr(self._session, "break_left_on", None)
857
+ ser = self.runner.serial_instance
858
+ if left_on is None or not left_on() or ser is None:
859
+ return
860
+ try:
861
+ ser.break_condition = False
862
+ except Exception as e:
863
+ self.runner._log(f"serial BREAK left on by {self.peer} not turned off: "
864
+ f"{type(e).__name__}: {e}")
865
+ return
866
+ self.runner._log(f"serial BREAK turned off: {self.peer} left it on")
867
+
868
+ def _send_net(self, ctrl: bytes) -> None:
869
+ """Control bytes for the client (telnet / RFC 2217 replies)."""
870
+ if ctrl:
871
+ with contextlib.suppress(asyncio.QueueFull):
872
+ self._out.put_nowait(ctrl)
873
+
874
+ async def _pump_net_to_serial(self) -> None:
875
+ m = self.runner.mapping
876
+ runner = self.runner
877
+ session = self._session
878
+ assert session is not None # noqa: S101 - the pumps start after run() created it
879
+ while not self._closing:
880
+ data = await self.reader.read(READ_CHUNK)
881
+ if not data:
882
+ break # client closed
883
+ self._last_activity = time.monotonic()
884
+ parts = session.from_network_parts(data)
885
+ self._send_net(session.take_net_out())
886
+ written = False
887
+ # in order: the data for the port, a BREAK, the commands that act on the port
888
+ # (each after what came before it; see the module docstring)
889
+ for part in parts:
890
+ if isinstance(part, bytes):
891
+ if not m.network.read_only and await runner.serial_write(part):
892
+ written = True
893
+ self._unsettled += len(part)
894
+ continue
895
+ if part is BREAK or part.settle:
896
+ if self._unsettled:
897
+ await runner.serial_settled(self._unsettled)
898
+ self._unsettled = 0
899
+ if part is BREAK:
900
+ await runner.send_break(f"client {self.peer}")
901
+ else:
902
+ part.apply()
903
+ self._send_net(session.take_net_out())
904
+ if not written:
905
+ # Nothing reached the port (read-only, only telnet/RFC 2217 commands, or
906
+ # the port is not open), so no serial drain() paces this client: let the
907
+ # other tasks run before the next read, which returns at once while the
908
+ # socket buffer holds more.
909
+ await asyncio.sleep(0)
910
+ raise _Disconnect("closed by peer")
911
+
912
+ async def _pump_serial_to_net(self) -> None:
913
+ while True:
914
+ data = await self._out.get()
915
+ if data is None:
916
+ # graceful-close sentinel (e.g. closeon): flush queued bytes, then close
917
+ while not self._out.empty():
918
+ d = self._out.get_nowait()
919
+ if d:
920
+ self.writer.write(d)
921
+ try:
922
+ await asyncio.wait_for(self.writer.drain(), timeout=TCP_CLOSE_TIMEOUT_S)
923
+ except asyncio.TimeoutError: # a peer that reads nothing: cut it off
924
+ with contextlib.suppress(Exception):
925
+ self.writer.transport.abort()
926
+ raise _Disconnect("closed by closeon match")
927
+ self.writer.write(data)
928
+ await self.writer.drain()
929
+ self._last_activity = time.monotonic()
930
+
931
+ async def _pump_poll(self) -> None:
932
+ session = self._session
933
+ assert session is not None # noqa: S101 - as above
934
+ interval = session.poll_interval or 1.0
935
+ while not self._closing:
936
+ await asyncio.sleep(interval)
937
+ extra = session.poll()
938
+ if extra:
939
+ with contextlib.suppress(asyncio.QueueFull):
940
+ self._out.put_nowait(extra)
941
+
942
+ async def _idle_guard(self, timeout: float) -> None:
943
+ while not self._closing:
944
+ await asyncio.sleep(1.0)
945
+ if time.monotonic() - self._last_activity > timeout:
946
+ raise _Disconnect(f"idle timeout ({fmt_duration(timeout)} without traffic)")
947
+
948
+ async def _lifetime_guard(self, limit: float) -> None:
949
+ await asyncio.sleep(limit)
950
+ raise _Disconnect(f"max connection time reached ({fmt_duration(limit)})")
951
+
952
+ def kick(self, reason: str = "kicked") -> None:
953
+ self._closing = True
954
+ self.close_reason = self.close_reason or reason
955
+ with contextlib.suppress(Exception):
956
+ self.writer.transport.abort()
957
+
958
+ def request_graceful_close(self) -> None:
959
+ """Stop feeding new serial data, but let already-queued bytes flush to the
960
+ client before closing (used by closeon so the triggering data is delivered)."""
961
+ if self._closing:
962
+ return
963
+ self._closing = True
964
+ self.close_reason = self.close_reason or "closed by closeon match"
965
+ try:
966
+ self._out.put_nowait(None) # sentinel handled by _pump_serial_to_net
967
+ except asyncio.QueueFull:
968
+ with contextlib.suppress(Exception):
969
+ self.writer.transport.abort()
970
+
971
+
972
+ class MappingRunner:
973
+ def __init__(self, mapping: MappingConfig, logger=None, trace_dir: Optional[str] = None):
974
+ self.mapping = mapping
975
+ self.trace_dir = trace_dir # where traces go (data/traces); None = tracing unavailable
976
+ self.status = RunnerStatus(state="stopped")
977
+ self.log = logger
978
+ self._server: Optional[netguard.Listener] = None
979
+ self._serial_task: Optional[asyncio.Task] = None
980
+ self._main_task: Optional[asyncio.Task] = None # client/udp/serialbridge driver
981
+ self._udp_transport: Optional[asyncio.DatagramTransport] = None
982
+ self._tracer: Optional[Tracer] = None
983
+ self._clients: set = set()
984
+ self._monitors: set = set() # browser console observers (read traffic)
985
+ self._traffic: set = set() # live hex view observers: both directions
986
+ self._mqtt: Optional[MqttPublisher] = None # optional MQTT publisher (serial -> broker)
987
+ self._stop = asyncio.Event()
988
+ # the serial port shared by server/client/udp (a serial<->serial bridge opens its own)
989
+ self._link = SerialLink(mapping.serial, self.status, self._stop, self._log,
990
+ on_change=self._serial_changed, owner=mapping.name)
991
+ self._bridge_backoff: Optional[Backoff] = None # a serial<->serial bridge's retries
992
+ self._access = AccessPolicy(mapping.network)
993
+ self._drop_log = _LogEvery(self._log) # data for the device that was not written
994
+ self._break_lock = asyncio.Lock() # one serial BREAK at a time
995
+ self._break_log = _LogEvery(self._log)
996
+ self._refusal_log = netguard.RefusalLog(self._log) # refused connections
997
+ # MQTT writes (<base_topic>/tx) waiting for the serial port, and their bytes
998
+ self._tx_topic = ""
999
+ self._tx_queue: list[bytes] = []
1000
+ self._tx_queued = 0
1001
+ self._tx_wake = asyncio.Event()
1002
+ self._tx_log = _LogEvery(self._log)
1003
+ self._tx_task: Optional[asyncio.Task] = None
1004
+ self._poll_task: Optional[asyncio.Task] = None # options.poll_string
1005
+ co = mapping.options.closeon
1006
+ # nothing to close on UDP (ignored_settings says so at start)
1007
+ if mapping.kind == "net" and mapping.network.mode == "udp":
1008
+ co = ""
1009
+ self._closeon = co.encode("utf-8", "replace") if co else b""
1010
+ self._closeon_tail = b"" # rolling carry-over so matches across reads aren't missed
1011
+
1012
+ @property
1013
+ def serial_instance(self):
1014
+ """The open pyserial object (None while the port is closed)."""
1015
+ return self._link.serial
1016
+
1017
+ @property
1018
+ def tracer(self) -> Optional[Tracer]:
1019
+ """The trace writer while the mapping runs with tracing on."""
1020
+ return self._tracer
1021
+
1022
+ @property
1023
+ def listener(self) -> Optional[netguard.Listener]:
1024
+ """The server mode listener while the mapping runs, else None."""
1025
+ return self._server
1026
+
1027
+ @property
1028
+ def needs_port(self) -> bool:
1029
+ """A client session runs only while the serial port is open: RFC 2217 (it drives
1030
+ the port), and any TCP mapping with disconnect_without_device."""
1031
+ return self.mapping.network.protocol == "rfc2217" or self.drops_clients
1032
+
1033
+ @property
1034
+ def drops_clients(self) -> bool:
1035
+ """disconnect_without_device, on a TCP mapping (true itself, not text)."""
1036
+ net = self.mapping.network
1037
+ return net.disconnect_without_device is True and net.mode in ("server", "client")
1038
+
1039
+ def wake_serial(self) -> None:
1040
+ """A serial device was plugged in (PortWatcher): a port waiting to be opened again
1041
+ is tried now instead of after its backoff."""
1042
+ self._link.wake()
1043
+ if self._bridge_backoff is not None:
1044
+ self._bridge_backoff.wake()
1045
+
1046
+ def _log(self, msg: str) -> None:
1047
+ # The logger (a per-mapping logger from AppState) adds the "[name]" prefix
1048
+ # for the global all.log and also writes to this mapping's own log file.
1049
+ if self.log:
1050
+ self.log(msg)
1051
+
1052
+ # ---------------- console monitors (browser xterm) ----------------
1053
+ def add_monitor(self, mon) -> None:
1054
+ self._monitors.add(mon)
1055
+
1056
+ def discard_monitor(self, mon) -> None:
1057
+ self._monitors.discard(mon)
1058
+
1059
+ def _feed_monitors(self, data: bytes) -> None:
1060
+ for mon in list(self._monitors):
1061
+ mon.feed(data)
1062
+
1063
+ def add_traffic_monitor(self, mon) -> None:
1064
+ self._traffic.add(mon)
1065
+
1066
+ def discard_traffic_monitor(self, mon) -> None:
1067
+ self._traffic.discard(mon)
1068
+
1069
+ def _feed_traffic(self, direction: int, data: bytes) -> None:
1070
+ for mon in list(self._traffic):
1071
+ mon.feed_traffic(direction, data)
1072
+
1073
+ # ---------------- lifecycle ----------------
1074
+ async def start(self) -> None:
1075
+ self._stop.clear()
1076
+
1077
+ if self.mapping.kind == "serialbridge":
1078
+ self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
1079
+ log_ignored_settings(self.mapping, self._log)
1080
+ self._main_task = asyncio.create_task(
1081
+ self._serialbridge_supervisor(), name=f"serbridge:{self.mapping.name}")
1082
+ self.status.state = "running"
1083
+ self._log("serial-to-serial bridge starting")
1084
+ return
1085
+
1086
+ net = self.mapping.network
1087
+ # The listener comes first (its TLS context, then the bind): a start that fails
1088
+ # there has opened nothing yet (no serial port, whose open pulses DTR/RTS, no
1089
+ # broker connection, no trace file), so the supervisor can simply try it again
1090
+ # later when the address can't be taken (BindError). Data for the device that
1091
+ # arrives before the port is open is dropped, as while it reconnects.
1092
+ udp: Optional[_UdpBridge] = None
1093
+ if net.mode == "server":
1094
+ # admission right after accept; a TLS client's allow-list check comes
1095
+ # before its handshake (netguard.start_server)
1096
+ self._server = await netguard.start_server(
1097
+ self._on_client, netguard.listen_hosts(net), net.port, ssl=server_ssl(net),
1098
+ allowed=self._access.allowed, log=self._log)
1099
+ self._log(f"listening on {netguard.listen_where(net, self._server.sockets)} "
1100
+ f"({net.protocol}{', TLS' if net.tls else ''}, {sockopts.describe(net)})")
1101
+ elif net.mode == "udp":
1102
+ udp = _UdpBridge(self) # a malformed stored reply address fails the start
1103
+ bound = udp
1104
+ sock = await netguard.udp_socket(net)
1105
+ try:
1106
+ self._udp_transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
1107
+ lambda: bound, sock=sock)
1108
+ except OSError as e:
1109
+ sock.close()
1110
+ raise netguard.BindError.of(e) from e
1111
+ self._log(f"UDP on {netguard.listen_where(net, [sock])}, {udp.describe()}")
1112
+ if self._stop.is_set(): # stopped while the listener came up: it found none to close
1113
+ await self.stop()
1114
+ return
1115
+
1116
+ # If the rest fails to come up, tear everything down (the listener too), so that
1117
+ # nothing is left holding the port or the serial device.
1118
+ try:
1119
+ self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
1120
+ log_ignored_settings(self.mapping, self._log)
1121
+ # optional MQTT publisher: serial lines -> broker (net mappings), and with
1122
+ # tx_enabled writes from <base_topic>/tx -> the serial port
1123
+ if self.mapping.mqtt.enabled:
1124
+ from .mqtt_pub import MqttPublisher
1125
+ subs = self._mqtt_subscriptions()
1126
+ self._mqtt = MqttPublisher(self.mapping.mqtt, logger=self._log,
1127
+ subscriptions=subs)
1128
+ self.status.mqtt = self._mqtt.state
1129
+ self._mqtt.connect()
1130
+ if subs:
1131
+ self._tx_task = asyncio.create_task(
1132
+ self._mqtt_tx_loop(), name=f"mqtt-tx:{self.mapping.name}")
1133
+ poll = self._poll_settings()
1134
+ if poll is not None:
1135
+ self._poll_task = asyncio.create_task(
1136
+ self._poll_loop(*poll), name=f"poll:{self.mapping.name}")
1137
+ # up from here; the serial side reports its own state once it has tried the port
1138
+ self.status.state = "running"
1139
+ self.status.last_error = ""
1140
+ # serial side is shared by server/client/udp
1141
+ self._serial_task = asyncio.create_task(
1142
+ self._link.run(self._serial_session), name=f"serial:{self.mapping.name}")
1143
+ if net.mode == "client":
1144
+ self.status.set_link("connecting")
1145
+ self._main_task = asyncio.create_task(
1146
+ self._client_supervisor(), name=f"connectout:{self.mapping.name}")
1147
+ self._log(f"connect-out to {host_port(net.remote_host, net.remote_port)} "
1148
+ f"({net.protocol}, {sockopts.describe(net)})")
1149
+ elif udp is not None:
1150
+ self._main_task = asyncio.create_task(udp.run(), name=f"udp:{self.mapping.name}")
1151
+ except BaseException:
1152
+ await self.stop()
1153
+ raise
1154
+
1155
+ async def stop(self) -> None:
1156
+ self._stop.set()
1157
+ # Stop accepting new connections first (but do NOT await wait_closed yet).
1158
+ if self._server is not None:
1159
+ self._server.close()
1160
+ if self._udp_transport is not None:
1161
+ with contextlib.suppress(Exception):
1162
+ self._udp_transport.close()
1163
+ self._udp_transport = None
1164
+ # Abort existing clients/monitors BEFORE awaiting the listener teardown:
1165
+ # on Python 3.12+ asyncio.Server.wait_closed() blocks until every active
1166
+ # connection has finished, so awaiting it before kicking clients would
1167
+ # deadlock a stop() while clients are still connected.
1168
+ for c in list(self._clients):
1169
+ c.kick("mapping stopped")
1170
+ for mon in list(self._monitors) + list(self._traffic):
1171
+ mon.close()
1172
+ self._monitors.clear()
1173
+ self._traffic.clear()
1174
+ if self._mqtt is not None:
1175
+ mqtt, self._mqtt = self._mqtt, None
1176
+ await mqtt.aclose()
1177
+ self.status.mqtt = {}
1178
+ if self._server is not None:
1179
+ with contextlib.suppress(Exception):
1180
+ await asyncio.wait_for(self._server.wait_closed(), timeout=5.0)
1181
+ self._server = None
1182
+ for task in (self._serial_task, self._main_task, self._tx_task, self._poll_task):
1183
+ if task:
1184
+ task.cancel()
1185
+ with contextlib.suppress(asyncio.CancelledError):
1186
+ await task
1187
+ self._serial_task = self._main_task = self._tx_task = self._poll_task = None
1188
+ self._tx_queue.clear()
1189
+ self._tx_queued = 0
1190
+ await self._link.close()
1191
+ if self._tracer:
1192
+ await self._tracer.aclose()
1193
+ self._tracer = None
1194
+ self.status.state = "stopped"
1195
+ self.status.set_link("")
1196
+ self.status.client_count = 0
1197
+ self.status.clients = []
1198
+ self._log("stopped")
1199
+
1200
+ # ---------------- MQTT writes and the poll string ----------------
1201
+ def _mqtt_subscriptions(self) -> dict:
1202
+ """With mqtt.tx_enabled: {<base_topic>/tx: handler} (nothing with a wildcard in the
1203
+ base topic, which edits refuse: such a subscription would take every topic it
1204
+ matches)."""
1205
+ mq = self.mapping.mqtt
1206
+ if mq.tx_enabled is not True:
1207
+ return {}
1208
+ if mqtt_topic_problem(mq.base_topic):
1209
+ self._log("MQTT writes not taken: the base topic has '+', '#' or a null character")
1210
+ return {}
1211
+ self._tx_topic = str(mq.base_topic).rstrip("/") + "/tx"
1212
+ note = (" (read-only: each is refused)" if self.mapping.network.read_only else "")
1213
+ self._log(f"MQTT: messages on {self._tx_topic} are written to the serial port{note}")
1214
+ missing = [what for what, have in (("TLS", mq.tls), ("a username", mq.username))
1215
+ if not have]
1216
+ if self.mapping.accepts_mqtt_tx and missing:
1217
+ self._log("warning: MQTT writes to the serial port come over a broker connection "
1218
+ f"without {' or '.join(missing)}: any client the broker lets publish to "
1219
+ f"{self._tx_topic} can write to the device; restrict it with the "
1220
+ "broker's access rules (ACLs)")
1221
+ return {self._tx_topic: self._on_mqtt_tx}
1222
+
1223
+ def _on_mqtt_tx(self, topic: str, payload: bytes) -> None:
1224
+ """A message on <base_topic>/tx (on the event loop; never empty or a retained copy:
1225
+ MqttPublisher). Queued for the serial port, or refused on .../tx/result."""
1226
+ mqtt = self._mqtt
1227
+ if mqtt is None:
1228
+ return
1229
+ why = ""
1230
+ if self.mapping.network.read_only:
1231
+ why = "mapping is read-only"
1232
+ elif len(payload) > MQTT_TX_MAX_BYTES:
1233
+ why = f"message too long ({len(payload)} bytes, at most {MQTT_TX_MAX_BYTES})"
1234
+ elif self._tx_queued + len(payload) > MQTT_TX_MAX_BYTES:
1235
+ why = f"busy ({self._tx_queued} bytes waiting for the serial port)"
1236
+ if why:
1237
+ self._tx_log(f"MQTT write from {topic} refused: {why}")
1238
+ mqtt.publish_raw(topic + "/result", f"error: {why}".encode())
1239
+ return
1240
+ self._tx_queue.append(payload)
1241
+ self._tx_queued += len(payload)
1242
+ self._tx_wake.set()
1243
+
1244
+ async def _mqtt_tx_loop(self) -> None:
1245
+ """Write the MQTT messages to the serial port, oldest first, each in one piece (so
1246
+ never mixed into a TCP client's data), and answer each: "ok" once the port took it,
1247
+ "error: ..." when it was not open."""
1248
+ result = self._tx_topic + "/result"
1249
+ while True:
1250
+ await self._tx_wake.wait()
1251
+ self._tx_wake.clear()
1252
+ while self._tx_queue:
1253
+ payload = self._tx_queue.pop(0)
1254
+ try:
1255
+ err = None if await self.serial_write(payload) else \
1256
+ "the serial port is not open"
1257
+ except Exception as e: # the port went away mid-write: SerialLink reopens it
1258
+ err = str(e) or type(e).__name__
1259
+ finally:
1260
+ self._tx_queued -= len(payload)
1261
+ if self._mqtt is not None:
1262
+ self._mqtt.publish_raw(result, b"ok" if err is None else
1263
+ f"error: {err}".encode())
1264
+
1265
+ def _poll_settings(self) -> Optional[tuple[bytes, float]]:
1266
+ """options.poll_string as bytes and poll_interval_s, or None when there is no poll
1267
+ (off, a serial-to-serial bridge; a stored value edits refuse: logged)."""
1268
+ o = self.mapping.options
1269
+ interval = o.poll_interval_s
1270
+ if not isinstance(interval, (int, float)) or isinstance(interval, bool) \
1271
+ or not interval or not o.poll_string:
1272
+ return None
1273
+ try:
1274
+ data = poll_bytes(o.poll_string)
1275
+ except ValueError as e:
1276
+ self._log(f"poll string not sent: {e}")
1277
+ return None
1278
+ if not (0.1 <= interval <= 86400) or not data:
1279
+ self._log(f"poll string not sent: the interval {interval!r} is not between 0.1 "
1280
+ "and 86400 seconds")
1281
+ return None
1282
+ return data, float(interval)
1283
+
1284
+ async def _poll_loop(self, data: bytes, interval: float) -> None:
1285
+ """Send the poll string every ``interval`` seconds while the port is open, and (unless
1286
+ poll_with_clients) while no TCP client is connected, which does the asking then."""
1287
+ with_clients = self.mapping.options.poll_with_clients is True
1288
+ self._log(f"polling the device every {interval:g} s with {data!r}"
1289
+ + ("" if with_clients else " while no TCP client is connected"))
1290
+ while True:
1291
+ with contextlib.suppress(asyncio.TimeoutError):
1292
+ await asyncio.wait_for(self._stop.wait(), timeout=interval)
1293
+ return
1294
+ if not self._link.is_open:
1295
+ continue
1296
+ if not with_clients and any(isinstance(c, _ClientConn) for c in self._clients):
1297
+ continue
1298
+ with contextlib.suppress(Exception): # the port went away: SerialLink reopens it
1299
+ await self.serial_write(data)
1300
+
1301
+ # ssl context for a connect-out TLS data bridge (a listener's: access.server_ssl)
1302
+ def _client_ssl(self):
1303
+ return tls.mapping_context(self.mapping.network)
1304
+
1305
+ # ---------------- serial side ----------------
1306
+ async def serial_write(self, data: bytes) -> bool:
1307
+ """Write data from the network (a client, a datagram, the browser console) to the
1308
+ device and count it: bytes_out once the open port took it, else bytes_dropped.
1309
+ Returns False when it was dropped. Awaits drain(): the caller reads no more from
1310
+ its client until the port has room (network->serial backpressure)."""
1311
+ w = self._link.writer
1312
+ if w is None or w.is_closing():
1313
+ # serial not currently open (status shows reconnecting): drop
1314
+ self._drop(len(data), "the serial port is not open")
1315
+ return False
1316
+ if self._tracer:
1317
+ self._tracer.write("net>ser", data)
1318
+ self._feed_traffic(TO_DEVICE, data)
1319
+ w.write(data)
1320
+ self.status.bytes_out += len(data)
1321
+ await w.drain()
1322
+ return True
1323
+
1324
+ async def serial_settled(self, written: int) -> None:
1325
+ """Wait until the port has sent what it holds (``written``: the bytes a client
1326
+ wrote since its last BREAK / setting change), at most their line time twice plus
1327
+ 0.2 s and never more than SETTLE_MAX_S."""
1328
+ w = self._link.writer
1329
+ if w is None or w.is_closing():
1330
+ return
1331
+ baud = getattr(self._link.serial, "baudrate", 0) or self.mapping.serial.baudrate
1332
+ line_time = written * 11 / max(1, int(baud)) # (a start, 8 data, parity, stop bit)
1333
+ await sent_within(w, min(SETTLE_MAX_S, 0.2 + 2 * line_time))
1334
+
1335
+ async def send_break(self, who: str) -> bool:
1336
+ """Send a serial BREAK (a telnet client's IAC BRK, the web console; ``who`` for the
1337
+ log): the line is held in the break condition for BREAK_S, on the event loop's
1338
+ timer. One at a time; False when none was sent (a read-only mapping, the port not
1339
+ open, a port that can't). Logged at most once a minute, with a count."""
1340
+ if self.mapping.network.read_only:
1341
+ self._break_log(f"serial BREAK from {who} not sent: the mapping is read-only")
1342
+ return False
1343
+ async with self._break_lock:
1344
+ ser = self._link.serial
1345
+ if ser is None or self._stop.is_set():
1346
+ self._break_log(f"serial BREAK from {who} not sent: the serial port is not "
1347
+ "open")
1348
+ return False
1349
+ try:
1350
+ ser.break_condition = True
1351
+ except Exception as e: # (a stand-in or a port without break control)
1352
+ self._break_log(f"serial BREAK from {who} not sent: {type(e).__name__}: {e}")
1353
+ return False
1354
+ try:
1355
+ await asyncio.sleep(BREAK_S)
1356
+ finally:
1357
+ with contextlib.suppress(Exception):
1358
+ ser.break_condition = False
1359
+ self._break_log(f"serial BREAK sent ({who})")
1360
+ return True
1361
+
1362
+ def _serial_changed(self, serial) -> None:
1363
+ """SerialLink: the port was reopened (``serial``) or closed (None). With
1364
+ disconnect_without_device a closed port disconnects every client. Otherwise an RFC
1365
+ 2217 session drives the port itself, so the connected client's session follows it:
1366
+ the client stays connected through a reconnect and keeps its settings."""
1367
+ if serial is None and self.drops_clients and not self._stop.is_set():
1368
+ if self._clients:
1369
+ self._log(f"serial port closed: disconnecting {len(self._clients)} "
1370
+ "client(s) (disconnect_without_device)")
1371
+ for c in list(self._clients):
1372
+ c.kick("the serial port is not open")
1373
+ elif self.mapping.network.protocol == "rfc2217":
1374
+ for c in list(self._clients):
1375
+ c.serial_changed(serial)
1376
+
1377
+ def _restore_port_settings(self) -> None:
1378
+ """rfc2217_restore_on_disconnect: once the RFC 2217 client has left, put the port
1379
+ back to this mapping's settings, so what comes next (the next client, the console,
1380
+ MQTT, a trace) runs at what the mapping says. Only what the mapping sets and a
1381
+ client can change over RFC 2217 (baud rate, data bits, parity, stop bits, flow
1382
+ control): RTS/DTR stay as they are, since toggling them can reset a device."""
1383
+ m = self.mapping
1384
+ ser = self._link.serial
1385
+ if (m.network.protocol != "rfc2217" or not m.options.rfc2217_restore_on_disconnect
1386
+ or ser is None or self._clients or self._stop.is_set()):
1387
+ return
1388
+ restored = False
1389
+ for name, value in serial_io.port_settings(m.serial).items():
1390
+ try:
1391
+ if getattr(ser, name) != value:
1392
+ setattr(ser, name, value)
1393
+ restored = True
1394
+ except Exception as e:
1395
+ self._log(f"serial setting not restored ({name} {value!r}): "
1396
+ f"{type(e).__name__}: {e}")
1397
+ if restored:
1398
+ self._log(f"serial settings restored to the mapping's ({m.serial.compact()}, "
1399
+ f"flow control {m.serial.flowcontrol}) after the RFC 2217 client left")
1400
+
1401
+ async def _serial_open(self) -> bool:
1402
+ """Wait until the serial port is open; False when the mapping stops first."""
1403
+ link = self._link
1404
+ if not link.is_open:
1405
+ ready = asyncio.ensure_future(link.ready.wait())
1406
+ stop = asyncio.ensure_future(self._stop.wait())
1407
+ try:
1408
+ await asyncio.wait((ready, stop), return_when=asyncio.FIRST_COMPLETED)
1409
+ finally:
1410
+ ready.cancel()
1411
+ stop.cancel()
1412
+ return link.is_open and not self._stop.is_set()
1413
+
1414
+ def _drop(self, n: int, why: str) -> None:
1415
+ """Count data for the device that is not written, and log it (rate-limited)."""
1416
+ if n <= 0:
1417
+ return
1418
+ self.status.bytes_dropped += n
1419
+ self._drop_log(f"data for the device dropped: {why} "
1420
+ f"({self.status.bytes_dropped} bytes so far)")
1421
+
1422
+ async def _serial_session(self, reader: asyncio.StreamReader,
1423
+ writer: asyncio.StreamWriter) -> None:
1424
+ """While the port is open (SerialLink.run): send the open string, then relay what
1425
+ the device sends until the port goes away."""
1426
+ if self.mapping.options.openstr:
1427
+ writer.write(self.mapping.options.openstr.encode("utf-8", "replace"))
1428
+ await writer.drain()
1429
+ await self._serial_read_loop(reader)
1430
+
1431
+ async def _serial_read_loop(self, reader: asyncio.StreamReader) -> None:
1432
+ while not self._stop.is_set():
1433
+ data = await reader.read(READ_CHUNK)
1434
+ if not data:
1435
+ raise ConnectionError("serial EOF / device removed")
1436
+ self.status.bytes_in += len(data) # once, whoever receives it (or nobody)
1437
+ if self._tracer:
1438
+ self._tracer.write("ser>net", data)
1439
+ self._feed_monitors(data)
1440
+ self._feed_traffic(FROM_DEVICE, data)
1441
+ if self._mqtt is not None:
1442
+ self._mqtt.feed(data)
1443
+ for c in list(self._clients):
1444
+ c.feed_from_serial(data)
1445
+ if self._closeon:
1446
+ self._check_closeon(data)
1447
+
1448
+ def _check_closeon(self, data: bytes) -> None:
1449
+ window = self._closeon_tail + data
1450
+ if self._closeon in window:
1451
+ self._log(f"closeon matched {self._closeon!r} — closing {len(self._clients)} client(s)")
1452
+ for c in list(self._clients):
1453
+ c.request_graceful_close()
1454
+ self._closeon_tail = b""
1455
+ elif len(self._closeon) > 1:
1456
+ self._closeon_tail = window[-(len(self._closeon) - 1):]
1457
+
1458
+ # ---------------- network side ----------------
1459
+ def _refresh_clients(self) -> None:
1460
+ self.status.set_clients(self._clients)
1461
+
1462
+ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
1463
+ if self._stop.is_set(): # accepted while the mapping stops
1464
+ writer.close()
1465
+ return
1466
+ peer_ip, peer_port = netguard.peer_address(writer)
1467
+ peer_label = netguard.client_label(peer_ip, peer_port)
1468
+
1469
+ # An RFC2217 session (and any, with disconnect_without_device) needs the open serial
1470
+ # port: wait briefly for it (an allowed client) BEFORE admission, so a client
1471
+ # waiting here holds no slot and nobody is kicked for a newcomer that is then
1472
+ # turned away.
1473
+ ready = self._link.ready
1474
+ if self.needs_port and not ready.is_set() and self._access.allowed(peer_ip):
1475
+ with contextlib.suppress(asyncio.TimeoutError):
1476
+ await asyncio.wait_for(ready.wait(),
1477
+ timeout=self.mapping.options.rfc2217_net_timeout_s)
1478
+ if not ready.is_set():
1479
+ # (a poller retrying all the time: each reason once a minute, with a count)
1480
+ if self.drops_clients:
1481
+ self._refusal_log(f"rejected {peer_label}: the serial port is not open "
1482
+ "(disconnect_without_device)")
1483
+ else:
1484
+ self._refusal_log(f"rejected {peer_label}: serial not ready for RFC2217")
1485
+ writer.close()
1486
+ return
1487
+
1488
+ # allow-list, then max connections (a priority client / kick_old_user makes room);
1489
+ # from here until the client is in self._clients nothing awaits, so connections
1490
+ # accepted together are admitted one after the other, each seeing the one before
1491
+ admission = self._access.admit(peer_ip, peer_label, self._clients, self._refusal_log)
1492
+ if not admission.admitted:
1493
+ if admission.at_capacity:
1494
+ with contextlib.suppress(Exception):
1495
+ writer.write(b"Busy: maximum connections reached.\r\n")
1496
+ await writer.drain()
1497
+ writer.close()
1498
+ return
1499
+ is_priority = admission.priority
1500
+
1501
+ self._enable_keepalive(writer, peer_label)
1502
+ client = _ClientConn(self, reader, writer, peer_ip, peer_port, priority=is_priority)
1503
+ self._clients.add(client)
1504
+ self._refresh_clients()
1505
+ tag = (" [priority]" if is_priority else "") + tls.client_certificate(writer)
1506
+ self._log(f"client connected: {peer_label}{tag} ({len(self._clients)} total)")
1507
+ try:
1508
+ await client.run()
1509
+ finally:
1510
+ self._clients.discard(client)
1511
+ self._refresh_clients()
1512
+ held = fmt_duration(time.monotonic() - client.connected_at)
1513
+ why = f" — {client.close_reason}" if client.close_reason else ""
1514
+ self._log(f"client disconnected: {peer_label} after {held}{why} "
1515
+ f"({len(self._clients)} total)")
1516
+ self._restore_port_settings()
1517
+ # closestr to device when the last client leaves
1518
+ sw = self._link.writer
1519
+ if not self._clients and self.mapping.options.closestr and sw:
1520
+ with contextlib.suppress(Exception):
1521
+ sw.write(self.mapping.options.closestr.encode("utf-8", "replace"))
1522
+ await sw.drain()
1523
+
1524
+ def _enable_keepalive(self, writer: asyncio.StreamWriter, peer: str) -> None:
1525
+ net = self.mapping.network
1526
+ if err := sockopts.apply_keepalive(writer, net):
1527
+ self._log(f"TCP keepalive not enabled for {peer}: {err}")
1528
+
1529
+ # ---------------- connect-out (TCP client) ----------------
1530
+ async def _connect_out(self):
1531
+ """Resolve the remote host, then connect (+ TLS handshake) to each resolved
1532
+ address in turn, giving each one connect_timeout_s — like an OS connect
1533
+ timeout per address — so a dual-stack host whose IPv6 path is dead still
1534
+ falls back to IPv4 instead of the first address eating the whole budget.
1535
+
1536
+ The DNS lookup is deliberately NOT under the timeout: getaddrinfo runs in a
1537
+ worker thread that cancellation can't stop, so timing it out would leave one
1538
+ stuck thread behind per retry during a DNS outage and starve the shared
1539
+ executor (asyncio.to_thread: password hashing, config saves, log reads).
1540
+ Connecting to the resolved IP literal needs no further lookup.
1541
+
1542
+ Returns (reader, writer, what the log says about TLS); over TLS the server's
1543
+ certificate is checked first (tls.check_server: a pin that does not match is that
1544
+ address's failure)."""
1545
+ net = self.mapping.network
1546
+ loop = asyncio.get_running_loop()
1547
+ infos = await loop.getaddrinfo(net.remote_host, net.remote_port,
1548
+ type=socket.SOCK_STREAM)
1549
+ ssl_ctx = self._client_ssl()
1550
+ error: BaseException = ConnectionError(f"no address for {net.remote_host}")
1551
+ for family, _type, _proto, _name, sockaddr in infos:
1552
+ try:
1553
+ reader, writer = await asyncio.wait_for(
1554
+ asyncio.open_connection(
1555
+ str(sockaddr[0]), int(sockaddr[1]), family=family, ssl=ssl_ctx,
1556
+ server_hostname=net.remote_host if ssl_ctx else None),
1557
+ timeout=net.connect_timeout_s or None)
1558
+ except asyncio.TimeoutError as e:
1559
+ if getattr(e, "errno", None) is not None:
1560
+ error = e # the OS's own connect timeout, not ours
1561
+ continue
1562
+ error = ConnectionError(f"timed out after {net.connect_timeout_s:g}s")
1563
+ continue
1564
+ except OSError as e:
1565
+ error = e
1566
+ continue
1567
+ if ssl_ctx is None:
1568
+ return reader, writer, ""
1569
+ try: # the server's certificate, before any byte of the link is used
1570
+ return reader, writer, f" ({tls.check_server(net, writer)})"
1571
+ except ConnectionError as e:
1572
+ writer.transport.abort()
1573
+ error = e
1574
+ raise error
1575
+
1576
+ async def _client_supervisor(self) -> None:
1577
+ net = self.mapping.network
1578
+ target = host_port(net.remote_host, net.remote_port)
1579
+ backoff = Backoff()
1580
+ failures = FailureLog(self._log, "connect-out failed")
1581
+ while not self._stop.is_set():
1582
+ # an RFC 2217 session drives the serial port (disconnect_without_device: no
1583
+ # session without it): connect out only while it is open
1584
+ if self.needs_port and not await self._serial_open():
1585
+ break
1586
+ st = self.status
1587
+ try:
1588
+ reader, writer, tls_note = await self._connect_out()
1589
+ self._enable_keepalive(writer, target)
1590
+ client = _ClientConn(self, reader, writer, net.remote_host, net.remote_port)
1591
+ self._clients.add(client)
1592
+ self._refresh_clients()
1593
+ self._log(f"connected out to {target}{tls_note}{failures.recovered()}")
1594
+ if st.link_error and st.last_error == st.link_error:
1595
+ st.last_error = "" # (the link's error, not one of the serial side)
1596
+ st.set_link("up")
1597
+ backoff.reset()
1598
+ try:
1599
+ await client.run()
1600
+ finally:
1601
+ self._clients.discard(client)
1602
+ self._refresh_clients()
1603
+ # closed (by either end, or a timer): "down" only if the next try fails,
1604
+ # so a max connection time or idle timeout recycle is no outage
1605
+ st.set_link("connecting")
1606
+ held = fmt_duration(time.monotonic() - client.connected_at)
1607
+ why = f" — {client.close_reason}" if client.close_reason else ""
1608
+ self._log(f"connect-out link to {target} closed after {held}{why}")
1609
+ self._restore_port_settings()
1610
+ except asyncio.CancelledError:
1611
+ raise
1612
+ except Exception as e:
1613
+ st.last_error = str(e)
1614
+ st.connect_failures += 1
1615
+ st.set_link("down", str(e))
1616
+ failures.failed(str(e))
1617
+ if self._stop.is_set():
1618
+ break
1619
+ if not await backoff.wait(self._stop):
1620
+ break
1621
+
1622
+ # ---------------- serial <-> serial bridge ----------------
1623
+ async def _serialbridge_supervisor(self) -> None:
1624
+ """Both ports open and close together, so this keeps its own loop (SerialLink
1625
+ supervises one port). The side that could not be opened last time is opened first:
1626
+ while one port is missing, the other one is not opened and closed again at every
1627
+ retry (each open pulses its DTR and RTS: a board that resets on DTR would reset
1628
+ every few seconds)."""
1629
+ backoff = self._bridge_backoff = Backoff()
1630
+ notes = NoteLog(self._log)
1631
+ failures = FailureLog(self._log, "serial bridge error")
1632
+ st = self.status
1633
+ sides = (self.mapping.serial, self.mapping.serial_b)
1634
+ first = 0 # the side opened first: 0 = A, 1 = B
1635
+ while not self._stop.is_set():
1636
+ opened: list = [None, None] # (reader, writer, serial, device) of each side
1637
+ was_open = False
1638
+ try:
1639
+ for side in (first, 1 - first):
1640
+ try:
1641
+ opened[side] = await serial_io.open_serial(sides[side])
1642
+ except Exception:
1643
+ first = side
1644
+ raise
1645
+ portdiag.opened(opened[side][2], self.mapping.name, opened[side][3])
1646
+ was_open = True
1647
+ (ra, wa, sa, da), (rb, wb, sb, db) = opened
1648
+ self.status.device = f"{da} <-> {db}"
1649
+ self.status.devices = (da, db)
1650
+ self.status.last_error = ""
1651
+ self.status.hint = None
1652
+ self.status.warnings = tuple(
1653
+ portdiag.Note("side", (side, note))
1654
+ for side, ser in (("A", sa), ("B", sb)) for note in serial_io.open_notes(ser))
1655
+ self.status.state = "running"
1656
+ backoff.reset()
1657
+ self._log(f"serial bridge open: {da} <-> {db}{failures.recovered()}")
1658
+ notes(self.status.warnings)
1659
+ async with TaskGroup() as tg:
1660
+ tg.create_task(self._pump_serial_pair(ra, wb, "A>B"))
1661
+ tg.create_task(self._pump_serial_pair(rb, wa, "B>A"))
1662
+ if not self._stop.is_set(): # (a pump ends by raising why)
1663
+ raise ConnectionError("serial EOF / device removed")
1664
+ except asyncio.CancelledError:
1665
+ raise
1666
+ except Exception as e:
1667
+ if not self._stop.is_set():
1668
+ # TaskGroup raises an ExceptionGroup; flatten its messages
1669
+ inner = getattr(e, "exceptions", None)
1670
+ st.last_error = "; ".join(str(x) for x in inner) if inner else str(e)
1671
+ if was_open:
1672
+ st.device_lost += 1
1673
+ st.state = "reconnecting"
1674
+ else:
1675
+ st.open_failures += 1
1676
+ st.state = "device-missing" if portdiag.missing(e) else "reconnecting"
1677
+ failures.failed(st.last_error)
1678
+ hint = None if was_open else await asyncio.to_thread(
1679
+ explain, e, sides[first])
1680
+ st.hint = portdiag.Note("side", ("AB"[first], hint)) if hint else None
1681
+ finally:
1682
+ self.status.warnings = ()
1683
+ await asyncio.gather(*(close_stream(port[1], self._log)
1684
+ for port in opened if port is not None))
1685
+ if self._stop.is_set():
1686
+ break
1687
+ self.status.reconnects += 1
1688
+ if not await backoff.wait(self._stop):
1689
+ break
1690
+
1691
+ async def _pump_serial_pair(self, reader, writer, tag: str) -> None:
1692
+ # port A is "the device" of the counters and the live view: A -> B counts as in,
1693
+ # B -> A as out
1694
+ from_a = tag.startswith("A")
1695
+ while not self._stop.is_set():
1696
+ data = await reader.read(READ_CHUNK)
1697
+ if not data:
1698
+ raise ConnectionError(f"serial EOF ({tag})")
1699
+ if self._tracer:
1700
+ self._tracer.write(tag, data)
1701
+ self._feed_monitors(data)
1702
+ self._feed_traffic(FROM_DEVICE if from_a else TO_DEVICE, data)
1703
+ writer.write(data)
1704
+ if from_a:
1705
+ self.status.bytes_in += len(data)
1706
+ else:
1707
+ self.status.bytes_out += len(data)
1708
+ await writer.drain()
1709
+
1710
+
1711
+ # ---------------------------------------------------------------------------
1712
+ # UDP transport (datagram bridge). The serial read loop broadcasts to _clients, so the
1713
+ # _UdpPeer in _clients carries serial->net; datagram_received carries net->serial, through
1714
+ # a bounded queue that _UdpBridge.run() writes to the port. One peer gets the device's
1715
+ # output: the reply address when one is set (udp_peer_host / udp_peer_port), otherwise
1716
+ # the sender of the last allowed datagram (forgotten after udp_peer_timeout_s without a
1717
+ # datagram from it, when that is set). The targets (udp_targets, a _UdpTarget each in
1718
+ # _clients) get it too, always; a peer that is one of them is not sent it twice.
1719
+ # ---------------------------------------------------------------------------
1720
+ def _udp_label(addr) -> str:
1721
+ """ip:port of a datagram's sender (an IPv4 one of a socket for both families as IPv4)."""
1722
+ return netguard.client_label(canonical_ip(str(addr[0])), addr[1])
1723
+
1724
+
1725
+ class _UdpPeer:
1726
+ """Where a UDP mapping sends the device's output; listed as its client."""
1727
+
1728
+ def __init__(self, bridge: "_UdpBridge", addr, label: str = "") -> None:
1729
+ self.bridge = bridge
1730
+ self.addr = addr
1731
+ self.peer = label or _udp_label(addr)
1732
+ self.priority = False
1733
+ self.seq = connection_order()
1734
+ self.connected_at = time.monotonic() # since when it gets the output
1735
+ self.last_heard = time.monotonic() # its last datagram (for the peer timeout)
1736
+
1737
+ def feed_from_serial(self, data: bytes) -> None:
1738
+ self.bridge.send(self, data)
1739
+
1740
+ def kick(self, reason: str = "") -> None:
1741
+ pass
1742
+
1743
+ def request_graceful_close(self) -> None:
1744
+ pass
1745
+
1746
+
1747
+ class _UdpTarget(_UdpPeer):
1748
+ """A UDP target (udp_targets): always gets the device's output; listed as a client."""
1749
+
1750
+ def feed_from_serial(self, data: bytes) -> None:
1751
+ self.bridge.send_target(self, data)
1752
+
1753
+
1754
+ class _UdpBridge(asyncio.DatagramProtocol):
1755
+ """A UDP mapping's socket side. Every datagram is checked against the allow-list; an
1756
+ allowed one makes its sender the peer (unless there is a reply address) and waits in a
1757
+ queue that run() writes to the serial port. A datagram that would take the queue past
1758
+ UDP_BACKLOG_MAX bytes is dropped and counted instead."""
1759
+
1760
+ def __init__(self, runner: "MappingRunner") -> None:
1761
+ self.runner = runner
1762
+ net = runner.mapping.network
1763
+ self.reply_to = udp_peer_address(net) # ValueError: a malformed stored one
1764
+ self.targets = udp_targets(net) # (the same)
1765
+ # the peer timeout is for a sender that was learned, not for the reply address
1766
+ self.timeout = 0.0 if self.reply_to else udp_peer_timeout(net)
1767
+ self.transport: Optional[asyncio.DatagramTransport] = None
1768
+ self.peer: Optional[_UdpPeer] = None
1769
+ self._target_addrs: set = set() # where the targets are sent (as the socket needs)
1770
+ self._queue: list[bytes] = []
1771
+ self._queued = 0 # bytes waiting: queued, or being written by run()
1772
+ self._wake = asyncio.Event()
1773
+ self._peer_log = _LogEvery(runner._log)
1774
+ self._refused_log = _LogEvery(runner._log)
1775
+ self._error_log = _LogEvery(runner._log)
1776
+
1777
+ def describe(self) -> str:
1778
+ """Where the device's output goes (for the start-up log line)."""
1779
+ if self.reply_to:
1780
+ out = f"device output to {_udp_label(self.reply_to)}"
1781
+ elif self.timeout:
1782
+ out = (f"device output to the last sender (forgotten after "
1783
+ f"{fmt_duration(self.timeout)} without a datagram from it)")
1784
+ else:
1785
+ out = "device output to the last sender"
1786
+ if self.targets:
1787
+ out += " and to " + ", ".join(_udp_label(t) for t in self.targets)
1788
+ return out
1789
+
1790
+ # ---- asyncio.DatagramProtocol ----
1791
+ def connection_made(self, transport) -> None:
1792
+ self.transport = transport
1793
+ sock = transport.get_extra_info("socket")
1794
+ v6 = sock is not None and sock.family == socket.AF_INET6
1795
+
1796
+ def sent_to(addr):
1797
+ host, port = addr
1798
+ if v6 and ipaddress.ip_address(host).version == 4:
1799
+ host = f"::ffff:{host}" # an IPv4 peer of a socket bound to an IPv6 address
1800
+ return host, port
1801
+
1802
+ r = self.runner
1803
+ for target in self.targets:
1804
+ addr = sent_to(target)
1805
+ if addr not in self._target_addrs:
1806
+ self._target_addrs.add(addr)
1807
+ r._clients.add(_UdpTarget(self, addr, _udp_label(target)))
1808
+ r._refresh_clients()
1809
+ if self.reply_to is not None:
1810
+ self._set_peer(sent_to(self.reply_to), _udp_label(self.reply_to))
1811
+
1812
+ def connection_lost(self, exc) -> None:
1813
+ self.transport = None
1814
+
1815
+ def error_received(self, exc: Exception) -> None:
1816
+ # e.g. the peer's port is closed (ICMP port unreachable) or there is no route to it
1817
+ hint = ""
1818
+ if (getattr(exc, "errno", None) == errno.EACCES
1819
+ and self.runner.mapping.network.udp_broadcast is not True):
1820
+ hint = " (to send to a broadcast address, allow broadcast: udp_broadcast)"
1821
+ self._error_log(f"udp error: {exc}{hint}")
1822
+
1823
+ def datagram_received(self, data: bytes, addr) -> None:
1824
+ r = self.runner
1825
+ # Access control: a TCP listener checks the allow-list in _on_client, but UDP has
1826
+ # no accept hook, so it is checked HERE, for every datagram. An unlisted source is
1827
+ # dropped entirely: it neither reaches the device nor becomes the peer. The check
1828
+ # looks at the datagram's source address, which a sender can forge (README,
1829
+ # Security); with a reply address the device's output can't be redirected.
1830
+ if not r._access.allowed(addr[0]):
1831
+ self._refused_log(f"rejected datagram from {_udp_label(addr)}: not in allowed list")
1832
+ return
1833
+ if self.reply_to is None:
1834
+ self._heard_from(addr)
1835
+ if r.mapping.network.read_only or not data:
1836
+ return
1837
+ if r._link.writer is None:
1838
+ r._drop(len(data), "the serial port is not open")
1839
+ elif self._queued + len(data) > UDP_BACKLOG_MAX:
1840
+ r._drop(len(data), f"the serial port cannot keep up with the datagrams "
1841
+ f"({self._queued} bytes waiting for it)")
1842
+ else:
1843
+ self._queue.append(data)
1844
+ self._queued += len(data)
1845
+ self._wake.set()
1846
+
1847
+ # ---- the peer ----
1848
+ def _set_peer(self, addr, label: str = "") -> _UdpPeer:
1849
+ r = self.runner
1850
+ if self.peer is not None:
1851
+ r._clients.discard(self.peer)
1852
+ peer = self.peer = _UdpPeer(self, addr, label)
1853
+ r._clients.add(peer)
1854
+ r._refresh_clients()
1855
+ return peer
1856
+
1857
+ def _heard_from(self, addr) -> None:
1858
+ """No reply address: the sender of an allowed datagram gets the device's output
1859
+ from now on. A new sender is logged (a change at most once a minute) and listed
1860
+ with its own connection time."""
1861
+ old = self.peer
1862
+ if old is not None and old.addr == addr:
1863
+ old.last_heard = time.monotonic()
1864
+ return
1865
+ peer = self._set_peer(addr)
1866
+ if old is None:
1867
+ self.runner._log(f"udp peer {peer.peer}")
1868
+ else:
1869
+ self._peer_log(f"udp peer is now {peer.peer} (was {old.peer})")
1870
+
1871
+ def _expired(self) -> bool:
1872
+ """Forget the sender once it has sent nothing for the peer timeout."""
1873
+ peer = self.peer
1874
+ if (peer is None or not self.timeout
1875
+ or time.monotonic() - peer.last_heard < self.timeout):
1876
+ return False
1877
+ r = self.runner
1878
+ r._clients.discard(peer)
1879
+ self.peer = None
1880
+ r._refresh_clients()
1881
+ r._log(f"udp peer {peer.peer} forgotten: no datagram for {fmt_duration(self.timeout)}")
1882
+ return True
1883
+
1884
+ def send(self, peer: _UdpPeer, data: bytes) -> None:
1885
+ """The device's output, to the peer (nothing once it has expired, or when it is one
1886
+ of the targets, which get it anyway)."""
1887
+ if (self.transport is None or peer is not self.peer or self._expired()
1888
+ or tuple(peer.addr[:2]) in self._target_addrs):
1889
+ return
1890
+ with contextlib.suppress(Exception): # a failed send reaches error_received
1891
+ self.transport.sendto(data, peer.addr)
1892
+
1893
+ def send_target(self, target: _UdpTarget, data: bytes) -> None:
1894
+ """The device's output, to a target."""
1895
+ if self.transport is None:
1896
+ return
1897
+ try:
1898
+ self.transport.sendto(data, target.addr)
1899
+ except OSError as e: # (a synchronous refusal, e.g. EACCES: a broadcast address)
1900
+ self.error_received(e)
1901
+ except Exception:
1902
+ pass
1903
+
1904
+ # ---- network -> serial ----
1905
+ async def run(self) -> None:
1906
+ """Write the queued datagrams to the serial port, oldest first: what has queued up
1907
+ goes in one write that awaits drain(), like a TCP client's data, so the port's own
1908
+ buffer stays at its high-water mark and the queue holds at most UDP_BACKLOG_MAX.
1909
+ With a peer timeout, also forgets a sender that went quiet."""
1910
+ r = self.runner
1911
+ while True:
1912
+ if self.timeout:
1913
+ with contextlib.suppress(asyncio.TimeoutError):
1914
+ await asyncio.wait_for(self._wake.wait(), timeout=1.0)
1915
+ self._expired()
1916
+ else:
1917
+ await self._wake.wait()
1918
+ self._wake.clear()
1919
+ if not self._queue:
1920
+ continue
1921
+ batch = self._queue[0] if len(self._queue) == 1 else b"".join(self._queue)
1922
+ self._queue.clear()
1923
+ try:
1924
+ await r.serial_write(batch)
1925
+ except Exception: # the port went away mid-write: SerialLink reopens it
1926
+ pass
1927
+ finally:
1928
+ self._queued -= len(batch)