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,632 @@
1
+ """Optional per-mapping MQTT publisher (serial -> MQTT northbound).
2
+
3
+ Publishes serial lines to an MQTT topic so meters, sensors and other line-oriented
4
+ serial devices can feed an IIoT broker / Unified Namespace. A line ends at LF (the
5
+ default), CR, or either (MqttSettings.line_end); past MAX_LINE_BUFFER bytes without one
6
+ the oldest are dropped, and the log says so once.
7
+ A retained ``<base_topic>/status`` birth/death (online after every connect, offline at
8
+ close and as the MQTT Last-Will) lets subscribers see link state.
9
+
10
+ paho-mqtt is an OPTIONAL dependency (not in the offline bootstrap): if it isn't
11
+ installed or fails to import, publishing is disabled with a log line saying which
12
+ (ser2netweb.optional) and the bridge runs normally.
13
+ The paho client runs its own network thread (loop_start) and ``publish()`` is
14
+ thread-safe, so feeding it from the asyncio serial loop never blocks the loop; a mapping's
15
+ stop closes it with aclose(), which waits for that thread off the loop (at most
16
+ MQTT_CLOSE_TIMEOUT_S). The thread waits for its sockets with select.select(), which
17
+ refuses descriptors at or above FD_SETSIZE (1024); on POSIX the client waits with poll()
18
+ instead (``PollSelect``). The connection to the broker is logged when it changes
19
+ (connecting, connected, refused, can't connect, lost; each reason once until it changes)
20
+ and kept in ``MqttPublisher.state`` (the mapping status' ``mqtt``).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import errno
27
+ import math
28
+ import select
29
+ import sys
30
+ import threading
31
+ import time
32
+ from collections.abc import Collection
33
+ from typing import Any, Callable, Optional
34
+
35
+ from .. import optional
36
+
37
+ MAX_LINE_BUFFER = 65536 # drop the oldest bytes if a line end never arrives (no unbounded growth)
38
+ LINE_ENDS = ("lf", "cr", "any") # (config.MQTT_LINE_ENDS)
39
+ KEEPALIVE_S = 30
40
+ # A broker publishes the Will of a connection that went quiet only once 1.5 x its keepalive
41
+ # has passed: that of the previous connection (a link that broke, a power loss) can come
42
+ # after the next connection's birth. With a blank client ID (the broker tells connections
43
+ # apart by nothing else) the birth is sent once more this long after each connect.
44
+ STALE_WILL_S = 1.5 * KEEPALIVE_S + 5
45
+ # QoS 1/2 messages paho keeps while the broker is away (about 2 KB each), per mapping;
46
+ # beyond that it refuses new ones (MQTT_ERR_QUEUE_SIZE) until the broker takes the backlog
47
+ MAX_QUEUED_MESSAGES = 10000
48
+ # aclose(): how long a mapping's stop waits for the broker to take the last messages
49
+ MQTT_CLOSE_TIMEOUT_S = 3.0
50
+ _NO_CONN = 4 # paho's MQTT_ERR_NO_CONN: a QoS 0 message with no connection is dropped
51
+ _QUEUE_SIZE = 15 # paho's MQTT_ERR_QUEUE_SIZE
52
+
53
+
54
+ class MqttPublisher:
55
+ """``will`` replaces the retained <base>/status "offline" Last-Will and ``birth``
56
+ the "online" message, sent after every connect (Sparkplug B sends NDEATH / NBIRTH
57
+ instead); a callable ``will`` is asked again after every disconnect (in paho's
58
+ thread), so the next connection carries a fresh one (the Sparkplug bdSeq).
59
+ ``on_connect`` runs on the event loop after every (re)connect; ``subscriptions`` maps
60
+ topics to handlers ``(topic, payload)`` that also run on the event loop — paho's
61
+ network thread never touches the bridges directly.
62
+
63
+ The subscriptions are command topics, and a command is acted on once, when it arrives:
64
+ the broker also sends the last retained message of a topic on every (re)subscribe
65
+ (after each reconnect, restart or config save), flagged as retained, and such a replay
66
+ is dropped and logged unless ``accept_retained``. An empty message is never a command:
67
+ it is what clears a retained one (and reaches subscribers live, without the flag).
68
+ ``state_topics``: subscriptions that carry a state, not a command (a Sparkplug host's
69
+ STATE): their retained message is the current state and is handed on.
70
+
71
+ ``state`` (updated on the event loop; the runner's status shows it as ``mqtt``):
72
+ "state" — connecting (no reply from the broker yet), connected, down (the last attempt
73
+ failed, was refused or the connection was lost: "error" says why; paho tries again,
74
+ waiting up to 2 minutes between attempts) or off (publishing could not start:
75
+ paho-mqtt missing, a base topic with a wildcard); "broker" host:port; "since" (unix
76
+ time of the last change); "dropped": messages not handed to the broker (published at
77
+ QoS 0 while it was away, or refused by paho: its queue full, an invalid topic).
78
+
79
+ QoS 1/2 while the broker is away: paho queues messages, at most MAX_QUEUED_MESSAGES
80
+ (newer ones are dropped beyond that, logged once per outage), and sends them after the
81
+ next connect; polled values (publish_value) instead keep only the latest one per
82
+ topic, sent after the next connect: no burst of stale readings, none newer lost."""
83
+
84
+ def __init__(self, settings, logger=None, client=None, *, will=None,
85
+ birth: bool = True, on_connect: Optional[Callable[[], None]] = None,
86
+ subscriptions: Optional[dict] = None, accept_retained: bool = False,
87
+ state_topics: Collection[str] = ()) -> None:
88
+ self._s = settings
89
+ self._log = logger or (lambda _m: None)
90
+ self._client = client # injected in tests; otherwise built in connect()
91
+ self._buf = bytearray()
92
+ self._connected = False
93
+ base = settings.base_topic.rstrip("/")
94
+ self._data_topic = base
95
+ self._status_topic = base + "/status"
96
+ self._will = will
97
+ self._birth = birth
98
+ self._on_connect_cb = on_connect
99
+ self._subs: dict[str, Callable[[str, bytes], None]] = dict(subscriptions or {})
100
+ self._accept_retained = accept_retained
101
+ self._state_topics = frozenset(state_topics)
102
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
103
+ end = getattr(settings, "line_end", "lf")
104
+ self._line_end = end if end in LINE_ENDS else "lf" # (a stored unknown one: as before)
105
+ self._cut_logged = False # the buffer was cut without a line end (logged once)
106
+ self._closing = False # close() has begun (a restart then stops short)
107
+ self._renewals = 0 # Wills renewed by the disconnect hook (see restart)
108
+ self._sessions = 0 # connects (CONNACKs) handled
109
+ self._rebirth: Optional[asyncio.TimerHandle] = None # see STALE_WILL_S
110
+ self._broker = f"{settings.host}:{settings.port}"
111
+ self.state: dict[str, Any] = {"state": "connecting", "broker": self._broker,
112
+ "since": int(time.time()), "error": "", "dropped": 0}
113
+ self._said: Optional[str] = None # the last problem logged (each is logged once)
114
+ self._refused = False # paho's thread: the CONNACK refused the connection
115
+ self._sub_mids: dict[int, str] = {} # this session's SUBSCRIBEs waiting for a SUBACK
116
+ self._bad_topics: set[str] = set() # topics whose publish failed (logged once)
117
+ self._queue_full = False # paho's queue overflowed (logged) since the connect
118
+ self._held: dict[str, bytes] = {} # QoS 1/2 polled values while not connected
119
+ self._paho: Any = None # the paho client module (texts of paho 1.x codes)
120
+
121
+ def connect(self) -> bool:
122
+ owned = self._client is None # an injected client (tests) is driven externally
123
+ if self._birth and any(c in self._data_topic for c in "+#"):
124
+ # no message can be published to it (paho refuses), nor can the Will
125
+ problem = f"the base topic {self._data_topic!r} contains '+' or '#'"
126
+ self._log(f"MQTT publishing disabled: {problem}, which a message can't be "
127
+ "published to")
128
+ self._set_state("off", problem)
129
+ return False
130
+ if owned:
131
+ try:
132
+ import paho.mqtt.client as mqtt
133
+ except Exception as e: # missing, or installed but failing to import
134
+ missing = str(optional.problem(e, 'paho-mqtt', 'paho.mqtt'))
135
+ self._log(f"MQTT publishing disabled: {missing}")
136
+ self._set_state("off", missing)
137
+ return False
138
+ self._paho = mqtt
139
+ use_poll(mqtt)
140
+ try: # paho-mqtt 2.x: the current callback API (no deprecation warning)
141
+ self._client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
142
+ client_id=self._s.client_id or "")
143
+ except AttributeError: # paho-mqtt 1.x
144
+ self._client = mqtt.Client(client_id=self._s.client_id or None)
145
+ if self._s.username:
146
+ self._client.username_pw_set(self._s.username, self._s.password)
147
+ if self._s.tls:
148
+ try:
149
+ self._client.tls_set_context(tls_context(self._s))
150
+ except Exception as e: # never a plaintext connection (with the password)
151
+ self._log(f"MQTT publishing disabled: TLS can't be set up: {e}")
152
+ self._set_state("off", f"TLS: {e}")
153
+ self._client = None
154
+ return False
155
+ with contextlib.suppress(Exception): # (paho takes it only while unconnected)
156
+ self._client.max_queued_messages_set(MAX_QUEUED_MESSAGES)
157
+ with contextlib.suppress(RuntimeError):
158
+ self._loop = asyncio.get_running_loop()
159
+ # register the Last-Will (retained offline) before connecting
160
+ self._set_will()
161
+ with contextlib.suppress(Exception):
162
+ self._client.on_connect = self._paho_on_connect
163
+ self._client.on_connect_fail = self._paho_on_connect_fail
164
+ self._client.on_message = self._paho_on_message
165
+ self._client.on_disconnect = self._paho_on_disconnect
166
+ self._client.on_subscribe = self._paho_on_subscribe
167
+ if owned:
168
+ with contextlib.suppress(Exception):
169
+ self._client.connect_async(self._s.host, self._s.port, keepalive=KEEPALIVE_S)
170
+ self._client.loop_start()
171
+ # the "online" birth goes out once the broker has accepted the connection
172
+ # (handle_connect): paho drops a QoS 0 message published before it has a socket
173
+ self._connected = True
174
+ self._set_state("connecting")
175
+ topic = f", topic '{self._data_topic}'" if self._birth else ""
176
+ self._log(f"MQTT: connecting to {self._broker}{' (TLS)' if self._s.tls else ''}{topic}")
177
+ return True
178
+
179
+ def _publish(self, topic: str, payload: bytes, retain: bool = False,
180
+ qos: Optional[int] = None) -> None:
181
+ client = self._client
182
+ if client is None:
183
+ return
184
+ qos = self._s.qos if qos is None else qos
185
+ try:
186
+ info = client.publish(topic, payload, qos=qos, retain=retain)
187
+ except Exception as e: # a topic paho refuses (a wildcard in it), a payload too large
188
+ self.state["dropped"] += 1
189
+ if topic not in self._bad_topics and len(self._bad_topics) < 100:
190
+ self._bad_topics.add(topic)
191
+ self._log(f"MQTT: can't publish to {shown(topic, 120)}: {e}")
192
+ return
193
+ rc = getattr(info, "rc", 0)
194
+ if rc == _NO_CONN and not qos: # (QoS 1/2: queued by paho)
195
+ self.state["dropped"] += 1
196
+ elif rc == _QUEUE_SIZE:
197
+ self.state["dropped"] += 1
198
+ if not self._queue_full:
199
+ self._queue_full = True
200
+ self._log(f"MQTT: {MAX_QUEUED_MESSAGES} QoS {qos} messages are waiting for "
201
+ f"{self._broker}, the most kept; newer ones are dropped until it "
202
+ "takes them")
203
+
204
+ def _set_state(self, link: str, error: str = "") -> None:
205
+ if link != self.state["state"]:
206
+ self.state["state"], self.state["since"] = link, int(time.time())
207
+ self.state["error"] = error
208
+
209
+ def _down(self, problem: str) -> None:
210
+ """An attempt failed: logged once per problem, until connected again."""
211
+ if not self._connected: # a late callback after close()
212
+ return
213
+ self._set_state("down", problem)
214
+ if problem != self._said:
215
+ self._said = problem
216
+ self._log(f"MQTT: {problem}; trying again")
217
+
218
+ def _text(self, rc, connack: bool = False) -> str:
219
+ """A CONNACK / disconnect code as text: paho 2's ReasonCode reads itself, paho 1.x
220
+ gives a number."""
221
+ if not isinstance(rc, int):
222
+ return str(rc)
223
+ describe = getattr(self._paho, "connack_string" if connack else "error_string", None)
224
+ with contextlib.suppress(Exception):
225
+ if describe is not None:
226
+ return str(describe(rc))
227
+ return f"code {int(rc)}"
228
+
229
+ def publish_raw(self, topic: str, payload: bytes, qos: Optional[int] = None,
230
+ retain: bool = False) -> None:
231
+ """Publish to an absolute topic (e.g. Sparkplug B spBv1.0/...)."""
232
+ if self._connected:
233
+ self._publish(topic, payload, retain=retain, qos=qos)
234
+
235
+ def _set_will(self) -> None:
236
+ will = self._will() if callable(self._will) else self._will
237
+ will = will or (self._status_topic, b"offline", self._s.qos, True)
238
+ with contextlib.suppress(Exception):
239
+ self._client.will_set(will[0], will[1], qos=will[2], retain=will[3])
240
+
241
+ def _paho_on_disconnect(self, client, userdata, *args) -> None:
242
+ if callable(self._will): # paho reconnects with whatever will is set now
243
+ self._set_will()
244
+ self._renewals += 1
245
+ # paho 2: (flags, reason code, properties); paho 1.x: (rc,)
246
+ rc = args[1] if len(args) >= 2 else args[0] if args else 0
247
+ failed = getattr(rc, "is_failure", None)
248
+ if failed is None:
249
+ failed = isinstance(rc, int) and rc != 0
250
+ refused, self._refused = self._refused, False
251
+ self._call_soon(self.handle_disconnect, self._text(rc), bool(failed), refused)
252
+
253
+ async def restart(self, final: Optional[tuple] = None) -> None:
254
+ """End this MQTT session and begin a new one: send ``final`` (topic, payload, qos,
255
+ retain) first, disconnect cleanly (the broker publishes no Will then), and connect
256
+ again, with the Will renewed as after any disconnect. paho's network thread is
257
+ stopped and started again off the event loop (stopping it waits for it)."""
258
+ client = self._client
259
+ if client is None or self._closing:
260
+ return
261
+ if final is not None:
262
+ self._publish(final[0], final[1], qos=final[2], retain=final[3])
263
+ renewed = self._renewals
264
+
265
+ def cycle() -> None:
266
+ with contextlib.suppress(Exception):
267
+ client.disconnect()
268
+ with contextlib.suppress(Exception):
269
+ client.loop_stop()
270
+ if self._closing:
271
+ return
272
+ if callable(self._will) and self._renewals == renewed:
273
+ self._set_will() # no disconnect callback came (it was not connected)
274
+ with contextlib.suppress(Exception):
275
+ client.connect_async(self._s.host, self._s.port, keepalive=KEEPALIVE_S)
276
+ client.loop_start()
277
+ if self._closing: # closed meanwhile: leave no connection behind
278
+ with contextlib.suppress(Exception):
279
+ client.loop_stop()
280
+ client.disconnect()
281
+ await asyncio.to_thread(cycle)
282
+
283
+ # ---- paho callbacks (its network thread) -> the event loop ----
284
+ def _call_soon(self, fn, *args) -> None:
285
+ loop = self._loop
286
+ if loop is None or loop.is_closed():
287
+ return
288
+ with contextlib.suppress(RuntimeError): # loop shutting down
289
+ loop.call_soon_threadsafe(fn, *args)
290
+
291
+ def _paho_on_connect(self, client, userdata, flags, rc, properties=None) -> None:
292
+ if getattr(rc, "is_failure", False) or (isinstance(rc, int) and rc != 0):
293
+ self._refused = True # paho reports the disconnect that follows: not a loss
294
+ self._call_soon(self._down, f"{self._broker} refused the connection: "
295
+ f"{self._text(rc, connack=True)}")
296
+ return
297
+ self._refused = False
298
+ self._call_soon(self.handle_connect)
299
+
300
+ def _paho_on_connect_fail(self, client, userdata) -> None:
301
+ """paho calls it while handling the OSError of a failed attempt: no route or
302
+ answer, DNS, a refused port, the TLS handshake (a certificate check)."""
303
+ e = sys.exc_info()[1]
304
+ why = (str(e) or type(e).__name__) if e is not None else "connection failed"
305
+ self._call_soon(self._down, f"can't connect to {self._broker}: {why}")
306
+
307
+ def _paho_on_subscribe(self, client, userdata, mid, codes, *args) -> None:
308
+ # paho 2: reason codes; paho 1.x (MQTT 3.1.1): granted QoS, 128 = refused
309
+ if any(getattr(c, "is_failure", False) or c == 128 for c in codes or ()):
310
+ self._call_soon(self._subscribe_refused, mid)
311
+
312
+ def _paho_on_message(self, client, userdata, msg) -> None:
313
+ self._call_soon(self.handle_message, msg.topic, bytes(msg.payload),
314
+ bool(getattr(msg, "retain", False)))
315
+
316
+ def handle_connect(self) -> None:
317
+ """After every (re)connect: the retained "online" birth, the subscriptions again,
318
+ the owner's callback, then the polled values kept while not connected."""
319
+ if not self._connected or self._client is None: # a late callback after close()
320
+ return
321
+ self._sessions += 1
322
+ self._set_state("connected")
323
+ self._said = None
324
+ self._queue_full = False
325
+ self._log(f"MQTT: connected to {self._broker}")
326
+ if self._birth:
327
+ self._publish(self._status_topic, b"online", retain=True)
328
+ if not self._s.client_id and self._loop is not None:
329
+ if self._rebirth is not None:
330
+ self._rebirth.cancel()
331
+ self._rebirth = self._loop.call_later(STALE_WILL_S, self._birth_again,
332
+ self._sessions)
333
+ self._sub_mids.clear()
334
+ for topic in self._subs:
335
+ with contextlib.suppress(Exception):
336
+ result = self._client.subscribe(topic, qos=1) # (rc, mid)
337
+ if isinstance(result, tuple) and len(result) == 2:
338
+ self._sub_mids[result[1]] = topic
339
+ if self._on_connect_cb is not None:
340
+ try:
341
+ self._on_connect_cb()
342
+ except Exception as e:
343
+ self._log(f"MQTT on-connect handler failed: {e}")
344
+ held, self._held = self._held, {}
345
+ for topic, payload in held.items():
346
+ self._publish(topic, payload, retain=self._s.retain)
347
+
348
+ def handle_disconnect(self, reason: str, failed: bool, refused: bool = False) -> None:
349
+ """The connection ended (on the event loop): ``failed`` — not because it was asked
350
+ to (close, restart); ``refused`` — it was the CONNACK's refusal, already reported."""
351
+ if not self._connected or refused:
352
+ return
353
+ if self.state["state"] == "connected":
354
+ if failed:
355
+ self._down(f"lost the connection to {self._broker}: {reason}")
356
+ else:
357
+ self._set_state("connecting")
358
+ elif failed: # it ended before the broker accepted it (no CONNACK)
359
+ self._down(f"{self._broker} closed the connection before accepting it: {reason}")
360
+
361
+ def _subscribe_refused(self, mid: int) -> None:
362
+ topic = self._sub_mids.pop(mid, None)
363
+ if self._connected and topic is not None:
364
+ self._log(f"MQTT: {self._broker} refused the subscription to {shown(topic, 120)}; "
365
+ "messages on it won't arrive (the broker's access rules?)")
366
+
367
+ def _birth_again(self, session: int) -> None:
368
+ """STALE_WILL_S after a connect, still in that session: "online" again, over a Will
369
+ of an earlier connection that the broker may have published after the birth."""
370
+ self._rebirth = None
371
+ if (self._connected and session == self._sessions
372
+ and self.state["state"] == "connected"):
373
+ self._publish(self._status_topic, b"online", retain=True)
374
+
375
+ def handle_message(self, topic: str, payload: bytes, retained: bool = False) -> None:
376
+ """A message on a subscribed topic (on the event loop); ``retained``: the broker
377
+ sent it from its retained store (paho's ``msg.retain``), not live."""
378
+ if not self._connected or not payload: # closed; or it clears a retained message,
379
+ return # which is never a command
380
+ handler = self._subs.get(topic)
381
+ if handler is None: # a subscription with a wildcard
382
+ handler = next((h for t, h in self._subs.items() if _matches(t, topic)), None)
383
+ if handler is not None and retained and topic not in self._state_topics:
384
+ if not self._accept_retained:
385
+ self._log(f"MQTT: ignored the retained message on {shown(topic, 120)}; a "
386
+ "command is applied when it is published, not again from the "
387
+ "broker's retained store (publish commands without retain)")
388
+ return
389
+ self._log(f"MQTT: applying the retained message on {shown(topic, 120)}")
390
+ if handler is not None:
391
+ try:
392
+ handler(topic, payload)
393
+ except Exception as e:
394
+ self._log(f"MQTT message on {topic}: {e}")
395
+
396
+ def publish_value(self, subtopic: str, payload: bytes) -> None:
397
+ """Publish a payload to <base_topic>/<subtopic> (used by Modbus register polling),
398
+ retained with mqtt.retain. At QoS 1/2 while not connected, only the latest value
399
+ per topic is kept."""
400
+ if not self._connected:
401
+ return
402
+ topic = self._data_topic + "/" + subtopic
403
+ if self._s.qos and self.state["state"] != "connected":
404
+ self._held.pop(topic, None) # (in the order they were last read)
405
+ self._held[topic] = payload
406
+ return
407
+ self._publish(topic, payload, retain=self._s.retain)
408
+
409
+ def feed(self, data: bytes) -> None:
410
+ """Buffer serial bytes and publish each complete line (line_end: LF, CR or either;
411
+ an empty line is not published; retained with mqtt.retain: a subscriber that
412
+ connects later gets the last line)."""
413
+ if not self._connected or self._client is None:
414
+ return
415
+ mode = self._line_end
416
+ if mode == "any":
417
+ data = data.replace(b"\r", b"\n") # (\r\n: an empty line in between)
418
+ self._buf += data
419
+ sep = b"\r" if mode == "cr" else b"\n"
420
+ while True:
421
+ end = self._buf.find(sep)
422
+ if end < 0:
423
+ break
424
+ line = bytes(self._buf[:end])
425
+ del self._buf[:end + 1]
426
+ # LF: a CR before it is not part of the line; CR: an LF next to it
427
+ line = line.rstrip(b"\r") if mode == "lf" else line.strip(b"\n")
428
+ if line:
429
+ self._publish(self._data_topic, line, retain=self._s.retain)
430
+ if len(self._buf) > MAX_LINE_BUFFER: # no line end in sight — don't grow forever
431
+ del self._buf[:-4096]
432
+ if not self._cut_logged:
433
+ self._cut_logged = True
434
+ shown_end = {"lf": "LF", "cr": "CR", "any": "CR or LF"}[mode]
435
+ self._log(f"MQTT: {MAX_LINE_BUFFER // 1024} KiB of serial data without a line "
436
+ f"end ({shown_end}): the oldest bytes were dropped, nothing was "
437
+ "published; a device that ends its lines otherwise needs another "
438
+ "MQTT line end (logged once)")
439
+
440
+ def close(self, final: Optional[tuple] = None) -> None:
441
+ """Disconnect; ``final`` = (topic, payload, qos, retain) to send first instead of
442
+ the retained "offline" status (Sparkplug B: an NDEATH). Waits for paho's network
443
+ thread, which stops only once the broker has taken what is queued or the
444
+ connection failed: from the event loop, use aclose()."""
445
+ client = self._last_messages(final)
446
+ if client is not None:
447
+ _stop_client(client)
448
+
449
+ async def aclose(self, final: Optional[tuple] = None,
450
+ timeout: float = MQTT_CLOSE_TIMEOUT_S) -> None:
451
+ """close() without holding up the event loop: paho's network thread is stopped
452
+ from a thread of its own (paho waits there until the broker took the last
453
+ messages, a QoS 1 one acknowledged, or the connection failed, which with a broker
454
+ that stopped answering lasts until the keepalive gives up), waited for at most
455
+ ``timeout`` seconds. What the broker has not taken by then is lost; its Last-Will
456
+ still tells subscribers that the link is gone."""
457
+ client = self._last_messages(final)
458
+ if client is None:
459
+ return
460
+ loop = asyncio.get_running_loop()
461
+ stopped = loop.create_future()
462
+
463
+ def done() -> None:
464
+ if not stopped.done():
465
+ stopped.set_result(None)
466
+
467
+ def stop() -> None:
468
+ _stop_client(client)
469
+ with contextlib.suppress(RuntimeError): # the loop has closed meanwhile
470
+ loop.call_soon_threadsafe(done)
471
+
472
+ threading.Thread(target=stop, name="mqtt-close", daemon=True).start()
473
+ try:
474
+ await asyncio.wait_for(stopped, timeout)
475
+ except asyncio.TimeoutError:
476
+ self._log(f"MQTT: the broker did not take the last messages within {timeout:g} s; "
477
+ "closing without waiting for it")
478
+
479
+ def _last_messages(self, final: Optional[tuple]) -> Any:
480
+ """Queue the last messages and hand over the client (None: not connected). paho's
481
+ callbacks that come later change nothing (_connected is False), and a restart
482
+ under way stops short (_closing)."""
483
+ client, self._client = self._client, None
484
+ if client is None:
485
+ return None
486
+ self._closing = True
487
+ self._connected = False
488
+ if self._rebirth is not None:
489
+ self._rebirth.cancel()
490
+ self._rebirth = None
491
+ topic, payload, qos, retain = final or (self._status_topic, b"offline", None, True)
492
+ if final is not None or self._birth:
493
+ with contextlib.suppress(Exception):
494
+ client.publish(topic, payload, qos=self._s.qos if qos is None else qos,
495
+ retain=retain)
496
+ return client
497
+
498
+
499
+ def _stop_client(client: Any) -> None:
500
+ """Stop paho's network thread (it first sends what is queued), then disconnect."""
501
+ with contextlib.suppress(Exception):
502
+ client.loop_stop()
503
+ client.disconnect()
504
+
505
+
506
+ class TlsFileError(ValueError):
507
+ """A TLS file of an MQTT connection can't be used. ``kind``: ca_unreadable, ca_empty,
508
+ key_without_cert, cert_unreadable, cert_unusable; ``path`` the file, ``detail`` what
509
+ the system said. str(): the log's text."""
510
+
511
+ def __init__(self, kind: str, text: str, path: str = "", detail: str = ""):
512
+ super().__init__(text)
513
+ self.kind, self.path, self.detail = kind, path, detail
514
+
515
+
516
+ def tls_context(settings) -> Any:
517
+ """The TLS context of an MQTT connection: the broker's certificate must chain to a CA
518
+ the system trusts (its CA bundle; Windows: its certificate store) — or, with ca_file,
519
+ to a certificate in that file only (PEM, or DER as Windows exports it) — and, unless
520
+ tls_check_hostname is off, name the broker host (a DNS name or an IP address). With
521
+ client_cert, that certificate is presented to a broker that asks for one (mutual TLS);
522
+ its key is in client_key or in the same file, decrypted with client_key_password (an
523
+ encrypted key with a wrong or no password is an error: OpenSSL never asks on a
524
+ terminal). Built by the shared helper (engine/tls.tls_context), so the files are read
525
+ as a mapping's TLS files are: a relative path in the data directory. Raises
526
+ TlsFileError."""
527
+ from . import tls
528
+
529
+ cert = str(settings.client_cert or "").strip()
530
+ key = str(settings.client_key or "").strip()
531
+ if key and not cert:
532
+ raise TlsFileError("key_without_cert", "a client key needs its client certificate",
533
+ key)
534
+ try:
535
+ return tls.tls_context(server_side=False, cert=cert, key=key,
536
+ password=settings.client_key_password,
537
+ ca=str(settings.ca_file or "").strip(), verify=True,
538
+ check_hostname=settings.tls_check_hostname is not False)
539
+ except tls.TlsFileError as e:
540
+ # (the shared kinds as the MQTT messages name them: a key that needs a password,
541
+ # a wrong one or another certificate's key is one "can't be used with its key")
542
+ kind = {"ca_unreadable": "ca_unreadable", "ca_empty": "ca_empty",
543
+ "unreadable": "cert_unreadable"}.get(e.kind, "cert_unusable")
544
+ raise TlsFileError(kind, str(e), e.path, e.detail) from None
545
+
546
+
547
+ class PollSelect:
548
+ """Stands in for the select module inside paho-mqtt's client module. Its network
549
+ thread waits on the broker socket with select.select(), which raises ValueError for a
550
+ descriptor >= FD_SETSIZE (1024): in a process with more files open than that, paho
551
+ took every such wait for a lost connection and reconnected forever, without a word.
552
+ select() here answers the same call with poll(), which has no such limit; everything
553
+ else is the real select module."""
554
+
555
+ def __getattr__(self, name: str) -> Any:
556
+ return getattr(select, name)
557
+
558
+ @staticmethod
559
+ def select(rlist, wlist, xlist, timeout=None):
560
+ """select.select(), with its errors for a bad argument: TypeError for something
561
+ that is not a descriptor, ValueError for a negative one, OSError (EBADF) for one
562
+ that is not open."""
563
+ if sys.platform == "win32": # no poll() there: use_poll installs this on POSIX only
564
+ return select.select(rlist, wlist, xlist, timeout)
565
+ if timeout is not None and timeout < 0:
566
+ raise ValueError("timeout must be non-negative")
567
+ lists = [[(_fd(o), o) for o in objs] for objs in (rlist, wlist, xlist)]
568
+ poller = select.poll()
569
+ wanted: dict[int, int] = {}
570
+ for (entries, flag) in zip(lists, (select.POLLIN, select.POLLOUT, select.POLLPRI),
571
+ strict=True):
572
+ for fd, _o in entries:
573
+ wanted[fd] = wanted.get(fd, 0) | flag
574
+ for fd, flags in wanted.items():
575
+ poller.register(fd, flags)
576
+ ms = None if timeout is None else math.ceil(timeout * 1000)
577
+ events = dict(poller.poll(ms))
578
+ if any(ev & select.POLLNVAL for ev in events.values()):
579
+ raise OSError(errno.EBADF, "Bad file descriptor")
580
+ # what select() reports for each poll() result (as Linux maps them)
581
+ readable = select.POLLIN | select.POLLHUP | select.POLLERR
582
+ writable = select.POLLOUT | select.POLLERR
583
+ return ([o for fd, o in lists[0] if events.get(fd, 0) & readable],
584
+ [o for fd, o in lists[1] if events.get(fd, 0) & writable],
585
+ [o for fd, o in lists[2] if events.get(fd, 0) & select.POLLPRI])
586
+
587
+
588
+ def _fd(obj) -> int:
589
+ fd = obj if isinstance(obj, int) else getattr(obj, "fileno", None)
590
+ if callable(fd):
591
+ fd = fd()
592
+ if not isinstance(fd, int):
593
+ raise TypeError("argument must be an int, or have a fileno() method.")
594
+ if fd < 0:
595
+ raise ValueError(f"file descriptor cannot be a negative integer ({fd})")
596
+ return fd
597
+
598
+
599
+ def use_poll(paho_client_module) -> bool:
600
+ """Make paho's client wait with poll() (PollSelect), on POSIX; once, and only while that
601
+ module still waits through the select module. Returns whether it does now."""
602
+ current = getattr(paho_client_module, "select", None)
603
+ if isinstance(current, PollSelect):
604
+ return True
605
+ if current is not select or not hasattr(select, "poll"):
606
+ return False # Windows (select() limits the count, not the value) or a changed paho
607
+ paho_client_module.select = PollSelect()
608
+ return True
609
+
610
+
611
+ def shown(value, limit: int = 40) -> str:
612
+ """repr() of a value that came in over MQTT, cut to ``limit`` characters: what a log line
613
+ or a .../set/result answer quotes of it (a payload can be as large as the broker allows).
614
+ A value nested deeper than repr() can follow (a JSON request Python 3.14 and later parse:
615
+ their parser goes as deep as the stack, repr() hits the stack's end sooner, e.g. on
616
+ Windows' smaller one) is named, not quoted: the error would end the writer instead."""
617
+ try:
618
+ text = repr(value)
619
+ except RecursionError:
620
+ text = f"a {type(value).__name__} nested too deeply"
621
+ return text if len(text) <= limit else text[:limit - 1] + "…"
622
+
623
+
624
+ def _matches(pattern: str, topic: str) -> bool:
625
+ """MQTT topic filter match ('+' one level, '#' the rest)."""
626
+ p, t = pattern.split("/"), topic.split("/")
627
+ for i, part in enumerate(p):
628
+ if part == "#":
629
+ return True
630
+ if i >= len(t) or (part != "+" and part != t[i]):
631
+ return False
632
+ return len(p) == len(t)