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