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/models.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Immutable public data models and declaration-time validation."""
|
|
2
|
+
|
|
3
|
+
# Public API schemas intentionally keep their complete field sets together.
|
|
4
|
+
# pylint: disable=duplicate-code
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import codecs
|
|
9
|
+
import re
|
|
10
|
+
import string
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from .enums import (
|
|
18
|
+
CommandStatus,
|
|
19
|
+
Correlation,
|
|
20
|
+
ResponseMode,
|
|
21
|
+
SendRoute,
|
|
22
|
+
TimeoutPolicy,
|
|
23
|
+
)
|
|
24
|
+
from .errors import CommandError, ConfigError
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .advanced import FramingConfig, RuntimeConfig, SerialConfig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalise_text(value: str) -> str:
|
|
31
|
+
"""Strip whitespace and common line terminators from a declaration."""
|
|
32
|
+
return value.strip().rstrip("\r\n").strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _compiled_pattern(value: str, field_name: str) -> re.Pattern[str]:
|
|
36
|
+
"""Compile a regular expression and reject unnamed capture groups."""
|
|
37
|
+
try:
|
|
38
|
+
compiled = re.compile(value)
|
|
39
|
+
except re.error as exc:
|
|
40
|
+
raise CommandError(f"invalid {field_name}: {exc}") from exc
|
|
41
|
+
unnamed = compiled.groups - len(compiled.groupindex)
|
|
42
|
+
if unnamed:
|
|
43
|
+
raise CommandError(
|
|
44
|
+
f"{field_name} contains unnamed capture groups; use (?:...)"
|
|
45
|
+
)
|
|
46
|
+
return compiled
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _template_fields(value: str) -> tuple[str, ...]:
|
|
50
|
+
"""Return and validate named placeholders in a command template."""
|
|
51
|
+
result: list[str] = []
|
|
52
|
+
formatter = string.Formatter()
|
|
53
|
+
try:
|
|
54
|
+
parts = formatter.parse(value)
|
|
55
|
+
for _, field_name, format_spec, conversion in parts:
|
|
56
|
+
if field_name is None:
|
|
57
|
+
continue
|
|
58
|
+
if format_spec or conversion:
|
|
59
|
+
raise CommandError(
|
|
60
|
+
"command placeholders cannot use format specs or "
|
|
61
|
+
"conversions"
|
|
62
|
+
)
|
|
63
|
+
if not field_name.isidentifier():
|
|
64
|
+
raise CommandError(
|
|
65
|
+
f"invalid command placeholder: {field_name!r}"
|
|
66
|
+
)
|
|
67
|
+
if field_name == "priority":
|
|
68
|
+
raise CommandError(
|
|
69
|
+
"command placeholder name 'priority' is reserved"
|
|
70
|
+
)
|
|
71
|
+
result.append(field_name)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise CommandError(f"invalid request template: {exc}") from exc
|
|
74
|
+
return tuple(result)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_result_type(
|
|
78
|
+
pattern: re.Pattern[str], result_type: type[Any] | None, field_name: str
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Check the basic one-to-one relation between groups and result types."""
|
|
81
|
+
if result_type is None:
|
|
82
|
+
return
|
|
83
|
+
if not isinstance(result_type, type):
|
|
84
|
+
raise CommandError(f"{field_name} must be a type")
|
|
85
|
+
if not pattern.groupindex and result_type not in {str, int, float}:
|
|
86
|
+
try:
|
|
87
|
+
result_fields = {item.name for item in fields(result_type)}
|
|
88
|
+
except TypeError as exc:
|
|
89
|
+
raise CommandError(
|
|
90
|
+
f"{field_name} requires a dataclass for named result fields"
|
|
91
|
+
) from exc
|
|
92
|
+
if result_fields:
|
|
93
|
+
raise CommandError(
|
|
94
|
+
f"{field_name} has no named groups for dataclass fields"
|
|
95
|
+
)
|
|
96
|
+
if pattern.groupindex and result_type not in {str, int, float}:
|
|
97
|
+
try:
|
|
98
|
+
result_fields = {item.name for item in fields(result_type)}
|
|
99
|
+
except TypeError:
|
|
100
|
+
return
|
|
101
|
+
group_fields = set(pattern.groupindex)
|
|
102
|
+
if result_fields != group_fields:
|
|
103
|
+
raise CommandError(
|
|
104
|
+
f"{field_name} fields {sorted(result_fields)} do not match "
|
|
105
|
+
f"pattern groups {sorted(group_fields)}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# A command declaration is one immutable public schema, not a parameter bag.
|
|
110
|
+
# pylint: disable=too-many-instance-attributes
|
|
111
|
+
@dataclass(frozen=True, eq=False)
|
|
112
|
+
class CommandSpec:
|
|
113
|
+
"""Declare one command request and the response it expects.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
request: Text written to the device, optionally containing placeholders.
|
|
117
|
+
pattern: Regular expression used to parse response frames.
|
|
118
|
+
result_type: Optional scalar or dataclass used for parsed data.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
CommandError: If the declaration is inconsistent or invalid.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
request: str
|
|
125
|
+
pattern: str | None = None
|
|
126
|
+
result_type: type[Any] | None = None
|
|
127
|
+
name: str | None = field(default=None, kw_only=True)
|
|
128
|
+
mode: ResponseMode | None = field(default=None, kw_only=True)
|
|
129
|
+
correlation: Correlation = field(
|
|
130
|
+
default=Correlation.EXCLUSIVE, kw_only=True
|
|
131
|
+
)
|
|
132
|
+
count: int | None = field(default=None, kw_only=True)
|
|
133
|
+
until: str | None = field(default=None, kw_only=True)
|
|
134
|
+
error_pattern: str | None = field(default=None, kw_only=True)
|
|
135
|
+
timeout_s: float | object = field(default=1.0, kw_only=True)
|
|
136
|
+
idle_timeout_s: float = field(default=0.3, kw_only=True)
|
|
137
|
+
total_timeout_s: float | None = field(default=None, kw_only=True)
|
|
138
|
+
idempotent: bool = field(default=False, kw_only=True)
|
|
139
|
+
parser: Callable[[str], Any] | None = field(default=None, kw_only=True)
|
|
140
|
+
|
|
141
|
+
# pylint: disable=too-many-branches
|
|
142
|
+
def __post_init__(self) -> None:
|
|
143
|
+
"""Validate and infer the response mode."""
|
|
144
|
+
if not isinstance(self.request, str) or not _normalise_text(
|
|
145
|
+
self.request
|
|
146
|
+
):
|
|
147
|
+
raise CommandError("request must be a non-empty string")
|
|
148
|
+
placeholders = _template_fields(self.request)
|
|
149
|
+
if self.pattern is not None:
|
|
150
|
+
compiled = _compiled_pattern(self.pattern, "pattern")
|
|
151
|
+
_validate_result_type(compiled, self.result_type, "result_type")
|
|
152
|
+
elif self.result_type is not None:
|
|
153
|
+
raise CommandError("result_type requires pattern")
|
|
154
|
+
if self.error_pattern is not None:
|
|
155
|
+
_compiled_pattern(self.error_pattern, "error_pattern")
|
|
156
|
+
if self.until is not None:
|
|
157
|
+
_compiled_pattern(self.until, "until")
|
|
158
|
+
if self.count is not None and self.count <= 0:
|
|
159
|
+
raise CommandError("count must be positive")
|
|
160
|
+
if self.count is not None and self.until is not None:
|
|
161
|
+
raise CommandError("count and until are mutually exclusive")
|
|
162
|
+
if self.idle_timeout_s <= 0:
|
|
163
|
+
raise CommandError("idle_timeout_s must be positive")
|
|
164
|
+
if self.total_timeout_s is not None and self.total_timeout_s <= 0:
|
|
165
|
+
raise CommandError("total_timeout_s must be positive")
|
|
166
|
+
if not isinstance(self.timeout_s, TimeoutPolicy):
|
|
167
|
+
if (
|
|
168
|
+
not isinstance(self.timeout_s, (int, float))
|
|
169
|
+
or self.timeout_s <= 0
|
|
170
|
+
):
|
|
171
|
+
raise CommandError(
|
|
172
|
+
"timeout_s must be positive or TimeoutPolicy.AUTO"
|
|
173
|
+
)
|
|
174
|
+
if placeholders and self.mode is ResponseMode.NO_REPLY and self.pattern:
|
|
175
|
+
raise CommandError("NO_REPLY cannot define pattern")
|
|
176
|
+
inferred = self.mode
|
|
177
|
+
if inferred is None:
|
|
178
|
+
if self.count is not None or self.until is not None:
|
|
179
|
+
inferred = ResponseMode.MULTI
|
|
180
|
+
elif self.pattern is None:
|
|
181
|
+
inferred = ResponseMode.NO_REPLY
|
|
182
|
+
else:
|
|
183
|
+
inferred = ResponseMode.SINGLE
|
|
184
|
+
if inferred is ResponseMode.NO_REPLY and (
|
|
185
|
+
self.pattern is not None
|
|
186
|
+
or self.count is not None
|
|
187
|
+
or self.until is not None
|
|
188
|
+
):
|
|
189
|
+
raise CommandError(
|
|
190
|
+
"NO_REPLY cannot define pattern, count, or until"
|
|
191
|
+
)
|
|
192
|
+
if inferred is ResponseMode.SINGLE and (
|
|
193
|
+
self.count is not None or self.until is not None
|
|
194
|
+
):
|
|
195
|
+
raise CommandError("SINGLE cannot define count or until")
|
|
196
|
+
if inferred is ResponseMode.STREAM and (
|
|
197
|
+
self.count is not None or self.until is not None
|
|
198
|
+
):
|
|
199
|
+
raise CommandError("STREAM cannot define count or until")
|
|
200
|
+
if (
|
|
201
|
+
inferred is ResponseMode.MULTI
|
|
202
|
+
and self.pattern is None
|
|
203
|
+
and self.result_type
|
|
204
|
+
):
|
|
205
|
+
raise CommandError("result_type requires pattern")
|
|
206
|
+
if self.name is None:
|
|
207
|
+
object.__setattr__(self, "name", _normalise_text(self.request))
|
|
208
|
+
elif not self.name.strip():
|
|
209
|
+
raise CommandError("name must not be empty")
|
|
210
|
+
object.__setattr__(self, "mode", inferred)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def placeholders(self) -> tuple[str, ...]:
|
|
214
|
+
"""Return placeholder names used by the request template."""
|
|
215
|
+
return _template_fields(self.request)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, eq=False)
|
|
219
|
+
class EventSpec:
|
|
220
|
+
"""Declare a device-initiated event frame."""
|
|
221
|
+
|
|
222
|
+
pattern: str
|
|
223
|
+
result_type: type[Any] | None = None
|
|
224
|
+
name: str | None = field(default=None, kw_only=True)
|
|
225
|
+
|
|
226
|
+
def __post_init__(self) -> None:
|
|
227
|
+
"""Validate the event pattern and result mapping."""
|
|
228
|
+
if not self.pattern.strip():
|
|
229
|
+
raise CommandError("pattern must be a non-empty string")
|
|
230
|
+
compiled = _compiled_pattern(self.pattern, "pattern")
|
|
231
|
+
_validate_result_type(compiled, self.result_type, "result_type")
|
|
232
|
+
if self.name is None:
|
|
233
|
+
object.__setattr__(self, "name", _normalise_text(self.pattern))
|
|
234
|
+
elif not self.name.strip():
|
|
235
|
+
raise CommandError("name must not be empty")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _default_serial_config() -> Any:
|
|
239
|
+
"""Build the advanced default without importing it at module load time."""
|
|
240
|
+
# Import lazily to keep the definition layer free of an import cycle.
|
|
241
|
+
# pylint: disable=import-outside-toplevel
|
|
242
|
+
from .advanced import SerialConfig
|
|
243
|
+
|
|
244
|
+
return SerialConfig()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _default_runtime_config() -> Any:
|
|
248
|
+
"""Build the advanced runtime default without a module cycle."""
|
|
249
|
+
# Import lazily to keep the definition layer free of an import cycle.
|
|
250
|
+
# pylint: disable=import-outside-toplevel
|
|
251
|
+
from .advanced import RuntimeConfig
|
|
252
|
+
|
|
253
|
+
return RuntimeConfig()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Device identity and transport defaults form one immutable public schema.
|
|
257
|
+
# pylint: disable=too-many-instance-attributes
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class DeviceProfile:
|
|
260
|
+
"""Describe how a device is identified and opened."""
|
|
261
|
+
|
|
262
|
+
vid_pid: Sequence[tuple[int, int]]
|
|
263
|
+
baudrates: Sequence[int]
|
|
264
|
+
probe: CommandSpec | object
|
|
265
|
+
name: str | None = None
|
|
266
|
+
terminator: str = "\r\n"
|
|
267
|
+
encoding: str = "utf-8"
|
|
268
|
+
echo: bool = False
|
|
269
|
+
heartbeat_s: float | None = None
|
|
270
|
+
serial: SerialConfig | None = None
|
|
271
|
+
framing: FramingConfig | None = None
|
|
272
|
+
runtime: RuntimeConfig | None = None
|
|
273
|
+
|
|
274
|
+
# pylint: disable=too-many-branches
|
|
275
|
+
def __post_init__(self) -> None:
|
|
276
|
+
"""Validate device identity, probe and default transport settings."""
|
|
277
|
+
pairs = tuple(self.vid_pid)
|
|
278
|
+
if not pairs:
|
|
279
|
+
raise ConfigError("vid_pid must not be empty")
|
|
280
|
+
for pair in pairs:
|
|
281
|
+
if len(pair) != 2 or any(
|
|
282
|
+
isinstance(value, bool) or not 0 <= value <= 0xFFFF
|
|
283
|
+
for value in pair
|
|
284
|
+
):
|
|
285
|
+
raise ConfigError(
|
|
286
|
+
"vid_pid values must be integer pairs in 0..65535"
|
|
287
|
+
)
|
|
288
|
+
rates = tuple(self.baudrates)
|
|
289
|
+
if not rates or any(
|
|
290
|
+
isinstance(rate, bool) or rate <= 0 for rate in rates
|
|
291
|
+
):
|
|
292
|
+
raise ConfigError("baudrates must contain unique positive integers")
|
|
293
|
+
if len(set(rates)) != len(rates):
|
|
294
|
+
raise ConfigError("baudrates must not contain duplicates")
|
|
295
|
+
if (
|
|
296
|
+
not isinstance(self.probe, CommandSpec)
|
|
297
|
+
and type(self.probe).__name__ != "ProbeSpec"
|
|
298
|
+
):
|
|
299
|
+
raise ConfigError("probe must be CommandSpec or advanced.ProbeSpec")
|
|
300
|
+
if isinstance(self.probe, CommandSpec):
|
|
301
|
+
if self.probe.placeholders:
|
|
302
|
+
raise ConfigError("probe command cannot contain placeholders")
|
|
303
|
+
if (
|
|
304
|
+
self.probe.pattern is None
|
|
305
|
+
or self.probe.mode is not ResponseMode.SINGLE
|
|
306
|
+
):
|
|
307
|
+
raise ConfigError(
|
|
308
|
+
"probe command must be a SINGLE command with pattern"
|
|
309
|
+
)
|
|
310
|
+
pattern = re.compile(self.probe.pattern)
|
|
311
|
+
if pattern.fullmatch(_normalise_text(self.probe.request)):
|
|
312
|
+
raise ConfigError("probe pattern must not match its request")
|
|
313
|
+
if not self.terminator:
|
|
314
|
+
raise ConfigError("terminator must not be empty")
|
|
315
|
+
try:
|
|
316
|
+
codecs.lookup(self.encoding)
|
|
317
|
+
except LookupError as exc:
|
|
318
|
+
raise ConfigError(f"unknown encoding: {self.encoding}") from exc
|
|
319
|
+
if self.heartbeat_s is not None and self.heartbeat_s <= 0:
|
|
320
|
+
raise ConfigError("heartbeat_s must be positive")
|
|
321
|
+
if self.serial is None:
|
|
322
|
+
object.__setattr__(self, "serial", _default_serial_config())
|
|
323
|
+
if self.runtime is None:
|
|
324
|
+
object.__setattr__(self, "runtime", _default_runtime_config())
|
|
325
|
+
if self.name is None:
|
|
326
|
+
first_vid, first_pid = pairs[0]
|
|
327
|
+
object.__setattr__(self, "name", f"{first_vid:04X}:{first_pid:04X}")
|
|
328
|
+
object.__setattr__(self, "vid_pid", pairs)
|
|
329
|
+
object.__setattr__(self, "baudrates", rates)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Logging retention fields belong together in the public configuration object.
|
|
333
|
+
# pylint: disable=too-many-instance-attributes
|
|
334
|
+
@dataclass(frozen=True)
|
|
335
|
+
class LogConfig:
|
|
336
|
+
"""Configure debug logging and optional per-connection files."""
|
|
337
|
+
|
|
338
|
+
enabled: bool = False
|
|
339
|
+
save_to_file: bool = False
|
|
340
|
+
dir: Path | None = None
|
|
341
|
+
app_name: str | None = None
|
|
342
|
+
max_files: int = 50
|
|
343
|
+
max_file_bytes: int = 20 * 1024 * 1024
|
|
344
|
+
|
|
345
|
+
def __post_init__(self) -> None:
|
|
346
|
+
"""Validate log retention limits."""
|
|
347
|
+
if self.max_files <= 0:
|
|
348
|
+
raise ConfigError("max_files must be positive")
|
|
349
|
+
if self.max_file_bytes <= 0:
|
|
350
|
+
raise ConfigError("max_file_bytes must be positive")
|
|
351
|
+
if self.app_name is not None and not self.app_name.strip():
|
|
352
|
+
raise ConfigError("app_name must not be empty")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Discovery metadata is intentionally represented as one immutable record.
|
|
356
|
+
# pylint: disable=too-many-instance-attributes
|
|
357
|
+
@dataclass(frozen=True)
|
|
358
|
+
class DeviceInfo:
|
|
359
|
+
"""Describe a discovered device and the successful probe."""
|
|
360
|
+
|
|
361
|
+
port: str
|
|
362
|
+
vid: int | None = None
|
|
363
|
+
pid: int | None = None
|
|
364
|
+
serial_number: str | None = None
|
|
365
|
+
location: str | None = None
|
|
366
|
+
description: str | None = None
|
|
367
|
+
baudrate: int | None = None
|
|
368
|
+
response: bytes | str | None = None
|
|
369
|
+
elapsed_s: float | None = None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(frozen=True)
|
|
373
|
+
class DeviceEvent:
|
|
374
|
+
"""Carry a device event, stream frame, or unrecognised frame."""
|
|
375
|
+
|
|
376
|
+
spec: EventSpec | CommandSpec | None = None
|
|
377
|
+
request_id: str | None = None
|
|
378
|
+
data: Any = None
|
|
379
|
+
raw_frame: bytes | str = b""
|
|
380
|
+
timestamp: float = 0.0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# Results carry every value needed by a signal consumer without mutable state.
|
|
384
|
+
# pylint: disable=too-many-instance-attributes
|
|
385
|
+
@dataclass(frozen=True)
|
|
386
|
+
class CommandResult:
|
|
387
|
+
"""Carry one command's terminal status and parsed response."""
|
|
388
|
+
|
|
389
|
+
spec: CommandSpec | None = None
|
|
390
|
+
route: SendRoute = SendRoute.SPEC
|
|
391
|
+
params: Mapping[str, object] = field(default_factory=dict)
|
|
392
|
+
sent: bytes = b""
|
|
393
|
+
status: CommandStatus = CommandStatus.OK
|
|
394
|
+
data: Any = None
|
|
395
|
+
raw_frames: tuple[bytes | str, ...] = ()
|
|
396
|
+
elapsed_s: float = 0.0
|
|
397
|
+
request_id: str | None = None
|
|
398
|
+
error_message: str | None = None
|
|
399
|
+
|
|
400
|
+
def __post_init__(self) -> None:
|
|
401
|
+
"""Freeze mapping and frame containers at the public boundary."""
|
|
402
|
+
object.__setattr__(self, "params", MappingProxyType(dict(self.params)))
|
|
403
|
+
object.__setattr__(self, "raw_frames", tuple(self.raw_frames))
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def ok(self) -> bool:
|
|
407
|
+
"""Return whether the command completed successfully."""
|
|
408
|
+
return self.status is CommandStatus.OK
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
__all__ = [
|
|
412
|
+
"DeviceProfile",
|
|
413
|
+
"CommandSpec",
|
|
414
|
+
"EventSpec",
|
|
415
|
+
"LogConfig",
|
|
416
|
+
"CommandResult",
|
|
417
|
+
"DeviceEvent",
|
|
418
|
+
"DeviceInfo",
|
|
419
|
+
]
|
serialforge/protocols.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Protocols for replaceable serial backends.
|
|
2
|
+
|
|
3
|
+
Only the transport implementation imports pyserial. Higher layers depend on
|
|
4
|
+
these small protocols so tests can inject fake ports without changing facade
|
|
5
|
+
constructors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortBackend(Protocol):
|
|
15
|
+
"""Provide port enumeration and opening."""
|
|
16
|
+
|
|
17
|
+
def list_ports(self) -> Sequence[Any]:
|
|
18
|
+
"""Return available port information."""
|
|
19
|
+
|
|
20
|
+
def open(
|
|
21
|
+
self, port: str, params: Mapping[str, object]
|
|
22
|
+
) -> TransportProtocol:
|
|
23
|
+
"""Open a port using the supplied serial parameters."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TransportProtocol(Protocol):
|
|
27
|
+
"""Describe the operations required by the transport layer."""
|
|
28
|
+
|
|
29
|
+
def read(self, size: int, timeout_s: float) -> bytes:
|
|
30
|
+
"""Read up to ``size`` bytes."""
|
|
31
|
+
|
|
32
|
+
def write(self, data: bytes) -> int:
|
|
33
|
+
"""Write bytes and return the number accepted."""
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
"""Close the port."""
|
|
37
|
+
|
|
38
|
+
def cancel_read(self) -> None:
|
|
39
|
+
"""Interrupt a blocked read."""
|
|
40
|
+
|
|
41
|
+
def cancel_write(self) -> None:
|
|
42
|
+
"""Interrupt a blocked write."""
|
|
43
|
+
|
|
44
|
+
def reset_input_buffer(self) -> None:
|
|
45
|
+
"""Discard stale input after a command timeout."""
|
|
46
|
+
|
|
47
|
+
def set_buffer_size(self, rx_size: int) -> None:
|
|
48
|
+
"""Request a driver receive buffer size when supported."""
|
|
49
|
+
|
|
50
|
+
def set_control_lines(
|
|
51
|
+
self, *, dtr: bool | None = None, rts: bool | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Apply explicitly configured control line values."""
|
serialforge/py.typed
ADDED
|
File without changes
|
serialforge/settings.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Default values kept in one place for the public configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
LOG_ENABLED: bool = False
|
|
8
|
+
LOG_SAVE_TO_FILE: bool = False
|
|
9
|
+
LOG_DIR: Path | None = None
|
|
10
|
+
LOG_MAX_FILES: int = 50
|
|
11
|
+
LOG_MAX_FILE_BYTES: int = 20 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
DEFAULT_TERMINATOR: str = "\r\n"
|
|
14
|
+
DEFAULT_ENCODING: str = "utf-8"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Stable public namespace for no-hardware serial tests."""
|
|
2
|
+
|
|
3
|
+
from .fake_backend import FakeBackend, use_fake_backend
|
|
4
|
+
from .fake_transport import FakeTransport
|
|
5
|
+
from .simulated_device import SimulatedDevice
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FakeBackend",
|
|
9
|
+
"FakeTransport",
|
|
10
|
+
"SimulatedDevice",
|
|
11
|
+
"use_fake_backend",
|
|
12
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""In-memory port backend for tests and examples."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from ..protocols import TransportProtocol
|
|
9
|
+
from ..transport import BackendSwitch, PortRegistry
|
|
10
|
+
from .fake_transport import FakeTransport
|
|
11
|
+
from .simulated_device import SimulatedDevice
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeBackend:
|
|
15
|
+
"""Expose simulated devices through the port backend protocol."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, devices: Sequence[SimulatedDevice] = ()) -> None:
|
|
18
|
+
"""Create a backend with a stable device snapshot."""
|
|
19
|
+
self.devices = tuple(devices)
|
|
20
|
+
self.transports: list[FakeTransport] = []
|
|
21
|
+
|
|
22
|
+
def list_ports(self) -> Sequence[SimulatedDevice]:
|
|
23
|
+
"""Return descriptors matching pyserial's common attributes."""
|
|
24
|
+
return tuple(
|
|
25
|
+
item
|
|
26
|
+
for item in self.devices
|
|
27
|
+
if not PortRegistry.is_owned(item.port)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def open(
|
|
31
|
+
self, port: str, params: Mapping[str, object]
|
|
32
|
+
) -> TransportProtocol:
|
|
33
|
+
"""Open a simulated port and record its transport for assertions."""
|
|
34
|
+
device = next(
|
|
35
|
+
(item for item in self.devices if item.port == port), None
|
|
36
|
+
)
|
|
37
|
+
if device is None:
|
|
38
|
+
raise OSError(f"simulated port not found: {port}")
|
|
39
|
+
baudrate = params.get("baudrate")
|
|
40
|
+
transport = FakeTransport(
|
|
41
|
+
device, baudrate if isinstance(baudrate, int) else None
|
|
42
|
+
)
|
|
43
|
+
self.transports.append(transport)
|
|
44
|
+
return transport
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def use_fake_backend(backend: FakeBackend) -> Iterator[FakeBackend]:
|
|
49
|
+
"""Temporarily make ``backend`` the process-wide active backend."""
|
|
50
|
+
with BackendSwitch.use(backend):
|
|
51
|
+
yield backend
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["FakeBackend", "use_fake_backend"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""In-memory transport implementing the serial byte-stream contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import deque
|
|
7
|
+
from threading import Condition
|
|
8
|
+
|
|
9
|
+
from .simulated_device import SimulatedDevice
|
|
10
|
+
|
|
11
|
+
# The fake handle mirrors the real transport's independent control state.
|
|
12
|
+
# pylint: disable=too-many-instance-attributes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeTransport:
|
|
16
|
+
"""Connect a caller to one scriptable :class:`SimulatedDevice`."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, device: SimulatedDevice, baudrate: int | None = None
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Create a closed-in-memory connection for ``device``."""
|
|
22
|
+
self._device = device
|
|
23
|
+
self._baudrate: int | None = baudrate
|
|
24
|
+
self._incoming: deque[bytes] = deque()
|
|
25
|
+
self._condition = Condition()
|
|
26
|
+
self._closed = False
|
|
27
|
+
self._cancelled_read = False
|
|
28
|
+
self._cancelled_write = False
|
|
29
|
+
self._write_count = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def closed(self) -> bool:
|
|
33
|
+
"""Return whether the fake handle has been closed."""
|
|
34
|
+
return self._closed
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def write_count(self) -> int:
|
|
38
|
+
"""Return the number of writes accepted by this handle."""
|
|
39
|
+
return self._write_count
|
|
40
|
+
|
|
41
|
+
def read(self, size: int, timeout_s: float) -> bytes:
|
|
42
|
+
"""Read up to ``size`` bytes, returning empty bytes on timeout."""
|
|
43
|
+
if size <= 0:
|
|
44
|
+
raise ValueError("size must be positive")
|
|
45
|
+
deadline = time.monotonic() + max(0.0, timeout_s)
|
|
46
|
+
with self._condition:
|
|
47
|
+
while not self._incoming and not self._closed:
|
|
48
|
+
if self._cancelled_read:
|
|
49
|
+
self._cancelled_read = False
|
|
50
|
+
return b""
|
|
51
|
+
remaining = deadline - time.monotonic()
|
|
52
|
+
if remaining <= 0:
|
|
53
|
+
return b""
|
|
54
|
+
self._condition.wait(remaining)
|
|
55
|
+
if self._closed:
|
|
56
|
+
return b""
|
|
57
|
+
data = self._incoming.popleft()
|
|
58
|
+
if len(data) > size:
|
|
59
|
+
self._incoming.appendleft(data[size:])
|
|
60
|
+
return data[:size]
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
def write(self, data: bytes) -> int:
|
|
64
|
+
"""Accept bytes and enqueue the scripted response chunks."""
|
|
65
|
+
if self._closed:
|
|
66
|
+
raise OSError("fake serial port is closed")
|
|
67
|
+
if self._cancelled_write:
|
|
68
|
+
self._cancelled_write = False
|
|
69
|
+
return 0
|
|
70
|
+
self._write_count += 1
|
|
71
|
+
if (
|
|
72
|
+
self._device.disconnect_after_writes is not None
|
|
73
|
+
and self._write_count > self._device.disconnect_after_writes
|
|
74
|
+
):
|
|
75
|
+
self.close()
|
|
76
|
+
raise OSError("simulated disconnect")
|
|
77
|
+
if self._device.latency_s > 0:
|
|
78
|
+
time.sleep(self._device.latency_s)
|
|
79
|
+
chunks = (
|
|
80
|
+
self._device.response_for(bytes(data))
|
|
81
|
+
if self._device.baudrate is None
|
|
82
|
+
or self._device.baudrate == self._baudrate
|
|
83
|
+
else [b"\x00"]
|
|
84
|
+
)
|
|
85
|
+
with self._condition:
|
|
86
|
+
self._incoming.extend(chunks)
|
|
87
|
+
self._condition.notify_all()
|
|
88
|
+
return len(data)
|
|
89
|
+
|
|
90
|
+
def inject(self, data: bytes) -> None:
|
|
91
|
+
"""Inject an unsolicited device frame."""
|
|
92
|
+
with self._condition:
|
|
93
|
+
self._incoming.append(bytes(data))
|
|
94
|
+
self._condition.notify_all()
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""Close the fake handle and wake blocked readers."""
|
|
98
|
+
with self._condition:
|
|
99
|
+
self._closed = True
|
|
100
|
+
self._condition.notify_all()
|
|
101
|
+
|
|
102
|
+
def cancel_read(self) -> None:
|
|
103
|
+
"""Wake a blocked read without closing the handle."""
|
|
104
|
+
with self._condition:
|
|
105
|
+
self._cancelled_read = True
|
|
106
|
+
self._condition.notify_all()
|
|
107
|
+
|
|
108
|
+
def cancel_write(self) -> None:
|
|
109
|
+
"""Cancel the next write operation."""
|
|
110
|
+
self._cancelled_write = True
|
|
111
|
+
|
|
112
|
+
def reset_input_buffer(self) -> None:
|
|
113
|
+
"""Discard pending fake response chunks."""
|
|
114
|
+
with self._condition:
|
|
115
|
+
self._incoming.clear()
|
|
116
|
+
|
|
117
|
+
def set_buffer_size(self, rx_size: int) -> None:
|
|
118
|
+
"""Accept a simulated driver receive buffer request."""
|
|
119
|
+
del rx_size
|
|
120
|
+
|
|
121
|
+
def set_control_lines(
|
|
122
|
+
self, *, dtr: bool | None = None, rts: bool | None = None
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Accept explicit simulated control line settings."""
|
|
125
|
+
del dtr, rts
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["FakeTransport"]
|