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.
@@ -0,0 +1,225 @@
1
+ """Linux SocketCAN backend for :mod:`pycyphal2.can`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import errno
7
+ from collections.abc import Iterable
8
+ import logging
9
+ from pathlib import Path
10
+ import socket
11
+ import struct
12
+ import sys
13
+
14
+ from .._api import ClosedError, Instant
15
+ from ._interface import Filter, Interface, TimestampedFrame
16
+
17
+ if sys.platform != "linux" or not hasattr(socket, "AF_CAN"):
18
+ raise ImportError("SocketCAN is available only on Linux with AF_CAN support")
19
+
20
+ _logger = logging.getLogger(__name__)
21
+
22
+ _CAN_FILTER_CAPACITY = 64
23
+ _CAN_INTERFACE_TYPE = 280
24
+ _CAN_CLASSIC_MTU = 16
25
+ _CAN_FD_MTU = 72
26
+ _CANFD_FDF = getattr(socket, "CANFD_FDF", 0)
27
+ _CAN_FRAME_STRUCT = struct.Struct("=IB3x8s")
28
+ _CANFD_FRAME_STRUCT = struct.Struct("=IBBBB64s")
29
+ _CAN_FILTER_STRUCT = struct.Struct("=II")
30
+ _TRANSIENT_TX_ERRNO = {errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOBUFS, errno.ENOMEM, errno.EBUSY}
31
+
32
+
33
+ class SocketCANInterface(Interface):
34
+ def __init__(self, name: str) -> None:
35
+ self._name = str(name)
36
+ self._sock = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
37
+ self._sock.setblocking(False)
38
+ self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_LOOPBACK, 1)
39
+ self._sock.bind((self._name,))
40
+ self._fd = self._read_iface_mtu() >= _CAN_FD_MTU
41
+ if self._fd:
42
+ self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1)
43
+ self._closed = False
44
+ self._failure: BaseException | None = None
45
+ self._tx_seq = 0
46
+ self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
47
+ self._tx_task: asyncio.Task[None] | None = None
48
+
49
+ @property
50
+ def name(self) -> str:
51
+ return self._name
52
+
53
+ @property
54
+ def fd(self) -> bool:
55
+ return self._fd
56
+
57
+ def filter(self, filters: Iterable[Filter]) -> None:
58
+ self._raise_if_closed()
59
+ flt = list(filters)
60
+ if len(flt) > _CAN_FILTER_CAPACITY:
61
+ flt = Filter.coalesce(flt, _CAN_FILTER_CAPACITY)
62
+ packed = bytearray()
63
+ for item in flt:
64
+ packed.extend(
65
+ _CAN_FILTER_STRUCT.pack(
66
+ socket.CAN_EFF_FLAG | (item.id & socket.CAN_EFF_MASK),
67
+ # Keep CAN_RTR_FLAG in the mask so the kernel rejects RTR frames at the filter layer.
68
+ socket.CAN_EFF_FLAG | socket.CAN_RTR_FLAG | (item.mask & socket.CAN_EFF_MASK),
69
+ )
70
+ )
71
+ self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FILTER, bytes(packed))
72
+
73
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
74
+ self._raise_if_closed()
75
+ if self._tx_task is None:
76
+ self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop())
77
+ for chunk in data:
78
+ self._tx_seq += 1
79
+ self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
80
+
81
+ def purge(self) -> None:
82
+ if self._closed:
83
+ return
84
+ dropped = 0
85
+ try:
86
+ while True:
87
+ self._tx_queue.get_nowait()
88
+ dropped += 1
89
+ except asyncio.QueueEmpty:
90
+ pass
91
+ if dropped > 0:
92
+ _logger.debug("SocketCAN purge iface=%s dropped=%d", self._name, dropped)
93
+
94
+ async def receive(self) -> TimestampedFrame:
95
+ self._raise_if_closed()
96
+ loop = asyncio.get_running_loop()
97
+ recv_size = _CAN_FD_MTU if self._fd else _CAN_CLASSIC_MTU
98
+ while True:
99
+ try:
100
+ raw = await loop.sock_recv(self._sock, recv_size)
101
+ except asyncio.CancelledError:
102
+ raise
103
+ except OSError as ex:
104
+ self._fail(ex)
105
+ raise ClosedError(f"SocketCAN interface {self._name} receive failed") from ex
106
+ frame = self._decode(raw)
107
+ if frame is not None:
108
+ return frame
109
+
110
+ def close(self) -> None:
111
+ if self._closed:
112
+ return
113
+ self._closed = True
114
+ if self._tx_task is not None:
115
+ self._tx_task.cancel()
116
+ self._tx_task = None
117
+ self._sock.close()
118
+
119
+ def __repr__(self) -> str:
120
+ return f"{type(self).__name__}({self._name!r}, fd={self._fd})"
121
+
122
+ async def _tx_loop(self) -> None:
123
+ loop = asyncio.get_running_loop()
124
+ while not self._closed:
125
+ try:
126
+ identifier, seq, deadline_ns, payload = await self._tx_queue.get()
127
+ except asyncio.CancelledError:
128
+ raise
129
+ if self._closed:
130
+ return
131
+ if Instant.now().ns >= deadline_ns:
132
+ _logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
133
+ continue
134
+ frame = self._encode(identifier, payload)
135
+ timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
136
+ if timeout <= 0.0:
137
+ _logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
138
+ continue
139
+ try:
140
+ await asyncio.wait_for(loop.sock_sendall(self._sock, frame), timeout=timeout)
141
+ except asyncio.TimeoutError:
142
+ self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
143
+ await asyncio.sleep(0.001)
144
+ except OSError as ex:
145
+ if self._is_transient_tx_error(ex):
146
+ _logger.debug("SocketCAN tx retry iface=%s err=%s", self._name, ex)
147
+ self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
148
+ await asyncio.sleep(0.001)
149
+ continue
150
+ self._fail(ex)
151
+ return
152
+
153
+ def _read_iface_mtu(self) -> int:
154
+ return int(Path(f"/sys/class/net/{self._name}/mtu").read_text().strip())
155
+
156
+ def _fail(self, ex: BaseException) -> None:
157
+ if self._failure is None:
158
+ self._failure = ex
159
+ _logger.error("SocketCAN interface %s failed: %s", self._name, ex)
160
+ self.close()
161
+
162
+ def _raise_if_closed(self) -> None:
163
+ if self._closed:
164
+ if self._failure is not None:
165
+ raise ClosedError(f"SocketCAN interface {self._name} failed") from self._failure
166
+ raise ClosedError(f"SocketCAN interface {self._name} closed")
167
+
168
+ @staticmethod
169
+ def _is_transient_tx_error(ex: OSError) -> bool:
170
+ return ex.errno in _TRANSIENT_TX_ERRNO
171
+
172
+ def _encode(self, identifier: int, data: bytes) -> bytes:
173
+ if len(data) > 8:
174
+ if not self._fd:
175
+ raise ClosedError(f"SocketCAN interface {self._name} is not CAN FD-capable")
176
+ return _CANFD_FRAME_STRUCT.pack(
177
+ socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK),
178
+ len(data),
179
+ _CANFD_FDF,
180
+ 0,
181
+ 0,
182
+ data.ljust(64, b"\x00"),
183
+ )
184
+ return _CAN_FRAME_STRUCT.pack(
185
+ socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK),
186
+ len(data),
187
+ data.ljust(8, b"\x00"),
188
+ )
189
+
190
+ @staticmethod
191
+ def _decode(raw: bytes) -> TimestampedFrame | None:
192
+ if len(raw) < _CAN_CLASSIC_MTU:
193
+ _logger.debug("SocketCAN drop short len=%d", len(raw))
194
+ return None
195
+ if len(raw) >= _CAN_FD_MTU:
196
+ can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:_CAN_FD_MTU])
197
+ payload = data[: min(length, 64)]
198
+ else:
199
+ can_id, length, data = _CAN_FRAME_STRUCT.unpack(raw[:_CAN_CLASSIC_MTU])
200
+ payload = data[: min(length, 8)]
201
+ if (can_id & socket.CAN_EFF_FLAG) == 0 or (can_id & (socket.CAN_RTR_FLAG | socket.CAN_ERR_FLAG)) != 0:
202
+ _logger.debug("SocketCAN drop non-extended or non-data id=%08x", can_id)
203
+ return None
204
+ return TimestampedFrame(
205
+ id=can_id & socket.CAN_EFF_MASK,
206
+ data=payload,
207
+ timestamp=Instant.now(),
208
+ )
209
+
210
+ @staticmethod
211
+ def list_interfaces() -> list[str]:
212
+ out: list[str] = []
213
+ base = Path("/sys/class/net")
214
+ try:
215
+ for item in sorted(base.iterdir()):
216
+ try:
217
+ if int((item / "type").read_text().strip()) == _CAN_INTERFACE_TYPE:
218
+ out.append(item.name)
219
+ except OSError:
220
+ continue
221
+ except ValueError:
222
+ continue
223
+ except OSError:
224
+ pass
225
+ return out
pycyphal2/py.typed ADDED
File without changes