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,193 @@
1
+ """Thread-safe command and event declarations with text request matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import string
7
+ import threading
8
+ from collections.abc import Mapping
9
+ from types import MappingProxyType
10
+
11
+ from ..errors import CommandError
12
+ from ..models import CommandSpec, EventSpec
13
+
14
+ RegistrySnapshot = tuple[
15
+ tuple[CommandSpec, ...],
16
+ tuple[EventSpec, ...],
17
+ Mapping[str, CommandSpec],
18
+ tuple[tuple[CommandSpec, re.Pattern[str], int], ...],
19
+ ]
20
+
21
+
22
+ class CommandRegistry:
23
+ """Publish immutable declaration snapshots for concurrent readers."""
24
+
25
+ _lock: threading.RLock
26
+ _snapshot: RegistrySnapshot
27
+ _terminator: str
28
+
29
+ def __init__(self, terminator: str = "\r\n") -> None:
30
+ """Create an empty registry without device-specific commands."""
31
+ self._lock = threading.RLock()
32
+ self._terminator = terminator
33
+ self._snapshot = ((), (), MappingProxyType({}), ())
34
+
35
+ @property
36
+ def commands(self) -> tuple[CommandSpec, ...]:
37
+ """Return the current immutable command snapshot."""
38
+ return self._snapshot[0]
39
+
40
+ @property
41
+ def events(self) -> tuple[EventSpec, ...]:
42
+ """Return the current immutable event snapshot."""
43
+ return self._snapshot[1]
44
+
45
+ def contains(self, spec: CommandSpec | EventSpec) -> bool:
46
+ """Check registration by object identity."""
47
+ if isinstance(spec, CommandSpec):
48
+ return spec in self._snapshot[0]
49
+ return spec in self._snapshot[1]
50
+
51
+ def register(self, *specs: CommandSpec | EventSpec) -> None:
52
+ """Atomically register declarations, rejecting ambiguous requests.
53
+
54
+ Args:
55
+ *specs: Existing immutable declarations to retain by identity.
56
+
57
+ Raises:
58
+ CommandError: If a different declaration conflicts with a request.
59
+ """
60
+ with self._lock:
61
+ commands = list(self._snapshot[0])
62
+ events = list(self._snapshot[1])
63
+ for spec in specs:
64
+ if isinstance(spec, CommandSpec):
65
+ if spec not in commands:
66
+ commands.append(spec)
67
+ elif isinstance(spec, EventSpec):
68
+ if spec not in events:
69
+ events.append(spec)
70
+ else:
71
+ raise CommandError(
72
+ "register accepts CommandSpec or EventSpec"
73
+ )
74
+ self._publish(commands, events)
75
+
76
+ def unregister(self, spec: CommandSpec | EventSpec) -> None:
77
+ """Remove a declaration from future snapshots without changing it."""
78
+ with self._lock:
79
+ if not self.contains(spec):
80
+ return
81
+ commands = [item for item in self._snapshot[0] if item is not spec]
82
+ events = [item for item in self._snapshot[1] if item is not spec]
83
+ self._publish(commands, events)
84
+
85
+ def _publish(
86
+ self, commands: list[CommandSpec], events: list[EventSpec]
87
+ ) -> None:
88
+ """Validate candidates before replacing the one shared snapshot."""
89
+ exact: dict[str, CommandSpec] = {}
90
+ templates: list[tuple[CommandSpec, re.Pattern[str], int]] = []
91
+ skeletons: set[str] = set()
92
+ for spec in commands:
93
+ request = self.normalise(spec.request, self._terminator)
94
+ skeleton = self.template_skeleton(request)
95
+ if skeleton in skeletons:
96
+ raise CommandError(f"duplicate command request: {request!r}")
97
+ skeletons.add(skeleton)
98
+ if spec.placeholders:
99
+ pattern, score = self.compile_template(request)
100
+ templates.append((spec, pattern, score))
101
+ else:
102
+ exact[request] = spec
103
+ patterns: set[str] = set()
104
+ for spec in events:
105
+ if spec.pattern in patterns:
106
+ raise CommandError(f"duplicate event pattern: {spec.pattern!r}")
107
+ patterns.add(spec.pattern)
108
+ self._snapshot = (
109
+ tuple(commands),
110
+ tuple(events),
111
+ MappingProxyType(exact),
112
+ tuple(templates),
113
+ )
114
+
115
+ def resolve_text(
116
+ self, text: str, terminator: str
117
+ ) -> tuple[CommandSpec, dict[str, str]] | None:
118
+ """Match normalised text to an exact request or best template.
119
+
120
+ Args:
121
+ text: Caller-supplied request text.
122
+ terminator: Configured line terminator, possibly non-whitespace.
123
+
124
+ Returns:
125
+ The original declaration and extracted parameters, if matched.
126
+
127
+ Raises:
128
+ CommandError: If input has control characters or templates tie.
129
+ """
130
+ normalised = self.normalise(text, terminator)
131
+ self.validate_text(normalised)
132
+ snapshot = self._snapshot
133
+ exact = snapshot[2].get(normalised)
134
+ if exact is not None:
135
+ return exact, {}
136
+ matches: list[tuple[int, CommandSpec, dict[str, str]]] = []
137
+ for spec, pattern, score in snapshot[3]:
138
+ match = pattern.fullmatch(normalised)
139
+ if match is not None:
140
+ matches.append((score, spec, match.groupdict()))
141
+ if not matches:
142
+ return None
143
+ matches.sort(key=lambda item: item[0], reverse=True)
144
+ if len(matches) > 1 and matches[0][0] == matches[1][0]:
145
+ raise CommandError(f"ambiguous command text: {normalised!r}")
146
+ _, spec, params = matches[0]
147
+ return spec, params
148
+
149
+ @staticmethod
150
+ def normalise(text: str, terminator: str) -> str:
151
+ """Strip surrounding whitespace and one configured terminator."""
152
+ result = text.strip()
153
+ if terminator and result.endswith(terminator):
154
+ result = result[: -len(terminator)]
155
+ return result.strip()
156
+
157
+ @staticmethod
158
+ def validate_text(text: str) -> None:
159
+ """Reject embedded controls that could inject another command."""
160
+ if not text or any(ord(char) < 32 or ord(char) == 127 for char in text):
161
+ raise CommandError(
162
+ "command text is empty or contains control characters"
163
+ )
164
+
165
+ @staticmethod
166
+ def template_skeleton(request: str) -> str:
167
+ """Replace placeholder names with a common marker for conflicts."""
168
+ pieces: list[str] = []
169
+ for literal, field_name, _, _ in string.Formatter().parse(request):
170
+ pieces.append(literal)
171
+ if field_name is not None:
172
+ pieces.append("{}")
173
+ return "".join(pieces)
174
+
175
+ @staticmethod
176
+ def compile_template(request: str) -> tuple[re.Pattern[str], int]:
177
+ """Compile a full-request matcher and count its literal characters."""
178
+ pieces: list[str] = []
179
+ seen: set[str] = set()
180
+ score = 0
181
+ for literal, field_name, _, _ in string.Formatter().parse(request):
182
+ pieces.append(re.escape(literal))
183
+ score += len(literal)
184
+ if field_name is not None:
185
+ if field_name in seen:
186
+ pieces.append(f"(?P={field_name})")
187
+ else:
188
+ pieces.append(f"(?P<{field_name}>.+?)")
189
+ seen.add(field_name)
190
+ return re.compile("".join(pieces)), score
191
+
192
+
193
+ __all__ = ["CommandRegistry"]
@@ -0,0 +1,380 @@
1
+ """QtCore connection worker for command pumping and automatic recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import re
7
+ import threading
8
+ import time
9
+ from dataclasses import replace
10
+ from typing import TYPE_CHECKING
11
+
12
+ from typing_extensions import override
13
+
14
+ # pylint: disable=no-name-in-module
15
+ from PySide6.QtCore import Qt, QThread
16
+
17
+ from ..advanced import FramingConfig, ProbeSpec, RuntimeConfig, SerialConfig
18
+ from ..discovery import DeviceFinder
19
+ from ..discovery.port_scanner import PortScanner
20
+ from ..enums import (
21
+ CommandPriority,
22
+ CommandStatus,
23
+ ConnectionState,
24
+ FramingMode,
25
+ ScanMode,
26
+ )
27
+ from ..errors import PortNotFoundError, SerialForgeError
28
+ from ..models import CommandResult, CommandSpec, DeviceInfo
29
+ from ..protocols import TransportProtocol
30
+ from ..transport import BackendSwitch, PortOptions, PortRegistry
31
+ from .read_thread import ReadThread
32
+
33
+ # The worker coordinates the facade's private state across Qt threads.
34
+ # pylint: disable=protected-access,too-many-instance-attributes
35
+
36
+ if TYPE_CHECKING:
37
+ from .serial_handler import SerialHandler
38
+
39
+
40
+ class ConnectionWorker(QThread):
41
+ """Own connection attempts, command pumping, and automatic recovery."""
42
+
43
+ def __init__(
44
+ self, handler: SerialHandler, target: str | DeviceInfo | None
45
+ ) -> None:
46
+ """Create a stopped worker for one explicit connection session."""
47
+ super().__init__()
48
+ self._handler: SerialHandler = handler
49
+ self._target: str | DeviceInfo | None = target
50
+ self._stop: threading.Event = threading.Event()
51
+ self._io_failed: threading.Event = threading.Event()
52
+ self._transport: TransportProtocol | None = None
53
+ self._reader: ReadThread | None = None
54
+ self._device: DeviceInfo | None = None
55
+ self._port: str | None = None
56
+ self._reconnects: int = 0
57
+ self._session_started: bool = False
58
+ self._finder: DeviceFinder | None = None
59
+
60
+ @property
61
+ def port(self) -> str | None:
62
+ """Return the port currently held by this worker."""
63
+ return self._port
64
+
65
+ def request_stop(self) -> None:
66
+ """Stop reads, writes, retries, and future command pumping."""
67
+ self._stop.set()
68
+ finder = self._finder
69
+ if finder is not None:
70
+ finder.cancel()
71
+ reader = self._reader
72
+ if reader is not None:
73
+ reader.request_stop()
74
+ transport = self._transport
75
+ if transport is not None:
76
+ transport.cancel_read()
77
+ transport.cancel_write()
78
+
79
+ def write(self, data: bytes) -> int:
80
+ """Write through the current transport and report a broken handle."""
81
+ transport = self._transport
82
+ if transport is None or self._stop.is_set():
83
+ raise RuntimeError("serial transport is not connected")
84
+ try:
85
+ written = transport.write(data)
86
+ if written != len(data):
87
+ self._io_failed.set()
88
+ return written
89
+ except (OSError, RuntimeError):
90
+ self._io_failed.set()
91
+ raise
92
+
93
+ def clear_input(self) -> None:
94
+ """Discard stale bytes after an exclusive timeout."""
95
+ transport = self._transport
96
+ if transport is not None:
97
+ transport.reset_input_buffer()
98
+
99
+ @override
100
+ def run(self) -> None:
101
+ """Open once, recover unexpected loss, and close every handle."""
102
+ runtime = self._handler._profile.runtime
103
+ assert isinstance(runtime, RuntimeConfig)
104
+ attempts = 0
105
+ first = True
106
+ try:
107
+ while not self._stop.is_set():
108
+ try:
109
+ device = self.resolve_target(reconnect=not first)
110
+ if self._stop.is_set():
111
+ break
112
+ self.open_device(device)
113
+ if self._stop.is_set():
114
+ break
115
+ attempts = 0
116
+ self.run_connected()
117
+ first = False
118
+ if self._stop.is_set():
119
+ break
120
+ self._handler._transition(ConnectionState.RECONNECTING)
121
+ except (OSError, RuntimeError, SerialForgeError) as exc:
122
+ if not self._stop.is_set():
123
+ self._handler._report_error(exc)
124
+ if first:
125
+ self._handler._transition(ConnectionState.FAILED)
126
+ return
127
+ self._handler._transition(ConnectionState.RECONNECTING)
128
+ finally:
129
+ self.close_transport()
130
+ if self._stop.is_set():
131
+ break
132
+ attempts += 1
133
+ if (
134
+ runtime.reconnect_max_attempts is not None
135
+ and attempts > runtime.reconnect_max_attempts
136
+ ):
137
+ self._handler._transition(ConnectionState.FAILED)
138
+ return
139
+ delay = min(
140
+ runtime.reconnect_initial_s * 2 ** (attempts - 1),
141
+ runtime.reconnect_max_s,
142
+ )
143
+ self._stop.wait(delay * random.uniform(0.9, 1.1))
144
+ finally:
145
+ self.close_transport()
146
+ self._handler._traffic.end_connection()
147
+ if self._stop.is_set():
148
+ self._handler._transition(ConnectionState.DISCONNECTED)
149
+
150
+ def resolve_target(self, *, reconnect: bool) -> DeviceInfo:
151
+ """Resolve an explicit target or ask discovery for a matching device."""
152
+ if reconnect and self._device is not None:
153
+ previous = self._device
154
+ candidates = PortScanner(
155
+ self._handler._profile, BackendSwitch.current()
156
+ ).scan()
157
+ for candidate in candidates:
158
+ same_serial = bool(previous.serial_number) and (
159
+ previous.serial_number == candidate.serial_number
160
+ )
161
+ same_location = bool(previous.location) and (
162
+ previous.location == candidate.location
163
+ )
164
+ if same_serial or same_location:
165
+ return replace(candidate, baudrate=previous.baudrate)
166
+ if (
167
+ not previous.serial_number
168
+ and not previous.location
169
+ and candidate.port == previous.port
170
+ ):
171
+ return replace(candidate, baudrate=previous.baudrate)
172
+ runtime = self._handler._profile.runtime
173
+ assert isinstance(runtime, RuntimeConfig)
174
+ if not runtime.reprobe_on_reconnect:
175
+ return previous
176
+ target = None if reconnect else self._target
177
+ if isinstance(target, DeviceInfo):
178
+ return target
179
+ self._handler._transition(ConnectionState.PROBING)
180
+ finder = DeviceFinder(self._handler._profile)
181
+ self._finder = finder
182
+ try:
183
+ if self._stop.is_set():
184
+ raise PortNotFoundError("device discovery was cancelled")
185
+ results = finder.find(ScanMode.FIRST_MATCH, port=target)
186
+ finally:
187
+ self._finder = None
188
+ if not results:
189
+ raise PortNotFoundError(
190
+ f"no matching serial device found on {target or 'available ports'}"
191
+ )
192
+ return results[0]
193
+
194
+ def open_device(self, device: DeviceInfo) -> None:
195
+ """Claim and open a port, then start its independent read thread."""
196
+ profile = self._handler._profile
197
+ serial_config = profile.serial
198
+ assert isinstance(serial_config, SerialConfig)
199
+ baudrate = device.baudrate or profile.baudrates[0]
200
+ self._handler._transition(ConnectionState.CONNECTING)
201
+ PortRegistry.acquire(device.port, self._handler)
202
+ self._port = device.port
203
+ transport = BackendSwitch.current().open(
204
+ device.port, PortOptions.build(serial_config, baudrate)
205
+ )
206
+ self._transport = transport
207
+ transport.set_control_lines(
208
+ dtr=serial_config.dtr, rts=serial_config.rts
209
+ )
210
+ if serial_config.rx_buffer_size is not None:
211
+ transport.set_buffer_size(serial_config.rx_buffer_size)
212
+ if self._stop.wait(serial_config.settle_time_s):
213
+ return
214
+ framing = profile.framing or FramingConfig(
215
+ mode=FramingMode.LINE,
216
+ terminator=profile.terminator.encode(profile.encoding),
217
+ )
218
+ self._device = device
219
+ self._io_failed.clear()
220
+ self._handler._dispatcher.set_clear_input(self.clear_input)
221
+ self._handler._dispatcher.set_connected(True)
222
+ reader = ReadThread(self._handler, transport, framing)
223
+ self._reader = reader
224
+ reader.start()
225
+
226
+ def run_connected(self) -> None:
227
+ """Run initialization, publish state, and pump until loss or stop."""
228
+ profile = self._handler._profile
229
+ for item in self._handler._init_sequence:
230
+ try:
231
+ result = self._run_command(item)
232
+ except SerialForgeError as exc:
233
+ self._handler._report_error(exc)
234
+ continue
235
+ if result is None or result.status is not CommandStatus.OK:
236
+ self._handler._report_error(
237
+ SerialForgeError(f"initialization command failed: {item!r}")
238
+ )
239
+ if self._stop.is_set() or self._reader_failed():
240
+ return
241
+ port = self._port
242
+ assert port is not None
243
+ if not self._session_started:
244
+ self._handler._traffic.begin_connection(port)
245
+ self._session_started = True
246
+ else:
247
+ self._reconnects += 1
248
+ self._handler._traffic.record_event(
249
+ port, f"RECONNECTED attempt={self._reconnects}"
250
+ )
251
+ self._handler._transition(ConnectionState.CONNECTED)
252
+ last_heartbeat = time.monotonic()
253
+ while not self._stop.is_set() and not self._reader_failed():
254
+ self._handler._dispatcher.pump()
255
+ self._handler._traffic.flush_stream_traffic()
256
+ if (
257
+ profile.heartbeat_s is not None
258
+ and time.monotonic() - last_heartbeat >= profile.heartbeat_s
259
+ ):
260
+ if not self.check_heartbeat():
261
+ self._io_failed.set()
262
+ break
263
+ last_heartbeat = time.monotonic()
264
+ self._stop.wait(0.005)
265
+ if not self._stop.is_set() and self._reader is not None:
266
+ failure = self._reader.failure
267
+ self._handler._report_error(
268
+ failure or SerialForgeError("serial connection was lost")
269
+ )
270
+
271
+ def _reader_failed(self) -> bool:
272
+ """Return whether either I/O direction can no longer be used."""
273
+ reader = self._reader
274
+ return self._io_failed.is_set() or (
275
+ reader is not None and reader.failure is not None
276
+ )
277
+
278
+ def _run_command(self, item: CommandSpec | str) -> CommandResult | None:
279
+ """Wait for one internal command while reads continue independently."""
280
+ completed = threading.Event()
281
+ received: list[CommandResult] = []
282
+
283
+ def on_finished(result: CommandResult) -> None:
284
+ received.append(result)
285
+ completed.set()
286
+
287
+ dispatcher = self._handler._dispatcher
288
+ dispatcher.command_finished.connect(
289
+ on_finished, Qt.ConnectionType.DirectConnection
290
+ )
291
+ try:
292
+ ticket = dispatcher.submit(
293
+ item, priority=CommandPriority.HIGH, allow_raw_text=False
294
+ )
295
+ while not self._stop.is_set():
296
+ for result in tuple(received):
297
+ if result.request_id == ticket.request_id:
298
+ return result
299
+ if self._reader_failed():
300
+ break
301
+ completed.clear()
302
+ dispatcher.pump()
303
+ completed.wait(0.005)
304
+ return None
305
+ finally:
306
+ dispatcher.command_finished.disconnect(on_finished)
307
+
308
+ def check_heartbeat(self) -> bool:
309
+ """Verify liveness using the configured probe declaration."""
310
+ probe = self._handler._profile.probe
311
+ if isinstance(probe, CommandSpec):
312
+ if not self._handler._registry.contains(probe):
313
+ self._handler._registry.register(probe)
314
+ result = self._run_command(probe)
315
+ return result is not None and result.status is CommandStatus.OK
316
+ assert isinstance(probe, ProbeSpec)
317
+ existing = self._handler._registry.resolve_text(
318
+ probe.request, self._handler._profile.terminator
319
+ )
320
+ temporary = existing is None
321
+ spec = (
322
+ CommandSpec(
323
+ probe.request,
324
+ r"(?s)(?P<response>.*)",
325
+ timeout_s=probe.timeout_s,
326
+ )
327
+ if temporary
328
+ else existing[0]
329
+ )
330
+ if temporary:
331
+ self._handler._registry.register(spec)
332
+ try:
333
+ result = self._run_command(spec)
334
+ finally:
335
+ if temporary:
336
+ self._handler._registry.unregister(spec)
337
+ if result is None or result.status is not CommandStatus.OK:
338
+ return False
339
+ frame = result.raw_frames[-1]
340
+ response = (
341
+ frame.decode(self._handler._profile.encoding, errors="replace")
342
+ if isinstance(frame, bytes)
343
+ else frame
344
+ )
345
+ if response == probe.request.strip():
346
+ return False
347
+ if isinstance(probe.expect, str):
348
+ return re.fullmatch(probe.expect, response) is not None
349
+ return bool(probe.expect(response))
350
+
351
+ def close_transport(self) -> None:
352
+ """Stop the reader, close the handle, and release ownership."""
353
+ dispatcher = self._handler._dispatcher
354
+ dispatcher.set_connected(
355
+ False,
356
+ user_initiated=self._stop.is_set(),
357
+ reconnecting=(
358
+ not self._stop.is_set()
359
+ and self._handler.state is ConnectionState.RECONNECTING
360
+ ),
361
+ )
362
+ reader = self._reader
363
+ self._reader = None
364
+ if reader is not None:
365
+ reader.request_stop()
366
+ reader.wait(2000)
367
+ transport = self._transport
368
+ self._transport = None
369
+ if transport is not None:
370
+ try:
371
+ transport.close()
372
+ except (OSError, RuntimeError) as exc:
373
+ self._handler._report_error(exc)
374
+ port = self._port
375
+ self._port = None
376
+ if port is not None:
377
+ PortRegistry.release(port, self._handler)
378
+
379
+
380
+ __all__ = ["ConnectionWorker"]
@@ -0,0 +1,37 @@
1
+ """Mutable state for one queued or in-flight command invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ..advanced import CommandTicket
10
+ from ..enums import CommandPriority, SendRoute
11
+ from ..models import CommandSpec
12
+
13
+
14
+ # One invocation needs these independent timing and result fields.
15
+ # pylint: disable=too-many-instance-attributes
16
+ @dataclass(eq=False)
17
+ class PendingCommand:
18
+ """Keep per-invocation state while retaining the registered spec object."""
19
+
20
+ ticket: CommandTicket
21
+ spec: CommandSpec | None
22
+ route: SendRoute
23
+ params: Mapping[str, object]
24
+ sent: bytes
25
+ priority: CommandPriority
26
+ sequence: int
27
+ queued_at: float
28
+ started_at: float | None = None
29
+ first_frame_at: float | None = None
30
+ last_frame_at: float | None = None
31
+ timeout_s: float = 1.0
32
+ raw_frames: list[bytes] = field(default_factory=list)
33
+ values: list[Any] = field(default_factory=list)
34
+ finished: bool = False
35
+
36
+
37
+ __all__ = ["PendingCommand"]
@@ -0,0 +1,72 @@
1
+ """QtCore serial read thread with idle-aware frame delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import TYPE_CHECKING
7
+
8
+ from typing_extensions import override
9
+
10
+ # pylint: disable=no-name-in-module
11
+ from PySide6.QtCore import QThread
12
+
13
+ from ..advanced import FramingConfig
14
+ from ..errors import FramingError
15
+ from ..protocols import TransportProtocol
16
+ from ..transport import FrameSplitter
17
+
18
+ # The read loop reports frames to its owning facade through private hooks.
19
+ # pylint: disable=protected-access
20
+
21
+ if TYPE_CHECKING:
22
+ from .serial_handler import SerialHandler
23
+
24
+
25
+ class ReadThread(QThread):
26
+ """Read bytes independently of command writes and idle-frame delivery."""
27
+
28
+ def __init__(
29
+ self,
30
+ handler: SerialHandler,
31
+ transport: TransportProtocol,
32
+ config: FramingConfig,
33
+ ) -> None:
34
+ """Bind a transport, frame boundary, and receiver."""
35
+ super().__init__()
36
+ self._handler: SerialHandler = handler
37
+ self._transport: TransportProtocol = transport
38
+ self._splitter: FrameSplitter = FrameSplitter(config)
39
+ self._stop: threading.Event = threading.Event()
40
+ self.failure: Exception | None = None
41
+
42
+ def request_stop(self) -> None:
43
+ """Wake a blocked read and request a bounded exit."""
44
+ self._stop.set()
45
+ self._transport.cancel_read()
46
+
47
+ @override
48
+ def run(self) -> None:
49
+ """Read promptly and flush a silence-gap frame while idle."""
50
+ try:
51
+ while not self._stop.is_set():
52
+ poll = self._handler._dispatcher.stats().poll_interval_s or 0.05
53
+ chunk = self._transport.read(1, min(poll, 0.05))
54
+ if self._stop.is_set():
55
+ break
56
+ if chunk:
57
+ parts = [chunk]
58
+ while True:
59
+ extra = self._transport.read(4096, 0.0)
60
+ if not extra:
61
+ break
62
+ parts.append(extra)
63
+ for frame in self._splitter.feed(b"".join(parts)):
64
+ self._handler._receive_frame(frame)
65
+ for frame in self._splitter.flush():
66
+ self._handler._receive_frame(frame)
67
+ except (OSError, RuntimeError, FramingError) as exc:
68
+ if not self._stop.is_set():
69
+ self.failure = exc
70
+
71
+
72
+ __all__ = ["ReadThread"]