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,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"]
|