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,67 @@
|
|
|
1
|
+
"""Scriptable device description used by the fake serial backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
ResponseScript = Callable[[bytes], bytes | Sequence[bytes] | None]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
# pylint: disable=too-many-instance-attributes
|
|
13
|
+
class SimulatedDevice:
|
|
14
|
+
"""Describe a fake port and its deterministic wire behavior.
|
|
15
|
+
|
|
16
|
+
``responses`` maps request bytes to response chunks. ``script`` can
|
|
17
|
+
provide dynamic behavior and takes precedence.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
vid: int
|
|
21
|
+
pid: int
|
|
22
|
+
port: str = "SIM1"
|
|
23
|
+
serial_number: str | None = None
|
|
24
|
+
location: str | None = None
|
|
25
|
+
description: str = "serialforge simulated device"
|
|
26
|
+
responses: Mapping[bytes | str, bytes | Sequence[bytes]] = field(
|
|
27
|
+
default_factory=dict
|
|
28
|
+
)
|
|
29
|
+
script: ResponseScript | None = None
|
|
30
|
+
latency_s: float = 0.0
|
|
31
|
+
chunk_size: int | None = None
|
|
32
|
+
echo: bool = False
|
|
33
|
+
disconnect_after_writes: int | None = None
|
|
34
|
+
baudrate: int | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def device(self) -> str:
|
|
38
|
+
"""Return the pyserial-style port name."""
|
|
39
|
+
return self.port
|
|
40
|
+
|
|
41
|
+
def response_for(self, data: bytes) -> list[bytes]:
|
|
42
|
+
"""Return scripted response chunks for a transmitted request."""
|
|
43
|
+
if self.script is not None:
|
|
44
|
+
result = self.script(data)
|
|
45
|
+
else:
|
|
46
|
+
key = data.decode("utf-8", errors="replace")
|
|
47
|
+
result = self.responses.get(data, self.responses.get(key))
|
|
48
|
+
if result is None:
|
|
49
|
+
chunks: list[bytes] = []
|
|
50
|
+
elif isinstance(result, bytes):
|
|
51
|
+
chunks = [result]
|
|
52
|
+
else:
|
|
53
|
+
chunks = [bytes(item) for item in result]
|
|
54
|
+
if self.echo:
|
|
55
|
+
chunks.insert(0, data)
|
|
56
|
+
if self.chunk_size is None or self.chunk_size <= 0:
|
|
57
|
+
return chunks
|
|
58
|
+
split: list[bytes] = []
|
|
59
|
+
for chunk in chunks:
|
|
60
|
+
split.extend(
|
|
61
|
+
chunk[index : index + self.chunk_size]
|
|
62
|
+
for index in range(0, len(chunk), self.chunk_size)
|
|
63
|
+
)
|
|
64
|
+
return split
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["SimulatedDevice", "ResponseScript"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Transport-layer implementations and test injection points."""
|
|
2
|
+
|
|
3
|
+
from .backend_switch import BackendSwitch
|
|
4
|
+
from .checksum_calculator import ChecksumCalculator
|
|
5
|
+
from .frame_splitter import FrameSplitter
|
|
6
|
+
from .latency_tracker import LatencyTracker
|
|
7
|
+
from .port_options import PortOptions
|
|
8
|
+
from .port_registry import PortRegistry
|
|
9
|
+
from .serial_transport import SerialTransport
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BackendSwitch",
|
|
13
|
+
"ChecksumCalculator",
|
|
14
|
+
"FrameSplitter",
|
|
15
|
+
"LatencyTracker",
|
|
16
|
+
"PortRegistry",
|
|
17
|
+
"PortOptions",
|
|
18
|
+
"SerialTransport",
|
|
19
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Process-wide selection of the serial port backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from ..protocols import PortBackend
|
|
9
|
+
from .serial_transport import SerialTransport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendSwitch:
|
|
13
|
+
"""Select a port backend and restore it safely after a test."""
|
|
14
|
+
|
|
15
|
+
_backend: PortBackend | None = None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def current(cls) -> PortBackend:
|
|
19
|
+
"""Return the active backend, creating the real backend lazily."""
|
|
20
|
+
if cls._backend is None:
|
|
21
|
+
cls._backend = SerialTransport()
|
|
22
|
+
return cls._backend
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
@contextmanager
|
|
26
|
+
def use(cls, backend: PortBackend) -> Iterator[PortBackend]:
|
|
27
|
+
"""Temporarily replace the active backend."""
|
|
28
|
+
previous = cls._backend
|
|
29
|
+
cls._backend = backend
|
|
30
|
+
try:
|
|
31
|
+
yield backend
|
|
32
|
+
finally:
|
|
33
|
+
cls._backend = previous
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["BackendSwitch"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Checksum calculation and verification for binary frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import binascii
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from ..enums import Checksum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChecksumCalculator:
|
|
12
|
+
"""Calculate or verify supported checksums with a stable byte order."""
|
|
13
|
+
|
|
14
|
+
# The explicit branches keep algorithm-specific byte order visible.
|
|
15
|
+
# pylint: disable=too-many-return-statements
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def calculate(
|
|
19
|
+
data: bytes,
|
|
20
|
+
algorithm: Checksum,
|
|
21
|
+
custom: Callable[[bytes], bytes] | None = None,
|
|
22
|
+
) -> bytes:
|
|
23
|
+
"""Return the checksum bytes for ``data``."""
|
|
24
|
+
if custom is not None:
|
|
25
|
+
return bytes(custom(data))
|
|
26
|
+
if algorithm is Checksum.NONE:
|
|
27
|
+
return b""
|
|
28
|
+
if algorithm is Checksum.SUM8:
|
|
29
|
+
return bytes((sum(data) & 0xFF,))
|
|
30
|
+
if algorithm is Checksum.XOR:
|
|
31
|
+
value = 0
|
|
32
|
+
for item in data:
|
|
33
|
+
value ^= item
|
|
34
|
+
return bytes((value,))
|
|
35
|
+
if algorithm is Checksum.CRC16_MODBUS:
|
|
36
|
+
return ChecksumCalculator.crc16(data, 0xA001, 0xFFFF)
|
|
37
|
+
if algorithm is Checksum.CRC16_CCITT:
|
|
38
|
+
return ChecksumCalculator.crc16_ccitt(data)
|
|
39
|
+
if algorithm is Checksum.CRC32:
|
|
40
|
+
return (binascii.crc32(data) & 0xFFFFFFFF).to_bytes(4, "little")
|
|
41
|
+
raise ValueError(f"unsupported checksum algorithm: {algorithm}")
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def verify(
|
|
45
|
+
data: bytes,
|
|
46
|
+
expected: bytes,
|
|
47
|
+
algorithm: Checksum,
|
|
48
|
+
custom: Callable[[bytes], bytes] | None = None,
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""Return whether ``expected`` matches the calculated checksum."""
|
|
51
|
+
return ChecksumCalculator.calculate(data, algorithm, custom) == expected
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def crc16(data: bytes, polynomial: int, initial: int) -> bytes:
|
|
55
|
+
"""Calculate reflected CRC-16 and return little-endian bytes."""
|
|
56
|
+
value = initial
|
|
57
|
+
for item in data:
|
|
58
|
+
value ^= item
|
|
59
|
+
for _ in range(8):
|
|
60
|
+
value = (value >> 1) ^ polynomial if value & 1 else value >> 1
|
|
61
|
+
return value.to_bytes(2, "little")
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def crc16_ccitt(data: bytes) -> bytes:
|
|
65
|
+
"""Calculate CRC-16/CCITT-FALSE and return big-endian bytes."""
|
|
66
|
+
value = 0xFFFF
|
|
67
|
+
for item in data:
|
|
68
|
+
value ^= item << 8
|
|
69
|
+
for _ in range(8):
|
|
70
|
+
if value & 0x8000:
|
|
71
|
+
value = ((value << 1) ^ 0x1021) & 0xFFFF
|
|
72
|
+
else:
|
|
73
|
+
value = (value << 1) & 0xFFFF
|
|
74
|
+
return value.to_bytes(2, "big")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["ChecksumCalculator"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Incremental byte framing for text and binary serial protocols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from ..advanced import FramingConfig
|
|
9
|
+
from ..enums import FramingMode
|
|
10
|
+
from ..errors import FramingError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FrameSplitter:
|
|
14
|
+
"""Split arbitrary byte chunks into complete frames without decoding."""
|
|
15
|
+
|
|
16
|
+
_config: FramingConfig
|
|
17
|
+
_buffer: bytearray
|
|
18
|
+
_last_byte_at: float | None
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: FramingConfig | None = None,
|
|
23
|
+
*,
|
|
24
|
+
clock: Callable[[], float] = time.monotonic,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Create a splitter with bounded buffering."""
|
|
27
|
+
self._config = config or FramingConfig()
|
|
28
|
+
self._buffer = bytearray()
|
|
29
|
+
self._last_byte_at = None
|
|
30
|
+
self._clock: Callable[[], float] = clock
|
|
31
|
+
|
|
32
|
+
def feed(self, data: bytes) -> list[bytes]:
|
|
33
|
+
"""Append bytes and return every complete frame now available."""
|
|
34
|
+
if data:
|
|
35
|
+
self._buffer.extend(data)
|
|
36
|
+
self._last_byte_at = self._clock()
|
|
37
|
+
if len(self._buffer) > self._config.max_buffer_bytes:
|
|
38
|
+
self._resynchronise()
|
|
39
|
+
raise FramingError("receive buffer exceeded max_buffer_bytes")
|
|
40
|
+
if self._config.mode is FramingMode.LINE:
|
|
41
|
+
return self.split_terminator(self._config.terminator)
|
|
42
|
+
if self._config.mode is FramingMode.DELIMITED:
|
|
43
|
+
return self.split_delimited()
|
|
44
|
+
if self._config.mode is FramingMode.LENGTH_PREFIX:
|
|
45
|
+
return self.split_length_prefix()
|
|
46
|
+
return self.split_silence_gap()
|
|
47
|
+
|
|
48
|
+
def flush(self, *, now: float | None = None) -> list[bytes]:
|
|
49
|
+
"""Emit a silence-gap frame after the configured idle period."""
|
|
50
|
+
if self._config.mode is not FramingMode.SILENCE_GAP:
|
|
51
|
+
return []
|
|
52
|
+
if not self._buffer or self._last_byte_at is None:
|
|
53
|
+
return []
|
|
54
|
+
current = self._clock() if now is None else now
|
|
55
|
+
gap = self._config.silence_gap_s
|
|
56
|
+
# FramingConfig validates SILENCE_GAP at construction.
|
|
57
|
+
assert gap is not None
|
|
58
|
+
if current - self._last_byte_at < gap:
|
|
59
|
+
return []
|
|
60
|
+
frame = bytes(self._buffer)
|
|
61
|
+
self._buffer.clear()
|
|
62
|
+
return [self.limit_frame(frame)]
|
|
63
|
+
|
|
64
|
+
def split_terminator(self, terminator: bytes) -> list[bytes]:
|
|
65
|
+
"""Split line-style frames, preserving no terminator bytes."""
|
|
66
|
+
frames: list[bytes] = []
|
|
67
|
+
while terminator:
|
|
68
|
+
index = self._buffer.find(terminator)
|
|
69
|
+
if index < 0:
|
|
70
|
+
break
|
|
71
|
+
frames.append(self.limit_frame(bytes(self._buffer[:index])))
|
|
72
|
+
del self._buffer[: index + len(terminator)]
|
|
73
|
+
return frames
|
|
74
|
+
|
|
75
|
+
def split_delimited(self) -> list[bytes]:
|
|
76
|
+
"""Split frames enclosed by start and end markers."""
|
|
77
|
+
start = self._config.start_marker
|
|
78
|
+
end = self._config.end_marker
|
|
79
|
+
frames: list[bytes] = []
|
|
80
|
+
while start and end:
|
|
81
|
+
begin = self._buffer.find(start)
|
|
82
|
+
if begin < 0:
|
|
83
|
+
self._resynchronise()
|
|
84
|
+
break
|
|
85
|
+
if begin:
|
|
86
|
+
del self._buffer[:begin]
|
|
87
|
+
finish = self._buffer.find(end, len(start))
|
|
88
|
+
if finish < 0:
|
|
89
|
+
break
|
|
90
|
+
stop = finish + len(end)
|
|
91
|
+
frames.append(self.limit_frame(bytes(self._buffer[:stop])))
|
|
92
|
+
del self._buffer[:stop]
|
|
93
|
+
return frames
|
|
94
|
+
|
|
95
|
+
def split_length_prefix(self) -> list[bytes]:
|
|
96
|
+
"""Split frames whose length is encoded in the header."""
|
|
97
|
+
offset = self._config.length_offset
|
|
98
|
+
width = self._config.length_size
|
|
99
|
+
frames: list[bytes] = []
|
|
100
|
+
while len(self._buffer) >= offset + width:
|
|
101
|
+
length_start = offset
|
|
102
|
+
length_end = length_start + width
|
|
103
|
+
payload_length = int.from_bytes(
|
|
104
|
+
self._buffer[length_start:length_end],
|
|
105
|
+
"little" if self._config.byteorder == "little" else "big",
|
|
106
|
+
)
|
|
107
|
+
frame_length = length_end + payload_length
|
|
108
|
+
if frame_length > self._config.max_frame_length:
|
|
109
|
+
self._resynchronise()
|
|
110
|
+
raise FramingError("frame exceeded max_frame_length")
|
|
111
|
+
if len(self._buffer) < frame_length:
|
|
112
|
+
break
|
|
113
|
+
frames.append(self.limit_frame(bytes(self._buffer[:frame_length])))
|
|
114
|
+
del self._buffer[:frame_length]
|
|
115
|
+
return frames
|
|
116
|
+
|
|
117
|
+
def split_silence_gap(self) -> list[bytes]:
|
|
118
|
+
"""Return no frames until ``flush`` observes a gap."""
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
def limit_frame(self, frame: bytes) -> bytes:
|
|
122
|
+
"""Validate one frame against the configured maximum."""
|
|
123
|
+
if len(frame) > self._config.max_frame_length:
|
|
124
|
+
raise FramingError("frame exceeded max_frame_length")
|
|
125
|
+
return frame
|
|
126
|
+
|
|
127
|
+
def _resynchronise(self) -> None:
|
|
128
|
+
"""Drop bounded garbage while retaining possible marker prefixes."""
|
|
129
|
+
limit = self._config.max_frame_length
|
|
130
|
+
if len(self._buffer) > limit:
|
|
131
|
+
del self._buffer[:-limit]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["FrameSplitter"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""EWMA latency and adaptive read interval tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..advanced import HandlerStats, ReadLoopConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LatencyTracker:
|
|
9
|
+
"""Track uncontended round trips and derive a bounded poll interval."""
|
|
10
|
+
|
|
11
|
+
_config: ReadLoopConfig
|
|
12
|
+
_sample_count: int
|
|
13
|
+
_srtt_s: float | None
|
|
14
|
+
_rttvar_s: float | None
|
|
15
|
+
_rto_s: float | None
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: ReadLoopConfig | None = None) -> None:
|
|
18
|
+
"""Create a cold-start tracker."""
|
|
19
|
+
self._config = config or ReadLoopConfig()
|
|
20
|
+
self._sample_count = 0
|
|
21
|
+
self._srtt_s = None
|
|
22
|
+
self._rttvar_s = None
|
|
23
|
+
self._rto_s = None
|
|
24
|
+
|
|
25
|
+
def observe(self, latency_s: float) -> None:
|
|
26
|
+
"""Record one positive uncontended round-trip sample."""
|
|
27
|
+
if latency_s <= 0:
|
|
28
|
+
raise ValueError("latency_s must be positive")
|
|
29
|
+
if self._srtt_s is None or self._rttvar_s is None:
|
|
30
|
+
self._srtt_s = latency_s
|
|
31
|
+
self._rttvar_s = latency_s / 2
|
|
32
|
+
else:
|
|
33
|
+
self._rttvar_s = 0.75 * self._rttvar_s + 0.25 * abs(
|
|
34
|
+
self._srtt_s - latency_s
|
|
35
|
+
)
|
|
36
|
+
self._srtt_s = 0.875 * self._srtt_s + 0.125 * latency_s
|
|
37
|
+
self._rto_s = self._srtt_s + 4 * self._rttvar_s
|
|
38
|
+
self._sample_count += 1
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
"""Forget all samples after reconnect or device changes."""
|
|
42
|
+
self._sample_count = 0
|
|
43
|
+
self._srtt_s = None
|
|
44
|
+
self._rttvar_s = None
|
|
45
|
+
self._rto_s = None
|
|
46
|
+
|
|
47
|
+
def poll_interval_s(self) -> float:
|
|
48
|
+
"""Return the cold-start or adaptive bounded read interval."""
|
|
49
|
+
if self._sample_count < self._config.min_samples:
|
|
50
|
+
return self._config.poll_initial_s
|
|
51
|
+
assert self._srtt_s is not None
|
|
52
|
+
value = self._srtt_s * self._config.poll_factor
|
|
53
|
+
return min(self._config.poll_max_s, max(self._config.poll_min_s, value))
|
|
54
|
+
|
|
55
|
+
def stats(self) -> HandlerStats:
|
|
56
|
+
"""Return a frozen snapshot for the public diagnostics API."""
|
|
57
|
+
return HandlerStats(
|
|
58
|
+
srtt_s=self._srtt_s,
|
|
59
|
+
rttvar_s=self._rttvar_s,
|
|
60
|
+
poll_interval_s=self.poll_interval_s(),
|
|
61
|
+
rto_s=self._rto_s,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["LatencyTracker"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Translate immutable device serial settings into pyserial options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..advanced import SerialConfig
|
|
6
|
+
from ..enums import FlowControl
|
|
7
|
+
|
|
8
|
+
# This utility intentionally exposes one static conversion operation.
|
|
9
|
+
# pylint: disable=too-few-public-methods
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PortOptions:
|
|
13
|
+
"""Translate one serial configuration for either opening path."""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def build(config: SerialConfig, baudrate: int) -> dict[str, object]:
|
|
17
|
+
"""Build pyserial options for a known baudrate."""
|
|
18
|
+
return {
|
|
19
|
+
"baudrate": baudrate,
|
|
20
|
+
"bytesize": int(config.data_bits),
|
|
21
|
+
"parity": config.parity.value,
|
|
22
|
+
"stopbits": float(config.stop_bits.value),
|
|
23
|
+
"xonxoff": config.flow_control is FlowControl.XON_XOFF,
|
|
24
|
+
"rtscts": config.flow_control is FlowControl.RTS_CTS,
|
|
25
|
+
"dsrdtr": config.flow_control is FlowControl.DSR_DTR,
|
|
26
|
+
"write_timeout": config.write_timeout_s,
|
|
27
|
+
"timeout": 0.05,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["PortOptions"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Process-local exclusive ownership of serial ports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from threading import RLock
|
|
8
|
+
|
|
9
|
+
from ..errors import PortBusyError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PortRegistry:
|
|
13
|
+
"""Track port ownership with re-entrant, process-local leases."""
|
|
14
|
+
|
|
15
|
+
_owners: dict[str, object] = {}
|
|
16
|
+
_lock = RLock()
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def acquire(cls, port: str, owner: object) -> None:
|
|
20
|
+
"""Claim ``port`` or raise ``PortBusyError``."""
|
|
21
|
+
with cls._lock:
|
|
22
|
+
current = cls._owners.get(port)
|
|
23
|
+
if current is not None and current is not owner:
|
|
24
|
+
raise PortBusyError(f"serial port is busy: {port}")
|
|
25
|
+
cls._owners[port] = owner
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def release(cls, port: str, owner: object) -> None:
|
|
29
|
+
"""Release a port only when owned by ``owner``."""
|
|
30
|
+
with cls._lock:
|
|
31
|
+
if cls._owners.get(port) is owner:
|
|
32
|
+
del cls._owners[port]
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def is_owned(cls, port: str) -> bool:
|
|
36
|
+
"""Return whether the port is currently leased."""
|
|
37
|
+
with cls._lock:
|
|
38
|
+
return port in cls._owners
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
@contextmanager
|
|
42
|
+
def lease(cls, port: str, owner: object) -> Iterator[object]:
|
|
43
|
+
"""Acquire a port for a context and release it afterwards."""
|
|
44
|
+
cls.acquire(port, owner)
|
|
45
|
+
try:
|
|
46
|
+
yield owner
|
|
47
|
+
finally:
|
|
48
|
+
cls.release(port, owner)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def clear(cls) -> None:
|
|
52
|
+
"""Clear all leases; intended for isolated test cleanup."""
|
|
53
|
+
with cls._lock:
|
|
54
|
+
cls._owners.clear()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["PortRegistry"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""pyserial-backed port backend and byte transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import serial
|
|
9
|
+
from serial.tools import list_ports
|
|
10
|
+
|
|
11
|
+
from ..protocols import TransportProtocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SerialTransport:
|
|
15
|
+
"""Provide the replaceable backend and one opened serial connection."""
|
|
16
|
+
|
|
17
|
+
_serial: serial.Serial | None
|
|
18
|
+
|
|
19
|
+
def __init__(self, connection: serial.Serial | None = None) -> None:
|
|
20
|
+
"""Create a backend or wrap an already opened connection."""
|
|
21
|
+
self._serial = connection
|
|
22
|
+
|
|
23
|
+
def list_ports(self) -> Sequence[Any]:
|
|
24
|
+
"""Return pyserial port descriptors."""
|
|
25
|
+
return tuple(list_ports.comports())
|
|
26
|
+
|
|
27
|
+
def open(
|
|
28
|
+
self, port: str, params: Mapping[str, object]
|
|
29
|
+
) -> TransportProtocol:
|
|
30
|
+
"""Open ``port`` with a mapping of pyserial keyword arguments."""
|
|
31
|
+
values = dict(params)
|
|
32
|
+
values["port"] = port
|
|
33
|
+
return SerialTransport(serial.Serial(**values))
|
|
34
|
+
|
|
35
|
+
def read(self, size: int, timeout_s: float) -> bytes:
|
|
36
|
+
"""Read up to ``size`` bytes with a bounded timeout."""
|
|
37
|
+
connection = self._require_connection()
|
|
38
|
+
connection.timeout = timeout_s
|
|
39
|
+
return bytes(connection.read(size))
|
|
40
|
+
|
|
41
|
+
def write(self, data: bytes) -> int:
|
|
42
|
+
"""Write bytes and return the accepted byte count."""
|
|
43
|
+
return cast(int, self._require_connection().write(data))
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the connection if it is open."""
|
|
47
|
+
if self._serial is not None:
|
|
48
|
+
self._serial.close()
|
|
49
|
+
|
|
50
|
+
def cancel_read(self) -> None:
|
|
51
|
+
"""Interrupt a read where the pyserial platform supports it."""
|
|
52
|
+
connection = self._serial
|
|
53
|
+
if connection is not None and hasattr(connection, "cancel_read"):
|
|
54
|
+
connection.cancel_read()
|
|
55
|
+
|
|
56
|
+
def cancel_write(self) -> None:
|
|
57
|
+
"""Interrupt a write where the pyserial platform supports it."""
|
|
58
|
+
connection = self._serial
|
|
59
|
+
if connection is not None and hasattr(connection, "cancel_write"):
|
|
60
|
+
connection.cancel_write()
|
|
61
|
+
|
|
62
|
+
def reset_input_buffer(self) -> None:
|
|
63
|
+
"""Discard queued input after an exclusive command timeout."""
|
|
64
|
+
self._require_connection().reset_input_buffer()
|
|
65
|
+
|
|
66
|
+
def set_buffer_size(self, rx_size: int) -> None:
|
|
67
|
+
"""Request a driver receive buffer size."""
|
|
68
|
+
connection = self._require_connection()
|
|
69
|
+
setter = getattr(connection, "set_buffer_size", None)
|
|
70
|
+
if setter is not None:
|
|
71
|
+
setter(rx_size=rx_size)
|
|
72
|
+
|
|
73
|
+
def set_control_lines(
|
|
74
|
+
self, *, dtr: bool | None = None, rts: bool | None = None
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Set DTR/RTS only when the caller explicitly supplied them."""
|
|
77
|
+
connection = self._require_connection()
|
|
78
|
+
if dtr is not None:
|
|
79
|
+
connection.dtr = dtr
|
|
80
|
+
if rts is not None:
|
|
81
|
+
connection.rts = rts
|
|
82
|
+
|
|
83
|
+
def _require_connection(self) -> serial.Serial:
|
|
84
|
+
"""Return the wrapped connection or fail before touching pyserial."""
|
|
85
|
+
if self._serial is None:
|
|
86
|
+
raise RuntimeError("serial transport is not open")
|
|
87
|
+
return self._serial
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["SerialTransport"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: serialforge
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PySide6 serial communication toolkit for Windows
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
|
|
8
|
+
Classifier: Operating System :: Microsoft :: Windows :: Windows 11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Terminals :: Serial
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: loguru>=0.7
|
|
17
|
+
Requires-Dist: pyserial>=3.5
|
|
18
|
+
Requires-Dist: pyside6-essentials>=6.5
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# serialforge
|
|
22
|
+
|
|
23
|
+
> 适用版本: 0.0.1 | 当前里程碑: M1
|
|
24
|
+
|
|
25
|
+
`serialforge` 是面向 Windows 10/11 64 位应用的 PySide6 串口通讯库。设计目标是用 QtCore 信号槽和线程封装设备发现、命令调度、自动重连、流量日志和无硬件测试后端。
|
|
26
|
+
|
|
27
|
+
当前版本完成包骨架与定义层,真实串口传输和调度将在后续里程碑实现。设计契约见 `串口通讯模块_方案_v10定稿.md`,AI 协作入口见 `AGENTS.md`。
|
|
28
|
+
|
|
29
|
+
## 开发
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
uv sync
|
|
33
|
+
uv run python scripts/ci.py --fast
|
|
34
|
+
uv run python scripts/ci.py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
项目仅支持 Windows 10/11 64 位;当前未执行 PyPI 上传或真实硬件验证。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
serialforge/__init__.py,sha256=EQzlRJq8K1jPVdIU9eXGYKyOxEcfY6YlVt1OwwKsIdo,1189
|
|
2
|
+
serialforge/advanced.py,sha256=Q-_qALa_vkODd_-BLIkQupBd4u4zGNgY4RhwBBp7lL4,9224
|
|
3
|
+
serialforge/enums.py,sha256=v1ZNPYPciMS40KowAV-GN76tJ7btcB3cPXF7rHrOHjE,2381
|
|
4
|
+
serialforge/errors.py,sha256=UCkppyxCsX8VKytCqw0X4D5Vti0gq8hwb_iKpGievhA,1072
|
|
5
|
+
serialforge/models.py,sha256=5K0VP2NyA8kfYsBqXVL0SocDH1wVylqY5AOEBN5O_rY,15854
|
|
6
|
+
serialforge/protocols.py,sha256=YQOeLxFZdDqowrdEN2lfDVnmMswRKtP6H0KN-lnwCEs,1581
|
|
7
|
+
serialforge/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
serialforge/settings.py,sha256=Dy2JfDbfNU2z1qyVKDvYsXMo5MGgXSRml6NrBsYUC1g,357
|
|
9
|
+
serialforge/connection/__init__.py,sha256=6PZGXCoW79Kf5EUU_UNTNOGCOCQYsWfMeilpELTN780,105
|
|
10
|
+
serialforge/connection/command_dispatcher.py,sha256=rn20zJN7olRTkdotRIiD54qwC3-_X4BD8gM8UXWGi4Q,27159
|
|
11
|
+
serialforge/connection/command_registry.py,sha256=DntLWB83wE2MYResIBQnwi_Bu856BdMq32PW2eKzVl8,7221
|
|
12
|
+
serialforge/connection/connection_worker.py,sha256=m2q_GI5veuJTSok9ibacKhilkD4GCb-TuUMMXM4J0n8,14726
|
|
13
|
+
serialforge/connection/pending_command.py,sha256=rZYFXIQmJrJDWHJo4hGQRWQg6e3TgIs_RK3m7Mnfjik,1067
|
|
14
|
+
serialforge/connection/read_thread.py,sha256=yvqMvBhnFM1aT7swjvEgRi19-kFidIkw9bkm3cjMftg,2421
|
|
15
|
+
serialforge/connection/response_parser.py,sha256=ZxPdTtoe2wgetMotkZhAHIuXo2Qb_KDu1DxndinNB7M,3560
|
|
16
|
+
serialforge/connection/serial_handler.py,sha256=WabGF7m4Rs7JNJKIlg7ad-yL8YXP4baDr6-elOzKZ_0,11135
|
|
17
|
+
serialforge/diagnostics/__init__.py,sha256=0HmHZGXcf4nRsMCRzvucQL7PLKJTdNyygJF0IalTK4Q,197
|
|
18
|
+
serialforge/diagnostics/log_file_manager.py,sha256=uxB0q8mWIXqHxuWhOsdYNjxIw7p0G4Q7KZxED97JXGs,12864
|
|
19
|
+
serialforge/diagnostics/traffic_logger.py,sha256=dmEvDbBn1ChgeqpvCFwKaE16dD-jX21R1Vhe_3Vvf_g,10083
|
|
20
|
+
serialforge/discovery/__init__.py,sha256=c6Qs5uB52P__gtryKQnVK_GCJipnHSEk1xj5HvqzOJA,101
|
|
21
|
+
serialforge/discovery/async_scan_thread.py,sha256=epn2UPiVw7dnJzNih19xZRemTtgFE5w7vKgoK9-fTQg,1054
|
|
22
|
+
serialforge/discovery/baud_cache.py,sha256=-bAAmy9-9HOYdJrk4rFfHbNvmawkoxxWerDMuuvT15E,4019
|
|
23
|
+
serialforge/discovery/baud_prober.py,sha256=Yo2rwE_CwkzESwigYKVSN_G4j50WBRO3GJPeExX-mFc,7008
|
|
24
|
+
serialforge/discovery/device_finder.py,sha256=OQaKDBd6q5ej6ZDf_SDws_-9ORIpBkE9AnXvEtVQfLY,6120
|
|
25
|
+
serialforge/discovery/port_scanner.py,sha256=vLPIbmN5gvxofS7ePN0VlorN3SlOO9n5788NdrcsYf4,1811
|
|
26
|
+
serialforge/testing/__init__.py,sha256=x0lpcoUm3Q6LFz1PNJtUbfpx3EcefrrW-LKwSCMDdQE,307
|
|
27
|
+
serialforge/testing/fake_backend.py,sha256=Q_uOTXfb1gKO3BApqeGAvO1_720rASNhGfRu6IUCRVY,1804
|
|
28
|
+
serialforge/testing/fake_transport.py,sha256=nAyECllGz8kPzBJ2MgcU0EEwka9BWxLmBspofzutrz8,4330
|
|
29
|
+
serialforge/testing/simulated_device.py,sha256=tXqAzGirAfPuRBm2yQrSRkYrTLiEGUml1t8xMaLwMzA,2139
|
|
30
|
+
serialforge/transport/__init__.py,sha256=xGN_JfA2BSgILiNQAcZ6yOommBKpyyRfnjBluVgUxgE,537
|
|
31
|
+
serialforge/transport/backend_switch.py,sha256=hRk7Vc2wDSgOaEVCItl0YLISXFs3-BEXjcWbmxQQ8NM,962
|
|
32
|
+
serialforge/transport/checksum_calculator.py,sha256=4DjKej_PaaEbZj2BuaOJRIAxrVrIywTvpsDcWXtbR0I,2614
|
|
33
|
+
serialforge/transport/frame_splitter.py,sha256=p6Pq4Spn8TPWy7pjvMlVBveXQtv5vTeKiWvNu-hjbYI,5060
|
|
34
|
+
serialforge/transport/latency_tracker.py,sha256=Pe0x6SkD-u-jfFrk3wgv3WZsyjNR8yg88NvWkUiRToY,2226
|
|
35
|
+
serialforge/transport/port_options.py,sha256=62NOq30vn_0Pu6TAq3Wpq6dRvBCuUwCYDwei67rrzEE,1047
|
|
36
|
+
serialforge/transport/port_registry.py,sha256=GHJfRI0EsubvhXlOfAWUQ9v5596tP1oaKaFnuKUYgAk,1669
|
|
37
|
+
serialforge/transport/serial_transport.py,sha256=rwK90-WJFED-aowUsTEZ_Tyav69F0r5n75C5C0sPMR4,3172
|
|
38
|
+
serialforge-0.0.1.dist-info/METADATA,sha256=ewJTgk0VlfSLiFI0gkV3evsiE3oPc8CDJVKfWSssvZ0,1378
|
|
39
|
+
serialforge-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
40
|
+
serialforge-0.0.1.dist-info/licenses/LICENSE,sha256=s6YNvjBbs8bqr2t4zQUyYysyU-iiVnJQtTGJNzw3tTE,1081
|
|
41
|
+
serialforge-0.0.1.dist-info/RECORD,,
|