serialforge 0.0.1__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.
- serialforge/__init__.py +56 -0
- serialforge/advanced.py +272 -0
- serialforge/connection/__init__.py +5 -0
- serialforge/connection/command_dispatcher.py +671 -0
- serialforge/connection/command_registry.py +193 -0
- serialforge/connection/connection_worker.py +380 -0
- serialforge/connection/pending_command.py +37 -0
- serialforge/connection/read_thread.py +72 -0
- serialforge/connection/response_parser.py +92 -0
- serialforge/connection/serial_handler.py +284 -0
- serialforge/diagnostics/__init__.py +6 -0
- serialforge/diagnostics/log_file_manager.py +352 -0
- serialforge/diagnostics/traffic_logger.py +286 -0
- serialforge/discovery/__init__.py +5 -0
- serialforge/discovery/async_scan_thread.py +39 -0
- serialforge/discovery/baud_cache.py +117 -0
- serialforge/discovery/baud_prober.py +187 -0
- serialforge/discovery/device_finder.py +161 -0
- serialforge/discovery/port_scanner.py +48 -0
- serialforge/enums.py +129 -0
- serialforge/errors.py +47 -0
- serialforge/models.py +419 -0
- serialforge/protocols.py +53 -0
- serialforge/py.typed +0 -0
- serialforge/settings.py +14 -0
- serialforge/testing/__init__.py +12 -0
- serialforge/testing/fake_backend.py +54 -0
- serialforge/testing/fake_transport.py +128 -0
- serialforge/testing/simulated_device.py +67 -0
- serialforge/transport/__init__.py +19 -0
- serialforge/transport/backend_switch.py +36 -0
- serialforge/transport/checksum_calculator.py +77 -0
- serialforge/transport/frame_splitter.py +134 -0
- serialforge/transport/latency_tracker.py +65 -0
- serialforge/transport/port_options.py +31 -0
- serialforge/transport/port_registry.py +57 -0
- serialforge/transport/serial_transport.py +90 -0
- serialforge-0.0.1.dist-info/METADATA +37 -0
- serialforge-0.0.1.dist-info/RECORD +41 -0
- serialforge-0.0.1.dist-info/WHEEL +4 -0
- serialforge-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Queue serial commands and associate complete frames with invocations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# pylint: disable=no-name-in-module
|
|
14
|
+
from PySide6.QtCore import QObject, Signal
|
|
15
|
+
|
|
16
|
+
from ..advanced import CommandTicket, HandlerStats, RuntimeConfig
|
|
17
|
+
from ..enums import (
|
|
18
|
+
CommandPriority,
|
|
19
|
+
CommandStatus,
|
|
20
|
+
Correlation,
|
|
21
|
+
ResponseMode,
|
|
22
|
+
SendRoute,
|
|
23
|
+
TimeoutPolicy,
|
|
24
|
+
)
|
|
25
|
+
from ..errors import CommandError
|
|
26
|
+
from ..models import CommandResult, CommandSpec, DeviceEvent, DeviceProfile
|
|
27
|
+
from ..transport import LatencyTracker
|
|
28
|
+
from .command_registry import CommandRegistry
|
|
29
|
+
from .pending_command import PendingCommand
|
|
30
|
+
from .response_parser import ResponseParser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# One dispatcher owns the synchronized queue, in-flight state, and deadlines.
|
|
34
|
+
# pylint: disable=too-many-instance-attributes,too-many-boolean-expressions
|
|
35
|
+
class CommandDispatcher(QObject):
|
|
36
|
+
"""Run the M4 command state machine on a transport owner's thread.
|
|
37
|
+
|
|
38
|
+
``submit`` only enqueues and may be called from any thread. The owner calls
|
|
39
|
+
``pump`` and ``receive_frame`` serially after opening a transport. The
|
|
40
|
+
owner is responsible for byte framing and for continuing to pump timeouts.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
command_finished = Signal(object)
|
|
44
|
+
event_received = Signal(object)
|
|
45
|
+
error_occurred = Signal(object)
|
|
46
|
+
tx_written = Signal(bytes, object, object)
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
profile: DeviceProfile,
|
|
51
|
+
registry: CommandRegistry,
|
|
52
|
+
write: Callable[[bytes], int],
|
|
53
|
+
*,
|
|
54
|
+
clock: Callable[[], float] = time.monotonic,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Bind a profile, registry, and single transport write function."""
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._profile: DeviceProfile = profile
|
|
59
|
+
self._registry: CommandRegistry = registry
|
|
60
|
+
self._write: Callable[[bytes], int] = write
|
|
61
|
+
self._clock: Callable[[], float] = clock
|
|
62
|
+
runtime = profile.runtime
|
|
63
|
+
assert isinstance(runtime, RuntimeConfig)
|
|
64
|
+
self._runtime: RuntimeConfig = runtime
|
|
65
|
+
self._queue: list[PendingCommand] = []
|
|
66
|
+
self._inflight: list[PendingCommand] = []
|
|
67
|
+
self._streams: list[PendingCommand] = []
|
|
68
|
+
self._writing_pending: PendingCommand | None = None
|
|
69
|
+
self._sequence: int = 0
|
|
70
|
+
self._connected: bool = False
|
|
71
|
+
self._reconnecting: bool = False
|
|
72
|
+
self._retry_after_reconnect: list[
|
|
73
|
+
tuple[CommandSpec, Mapping[str, object], CommandPriority]
|
|
74
|
+
] = []
|
|
75
|
+
self._last_write_at: float | None = None
|
|
76
|
+
self._drain_until: float = 0.0
|
|
77
|
+
self._clear_input: Callable[[], None] | None = None
|
|
78
|
+
self._latency: LatencyTracker = LatencyTracker()
|
|
79
|
+
self._auto_rto: float = 1.0
|
|
80
|
+
self._events: deque[DeviceEvent] = deque()
|
|
81
|
+
self._last_event_emit_at: float = clock()
|
|
82
|
+
self._dropped_events: int = 0
|
|
83
|
+
# A lock keeps the snapshot and queue coherent across caller threads.
|
|
84
|
+
self._lock: threading.RLock = threading.RLock()
|
|
85
|
+
|
|
86
|
+
def set_connected(
|
|
87
|
+
self,
|
|
88
|
+
connected: bool,
|
|
89
|
+
*,
|
|
90
|
+
user_initiated: bool = False,
|
|
91
|
+
reconnecting: bool = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Mark transport availability and finish outstanding work on loss."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._connected = connected
|
|
96
|
+
self._reconnecting = reconnecting and not connected
|
|
97
|
+
if not connected:
|
|
98
|
+
status = (
|
|
99
|
+
CommandStatus.CANCELLED
|
|
100
|
+
if user_initiated
|
|
101
|
+
else CommandStatus.DISCONNECTED
|
|
102
|
+
)
|
|
103
|
+
active = self._queue + self._inflight + self._streams
|
|
104
|
+
if self._writing_pending is not None:
|
|
105
|
+
active.append(self._writing_pending)
|
|
106
|
+
for pending in tuple(active):
|
|
107
|
+
if reconnecting:
|
|
108
|
+
self._remember_retry(pending)
|
|
109
|
+
self._finish(pending, status)
|
|
110
|
+
if not reconnecting:
|
|
111
|
+
self._retry_after_reconnect.clear()
|
|
112
|
+
self._drain_until = 0.0
|
|
113
|
+
self._latency.reset()
|
|
114
|
+
self._auto_rto = 1.0
|
|
115
|
+
elif self._retry_after_reconnect:
|
|
116
|
+
retry = tuple(self._retry_after_reconnect)
|
|
117
|
+
self._retry_after_reconnect.clear()
|
|
118
|
+
for spec, params, priority in retry:
|
|
119
|
+
if self._registry.contains(spec):
|
|
120
|
+
# Placeholder values are intentionally opaque objects;
|
|
121
|
+
# ``submit`` validates their string rendering.
|
|
122
|
+
# ty: ignore[invalid-argument-type] -- retry params are command values.
|
|
123
|
+
self.submit(spec, priority=priority, **params)
|
|
124
|
+
|
|
125
|
+
def set_clear_input(self, clear_input: Callable[[], None]) -> None:
|
|
126
|
+
"""Provide the transport's input-buffer drain action."""
|
|
127
|
+
self._clear_input = clear_input
|
|
128
|
+
|
|
129
|
+
def cancel_ticket(
|
|
130
|
+
self,
|
|
131
|
+
ticket: CommandTicket,
|
|
132
|
+
status: CommandStatus = CommandStatus.CANCELLED,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Finish one accepted ticket immediately and at most once."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
active = self._queue + self._inflight + self._streams
|
|
137
|
+
if self._writing_pending is not None:
|
|
138
|
+
active.append(self._writing_pending)
|
|
139
|
+
for pending in active:
|
|
140
|
+
if pending.ticket is ticket:
|
|
141
|
+
self._finish(pending, status)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Validation must distinguish spec, matched text, and raw text paths.
|
|
145
|
+
# pylint: disable=too-many-branches
|
|
146
|
+
def submit(
|
|
147
|
+
self,
|
|
148
|
+
target: CommandSpec | str,
|
|
149
|
+
*,
|
|
150
|
+
priority: CommandPriority = CommandPriority.NORMAL,
|
|
151
|
+
allow_raw_text: bool = True,
|
|
152
|
+
**params: object,
|
|
153
|
+
) -> CommandTicket:
|
|
154
|
+
"""Validate and enqueue a command without performing an I/O write.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
CommandError: If the declaration, text, or parameters are invalid.
|
|
158
|
+
"""
|
|
159
|
+
spec: CommandSpec | None
|
|
160
|
+
route: SendRoute
|
|
161
|
+
actual_params: Mapping[str, object]
|
|
162
|
+
if isinstance(target, CommandSpec):
|
|
163
|
+
if not self._registry.contains(target):
|
|
164
|
+
raise CommandError(
|
|
165
|
+
f"命令 {target.name!r} 尚未注册,请先调用 handler.register"
|
|
166
|
+
)
|
|
167
|
+
spec = target
|
|
168
|
+
route = SendRoute.SPEC
|
|
169
|
+
actual_params = params
|
|
170
|
+
sent = self.render_request(spec, params)
|
|
171
|
+
elif isinstance(target, str):
|
|
172
|
+
if params:
|
|
173
|
+
raise CommandError("text command cannot also receive params")
|
|
174
|
+
match = self._registry.resolve_text(
|
|
175
|
+
target, self._profile.terminator
|
|
176
|
+
)
|
|
177
|
+
if match is None:
|
|
178
|
+
if not allow_raw_text:
|
|
179
|
+
raise CommandError(f"unregistered command text: {target!r}")
|
|
180
|
+
spec = None
|
|
181
|
+
route = SendRoute.RAW
|
|
182
|
+
actual_params = {}
|
|
183
|
+
sent = self.render_raw(target)
|
|
184
|
+
else:
|
|
185
|
+
spec, actual_params = match
|
|
186
|
+
route = SendRoute.MATCHED
|
|
187
|
+
sent = self.render_request(spec, actual_params)
|
|
188
|
+
else:
|
|
189
|
+
raise CommandError("send target must be CommandSpec or str")
|
|
190
|
+
if not isinstance(priority, CommandPriority):
|
|
191
|
+
raise CommandError("priority must be CommandPriority")
|
|
192
|
+
now = self._clock()
|
|
193
|
+
ticket = CommandTicket(uuid.uuid4().hex, spec, route)
|
|
194
|
+
with self._lock:
|
|
195
|
+
self._sequence += 1
|
|
196
|
+
pending = PendingCommand(
|
|
197
|
+
ticket=ticket,
|
|
198
|
+
spec=spec,
|
|
199
|
+
route=route,
|
|
200
|
+
params=actual_params,
|
|
201
|
+
sent=sent,
|
|
202
|
+
priority=priority,
|
|
203
|
+
sequence=self._sequence,
|
|
204
|
+
queued_at=now,
|
|
205
|
+
)
|
|
206
|
+
if not self._connected and not (
|
|
207
|
+
self._reconnecting and self._runtime.queue_while_reconnecting
|
|
208
|
+
):
|
|
209
|
+
self._finish(pending, CommandStatus.DISCONNECTED)
|
|
210
|
+
elif (
|
|
211
|
+
self._streams
|
|
212
|
+
and spec is not None
|
|
213
|
+
and spec.mode is not ResponseMode.NO_REPLY
|
|
214
|
+
and spec.correlation is Correlation.EXCLUSIVE
|
|
215
|
+
):
|
|
216
|
+
self._finish(pending, CommandStatus.BUSY)
|
|
217
|
+
elif len(self._queue) >= self._runtime.queue_limit:
|
|
218
|
+
self._finish(pending, CommandStatus.BUSY)
|
|
219
|
+
else:
|
|
220
|
+
self._queue.append(pending)
|
|
221
|
+
self._queue.sort(
|
|
222
|
+
key=lambda item: (-item.priority, item.sequence)
|
|
223
|
+
)
|
|
224
|
+
return ticket
|
|
225
|
+
|
|
226
|
+
def render_request(
|
|
227
|
+
self, spec: CommandSpec, params: Mapping[str, object]
|
|
228
|
+
) -> bytes:
|
|
229
|
+
"""Render exactly one safe line from a registered declaration."""
|
|
230
|
+
expected = set(spec.placeholders)
|
|
231
|
+
if set(params) != expected:
|
|
232
|
+
raise CommandError(
|
|
233
|
+
f"request params must be {sorted(expected)}, "
|
|
234
|
+
f"got {sorted(params)}"
|
|
235
|
+
)
|
|
236
|
+
values: dict[str, str] = {}
|
|
237
|
+
for name, value in params.items():
|
|
238
|
+
rendered = str(value)
|
|
239
|
+
CommandRegistry.validate_text(rendered)
|
|
240
|
+
values[name] = rendered
|
|
241
|
+
request = CommandRegistry.normalise(
|
|
242
|
+
spec.request.format_map(values), self._profile.terminator
|
|
243
|
+
)
|
|
244
|
+
CommandRegistry.validate_text(request)
|
|
245
|
+
return (request + self._profile.terminator).encode(
|
|
246
|
+
self._profile.encoding
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def render_raw(self, text: str) -> bytes:
|
|
250
|
+
"""Retain the caller's text while adding exactly one terminator."""
|
|
251
|
+
request = text.rstrip()
|
|
252
|
+
terminator = self._profile.terminator
|
|
253
|
+
if request.endswith(terminator):
|
|
254
|
+
request = request[: -len(terminator)]
|
|
255
|
+
CommandRegistry.validate_text(request.strip())
|
|
256
|
+
return (request + terminator).encode(self._profile.encoding)
|
|
257
|
+
|
|
258
|
+
# Queue scheduling and three independent timeout kinds share one clock tick.
|
|
259
|
+
# pylint: disable=too-many-branches
|
|
260
|
+
def pump(self) -> None:
|
|
261
|
+
"""Advance timeouts, cancellation, throttled events, and writes."""
|
|
262
|
+
while True:
|
|
263
|
+
with self._lock:
|
|
264
|
+
if self._writing_pending is not None:
|
|
265
|
+
return
|
|
266
|
+
now = self._clock()
|
|
267
|
+
for pending in tuple(
|
|
268
|
+
self._queue + self._inflight + self._streams
|
|
269
|
+
):
|
|
270
|
+
if pending.ticket.cancelled:
|
|
271
|
+
self._finish(pending, CommandStatus.CANCELLED)
|
|
272
|
+
continue
|
|
273
|
+
if pending.started_at is None or pending.spec is None:
|
|
274
|
+
continue
|
|
275
|
+
spec = pending.spec
|
|
276
|
+
elapsed = now - pending.started_at
|
|
277
|
+
if (
|
|
278
|
+
spec.total_timeout_s is not None
|
|
279
|
+
and elapsed >= spec.total_timeout_s
|
|
280
|
+
):
|
|
281
|
+
self.timeout(pending, now)
|
|
282
|
+
elif (
|
|
283
|
+
pending.first_frame_at is None
|
|
284
|
+
and elapsed >= pending.timeout_s
|
|
285
|
+
and spec.mode is not ResponseMode.STREAM
|
|
286
|
+
):
|
|
287
|
+
self.timeout(pending, now)
|
|
288
|
+
elif (
|
|
289
|
+
spec.mode is ResponseMode.MULTI
|
|
290
|
+
and pending.last_frame_at is not None
|
|
291
|
+
and now - pending.last_frame_at >= spec.idle_timeout_s
|
|
292
|
+
):
|
|
293
|
+
self._finish(pending, CommandStatus.OK)
|
|
294
|
+
if self._drain_until and now >= self._drain_until:
|
|
295
|
+
self._drain_until = 0.0
|
|
296
|
+
if self._clear_input is not None:
|
|
297
|
+
self._clear_input()
|
|
298
|
+
interval = self._runtime.stream_emit_interval_ms / 1000
|
|
299
|
+
if now - self._last_event_emit_at >= interval:
|
|
300
|
+
self.flush_events()
|
|
301
|
+
if not self._connected or not self._queue:
|
|
302
|
+
return
|
|
303
|
+
if now < self._drain_until:
|
|
304
|
+
return
|
|
305
|
+
if (
|
|
306
|
+
self._last_write_at is not None
|
|
307
|
+
and now - self._last_write_at
|
|
308
|
+
< self._runtime.min_command_interval_s
|
|
309
|
+
):
|
|
310
|
+
return
|
|
311
|
+
pending = self._queue[0]
|
|
312
|
+
spec = pending.spec
|
|
313
|
+
needs_reply = (
|
|
314
|
+
spec is not None and spec.mode is not ResponseMode.NO_REPLY
|
|
315
|
+
)
|
|
316
|
+
if self._inflight and (
|
|
317
|
+
any(
|
|
318
|
+
item.spec is not None
|
|
319
|
+
and item.spec.correlation is Correlation.EXCLUSIVE
|
|
320
|
+
for item in self._inflight
|
|
321
|
+
)
|
|
322
|
+
or (
|
|
323
|
+
needs_reply
|
|
324
|
+
and spec.correlation is Correlation.EXCLUSIVE
|
|
325
|
+
)
|
|
326
|
+
or pending.route is SendRoute.RAW
|
|
327
|
+
):
|
|
328
|
+
return
|
|
329
|
+
self._queue.pop(0)
|
|
330
|
+
self._writing_pending = pending
|
|
331
|
+
try:
|
|
332
|
+
self.start(pending, now)
|
|
333
|
+
finally:
|
|
334
|
+
with self._lock:
|
|
335
|
+
self._writing_pending = None
|
|
336
|
+
if (
|
|
337
|
+
needs_reply
|
|
338
|
+
and spec.correlation is Correlation.EXCLUSIVE
|
|
339
|
+
and not pending.finished
|
|
340
|
+
):
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Route in strict order: in-flight, stream, event, raw multi, unknown.
|
|
344
|
+
# pylint: disable=too-many-branches,too-many-statements,too-many-return-statements
|
|
345
|
+
def receive_frame(self, frame: bytes) -> None:
|
|
346
|
+
"""Associate a complete frame with an invocation or device event."""
|
|
347
|
+
with self._lock:
|
|
348
|
+
now = self._clock()
|
|
349
|
+
if now < self._drain_until:
|
|
350
|
+
return
|
|
351
|
+
text = frame.decode(self._profile.encoding, errors="replace")
|
|
352
|
+
if self._profile.echo and any(
|
|
353
|
+
frame
|
|
354
|
+
== item.sent.removesuffix(
|
|
355
|
+
self._profile.terminator.encode(self._profile.encoding)
|
|
356
|
+
)
|
|
357
|
+
for item in self._inflight + self._streams
|
|
358
|
+
):
|
|
359
|
+
return
|
|
360
|
+
for pending in tuple(self._inflight):
|
|
361
|
+
spec = pending.spec
|
|
362
|
+
assert spec is not None
|
|
363
|
+
if spec.error_pattern is not None and re.fullmatch(
|
|
364
|
+
spec.error_pattern, text
|
|
365
|
+
):
|
|
366
|
+
pending.raw_frames.append(frame)
|
|
367
|
+
self._finish(
|
|
368
|
+
pending, CommandStatus.DEVICE_ERROR, error_message=text
|
|
369
|
+
)
|
|
370
|
+
return
|
|
371
|
+
if spec.mode is ResponseMode.MULTI and spec.until is not None:
|
|
372
|
+
if re.fullmatch(spec.until, text):
|
|
373
|
+
pending.raw_frames.append(frame)
|
|
374
|
+
self._finish(pending, CommandStatus.OK)
|
|
375
|
+
return
|
|
376
|
+
if spec.pattern is None:
|
|
377
|
+
continue
|
|
378
|
+
matched, data = self.parse_command(spec, frame, pending)
|
|
379
|
+
if pending.finished:
|
|
380
|
+
return
|
|
381
|
+
if not matched:
|
|
382
|
+
continue
|
|
383
|
+
pending.raw_frames.append(frame)
|
|
384
|
+
pending.last_frame_at = now
|
|
385
|
+
if pending.first_frame_at is None:
|
|
386
|
+
pending.first_frame_at = now
|
|
387
|
+
pending.values.append(data)
|
|
388
|
+
if spec.mode is ResponseMode.SINGLE or (
|
|
389
|
+
spec.mode is ResponseMode.MULTI
|
|
390
|
+
and spec.count is not None
|
|
391
|
+
and len(pending.values) >= spec.count
|
|
392
|
+
):
|
|
393
|
+
self._finish(pending, CommandStatus.OK)
|
|
394
|
+
return
|
|
395
|
+
for pending in tuple(self._streams):
|
|
396
|
+
spec = pending.spec
|
|
397
|
+
assert spec is not None
|
|
398
|
+
if spec.pattern is None:
|
|
399
|
+
continue
|
|
400
|
+
if spec.error_pattern is not None and re.fullmatch(
|
|
401
|
+
spec.error_pattern, text
|
|
402
|
+
):
|
|
403
|
+
pending.raw_frames.append(frame)
|
|
404
|
+
self._finish(
|
|
405
|
+
pending, CommandStatus.DEVICE_ERROR, error_message=text
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
matched, data = self.parse_command(spec, frame, pending)
|
|
409
|
+
if pending.finished:
|
|
410
|
+
return
|
|
411
|
+
if matched:
|
|
412
|
+
pending.raw_frames.append(frame)
|
|
413
|
+
pending.last_frame_at = now
|
|
414
|
+
self.enqueue_event(
|
|
415
|
+
DeviceEvent(
|
|
416
|
+
spec=spec,
|
|
417
|
+
request_id=pending.ticket.request_id,
|
|
418
|
+
data=data,
|
|
419
|
+
raw_frame=frame,
|
|
420
|
+
timestamp=now,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
for event_spec in self._registry.events:
|
|
425
|
+
try:
|
|
426
|
+
matched, data = ResponseParser.match(event_spec, text)
|
|
427
|
+
except CommandError as exc:
|
|
428
|
+
self.error_occurred.emit(exc)
|
|
429
|
+
return
|
|
430
|
+
if matched:
|
|
431
|
+
self.enqueue_event(
|
|
432
|
+
DeviceEvent(
|
|
433
|
+
spec=event_spec,
|
|
434
|
+
data=data,
|
|
435
|
+
raw_frame=frame,
|
|
436
|
+
timestamp=now,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return
|
|
440
|
+
for pending in tuple(self._streams):
|
|
441
|
+
spec = pending.spec
|
|
442
|
+
assert spec is not None
|
|
443
|
+
if spec.pattern is None:
|
|
444
|
+
pending.raw_frames.append(frame)
|
|
445
|
+
pending.last_frame_at = now
|
|
446
|
+
self.enqueue_event(
|
|
447
|
+
DeviceEvent(
|
|
448
|
+
spec=spec,
|
|
449
|
+
request_id=pending.ticket.request_id,
|
|
450
|
+
data=text,
|
|
451
|
+
raw_frame=frame,
|
|
452
|
+
timestamp=now,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return
|
|
456
|
+
for pending in tuple(self._inflight):
|
|
457
|
+
spec = pending.spec
|
|
458
|
+
assert spec is not None
|
|
459
|
+
if spec.mode is ResponseMode.MULTI and spec.pattern is None:
|
|
460
|
+
pending.raw_frames.append(frame)
|
|
461
|
+
pending.values.append(text)
|
|
462
|
+
pending.last_frame_at = now
|
|
463
|
+
if pending.first_frame_at is None:
|
|
464
|
+
pending.first_frame_at = now
|
|
465
|
+
if (
|
|
466
|
+
spec.count is not None
|
|
467
|
+
and len(pending.values) >= spec.count
|
|
468
|
+
):
|
|
469
|
+
self._finish(pending, CommandStatus.OK)
|
|
470
|
+
return
|
|
471
|
+
self.enqueue_event(
|
|
472
|
+
DeviceEvent(data=text, raw_frame=frame, timestamp=now)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def flush_events(self) -> None:
|
|
476
|
+
"""Emit bounded queued events on the owner thread."""
|
|
477
|
+
with self._lock:
|
|
478
|
+
while self._events:
|
|
479
|
+
self.event_received.emit(self._events.popleft())
|
|
480
|
+
self._last_event_emit_at = self._clock()
|
|
481
|
+
|
|
482
|
+
def stats(self) -> HandlerStats:
|
|
483
|
+
"""Return latency and dropped-event counters."""
|
|
484
|
+
latency = self._latency.stats()
|
|
485
|
+
return HandlerStats(
|
|
486
|
+
srtt_s=latency.srtt_s,
|
|
487
|
+
rttvar_s=latency.rttvar_s,
|
|
488
|
+
poll_interval_s=latency.poll_interval_s,
|
|
489
|
+
rto_s=self._auto_rto,
|
|
490
|
+
dropped_events=self._dropped_events,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def parse_command(
|
|
494
|
+
self, spec: CommandSpec, frame: bytes, pending: PendingCommand
|
|
495
|
+
) -> tuple[bool, Any]:
|
|
496
|
+
"""Check correlation keys and turn parser errors into results."""
|
|
497
|
+
text = frame.decode(self._profile.encoding, errors="replace")
|
|
498
|
+
if spec.pattern is None:
|
|
499
|
+
return (
|
|
500
|
+
(True, text)
|
|
501
|
+
if spec.mode is ResponseMode.STREAM
|
|
502
|
+
else (False, None)
|
|
503
|
+
)
|
|
504
|
+
match = re.fullmatch(spec.pattern, text)
|
|
505
|
+
if match is None:
|
|
506
|
+
return False, None
|
|
507
|
+
for key, value in match.groupdict().items():
|
|
508
|
+
if key in pending.params and str(pending.params[key]) != value:
|
|
509
|
+
return False, None
|
|
510
|
+
try:
|
|
511
|
+
return ResponseParser.match(spec, text)
|
|
512
|
+
except CommandError as exc:
|
|
513
|
+
pending.raw_frames.append(frame)
|
|
514
|
+
self._finish(
|
|
515
|
+
pending, CommandStatus.PARSE_ERROR, error_message=str(exc)
|
|
516
|
+
)
|
|
517
|
+
return False, None
|
|
518
|
+
|
|
519
|
+
def start(self, pending: PendingCommand, now: float) -> None:
|
|
520
|
+
"""Write one invocation and mark its response deadline."""
|
|
521
|
+
with self._lock:
|
|
522
|
+
if pending.finished:
|
|
523
|
+
return
|
|
524
|
+
pending.started_at = now
|
|
525
|
+
spec = pending.spec
|
|
526
|
+
if spec is not None:
|
|
527
|
+
if spec.timeout_s is TimeoutPolicy.AUTO:
|
|
528
|
+
pending.timeout_s = self._auto_rto
|
|
529
|
+
else:
|
|
530
|
+
assert isinstance(spec.timeout_s, (int, float))
|
|
531
|
+
pending.timeout_s = float(spec.timeout_s)
|
|
532
|
+
if spec.mode is ResponseMode.STREAM:
|
|
533
|
+
self._streams.append(pending)
|
|
534
|
+
elif spec.mode is not ResponseMode.NO_REPLY:
|
|
535
|
+
self._inflight.append(pending)
|
|
536
|
+
try:
|
|
537
|
+
written = self._write(pending.sent)
|
|
538
|
+
except (OSError, RuntimeError) as exc:
|
|
539
|
+
with self._lock:
|
|
540
|
+
self._remember_retry(pending)
|
|
541
|
+
self._finish(
|
|
542
|
+
pending,
|
|
543
|
+
CommandStatus.DISCONNECTED,
|
|
544
|
+
error_message=str(exc),
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
with self._lock:
|
|
548
|
+
if written != len(pending.sent):
|
|
549
|
+
self._remember_retry(pending)
|
|
550
|
+
self._finish(
|
|
551
|
+
pending,
|
|
552
|
+
CommandStatus.DISCONNECTED,
|
|
553
|
+
error_message="serial write accepted too few bytes",
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
self._last_write_at = now
|
|
557
|
+
self.tx_written.emit(pending.sent, pending.route, spec)
|
|
558
|
+
if pending.ticket.cancelled:
|
|
559
|
+
self._finish(pending, CommandStatus.CANCELLED)
|
|
560
|
+
elif spec is None or spec.mode is ResponseMode.NO_REPLY:
|
|
561
|
+
self._finish(pending, CommandStatus.OK)
|
|
562
|
+
|
|
563
|
+
def _remember_retry(self, pending: PendingCommand) -> None:
|
|
564
|
+
"""Retain one safe invocation after a failed transport write."""
|
|
565
|
+
spec = pending.spec
|
|
566
|
+
if (
|
|
567
|
+
self._runtime.resend_idempotent
|
|
568
|
+
and pending.started_at is not None
|
|
569
|
+
and spec is not None
|
|
570
|
+
and spec.idempotent
|
|
571
|
+
and not pending.ticket.cancelled
|
|
572
|
+
and not pending.finished
|
|
573
|
+
):
|
|
574
|
+
self._retry_after_reconnect.append(
|
|
575
|
+
(spec, pending.params, pending.priority)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def timeout(self, pending: PendingCommand, now: float) -> None:
|
|
579
|
+
"""Finish an expired invocation and isolate late exclusive frames."""
|
|
580
|
+
spec = pending.spec
|
|
581
|
+
if spec is not None and spec.timeout_s is TimeoutPolicy.AUTO:
|
|
582
|
+
self._auto_rto = min(self._runtime.rto_max_s, self._auto_rto * 2)
|
|
583
|
+
if spec is not None and spec.correlation is Correlation.EXCLUSIVE:
|
|
584
|
+
self._drain_until = now + self._runtime.post_timeout_drain_s
|
|
585
|
+
self._finish(pending, CommandStatus.TIMEOUT)
|
|
586
|
+
|
|
587
|
+
def _finish(
|
|
588
|
+
self,
|
|
589
|
+
pending: PendingCommand,
|
|
590
|
+
status: CommandStatus,
|
|
591
|
+
*,
|
|
592
|
+
error_message: str | None = None,
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Publish exactly one terminal result for an accepted ticket."""
|
|
595
|
+
if pending.finished:
|
|
596
|
+
return
|
|
597
|
+
pending.finished = True
|
|
598
|
+
for collection in (self._queue, self._inflight, self._streams):
|
|
599
|
+
if pending in collection:
|
|
600
|
+
collection.remove(pending)
|
|
601
|
+
now = self._clock()
|
|
602
|
+
if (
|
|
603
|
+
status is CommandStatus.OK
|
|
604
|
+
and pending.started_at is not None
|
|
605
|
+
and pending.first_frame_at is not None
|
|
606
|
+
and pending.spec is not None
|
|
607
|
+
and pending.spec.mode is ResponseMode.SINGLE
|
|
608
|
+
):
|
|
609
|
+
self._latency.observe(max(now - pending.started_at, 0.000001))
|
|
610
|
+
estimate = self._latency.stats().rto_s
|
|
611
|
+
if estimate is not None:
|
|
612
|
+
self._auto_rto = min(
|
|
613
|
+
self._runtime.rto_max_s,
|
|
614
|
+
max(self._runtime.rto_min_s, estimate),
|
|
615
|
+
)
|
|
616
|
+
data: Any = None
|
|
617
|
+
if pending.values:
|
|
618
|
+
if (
|
|
619
|
+
pending.spec is not None
|
|
620
|
+
and pending.spec.mode is ResponseMode.MULTI
|
|
621
|
+
):
|
|
622
|
+
data = list(pending.values)
|
|
623
|
+
else:
|
|
624
|
+
data = pending.values[0]
|
|
625
|
+
result = CommandResult(
|
|
626
|
+
spec=pending.spec,
|
|
627
|
+
route=pending.route,
|
|
628
|
+
params=pending.params,
|
|
629
|
+
sent=pending.sent if pending.started_at is not None else b"",
|
|
630
|
+
status=status,
|
|
631
|
+
data=data,
|
|
632
|
+
raw_frames=tuple(pending.raw_frames),
|
|
633
|
+
elapsed_s=(now - pending.started_at)
|
|
634
|
+
if pending.started_at is not None
|
|
635
|
+
else 0.0,
|
|
636
|
+
request_id=pending.ticket.request_id,
|
|
637
|
+
error_message=error_message,
|
|
638
|
+
)
|
|
639
|
+
self.command_finished.emit(result)
|
|
640
|
+
|
|
641
|
+
def enqueue_event(self, event: DeviceEvent) -> None:
|
|
642
|
+
"""Retain bounded event traffic until the next emission interval."""
|
|
643
|
+
if len(self._events) >= self._runtime.event_queue_high_water:
|
|
644
|
+
if isinstance(event.spec, CommandSpec):
|
|
645
|
+
for index, queued in enumerate(self._events):
|
|
646
|
+
if queued.spec is event.spec:
|
|
647
|
+
del self._events[index]
|
|
648
|
+
self._dropped_events += 1
|
|
649
|
+
break
|
|
650
|
+
else:
|
|
651
|
+
for index, queued in enumerate(self._events):
|
|
652
|
+
if isinstance(queued.spec, CommandSpec):
|
|
653
|
+
del self._events[index]
|
|
654
|
+
self._dropped_events += 1
|
|
655
|
+
break
|
|
656
|
+
else:
|
|
657
|
+
self._dropped_events += 1
|
|
658
|
+
return
|
|
659
|
+
self._events.append(event)
|
|
660
|
+
return
|
|
661
|
+
for index, queued in enumerate(self._events):
|
|
662
|
+
if isinstance(queued.spec, CommandSpec):
|
|
663
|
+
del self._events[index]
|
|
664
|
+
break
|
|
665
|
+
else:
|
|
666
|
+
self._events.popleft()
|
|
667
|
+
self._dropped_events += 1
|
|
668
|
+
self._events.append(event)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
__all__ = ["CommandDispatcher"]
|