python-can-canpal 0.1.0rc1__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.
- canpal/__init__.py +10 -0
- canpal/bus.py +312 -0
- canpal/codec.py +110 -0
- canpal/discovery.py +66 -0
- canpal/native.py +187 -0
- python_can_canpal-0.1.0rc1.dist-info/METADATA +109 -0
- python_can_canpal-0.1.0rc1.dist-info/RECORD +11 -0
- python_can_canpal-0.1.0rc1.dist-info/WHEEL +5 -0
- python_can_canpal-0.1.0rc1.dist-info/entry_points.txt +2 -0
- python_can_canpal-0.1.0rc1.dist-info/licenses/LICENSE +25 -0
- python_can_canpal-0.1.0rc1.dist-info/top_level.txt +1 -0
canpal/__init__.py
ADDED
canpal/bus.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""One physical CANPAL device and selected channel per python-can Bus."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
import ctypes as C
|
|
6
|
+
import ipaddress
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from can import BusABC, CanInitializationError, CanOperationError, CanTimeoutError
|
|
13
|
+
from can.bus import CanProtocol
|
|
14
|
+
|
|
15
|
+
from . import codec, discovery, native
|
|
16
|
+
|
|
17
|
+
LOG = logging.getLogger(__name__)
|
|
18
|
+
_connect_lock = threading.Lock()
|
|
19
|
+
_registry_lock = threading.Lock()
|
|
20
|
+
# Values are opaque ownership tokens: the registry must not keep Bus objects alive.
|
|
21
|
+
_uids = {}
|
|
22
|
+
_endpoints = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _timeout(value):
|
|
26
|
+
if value is not None and (not isinstance(value, (int, float))
|
|
27
|
+
or not math.isfinite(value) or value < 0):
|
|
28
|
+
raise ValueError("timeout must be None or a finite nonnegative number")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CanpalBus(BusABC):
|
|
33
|
+
def __init__(
|
|
34
|
+
self, channel=0, *, transport, bitrate, device_id=None, usb_port=None,
|
|
35
|
+
host=None, port=None, fd=False, data_bitrate=None, listen_only=False,
|
|
36
|
+
termination=None, can_filters=None, receive_own_messages=False,
|
|
37
|
+
library_path=None, **kwargs,
|
|
38
|
+
):
|
|
39
|
+
self._is_shutdown = True
|
|
40
|
+
self._handle = None
|
|
41
|
+
self._token = object()
|
|
42
|
+
self._registered_uid = None
|
|
43
|
+
self._endpoint = None
|
|
44
|
+
self._closing = False
|
|
45
|
+
self._fault = None
|
|
46
|
+
self._initialized = False
|
|
47
|
+
self._condition = threading.Condition()
|
|
48
|
+
self._inflight = 0
|
|
49
|
+
self._send_lock = threading.Lock()
|
|
50
|
+
self._recv_lock = threading.Lock()
|
|
51
|
+
self._shutdown_lock = threading.Lock()
|
|
52
|
+
self._next_tag = 1
|
|
53
|
+
if kwargs:
|
|
54
|
+
raise ValueError(f"Unsupported CANPAL options: {', '.join(sorted(kwargs))}")
|
|
55
|
+
if transport not in ("usb", "wlan"):
|
|
56
|
+
raise ValueError("transport must be 'usb' or 'wlan'")
|
|
57
|
+
if type(channel) is not int or not 0 <= channel <= 255:
|
|
58
|
+
raise ValueError("channel must be a nonnegative integer")
|
|
59
|
+
for name, value in (("fd", fd), ("listen_only", listen_only),
|
|
60
|
+
("receive_own_messages", receive_own_messages)):
|
|
61
|
+
if type(value) is not bool:
|
|
62
|
+
raise ValueError(f"{name} must be a bool")
|
|
63
|
+
if termination is not None and type(termination) is not bool:
|
|
64
|
+
raise ValueError("termination must be None or bool")
|
|
65
|
+
if receive_own_messages:
|
|
66
|
+
raise ValueError("receive_own_messages=True is not supported")
|
|
67
|
+
if type(bitrate) is not int or bitrate <= 0:
|
|
68
|
+
raise ValueError("bitrate must be a positive integer")
|
|
69
|
+
if fd and (type(data_bitrate) is not int or data_bitrate <= 0):
|
|
70
|
+
raise ValueError("fd=True requires data_bitrate")
|
|
71
|
+
if not fd and data_bitrate is not None:
|
|
72
|
+
raise ValueError("data_bitrate requires fd=True")
|
|
73
|
+
expected_uid = discovery.normalize_uid(device_id)
|
|
74
|
+
if transport == "usb":
|
|
75
|
+
if host is not None or port is not None:
|
|
76
|
+
raise ValueError("USB cannot use host/port")
|
|
77
|
+
if expected_uid is None and usb_port is None:
|
|
78
|
+
raise ValueError("USB requires device_id or usb_port")
|
|
79
|
+
if usb_port is not None:
|
|
80
|
+
discovery.normalize_port(usb_port)
|
|
81
|
+
else:
|
|
82
|
+
if usb_port is not None:
|
|
83
|
+
raise ValueError("WLAN cannot use usb_port")
|
|
84
|
+
if not isinstance(host, str):
|
|
85
|
+
raise ValueError("WLAN requires an IPv4 host")
|
|
86
|
+
host = str(ipaddress.IPv4Address(host))
|
|
87
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
88
|
+
raise ValueError("WLAN requires a TCP port between 1 and 65535")
|
|
89
|
+
self.channel, self.fd, self.listen_only = channel, fd, listen_only
|
|
90
|
+
self._api = native.load_library(library_path)
|
|
91
|
+
try:
|
|
92
|
+
with _connect_lock:
|
|
93
|
+
with _registry_lock:
|
|
94
|
+
if expected_uid in _uids:
|
|
95
|
+
raise CanInitializationError(f"CANPAL {expected_uid} is already occupied")
|
|
96
|
+
occupied = {ep[1] for ep in _endpoints if ep[0] == "usb"}
|
|
97
|
+
if transport == "usb":
|
|
98
|
+
candidates = [
|
|
99
|
+
(("usb", name), native.Connection(CommType=0, LocalPort=number))
|
|
100
|
+
for name, number in discovery.usb_candidates(
|
|
101
|
+
usb_port, occupied, expected_uid is not None
|
|
102
|
+
)
|
|
103
|
+
]
|
|
104
|
+
else:
|
|
105
|
+
candidates = [(("wlan", host, port), native.Connection(
|
|
106
|
+
CommType=2, DestHost=host.encode("ascii"), DestPort=port
|
|
107
|
+
))]
|
|
108
|
+
errors = []
|
|
109
|
+
for endpoint, connection in candidates:
|
|
110
|
+
with _registry_lock:
|
|
111
|
+
if endpoint in _endpoints:
|
|
112
|
+
raise CanInitializationError(f"CANPAL endpoint {endpoint} is occupied")
|
|
113
|
+
handle = self._api.YX_Open(C.byref(connection))
|
|
114
|
+
if not handle:
|
|
115
|
+
errors.append(f"{endpoint}: open failed")
|
|
116
|
+
continue
|
|
117
|
+
retained = False
|
|
118
|
+
try:
|
|
119
|
+
info = native.DeviceInfo()
|
|
120
|
+
if not self._api.YX_GetDeviceInfo(handle, C.byref(info)):
|
|
121
|
+
errors.append(f"{endpoint}: identity read failed")
|
|
122
|
+
continue
|
|
123
|
+
if expected_uid and info.uid != expected_uid:
|
|
124
|
+
errors.append(f"{endpoint}: UID mismatch ({info.uid})")
|
|
125
|
+
continue
|
|
126
|
+
with _registry_lock:
|
|
127
|
+
if info.uid in _uids:
|
|
128
|
+
raise CanInitializationError(f"CANPAL {info.uid} is already occupied")
|
|
129
|
+
_uids[info.uid] = self._token
|
|
130
|
+
_endpoints[endpoint] = self._token
|
|
131
|
+
self._registered_uid = self.device_id = info.uid
|
|
132
|
+
self._endpoint, self._handle, self.device_info = endpoint, handle, info
|
|
133
|
+
retained = True
|
|
134
|
+
break
|
|
135
|
+
finally:
|
|
136
|
+
if not retained:
|
|
137
|
+
self._api.YX_Close(handle)
|
|
138
|
+
if self._handle is None:
|
|
139
|
+
raise CanInitializationError(
|
|
140
|
+
"CANPAL not found or inaccessible: " + ("; ".join(errors) or "no free USB candidates")
|
|
141
|
+
)
|
|
142
|
+
self._configure(bitrate, data_bitrate, termination)
|
|
143
|
+
self.channel_info = f"CANPAL {self.device_id} {self._endpoint} CAN{channel}"
|
|
144
|
+
self._can_protocol = CanProtocol.CAN_FD if fd else CanProtocol.CAN_20
|
|
145
|
+
super().__init__(channel=channel, can_filters=can_filters)
|
|
146
|
+
except BaseException:
|
|
147
|
+
self._dispose()
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
def _command(self, name, *args):
|
|
151
|
+
result = getattr(self._api, name)(self._handle, *args)
|
|
152
|
+
if result:
|
|
153
|
+
self._fault = f"{name} returned SDK error {result}"
|
|
154
|
+
raise CanInitializationError(
|
|
155
|
+
f"{getattr(self, 'device_id', '?')}: {self._fault}", error_code=result
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _configure(self, bitrate, data_bitrate, termination):
|
|
159
|
+
capacity = native.Capacity()
|
|
160
|
+
info = self.device_info
|
|
161
|
+
if not self._api.YX_GetCapacity(info.DeviceModel, C.byref(capacity)):
|
|
162
|
+
raise CanInitializationError(f"Unsupported device model {info.DeviceModel}")
|
|
163
|
+
if self.channel >= capacity.CAN_channel_num:
|
|
164
|
+
raise CanInitializationError("Selected CAN channel is unavailable")
|
|
165
|
+
if self.fd and not capacity.CAN_FD_supported:
|
|
166
|
+
raise CanInitializationError("This CANPAL does not support CAN FD")
|
|
167
|
+
if not capacity.CAN_Classic_supported:
|
|
168
|
+
raise CanInitializationError("This CANPAL does not support CAN")
|
|
169
|
+
if self._endpoint[0] == "wlan" and not (capacity.Wifi_supported or capacity.Ethernet_supported):
|
|
170
|
+
raise CanInitializationError("Device does not support network transport")
|
|
171
|
+
self._use_fd_api = info.DeviceModel in (2, 3, 5)
|
|
172
|
+
suffix = "FD" if self._use_fd_api else ""
|
|
173
|
+
ctrl_type = native.FDController if self._use_fd_api else native.Controller
|
|
174
|
+
previous, ctrl = ctrl_type(), ctrl_type()
|
|
175
|
+
self._command("YXCAN_GetController" + suffix, self.channel, C.byref(previous))
|
|
176
|
+
ctrl.CANMode = 2 if self.listen_only else 0
|
|
177
|
+
try:
|
|
178
|
+
if self._use_fd_api:
|
|
179
|
+
modern = info.SWVer >= 0x0120
|
|
180
|
+
nominal = codec.FD_NOMINAL_80 if modern else codec.FD_NOMINAL_100
|
|
181
|
+
data = codec.FD_DATA_80 if modern else codec.FD_DATA_100
|
|
182
|
+
ctrl.NomBaudrate = bitrate
|
|
183
|
+
ctrl.NomSJW, ctrl.NomBS1, ctrl.NomBS2 = nominal[bitrate]
|
|
184
|
+
ctrl.DataBaudrate = data_bitrate if self.fd else 2_000_000
|
|
185
|
+
ctrl.DataSJW, ctrl.DataBS1, ctrl.DataBS2 = data[ctrl.DataBaudrate]
|
|
186
|
+
ctrl.FrameMode, ctrl.ISOModeEnable = (2 if self.fd else 0), 1
|
|
187
|
+
ctrl.ResistorEnable = previous.ResistorEnable if termination is None else int(termination)
|
|
188
|
+
else:
|
|
189
|
+
ctrl.Baudrate = bitrate
|
|
190
|
+
ctrl.SJW, ctrl.BS1, ctrl.BS2 = codec.CLASSIC_TIMING[bitrate]
|
|
191
|
+
ctrl.Option = (previous.Option & 0x0C) if termination is None else (4 if termination else 0)
|
|
192
|
+
except KeyError as exc:
|
|
193
|
+
raise CanInitializationError(f"No SDK timing preset for bitrate {exc.args[0]}") from exc
|
|
194
|
+
# Device-wide options are volatile. One Bus exclusively owns this device.
|
|
195
|
+
# Same sequence as the SDK's J2534 setup; no YX_SaveSetting call.
|
|
196
|
+
for parameter_id in (0, 1, 19, 21): # filter, automation, bridge, spy
|
|
197
|
+
value = native.Parameter(ID=parameter_id, Len=1)
|
|
198
|
+
self._command("YX_SetParameter", C.byref(value))
|
|
199
|
+
check = native.Parameter(ID=parameter_id)
|
|
200
|
+
self._command("YX_GetParameter", C.byref(check))
|
|
201
|
+
if check.Len != 1 or check.Data[0] != 0:
|
|
202
|
+
raise CanInitializationError(f"Parameter {parameter_id} readback mismatch")
|
|
203
|
+
self._command("YXCAN_SetController" + suffix, self.channel, C.byref(ctrl))
|
|
204
|
+
check = ctrl_type()
|
|
205
|
+
self._command("YXCAN_GetController" + suffix, self.channel, C.byref(check))
|
|
206
|
+
if bytes(check) != bytes(ctrl):
|
|
207
|
+
raise CanInitializationError("Controller readback mismatch")
|
|
208
|
+
init = native.InitHW()
|
|
209
|
+
before = time.time()
|
|
210
|
+
self._command("YXCAN_Initialize", self.channel, C.byref(init))
|
|
211
|
+
after = time.time()
|
|
212
|
+
if not init.Inited:
|
|
213
|
+
raise CanInitializationError("SDK returned Inited=0")
|
|
214
|
+
self._initialized = True
|
|
215
|
+
self._clock = codec.TimestampClock(int.from_bytes(init.Timestamp, "little"), (before + after) / 2)
|
|
216
|
+
self._send_native = getattr(self._api, "YXCAN_Send" + suffix)
|
|
217
|
+
self._receive_native = getattr(self._api, "YXCAN_Receive" + suffix)
|
|
218
|
+
self._message_type = native.FDMessage if self._use_fd_api else native.ClassicMessage
|
|
219
|
+
|
|
220
|
+
@contextmanager
|
|
221
|
+
def _call(self):
|
|
222
|
+
with self._condition:
|
|
223
|
+
if self._closing or not self._handle or self._fault:
|
|
224
|
+
raise CanOperationError(self._fault or "CANPAL is closing or closed")
|
|
225
|
+
self._inflight += 1
|
|
226
|
+
handle = self._handle
|
|
227
|
+
try:
|
|
228
|
+
yield handle
|
|
229
|
+
finally:
|
|
230
|
+
with self._condition:
|
|
231
|
+
self._inflight -= 1
|
|
232
|
+
self._condition.notify_all()
|
|
233
|
+
|
|
234
|
+
def send(self, msg, timeout=None):
|
|
235
|
+
_timeout(timeout)
|
|
236
|
+
if self.listen_only:
|
|
237
|
+
raise CanOperationError("Cannot transmit in listen-only mode")
|
|
238
|
+
# Validate before waiting; tag assignment is serialized below.
|
|
239
|
+
packet = codec.encode(msg, self.channel, self._use_fd_api, self.fd, 1)
|
|
240
|
+
acquired = self._send_lock.acquire() if timeout is None else self._send_lock.acquire(timeout=timeout)
|
|
241
|
+
if not acquired:
|
|
242
|
+
raise CanTimeoutError("Timed out before CANPAL submission; frame was not submitted")
|
|
243
|
+
try:
|
|
244
|
+
with self._call() as handle:
|
|
245
|
+
packet.TXCtrl.TxHandle = self._next_tag
|
|
246
|
+
self._next_tag = self._next_tag % 65535 + 1
|
|
247
|
+
if not self._send_native(handle, C.byref(packet)):
|
|
248
|
+
raise CanOperationError("CANPAL rejected frame submission; no Python retry")
|
|
249
|
+
finally:
|
|
250
|
+
self._send_lock.release()
|
|
251
|
+
|
|
252
|
+
def _recv_internal(self, timeout):
|
|
253
|
+
_timeout(timeout)
|
|
254
|
+
if not self._recv_lock.acquire(blocking=False):
|
|
255
|
+
raise CanOperationError("Only one receiver/Notifier may read a CANPAL Bus")
|
|
256
|
+
try:
|
|
257
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
258
|
+
while True:
|
|
259
|
+
remaining = None if deadline is None else max(0, deadline - time.monotonic())
|
|
260
|
+
wait_ms = 50 if remaining is None else min(50, int(remaining * 1000))
|
|
261
|
+
packet = self._message_type()
|
|
262
|
+
with self._call() as handle:
|
|
263
|
+
got = self._receive_native(handle, C.byref(packet), wait_ms)
|
|
264
|
+
if not got and not self._api.YX_Online(handle):
|
|
265
|
+
self._fault = f"CANPAL {self.device_id} disconnected"
|
|
266
|
+
raise CanOperationError(self._fault)
|
|
267
|
+
if got and packet.Bus == self.channel:
|
|
268
|
+
if packet.MsgType == 0:
|
|
269
|
+
try:
|
|
270
|
+
stamp = self._clock.convert(packet.Timestamp)
|
|
271
|
+
message = codec.decode(packet, stamp)
|
|
272
|
+
if message.is_fd and not self.fd:
|
|
273
|
+
raise ValueError("FD frame received on classic-only Bus")
|
|
274
|
+
return message, False
|
|
275
|
+
except ValueError as exc:
|
|
276
|
+
LOG.warning("Discarding invalid frame from %s: %s", self.device_id, exc)
|
|
277
|
+
else:
|
|
278
|
+
LOG.debug("CANPAL %s record type=%d result=%d",
|
|
279
|
+
self.device_id, packet.MsgType, packet.TXCtrl.Result)
|
|
280
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
281
|
+
return None, False
|
|
282
|
+
if wait_ms == 0 and not got:
|
|
283
|
+
time.sleep(min(0.001, max(0, deadline - time.monotonic())))
|
|
284
|
+
finally:
|
|
285
|
+
self._recv_lock.release()
|
|
286
|
+
|
|
287
|
+
def _dispose(self):
|
|
288
|
+
if self._handle:
|
|
289
|
+
self._api.YX_Close(self._handle)
|
|
290
|
+
self._handle = None
|
|
291
|
+
with _registry_lock:
|
|
292
|
+
if self._registered_uid and _uids.get(self._registered_uid) is self._token:
|
|
293
|
+
del _uids[self._registered_uid]
|
|
294
|
+
if self._endpoint and _endpoints.get(self._endpoint) is self._token:
|
|
295
|
+
del _endpoints[self._endpoint]
|
|
296
|
+
|
|
297
|
+
def shutdown(self):
|
|
298
|
+
with self._shutdown_lock:
|
|
299
|
+
if self._is_shutdown and self._handle is None:
|
|
300
|
+
return
|
|
301
|
+
with self._condition:
|
|
302
|
+
self._closing = True
|
|
303
|
+
super().shutdown()
|
|
304
|
+
with self._condition:
|
|
305
|
+
self._condition.wait_for(lambda: self._inflight == 0)
|
|
306
|
+
try:
|
|
307
|
+
if self._handle and self._initialized and not self._fault and self._api.YX_Online(self._handle):
|
|
308
|
+
result = self._api.YXCAN_Uninitialize(self._handle, self.channel)
|
|
309
|
+
if result:
|
|
310
|
+
LOG.warning("CANPAL %s uninitialize failed (%d); closing", self.device_id, result)
|
|
311
|
+
finally:
|
|
312
|
+
self._dispose()
|
canpal/codec.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""CAN message conversion without mutating caller-owned messages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from can import Message
|
|
5
|
+
|
|
6
|
+
from .native import ClassicMessage, FDMessage
|
|
7
|
+
|
|
8
|
+
FD_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
|
|
9
|
+
TIMESTAMP_MASK = (1 << 48) - 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode(message: Message, channel: int, use_fd_api: bool, fd_enabled: bool, tag: int):
|
|
13
|
+
if not isinstance(message, Message):
|
|
14
|
+
raise ValueError("send expects a can.Message")
|
|
15
|
+
if message.is_error_frame:
|
|
16
|
+
raise ValueError("Transmitting error frames is not supported")
|
|
17
|
+
if not isinstance(message.arbitration_id, int) or not (
|
|
18
|
+
0 <= message.arbitration_id <= (0x1FFFFFFF if message.is_extended_id else 0x7FF)
|
|
19
|
+
):
|
|
20
|
+
raise ValueError("CAN arbitration ID is out of range")
|
|
21
|
+
if message.is_fd and (not fd_enabled or not use_fd_api):
|
|
22
|
+
raise ValueError("FD transmission requires an FD-enabled Bus")
|
|
23
|
+
if message.is_fd and message.is_remote_frame:
|
|
24
|
+
raise ValueError("CAN FD cannot contain RTR frames")
|
|
25
|
+
if not message.is_fd and (message.bitrate_switch or message.error_state_indicator):
|
|
26
|
+
raise ValueError("BRS/ESI require a CAN FD frame")
|
|
27
|
+
if not isinstance(message.dlc, int) or not 0 <= message.dlc <= (64 if message.is_fd else 8):
|
|
28
|
+
raise ValueError("CAN data length is out of range")
|
|
29
|
+
if message.is_remote_frame:
|
|
30
|
+
if len(message.data):
|
|
31
|
+
raise ValueError("RTR frames must not contain data")
|
|
32
|
+
elif message.dlc != len(message.data):
|
|
33
|
+
raise ValueError("Message.dlc must equal the data length")
|
|
34
|
+
if not 1 <= tag <= 65535:
|
|
35
|
+
raise ValueError("TX tag must be between 1 and 65535")
|
|
36
|
+
native = FDMessage() if use_fd_api else ClassicMessage()
|
|
37
|
+
native.MsgFlag = (
|
|
38
|
+
int(message.is_extended_id) | (int(message.is_remote_frame) << 1)
|
|
39
|
+
| (int(message.is_fd) << 2) | (int(message.bitrate_switch) << 3)
|
|
40
|
+
| (int(message.error_state_indicator) << 4)
|
|
41
|
+
)
|
|
42
|
+
native.Bus, native.ID = channel, message.arbitration_id
|
|
43
|
+
native.DLC = (next(i for i, size in enumerate(FD_LENGTHS) if size >= message.dlc)
|
|
44
|
+
if message.is_fd else message.dlc)
|
|
45
|
+
native.Data[:len(message.data)] = message.data
|
|
46
|
+
native.TXCtrl.TxHandle = tag
|
|
47
|
+
native.TXCtrl.MaxTry = 255
|
|
48
|
+
return native
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode(native, timestamp: float) -> Message:
|
|
52
|
+
flags = int(native.MsgFlag)
|
|
53
|
+
is_fd, is_rtr = bool(flags & 4), bool(flags & 2)
|
|
54
|
+
if flags & ~0x1F or (is_fd and is_rtr) or (not is_fd and flags & 0x18):
|
|
55
|
+
raise ValueError(f"Invalid native CAN flags: 0x{flags:02x}")
|
|
56
|
+
if native.DLC > (15 if is_fd else 8):
|
|
57
|
+
raise ValueError("Invalid native DLC")
|
|
58
|
+
if is_fd and not isinstance(native, FDMessage):
|
|
59
|
+
raise ValueError("FD frame in a classic API record")
|
|
60
|
+
length = FD_LENGTHS[native.DLC] if is_fd else native.DLC
|
|
61
|
+
return Message(
|
|
62
|
+
timestamp=timestamp, arbitration_id=native.ID,
|
|
63
|
+
is_extended_id=bool(flags & 1), is_remote_frame=is_rtr,
|
|
64
|
+
is_fd=is_fd, bitrate_switch=bool(flags & 8),
|
|
65
|
+
error_state_indicator=bool(flags & 16), channel=int(native.Bus),
|
|
66
|
+
dlc=length, data=b"" if is_rtr else bytes(native.Data[:length]),
|
|
67
|
+
is_rx=True, check=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TimestampClock:
|
|
72
|
+
def __init__(self, raw_anchor: int, wall_anchor: float):
|
|
73
|
+
self.raw_anchor, self.wall_anchor = raw_anchor, wall_anchor
|
|
74
|
+
|
|
75
|
+
def convert(self, raw) -> float:
|
|
76
|
+
value = int.from_bytes(raw, "little")
|
|
77
|
+
delta = (value - self.raw_anchor) & TIMESTAMP_MASK
|
|
78
|
+
# A record from before initialization must not be dated years into the future.
|
|
79
|
+
if delta > (1 << 47):
|
|
80
|
+
raise ValueError("Timestamp precedes this controller initialization")
|
|
81
|
+
return self.wall_anchor + delta / 1_000_000
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Presets originate in CANPALAPI canpal_driver_tp.cpp at c78e2d3.
|
|
85
|
+
# Classic SJW/BS values use SDK register encodings (time quanta minus one).
|
|
86
|
+
CLASSIC_TIMING = {
|
|
87
|
+
1_000_000: (0, 15, 3), 800_000: (0, 8, 2), 500_000: (0, 9, 2),
|
|
88
|
+
250_000: (0, 15, 3), 125_000: (0, 15, 3), 100_000: (0, 15, 3),
|
|
89
|
+
83_333: (0, 15, 3), 50_000: (0, 15, 3), 33_333: (0, 11, 2),
|
|
90
|
+
20_000: (0, 15, 2), 10_000: (0, 15, 2), 5_000: (0, 15, 2),
|
|
91
|
+
}
|
|
92
|
+
FD_NOMINAL_100 = {
|
|
93
|
+
1_000_000: (3, 18, 6), 500_000: (3, 19, 5), 250_000: (3, 19, 5),
|
|
94
|
+
125_000: (3, 19, 5), 100_000: (4, 31, 8), 83_333: (4, 31, 8),
|
|
95
|
+
50_000: (4, 31, 8), 33_333: (4, 31, 8), 25_000: (4, 31, 8),
|
|
96
|
+
20_000: (4, 31, 8), 15_000: (4, 32, 8), 10_000: (4, 31, 8),
|
|
97
|
+
5_000: (4, 31, 8),
|
|
98
|
+
}
|
|
99
|
+
FD_NOMINAL_80 = {
|
|
100
|
+
1_000_000: (2, 11, 4), 500_000: (3, 15, 4), 250_000: (3, 15, 4),
|
|
101
|
+
125_000: (3, 15, 4), 100_000: (4, 19, 5), 83_333: (3, 19, 4),
|
|
102
|
+
50_000: (5, 25, 6), 33_333: (5, 23, 6), 25_000: (4, 34, 5),
|
|
103
|
+
20_000: (4, 34, 5), 15_000: (6, 40, 7), 10_000: (5, 43, 6),
|
|
104
|
+
5_000: (6, 56, 7),
|
|
105
|
+
}
|
|
106
|
+
FD_DATA_100 = {5_000_000: (1, 13, 6), 4_000_000: (1, 18, 6), 2_000_000: (1, 18, 6)}
|
|
107
|
+
FD_DATA_80 = {
|
|
108
|
+
8_000_000: (1, 6, 3), 5_000_000: (1, 11, 4),
|
|
109
|
+
4_000_000: (1, 13, 6), 2_000_000: (1, 29, 10),
|
|
110
|
+
}
|
canpal/discovery.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""USB enumeration only: no CAN traffic and no network discovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from serial.tools import list_ports
|
|
9
|
+
|
|
10
|
+
VID_PID = (0x0483, 0x5740)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_uid(value: str | None) -> str | None:
|
|
14
|
+
if value is None:
|
|
15
|
+
return None
|
|
16
|
+
if not isinstance(value, str) or re.fullmatch(r"[0-9a-fA-F]{24}", value) is None:
|
|
17
|
+
raise ValueError("device_id must contain exactly 24 hexadecimal digits")
|
|
18
|
+
return value.lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_port(value: str, platform: str | None = None) -> tuple[str, int]:
|
|
22
|
+
platform = platform or sys.platform
|
|
23
|
+
if not isinstance(value, str):
|
|
24
|
+
raise ValueError("usb_port must be a COM name or /dev/ttyACM path")
|
|
25
|
+
if platform == "win32":
|
|
26
|
+
match = re.fullmatch(r"COM([0-9]+)", value, re.IGNORECASE)
|
|
27
|
+
number = int(match[1]) if match else -1
|
|
28
|
+
if not 1 <= number <= 65535:
|
|
29
|
+
raise ValueError(f"Invalid CANPAL USB port: {value!r}")
|
|
30
|
+
return f"COM{number}", number
|
|
31
|
+
if value.startswith("/dev/serial/by-id/"):
|
|
32
|
+
value = str(Path(value).resolve(strict=True))
|
|
33
|
+
match = re.fullmatch(r"/dev/ttyACM([0-9]+)", value)
|
|
34
|
+
number = int(match[1]) if match else -1
|
|
35
|
+
if not 0 <= number <= 65535:
|
|
36
|
+
raise ValueError(f"Invalid CANPAL USB port: {value!r}")
|
|
37
|
+
return f"/dev/ttyACM{number}", number
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def usb_candidates(preferred: str | None, occupied: set[str], discover: bool):
|
|
41
|
+
records = list(list_ports.comports())
|
|
42
|
+
candidates = []
|
|
43
|
+
if preferred is not None:
|
|
44
|
+
name, number = normalize_port(preferred)
|
|
45
|
+
known = next((item for item in records if item.device.lower() == name.lower()), None)
|
|
46
|
+
incompatible = (known is not None and known.vid is not None and known.pid is not None
|
|
47
|
+
and (known.vid, known.pid) != VID_PID)
|
|
48
|
+
if incompatible and not discover:
|
|
49
|
+
raise ValueError(f"{name} belongs to a different USB product")
|
|
50
|
+
if not incompatible:
|
|
51
|
+
candidates.append((name, number))
|
|
52
|
+
if discover:
|
|
53
|
+
discovered = []
|
|
54
|
+
for item in records:
|
|
55
|
+
if (item.vid, item.pid) != VID_PID:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
discovered.append(normalize_port(item.device))
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
candidates.extend(sorted(discovered, key=lambda candidate: candidate[1]))
|
|
62
|
+
seen = set()
|
|
63
|
+
for name, number in candidates:
|
|
64
|
+
if name not in seen and name not in occupied:
|
|
65
|
+
seen.add(name)
|
|
66
|
+
yield name, number
|
canpal/native.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""ctypes bindings to the packed CANPAL v1.3.7.6 C interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ctypes as C
|
|
5
|
+
import ctypes.util
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import struct
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from can import CanInterfaceNotImplementedError
|
|
14
|
+
|
|
15
|
+
U8, U16, U32 = C.c_uint8, C.c_uint16, C.c_uint32
|
|
16
|
+
P = C.POINTER
|
|
17
|
+
HANDLE = C.c_void_p
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Packed(C.Structure):
|
|
21
|
+
_pack_ = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Connection(Packed):
|
|
25
|
+
_fields_ = [
|
|
26
|
+
("CommType", U32), ("DestHost", C.c_char * 256), ("DestPort", U16),
|
|
27
|
+
("LocalHost", C.c_char * 256), ("LocalPort", U16),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeviceInfo(Packed):
|
|
32
|
+
_fields_ = [
|
|
33
|
+
("DeviceName", C.c_char * 20), ("DeviceModel", U32),
|
|
34
|
+
("HWVer", U16), ("SWVer", U16), ("UniqueID", U8 * 12),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def uid(self):
|
|
39
|
+
return bytes(self.UniqueID).hex()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Capacity(Packed):
|
|
43
|
+
_fields_ = [(name, U8) for name in (
|
|
44
|
+
"CAN_Classic_supported", "CAN_FD_supported", "LIN_supported",
|
|
45
|
+
"KLine_supported", "ISO_TP_supported", "Wifi_supported",
|
|
46
|
+
"Ethernet_supported", "USB_supported", "CAN_channel_num",
|
|
47
|
+
"LIN_channel_num", "KLine_channel_num",
|
|
48
|
+
)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Parameter(Packed):
|
|
52
|
+
_fields_ = [("ID", U8), ("Data", U8 * 32), ("Len", U8)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TxControl(Packed):
|
|
56
|
+
_fields_ = [("TxHandle", U16), ("MaxTry", U8), ("MinGap", U8), ("Result", U16)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ClassicMessage(Packed):
|
|
60
|
+
_fields_ = [
|
|
61
|
+
("MsgType", U8), ("MsgFlag", U8), ("Bus", U8), ("ID", U32),
|
|
62
|
+
("DLC", U8), ("Data", U8 * 8), ("Timestamp", U8 * 6),
|
|
63
|
+
("TXCtrl", TxControl),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FDMessage(Packed):
|
|
68
|
+
_fields_ = [
|
|
69
|
+
("MsgType", U8), ("MsgFlag", U8), ("Bus", U8), ("ID", U32),
|
|
70
|
+
("DLC", U8), ("Timestamp", U8 * 6), ("TXCtrl", TxControl),
|
|
71
|
+
("Data", U8 * 64),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Controller(Packed):
|
|
76
|
+
_fields_ = [
|
|
77
|
+
("CANMode", U8), ("Baudrate", U32), ("SJW", U8), ("BS1", U8),
|
|
78
|
+
("BS2", U8), ("Option", U8),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FDController(Packed):
|
|
83
|
+
_fields_ = [
|
|
84
|
+
("CANMode", U8), ("NomBaudrate", U32), ("NomSJW", U8),
|
|
85
|
+
("NomBS1", U16), ("NomBS2", U8), ("DataBaudrate", U32),
|
|
86
|
+
("DataSJW", U8), ("DataBS1", U8), ("DataBS2", U8),
|
|
87
|
+
("FrameMode", U8), ("ResistorEnable", U8), ("ISOModeEnable", U8),
|
|
88
|
+
("CANTPEnable", U8),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InitHW(Packed):
|
|
93
|
+
_fields_ = [("Inited", U8), ("Timestamp", U8 * 6)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Filter(Packed):
|
|
97
|
+
_fields_ = [
|
|
98
|
+
("Index", U8), ("Active", U8), ("FilterType", U8),
|
|
99
|
+
("ID", U32), ("Mask", U32),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
ABI_TYPES = {
|
|
104
|
+
"YXConnection": Connection, "YXDeviceInfo": DeviceInfo, "YXCapacity": Capacity,
|
|
105
|
+
"YXParameter": Parameter, "YXCANMsgTxControl": TxControl,
|
|
106
|
+
"YXCANMsg": ClassicMessage, "YXCANFDMsg": FDMessage,
|
|
107
|
+
"YXCANController": Controller, "YXCANFDController": FDController,
|
|
108
|
+
"YXCANInitHW": InitHW, "YXCANFilter": Filter,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Native:
|
|
113
|
+
def __init__(self, path: str):
|
|
114
|
+
self.path = path
|
|
115
|
+
self._dll_dirs = []
|
|
116
|
+
try:
|
|
117
|
+
if sys.platform == "win32":
|
|
118
|
+
if Path(path).is_file():
|
|
119
|
+
self._dll_dirs.append(os.add_dll_directory(str(Path(path).resolve().parent)))
|
|
120
|
+
for directory in os.environ.get("CANPAL_DEPENDENCY_DIRS", "").split(os.pathsep):
|
|
121
|
+
if directory:
|
|
122
|
+
self._dll_dirs.append(os.add_dll_directory(str(Path(directory).resolve())))
|
|
123
|
+
self.lib = C.WinDLL(path)
|
|
124
|
+
else:
|
|
125
|
+
self.lib = C.CDLL(path)
|
|
126
|
+
self._bind("YX_Open", HANDLE, P(Connection))
|
|
127
|
+
self._bind("YX_Close", None, HANDLE)
|
|
128
|
+
self._bind("YX_Online", C.c_bool, HANDLE)
|
|
129
|
+
self._bind("YX_GetDeviceInfo", C.c_bool, HANDLE, P(DeviceInfo))
|
|
130
|
+
self._bind("YX_GetCapacity", C.c_bool, U32, P(Capacity))
|
|
131
|
+
self._bind("YX_SetParameter", U32, HANDLE, P(Parameter))
|
|
132
|
+
self._bind("YX_GetParameter", U32, HANDLE, P(Parameter))
|
|
133
|
+
self._bind("YXCAN_Initialize", U32, HANDLE, U8, P(InitHW))
|
|
134
|
+
self._bind("YXCAN_Uninitialize", U32, HANDLE, U8)
|
|
135
|
+
for suffix, message, controller in (
|
|
136
|
+
("", ClassicMessage, Controller), ("FD", FDMessage, FDController),
|
|
137
|
+
):
|
|
138
|
+
self._bind("YXCAN_Send" + suffix, C.c_bool, HANDLE, P(message))
|
|
139
|
+
self._bind("YXCAN_Receive" + suffix, C.c_bool, HANDLE, P(message), U32)
|
|
140
|
+
self._bind("YXCAN_SetController" + suffix, U32, HANDLE, U8, P(controller))
|
|
141
|
+
self._bind("YXCAN_GetController" + suffix, U32, HANDLE, U8, P(controller))
|
|
142
|
+
self._bind("YXCAN_SetFilter", U32, HANDLE, U8, P(Filter))
|
|
143
|
+
self._bind("YXCAN_GetFilter", U32, HANDLE, U8, P(Filter))
|
|
144
|
+
except (OSError, AttributeError) as exc:
|
|
145
|
+
for directory in self._dll_dirs:
|
|
146
|
+
directory.close()
|
|
147
|
+
raise CanInterfaceNotImplementedError(
|
|
148
|
+
f"Cannot load CANPAL library {path!r}: {exc}"
|
|
149
|
+
) from exc
|
|
150
|
+
|
|
151
|
+
def _bind(self, name, restype, *argtypes):
|
|
152
|
+
function = getattr(self.lib, name)
|
|
153
|
+
function.restype = restype
|
|
154
|
+
function.argtypes = list(argtypes)
|
|
155
|
+
setattr(self, name, function)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_load_lock = threading.Lock()
|
|
159
|
+
_loaded: Native | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_library(path=None) -> Native:
|
|
163
|
+
global _loaded
|
|
164
|
+
if (struct.calcsize("P") != 8 or sys.platform not in ("win32", "linux")
|
|
165
|
+
or platform.machine().lower() not in ("amd64", "x86_64")):
|
|
166
|
+
raise CanInterfaceNotImplementedError("CANPAL requires x86-64 Windows or Linux")
|
|
167
|
+
candidate = path or os.environ.get("CANPAL_LIBRARY")
|
|
168
|
+
if candidate:
|
|
169
|
+
try:
|
|
170
|
+
candidate = str(Path(candidate).expanduser().resolve(strict=True))
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise CanInterfaceNotImplementedError(f"CANPAL library does not exist: {candidate}") from exc
|
|
173
|
+
else:
|
|
174
|
+
candidate = ctypes.util.find_library("CANPAL")
|
|
175
|
+
if not candidate:
|
|
176
|
+
raise CanInterfaceNotImplementedError(
|
|
177
|
+
"Set library_path or CANPAL_LIBRARY to the installed CANPAL DLL/SO"
|
|
178
|
+
)
|
|
179
|
+
with _load_lock:
|
|
180
|
+
if _loaded is not None:
|
|
181
|
+
if os.path.normcase(_loaded.path) != os.path.normcase(candidate):
|
|
182
|
+
raise CanInterfaceNotImplementedError(
|
|
183
|
+
"A different CANPAL SDK is already loaded in this process"
|
|
184
|
+
)
|
|
185
|
+
return _loaded
|
|
186
|
+
_loaded = Native(candidate)
|
|
187
|
+
return _loaded
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-can-canpal
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: CANPAL USB and TCP backend for python-can
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
11
|
+
Requires-Python: <3.13,>=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: python-can==4.6.1
|
|
15
|
+
Requires-Dist: pyserial==3.5
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest<10,>=8; extra == "test"
|
|
18
|
+
Requires-Dist: build<2,>=1; extra == "test"
|
|
19
|
+
Requires-Dist: twine<8,>=6; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# python-can-canpal
|
|
23
|
+
|
|
24
|
+
CANPAL USB and TCP client backend for [python-can](https://python-can.readthedocs.io/).
|
|
25
|
+
|
|
26
|
+
**0.1.0rc1 is an experimental preview. Full hardware release acceptance is incomplete.**
|
|
27
|
+
Use it for evaluation on an isolated test bench. It is not the qualified 0.1.0 release.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Requires Python 3.12, python-can 4.6.1, pySerial 3.5, and a separately supplied
|
|
32
|
+
64-bit CANPAL native SDK with the fixes described below.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
python -m pip install python-can-canpal==0.1.0rc1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The Python package does **not** include the CANPAL SDK, firmware, device drivers,
|
|
39
|
+
Qt/MinGW runtime, or fake binaries. Obtain compatible native components from your
|
|
40
|
+
CANPAL supplier before attempting hardware access. The MIT license applies to
|
|
41
|
+
the new Python plugin, tests and documentation; native components have separate licenses.
|
|
42
|
+
|
|
43
|
+
## What has been verified
|
|
44
|
+
|
|
45
|
+
| Environment | Preview evidence |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Windows 11 x64 | X5 classic CAN/RTR and X6 classic CAN/RTR/ISO FD over USB and explicit-IP WLAN TCP |
|
|
48
|
+
| Windows, multiple devices | Short concurrent checks: two USB devices, X5 USB + X6 WLAN, X5 WLAN + X6 USB |
|
|
49
|
+
| Ubuntu 24.04 x86_64 in Hyper-V | Software, ABI and real SDK regressions; X5/X6 hardware checks through temporary SSH TCP forwarding via a Windows WLAN connection |
|
|
50
|
+
|
|
51
|
+
Both devices had CAN1 wired to their own CAN2 on separate physical buses.
|
|
52
|
+
These checks do not use an independent reference CAN interface. Classic checks
|
|
53
|
+
covered standard/extended frames and RTR DLC 0–8 in both directions. X6 FD checks
|
|
54
|
+
covered lengths 0–64 and BRS off/on. The post-fix checks verified 5,280 frames in total.
|
|
55
|
+
|
|
56
|
+
**Not yet qualified:** independent-reference CAN/CAN FD interoperability, native
|
|
57
|
+
Linux USB or direct Linux WLAN, two simultaneous WLAN devices, controlled physical
|
|
58
|
+
port renumbering/disconnection, rated-load/endurance runs, and native resource
|
|
59
|
+
stability. Fake tests cover additional error paths but do not establish those hardware claims.
|
|
60
|
+
Formal 0.1.0 retains the complete release acceptance requirements.
|
|
61
|
+
|
|
62
|
+
## Native SDK and firmware requirements
|
|
63
|
+
|
|
64
|
+
The tested SDK source revision is `e222ccf48f29c530fd09617df6e3a83a8095d25f`.
|
|
65
|
+
It includes command ACK matching, cooperative worker shutdown and a Qt serial
|
|
66
|
+
buffered-read fix. Matching exported function names alone does not establish compatibility.
|
|
67
|
+
|
|
68
|
+
Classic RTR also requires firmware that preserves the received DLC. The original
|
|
69
|
+
X5 firmware reporting `0x0332` and X6 firmware reporting `0x0127` cleared RTR DLC
|
|
70
|
+
to zero. Supplier-signed diagnostic builds fixed this on the two test devices.
|
|
71
|
+
Those builds still report the same version numbers: ask your supplier to identify
|
|
72
|
+
the actual fixed build. The plugin cannot recover DLC that firmware has discarded.
|
|
73
|
+
|
|
74
|
+
Set `CANPAL_LIBRARY` to the absolute DLL/SO path, or pass `library_path`.
|
|
75
|
+
On Windows, `CANPAL_DEPENDENCY_DIRS` may list Qt/MinGW runtime directories,
|
|
76
|
+
separated by semicolons. On Linux, install the matching Qt 5 runtime and grant
|
|
77
|
+
serial-port access through udev or the dialout group.
|
|
78
|
+
|
|
79
|
+
## Connect by UID or IP
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import can
|
|
83
|
+
|
|
84
|
+
with can.Bus(interface="canpal", ignore_config=True,
|
|
85
|
+
transport="usb", device_id="YOUR_24_HEX_DIGIT_UID",
|
|
86
|
+
channel=0, bitrate=500_000) as bus:
|
|
87
|
+
message = bus.recv(timeout=1)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The device UID remains the selector when a COM/ttyACM number changes. After a
|
|
91
|
+
reconnection, close and recreate the Bus using the same UID; automatic reconnect
|
|
92
|
+
is not implemented. Physical port renumbering acceptance is still pending.
|
|
93
|
+
|
|
94
|
+
Use `transport="wlan", host="192.0.2.10", port=8000` for TCP direct connection.
|
|
95
|
+
The IP and port must match the actual device; no network discovery is attempted.
|
|
96
|
+
Keep `device_id` to verify identity at that endpoint. X6 CAN FD additionally uses
|
|
97
|
+
`fd=True, data_bitrate=2_000_000`.
|
|
98
|
+
|
|
99
|
+
Different physical devices use independent Bus objects. A device can have one
|
|
100
|
+
Bus and one selected CAN channel at a time. Use one Notifier per device for
|
|
101
|
+
independent fault handling. UDP, server mode, transmit echo and concurrent
|
|
102
|
+
channels on one device are not implemented.
|
|
103
|
+
|
|
104
|
+
A successful `send()` means the native queue accepted the frame, not that the
|
|
105
|
+
CAN bus acknowledged it. Use a single receiver per Bus, stop its Notifier before
|
|
106
|
+
shutdown, and handle python-can exceptions. Timestamp alignment is approximate
|
|
107
|
+
per device and does not provide precise synchronization between devices.
|
|
108
|
+
|
|
109
|
+
The source distribution includes Chinese installation, testing and release notes.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
canpal/__init__.py,sha256=ma9PXNvzG0PuVnfsA_VgWV0fEqEnjENeKocNVhpjGQ0,247
|
|
2
|
+
canpal/bus.py,sha256=qvbEPcj6JHpb9CSuWfOmXwSyc-7qfgaAJ6GL_CLkSUM,15772
|
|
3
|
+
canpal/codec.py,sha256=6Ca234uYrnLZnvHHss4zmTg9IGMsdlsJlNGPe6mjDFw,4991
|
|
4
|
+
canpal/discovery.py,sha256=UNx9Eaem9p-33Na_YwKUDOF-k2bIkpLlX2Ob6UiMWgU,2620
|
|
5
|
+
canpal/native.py,sha256=C5FyVPf5x3TjTFduRuaYYD1TFFDj84eqfzX0j0T0Ftk,6626
|
|
6
|
+
python_can_canpal-0.1.0rc1.dist-info/licenses/LICENSE,sha256=kEiYDR3fINcxYdg12ab44v0EmiP5ktgYtNML_-LllSE,1309
|
|
7
|
+
python_can_canpal-0.1.0rc1.dist-info/METADATA,sha256=XZ2l8PEDgaK6awzUTj3zP9FlSpQzUL8M-l_PfTVjUk4,5276
|
|
8
|
+
python_can_canpal-0.1.0rc1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
python_can_canpal-0.1.0rc1.dist-info/entry_points.txt,sha256=U9E74XVtECBGl6VUWoQpI_2tBmb2lkOBRJ048FCQCeM,46
|
|
10
|
+
python_can_canpal-0.1.0rc1.dist-info/top_level.txt,sha256=WsJHmkaVmRho9-7_TQ3I8BwrHsYL4HT9C0H2d1bsFXQ,7
|
|
11
|
+
python_can_canpal-0.1.0rc1.dist-info/RECORD,,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Python CANPAL contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
This license applies to the newly developed PythonCAN plugin, test harness,
|
|
24
|
+
and documentation. The separately supplied CANPAL SDK, its headers, firmware,
|
|
25
|
+
Qt, and other third-party components retain their respective licenses.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
canpal
|