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
pycyphal2/_subscriber.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from ._api import DeliveryError, Instant, LivenessError, NackError, Priority, SendError
|
|
9
|
+
from ._api import Subscriber, Breadcrumb, Topic, Arrival
|
|
10
|
+
from ._header import SEQNO48_MASK, RspBeHeader, RspRelHeader
|
|
11
|
+
from ._node import (
|
|
12
|
+
ACK_BASELINE_DEFAULT_TIMEOUT,
|
|
13
|
+
REORDERING_CAPACITY,
|
|
14
|
+
SESSION_LIFETIME,
|
|
15
|
+
NodeImpl,
|
|
16
|
+
SubscriberRoot,
|
|
17
|
+
TopicImpl,
|
|
18
|
+
match_pattern,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_logger = logging.getLogger(__name__)
|
|
22
|
+
REORDERING_WINDOW_MAX = SESSION_LIFETIME / 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# =====================================================================================================================
|
|
26
|
+
# Reordering
|
|
27
|
+
# =====================================================================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class InternedMsg:
|
|
32
|
+
arrival: Arrival
|
|
33
|
+
tag: int
|
|
34
|
+
remote_id: int
|
|
35
|
+
lin_tag: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ReorderingState:
|
|
40
|
+
"""Per (remote_id, topic_hash) reordering state for ordered subscriptions."""
|
|
41
|
+
|
|
42
|
+
tag_baseline: int = 0
|
|
43
|
+
last_ejected_lin_tag: int = 0
|
|
44
|
+
last_active_at: float = 0.0
|
|
45
|
+
interned: dict[int, InternedMsg] = field(default_factory=dict) # lin_tag -> msg
|
|
46
|
+
timeout_handle: asyncio.TimerHandle | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SubscriberImpl(Subscriber):
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
node: NodeImpl,
|
|
53
|
+
root: SubscriberRoot,
|
|
54
|
+
pattern: str,
|
|
55
|
+
verbatim: bool,
|
|
56
|
+
reordering_window: float | None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._node = node
|
|
59
|
+
self._root = root
|
|
60
|
+
self._pattern = pattern
|
|
61
|
+
self._verbatim = verbatim
|
|
62
|
+
self._timeout = float("inf")
|
|
63
|
+
self._reordering_window = self._normalize_reordering_window(reordering_window)
|
|
64
|
+
self.queue: asyncio.Queue[Arrival | BaseException] = asyncio.Queue()
|
|
65
|
+
self._reordering: dict[tuple[int, int], ReorderingState] = {} # (remote_id, topic_hash)
|
|
66
|
+
self.closed = False
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _normalize_reordering_window(reordering_window: float | None) -> float | None:
|
|
70
|
+
if reordering_window is None:
|
|
71
|
+
return None
|
|
72
|
+
out = float(reordering_window)
|
|
73
|
+
if (out < 0.0) or (not math.isfinite(out)):
|
|
74
|
+
raise ValueError("Reordering window must be a finite non-negative duration")
|
|
75
|
+
if out > REORDERING_WINDOW_MAX:
|
|
76
|
+
raise ValueError(f"Reordering window is too large")
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def pattern(self) -> str:
|
|
81
|
+
return self._pattern
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def verbatim(self) -> bool:
|
|
85
|
+
return self._verbatim
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def timeout(self) -> float:
|
|
89
|
+
return self._timeout
|
|
90
|
+
|
|
91
|
+
@timeout.setter
|
|
92
|
+
def timeout(self, duration: float) -> None:
|
|
93
|
+
self._timeout = duration
|
|
94
|
+
|
|
95
|
+
def substitutions(self, topic: Topic) -> list[tuple[str, int]] | None:
|
|
96
|
+
return match_pattern(self._pattern, topic.name)
|
|
97
|
+
|
|
98
|
+
def __aiter__(self) -> SubscriberImpl:
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
async def __anext__(self) -> Arrival:
|
|
102
|
+
if self.closed:
|
|
103
|
+
raise StopAsyncIteration
|
|
104
|
+
timeout = self._timeout if self._timeout != float("inf") else None
|
|
105
|
+
try:
|
|
106
|
+
item = await asyncio.wait_for(self.queue.get(), timeout=timeout)
|
|
107
|
+
except asyncio.TimeoutError:
|
|
108
|
+
raise LivenessError("No message received within timeout")
|
|
109
|
+
if isinstance(item, StopAsyncIteration):
|
|
110
|
+
raise item
|
|
111
|
+
if isinstance(item, BaseException):
|
|
112
|
+
raise item
|
|
113
|
+
return item
|
|
114
|
+
|
|
115
|
+
def deliver(self, arrival: Arrival, tag: int, remote_id: int) -> bool:
|
|
116
|
+
"""Called by the node to deliver a message to this subscriber."""
|
|
117
|
+
if self.closed:
|
|
118
|
+
return False
|
|
119
|
+
if self._reordering_window is None:
|
|
120
|
+
self.queue.put_nowait(arrival)
|
|
121
|
+
return True
|
|
122
|
+
# Reordering enabled.
|
|
123
|
+
self._drop_stale_reordering(arrival.timestamp.s)
|
|
124
|
+
topic_hash = arrival.breadcrumb.topic.hash
|
|
125
|
+
key = (remote_id, topic_hash)
|
|
126
|
+
state = self._reordering.get(key)
|
|
127
|
+
if state is None:
|
|
128
|
+
state = ReorderingState(
|
|
129
|
+
tag_baseline=tag - (REORDERING_CAPACITY // 2),
|
|
130
|
+
last_ejected_lin_tag=0,
|
|
131
|
+
last_active_at=arrival.timestamp.s,
|
|
132
|
+
)
|
|
133
|
+
self._reordering[key] = state
|
|
134
|
+
state.last_active_at = arrival.timestamp.s
|
|
135
|
+
lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
|
|
136
|
+
|
|
137
|
+
# Detect wraparound / very late messages.
|
|
138
|
+
if lin_tag > ((1 << 63) - 1):
|
|
139
|
+
_logger.debug("Reorder drop late tag=%d lin=%d", tag, lin_tag)
|
|
140
|
+
return False
|
|
141
|
+
if lin_tag <= state.last_ejected_lin_tag:
|
|
142
|
+
_logger.debug("Reorder drop dup/late tag=%d lin=%d last=%d", tag, lin_tag, state.last_ejected_lin_tag)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
while state.interned and lin_tag > (state.last_ejected_lin_tag + REORDERING_CAPACITY):
|
|
146
|
+
self._scan_reordering(state, force_first=True)
|
|
147
|
+
|
|
148
|
+
expected = state.last_ejected_lin_tag + 1
|
|
149
|
+
if lin_tag == expected:
|
|
150
|
+
# In-order: eject immediately and scan for consecutive.
|
|
151
|
+
self.queue.put_nowait(arrival)
|
|
152
|
+
state.last_ejected_lin_tag = lin_tag
|
|
153
|
+
self._scan_reordering(state, force_first=False)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
if lin_tag > (state.last_ejected_lin_tag + REORDERING_CAPACITY):
|
|
157
|
+
state.tag_baseline = tag - (REORDERING_CAPACITY // 2)
|
|
158
|
+
state.last_ejected_lin_tag = 0
|
|
159
|
+
lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
|
|
160
|
+
_logger.debug("Reorder resequence tag=%d lin=%d", tag, lin_tag)
|
|
161
|
+
|
|
162
|
+
# Out-of-order but within capacity: intern.
|
|
163
|
+
if lin_tag in state.interned:
|
|
164
|
+
return True
|
|
165
|
+
state.interned[lin_tag] = InternedMsg(arrival=arrival, tag=tag, remote_id=remote_id, lin_tag=lin_tag)
|
|
166
|
+
self._rearm_reorder_timeout(state)
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def _scan_reordering(self, state: ReorderingState, force_first: bool) -> None:
|
|
170
|
+
while True:
|
|
171
|
+
if not state.interned:
|
|
172
|
+
if state.timeout_handle is not None:
|
|
173
|
+
state.timeout_handle.cancel()
|
|
174
|
+
state.timeout_handle = None
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
lin_tag = min(state.interned)
|
|
178
|
+
if force_first or ((state.last_ejected_lin_tag + 1) == lin_tag):
|
|
179
|
+
force_first = False
|
|
180
|
+
interned = state.interned.pop(lin_tag)
|
|
181
|
+
self.queue.put_nowait(interned.arrival)
|
|
182
|
+
state.last_ejected_lin_tag = lin_tag
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
self._rearm_reorder_timeout(state)
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
def _force_eject_all(self, state: ReorderingState, *, silenced: bool = False) -> None:
|
|
189
|
+
"""Force-eject all interned messages in tag order."""
|
|
190
|
+
while state.interned:
|
|
191
|
+
lin_tag = min(state.interned)
|
|
192
|
+
interned = state.interned.pop(lin_tag)
|
|
193
|
+
state.last_ejected_lin_tag = lin_tag
|
|
194
|
+
if not silenced:
|
|
195
|
+
self.queue.put_nowait(interned.arrival)
|
|
196
|
+
if state.timeout_handle is not None:
|
|
197
|
+
state.timeout_handle.cancel()
|
|
198
|
+
state.timeout_handle = None
|
|
199
|
+
|
|
200
|
+
def _rearm_reorder_timeout(self, state: ReorderingState) -> None:
|
|
201
|
+
"""Arm or rearm the reordering timeout against the current head-of-line slot."""
|
|
202
|
+
if self._reordering_window is None:
|
|
203
|
+
return
|
|
204
|
+
if not state.interned:
|
|
205
|
+
if state.timeout_handle is not None:
|
|
206
|
+
state.timeout_handle.cancel()
|
|
207
|
+
state.timeout_handle = None
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
lin_tag = min(state.interned)
|
|
211
|
+
delay = max(0.0, (state.interned[lin_tag].arrival.timestamp.s + self._reordering_window) - Instant.now().s)
|
|
212
|
+
|
|
213
|
+
loop = self._node.loop
|
|
214
|
+
if state.timeout_handle is not None:
|
|
215
|
+
state.timeout_handle.cancel()
|
|
216
|
+
|
|
217
|
+
def on_timeout() -> None:
|
|
218
|
+
state.timeout_handle = None
|
|
219
|
+
self._scan_reordering(state, force_first=True)
|
|
220
|
+
|
|
221
|
+
state.timeout_handle = loop.call_later(delay, on_timeout)
|
|
222
|
+
|
|
223
|
+
def _arm_reorder_timeout(self, state: ReorderingState) -> None:
|
|
224
|
+
self._rearm_reorder_timeout(state)
|
|
225
|
+
|
|
226
|
+
def _drop_stale_reordering(self, now: float) -> None:
|
|
227
|
+
stale = [key for key, state in self._reordering.items() if (state.last_active_at + SESSION_LIFETIME) < now]
|
|
228
|
+
for key in stale:
|
|
229
|
+
state = self._reordering.pop(key)
|
|
230
|
+
self._force_eject_all(state)
|
|
231
|
+
|
|
232
|
+
def forget_topic_reordering(self, topic_hash: int, *, silenced: bool = True) -> None:
|
|
233
|
+
keys = [key for key in self._reordering if key[1] == topic_hash]
|
|
234
|
+
for key in keys:
|
|
235
|
+
state = self._reordering.pop(key)
|
|
236
|
+
self._force_eject_all(state, silenced=silenced)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
if self.closed:
|
|
240
|
+
return
|
|
241
|
+
self.closed = True
|
|
242
|
+
for state in self._reordering.values():
|
|
243
|
+
self._force_eject_all(state)
|
|
244
|
+
self._reordering.clear()
|
|
245
|
+
if self in self._root.subscribers:
|
|
246
|
+
self._root.subscribers.remove(self)
|
|
247
|
+
if not self._root.subscribers:
|
|
248
|
+
if self._root.scout_task is not None:
|
|
249
|
+
self._root.scout_task.cancel()
|
|
250
|
+
self._root.scout_task = None
|
|
251
|
+
if self._root.is_pattern:
|
|
252
|
+
self._node.sub_roots_pattern.pop(self._root.name, None)
|
|
253
|
+
else:
|
|
254
|
+
self._node.sub_roots_verbatim.pop(self._root.name, None)
|
|
255
|
+
for topic in list(self._node.topics_by_name.values()):
|
|
256
|
+
self._node.decouple_topic_root(topic, self._root)
|
|
257
|
+
self.queue.put_nowait(StopAsyncIteration())
|
|
258
|
+
_logger.info("Subscriber closed for '%s'", self._pattern)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# =====================================================================================================================
|
|
262
|
+
# Breadcrumb
|
|
263
|
+
# =====================================================================================================================
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class BreadcrumbImpl(Breadcrumb):
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
node: NodeImpl,
|
|
270
|
+
remote_id: int,
|
|
271
|
+
topic: TopicImpl,
|
|
272
|
+
message_tag: int,
|
|
273
|
+
initial_priority: Priority,
|
|
274
|
+
) -> None:
|
|
275
|
+
self._node = node
|
|
276
|
+
self._remote_id = remote_id
|
|
277
|
+
self._topic = topic
|
|
278
|
+
self._message_tag = message_tag
|
|
279
|
+
self._priority = initial_priority
|
|
280
|
+
self._seqno = 0
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def remote_id(self) -> int:
|
|
284
|
+
return self._remote_id
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def topic(self) -> Topic:
|
|
288
|
+
return self._topic
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def tag(self) -> int:
|
|
292
|
+
return self._message_tag
|
|
293
|
+
|
|
294
|
+
async def __call__(
|
|
295
|
+
self,
|
|
296
|
+
deadline: Instant,
|
|
297
|
+
message: memoryview | bytes,
|
|
298
|
+
*,
|
|
299
|
+
reliable: bool = False,
|
|
300
|
+
) -> None:
|
|
301
|
+
seqno = self._seqno & SEQNO48_MASK
|
|
302
|
+
self._seqno += 1
|
|
303
|
+
|
|
304
|
+
hdr: RspBeHeader | RspRelHeader
|
|
305
|
+
if not reliable:
|
|
306
|
+
hdr = RspBeHeader(
|
|
307
|
+
tag=0xFF,
|
|
308
|
+
seqno=seqno,
|
|
309
|
+
topic_hash=self._topic.hash,
|
|
310
|
+
message_tag=self._message_tag,
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
rsp_tag = self._allocate_response_tag(seqno)
|
|
314
|
+
hdr = RspRelHeader(
|
|
315
|
+
tag=rsp_tag,
|
|
316
|
+
seqno=seqno,
|
|
317
|
+
topic_hash=self._topic.hash,
|
|
318
|
+
message_tag=self._message_tag,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
data = hdr.serialize() + bytes(message)
|
|
322
|
+
if not reliable:
|
|
323
|
+
await self._node.transport.unicast(deadline, self._priority, self._remote_id, data)
|
|
324
|
+
_logger.debug("Response BE sent seqno=%d to %016x", seqno, self._remote_id)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Reliable response with retransmission.
|
|
328
|
+
tracker = RespondTracker(
|
|
329
|
+
remote_id=self._remote_id,
|
|
330
|
+
message_tag=self._message_tag,
|
|
331
|
+
topic_hash=self._topic.hash,
|
|
332
|
+
seqno=seqno,
|
|
333
|
+
tag=hdr.tag,
|
|
334
|
+
)
|
|
335
|
+
key = tracker.key
|
|
336
|
+
self._node.respond_futures[key] = tracker
|
|
337
|
+
|
|
338
|
+
ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT * (1 << int(self._priority))
|
|
339
|
+
try:
|
|
340
|
+
initial_window = _ack_window(deadline.ns, ack_timeout)
|
|
341
|
+
if initial_window is None:
|
|
342
|
+
raise DeliveryError("Reliable response not acknowledged before deadline")
|
|
343
|
+
|
|
344
|
+
ack_deadline_ns, last_attempt = initial_window
|
|
345
|
+
tracker.ack_event.clear()
|
|
346
|
+
try:
|
|
347
|
+
await self._node.transport.unicast(Instant(ns=ack_deadline_ns), self._priority, self._remote_id, data)
|
|
348
|
+
except SendError:
|
|
349
|
+
raise
|
|
350
|
+
except OSError as ex:
|
|
351
|
+
raise SendError("Reliable response initial send failed") from ex
|
|
352
|
+
|
|
353
|
+
while True:
|
|
354
|
+
if tracker.done:
|
|
355
|
+
if tracker.nacked:
|
|
356
|
+
raise NackError("Response NACK'd by remote")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
wait_until_ns = deadline.ns if last_attempt else ack_deadline_ns
|
|
360
|
+
wait_time = max(0.0, (wait_until_ns - Instant.now().ns) * 1e-9)
|
|
361
|
+
try:
|
|
362
|
+
await asyncio.wait_for(tracker.ack_event.wait(), timeout=wait_time)
|
|
363
|
+
except asyncio.TimeoutError:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
if tracker.done:
|
|
367
|
+
if tracker.nacked:
|
|
368
|
+
raise NackError("Response NACK'd by remote")
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
if last_attempt:
|
|
372
|
+
break
|
|
373
|
+
ack_timeout *= 2
|
|
374
|
+
next_window = _ack_window(deadline.ns, ack_timeout)
|
|
375
|
+
if next_window is None:
|
|
376
|
+
break
|
|
377
|
+
ack_deadline_ns, last_attempt = next_window
|
|
378
|
+
tracker.ack_event.clear()
|
|
379
|
+
try:
|
|
380
|
+
await self._node.transport.unicast(
|
|
381
|
+
Instant(ns=ack_deadline_ns), self._priority, self._remote_id, data
|
|
382
|
+
)
|
|
383
|
+
except (SendError, OSError):
|
|
384
|
+
pass
|
|
385
|
+
|
|
386
|
+
if not tracker.done:
|
|
387
|
+
raise DeliveryError("Reliable response not acknowledged before deadline")
|
|
388
|
+
finally:
|
|
389
|
+
self._node.respond_futures.pop(key, None)
|
|
390
|
+
|
|
391
|
+
def _allocate_response_tag(self, seqno: int) -> int:
|
|
392
|
+
for tag in range(256):
|
|
393
|
+
key = (self._remote_id, self._message_tag, self._topic.hash, seqno, tag)
|
|
394
|
+
if key not in self._node.respond_futures:
|
|
395
|
+
return tag
|
|
396
|
+
raise DeliveryError("Reliable response tag space exhausted")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class RespondTracker:
|
|
400
|
+
"""Tracks a pending reliable response awaiting ACK."""
|
|
401
|
+
|
|
402
|
+
def __init__(self, remote_id: int, message_tag: int, topic_hash: int, seqno: int, tag: int) -> None:
|
|
403
|
+
self.remote_id = remote_id
|
|
404
|
+
self.message_tag = message_tag
|
|
405
|
+
self.topic_hash = topic_hash
|
|
406
|
+
self.seqno = seqno
|
|
407
|
+
self.tag = tag
|
|
408
|
+
self.key = (remote_id, message_tag, topic_hash, seqno, tag)
|
|
409
|
+
self.ack_event = asyncio.Event()
|
|
410
|
+
self.done = False
|
|
411
|
+
self.nacked = False
|
|
412
|
+
|
|
413
|
+
def on_ack(self, positive: bool) -> None:
|
|
414
|
+
self.done = True
|
|
415
|
+
self.nacked = not positive
|
|
416
|
+
self.ack_event.set()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
|
|
420
|
+
next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
|
|
421
|
+
remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
|
|
422
|
+
return remaining_budget_ns < next_ack_timeout_ns
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _ack_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
|
|
426
|
+
now_ns = Instant.now().ns
|
|
427
|
+
if now_ns >= deadline_ns:
|
|
428
|
+
return None
|
|
429
|
+
ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
|
|
430
|
+
return ack_deadline_ns, _ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
|
pycyphal2/_transport.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The bottom-layer API that connects the session layer to the underlying transport layer.
|
|
3
|
+
Normally, applications don't care about this unless a custom transport is needed (very uncommon),
|
|
4
|
+
so it is moved into a separate module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import abstractmethod
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ._api import Closable, Instant, Priority
|
|
14
|
+
|
|
15
|
+
SUBJECT_ID_MODULUS_16bit = 57203 # Suitable for all Cyphal transports
|
|
16
|
+
SUBJECT_ID_MODULUS_23bit = 8378431 # Incompatible with Cyphal/CAN
|
|
17
|
+
SUBJECT_ID_MODULUS_32bit = 4294954663 # Incompatible with Cyphal/CAN and Cyphal/UDPv4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SubjectWriter(Closable):
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TransportArrival:
|
|
28
|
+
"""
|
|
29
|
+
Arrival of a transfer from the underlying transport.
|
|
30
|
+
The session layer (this library) will parse the header and process the message.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
timestamp: Instant
|
|
34
|
+
priority: Priority
|
|
35
|
+
remote_id: int
|
|
36
|
+
message: bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Transport(Closable):
|
|
40
|
+
"""
|
|
41
|
+
Serves the same purpose as cy_platform_t in Cy, with several Pythonic deviations documented below.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def subject_id_modulus(self) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Constant, cannot be changed while the transport is in used because that would invalidate subject allocations.
|
|
49
|
+
"""
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> Closable:
|
|
54
|
+
"""
|
|
55
|
+
Subscribe to a subject to receive messages from it until the returned closable handle is closed.
|
|
56
|
+
The session layer may request at most one listener per subject at any given time, similar to the reference impl.
|
|
57
|
+
Duplicate requests for the same subject should raise ValueError.
|
|
58
|
+
|
|
59
|
+
REFERENCE PARITY: Unlike the reference implementation, our listeners do not have the extent setting --
|
|
60
|
+
the extent mostly matters for high-reliability/real-time applications; this Python implementation
|
|
61
|
+
assumes infinite extent.
|
|
62
|
+
"""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def subject_advertise(self, subject_id: int) -> SubjectWriter:
|
|
67
|
+
"""
|
|
68
|
+
Begin sending messages on a subject.
|
|
69
|
+
The session layer may request at most one writer per subject at any given time, similar to the reference impl.
|
|
70
|
+
Duplicate requests for the same subject should raise ValueError.
|
|
71
|
+
"""
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None:
|
|
76
|
+
"""
|
|
77
|
+
The session layer will invoke this once to configure the handler that will process incoming unicast messages.
|
|
78
|
+
Normally it will happen very early in initialization so no messages are lost; if, however, it somehow comes
|
|
79
|
+
to pass that messages arrive while the handler is still not set, they may be silently dropped.
|
|
80
|
+
"""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Send a unicast message to the specified remote node.
|
|
87
|
+
"""
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cyphal/CAN transport — real-time reliable pub/sub over Classic CAN and CAN FD.
|
|
3
|
+
Supports various backends such as SocketCAN and Python-CAN.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from pycyphal2.can import CANTransport
|
|
7
|
+
# Import the backend you need.
|
|
8
|
+
# Beware: optional dependencies may be needed, check pyproject.toml.
|
|
9
|
+
from pycyphal2.can.socketcan import SocketCANInterface
|
|
10
|
+
|
|
11
|
+
transport = CANTransport.new(SocketCANInterface("can0"))
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Python-CAN is useful when the application runs not on GNU/Linux or already uses `python-can` or needs
|
|
15
|
+
`one of its *many* hardware backends <https://python-can.readthedocs.io/en/stable/interfaces.html>`_
|
|
16
|
+
-- GS-USB, SLCAN, PCAN, etc:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import can
|
|
20
|
+
from pycyphal2.can import CANTransport
|
|
21
|
+
from pycyphal2.can.pythoncan import PythonCANInterface
|
|
22
|
+
|
|
23
|
+
bus = can.ThreadSafeBus(interface="socketcan", channel="can0")
|
|
24
|
+
transport = CANTransport.new(PythonCANInterface(bus))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Pass the transport to `pycyphal2.Node.new()` to start a node.
|
|
28
|
+
|
|
29
|
+
For the available dependencies see the submodules such as `socketcan` et al.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from ._interface import Filter as Filter
|
|
35
|
+
from ._interface import Frame as Frame
|
|
36
|
+
from ._interface import Interface as Interface
|
|
37
|
+
from ._interface import TimestampedFrame as TimestampedFrame
|
|
38
|
+
from ._transport import CANTransport as CANTransport
|
|
39
|
+
|
|
40
|
+
# Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan;
|
|
41
|
+
# they are not eagerly imported here because they pull in optional dependencies.
|
|
42
|
+
|
|
43
|
+
__all__ = ["CANTransport", "Frame", "TimestampedFrame", "Filter", "Interface"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
import itertools
|
|
7
|
+
|
|
8
|
+
from .. import Closable, Instant
|
|
9
|
+
|
|
10
|
+
_CAN_EXT_ID_MASK = (1 << 29) - 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Frame:
|
|
15
|
+
"""29-bit extended data frame."""
|
|
16
|
+
|
|
17
|
+
id: int
|
|
18
|
+
data: bytes
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if not isinstance(self.id, int) or not (0 <= self.id <= _CAN_EXT_ID_MASK):
|
|
22
|
+
raise ValueError(f"Invalid CAN identifier: {self.id!r}")
|
|
23
|
+
data = bytes(self.data)
|
|
24
|
+
if len(data) > 64:
|
|
25
|
+
raise ValueError(f"Invalid CAN data length: {len(data)}")
|
|
26
|
+
object.__setattr__(self, "data", data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TimestampedFrame(Frame):
|
|
31
|
+
timestamp: Instant
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Filter:
|
|
36
|
+
"""29-bit extended identifier acceptance filter."""
|
|
37
|
+
|
|
38
|
+
id: int
|
|
39
|
+
mask: int
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not (0 <= self.id <= _CAN_EXT_ID_MASK):
|
|
43
|
+
raise ValueError(f"Invalid CAN identifier: {self.id!r}")
|
|
44
|
+
if not (0 <= self.mask <= _CAN_EXT_ID_MASK):
|
|
45
|
+
raise ValueError(f"Invalid CAN mask: {self.mask!r}")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def rank(self) -> int:
|
|
49
|
+
return self.mask.bit_count()
|
|
50
|
+
|
|
51
|
+
def merge(self, other: Filter) -> Filter:
|
|
52
|
+
mask = self.mask & other.mask & ~(self.id ^ other.id)
|
|
53
|
+
return Filter(id=self.id & mask, mask=mask)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def promiscuous() -> Filter:
|
|
57
|
+
return Filter(id=0, mask=0)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def coalesce(filters: Iterable[Filter], count: int) -> list[Filter]:
|
|
61
|
+
if count < 1:
|
|
62
|
+
raise ValueError("The target number of filters must be positive")
|
|
63
|
+
filters = list(filters)
|
|
64
|
+
assert isinstance(filters, list)
|
|
65
|
+
# REFERENCE PARITY: Do not flag this as a divergence; this implementation is correct.
|
|
66
|
+
while len(filters) > count:
|
|
67
|
+
options = itertools.starmap(
|
|
68
|
+
lambda ia, ib: (ia[0], ib[0], ia[1].merge(ib[1])), itertools.permutations(enumerate(filters), 2)
|
|
69
|
+
)
|
|
70
|
+
index_replace, index_remove, merged = max(options, key=lambda x: int(x[2].rank))
|
|
71
|
+
filters[index_replace] = merged
|
|
72
|
+
del filters[index_remove] # Invalidates indexes
|
|
73
|
+
assert all(map(lambda x: isinstance(x, Filter), filters))
|
|
74
|
+
return filters
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Interface(Closable, ABC):
|
|
78
|
+
"""
|
|
79
|
+
A local CAN controller interface.
|
|
80
|
+
Only extended-ID data frames are supported; everything else is silently dropped.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def fd(self) -> bool:
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def filter(self, filters: Iterable[Filter]) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Request the hardware acceptance filter configuration.
|
|
97
|
+
Implementations with a smaller hardware capacity shall coalesce the list locally.
|
|
98
|
+
"""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Schedule one or more frames for transmission. All frames share the same extended identifier.
|
|
105
|
+
The frame order within the iterable shall be preserved. Implementations may prioritize queued
|
|
106
|
+
frames by CAN identifier to approximate bus arbitration, but the relative order of frames
|
|
107
|
+
belonging to one transfer shall remain unchanged.
|
|
108
|
+
"""
|
|
109
|
+
# REFERENCE PARITY: TX queue ownership intentionally belongs to the interface rather than the transport.
|
|
110
|
+
# This differs from libcanard's internal queue placement but it is not a parity drift because it does not
|
|
111
|
+
# affect the wire-visible behavior by itself.
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def purge(self) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Drop all queued but not yet transmitted frames.
|
|
118
|
+
Used when the local node-ID changes and queued continuations become invalid.
|
|
119
|
+
"""
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def receive(self) -> TimestampedFrame:
|
|
124
|
+
"""
|
|
125
|
+
Suspend until the next frame is received.
|
|
126
|
+
Raises an exception if the interface is closed or has failed.
|
|
127
|
+
"""
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
raise NotImplementedError
|