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