pycyphal2 2.0.0.dev2__tar.gz → 2.0.0.dev3__tar.gz

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 (44) hide show
  1. {pycyphal2-2.0.0.dev2/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev3}/PKG-INFO +1 -1
  2. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/__init__.py +1 -1
  3. pycyphal2-2.0.0.dev3/src/pycyphal2/can/_media_slcan.py +199 -0
  4. pycyphal2-2.0.0.dev3/src/pycyphal2/can/webserial.py +287 -0
  5. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3/src/pycyphal2.egg-info}/PKG-INFO +1 -1
  6. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/SOURCES.txt +2 -0
  7. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/LICENSE +0 -0
  8. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/README.md +0 -0
  9. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/pyproject.toml +0 -0
  10. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/setup.cfg +0 -0
  11. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_api.py +0 -0
  12. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_hash.py +0 -0
  13. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_header.py +0 -0
  14. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_node.py +0 -0
  15. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_publisher.py +0 -0
  16. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_subscriber.py +0 -0
  17. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_transport.py +0 -0
  18. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/__init__.py +0 -0
  19. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_interface.py +0 -0
  20. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_reassembly.py +0 -0
  21. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_transport.py +0 -0
  22. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_wire.py +0 -0
  23. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/pythoncan.py +0 -0
  24. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/socketcan.py +0 -0
  25. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/py.typed +0 -0
  26. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/udp.py +0 -0
  27. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
  28. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/requires.txt +0 -0
  29. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/top_level.txt +0 -0
  30. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_gossip.py +0 -0
  31. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_hash.py +0 -0
  32. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_header.py +0 -0
  33. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_integration.py +0 -0
  34. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_monitor.py +0 -0
  35. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_names.py +0 -0
  36. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_parity.py +0 -0
  37. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_parity_coverage.py +0 -0
  38. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_pubsub.py +0 -0
  39. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_reliable.py +0 -0
  40. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_reorder.py +0 -0
  41. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_rpc.py +0 -0
  42. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_scout.py +0 -0
  43. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_topic.py +0 -0
  44. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_udp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev2
3
+ Version: 2.0.0.dev3
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -155,7 +155,7 @@ from ._transport import SubjectWriter as SubjectWriter
155
155
  from ._transport import Transport as Transport
156
156
  from ._transport import TransportArrival as TransportArrival
157
157
 
158
- __version__ = "2.0.0.dev2"
158
+ __version__ = "2.0.0.dev3"
159
159
 
160
160
  # pdoc needs __all__ to display re-exported members.
