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,117 @@
|
|
|
1
|
+
"""Persist successful serial baudrates under a stable device identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from os import replace as atomic_replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
# pylint: disable=no-name-in-module
|
|
14
|
+
from PySide6.QtCore import QStandardPaths
|
|
15
|
+
|
|
16
|
+
from ..models import DeviceInfo
|
|
17
|
+
from ..settings import LOG_ENABLED
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaudCache:
|
|
21
|
+
"""Keep baudrate hints in memory and optionally in an atomic JSON file."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
24
|
+
"""Load a cache; ``None`` keeps fake backend scans in memory."""
|
|
25
|
+
self._path: Path | None = path
|
|
26
|
+
self._lock: threading.RLock = threading.RLock()
|
|
27
|
+
self._rates: dict[str, int] = {}
|
|
28
|
+
self._load()
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def application_cache(cls) -> BaudCache:
|
|
32
|
+
"""Locate persistent baud hints in Qt's application data folder."""
|
|
33
|
+
root = Path(
|
|
34
|
+
QStandardPaths.writableLocation(
|
|
35
|
+
QStandardPaths.StandardLocation.AppLocalDataLocation
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return cls(root / "serialforge" / "baud_cache.json")
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def key(device: DeviceInfo) -> str:
|
|
42
|
+
"""Prefer USB serial, then physical location, then the COM name."""
|
|
43
|
+
if device.vid is not None and device.pid is not None:
|
|
44
|
+
prefix = f"{device.vid:04X}:{device.pid:04X}"
|
|
45
|
+
if device.serial_number:
|
|
46
|
+
return f"{prefix}:serial:{device.serial_number}"
|
|
47
|
+
if device.location:
|
|
48
|
+
return f"{prefix}:location:{device.location}"
|
|
49
|
+
return f"port:{device.port.upper()}"
|
|
50
|
+
|
|
51
|
+
def get(self, device: DeviceInfo) -> int | None:
|
|
52
|
+
"""Return a remembered baudrate for this identity, if any."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
return self._rates.get(self.key(device))
|
|
55
|
+
|
|
56
|
+
def remember(self, device: DeviceInfo, baudrate: int) -> None:
|
|
57
|
+
"""Persist a successful baudrate when it changes."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
key = self.key(device)
|
|
60
|
+
if self._rates.get(key) == baudrate:
|
|
61
|
+
return
|
|
62
|
+
self._rates[key] = baudrate
|
|
63
|
+
self._save()
|
|
64
|
+
|
|
65
|
+
def forget(self, device: DeviceInfo) -> None:
|
|
66
|
+
"""Remove a stale baudrate hint for a device."""
|
|
67
|
+
with self._lock:
|
|
68
|
+
if self._rates.pop(self.key(device), None) is not None:
|
|
69
|
+
self._save()
|
|
70
|
+
|
|
71
|
+
def _load(self) -> None:
|
|
72
|
+
"""Ignore a missing or damaged cache without blocking discovery."""
|
|
73
|
+
path = self._path
|
|
74
|
+
if path is None:
|
|
75
|
+
return
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
document = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
80
|
+
if not isinstance(document, dict):
|
|
81
|
+
return
|
|
82
|
+
self._rates = {
|
|
83
|
+
key: value
|
|
84
|
+
for key, value in document.items()
|
|
85
|
+
if isinstance(key, str)
|
|
86
|
+
and isinstance(value, int)
|
|
87
|
+
and not isinstance(value, bool)
|
|
88
|
+
and value > 0
|
|
89
|
+
}
|
|
90
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
91
|
+
if LOG_ENABLED:
|
|
92
|
+
logger.debug("baud cache unavailable: {}", exc)
|
|
93
|
+
|
|
94
|
+
def _save(self) -> None:
|
|
95
|
+
"""Replace the cache atomically so interrupted writes stay safe."""
|
|
96
|
+
path = self._path
|
|
97
|
+
if path is None:
|
|
98
|
+
return
|
|
99
|
+
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
100
|
+
try:
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
temporary.write_text(
|
|
103
|
+
json.dumps(self._rates, ensure_ascii=False, sort_keys=True),
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
)
|
|
106
|
+
atomic_replace(temporary, path)
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
if LOG_ENABLED:
|
|
109
|
+
logger.debug("baud cache write failed: {}", exc)
|
|
110
|
+
finally:
|
|
111
|
+
try:
|
|
112
|
+
temporary.unlink(missing_ok=True)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["BaudCache"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Probe candidate baudrates with bounded reads and byte framing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from ..advanced import FramingConfig, ProbeSpec, SerialConfig
|
|
14
|
+
from ..enums import FramingMode
|
|
15
|
+
from ..errors import FramingError, PortBusyError
|
|
16
|
+
from ..models import CommandSpec, DeviceInfo, DeviceProfile
|
|
17
|
+
from ..protocols import PortBackend, TransportProtocol
|
|
18
|
+
from ..settings import LOG_ENABLED
|
|
19
|
+
from ..transport import FrameSplitter, PortOptions, PortRegistry
|
|
20
|
+
from .baud_cache import BaudCache
|
|
21
|
+
|
|
22
|
+
# Probe state is deliberately kept together while each rate owns one handle.
|
|
23
|
+
# pylint: disable=too-many-locals
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaudProber:
|
|
27
|
+
"""Try allowed baudrates serially on one port and validate its reply."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, profile: DeviceProfile, backend: PortBackend, cache: BaudCache
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Retain the profile, backend, and per-device baud hint cache."""
|
|
33
|
+
self._profile: DeviceProfile = profile
|
|
34
|
+
self._backend: PortBackend = backend
|
|
35
|
+
self._cache: BaudCache = cache
|
|
36
|
+
|
|
37
|
+
def probe(
|
|
38
|
+
self,
|
|
39
|
+
device: DeviceInfo,
|
|
40
|
+
cancel: threading.Event,
|
|
41
|
+
deadline: float,
|
|
42
|
+
progress: Callable[[str, int], None],
|
|
43
|
+
) -> DeviceInfo | None:
|
|
44
|
+
"""Return the first validated baudrate or no result for this port."""
|
|
45
|
+
started = time.monotonic()
|
|
46
|
+
cached = self._cache.get(device)
|
|
47
|
+
rates = list(self._profile.baudrates)
|
|
48
|
+
if cached in rates:
|
|
49
|
+
rates.remove(cached)
|
|
50
|
+
rates.insert(0, cached)
|
|
51
|
+
for baudrate in rates:
|
|
52
|
+
if cancel.is_set() or time.monotonic() >= deadline:
|
|
53
|
+
return None
|
|
54
|
+
progress(device.port, baudrate)
|
|
55
|
+
try:
|
|
56
|
+
response = self.try_rate(device, baudrate, cancel, deadline)
|
|
57
|
+
except (OSError, RuntimeError, FramingError, PortBusyError) as exc:
|
|
58
|
+
if LOG_ENABLED:
|
|
59
|
+
logger.debug(
|
|
60
|
+
"probe failed on {} at {}: {}",
|
|
61
|
+
device.port,
|
|
62
|
+
baudrate,
|
|
63
|
+
exc,
|
|
64
|
+
)
|
|
65
|
+
if isinstance(exc, PortBusyError):
|
|
66
|
+
return None
|
|
67
|
+
continue
|
|
68
|
+
if response is not None:
|
|
69
|
+
result = replace(
|
|
70
|
+
device,
|
|
71
|
+
baudrate=baudrate,
|
|
72
|
+
response=response,
|
|
73
|
+
elapsed_s=time.monotonic() - started,
|
|
74
|
+
)
|
|
75
|
+
self._cache.remember(result, baudrate)
|
|
76
|
+
return result
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def try_rate(
|
|
80
|
+
self,
|
|
81
|
+
device: DeviceInfo,
|
|
82
|
+
baudrate: int,
|
|
83
|
+
cancel: threading.Event,
|
|
84
|
+
total_deadline: float,
|
|
85
|
+
) -> bytes | None:
|
|
86
|
+
"""Open, probe, and always close one baudrate attempt."""
|
|
87
|
+
config = self._profile.serial
|
|
88
|
+
assert isinstance(config, SerialConfig)
|
|
89
|
+
owner = object()
|
|
90
|
+
with PortRegistry.lease(device.port, owner):
|
|
91
|
+
transport = self._backend.open(
|
|
92
|
+
device.port, PortOptions.build(config, baudrate)
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
transport.set_control_lines(dtr=config.dtr, rts=config.rts)
|
|
96
|
+
if config.rx_buffer_size is not None:
|
|
97
|
+
transport.set_buffer_size(config.rx_buffer_size)
|
|
98
|
+
probe = self._profile.probe
|
|
99
|
+
assert isinstance(probe, (CommandSpec, ProbeSpec))
|
|
100
|
+
settle = (
|
|
101
|
+
probe.settle_time_s
|
|
102
|
+
if isinstance(probe, ProbeSpec)
|
|
103
|
+
and probe.settle_time_s is not None
|
|
104
|
+
else config.settle_time_s
|
|
105
|
+
)
|
|
106
|
+
if cancel.wait(
|
|
107
|
+
min(settle, max(0.0, total_deadline - time.monotonic()))
|
|
108
|
+
):
|
|
109
|
+
return None
|
|
110
|
+
if time.monotonic() >= total_deadline:
|
|
111
|
+
return None
|
|
112
|
+
transport.reset_input_buffer()
|
|
113
|
+
request = (
|
|
114
|
+
probe.request.rstrip("\r\n") + self._profile.terminator
|
|
115
|
+
).encode(self._profile.encoding)
|
|
116
|
+
if transport.write(request) != len(request):
|
|
117
|
+
return None
|
|
118
|
+
timeout = probe.timeout_s
|
|
119
|
+
seconds = (
|
|
120
|
+
float(timeout) if isinstance(timeout, (int, float)) else 1.0
|
|
121
|
+
)
|
|
122
|
+
deadline = min(total_deadline, time.monotonic() + seconds)
|
|
123
|
+
return self.read_reply(transport, request, cancel, deadline)
|
|
124
|
+
finally:
|
|
125
|
+
try:
|
|
126
|
+
transport.close()
|
|
127
|
+
except (OSError, RuntimeError) as exc:
|
|
128
|
+
if LOG_ENABLED:
|
|
129
|
+
logger.debug(
|
|
130
|
+
"probe close failed on {}: {}", device.port, exc
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def read_reply(
|
|
134
|
+
self,
|
|
135
|
+
transport: TransportProtocol,
|
|
136
|
+
request: bytes,
|
|
137
|
+
cancel: threading.Event,
|
|
138
|
+
deadline: float,
|
|
139
|
+
) -> bytes | None:
|
|
140
|
+
"""Validate framed responses until success, cancellation, or timeout."""
|
|
141
|
+
profile = self._profile
|
|
142
|
+
framing = profile.framing or FramingConfig(
|
|
143
|
+
mode=FramingMode.LINE,
|
|
144
|
+
terminator=profile.terminator.encode(profile.encoding),
|
|
145
|
+
)
|
|
146
|
+
splitter = FrameSplitter(framing)
|
|
147
|
+
sent_text = request.decode(profile.encoding, errors="replace").strip()
|
|
148
|
+
while not cancel.is_set():
|
|
149
|
+
remaining = deadline - time.monotonic()
|
|
150
|
+
if remaining <= 0:
|
|
151
|
+
return None
|
|
152
|
+
chunk = transport.read(1, min(0.02, remaining))
|
|
153
|
+
if cancel.is_set():
|
|
154
|
+
return None
|
|
155
|
+
frames: list[bytes] = []
|
|
156
|
+
if chunk:
|
|
157
|
+
parts = [chunk]
|
|
158
|
+
while True:
|
|
159
|
+
extra = transport.read(4096, 0.0)
|
|
160
|
+
if not extra:
|
|
161
|
+
break
|
|
162
|
+
parts.append(extra)
|
|
163
|
+
frames.extend(splitter.feed(b"".join(parts)))
|
|
164
|
+
frames.extend(splitter.flush())
|
|
165
|
+
for frame in frames:
|
|
166
|
+
text = frame.decode(profile.encoding, errors="replace")
|
|
167
|
+
if text == sent_text:
|
|
168
|
+
continue
|
|
169
|
+
if self.matches(text):
|
|
170
|
+
return frame
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def matches(self, text: str) -> bool:
|
|
174
|
+
"""Require the probe's regex or predicate, never mere activity."""
|
|
175
|
+
probe = self._profile.probe
|
|
176
|
+
if isinstance(probe, CommandSpec):
|
|
177
|
+
return (
|
|
178
|
+
probe.pattern is not None
|
|
179
|
+
and re.fullmatch(probe.pattern, text) is not None
|
|
180
|
+
)
|
|
181
|
+
assert isinstance(probe, ProbeSpec)
|
|
182
|
+
if isinstance(probe.expect, str):
|
|
183
|
+
return re.fullmatch(probe.expect, text) is not None
|
|
184
|
+
return bool(probe.expect(text))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["BaudProber"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""QtCore facade for bounded serial discovery and baudrate probing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import (
|
|
8
|
+
ThreadPoolExecutor,
|
|
9
|
+
TimeoutError as FutureTimeoutError,
|
|
10
|
+
as_completed,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
# pylint: disable=no-name-in-module
|
|
16
|
+
from PySide6.QtCore import QCoreApplication, QObject, Signal
|
|
17
|
+
|
|
18
|
+
from ..advanced import RuntimeConfig
|
|
19
|
+
from ..enums import ScanMode
|
|
20
|
+
from ..errors import ProbeError, SerialForgeError
|
|
21
|
+
from ..models import DeviceInfo, DeviceProfile
|
|
22
|
+
from ..protocols import PortBackend
|
|
23
|
+
from ..settings import LOG_ENABLED
|
|
24
|
+
from ..transport import BackendSwitch, SerialTransport
|
|
25
|
+
from .async_scan_thread import AsyncScanThread
|
|
26
|
+
from .baud_cache import BaudCache
|
|
27
|
+
from .baud_prober import BaudProber
|
|
28
|
+
from .port_scanner import PortScanner
|
|
29
|
+
|
|
30
|
+
# The finder coordinates cache, cancellation, and Qt worker ownership.
|
|
31
|
+
# pylint: disable=too-many-instance-attributes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DeviceFinder(QObject):
|
|
35
|
+
"""Find matching serial devices and remember their working baudrates."""
|
|
36
|
+
|
|
37
|
+
probe_progress = Signal(str, int)
|
|
38
|
+
probe_finished = Signal(object)
|
|
39
|
+
|
|
40
|
+
def __init__(self, profile: DeviceProfile) -> None:
|
|
41
|
+
"""Create a finder without enumerating or opening any port."""
|
|
42
|
+
application = QCoreApplication.instance()
|
|
43
|
+
if application is None:
|
|
44
|
+
raise SerialForgeError("DeviceFinder requires a QCoreApplication")
|
|
45
|
+
super().__init__()
|
|
46
|
+
self._profile: DeviceProfile = profile
|
|
47
|
+
self._backend: PortBackend = BackendSwitch.current()
|
|
48
|
+
self._cache: BaudCache = (
|
|
49
|
+
BaudCache.application_cache()
|
|
50
|
+
if isinstance(self._backend, SerialTransport)
|
|
51
|
+
else BaudCache()
|
|
52
|
+
)
|
|
53
|
+
self._cancel: threading.Event = threading.Event()
|
|
54
|
+
self._lock: threading.RLock = threading.RLock()
|
|
55
|
+
self._active: bool = False
|
|
56
|
+
self._thread: AsyncScanThread | None = None
|
|
57
|
+
self._shutdown_hooked: bool = False
|
|
58
|
+
|
|
59
|
+
def find(
|
|
60
|
+
self, mode: ScanMode = ScanMode.ALL, *, port: str | None = None
|
|
61
|
+
) -> list[DeviceInfo]:
|
|
62
|
+
"""Scan synchronously while keeping enumeration on a worker thread."""
|
|
63
|
+
self._begin()
|
|
64
|
+
return self.run_find(mode, port)
|
|
65
|
+
|
|
66
|
+
def find_async(
|
|
67
|
+
self, mode: ScanMode = ScanMode.ALL, *, port: str | None = None
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Start one background scan and publish its eventual result."""
|
|
70
|
+
self._begin()
|
|
71
|
+
thread = AsyncScanThread(self, mode, port)
|
|
72
|
+
self._thread = thread
|
|
73
|
+
application = QCoreApplication.instance()
|
|
74
|
+
assert application is not None
|
|
75
|
+
application.aboutToQuit.connect(self._shutdown)
|
|
76
|
+
self._shutdown_hooked = True
|
|
77
|
+
thread.start()
|
|
78
|
+
|
|
79
|
+
def cancel(self) -> None:
|
|
80
|
+
"""Request cooperative cancellation of an active scan."""
|
|
81
|
+
self._cancel.set()
|
|
82
|
+
|
|
83
|
+
def _shutdown(self) -> None:
|
|
84
|
+
"""Join an asynchronous scan before Qt destroys its thread."""
|
|
85
|
+
self.cancel()
|
|
86
|
+
thread = self._thread
|
|
87
|
+
if thread is not None and thread.isRunning():
|
|
88
|
+
thread.wait(5000)
|
|
89
|
+
|
|
90
|
+
def forget(self, device: DeviceInfo) -> None:
|
|
91
|
+
"""Discard one remembered baudrate hint."""
|
|
92
|
+
self._cache.forget(device)
|
|
93
|
+
|
|
94
|
+
def _begin(self) -> None:
|
|
95
|
+
"""Reject overlapping scans and reset cancellation state."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._active:
|
|
98
|
+
raise ProbeError("a device scan is already active")
|
|
99
|
+
self._active = True
|
|
100
|
+
self._cancel.clear()
|
|
101
|
+
|
|
102
|
+
def run_find(self, mode: ScanMode, port: str | None) -> list[DeviceInfo]:
|
|
103
|
+
"""Run the coordinator and publish exactly one completion signal."""
|
|
104
|
+
result: list[DeviceInfo] = []
|
|
105
|
+
try:
|
|
106
|
+
with ThreadPoolExecutor(max_workers=1) as coordinator:
|
|
107
|
+
result = coordinator.submit(self._scan, mode, port).result()
|
|
108
|
+
except (OSError, RuntimeError) as exc:
|
|
109
|
+
if LOG_ENABLED:
|
|
110
|
+
logger.debug("device scan failed: {}", exc)
|
|
111
|
+
finally:
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._active = False
|
|
114
|
+
if self._shutdown_hooked:
|
|
115
|
+
application = QCoreApplication.instance()
|
|
116
|
+
if application is not None:
|
|
117
|
+
application.aboutToQuit.disconnect(self._shutdown)
|
|
118
|
+
self._shutdown_hooked = False
|
|
119
|
+
self.probe_finished.emit(result)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
def _scan(self, mode: ScanMode, port: str | None) -> list[DeviceInfo]:
|
|
123
|
+
"""Probe separate ports concurrently and stop on the first match."""
|
|
124
|
+
runtime = self._profile.runtime
|
|
125
|
+
assert isinstance(runtime, RuntimeConfig)
|
|
126
|
+
deadline = time.monotonic() + runtime.probe_total_timeout_s
|
|
127
|
+
candidates = PortScanner(self._profile, self._backend).scan(port)
|
|
128
|
+
prober = BaudProber(self._profile, self._backend, self._cache)
|
|
129
|
+
results: list[DeviceInfo] = []
|
|
130
|
+
with ThreadPoolExecutor(max_workers=runtime.probe_pool_size) as pool:
|
|
131
|
+
futures = [
|
|
132
|
+
pool.submit(
|
|
133
|
+
prober.probe,
|
|
134
|
+
candidate,
|
|
135
|
+
self._cancel,
|
|
136
|
+
deadline,
|
|
137
|
+
self.probe_progress.emit,
|
|
138
|
+
)
|
|
139
|
+
for candidate in candidates
|
|
140
|
+
]
|
|
141
|
+
try:
|
|
142
|
+
for future in as_completed(
|
|
143
|
+
futures, timeout=max(0.001, deadline - time.monotonic())
|
|
144
|
+
):
|
|
145
|
+
try:
|
|
146
|
+
device = future.result()
|
|
147
|
+
except (OSError, RuntimeError) as exc:
|
|
148
|
+
if LOG_ENABLED:
|
|
149
|
+
logger.debug("device probe worker failed: {}", exc)
|
|
150
|
+
continue
|
|
151
|
+
if device is not None:
|
|
152
|
+
results.append(device)
|
|
153
|
+
if mode is ScanMode.FIRST_MATCH:
|
|
154
|
+
self._cancel.set()
|
|
155
|
+
break
|
|
156
|
+
except FutureTimeoutError:
|
|
157
|
+
self._cancel.set()
|
|
158
|
+
return results[:1] if mode is ScanMode.FIRST_MATCH else results
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["DeviceFinder"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Filter backend port descriptors by a device profile's USB identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..models import DeviceInfo, DeviceProfile
|
|
6
|
+
from ..protocols import PortBackend
|
|
7
|
+
from ..transport import PortRegistry
|
|
8
|
+
|
|
9
|
+
# The scanner is a small stateless adapter with one public operation.
|
|
10
|
+
# pylint: disable=too-few-public-methods
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PortScanner:
|
|
14
|
+
"""Enumerate only available ports allowed by one device profile."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, profile: DeviceProfile, backend: PortBackend) -> None:
|
|
17
|
+
"""Retain a profile and the selected serial backend."""
|
|
18
|
+
self._profile: DeviceProfile = profile
|
|
19
|
+
self._backend: PortBackend = backend
|
|
20
|
+
|
|
21
|
+
def scan(self, port: str | None = None) -> list[DeviceInfo]:
|
|
22
|
+
"""Return matching descriptors without opening any serial port."""
|
|
23
|
+
matches: list[DeviceInfo] = []
|
|
24
|
+
allowed = set(self._profile.vid_pid)
|
|
25
|
+
for descriptor in self._backend.list_ports():
|
|
26
|
+
name = getattr(descriptor, "device", None)
|
|
27
|
+
vid = getattr(descriptor, "vid", None)
|
|
28
|
+
pid = getattr(descriptor, "pid", None)
|
|
29
|
+
if not isinstance(name, str) or (vid, pid) not in allowed:
|
|
30
|
+
continue
|
|
31
|
+
if port is not None and name.upper() != port.upper():
|
|
32
|
+
continue
|
|
33
|
+
if PortRegistry.is_owned(name):
|
|
34
|
+
continue
|
|
35
|
+
matches.append(
|
|
36
|
+
DeviceInfo(
|
|
37
|
+
port=name,
|
|
38
|
+
vid=vid,
|
|
39
|
+
pid=pid,
|
|
40
|
+
serial_number=getattr(descriptor, "serial_number", None),
|
|
41
|
+
location=getattr(descriptor, "location", None),
|
|
42
|
+
description=getattr(descriptor, "description", None),
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return matches
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["PortScanner"]
|
serialforge/enums.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Enumerations shared by the serialforge public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum, StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommandStatus(StrEnum):
|
|
9
|
+
"""Terminal status reported for a command."""
|
|
10
|
+
|
|
11
|
+
OK = "ok"
|
|
12
|
+
TIMEOUT = "timeout"
|
|
13
|
+
DISCONNECTED = "disconnected"
|
|
14
|
+
CANCELLED = "cancelled"
|
|
15
|
+
PARSE_ERROR = "parse_error"
|
|
16
|
+
DEVICE_ERROR = "device_error"
|
|
17
|
+
BUSY = "busy"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectionState(StrEnum):
|
|
21
|
+
"""Connection lifecycle state."""
|
|
22
|
+
|
|
23
|
+
DISCONNECTED = "disconnected"
|
|
24
|
+
PROBING = "probing"
|
|
25
|
+
CONNECTING = "connecting"
|
|
26
|
+
CONNECTED = "connected"
|
|
27
|
+
RECONNECTING = "reconnecting"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResponseMode(StrEnum):
|
|
32
|
+
"""Expected response shape for a command."""
|
|
33
|
+
|
|
34
|
+
NO_REPLY = "no_reply"
|
|
35
|
+
SINGLE = "single"
|
|
36
|
+
MULTI = "multi"
|
|
37
|
+
STREAM = "stream"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Correlation(StrEnum):
|
|
41
|
+
"""How responses are associated with in-flight commands."""
|
|
42
|
+
|
|
43
|
+
EXCLUSIVE = "exclusive"
|
|
44
|
+
TAGGED = "tagged"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ScanMode(StrEnum):
|
|
48
|
+
"""Device scan completion policy."""
|
|
49
|
+
|
|
50
|
+
ALL = "all"
|
|
51
|
+
FIRST_MATCH = "first_match"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DataBits(IntEnum):
|
|
55
|
+
"""Number of data bits in a serial frame."""
|
|
56
|
+
|
|
57
|
+
FIVE = 5
|
|
58
|
+
SIX = 6
|
|
59
|
+
SEVEN = 7
|
|
60
|
+
EIGHT = 8
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Parity(StrEnum):
|
|
64
|
+
"""Serial parity mode."""
|
|
65
|
+
|
|
66
|
+
NONE = "N"
|
|
67
|
+
EVEN = "E"
|
|
68
|
+
ODD = "O"
|
|
69
|
+
MARK = "M"
|
|
70
|
+
SPACE = "S"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StopBits(StrEnum):
|
|
74
|
+
"""Number of stop bits."""
|
|
75
|
+
|
|
76
|
+
ONE = "1"
|
|
77
|
+
ONE_POINT_FIVE = "1.5"
|
|
78
|
+
TWO = "2"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FlowControl(StrEnum):
|
|
82
|
+
"""Serial flow-control mode."""
|
|
83
|
+
|
|
84
|
+
NONE = "none"
|
|
85
|
+
XON_XOFF = "xon_xoff"
|
|
86
|
+
RTS_CTS = "rts_cts"
|
|
87
|
+
DSR_DTR = "dsr_dtr"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FramingMode(StrEnum):
|
|
91
|
+
"""Byte framing strategy."""
|
|
92
|
+
|
|
93
|
+
LINE = "line"
|
|
94
|
+
DELIMITED = "delimited"
|
|
95
|
+
LENGTH_PREFIX = "length_prefix"
|
|
96
|
+
SILENCE_GAP = "silence_gap"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Checksum(StrEnum):
|
|
100
|
+
"""Checksum algorithm selected for binary frames."""
|
|
101
|
+
|
|
102
|
+
NONE = "none"
|
|
103
|
+
SUM8 = "sum8"
|
|
104
|
+
XOR = "xor"
|
|
105
|
+
CRC16_MODBUS = "crc16_modbus"
|
|
106
|
+
CRC16_CCITT = "crc16_ccitt"
|
|
107
|
+
CRC32 = "crc32"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CommandPriority(IntEnum):
|
|
111
|
+
"""Priority used by the command queue."""
|
|
112
|
+
|
|
113
|
+
NORMAL = 0
|
|
114
|
+
HIGH = 1
|
|
115
|
+
URGENT = 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SendRoute(StrEnum):
|
|
119
|
+
"""How a send target was resolved."""
|
|
120
|
+
|
|
121
|
+
SPEC = "spec"
|
|
122
|
+
MATCHED = "matched"
|
|
123
|
+
RAW = "raw"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TimeoutPolicy(StrEnum):
|
|
127
|
+
"""Special timeout policies accepted by command declarations."""
|
|
128
|
+
|
|
129
|
+
AUTO = "auto"
|
serialforge/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Exception hierarchy for serialforge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SerialForgeError(Exception):
|
|
7
|
+
"""Base class for all serialforge errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PortBusyError(SerialForgeError):
|
|
11
|
+
"""Raised when a serial port is already owned by this process."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortNotFoundError(SerialForgeError):
|
|
15
|
+
"""Raised when a requested serial port cannot be found."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionDeniedError(SerialForgeError):
|
|
19
|
+
"""Raised when Windows denies access to a serial port."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProbeError(SerialForgeError):
|
|
23
|
+
"""Raised when device probing fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FramingError(SerialForgeError):
|
|
27
|
+
"""Raised when a frame cannot be split or validated."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandError(SerialForgeError):
|
|
31
|
+
"""Raised for invalid command declarations or usage."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigError(SerialForgeError):
|
|
35
|
+
"""Raised for invalid device or transport configuration."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"SerialForgeError",
|
|
40
|
+
"PortBusyError",
|
|
41
|
+
"PortNotFoundError",
|
|
42
|
+
"PermissionDeniedError",
|
|
43
|
+
"ProbeError",
|
|
44
|
+
"FramingError",
|
|
45
|
+
"CommandError",
|
|
46
|
+
"ConfigError",
|
|
47
|
+
]
|