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
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
+ ]
@@ -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
@@ -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"]