161
161
  __all__ = [
@@ -0,0 +1,199 @@
1
+ """
2
+ SLCAN text protocol for CAN media (frame codec + adapter handshake).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ from ._interface import Frame
10
+ from ._wire import CAN_EXT_ID_MASK, DLC_TO_LENGTH, MTU_CAN_CLASSIC
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ _CAN_STD_ID_MASK = (1 << 11) - 1
15
+ _CR = 0x0D # ACK / carriage return
16
+ _LF = 0x0A
17
+ _BEL = 0x07 # NACK / bell
18
+ _MAX_LINE_LENGTH = 256
19
+ _STRIP_CHARS = b" \t\r\n\x07\x03"
20
+
21
+ _CMD_TERMINATOR = bytes([_CR])
22
+ _CMD_CLOSE = b"C"
23
+ _CMD_OPEN = b"O"
24
+ _CMD_SET_BITRATE_PREFIX = b"S"
25
+ _BITRATE_TO_SPEED_CODE = {
26
+ 1_000_000: 8,
27
+ 800_000: 7,
28
+ 500_000: 6,
29
+ 250_000: 5,
30
+ 125_000: 4,
31
+ 100_000: 3,
32
+ 50_000: 2,
33
+ 20_000: 1,
34
+ 10_000: 0,
35
+ }
36
+
37
+
38
+ def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes:
39
+ """
40
+ Encode an extended-ID Classic CAN data frame into one SLCAN ``T`` command line.
41
+ """
42
+ if not isinstance(identifier, int) or not (0 <= identifier <= CAN_EXT_ID_MASK):
43
+ raise ValueError(f"Invalid CAN identifier: {identifier!r}")
44
+ payload = bytes(data)
45
+ if len(payload) > MTU_CAN_CLASSIC:
46
+ raise ValueError(f"Invalid CAN data length: {len(payload)}")
47
+ return f"T{identifier:08X}{len(payload):1d}{payload.hex().upper()}\r".encode()
48
+
49
+
50
+ def encode_deinit() -> bytes:
51
+ """The close command line. Sent fire-and-forget before purging input to reset the adapter."""
52
+ return _CMD_CLOSE + _CMD_TERMINATOR
53
+
54
+
55
+ def encode_init_sequence(bitrate: int | None) -> list[bytes]:
56
+ """
57
+ Command lines to bring the adapter up after deinit+purge: optionally set the bitrate, then open.
58
+ If ``bitrate`` is None, the bitrate command is skipped, the old configured value (or adapter default) is kept.
59
+ A bitrate not in the standard speed-code table is sent as-is (some adapters accept raw bitrates, e.g. Zubax).
60
+ Each returned command line expects an ACK.
61
+ """
62
+ out: list[bytes] = []
63
+ if bitrate is not None:
64
+ code = _BITRATE_TO_SPEED_CODE.get(bitrate, bitrate)
65
+ out.append(_CMD_SET_BITRATE_PREFIX + str(code).encode("ascii") + _CMD_TERMINATOR)
66
+ out.append(_CMD_OPEN + _CMD_TERMINATOR)
67
+ return out
68
+
69
+
70
+ def classify_init_response(chunk: bytes) -> bool | None:
71
+ """Scan a chunk for the first ACK (True) or NACK (False); return None if neither appears."""
72
+ for byte in chunk:
73
+ if byte == _CR:
74
+ return True
75
+ if byte == _BEL:
76
+ return False
77
+ return None
78
+
79
+
80
+ class SLCANParser:
81
+ """
82
+ Incremental SLCAN parser.
83
+ Only data frames are returned. Unsupported or malformed input is silently dropped with debug logging.
84
+ Adapter-specific suffixes after the payload, such as timestamps or flags, are ignored.
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ self._buffer = bytearray()
89
+ self._discarding = False
90
+
91
+ def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
92
+ out: list[Frame] = []
93
+ for byte in bytes(chunk):
94
+ if byte == _BEL:
95
+ if self._buffer or self._discarding:
96
+ _logger.debug("SLCAN drop adapter error len=%d", len(self._buffer))
97
+ self._buffer.clear()
98
+ self._discarding = False
99
+ continue
100
+ if byte in (_CR, _LF):
101
+ if self._discarding:
102
+ self._buffer.clear()
103
+ self._discarding = False
104
+ continue
105
+ if self._buffer:
106
+ frame = _parse_line(bytes(self._buffer))
107
+ if frame is not None:
108
+ out.append(frame)
109
+ self._buffer.clear()
110
+ continue
111
+ if self._discarding:
112
+ continue
113
+ if len(self._buffer) >= _MAX_LINE_LENGTH:
114
+ _logger.debug("SLCAN drop overlong line len>%d", _MAX_LINE_LENGTH)
115
+ self._buffer.clear()
116
+ self._discarding = True
117
+ continue
118
+ self._buffer.append(byte)
119
+ return out
120
+
121
+
122
+ def _parse_line(line: bytes) -> Frame | None:
123
+ # Based on the original PyUAVCAN/PyDroneCAN implementation.
124
+ # Strips surrounding whitespace and control characters like BEL/ETX.
125
+ line = line.strip(_STRIP_CHARS)
126
+ if not line:
127
+ return None
128
+ command = line[:1]
129
+ if command in (b"T", b"x"):
130
+ return _parse_data_frame(line, id_length=8, max_payload_length=MTU_CAN_CLASSIC)
131
+ if command == b"t":
132
+ return _parse_data_frame(line, id_length=3, max_payload_length=MTU_CAN_CLASSIC)
133
+ if command == b"D":
134
+ return _parse_data_frame(line, id_length=8, max_payload_length=64)
135
+ if command in (b"r", b"R"):
136
+ _logger.debug("SLCAN drop unsupported frame type cmd=%r", command)
137
+ return None
138
+ _logger.debug("SLCAN drop unknown line=%r", line)
139
+ return None
140
+
141
+
142
+ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -> Frame | None:
143
+ header_length = 2 + id_length
144
+ if len(line) < header_length:
145
+ _logger.debug("SLCAN drop short data line=%r", line)
146
+ return None
147
+ identifier = _parse_hex_int(line[1 : 1 + id_length])
148
+ dlc = _parse_dlc(line[1 + id_length])
149
+ if identifier is None or dlc is None:
150
+ _logger.debug("SLCAN drop malformed data header line=%r", line)
151
+ return None
152
+ payload_length = DLC_TO_LENGTH[dlc] # _parse_dlc guarantees dlc in [0, 15]
153
+ if payload_length > max_payload_length:
154
+ _logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
155
+ return None
156
+ expected = header_length + payload_length * 2
157
+ if len(line) < expected:
158
+ _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
159
+ return None
160
+ if id_length == 3 and identifier > _CAN_STD_ID_MASK:
161
+ _logger.debug("SLCAN drop invalid standard id=%x", identifier)
162
+ return None
163
+ data = _parse_hex_bytes(line[header_length:expected])
164
+ if data is None:
165
+ _logger.debug("SLCAN drop malformed data id=%08x", identifier)
166
+ return None
167
+ try:
168
+ return Frame(id=identifier, data=data)
169
+ except ValueError as ex:
170
+ _logger.debug("SLCAN drop invalid frame: %s", ex)
171
+ return None
172
+
173
+
174
+ def _parse_hex_int(value: bytes) -> int | None:
175
+ if not value:
176
+ return None
177
+ try:
178
+ return int(value, 16)
179
+ except ValueError:
180
+ return None
181
+
182
+
183
+ def _parse_hex_bytes(value: bytes) -> bytes | None:
184
+ if len(value) % 2 != 0:
185
+ return None
186
+ try:
187
+ return bytes.fromhex(value.decode("ascii"))
188
+ except (UnicodeDecodeError, ValueError):
189
+ return None
190
+
191
+
192
+ def _parse_dlc(value: int) -> int | None:
193
+ if ord("0") <= value <= ord("9"):
194
+ return value - ord("0")
195
+ if ord("A") <= value <= ord("F"):
196
+ return 10 + value - ord("A")
197
+ if ord("a") <= value <= ord("f"):
198
+ return 10 + value - ord("a")
199
+ return None
@@ -0,0 +1,287 @@
1
+ """
2
+ Browser-oriented SLCAN backend for WebSerial/Pyodide.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Iterable
11
+
12
+ from .._api import ClosedError, Instant
13
+ from ._interface import Filter, Interface, TimestampedFrame
14
+ from ._media_slcan import (
15
+ SLCANParser,
16
+ classify_init_response,
17
+ encode_deinit,
18
+ encode_frame,
19
+ encode_init_sequence,
20
+ )
21
+
22
+ _logger = logging.getLogger(__name__)
23
+
24
+ _ACK_TIMEOUT = 3.0
25
+ _DEINIT_SETTLE = 0.1
26
+ _PURGE_DRAIN_TIMEOUT = 0.05
27
+
28
+
29
+ class AsyncSerialPort(ABC):
30
+ """Minimal async byte stream expected from a WebSerial adapter."""
31
+
32
+ @abstractmethod
33
+ async def read(self) -> bytes:
34
+ raise NotImplementedError
35
+
36
+ @abstractmethod
37
+ async def write(self, data: bytes) -> None:
38
+ raise NotImplementedError
39
+
40
+ @abstractmethod
41
+ async def close(self) -> None:
42
+ raise NotImplementedError
43
+
44
+
45
+ class WebSerialSLCANInterface(Interface):
46
+ """
47
+ SLCAN CAN interface over an application-provided async serial byte stream.
48
+
49
+ The port is expected to be already opened by browser/Pyodide glue code.
50
+ On startup the adapter is reset: closed, left to settle, its pending input purged (so stale frames
51
+ from a previous configuration are dropped), then configured for the selected bitrate and reopened.
52
+ If no bitrate is given, the bitrate is left unconfigured (old/default).
53
+ """
54
+
55
+ def __init__(self, port: AsyncSerialPort, *, name: str = "webserial", bitrate: int | None = None) -> None:
56
+ self._port = port
57
+ self._name = str(name)
58
+ self._bitrate = None if bitrate is None else int(bitrate)
59
+ self._closed = False
60
+ self._failure: BaseException | None = None
61
+ self._parser = SLCANParser()
62
+ self._tx_seq = 0
63
+ self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
64
+ self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue()
65
+ self._init_task: asyncio.Task[None] | None = None
66
+ self._tx_task: asyncio.Task[None] | None = None
67
+ self._rx_task: asyncio.Task[None] | None = None
68
+ self._close_task: asyncio.Task[None] | None = None
69
+ self._start_init() # Requires a running loop; this is an async interface, always built in one.
70
+ _logger.info("WebSerial SLCAN init iface=%s bitrate=%s", self._name, self._bitrate)
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ return self._name
75
+
76
+ @property
77
+ def fd(self) -> bool:
78
+ return False
79
+
80
+ def filter(self, filters: Iterable[Filter]) -> None:
81
+ del filters
82
+ self._raise_if_closed()
83
+ # No-op: WebSerial adapters do not provide hardware acceptance filtering.
84
+
85
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
86
+ self._raise_if_closed()
87
+ chunks = tuple(bytes(item) for item in data)
88
+ for chunk in chunks:
89
+ encode_frame(id, chunk) # Validate before mutating the queue.
90
+ if self._tx_task is None:
91
+ self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop())
92
+ for chunk in chunks:
93
+ self._tx_seq += 1
94
+ self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, chunk))
95
+
96
+ def purge(self) -> None:
97
+ if self._closed:
98
+ return
99
+ dropped = 0
100
+ try:
101
+ while True:
102
+ self._tx_queue.get_nowait()
103
+ dropped += 1
104
+ except asyncio.QueueEmpty:
105
+ pass
106
+ if dropped > 0:
107
+ _logger.debug("WebSerial SLCAN purge iface=%s dropped=%d", self._name, dropped)
108
+
109
+ async def receive(self) -> TimestampedFrame:
110
+ self._raise_if_closed()
111
+ if self._rx_task is None:
112
+ self._rx_task = asyncio.get_running_loop().create_task(self._rx_loop())
113
+ item = await self._rx_queue.get()
114
+ if isinstance(item, BaseException):
115
+ if isinstance(item, ClosedError):
116
+ raise item
117
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} receive failed") from item
118
+ return item
119
+
120
+ def close(self) -> None:
121
+ self._close(ClosedError(f"WebSerial SLCAN interface {self._name} closed"))
122
+
123
+ def __repr__(self) -> str:
124
+ return f"{type(self).__name__}({self._name!r}, fd={self.fd})"
125
+
126
+ async def _tx_loop(self) -> None:
127
+ try:
128
+ await self._ensure_initialized()
129
+ except Exception as ex:
130
+ self._fail(ex)
131
+ return
132
+ while not self._closed:
133
+ identifier, seq, deadline_ns, payload = await self._tx_queue.get()
134
+ if self._closed:
135
+ return
136
+ timeout = (deadline_ns - Instant.now().ns) * 1e-9
137
+ if timeout <= 0.0:
138
+ _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier)
139
+ continue
140
+ try:
141
+ await asyncio.wait_for(self._port.write(encode_frame(identifier, payload)), timeout=timeout)
142
+ except asyncio.TimeoutError:
143
+ self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
144
+ await asyncio.sleep(0.001)
145
+ except Exception as ex:
146
+ self._fail(ex)
147
+ return
148
+
149
+ async def _rx_loop(self) -> None:
150
+ try:
151
+ await self._ensure_initialized()
152
+ except Exception as ex:
153
+ self._fail(ex)
154
+ return
155
+ while not self._closed:
156
+ try:
157
+ chunk = await self._port.read()
158
+ except Exception as ex:
159
+ self._fail(ex)
160
+ return
161
+ if not chunk:
162
+ self._fail(EOFError(f"WebSerial SLCAN interface {self._name} ended"))
163
+ return
164
+ for frame in self._parser.feed(chunk):
165
+ self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
166
+
167
+ def _start_init(self) -> None:
168
+ if self._init_task is None:
169
+ self._init_task = asyncio.get_running_loop().create_task(self._init_adapter())
170
+ self._init_task.add_done_callback(self._on_init_done)
171
+
172
+ def _on_init_done(self, task: asyncio.Task[None]) -> None:
173
+ if task.cancelled():
174
+ return
175
+ try:
176
+ task.result()
177
+ except Exception as ex:
178
+ if not self._closed:
179
+ self._fail(ex)
180
+
181
+ async def _ensure_initialized(self) -> None:
182
+ self._raise_if_closed()
183
+ self._start_init()
184
+ assert self._init_task is not None
185
+ await asyncio.shield(self._init_task)
186
+ self._raise_if_closed()
187
+
188
+ async def _init_adapter(self) -> None:
189
+ _logger.info("WebSerial SLCAN setup iface=%s bitrate=%s", self._name, self._bitrate)
190
+ # Reset an adapter that may be in an unknown state: close, settle, discard whatever it was
191
+ # forwarding under the old config, then configure and open.
192
+ await self._port.write(encode_deinit())
193
+ await asyncio.sleep(_DEINIT_SETTLE)
194
+ await self._purge_input()
195
+ for command in encode_init_sequence(self._bitrate):
196
+ _logger.debug("WebSerial SLCAN setup cmd iface=%s cmd=%r", self._name, command)
197
+ await self._port.write(command)
198
+ await self._wait_for_init_ack()
199
+ _logger.info("WebSerial SLCAN setup done iface=%s", self._name)
200
+
201
+ async def _purge_input(self) -> None:
202
+ dropped = 0
203
+ while True:
204
+ try:
205
+ chunk = await asyncio.wait_for(self._port.read(), timeout=_PURGE_DRAIN_TIMEOUT)
206
+ except asyncio.TimeoutError:
207
+ break
208
+ if not chunk:
209
+ raise EOFError("SLCAN channel ended while purging input")
210
+ dropped += len(chunk)
211
+ if dropped > 0:
212
+ _logger.debug("WebSerial SLCAN purge stale input iface=%s dropped=%d", self._name, dropped)
213
+
214
+ async def _wait_for_init_ack(self) -> None:
215
+ loop = asyncio.get_running_loop()
216
+ deadline = loop.time() + _ACK_TIMEOUT
217
+ while True:
218
+ timeout = deadline - loop.time()
219
+ if timeout <= 0.0:
220
+ raise TimeoutError("SLCAN ACK timeout")
221
+ chunk = await asyncio.wait_for(self._port.read(), timeout=timeout)
222
+ if not chunk:
223
+ raise EOFError("SLCAN channel ended while waiting for ACK")
224
+ response = classify_init_response(chunk)
225
+ if response is True:
226
+ return
227
+ if response is False:
228
+ raise OSError("SLCAN NACK in response")
229
+ _logger.debug("WebSerial SLCAN setup ignored bytes iface=%s len=%d", self._name, len(chunk))
230
+
231
+ def _fail(self, ex: BaseException) -> None:
232
+ if self._failure is None:
233
+ self._failure = ex
234
+ _logger.error("WebSerial SLCAN interface %s failed: %s", self._name, ex)
235
+ self._close(ex)
236
+
237
+ def _close(self, unblock: BaseException) -> None:
238
+ if self._closed:
239
+ return
240
+ self._closed = True
241
+ self._cancel_worker_tasks()
242
+ self._drain_rx_queue()
243
+ self._rx_queue.put_nowait(unblock)
244
+ self._close_port()
245
+
246
+ def _cancel_worker_tasks(self) -> None:
247
+ current: asyncio.Task[object] | None
248
+ try:
249
+ current = asyncio.current_task()
250
+ except RuntimeError:
251
+ current = None
252
+ for task in (self._init_task, self._tx_task, self._rx_task):
253
+ if task is not None and task is not current:
254
+ task.cancel()
255
+ self._init_task = None
256
+ self._tx_task = None
257
+ self._rx_task = None
258
+
259
+ def _close_port(self) -> None:
260
+ try:
261
+ loop = asyncio.get_running_loop()
262
+ except RuntimeError:
263
+ try:
264
+ asyncio.run(self._close_port_async())
265
+ except Exception as ex:
266
+ _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex)
267
+ return
268
+ self._close_task = loop.create_task(self._close_port_async())
269
+
270
+ async def _close_port_async(self) -> None:
271
+ try:
272
+ await self._port.close()
273
+ except Exception as ex:
274
+ _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex)
275
+
276
+ def _raise_if_closed(self) -> None:
277
+ if self._closed:
278
+ if self._failure is not None:
279
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} failed") from self._failure
280
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} closed")
281
+
282
+ def _drain_rx_queue(self) -> None:
283
+ try:
284
+ while True:
285
+ self._rx_queue.get_nowait()
286
+ except asyncio.QueueEmpty:
287
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev2
3
+ Version: 2.0.0.dev3
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -18,11 +18,13 @@ src/pycyphal2.egg-info/requires.txt
18
18
  src/pycyphal2.egg-info/top_level.txt
19
19
  src/pycyphal2/can/__init__.py
20
20
  src/pycyphal2/can/_interface.py
21
+ src/pycyphal2/can/_media_slcan.py
21
22
  src/pycyphal2/can/_reassembly.py
22
23
  src/pycyphal2/can/_transport.py
23
24
  src/pycyphal2/can/_wire.py
24
25
  src/pycyphal2/can/pythoncan.py
25
26
  src/pycyphal2/can/socketcan.py
27
+ src/pycyphal2/can/webserial.py
26
28
  tests/test_gossip.py
27
29
  tests/test_hash.py
28
30
  tests/test_header.py
File without changes
File without changes
File without changes