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,92 @@
|
|
|
1
|
+
"""Parse command responses and device events into declared data shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import fields, is_dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, get_type_hints
|
|
9
|
+
|
|
10
|
+
from ..errors import CommandError
|
|
11
|
+
from ..models import CommandSpec, EventSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Parsing branches correspond to the public result data-shape table.
|
|
15
|
+
# pylint: disable=too-few-public-methods
|
|
16
|
+
class ResponseParser:
|
|
17
|
+
"""Apply a declaration's pattern and optional result conversion."""
|
|
18
|
+
|
|
19
|
+
# Each shape returns its own data type or a contextual conversion error.
|
|
20
|
+
# pylint: disable=too-many-branches,too-many-return-statements
|
|
21
|
+
@staticmethod
|
|
22
|
+
def match(spec: CommandSpec | EventSpec, text: str) -> tuple[bool, Any]:
|
|
23
|
+
"""Return whether a frame matches and its parsed data.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
spec: Original immutable declaration.
|
|
27
|
+
text: A complete decoded frame without framing bytes.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A match flag and data, or ``(False, None)``.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
CommandError: If a matched frame cannot be converted.
|
|
34
|
+
"""
|
|
35
|
+
if spec.pattern is None:
|
|
36
|
+
if spec.parser is not None:
|
|
37
|
+
try:
|
|
38
|
+
return True, spec.parser(text)
|
|
39
|
+
except (ValueError, TypeError) as exc:
|
|
40
|
+
raise CommandError(
|
|
41
|
+
f"response parser failed: {exc}"
|
|
42
|
+
) from exc
|
|
43
|
+
return True, text
|
|
44
|
+
match = re.fullmatch(spec.pattern, text)
|
|
45
|
+
if match is None:
|
|
46
|
+
return False, None
|
|
47
|
+
if isinstance(spec, CommandSpec) and spec.parser is not None:
|
|
48
|
+
try:
|
|
49
|
+
return True, spec.parser(text)
|
|
50
|
+
except (ValueError, TypeError) as exc:
|
|
51
|
+
raise CommandError(f"response parser failed: {exc}") from exc
|
|
52
|
+
result_type = spec.result_type
|
|
53
|
+
captures = match.groupdict()
|
|
54
|
+
if result_type is None:
|
|
55
|
+
return True, captures if captures else match.group(0)
|
|
56
|
+
if result_type in {str, int, float}:
|
|
57
|
+
value = (
|
|
58
|
+
next(iter(captures.values())) if captures else match.group(0)
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
return True, result_type(value)
|
|
62
|
+
except (ValueError, TypeError) as exc:
|
|
63
|
+
raise CommandError(
|
|
64
|
+
f"response conversion failed: {exc}"
|
|
65
|
+
) from exc
|
|
66
|
+
if not is_dataclass(result_type):
|
|
67
|
+
raise CommandError("result_type must be a scalar or dataclass")
|
|
68
|
+
hints = get_type_hints(result_type)
|
|
69
|
+
converted: dict[str, Any] = {}
|
|
70
|
+
try:
|
|
71
|
+
for field in fields(result_type):
|
|
72
|
+
raw = captures[field.name]
|
|
73
|
+
field_type = hints[field.name]
|
|
74
|
+
if field_type in {str, int, float}:
|
|
75
|
+
converted[field.name] = field_type(raw)
|
|
76
|
+
elif isinstance(field_type, type) and issubclass(
|
|
77
|
+
field_type, Enum
|
|
78
|
+
):
|
|
79
|
+
try:
|
|
80
|
+
converted[field.name] = field_type(raw)
|
|
81
|
+
except ValueError:
|
|
82
|
+
converted[field.name] = field_type[raw]
|
|
83
|
+
else:
|
|
84
|
+
raise CommandError(
|
|
85
|
+
f"unsupported result field type: {field.name}"
|
|
86
|
+
)
|
|
87
|
+
return True, result_type(**converted)
|
|
88
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
89
|
+
raise CommandError(f"response conversion failed: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["ResponseParser"]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""One-device QtCore facade for serial connection and command lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
# PySide6 exposes these C++ types through generated bindings, which pylint
|
|
10
|
+
# cannot resolve even though ty and Python imports can.
|
|
11
|
+
# pylint: disable=no-name-in-module
|
|
12
|
+
from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, Signal
|
|
13
|
+
|
|
14
|
+
# pylint: enable=no-name-in-module
|
|
15
|
+
from ..advanced import CommandPriority, CommandTicket, HandlerStats
|
|
16
|
+
from ..diagnostics import TrafficLogger
|
|
17
|
+
from ..enums import CommandStatus, ConnectionState, SendRoute
|
|
18
|
+
from ..errors import CommandError, SerialForgeError
|
|
19
|
+
from ..models import (
|
|
20
|
+
CommandResult,
|
|
21
|
+
CommandSpec,
|
|
22
|
+
DeviceInfo,
|
|
23
|
+
DeviceProfile,
|
|
24
|
+
EventSpec,
|
|
25
|
+
LogConfig,
|
|
26
|
+
)
|
|
27
|
+
from .command_registry import CommandRegistry
|
|
28
|
+
from .connection_worker import ConnectionWorker
|
|
29
|
+
|
|
30
|
+
# The facade owns the complete lifecycle state for one serial connection.
|
|
31
|
+
# pylint: disable=too-many-instance-attributes
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .command_dispatcher import CommandDispatcher
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SerialHandler(QObject):
|
|
38
|
+
"""Coordinate one connection, its command registry, and Qt signals."""
|
|
39
|
+
|
|
40
|
+
connection_state_changed = Signal(ConnectionState)
|
|
41
|
+
command_finished = Signal(object)
|
|
42
|
+
event_received = Signal(object)
|
|
43
|
+
traffic_logged = Signal(object)
|
|
44
|
+
error_occurred = Signal(object)
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
profile: DeviceProfile,
|
|
49
|
+
*,
|
|
50
|
+
log: LogConfig | None = None,
|
|
51
|
+
allow_raw_text: bool = True,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Create a disconnected handler with no open serial resources."""
|
|
54
|
+
application = QCoreApplication.instance()
|
|
55
|
+
if application is None:
|
|
56
|
+
raise SerialForgeError("SerialHandler requires a QCoreApplication")
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._profile: DeviceProfile = profile
|
|
59
|
+
self._log: LogConfig = log or LogConfig()
|
|
60
|
+
self._allow_raw_text: bool = allow_raw_text
|
|
61
|
+
self._state: ConnectionState = ConnectionState.DISCONNECTED
|
|
62
|
+
self._state_lock: threading.RLock = threading.RLock()
|
|
63
|
+
self._worker: ConnectionWorker | None = None
|
|
64
|
+
self._traffic: TrafficLogger = TrafficLogger(
|
|
65
|
+
self._log, profile.runtime, encoding=profile.encoding
|
|
66
|
+
)
|
|
67
|
+
self._traffic.traffic_logged.connect(
|
|
68
|
+
self.traffic_logged.emit, Qt.ConnectionType.DirectConnection
|
|
69
|
+
)
|
|
70
|
+
self._traffic.error_occurred.connect(
|
|
71
|
+
self._report_error, Qt.ConnectionType.DirectConnection
|
|
72
|
+
)
|
|
73
|
+
self._registry: CommandRegistry = CommandRegistry(profile.terminator)
|
|
74
|
+
# Keep the top-level package import free of advanced implementation.
|
|
75
|
+
# pylint: disable=import-outside-toplevel
|
|
76
|
+
from .command_dispatcher import CommandDispatcher
|
|
77
|
+
|
|
78
|
+
self._dispatcher: CommandDispatcher = CommandDispatcher(
|
|
79
|
+
profile, self._registry, self._write
|
|
80
|
+
)
|
|
81
|
+
self._dispatcher.command_finished.connect(
|
|
82
|
+
self.command_finished.emit, Qt.ConnectionType.DirectConnection
|
|
83
|
+
)
|
|
84
|
+
self._dispatcher.event_received.connect(
|
|
85
|
+
self.event_received.emit, Qt.ConnectionType.DirectConnection
|
|
86
|
+
)
|
|
87
|
+
self._dispatcher.error_occurred.connect(
|
|
88
|
+
self.error_occurred.emit, Qt.ConnectionType.DirectConnection
|
|
89
|
+
)
|
|
90
|
+
self._dispatcher.tx_written.connect(
|
|
91
|
+
self._record_tx, Qt.ConnectionType.DirectConnection
|
|
92
|
+
)
|
|
93
|
+
self._init_sequence: tuple[CommandSpec | str, ...] = ()
|
|
94
|
+
application.aboutToQuit.connect(self.disconnect)
|
|
95
|
+
|
|
96
|
+
def _write(self, data: bytes) -> int:
|
|
97
|
+
"""Send bytes through the active connection worker."""
|
|
98
|
+
worker = self._worker
|
|
99
|
+
if worker is None:
|
|
100
|
+
raise RuntimeError("serial transport is not connected")
|
|
101
|
+
return worker.write(data)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def state(self) -> ConnectionState:
|
|
105
|
+
"""Return the current connection state."""
|
|
106
|
+
with self._state_lock:
|
|
107
|
+
return self._state
|
|
108
|
+
|
|
109
|
+
# ty: ignore[invalid-method-override, missing-override-decorator] --
|
|
110
|
+
# QObject has an unrelated C++ signal-connect overload with the same name.
|
|
111
|
+
def connect(self, target: str | DeviceInfo | None = None) -> None:
|
|
112
|
+
"""Start an asynchronous connection to a device or matching profile."""
|
|
113
|
+
with self._state_lock:
|
|
114
|
+
if self._state not in {
|
|
115
|
+
ConnectionState.DISCONNECTED,
|
|
116
|
+
ConnectionState.FAILED,
|
|
117
|
+
}:
|
|
118
|
+
raise SerialForgeError(
|
|
119
|
+
f"cannot connect while {self._state.value}"
|
|
120
|
+
)
|
|
121
|
+
worker = self._worker
|
|
122
|
+
if worker is not None and worker.isRunning():
|
|
123
|
+
raise SerialForgeError(
|
|
124
|
+
"previous connection worker is still running"
|
|
125
|
+
)
|
|
126
|
+
self._worker = ConnectionWorker(self, target)
|
|
127
|
+
self._transition(
|
|
128
|
+
ConnectionState.CONNECTING
|
|
129
|
+
if isinstance(target, DeviceInfo)
|
|
130
|
+
else ConnectionState.PROBING
|
|
131
|
+
)
|
|
132
|
+
assert self._worker is not None
|
|
133
|
+
self._worker.start()
|
|
134
|
+
|
|
135
|
+
# ty: ignore[invalid-method-override, missing-override-decorator] --
|
|
136
|
+
# This public facade method intentionally shadows QObject.disconnect().
|
|
137
|
+
def disconnect(self) -> None:
|
|
138
|
+
"""Stop the connection and join its I/O threads idempotently."""
|
|
139
|
+
worker = self._worker
|
|
140
|
+
if worker is not None:
|
|
141
|
+
worker.request_stop()
|
|
142
|
+
for _ in range(50):
|
|
143
|
+
if worker.wait(100):
|
|
144
|
+
break
|
|
145
|
+
worker.request_stop()
|
|
146
|
+
else:
|
|
147
|
+
raise SerialForgeError("connection worker did not stop")
|
|
148
|
+
self._worker = None
|
|
149
|
+
self._dispatcher.set_connected(False, user_initiated=True)
|
|
150
|
+
self._traffic.end_connection()
|
|
151
|
+
self._transition(ConnectionState.DISCONNECTED)
|
|
152
|
+
|
|
153
|
+
def _transition(self, state: ConnectionState) -> None:
|
|
154
|
+
"""Publish a state change from either connection thread."""
|
|
155
|
+
with self._state_lock:
|
|
156
|
+
if self._state is state:
|
|
157
|
+
return
|
|
158
|
+
self._state = state
|
|
159
|
+
self.connection_state_changed.emit(state)
|
|
160
|
+
|
|
161
|
+
def _report_error(self, error: Exception) -> None:
|
|
162
|
+
"""Normalize I/O failures to the public exception hierarchy."""
|
|
163
|
+
payload = (
|
|
164
|
+
error
|
|
165
|
+
if isinstance(error, SerialForgeError)
|
|
166
|
+
else SerialForgeError(str(error))
|
|
167
|
+
)
|
|
168
|
+
self.error_occurred.emit(payload)
|
|
169
|
+
|
|
170
|
+
def _receive_frame(self, frame: bytes) -> None:
|
|
171
|
+
"""Record and dispatch one completed receive frame."""
|
|
172
|
+
worker = self._worker
|
|
173
|
+
port = worker.port if worker is not None else None
|
|
174
|
+
self._traffic.record_rx(port or "-", frame)
|
|
175
|
+
self._dispatcher.receive_frame(frame)
|
|
176
|
+
|
|
177
|
+
def _record_tx(
|
|
178
|
+
self, data: bytes, route: SendRoute, spec: CommandSpec | None
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Record a successful command write with its original spec."""
|
|
181
|
+
worker = self._worker
|
|
182
|
+
port = worker.port if worker is not None else None
|
|
183
|
+
self._traffic.record_tx(port or "-", data, route, spec)
|
|
184
|
+
|
|
185
|
+
def register(self, *specs: CommandSpec | EventSpec) -> None:
|
|
186
|
+
"""Register command and event declarations by object identity."""
|
|
187
|
+
self._registry.register(*specs)
|
|
188
|
+
|
|
189
|
+
def unregister(self, spec: CommandSpec | EventSpec) -> None:
|
|
190
|
+
"""Remove a declaration from future sends and event matches."""
|
|
191
|
+
self._registry.unregister(spec)
|
|
192
|
+
|
|
193
|
+
def set_init_sequence(self, items: list[CommandSpec | str]) -> None:
|
|
194
|
+
"""Validate and retain steps for M5 connection initialization."""
|
|
195
|
+
for item in items:
|
|
196
|
+
if isinstance(item, CommandSpec):
|
|
197
|
+
if not self._registry.contains(item):
|
|
198
|
+
raise CommandError(
|
|
199
|
+
f"init command {item.name!r} is not registered"
|
|
200
|
+
)
|
|
201
|
+
if item.placeholders:
|
|
202
|
+
raise CommandError(
|
|
203
|
+
"init command with placeholders requires text"
|
|
204
|
+
)
|
|
205
|
+
elif isinstance(item, str):
|
|
206
|
+
if (
|
|
207
|
+
self._registry.resolve_text(item, self._profile.terminator)
|
|
208
|
+
is None
|
|
209
|
+
):
|
|
210
|
+
raise CommandError(
|
|
211
|
+
f"init command text is not registered: {item!r}"
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
raise CommandError("init sequence accepts CommandSpec or str")
|
|
215
|
+
self._init_sequence = tuple(items)
|
|
216
|
+
|
|
217
|
+
def send(
|
|
218
|
+
self,
|
|
219
|
+
target: CommandSpec | str,
|
|
220
|
+
/,
|
|
221
|
+
*,
|
|
222
|
+
priority: CommandPriority | None = None,
|
|
223
|
+
**params: object,
|
|
224
|
+
) -> CommandTicket:
|
|
225
|
+
"""Queue a registered command or safe raw text without blocking."""
|
|
226
|
+
if priority is None:
|
|
227
|
+
priority = CommandPriority.NORMAL
|
|
228
|
+
return self._dispatcher.submit(
|
|
229
|
+
target,
|
|
230
|
+
priority=priority,
|
|
231
|
+
allow_raw_text=self._allow_raw_text,
|
|
232
|
+
**params,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def send_and_wait(
|
|
236
|
+
self, target: CommandSpec | str, /, *, timeout: float, **params: object
|
|
237
|
+
) -> CommandResult:
|
|
238
|
+
"""Wait in a script thread that has no running Qt event loop."""
|
|
239
|
+
if QThread.currentThread().loopLevel() > 0:
|
|
240
|
+
raise CommandError("send_and_wait cannot run in a Qt event loop")
|
|
241
|
+
if timeout <= 0:
|
|
242
|
+
raise CommandError("timeout must be positive")
|
|
243
|
+
received: list[CommandResult] = []
|
|
244
|
+
done = threading.Event()
|
|
245
|
+
request_id: str | None = None
|
|
246
|
+
|
|
247
|
+
def on_finished(result: CommandResult) -> None:
|
|
248
|
+
received.append(result)
|
|
249
|
+
if result.request_id == request_id:
|
|
250
|
+
done.set()
|
|
251
|
+
|
|
252
|
+
self.command_finished.connect(
|
|
253
|
+
on_finished, Qt.ConnectionType.DirectConnection
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
# ty: ignore[invalid-argument-type] -- send validates variadic parameters.
|
|
257
|
+
ticket = self.send(target, **params)
|
|
258
|
+
request_id = ticket.request_id
|
|
259
|
+
if not any(
|
|
260
|
+
result.request_id == ticket.request_id for result in received
|
|
261
|
+
):
|
|
262
|
+
done.wait(timeout)
|
|
263
|
+
for result in received:
|
|
264
|
+
if result.request_id == ticket.request_id:
|
|
265
|
+
return result
|
|
266
|
+
self._dispatcher.cancel_ticket(ticket, CommandStatus.TIMEOUT)
|
|
267
|
+
for result in received:
|
|
268
|
+
if result.request_id == ticket.request_id:
|
|
269
|
+
return result
|
|
270
|
+
raise CommandError("command timeout could not be resolved")
|
|
271
|
+
finally:
|
|
272
|
+
self.command_finished.disconnect(on_finished)
|
|
273
|
+
|
|
274
|
+
def set_log_config(self, config: LogConfig) -> None:
|
|
275
|
+
"""Apply log switches and retain file policy for this session."""
|
|
276
|
+
self._log = config
|
|
277
|
+
self._traffic.set_log_config(config)
|
|
278
|
+
|
|
279
|
+
def stats(self) -> HandlerStats:
|
|
280
|
+
"""Return latency, event, and file traffic drop counters."""
|
|
281
|
+
return replace(
|
|
282
|
+
self._dispatcher.stats(),
|
|
283
|
+
dropped_traffic_lines=self._traffic.dropped_traffic_lines,
|
|
284
|
+
)
|