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.
Files changed (41) hide show
  1. serialforge/__init__.py +56 -0
  2. serialforge/advanced.py +272 -0
  3. serialforge/connection/__init__.py +5 -0
  4. serialforge/connection/command_dispatcher.py +671 -0
  5. serialforge/connection/command_registry.py +193 -0
  6. serialforge/connection/connection_worker.py +380 -0
  7. serialforge/connection/pending_command.py +37 -0
  8. serialforge/connection/read_thread.py +72 -0
  9. serialforge/connection/response_parser.py +92 -0
  10. serialforge/connection/serial_handler.py +284 -0
  11. serialforge/diagnostics/__init__.py +6 -0
  12. serialforge/diagnostics/log_file_manager.py +352 -0
  13. serialforge/diagnostics/traffic_logger.py +286 -0
  14. serialforge/discovery/__init__.py +5 -0
  15. serialforge/discovery/async_scan_thread.py +39 -0
  16. serialforge/discovery/baud_cache.py +117 -0
  17. serialforge/discovery/baud_prober.py +187 -0
  18. serialforge/discovery/device_finder.py +161 -0
  19. serialforge/discovery/port_scanner.py +48 -0
  20. serialforge/enums.py +129 -0
  21. serialforge/errors.py +47 -0
  22. serialforge/models.py +419 -0
  23. serialforge/protocols.py +53 -0
  24. serialforge/py.typed +0 -0
  25. serialforge/settings.py +14 -0
  26. serialforge/testing/__init__.py +12 -0
  27. serialforge/testing/fake_backend.py +54 -0
  28. serialforge/testing/fake_transport.py +128 -0
  29. serialforge/testing/simulated_device.py +67 -0
  30. serialforge/transport/__init__.py +19 -0
  31. serialforge/transport/backend_switch.py +36 -0
  32. serialforge/transport/checksum_calculator.py +77 -0
  33. serialforge/transport/frame_splitter.py +134 -0
  34. serialforge/transport/latency_tracker.py +65 -0
  35. serialforge/transport/port_options.py +31 -0
  36. serialforge/transport/port_registry.py +57 -0
  37. serialforge/transport/serial_transport.py +90 -0
  38. serialforge-0.0.1.dist-info/METADATA +37 -0
  39. serialforge-0.0.1.dist-info/RECORD +41 -0
  40. serialforge-0.0.1.dist-info/WHEEL +4 -0
  41. 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"]