pycyphal2 2.0.0.dev0__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.
- pycyphal2/__init__.py +89 -0
- pycyphal2/_api.py +604 -0
- pycyphal2/_hash.py +204 -0
- pycyphal2/_header.py +349 -0
- pycyphal2/_node.py +1472 -0
- pycyphal2/_publisher.py +427 -0
- pycyphal2/_subscriber.py +430 -0
- pycyphal2/_transport.py +92 -0
- pycyphal2/can/__init__.py +43 -0
- pycyphal2/can/_interface.py +131 -0
- pycyphal2/can/_reassembly.py +158 -0
- pycyphal2/can/_transport.py +525 -0
- pycyphal2/can/_wire.py +376 -0
- pycyphal2/can/pythoncan.py +261 -0
- pycyphal2/can/socketcan.py +225 -0
- pycyphal2/py.typed +0 -0
- pycyphal2/udp.py +1000 -0
- pycyphal2-2.0.0.dev0.dist-info/METADATA +58 -0
- pycyphal2-2.0.0.dev0.dist-info/RECORD +22 -0
- pycyphal2-2.0.0.dev0.dist-info/WHEEL +5 -0
- pycyphal2-2.0.0.dev0.dist-info/licenses/LICENSE +20 -0
- pycyphal2-2.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .._api import Instant, Priority
|
|
8
|
+
from ._wire import (
|
|
9
|
+
NODE_ID_ANONYMOUS,
|
|
10
|
+
PRIORITY_COUNT,
|
|
11
|
+
RX_SESSION_RETENTION_NS,
|
|
12
|
+
TRANSFER_ID_TIMEOUT_NS,
|
|
13
|
+
ParsedFrame,
|
|
14
|
+
TransferKind,
|
|
15
|
+
crc_add,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RxSlot:
|
|
23
|
+
start_ts_ns: int
|
|
24
|
+
transfer_id: int
|
|
25
|
+
iface_index: int
|
|
26
|
+
expected_toggle: bool
|
|
27
|
+
crc: int = 0xFFFF
|
|
28
|
+
data: bytearray = field(default_factory=bytearray)
|
|
29
|
+
|
|
30
|
+
def accept(self, payload: bytes) -> None:
|
|
31
|
+
self.data.extend(payload)
|
|
32
|
+
self.crc = crc_add(self.crc, payload)
|
|
33
|
+
self.expected_toggle = not self.expected_toggle
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RxSession:
|
|
38
|
+
last_admission_ts_ns: int
|
|
39
|
+
last_admitted_transfer_id: int
|
|
40
|
+
last_admitted_priority: int
|
|
41
|
+
iface_index: int
|
|
42
|
+
slots: list[RxSlot | None]
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def new(iface_index: int) -> RxSession:
|
|
46
|
+
return RxSession(
|
|
47
|
+
last_admission_ts_ns=-(1 << 62),
|
|
48
|
+
last_admitted_transfer_id=0,
|
|
49
|
+
last_admitted_priority=0,
|
|
50
|
+
iface_index=iface_index,
|
|
51
|
+
slots=[None] * PRIORITY_COUNT,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Endpoint:
|
|
57
|
+
kind: TransferKind
|
|
58
|
+
port_id: int
|
|
59
|
+
on_transfer: Callable[[Instant, int, Priority, bytes], None]
|
|
60
|
+
sessions: dict[int, RxSession] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Reassembler:
|
|
64
|
+
@staticmethod
|
|
65
|
+
def cleanup_sessions(endpoints: Iterable[Endpoint], now_ns: int) -> None:
|
|
66
|
+
stale_deadline = now_ns - RX_SESSION_RETENTION_NS
|
|
67
|
+
for endpoint in endpoints:
|
|
68
|
+
for source_id, session in list(endpoint.sessions.items()):
|
|
69
|
+
for priority, slot in enumerate(session.slots):
|
|
70
|
+
if slot is not None and slot.start_ts_ns < stale_deadline:
|
|
71
|
+
session.slots[priority] = None
|
|
72
|
+
if all(slot is None for slot in session.slots) and session.last_admission_ts_ns < stale_deadline:
|
|
73
|
+
endpoint.sessions.pop(source_id, None)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def ingest(endpoint: Endpoint, iface_index: int, timestamp: Instant, parsed: ParsedFrame) -> None:
|
|
77
|
+
if parsed.source_id == NODE_ID_ANONYMOUS:
|
|
78
|
+
if parsed.start_of_transfer and parsed.end_of_transfer:
|
|
79
|
+
endpoint.on_transfer(timestamp, parsed.source_id, Priority(parsed.priority), parsed.payload)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
session = endpoint.sessions.get(parsed.source_id)
|
|
83
|
+
if session is None:
|
|
84
|
+
if not parsed.start_of_transfer:
|
|
85
|
+
return
|
|
86
|
+
session = RxSession.new(iface_index)
|
|
87
|
+
endpoint.sessions[parsed.source_id] = session
|
|
88
|
+
if not Reassembler._solve_admission(
|
|
89
|
+
session,
|
|
90
|
+
timestamp.ns,
|
|
91
|
+
parsed.priority,
|
|
92
|
+
parsed.start_of_transfer,
|
|
93
|
+
parsed.toggle,
|
|
94
|
+
parsed.transfer_id,
|
|
95
|
+
iface_index,
|
|
96
|
+
):
|
|
97
|
+
return
|
|
98
|
+
if parsed.start_of_transfer:
|
|
99
|
+
if session.slots[parsed.priority] is not None:
|
|
100
|
+
session.slots[parsed.priority] = None
|
|
101
|
+
if not parsed.end_of_transfer:
|
|
102
|
+
Reassembler._cleanup_session_slots(session, timestamp.ns)
|
|
103
|
+
session.slots[parsed.priority] = RxSlot(
|
|
104
|
+
start_ts_ns=timestamp.ns,
|
|
105
|
+
transfer_id=parsed.transfer_id,
|
|
106
|
+
iface_index=iface_index,
|
|
107
|
+
expected_toggle=parsed.toggle,
|
|
108
|
+
)
|
|
109
|
+
session.last_admission_ts_ns = timestamp.ns
|
|
110
|
+
session.last_admitted_transfer_id = parsed.transfer_id
|
|
111
|
+
session.last_admitted_priority = parsed.priority
|
|
112
|
+
session.iface_index = iface_index
|
|
113
|
+
|
|
114
|
+
slot = session.slots[parsed.priority]
|
|
115
|
+
if slot is None:
|
|
116
|
+
endpoint.on_transfer(timestamp, parsed.source_id, Priority(parsed.priority), parsed.payload)
|
|
117
|
+
return
|
|
118
|
+
slot.accept(parsed.payload)
|
|
119
|
+
if parsed.end_of_transfer:
|
|
120
|
+
session.slots[parsed.priority] = None
|
|
121
|
+
if len(slot.data) >= 2 and slot.crc == 0:
|
|
122
|
+
endpoint.on_transfer(
|
|
123
|
+
Instant(ns=slot.start_ts_ns), parsed.source_id, Priority(parsed.priority), bytes(slot.data[:-2])
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
_logger.debug(
|
|
127
|
+
"CAN drop bad CRC kind=%s port=%d src=%d", endpoint.kind.name, endpoint.port_id, parsed.source_id
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _cleanup_session_slots(session: RxSession, now_ns: int) -> None:
|
|
132
|
+
deadline = now_ns - RX_SESSION_RETENTION_NS
|
|
133
|
+
for priority, slot in enumerate(session.slots):
|
|
134
|
+
if slot is not None and slot.start_ts_ns < deadline:
|
|
135
|
+
session.slots[priority] = None
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _solve_admission(
|
|
139
|
+
session: RxSession,
|
|
140
|
+
timestamp_ns: int,
|
|
141
|
+
priority: int,
|
|
142
|
+
start_of_transfer: bool,
|
|
143
|
+
toggle: bool,
|
|
144
|
+
transfer_id: int,
|
|
145
|
+
iface_index: int,
|
|
146
|
+
) -> bool:
|
|
147
|
+
if not start_of_transfer:
|
|
148
|
+
slot = session.slots[priority]
|
|
149
|
+
return (
|
|
150
|
+
slot is not None
|
|
151
|
+
and slot.transfer_id == transfer_id
|
|
152
|
+
and slot.iface_index == iface_index
|
|
153
|
+
and slot.expected_toggle == toggle
|
|
154
|
+
)
|
|
155
|
+
fresh = (transfer_id != session.last_admitted_transfer_id) or (priority != session.last_admitted_priority)
|
|
156
|
+
affine = session.iface_index == iface_index
|
|
157
|
+
stale = (timestamp_ns - TRANSFER_ID_TIMEOUT_NS) > session.last_admission_ts_ns
|
|
158
|
+
return (fresh and affine) or (affine and stale) or (stale and fresh)
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
|
|
11
|
+
from .._api import ClosedError, Closable, Instant, Priority, SUBJECT_ID_PINNED_MAX, SendError
|
|
12
|
+
from .._hash import rapidhash
|
|
13
|
+
from .._header import HEADER_SIZE
|
|
14
|
+
from .._transport import SUBJECT_ID_MODULUS_16bit, SubjectWriter, Transport, TransportArrival
|
|
15
|
+
from ._interface import Filter, Interface, TimestampedFrame
|
|
16
|
+
from ._reassembly import Endpoint, Reassembler
|
|
17
|
+
from ._wire import (
|
|
18
|
+
MTU_CAN_CLASSIC,
|
|
19
|
+
MTU_CAN_FD,
|
|
20
|
+
NODE_ID_ANONYMOUS,
|
|
21
|
+
NODE_ID_CAPACITY,
|
|
22
|
+
NODE_ID_MAX,
|
|
23
|
+
SUBJECT_ID_MAX_16,
|
|
24
|
+
TRANSFER_ID_MODULO,
|
|
25
|
+
ParsedFrame,
|
|
26
|
+
TransferKind,
|
|
27
|
+
UNICAST_SERVICE_ID,
|
|
28
|
+
ensure_forced_filters,
|
|
29
|
+
make_filter,
|
|
30
|
+
pack_u32_le,
|
|
31
|
+
pack_u64_le,
|
|
32
|
+
parse_frames,
|
|
33
|
+
serialize_transfer,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _PinnedSubjectState:
|
|
41
|
+
subject_id: int
|
|
42
|
+
header_prefix: bytes
|
|
43
|
+
next_tag: int = 0
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def new(subject_id: int) -> _PinnedSubjectState:
|
|
47
|
+
buf = bytearray(HEADER_SIZE)
|
|
48
|
+
buf[3] = 0xFF
|
|
49
|
+
buf[4:8] = pack_u32_le(0xFFFFFFFF - subject_id)
|
|
50
|
+
buf[8:16] = pack_u64_le(rapidhash(str(subject_id)))
|
|
51
|
+
return _PinnedSubjectState(subject_id=subject_id, header_prefix=bytes(buf[:16]))
|
|
52
|
+
|
|
53
|
+
def wrap(self, payload: bytes) -> bytes:
|
|
54
|
+
self.next_tag += 1
|
|
55
|
+
return self.header_prefix + pack_u64_le(self.next_tag) + payload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CANTransport(Transport, ABC):
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def id(self) -> int:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def interfaces(self) -> list[Interface]:
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def closed(self) -> bool:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def collision_count(self) -> int:
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def new(interfaces: Iterable[Interface] | Interface) -> CANTransport:
|
|
81
|
+
if isinstance(interfaces, Interface):
|
|
82
|
+
items = [interfaces]
|
|
83
|
+
else:
|
|
84
|
+
items = list(interfaces)
|
|
85
|
+
if not items or not all(isinstance(itf, Interface) for itf in items):
|
|
86
|
+
raise ValueError("interfaces must contain at least one Interface instance")
|
|
87
|
+
return _CANTransportImpl(items)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _SubjectWriter(SubjectWriter):
|
|
91
|
+
def __init__(self, transport: _CANTransportImpl, subject_id: int) -> None:
|
|
92
|
+
self._transport = transport
|
|
93
|
+
self._subject_id = subject_id
|
|
94
|
+
self._closed = False
|
|
95
|
+
self._next_tid_13 = 0
|
|
96
|
+
self._next_tid_16 = 0
|
|
97
|
+
|
|
98
|
+
async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
|
|
99
|
+
if self._closed:
|
|
100
|
+
raise ClosedError("CAN subject writer closed")
|
|
101
|
+
if self._transport.closed:
|
|
102
|
+
raise ClosedError("CAN transport closed")
|
|
103
|
+
data = bytes(message)
|
|
104
|
+
pinned = self._subject_id <= SUBJECT_ID_PINNED_MAX
|
|
105
|
+
best_effort = len(data) >= HEADER_SIZE and data[0] == 0
|
|
106
|
+
use_13b = pinned and best_effort
|
|
107
|
+
if use_13b:
|
|
108
|
+
transfer_id = self._next_tid_13
|
|
109
|
+
self._next_tid_13 = (transfer_id + 1) % TRANSFER_ID_MODULO
|
|
110
|
+
payload = data[HEADER_SIZE:]
|
|
111
|
+
kind = TransferKind.MESSAGE_13
|
|
112
|
+
else:
|
|
113
|
+
transfer_id = self._next_tid_16
|
|
114
|
+
self._next_tid_16 = (transfer_id + 1) % TRANSFER_ID_MODULO
|
|
115
|
+
payload = data
|
|
116
|
+
kind = TransferKind.MESSAGE_16
|
|
117
|
+
await self._transport.send_transfer(
|
|
118
|
+
deadline=deadline,
|
|
119
|
+
priority=priority,
|
|
120
|
+
kind=kind,
|
|
121
|
+
port_id=self._subject_id,
|
|
122
|
+
payload=payload,
|
|
123
|
+
transfer_id=transfer_id,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
if self._closed:
|
|
128
|
+
return
|
|
129
|
+
self._closed = True
|
|
130
|
+
self._transport.remove_subject_writer(self._subject_id, self)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _SubjectListener(Closable):
|
|
134
|
+
def __init__(
|
|
135
|
+
self, transport: _CANTransportImpl, subject_id: int, handler: Callable[[TransportArrival], None]
|
|
136
|
+
) -> None:
|
|
137
|
+
self._transport = transport
|
|
138
|
+
self._subject_id = subject_id
|
|
139
|
+
self._handler = handler
|
|
140
|
+
self._closed = False
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if self._closed:
|
|
144
|
+
return
|
|
145
|
+
self._closed = True
|
|
146
|
+
self._transport.remove_subject_listener(self._subject_id, self._handler)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _CANTransportImpl(CANTransport):
|
|
150
|
+
def __init__(self, interfaces: Iterable[Interface]) -> None:
|
|
151
|
+
self._loop = asyncio.get_running_loop()
|
|
152
|
+
self._closed = False
|
|
153
|
+
self._interfaces = list(interfaces)
|
|
154
|
+
if not self._interfaces:
|
|
155
|
+
raise ValueError("At least one CAN interface is required")
|
|
156
|
+
if len({itf.fd for itf in self._interfaces}) > 1:
|
|
157
|
+
raise ValueError("Mixed Classic-CAN and CAN FD interface sets are not supported")
|
|
158
|
+
|
|
159
|
+
self._fd = self._interfaces[0].fd
|
|
160
|
+
self._interface_index = {id(itf): i for i, itf in enumerate(self._interfaces)}
|
|
161
|
+
self._reader_tasks: dict[int, asyncio.Task[None]] = {}
|
|
162
|
+
self._filter_dirty: set[Interface] = set(self._interfaces)
|
|
163
|
+
self._filter_retry_event = asyncio.Event()
|
|
164
|
+
self._filter_failures: dict[Interface, int] = {}
|
|
165
|
+
self._rng = random.Random(int.from_bytes(os.urandom(8), "little"))
|
|
166
|
+
self._node_id_occupancy = 1
|
|
167
|
+
self._local_node_id = self._rng.randrange(1, NODE_ID_CAPACITY)
|
|
168
|
+
self._collision_count = 0
|
|
169
|
+
self._subject_handlers: dict[int, Callable[[TransportArrival], None]] = {}
|
|
170
|
+
self._subject_writers: dict[int, _SubjectWriter] = {}
|
|
171
|
+
self._pinned_subjects: dict[int, _PinnedSubjectState] = {}
|
|
172
|
+
self._endpoints: dict[tuple[TransferKind, int], Endpoint] = {}
|
|
173
|
+
self._unicast_handler: Callable[[TransportArrival], None] | None = None
|
|
174
|
+
self._unicast_tid = [0] * NODE_ID_CAPACITY
|
|
175
|
+
self._filter_retry_task = self._loop.create_task(self._filter_retry_loop())
|
|
176
|
+
self._cleanup_task = self._loop.create_task(self._cleanup_loop())
|
|
177
|
+
|
|
178
|
+
self._install_unicast_endpoint()
|
|
179
|
+
for itf in self._interfaces:
|
|
180
|
+
self._reader_tasks[id(itf)] = self._loop.create_task(self._reader_loop(itf))
|
|
181
|
+
self._refresh_filters()
|
|
182
|
+
_logger.info(
|
|
183
|
+
"CAN transport init ifaces=%s fd=%s nid=%d", [itf.name for itf in self._interfaces], self._fd, self.id
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def closed(self) -> bool:
|
|
188
|
+
return self._closed
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def id(self) -> int:
|
|
192
|
+
return self._local_node_id
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def interfaces(self) -> list[Interface]:
|
|
196
|
+
return list(self._interfaces)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def collision_count(self) -> int:
|
|
200
|
+
return self._collision_count
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def subject_id_modulus(self) -> int:
|
|
204
|
+
return SUBJECT_ID_MODULUS_16bit
|
|
205
|
+
|
|
206
|
+
def __repr__(self) -> str:
|
|
207
|
+
return f"CANTransport(id={self.id}, fd={self._fd}, interfaces={[itf.name for itf in self._interfaces]!r})"
|
|
208
|
+
|
|
209
|
+
def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> Closable:
|
|
210
|
+
if not (0 <= subject_id <= SUBJECT_ID_MAX_16):
|
|
211
|
+
raise ValueError(f"Invalid subject-ID: {subject_id}")
|
|
212
|
+
if subject_id in self._subject_handlers:
|
|
213
|
+
raise ValueError(f"Subject {subject_id} already has an active listener")
|
|
214
|
+
self._subject_handlers[subject_id] = handler
|
|
215
|
+
|
|
216
|
+
def on_transfer_16(timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
|
|
217
|
+
handler(TransportArrival(timestamp, priority, remote_id, payload))
|
|
218
|
+
|
|
219
|
+
self._endpoints[(TransferKind.MESSAGE_16, subject_id)] = Endpoint(
|
|
220
|
+
kind=TransferKind.MESSAGE_16,
|
|
221
|
+
port_id=subject_id,
|
|
222
|
+
on_transfer=on_transfer_16,
|
|
223
|
+
)
|
|
224
|
+
if subject_id <= SUBJECT_ID_PINNED_MAX:
|
|
225
|
+
pinned = self._pinned_subjects.setdefault(subject_id, _PinnedSubjectState.new(subject_id))
|
|
226
|
+
|
|
227
|
+
def on_transfer_13(timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
|
|
228
|
+
handler(TransportArrival(timestamp, priority, remote_id, pinned.wrap(payload)))
|
|
229
|
+
|
|
230
|
+
self._endpoints[(TransferKind.MESSAGE_13, subject_id)] = Endpoint(
|
|
231
|
+
kind=TransferKind.MESSAGE_13,
|
|
232
|
+
port_id=subject_id,
|
|
233
|
+
on_transfer=on_transfer_13,
|
|
234
|
+
)
|
|
235
|
+
self._refresh_filters()
|
|
236
|
+
return _SubjectListener(self, subject_id, handler)
|
|
237
|
+
|
|
238
|
+
def subject_advertise(self, subject_id: int) -> SubjectWriter:
|
|
239
|
+
if not (0 <= subject_id <= SUBJECT_ID_MAX_16):
|
|
240
|
+
raise ValueError(f"Invalid subject-ID: {subject_id}")
|
|
241
|
+
if subject_id in self._subject_writers:
|
|
242
|
+
raise ValueError(f"Subject {subject_id} already has an active writer")
|
|
243
|
+
writer = _SubjectWriter(self, subject_id)
|
|
244
|
+
self._subject_writers[subject_id] = writer
|
|
245
|
+
return writer
|
|
246
|
+
|
|
247
|
+
def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None:
|
|
248
|
+
self._unicast_handler = handler
|
|
249
|
+
|
|
250
|
+
async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None:
|
|
251
|
+
if self._closed:
|
|
252
|
+
raise ClosedError("CAN transport closed")
|
|
253
|
+
if not (1 <= remote_id <= NODE_ID_MAX):
|
|
254
|
+
raise ValueError(f"Invalid remote node-ID: {remote_id}")
|
|
255
|
+
transfer_id = self._unicast_tid[remote_id]
|
|
256
|
+
self._unicast_tid[remote_id] = (transfer_id + 1) % TRANSFER_ID_MODULO
|
|
257
|
+
await self.send_transfer(
|
|
258
|
+
deadline=deadline,
|
|
259
|
+
priority=priority,
|
|
260
|
+
kind=TransferKind.REQUEST,
|
|
261
|
+
port_id=UNICAST_SERVICE_ID,
|
|
262
|
+
payload=bytes(message),
|
|
263
|
+
transfer_id=transfer_id,
|
|
264
|
+
destination_id=remote_id,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def send_transfer(
|
|
268
|
+
self,
|
|
269
|
+
*,
|
|
270
|
+
deadline: Instant,
|
|
271
|
+
priority: Priority,
|
|
272
|
+
kind: TransferKind,
|
|
273
|
+
port_id: int,
|
|
274
|
+
payload: bytes | memoryview,
|
|
275
|
+
transfer_id: int,
|
|
276
|
+
destination_id: int | None = None,
|
|
277
|
+
) -> None:
|
|
278
|
+
if self._closed:
|
|
279
|
+
raise ClosedError("CAN transport closed")
|
|
280
|
+
if Instant.now().ns >= deadline.ns:
|
|
281
|
+
raise SendError("Deadline exceeded")
|
|
282
|
+
identifier, frames = serialize_transfer(
|
|
283
|
+
kind=kind,
|
|
284
|
+
priority=int(priority),
|
|
285
|
+
port_id=port_id,
|
|
286
|
+
source_id=self._local_node_id,
|
|
287
|
+
destination_id=destination_id,
|
|
288
|
+
payload=payload,
|
|
289
|
+
transfer_id=transfer_id,
|
|
290
|
+
fd=self._fd,
|
|
291
|
+
)
|
|
292
|
+
views = tuple(memoryview(frm) for frm in frames)
|
|
293
|
+
accepted = 0
|
|
294
|
+
errors: list[BaseException] = []
|
|
295
|
+
for itf in tuple(self._interfaces):
|
|
296
|
+
try:
|
|
297
|
+
itf.enqueue(identifier, views, deadline)
|
|
298
|
+
except ClosedError as ex:
|
|
299
|
+
errors.append(ex)
|
|
300
|
+
self._drop_interface(itf, ex)
|
|
301
|
+
except Exception as ex: # pragma: no cover - exercised via tests with injected failures
|
|
302
|
+
errors.append(ex)
|
|
303
|
+
_logger.debug("CAN iface %s tx rejected: %s", itf.name, ex)
|
|
304
|
+
else:
|
|
305
|
+
accepted += 1
|
|
306
|
+
if accepted > 0:
|
|
307
|
+
return
|
|
308
|
+
first_error = errors[0] if errors else None
|
|
309
|
+
if self._closed:
|
|
310
|
+
raise ClosedError("CAN transport closed") from first_error
|
|
311
|
+
raise SendError("CAN transfer rejected by all interfaces") from first_error
|
|
312
|
+
|
|
313
|
+
def remove_subject_listener(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> None:
|
|
314
|
+
if self._subject_handlers.get(subject_id) is not handler:
|
|
315
|
+
return
|
|
316
|
+
self._subject_handlers.pop(subject_id, None)
|
|
317
|
+
self._endpoints.pop((TransferKind.MESSAGE_16, subject_id), None)
|
|
318
|
+
self._endpoints.pop((TransferKind.MESSAGE_13, subject_id), None)
|
|
319
|
+
self._pinned_subjects.pop(subject_id, None)
|
|
320
|
+
self._refresh_filters()
|
|
321
|
+
|
|
322
|
+
def remove_subject_writer(self, subject_id: int, writer: _SubjectWriter) -> None:
|
|
323
|
+
if self._subject_writers.get(subject_id) is writer:
|
|
324
|
+
self._subject_writers.pop(subject_id, None)
|
|
325
|
+
|
|
326
|
+
def close(self) -> None:
|
|
327
|
+
if self._closed:
|
|
328
|
+
return
|
|
329
|
+
self._closed = True
|
|
330
|
+
self._filter_retry_task.cancel()
|
|
331
|
+
self._cleanup_task.cancel()
|
|
332
|
+
for task in self._reader_tasks.values():
|
|
333
|
+
task.cancel()
|
|
334
|
+
self._reader_tasks.clear()
|
|
335
|
+
for itf in self._interfaces:
|
|
336
|
+
itf.close()
|
|
337
|
+
self._interfaces.clear()
|
|
338
|
+
self._filter_dirty.clear()
|
|
339
|
+
self._filter_failures.clear()
|
|
340
|
+
self._subject_handlers.clear()
|
|
341
|
+
self._subject_writers.clear()
|
|
342
|
+
self._pinned_subjects.clear()
|
|
343
|
+
self._endpoints.clear()
|
|
344
|
+
self._unicast_handler = None
|
|
345
|
+
|
|
346
|
+
async def _reader_loop(self, itf: Interface) -> None:
|
|
347
|
+
while not self._closed:
|
|
348
|
+
try:
|
|
349
|
+
frame = await itf.receive()
|
|
350
|
+
except asyncio.CancelledError:
|
|
351
|
+
raise
|
|
352
|
+
except Exception as ex:
|
|
353
|
+
if not self._closed:
|
|
354
|
+
self._drop_interface(itf, ex)
|
|
355
|
+
return
|
|
356
|
+
iface_index = self._interface_index.get(id(itf))
|
|
357
|
+
if iface_index is None:
|
|
358
|
+
return
|
|
359
|
+
self._ingest_frame(iface_index, frame)
|
|
360
|
+
|
|
361
|
+
def _drop_interface(self, itf: Interface, ex: BaseException) -> None:
|
|
362
|
+
if itf not in self._interfaces:
|
|
363
|
+
return
|
|
364
|
+
_logger.error("CAN iface %s failed and is being removed: %s", itf.name, ex)
|
|
365
|
+
self._interfaces.remove(itf)
|
|
366
|
+
self._interface_index.pop(id(itf), None)
|
|
367
|
+
self._filter_dirty.discard(itf)
|
|
368
|
+
self._filter_failures.pop(itf, None)
|
|
369
|
+
task = self._reader_tasks.pop(id(itf), None)
|
|
370
|
+
if task is not None and task is not asyncio.current_task():
|
|
371
|
+
task.cancel()
|
|
372
|
+
try:
|
|
373
|
+
itf.close()
|
|
374
|
+
except Exception: # pragma: no cover - defensive
|
|
375
|
+
_logger.exception("CAN iface %s close failed", itf.name)
|
|
376
|
+
if not self._interfaces:
|
|
377
|
+
_logger.critical("CAN transport closed because no interfaces remain")
|
|
378
|
+
self.close()
|
|
379
|
+
|
|
380
|
+
def _install_unicast_endpoint(self) -> None:
|
|
381
|
+
self._endpoints[(TransferKind.REQUEST, UNICAST_SERVICE_ID)] = Endpoint(
|
|
382
|
+
kind=TransferKind.REQUEST,
|
|
383
|
+
port_id=UNICAST_SERVICE_ID,
|
|
384
|
+
on_transfer=self._on_unicast_transfer,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _on_unicast_transfer(self, timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
|
|
388
|
+
handler = self._unicast_handler
|
|
389
|
+
if handler is not None:
|
|
390
|
+
handler(TransportArrival(timestamp, priority, remote_id, payload))
|
|
391
|
+
|
|
392
|
+
def _current_filters(self) -> list[Filter]:
|
|
393
|
+
filters = [make_filter(TransferKind.REQUEST, UNICAST_SERVICE_ID, self._local_node_id)]
|
|
394
|
+
for subject_id in self._subject_handlers:
|
|
395
|
+
filters.append(make_filter(TransferKind.MESSAGE_16, subject_id, self._local_node_id))
|
|
396
|
+
if subject_id <= SUBJECT_ID_PINNED_MAX:
|
|
397
|
+
filters.append(make_filter(TransferKind.MESSAGE_13, subject_id, self._local_node_id))
|
|
398
|
+
return ensure_forced_filters(filters, self._local_node_id)
|
|
399
|
+
|
|
400
|
+
def _mark_filters_dirty(self, interfaces: Iterable[Interface] | None = None) -> None:
|
|
401
|
+
if interfaces is None:
|
|
402
|
+
self._filter_dirty.update(self._interfaces)
|
|
403
|
+
else:
|
|
404
|
+
self._filter_dirty.update(itf for itf in interfaces if itf in self._interfaces)
|
|
405
|
+
|
|
406
|
+
def _refresh_filters(self) -> None:
|
|
407
|
+
self._mark_filters_dirty()
|
|
408
|
+
self._apply_dirty_filters()
|
|
409
|
+
if self._filter_dirty:
|
|
410
|
+
self._filter_retry_event.set()
|
|
411
|
+
|
|
412
|
+
def _apply_dirty_filters(self) -> None:
|
|
413
|
+
if self._closed:
|
|
414
|
+
return
|
|
415
|
+
filters = self._current_filters()
|
|
416
|
+
for itf in tuple(self._filter_dirty):
|
|
417
|
+
if itf not in self._interfaces:
|
|
418
|
+
self._filter_dirty.discard(itf)
|
|
419
|
+
self._filter_failures.pop(itf, None)
|
|
420
|
+
continue
|
|
421
|
+
try:
|
|
422
|
+
itf.filter(filters)
|
|
423
|
+
except Exception as ex:
|
|
424
|
+
failures = self._filter_failures.get(itf, 0) + 1
|
|
425
|
+
self._filter_failures[itf] = failures
|
|
426
|
+
if failures == 1:
|
|
427
|
+
_logger.critical("CAN iface %s filter apply failed: %s", itf.name, ex)
|
|
428
|
+
else:
|
|
429
|
+
_logger.debug("CAN iface %s filter retry failed #%d: %s", itf.name, failures, ex)
|
|
430
|
+
else:
|
|
431
|
+
if self._filter_failures.pop(itf, None) is not None:
|
|
432
|
+
_logger.info("CAN iface %s filter apply recovered", itf.name)
|
|
433
|
+
self._filter_dirty.discard(itf)
|
|
434
|
+
|
|
435
|
+
async def _filter_retry_loop(self) -> None:
|
|
436
|
+
try:
|
|
437
|
+
while not self._closed:
|
|
438
|
+
if not self._filter_dirty:
|
|
439
|
+
self._filter_retry_event.clear()
|
|
440
|
+
await self._filter_retry_event.wait()
|
|
441
|
+
continue
|
|
442
|
+
self._apply_dirty_filters()
|
|
443
|
+
if not self._filter_dirty:
|
|
444
|
+
continue
|
|
445
|
+
attempts = max(self._filter_failures.get(itf, 1) for itf in self._filter_dirty)
|
|
446
|
+
delay = min(1.0, 0.05 * (2 ** min(attempts - 1, 4)))
|
|
447
|
+
self._filter_retry_event.clear()
|
|
448
|
+
try:
|
|
449
|
+
await asyncio.wait_for(self._filter_retry_event.wait(), timeout=delay)
|
|
450
|
+
except asyncio.TimeoutError:
|
|
451
|
+
pass
|
|
452
|
+
except asyncio.CancelledError:
|
|
453
|
+
raise
|
|
454
|
+
|
|
455
|
+
async def _cleanup_loop(self) -> None:
|
|
456
|
+
try:
|
|
457
|
+
while not self._closed:
|
|
458
|
+
await asyncio.sleep(1.0)
|
|
459
|
+
Reassembler.cleanup_sessions(self._endpoints.values(), Instant.now().ns)
|
|
460
|
+
except asyncio.CancelledError:
|
|
461
|
+
raise
|
|
462
|
+
|
|
463
|
+
def _ingest_frame(self, iface_index: int, frame: TimestampedFrame) -> None:
|
|
464
|
+
parsed_items = parse_frames(frame.id, frame.data, mtu=MTU_CAN_FD if self._fd else MTU_CAN_CLASSIC)
|
|
465
|
+
if not parsed_items:
|
|
466
|
+
_logger.debug("CAN drop malformed id=%08x len=%d", frame.id, len(frame.data))
|
|
467
|
+
return
|
|
468
|
+
for parsed in parsed_items:
|
|
469
|
+
if parsed.start_of_transfer:
|
|
470
|
+
self._node_id_occupancy_update(parsed.source_id)
|
|
471
|
+
endpoint = self._route_endpoint(parsed)
|
|
472
|
+
if endpoint is not None:
|
|
473
|
+
Reassembler.ingest(endpoint, iface_index, frame.timestamp, parsed)
|
|
474
|
+
|
|
475
|
+
def _route_endpoint(self, parsed: ParsedFrame) -> Endpoint | None:
|
|
476
|
+
if parsed.kind is TransferKind.MESSAGE_16:
|
|
477
|
+
return self._endpoints.get((TransferKind.MESSAGE_16, parsed.port_id))
|
|
478
|
+
if parsed.kind is TransferKind.MESSAGE_13:
|
|
479
|
+
return self._endpoints.get((TransferKind.MESSAGE_13, parsed.port_id))
|
|
480
|
+
if (
|
|
481
|
+
parsed.kind is TransferKind.REQUEST
|
|
482
|
+
and parsed.port_id == UNICAST_SERVICE_ID
|
|
483
|
+
and parsed.destination_id == self._local_node_id
|
|
484
|
+
):
|
|
485
|
+
return self._endpoints.get((TransferKind.REQUEST, UNICAST_SERVICE_ID))
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
def _purge_interfaces(self) -> None:
|
|
489
|
+
# REFERENCE PARITY: Because TX queues are backend-owned in this design,
|
|
490
|
+
# a node-ID collision drops each backend queue wholesale instead of preserving unstarted transfers.
|
|
491
|
+
for itf in tuple(self._interfaces):
|
|
492
|
+
try:
|
|
493
|
+
itf.purge()
|
|
494
|
+
except Exception as ex: # pragma: no cover - defensive
|
|
495
|
+
_logger.error("CAN iface %s purge failed: %s", itf.name, ex)
|
|
496
|
+
|
|
497
|
+
def _node_id_occupancy_update(self, source_id: int) -> None:
|
|
498
|
+
if source_id == NODE_ID_ANONYMOUS:
|
|
499
|
+
return
|
|
500
|
+
mask = 1 << source_id
|
|
501
|
+
if (self._node_id_occupancy & mask) and (self._local_node_id != source_id):
|
|
502
|
+
return
|
|
503
|
+
self._node_id_occupancy |= mask
|
|
504
|
+
population = self._node_id_occupancy.bit_count()
|
|
505
|
+
free_count = NODE_ID_CAPACITY - population
|
|
506
|
+
purge = free_count > 0 and population > (NODE_ID_CAPACITY // 2) and (self._rng.randrange(free_count) == 0)
|
|
507
|
+
if self._local_node_id == source_id:
|
|
508
|
+
if free_count > 0:
|
|
509
|
+
free_index = self._rng.randrange(free_count)
|
|
510
|
+
new_node_id = 0
|
|
511
|
+
while True:
|
|
512
|
+
if (self._node_id_occupancy & (1 << new_node_id)) == 0:
|
|
513
|
+
if free_index == 0:
|
|
514
|
+
break
|
|
515
|
+
free_index -= 1
|
|
516
|
+
new_node_id += 1
|
|
517
|
+
self._local_node_id = new_node_id
|
|
518
|
+
self._collision_count += 1
|
|
519
|
+
self._purge_interfaces()
|
|
520
|
+
self._refresh_filters()
|
|
521
|
+
_logger.warning("CAN node-ID collision detected, switched to %d", self._local_node_id)
|
|
522
|
+
else:
|
|
523
|
+
_logger.warning("CAN node-ID collision detected on %d but no free slot remains", source_id)
|
|
524
|
+
if purge:
|
|
525
|
+
self._node_id_occupancy = 1 | mask
|