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,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
+ )
@@ -0,0 +1,6 @@
1
+ """Traffic logging and per-connection log file management."""
2
+
3
+ from .log_file_manager import LogFileManager
4
+ from .traffic_logger import TrafficLogger
5
+
6
+ __all__ = ["LogFileManager", "TrafficLogger"]