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,352 @@
1
+ """Manage queued, per-connection log files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from _thread import RLock as RLockType
7
+ from collections.abc import Callable
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from threading import RLock
11
+ from typing import TYPE_CHECKING, TextIO
12
+
13
+ from loguru import logger
14
+
15
+ # PySide6 exposes these C++ types through generated bindings, which pylint
16
+ # cannot resolve even though ty and Python imports can.
17
+ # pylint: disable=no-name-in-module
18
+ from PySide6.QtCore import QCoreApplication, QStandardPaths
19
+
20
+ # pylint: enable=no-name-in-module
21
+ from serialforge.models import LogConfig
22
+
23
+ if TYPE_CHECKING:
24
+ from loguru import Message, Record
25
+
26
+ _LOG_FILE_PATTERN: re.Pattern[str] = re.compile(
27
+ r"^\d{8}_\d{6}_\d{3}(?:_\d+)?\.txt$"
28
+ )
29
+
30
+
31
+ # pylint: disable=protected-access,too-many-instance-attributes
32
+ class LogFileManager:
33
+ """Manage one timestamped UTF-8 log file for an active connection."""
34
+
35
+ class _QueuedFileSink:
36
+ """Write queued loguru messages and enforce the traffic size limit."""
37
+
38
+ def __init__(
39
+ self,
40
+ manager: LogFileManager,
41
+ path: Path,
42
+ max_file_bytes: int,
43
+ on_error: Callable[[OSError], None],
44
+ ) -> None:
45
+ """Open a log file for append."""
46
+ self._manager: LogFileManager = manager
47
+ self._max_file_bytes: int = max_file_bytes
48
+ self._on_error: Callable[[OSError], None] = on_error
49
+ self._file: TextIO = path.open("a", encoding="utf-8", newline="")
50
+ self._failed: bool = False
51
+ self._lock: RLockType = RLock()
52
+
53
+ def write(self, message: Message) -> None:
54
+ """Write a formatted message unless traffic exceeds the limit."""
55
+ with self._lock:
56
+ if self._failed or self._file.closed:
57
+ return
58
+ kind = message.record["extra"].get("traffic_kind", "")
59
+ direction = kind if isinstance(kind, str) else ""
60
+ byte_count = len(message.encode("utf-8"))
61
+ manager = self._manager
62
+ if direction in {"TX", "RX"} and (
63
+ manager._traffic_limit_reached
64
+ or manager._written_bytes + byte_count
65
+ > self._max_file_bytes
66
+ ):
67
+ manager._dropped_rows += 1
68
+ if not manager._limit_notice_written:
69
+ manager._traffic_limit_reached = True
70
+ manager._limit_notice_written = True
71
+ self.write_event(
72
+ "max_file_bytes reached; "
73
+ "TX/RX traffic rows disabled"
74
+ )
75
+ return
76
+ self._write(message, byte_count)
77
+
78
+ def stop(self) -> None:
79
+ """Close the file after loguru drains the queued messages."""
80
+ with self._lock:
81
+ if not self._file.closed:
82
+ self._file.close()
83
+
84
+ def write_event(self, text: str) -> None:
85
+ """Append an event row that is allowed beyond the traffic limit."""
86
+ line = _event_line(text)
87
+ self._write(line, len(line.encode("utf-8")))
88
+
89
+ def _write(self, message: str, byte_count: int) -> None:
90
+ """Write and flush one row, reporting file I/O failures."""
91
+ try:
92
+ self._file.write(message)
93
+ self._file.flush()
94
+ self._manager._written_bytes += byte_count
95
+ except OSError as exc:
96
+ self._failed = True
97
+ self._file.close()
98
+ self._on_error(exc)
99
+
100
+ def __init__(
101
+ self,
102
+ config: LogConfig,
103
+ *,
104
+ on_error: Callable[[OSError], None] | None = None,
105
+ ) -> None:
106
+ """Store file policy without creating directories or files."""
107
+ self._config: LogConfig = config
108
+ self._on_error: Callable[[OSError], None] = (
109
+ on_error or self._ignore_error
110
+ )
111
+ self._enabled: bool = config.enabled
112
+ self._save_to_file: bool = config.save_to_file
113
+ self._connection_id: str | None = None
114
+ self._path: Path | None = None
115
+ self._sink_id: int | None = None
116
+ self._sink: LogFileManager._QueuedFileSink | None = None
117
+ self._written_bytes: int = 0
118
+ self._dropped_rows: int = 0
119
+ self._traffic_limit_reached: bool = False
120
+ self._limit_notice_written: bool = False
121
+ self._application: QCoreApplication | None = None
122
+ self._closed: bool = False
123
+ self._lock: RLockType = RLock()
124
+ application = QCoreApplication.instance()
125
+ if application is not None:
126
+ application.aboutToQuit.connect(self.close)
127
+ self._application = application
128
+
129
+ @property
130
+ def current_path(self) -> Path | None:
131
+ """Return the current connection's file path, if one was created."""
132
+ with self._lock:
133
+ return self._path
134
+
135
+ @property
136
+ def dropped_rows(self) -> int:
137
+ """Return the number of traffic rows omitted by the file limit."""
138
+ with self._lock:
139
+ return self._dropped_rows
140
+
141
+ def set_config(self, config: LogConfig) -> None:
142
+ """Apply runtime policy without splitting an existing file session."""
143
+ with self._lock:
144
+ self._config = config
145
+ self._enabled = config.enabled
146
+ self._save_to_file = config.save_to_file
147
+ if self._enabled and self._save_to_file:
148
+ self._start_sink_if_enabled()
149
+ else:
150
+ self._stop_sink()
151
+
152
+ def begin_connection(self, connection_id: str) -> Path | None:
153
+ """Start a connection session without creating a file during probing."""
154
+ if not connection_id:
155
+ raise ValueError("connection_id must not be empty")
156
+ with self._lock:
157
+ if self._closed:
158
+ raise RuntimeError("LogFileManager is closed")
159
+ if self._connection_id is not None:
160
+ raise RuntimeError("a log connection is already active")
161
+ self._connection_id = connection_id
162
+ self._path = None
163
+ self._written_bytes = 0
164
+ self._dropped_rows = 0
165
+ self._traffic_limit_reached = False
166
+ self._limit_notice_written = False
167
+ self._start_sink_if_enabled()
168
+ return self._path
169
+
170
+ def end_connection(self) -> None:
171
+ """Finish the active connection and flush its queued file messages."""
172
+ with self._lock:
173
+ self._stop_sink()
174
+ self._write_drop_summary()
175
+ self._connection_id = None
176
+ self._path = None
177
+
178
+ def set_enabled(self, enabled: bool) -> None:
179
+ """Change the total logging switch while preserving the active file."""
180
+ with self._lock:
181
+ self._enabled = enabled
182
+ if enabled:
183
+ self._start_sink_if_enabled()
184
+ else:
185
+ self._stop_sink()
186
+
187
+ def set_save_to_file(self, enabled: bool) -> None:
188
+ """Change file logging for the active connection at runtime."""
189
+ with self._lock:
190
+ self._save_to_file = enabled
191
+ if enabled:
192
+ self._start_sink_if_enabled()
193
+ else:
194
+ self._stop_sink()
195
+
196
+ def close(self) -> None:
197
+ """Flush and close the active sink during shutdown."""
198
+ with self._lock:
199
+ if self._closed:
200
+ return
201
+ self._stop_sink()
202
+ self._write_drop_summary()
203
+ self._connection_id = None
204
+ self._path = None
205
+ self._closed = True
206
+ application = self._application
207
+ if (
208
+ application is not None
209
+ and application is QCoreApplication.instance()
210
+ ):
211
+ application.aboutToQuit.disconnect(self.close)
212
+ self._application = None
213
+
214
+ def _start_sink_if_enabled(self) -> None:
215
+ """Create or reopen the active file when both switches are on."""
216
+ if (
217
+ not self._enabled
218
+ or not self._save_to_file
219
+ or self._connection_id is None
220
+ or self._sink_id is not None
221
+ or self._closed
222
+ ):
223
+ return
224
+ try:
225
+ if self._path is None:
226
+ self._path = self._create_file()
227
+ self._written_bytes = self._path.stat().st_size
228
+ sink = self._QueuedFileSink(
229
+ self,
230
+ self._path,
231
+ self._config.max_file_bytes,
232
+ self._handle_sink_error,
233
+ )
234
+ connection_id = self._connection_id
235
+
236
+ def connection_filter(record: Record) -> bool:
237
+ value = record["extra"].get("conn_id")
238
+ return isinstance(value, str) and value == connection_id
239
+
240
+ self._sink = sink
241
+ self._sink_id = logger.add(
242
+ sink,
243
+ level="DEBUG",
244
+ format="{message}",
245
+ filter=connection_filter,
246
+ enqueue=True,
247
+ catch=True,
248
+ )
249
+ except OSError as exc:
250
+ self._save_to_file = False
251
+ self._on_error(exc)
252
+
253
+ def _stop_sink(self) -> None:
254
+ """Remove the current handler and drain queued messages."""
255
+ if self._sink_id is None:
256
+ return
257
+ sink_id = self._sink_id
258
+ self._sink_id = None
259
+ self._sink = None
260
+ logger.remove(sink_id)
261
+ logger.complete()
262
+
263
+ def _create_file(self) -> Path:
264
+ """Create a unique timestamp path and apply safe retention cleanup."""
265
+ directory = self._log_directory()
266
+ directory.mkdir(parents=True, exist_ok=True)
267
+ base = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
268
+ suffix = 0
269
+ while True:
270
+ extra = f"_{suffix}" if suffix else ""
271
+ path = directory / f"{base}{extra}.txt"
272
+ try:
273
+ with path.open("x", encoding="utf-8", newline=""):
274
+ pass
275
+ break
276
+ except FileExistsError:
277
+ suffix += 1
278
+ self._clean_old_files(directory, path)
279
+ return path
280
+
281
+ def _log_directory(self) -> Path:
282
+ """Resolve an explicit folder or the app-local serial log folder."""
283
+ if self._config.dir is not None:
284
+ return self._config.dir
285
+ app_name = self._config.app_name
286
+ if app_name is None:
287
+ app_name = QCoreApplication.applicationName() or "serialforge"
288
+ root = Path(
289
+ QStandardPaths.writableLocation(
290
+ QStandardPaths.StandardLocation.AppLocalDataLocation
291
+ )
292
+ )
293
+ return root / app_name / "serial_logs"
294
+
295
+ def _clean_old_files(self, directory: Path, current_path: Path) -> None:
296
+ """Delete oldest matching files, leaving unrelated files untouched."""
297
+ files = sorted(
298
+ path
299
+ for path in directory.iterdir()
300
+ if path.is_file() and _LOG_FILE_PATTERN.fullmatch(path.name)
301
+ )
302
+ remaining = len(files)
303
+ for path in files:
304
+ if remaining <= self._config.max_files:
305
+ break
306
+ if path == current_path:
307
+ continue
308
+ try:
309
+ path.unlink()
310
+ except FileNotFoundError:
311
+ remaining -= 1
312
+ except OSError:
313
+ continue
314
+ else:
315
+ remaining -= 1
316
+
317
+ def _write_drop_summary(self) -> None:
318
+ """Append one cumulative traffic-drop summary at connection end."""
319
+ if self._path is None or self._dropped_rows <= 0:
320
+ return
321
+ line = _event_line(
322
+ f"log closed; dropped {self._dropped_rows} traffic rows"
323
+ )
324
+ try:
325
+ with self._path.open("a", encoding="utf-8", newline="") as file:
326
+ file.write(line)
327
+ file.flush()
328
+ self._written_bytes += len(line.encode("utf-8"))
329
+ except OSError as exc:
330
+ self._save_to_file = False
331
+ self._on_error(exc)
332
+
333
+ def _handle_sink_error(self, error: OSError) -> None:
334
+ """Disable future file output after an asynchronous write failure."""
335
+ self._save_to_file = False
336
+ self._on_error(error)
337
+
338
+ @staticmethod
339
+ def _ignore_error(error: OSError) -> None:
340
+ """Discard errors when no callback was supplied."""
341
+ del error
342
+
343
+
344
+ def _event_line(text: str) -> str:
345
+ """Format a lifecycle line for file sink quota notices and summaries."""
346
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
347
+ data = text.encode("utf-8")
348
+ hex_text = " ".join(f"{byte:02X}" for byte in data)
349
+ return f"{now} | EVT | - | - | {hex_text} | {text}\n"
350
+
351
+
352
+ __all__ = ["LogFileManager"]
@@ -0,0 +1,286 @@
1
+ """Capture bounded traffic history and emit DEBUG loguru records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from _thread import RLock as RLockType
6
+ from codecs import lookup
7
+ from collections import deque
8
+ from collections.abc import Callable
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from threading import RLock
12
+ from time import monotonic, time
13
+ from uuid import uuid4
14
+
15
+ from loguru import logger
16
+
17
+ # PySide6 exposes these C++ types through generated bindings, which pylint
18
+ # cannot resolve even though ty and Python imports can.
19
+ # pylint: disable=no-name-in-module
20
+ from PySide6.QtCore import QObject, Signal
21
+
22
+ # pylint: enable=no-name-in-module
23
+ from serialforge.advanced import RuntimeConfig, TrafficRecord
24
+ from serialforge.diagnostics.log_file_manager import LogFileManager
25
+ from serialforge.enums import SendRoute
26
+ from serialforge.errors import ConfigError
27
+ from serialforge.models import CommandSpec, EventSpec, LogConfig
28
+
29
+ _TEXT_ESCAPE_TABLE: dict[int, str] = str.maketrans(
30
+ {"\\": "\\\\", "\r": "\\r", "\n": "\\n", "|": "\\x7C"}
31
+ )
32
+
33
+
34
+ # pylint: disable=too-many-instance-attributes
35
+ class TrafficLogger(QObject):
36
+ """Record TX/RX traffic and lifecycle events for one device handler.
37
+
38
+ Note:
39
+ Gate emission per instance instead of disabling the ``serialforge``
40
+ loguru namespace during import, which would mutate host-global state.
41
+ """
42
+
43
+ traffic_logged = Signal(object)
44
+ error_occurred = Signal(object)
45
+
46
+ def __init__(
47
+ self,
48
+ config: LogConfig | None = None,
49
+ runtime: RuntimeConfig | None = None,
50
+ *,
51
+ encoding: str = "utf-8",
52
+ clock: Callable[[], float] = monotonic,
53
+ ) -> None:
54
+ """Create an in-memory logger without changing global loguru state."""
55
+ super().__init__()
56
+ self._config: LogConfig = config or LogConfig()
57
+ self._runtime: RuntimeConfig = runtime or RuntimeConfig()
58
+ self._records: deque[TrafficRecord] = deque(
59
+ maxlen=self._runtime.traffic_ring_size
60
+ )
61
+ self._records_lock: RLockType = RLock()
62
+ self._clock: Callable[[], float] = clock
63
+ self._pending_stream_records: dict[CommandSpec, TrafficRecord] = {}
64
+ self._last_stream_emit_at: float = (
65
+ clock() - self._runtime.stream_emit_interval_ms / 1000
66
+ )
67
+ self._enabled: bool = self._config.enabled
68
+ try:
69
+ lookup(encoding)
70
+ except LookupError as exc:
71
+ raise ConfigError(f"unknown encoding: {encoding}") from exc
72
+ self._encoding: str = encoding
73
+ self._connection_id: str | None = None
74
+ self._scope_id: str = uuid4().hex
75
+ self._port: str = "-"
76
+ self._manager: LogFileManager = LogFileManager(
77
+ self._config,
78
+ on_error=self.error_occurred.emit,
79
+ )
80
+
81
+ @property
82
+ def recent_records(self) -> tuple[TrafficRecord, ...]:
83
+ """Return an immutable snapshot of the bounded TX/RX history."""
84
+ with self._records_lock:
85
+ return tuple(self._records)
86
+
87
+ @property
88
+ def current_log_path(self) -> Path | None:
89
+ """Return the current connection's file path when file logging began."""
90
+ return self._manager.current_path
91
+
92
+ @property
93
+ def dropped_traffic_lines(self) -> int:
94
+ """Return the file sink's cumulative dropped traffic rows."""
95
+ return self._manager.dropped_rows
96
+
97
+ def set_log_config(self, config: LogConfig) -> None:
98
+ """Apply a complete runtime log policy to this traffic session."""
99
+ self._config = config
100
+ self._enabled = config.enabled
101
+ self._manager.set_config(config)
102
+
103
+ def begin_connection(self, port: str) -> None:
104
+ """Begin a log session after a connection has reached CONNECTED."""
105
+ if self._connection_id is not None:
106
+ raise RuntimeError("a traffic log connection is already active")
107
+ connection_id = uuid4().hex
108
+ self._manager.begin_connection(connection_id)
109
+ self._connection_id = connection_id
110
+ self._port = port
111
+ self.record_event(port, "CONNECTED")
112
+
113
+ def end_connection(self, port: str | None = None) -> None:
114
+ """Write a disconnect event and flush the active connection file."""
115
+ if self._connection_id is None:
116
+ return
117
+ self.record_event(port or self._port, "DISCONNECTED")
118
+ self._manager.end_connection()
119
+ self._connection_id = None
120
+ self._port = "-"
121
+
122
+ def set_enabled(self, enabled: bool) -> None:
123
+ """Enable or silence loguru output and any configured file sink."""
124
+ self._enabled = enabled
125
+ self._manager.set_enabled(enabled)
126
+
127
+ def set_save_to_file(self, enabled: bool) -> None:
128
+ """Enable or stop file output without changing the active file path."""
129
+ self._manager.set_save_to_file(enabled)
130
+
131
+ def record_tx(
132
+ self,
133
+ port: str,
134
+ data: bytes,
135
+ route: SendRoute,
136
+ spec: CommandSpec | EventSpec | None = None,
137
+ ) -> TrafficRecord:
138
+ """Record one transmitted frame and its command match annotation."""
139
+ if route is SendRoute.RAW:
140
+ annotation = "RAW"
141
+ elif spec is None or spec.name is None:
142
+ raise ValueError(f"route {route.value} requires a named spec")
143
+ else:
144
+ annotation = f"{route.name}:{spec.name}"
145
+ record = TrafficRecord(
146
+ direction="TX",
147
+ timestamp=time(),
148
+ port=port,
149
+ data=bytes(data),
150
+ route=route,
151
+ spec=spec,
152
+ )
153
+ self._record(record, annotation)
154
+ return record
155
+
156
+ def record_rx(
157
+ self,
158
+ port: str,
159
+ data: bytes,
160
+ *,
161
+ stream_spec: CommandSpec | None = None,
162
+ ) -> TrafficRecord:
163
+ """Record a frame, coalescing high-rate stream UI notifications."""
164
+ record = TrafficRecord(
165
+ direction="RX",
166
+ timestamp=time(),
167
+ port=port,
168
+ data=bytes(data),
169
+ spec=stream_spec,
170
+ )
171
+ self._record(record, "-", stream_spec=stream_spec)
172
+ return record
173
+
174
+ def flush_stream_traffic(self, *, force: bool = False) -> None:
175
+ """Emit the latest buffered record per stream at the interval."""
176
+ now = self._clock()
177
+ with self._records_lock:
178
+ if not force and now - self._last_stream_emit_at < (
179
+ self._runtime.stream_emit_interval_ms / 1000
180
+ ):
181
+ return
182
+ records = tuple(self._pending_stream_records.values())
183
+ self._pending_stream_records.clear()
184
+ self._last_stream_emit_at = now
185
+ for record in records:
186
+ self.traffic_logged.emit(record)
187
+
188
+ def record_event(self, port: str, event: str) -> None:
189
+ """Record one lifecycle event without adding it to the traffic ring."""
190
+ if not self._manager_enabled:
191
+ return
192
+ timestamp = time()
193
+ data = event.encode("utf-8")
194
+ logger.bind(
195
+ conn_id=self._connection_id or self._scope_id,
196
+ route="event",
197
+ spec_name="",
198
+ traffic_kind="EVT",
199
+ ).opt(lazy=True).debug(
200
+ "{}",
201
+ lambda: self._format_line(timestamp, "EVT", port, "-", data),
202
+ )
203
+
204
+ def close(self) -> None:
205
+ """Flush the manager and release its application shutdown hook."""
206
+ self.flush_stream_traffic(force=True)
207
+ self.end_connection()
208
+ self._manager.close()
209
+
210
+ @property
211
+ def _manager_enabled(self) -> bool:
212
+ """Return whether this instance is allowed to emit loguru records."""
213
+ return self._enabled
214
+
215
+ def _record(
216
+ self,
217
+ record: TrafficRecord,
218
+ annotation: str,
219
+ *,
220
+ stream_spec: CommandSpec | None = None,
221
+ ) -> None:
222
+ """Append, signal, and optionally log one traffic record."""
223
+ with self._records_lock:
224
+ self._records.append(record)
225
+ if stream_spec is not None:
226
+ self._pending_stream_records[stream_spec] = record
227
+ if stream_spec is None:
228
+ self.traffic_logged.emit(record)
229
+ else:
230
+ self.flush_stream_traffic()
231
+ if not self._manager_enabled:
232
+ return
233
+ spec_name = self._spec_name(record.spec)
234
+ logger.bind(
235
+ conn_id=self._connection_id or self._scope_id,
236
+ route=record.route.value if record.route is not None else "",
237
+ spec_name=spec_name,
238
+ traffic_kind=record.direction,
239
+ ).opt(lazy=True).debug(
240
+ "{}", lambda: self._format_traffic(record, annotation)
241
+ )
242
+
243
+ def _format_traffic(self, record: TrafficRecord, annotation: str) -> str:
244
+ """Format a traffic record using the public file and console layout."""
245
+ return self._format_line(
246
+ record.timestamp,
247
+ record.direction,
248
+ record.port,
249
+ annotation,
250
+ record.data,
251
+ )
252
+
253
+ def _format_line(
254
+ self,
255
+ timestamp: float,
256
+ direction: str,
257
+ port: str,
258
+ annotation: str,
259
+ data: bytes,
260
+ ) -> str:
261
+ """Render a timestamped row with bounded hexadecimal output."""
262
+ time_text = datetime.fromtimestamp(timestamp).strftime(
263
+ "%Y-%m-%d %H:%M:%S.%f"
264
+ )[:-3]
265
+ byte_limit = self._runtime.hex_truncate_bytes
266
+ display = data[:byte_limit]
267
+ hex_text = " ".join(f"{value:02X}" for value in display)
268
+ if len(data) > byte_limit:
269
+ hex_text += f" ... (+{len(data) - byte_limit} bytes)"
270
+ text = data.decode(self._encoding, errors="replace").translate(
271
+ _TEXT_ESCAPE_TABLE
272
+ )
273
+ return (
274
+ f"{time_text} | {direction} | {port} | {annotation} | "
275
+ f"{hex_text} | {text}"
276
+ )
277
+
278
+ @staticmethod
279
+ def _spec_name(spec: object | None) -> str:
280
+ """Return a stable display name for the bound loguru record."""
281
+ if isinstance(spec, (CommandSpec, EventSpec)):
282
+ return spec.name or ""
283
+ return ""
284
+
285
+
286
+ __all__ = ["TrafficLogger"]
@@ -0,0 +1,5 @@
1
+ """Discovery facade exports."""
2
+
3
+ from .device_finder import DeviceFinder
4
+
5
+ __all__ = ["DeviceFinder"]
@@ -0,0 +1,39 @@
1
+ """QtCore worker for a single asynchronous device discovery request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from typing_extensions import override
8
+
9
+ # pylint: disable=no-name-in-module
10
+ from PySide6.QtCore import QThread
11
+
12
+ from ..enums import ScanMode
13
+
14
+ # A single-purpose Qt worker intentionally exposes only ``run``.
15
+ # pylint: disable=too-few-public-methods
16
+
17
+ if TYPE_CHECKING:
18
+ from .device_finder import DeviceFinder
19
+
20
+
21
+ class AsyncScanThread(QThread):
22
+ """Run one scan without enumerating ports on the application thread."""
23
+
24
+ def __init__(
25
+ self, finder: DeviceFinder, mode: ScanMode, port: str | None
26
+ ) -> None:
27
+ """Bind an asynchronous scan request to its finder."""
28
+ super().__init__()
29
+ self._finder: DeviceFinder = finder
30
+ self._mode: ScanMode = mode
31
+ self._port: str | None = port
32
+
33
+ @override
34
+ def run(self) -> None:
35
+ """Execute and publish the scan result."""
36
+ self._finder.run_find(self._mode, self._port)
37
+
38
+
39
+ __all__ = ["AsyncScanThread"]