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,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
+ ]
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ """Connection facade exports."""
2
+
3
+ from .serial_handler import SerialHandler
4
+
5
+ __all__ = ["SerialHandler"]