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