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.
- {pycyphal2-2.0.0.dev2/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev3}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/__init__.py +1 -1
- pycyphal2-2.0.0.dev3/src/pycyphal2/can/_media_slcan.py +199 -0
- pycyphal2-2.0.0.dev3/src/pycyphal2/can/webserial.py +287 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3/src/pycyphal2.egg-info}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/SOURCES.txt +2 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/README.md +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_api.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_hash.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_header.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_node.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_publisher.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_subscriber.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/_transport.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/__init__.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_interface.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_transport.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/_wire.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/pythoncan.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/can/socketcan.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2/udp.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_gossip.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_hash.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_header.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_integration.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_monitor.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_names.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_parity.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_parity_coverage.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_pubsub.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_reliable.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_reorder.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_rpc.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_scout.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev3}/tests/test_topic.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|