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
serialforge/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Public serialforge API.
|
|
2
|
+
|
|
3
|
+
Importing this module only defines types and Qt signals. It does not create a
|
|
4
|
+
Qt application, open a port, start a thread, create a file or configure a
|
|
5
|
+
loguru sink.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Re-export lists are intentionally repeated at each public namespace boundary.
|
|
9
|
+
# pylint: disable=duplicate-code
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version("serialforge")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
__version__ = "0.0.1"
|
|
19
|
+
|
|
20
|
+
from .connection import SerialHandler
|
|
21
|
+
from .discovery import DeviceFinder
|
|
22
|
+
from .enums import (
|
|
23
|
+
CommandStatus,
|
|
24
|
+
ConnectionState,
|
|
25
|
+
Correlation,
|
|
26
|
+
ResponseMode,
|
|
27
|
+
ScanMode,
|
|
28
|
+
)
|
|
29
|
+
from .errors import SerialForgeError
|
|
30
|
+
from .models import (
|
|
31
|
+
CommandResult,
|
|
32
|
+
CommandSpec,
|
|
33
|
+
DeviceEvent,
|
|
34
|
+
DeviceInfo,
|
|
35
|
+
DeviceProfile,
|
|
36
|
+
EventSpec,
|
|
37
|
+
LogConfig,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"SerialHandler",
|
|
42
|
+
"DeviceFinder",
|
|
43
|
+
"DeviceProfile",
|
|
44
|
+
"CommandSpec",
|
|
45
|
+
"EventSpec",
|
|
46
|
+
"LogConfig",
|
|
47
|
+
"CommandResult",
|
|
48
|
+
"DeviceEvent",
|
|
49
|
+
"DeviceInfo",
|
|
50
|
+
"CommandStatus",
|
|
51
|
+
"ConnectionState",
|
|
52
|
+
"ResponseMode",
|
|
53
|
+
"Correlation",
|
|
54
|
+
"ScanMode",
|
|
55
|
+
"SerialForgeError",
|
|
56
|
+
]
|
serialforge/advanced.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Advanced configuration and diagnostics types.
|
|
2
|
+
|
|
3
|
+
The names in this module are intentionally outside the daily top-level API.
|
|
4
|
+
They let an application tune transport details without coupling normal code to
|
|
5
|
+
the implementation subpackages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .enums import (
|
|
15
|
+
Checksum,
|
|
16
|
+
CommandPriority,
|
|
17
|
+
DataBits,
|
|
18
|
+
FlowControl,
|
|
19
|
+
FramingMode,
|
|
20
|
+
Parity,
|
|
21
|
+
SendRoute,
|
|
22
|
+
StopBits,
|
|
23
|
+
TimeoutPolicy,
|
|
24
|
+
)
|
|
25
|
+
from .errors import ConfigError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Public schemas intentionally expose all transport knobs in one frozen object.
|
|
29
|
+
# pylint: disable=too-many-instance-attributes
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class SerialConfig:
|
|
32
|
+
"""Store low-level serial port settings; defaults are 8N1."""
|
|
33
|
+
|
|
34
|
+
data_bits: DataBits = DataBits.EIGHT
|
|
35
|
+
parity: Parity = Parity.NONE
|
|
36
|
+
stop_bits: StopBits = StopBits.ONE
|
|
37
|
+
flow_control: FlowControl = FlowControl.NONE
|
|
38
|
+
write_timeout_s: float = 1.0
|
|
39
|
+
rx_buffer_size: int | None = None
|
|
40
|
+
dtr: bool | None = None
|
|
41
|
+
rts: bool | None = None
|
|
42
|
+
rs485_switch_delay_s: float | None = None
|
|
43
|
+
settle_time_s: float = 0.3
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Validate serial timing and buffer settings."""
|
|
47
|
+
if self.write_timeout_s <= 0:
|
|
48
|
+
raise ConfigError("write_timeout_s must be positive")
|
|
49
|
+
if self.rx_buffer_size is not None and self.rx_buffer_size <= 0:
|
|
50
|
+
raise ConfigError("rx_buffer_size must be positive")
|
|
51
|
+
if (
|
|
52
|
+
self.rs485_switch_delay_s is not None
|
|
53
|
+
and self.rs485_switch_delay_s < 0
|
|
54
|
+
):
|
|
55
|
+
raise ConfigError("rs485_switch_delay_s cannot be negative")
|
|
56
|
+
if self.settle_time_s < 0:
|
|
57
|
+
raise ConfigError("settle_time_s cannot be negative")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ProbeSpec:
|
|
62
|
+
"""Describe a custom device probe used by ``DeviceProfile``."""
|
|
63
|
+
|
|
64
|
+
request: str
|
|
65
|
+
expect: str | Callable[[str], bool]
|
|
66
|
+
timeout_s: float = 1.0
|
|
67
|
+
settle_time_s: float | None = None
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
"""Validate the custom probe declaration."""
|
|
71
|
+
if not self.request.strip():
|
|
72
|
+
raise ConfigError("ProbeSpec.request must not be empty")
|
|
73
|
+
if "{" in self.request or "}" in self.request:
|
|
74
|
+
raise ConfigError("ProbeSpec.request cannot contain placeholders")
|
|
75
|
+
if self.timeout_s <= 0:
|
|
76
|
+
raise ConfigError("ProbeSpec.timeout_s must be positive")
|
|
77
|
+
if self.settle_time_s is not None and self.settle_time_s < 0:
|
|
78
|
+
raise ConfigError("ProbeSpec.settle_time_s cannot be negative")
|
|
79
|
+
if isinstance(self.expect, str):
|
|
80
|
+
try:
|
|
81
|
+
pattern = re.compile(self.expect)
|
|
82
|
+
except re.error as exc:
|
|
83
|
+
raise ConfigError(f"invalid ProbeSpec.expect: {exc}") from exc
|
|
84
|
+
if pattern.fullmatch(self.request.strip()):
|
|
85
|
+
raise ConfigError("ProbeSpec.expect must not match its request")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Framing options are a single public schema by design.
|
|
89
|
+
# pylint: disable=too-many-instance-attributes
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class FramingConfig:
|
|
92
|
+
"""Describe byte framing and checksum options."""
|
|
93
|
+
|
|
94
|
+
mode: FramingMode = FramingMode.LINE
|
|
95
|
+
terminator: bytes = b"\r\n"
|
|
96
|
+
start_marker: bytes = b""
|
|
97
|
+
end_marker: bytes = b""
|
|
98
|
+
length_offset: int = 0
|
|
99
|
+
length_size: int = 2
|
|
100
|
+
byteorder: str = "little"
|
|
101
|
+
max_frame_length: int = 65536
|
|
102
|
+
max_buffer_bytes: int = 262144
|
|
103
|
+
silence_gap_s: float | None = None
|
|
104
|
+
checksum: Checksum = Checksum.NONE
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
"""Validate framing boundaries."""
|
|
108
|
+
if self.mode is FramingMode.LINE and not self.terminator:
|
|
109
|
+
raise ConfigError("LINE framing requires a non-empty terminator")
|
|
110
|
+
if self.mode is FramingMode.SILENCE_GAP and self.silence_gap_s is None:
|
|
111
|
+
raise ConfigError("SILENCE_GAP framing requires silence_gap_s")
|
|
112
|
+
if self.length_offset < 0 or self.length_size <= 0:
|
|
113
|
+
raise ConfigError("length_offset and length_size must be positive")
|
|
114
|
+
if self.byteorder not in {"little", "big"}:
|
|
115
|
+
raise ConfigError("byteorder must be 'little' or 'big'")
|
|
116
|
+
if self.max_frame_length <= 0 or self.max_buffer_bytes <= 0:
|
|
117
|
+
raise ConfigError("frame limits must be positive")
|
|
118
|
+
if self.silence_gap_s is not None and self.silence_gap_s <= 0:
|
|
119
|
+
raise ConfigError("silence_gap_s must be positive")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class ReadLoopConfig:
|
|
124
|
+
"""Configure adaptive serial read polling."""
|
|
125
|
+
|
|
126
|
+
poll_min_s: float = 0.005
|
|
127
|
+
poll_max_s: float = 0.100
|
|
128
|
+
poll_initial_s: float = 0.050
|
|
129
|
+
poll_factor: float = 0.25
|
|
130
|
+
min_samples: int = 5
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
"""Validate adaptive polling bounds."""
|
|
134
|
+
if not 0 < self.poll_min_s < self.poll_initial_s <= self.poll_max_s:
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
"poll_min_s < poll_initial_s <= poll_max_s is required"
|
|
137
|
+
)
|
|
138
|
+
if self.poll_factor <= 0:
|
|
139
|
+
raise ConfigError("poll_factor must be positive")
|
|
140
|
+
if self.min_samples <= 0:
|
|
141
|
+
raise ConfigError("min_samples must be positive")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Runtime tuning is grouped here so callers do not pass a long parameter list.
|
|
145
|
+
# pylint: disable=too-many-instance-attributes
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class RuntimeConfig:
|
|
148
|
+
"""Configure queueing, probing, reconnect and backpressure behavior."""
|
|
149
|
+
|
|
150
|
+
min_command_interval_s: float = 0.0
|
|
151
|
+
queue_limit: int = 256
|
|
152
|
+
post_timeout_drain_s: float = 0.2
|
|
153
|
+
rto_min_s: float = 0.05
|
|
154
|
+
rto_max_s: float = 5.0
|
|
155
|
+
stream_emit_interval_ms: int = 50
|
|
156
|
+
event_queue_high_water: int = 1000
|
|
157
|
+
traffic_ring_size: int = 1000
|
|
158
|
+
hex_truncate_bytes: int = 64
|
|
159
|
+
resend_idempotent: bool = False
|
|
160
|
+
queue_while_reconnecting: bool = False
|
|
161
|
+
reconnect_initial_s: float = 0.5
|
|
162
|
+
reconnect_max_s: float = 10.0
|
|
163
|
+
reconnect_max_attempts: int | None = None
|
|
164
|
+
reprobe_on_reconnect: bool = True
|
|
165
|
+
hotplug_poll_s: float = 1.5
|
|
166
|
+
probe_pool_size: int = 4
|
|
167
|
+
probe_total_timeout_s: float = 30.0
|
|
168
|
+
|
|
169
|
+
def __post_init__(self) -> None:
|
|
170
|
+
"""Validate runtime limits and timeout ranges."""
|
|
171
|
+
if self.min_command_interval_s < 0:
|
|
172
|
+
raise ConfigError("min_command_interval_s cannot be negative")
|
|
173
|
+
if self.queue_limit <= 0 or self.event_queue_high_water <= 0:
|
|
174
|
+
raise ConfigError("queue limits must be positive")
|
|
175
|
+
if self.post_timeout_drain_s < 0:
|
|
176
|
+
raise ConfigError("post_timeout_drain_s cannot be negative")
|
|
177
|
+
if not 0 < self.rto_min_s <= self.rto_max_s:
|
|
178
|
+
raise ConfigError("rto_min_s must be <= rto_max_s and positive")
|
|
179
|
+
if self.stream_emit_interval_ms <= 0:
|
|
180
|
+
raise ConfigError("stream_emit_interval_ms must be positive")
|
|
181
|
+
if self.traffic_ring_size <= 0 or self.hex_truncate_bytes <= 0:
|
|
182
|
+
raise ConfigError(
|
|
183
|
+
"traffic_ring_size and hex_truncate_bytes must be positive"
|
|
184
|
+
)
|
|
185
|
+
if not 0 < self.reconnect_initial_s <= self.reconnect_max_s:
|
|
186
|
+
raise ConfigError("reconnect backoff bounds are invalid")
|
|
187
|
+
if (
|
|
188
|
+
self.reconnect_max_attempts is not None
|
|
189
|
+
and self.reconnect_max_attempts <= 0
|
|
190
|
+
):
|
|
191
|
+
raise ConfigError("reconnect_max_attempts must be positive")
|
|
192
|
+
if self.hotplug_poll_s <= 0 or self.probe_pool_size <= 0:
|
|
193
|
+
raise ConfigError(
|
|
194
|
+
"hotplug_poll_s and probe_pool_size must be positive"
|
|
195
|
+
)
|
|
196
|
+
if self.probe_total_timeout_s <= 0:
|
|
197
|
+
raise ConfigError("probe_total_timeout_s must be positive")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class HandlerStats:
|
|
202
|
+
"""Expose read latency and dropped-event counters."""
|
|
203
|
+
|
|
204
|
+
srtt_s: float | None = None
|
|
205
|
+
rttvar_s: float | None = None
|
|
206
|
+
poll_interval_s: float | None = None
|
|
207
|
+
rto_s: float | None = None
|
|
208
|
+
dropped_events: int = 0
|
|
209
|
+
dropped_traffic_lines: int = 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class TrafficRecord:
|
|
214
|
+
"""Describe one transmitted or received traffic line."""
|
|
215
|
+
|
|
216
|
+
direction: str
|
|
217
|
+
timestamp: float
|
|
218
|
+
port: str
|
|
219
|
+
data: bytes
|
|
220
|
+
route: SendRoute | None = None
|
|
221
|
+
spec: object | None = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class CommandTicket:
|
|
225
|
+
"""Represent an asynchronous command and allow local cancellation."""
|
|
226
|
+
|
|
227
|
+
request_id: str
|
|
228
|
+
spec: object | None
|
|
229
|
+
route: SendRoute
|
|
230
|
+
_cancelled: bool
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
request_id: str,
|
|
235
|
+
spec: object | None,
|
|
236
|
+
route: SendRoute,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Create a ticket for a queued command."""
|
|
239
|
+
self.request_id = request_id
|
|
240
|
+
self.spec = spec
|
|
241
|
+
self.route = route
|
|
242
|
+
self._cancelled = False
|
|
243
|
+
|
|
244
|
+
def cancel(self) -> None:
|
|
245
|
+
"""Request local cancellation of the command."""
|
|
246
|
+
self._cancelled = True
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def cancelled(self) -> bool:
|
|
250
|
+
"""Return whether cancellation was requested."""
|
|
251
|
+
return self._cancelled
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
__all__ = [
|
|
255
|
+
"SerialConfig",
|
|
256
|
+
"DataBits",
|
|
257
|
+
"Parity",
|
|
258
|
+
"StopBits",
|
|
259
|
+
"FlowControl",
|
|
260
|
+
"ProbeSpec",
|
|
261
|
+
"FramingMode",
|
|
262
|
+
"FramingConfig",
|
|
263
|
+
"Checksum",
|
|
264
|
+
"CommandPriority",
|
|
265
|
+
"CommandTicket",
|
|
266
|
+
"SendRoute",
|
|
267
|
+
"TimeoutPolicy",
|
|
268
|
+
"TrafficRecord",
|
|
269
|
+
"ReadLoopConfig",
|
|
270
|
+
"RuntimeConfig",
|
|
271
|
+
"HandlerStats",
|
|
272
|
+
]
|