pyxcp 0.23.3__cp312-cp312-win_arm64.whl → 0.25.6__cp312-cp312-win_arm64.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.
Files changed (87) hide show
  1. pyxcp/__init__.py +1 -1
  2. pyxcp/asamkeydll.exe +0 -0
  3. pyxcp/cmdline.py +15 -30
  4. pyxcp/config/__init__.py +73 -20
  5. pyxcp/cpp_ext/aligned_buffer.hpp +168 -0
  6. pyxcp/cpp_ext/bin.hpp +7 -6
  7. pyxcp/cpp_ext/cpp_ext.cp310-win_arm64.pyd +0 -0
  8. pyxcp/cpp_ext/cpp_ext.cp311-win_arm64.pyd +0 -0
  9. pyxcp/cpp_ext/cpp_ext.cp312-win_arm64.pyd +0 -0
  10. pyxcp/cpp_ext/daqlist.hpp +241 -73
  11. pyxcp/cpp_ext/extension_wrapper.cpp +123 -15
  12. pyxcp/cpp_ext/framing.hpp +360 -0
  13. pyxcp/cpp_ext/mcobject.hpp +5 -3
  14. pyxcp/cpp_ext/sxi_framing.hpp +332 -0
  15. pyxcp/daq_stim/__init__.py +182 -45
  16. pyxcp/daq_stim/optimize/binpacking.py +2 -2
  17. pyxcp/daq_stim/scheduler.cpp +8 -8
  18. pyxcp/daq_stim/stim.cp310-win_arm64.pyd +0 -0
  19. pyxcp/daq_stim/stim.cp311-win_arm64.pyd +0 -0
  20. pyxcp/daq_stim/stim.cp312-win_arm64.pyd +0 -0
  21. pyxcp/errormatrix.py +2 -2
  22. pyxcp/examples/run_daq.py +5 -3
  23. pyxcp/examples/xcp_policy.py +6 -6
  24. pyxcp/examples/xcp_read_benchmark.py +2 -2
  25. pyxcp/examples/xcp_skel.py +1 -2
  26. pyxcp/examples/xcp_unlock.py +10 -12
  27. pyxcp/examples/xcp_user_supplied_driver.py +1 -2
  28. pyxcp/examples/xcphello.py +2 -15
  29. pyxcp/examples/xcphello_recorder.py +2 -2
  30. pyxcp/master/__init__.py +1 -0
  31. pyxcp/master/errorhandler.py +248 -13
  32. pyxcp/master/master.py +838 -250
  33. pyxcp/recorder/.idea/.gitignore +8 -0
  34. pyxcp/recorder/.idea/misc.xml +4 -0
  35. pyxcp/recorder/.idea/modules.xml +8 -0
  36. pyxcp/recorder/.idea/recorder.iml +6 -0
  37. pyxcp/recorder/.idea/sonarlint/issuestore/3/8/3808afc69ac1edb9d760000a2f137335b1b99728 +7 -0
  38. pyxcp/recorder/.idea/sonarlint/issuestore/9/a/9a2aa4db38d3115ed60da621e012c0efc0172aae +0 -0
  39. pyxcp/recorder/.idea/sonarlint/issuestore/b/4/b49006702b459496a8e8c94ebe60947108361b91 +0 -0
  40. pyxcp/recorder/.idea/sonarlint/issuestore/index.pb +7 -0
  41. pyxcp/recorder/.idea/sonarlint/securityhotspotstore/3/8/3808afc69ac1edb9d760000a2f137335b1b99728 +0 -0
  42. pyxcp/recorder/.idea/sonarlint/securityhotspotstore/9/a/9a2aa4db38d3115ed60da621e012c0efc0172aae +0 -0
  43. pyxcp/recorder/.idea/sonarlint/securityhotspotstore/b/4/b49006702b459496a8e8c94ebe60947108361b91 +0 -0
  44. pyxcp/recorder/.idea/sonarlint/securityhotspotstore/index.pb +7 -0
  45. pyxcp/recorder/.idea/vcs.xml +10 -0
  46. pyxcp/recorder/__init__.py +5 -10
  47. pyxcp/recorder/converter/__init__.py +4 -10
  48. pyxcp/recorder/reader.hpp +0 -1
  49. pyxcp/recorder/reco.py +1 -0
  50. pyxcp/recorder/rekorder.cp310-win_arm64.pyd +0 -0
  51. pyxcp/recorder/rekorder.cp311-win_arm64.pyd +0 -0
  52. pyxcp/recorder/rekorder.cp312-win_arm64.pyd +0 -0
  53. pyxcp/recorder/unfolder.hpp +129 -107
  54. pyxcp/recorder/wrap.cpp +3 -8
  55. pyxcp/scripts/xcp_fetch_a2l.py +2 -2
  56. pyxcp/scripts/xcp_id_scanner.py +1 -2
  57. pyxcp/scripts/xcp_info.py +66 -51
  58. pyxcp/scripts/xcp_profile.py +1 -2
  59. pyxcp/tests/test_daq.py +1 -1
  60. pyxcp/tests/test_framing.py +262 -0
  61. pyxcp/tests/test_master.py +210 -100
  62. pyxcp/tests/test_transport.py +138 -42
  63. pyxcp/timing.py +1 -1
  64. pyxcp/transport/__init__.py +8 -5
  65. pyxcp/transport/base.py +187 -143
  66. pyxcp/transport/can.py +117 -13
  67. pyxcp/transport/eth.py +55 -20
  68. pyxcp/transport/hdf5_policy.py +167 -0
  69. pyxcp/transport/sxi.py +126 -52
  70. pyxcp/transport/transport_ext.cp310-win_arm64.pyd +0 -0
  71. pyxcp/transport/transport_ext.cp311-win_arm64.pyd +0 -0
  72. pyxcp/transport/transport_ext.cp312-win_arm64.pyd +0 -0
  73. pyxcp/transport/transport_ext.hpp +214 -0
  74. pyxcp/transport/transport_wrapper.cpp +249 -0
  75. pyxcp/transport/usb_transport.py +47 -31
  76. pyxcp/types.py +0 -13
  77. pyxcp/{utils.py → utils/__init__.py} +3 -4
  78. pyxcp/utils/cli.py +78 -0
  79. pyxcp-0.25.6.dist-info/METADATA +341 -0
  80. pyxcp-0.25.6.dist-info/RECORD +153 -0
  81. {pyxcp-0.23.3.dist-info → pyxcp-0.25.6.dist-info}/WHEEL +1 -1
  82. pyxcp/examples/conf_sxi.json +0 -9
  83. pyxcp/examples/conf_sxi.toml +0 -7
  84. pyxcp-0.23.3.dist-info/METADATA +0 -219
  85. pyxcp-0.23.3.dist-info/RECORD +0 -131
  86. {pyxcp-0.23.3.dist-info → pyxcp-0.25.6.dist-info}/entry_points.txt +0 -0
  87. {pyxcp-0.23.3.dist-info → pyxcp-0.25.6.dist-info/licenses}/LICENSE +0 -0
pyxcp/transport/base.py CHANGED
@@ -3,127 +3,50 @@ import abc
3
3
  import logging
4
4
  import threading
5
5
  from collections import deque
6
- from typing import Any, Dict, Optional, Set, Type
7
-
6
+ from typing import Any, Dict, Optional, Type
7
+ from pyxcp.timing import Timing
8
8
  import pyxcp.types as types
9
+
9
10
  from pyxcp.cpp_ext.cpp_ext import Timestamp, TimestampType
10
- from pyxcp.recorder import XcpLogFileWriter
11
- from pyxcp.timing import Timing
11
+ from pyxcp.transport.transport_ext import (
12
+ FrameCategory,
13
+ FrameAcquisitionPolicy,
14
+ LegacyFrameAcquisitionPolicy,
15
+ XcpFraming,
16
+ XcpFramingConfig,
17
+ XcpTransportLayerType, # noqa: F401
18
+ ChecksumType, # noqa: F401
19
+ )
12
20
  from pyxcp.utils import (
13
21
  CurrentDatetime,
14
- flatten,
15
22
  hexDump,
16
23
  seconds_to_nanoseconds,
17
24
  short_sleep,
18
25
  )
19
26
 
20
27
 
21
- class FrameAcquisitionPolicy:
22
- """
23
- Base class for all frame acquisition policies.
24
-
25
- Parameters
26
- ---------
27
- filter_out: set or None
28
- A set of frame types to filter out.
29
- If None, all frame types are accepted for further processing.
30
-
31
- Example: (FrameType.REQUEST, FrameType.RESPONSE, FrameType.EVENT, FrameType.SERV)
32
- ==> care only about DAQ frames.
33
- """
34
-
35
- def __init__(self, filter_out: Optional[Set[types.FrameCategory]] = None):
36
- self._frame_types_to_filter_out = filter_out or set()
37
-
38
- @property
39
- def filtered_out(self) -> Set[types.FrameCategory]:
40
- return self._frame_types_to_filter_out
41
-
42
- def feed(self, frame_type: types.FrameCategory, counter: int, timestamp: int, payload: bytes) -> None: ... # noqa: E704
43
-
44
- def finalize(self) -> None:
45
- """
46
- Finalize the frame acquisition policy (if required).
47
- """
48
- ...
49
-
50
-
51
- class NoOpPolicy(FrameAcquisitionPolicy):
52
- """
53
- No operation / do nothing policy.
54
- """
55
-
56
-
57
- class LegacyFrameAcquisitionPolicy(FrameAcquisitionPolicy):
58
- """Dequeue based frame acquisition policy.
59
-
60
- Deprecated: Use only for compatibility reasons.
61
- """
62
-
63
- def __init__(self, filter_out: Optional[Set[types.FrameCategory]] = None) -> None:
64
- super().__init__(filter_out)
65
- self.reqQueue = deque()
66
- self.resQueue = deque()
67
- self.daqQueue = deque()
68
- self.evQueue = deque()
69
- self.servQueue = deque()
70
- self.metaQueue = deque()
71
- self.errorQueue = deque()
72
- self.stimQueue = deque()
73
- self.QUEUE_MAP = {
74
- types.FrameCategory.CMD: self.reqQueue,
75
- types.FrameCategory.RESPONSE: self.resQueue,
76
- types.FrameCategory.EVENT: self.evQueue,
77
- types.FrameCategory.SERV: self.servQueue,
78
- types.FrameCategory.DAQ: self.daqQueue,
79
- types.FrameCategory.METADATA: self.metaQueue,
80
- types.FrameCategory.ERROR: self.errorQueue,
81
- types.FrameCategory.STIM: self.stimQueue,
82
- }
83
-
84
- def feed(self, frame_type: types.FrameCategory, counter: int, timestamp: int, payload: bytes) -> None:
85
- if frame_type not in self.filtered_out:
86
- queue = self.QUEUE_MAP.get(frame_type)
87
- if queue is not None:
88
- queue.append((counter, timestamp, payload))
89
-
90
-
91
- class FrameRecorderPolicy(FrameAcquisitionPolicy):
92
- """Frame acquisition policy that records frames."""
93
-
94
- def __init__(
95
- self,
96
- file_name: str,
97
- filter_out: Optional[Set[types.FrameCategory]] = None,
98
- prealloc: int = 10,
99
- chunk_size: int = 1,
100
- ) -> None:
101
- super().__init__(filter_out)
102
- self.recorder = XcpLogFileWriter(file_name, prealloc=prealloc, chunk_size=chunk_size)
103
-
104
- def feed(self, frame_type: types.FrameCategory, counter: int, timestamp: int, payload: bytes) -> None:
105
- if frame_type not in self.filtered_out:
106
- self.recorder.add_frame(frame_type, counter, timestamp, payload)
107
-
108
- def finalize(self) -> None:
109
- self.recorder.finalize()
110
-
111
-
112
- class StdoutPolicy(FrameAcquisitionPolicy):
113
- """Frame acquisition policy that prints frames to stdout."""
114
-
115
- def __init__(self, filter_out: Optional[Set[types.FrameCategory]] = None) -> None:
116
- super().__init__(filter_out)
117
-
118
- def feed(self, frame_type: types.FrameCategory, counter: int, timestamp: int, payload: bytes) -> None:
119
- if frame_type not in self.filtered_out:
120
- print(f"{frame_type.name:8} {counter:6} {timestamp:8d} {hexDump(payload)}")
121
-
122
-
123
28
  class EmptyFrameError(Exception):
124
29
  """Raised when an empty frame is received."""
125
30
 
126
31
 
32
+ def parse_header_format(header_format: str) -> tuple:
33
+ """SxI and USB framing is configurable."""
34
+ if header_format == "HEADER_LEN_BYTE":
35
+ return 1, 0, 0
36
+ elif header_format == "HEADER_LEN_CTR_BYTE":
37
+ return 1, 1, 0
38
+ elif header_format == "HEADER_LEN_FILL_BYTE":
39
+ return 1, 0, 1
40
+ elif header_format == "HEADER_LEN_WORD":
41
+ return 2, 0, 0
42
+ elif header_format == "HEADER_LEN_CTR_WORD":
43
+ return 2, 2, 0
44
+ elif header_format == "HEADER_LEN_FILL_WORD":
45
+ return 2, 0, 2
46
+ else:
47
+ raise ValueError(f"Invalid header format: {header_format}")
48
+
49
+
127
50
  class BaseTransport(metaclass=abc.ABCMeta):
128
51
  """Base class for transport-layers (Can, Eth, Sxi).
129
52
 
@@ -136,10 +59,17 @@ class BaseTransport(metaclass=abc.ABCMeta):
136
59
 
137
60
  """
138
61
 
139
- def __init__(self, config, policy: Optional[FrameAcquisitionPolicy] = None, transport_layer_interface: Optional[Any] = None):
62
+ def __init__(
63
+ self,
64
+ config,
65
+ framing_config: XcpFramingConfig,
66
+ policy: Optional[FrameAcquisitionPolicy] = None,
67
+ transport_layer_interface: Optional[Any] = None,
68
+ ):
140
69
  self.has_user_supplied_interface: bool = transport_layer_interface is not None
141
70
  self.transport_layer_interface: Optional[Any] = transport_layer_interface
142
71
  self.parent = None
72
+ self.framing = XcpFraming(framing_config)
143
73
  self.policy: FrameAcquisitionPolicy = policy or LegacyFrameAcquisitionPolicy()
144
74
  self.closeEvent: threading.Event = threading.Event()
145
75
 
@@ -150,7 +80,6 @@ class BaseTransport(metaclass=abc.ABCMeta):
150
80
  self._debug: bool = self.logger.level == 10
151
81
  if transport_layer_interface:
152
82
  self.logger.info(f"Transport - User Supplied Transport-Layer Interface: '{transport_layer_interface!s}'")
153
- self.counter_send: int = 0
154
83
  self.counter_received: int = -1
155
84
  self.create_daq_timestamps: bool = config.create_daq_timestamps
156
85
  self.timestamp = Timestamp(TimestampType.ABSOLUTE_TS)
@@ -164,6 +93,7 @@ class BaseTransport(metaclass=abc.ABCMeta):
164
93
  target=self.listen,
165
94
  args=(),
166
95
  kwargs={},
96
+ daemon=True,
167
97
  )
168
98
 
169
99
  self.first_daq_timestamp: Optional[int] = None
@@ -172,6 +102,13 @@ class BaseTransport(metaclass=abc.ABCMeta):
172
102
  self.pre_send_timestamp: int = self.timestamp.value
173
103
  self.post_send_timestamp: int = self.timestamp.value
174
104
  self.recv_timestamp: int = self.timestamp.value
105
+ # Ring buffer for last PDUs to aid diagnostics on failures
106
+ try:
107
+ from collections import deque as _dq
108
+
109
+ self._last_pdus = _dq(maxlen=200)
110
+ except Exception:
111
+ self._last_pdus = []
175
112
 
176
113
  def __del__(self) -> None:
177
114
  self.finish_listener()
@@ -185,8 +122,12 @@ class BaseTransport(metaclass=abc.ABCMeta):
185
122
  def close(self) -> None:
186
123
  """Close the transport-layer connection and event-loop."""
187
124
  self.finish_listener()
188
- if self.listener.is_alive():
189
- self.listener.join()
125
+ # Avoid indefinite blocking on buggy threads
126
+ try:
127
+ if self.listener.is_alive():
128
+ self.listener.join(timeout=2.0)
129
+ except Exception:
130
+ pass
190
131
  self.close_connection()
191
132
 
192
133
  @abc.abstractmethod
@@ -204,7 +145,6 @@ class BaseTransport(metaclass=abc.ABCMeta):
204
145
  raise EmptyFrameError
205
146
  short_sleep()
206
147
  item = self.resQueue.popleft()
207
- # print("Q", item)
208
148
  return item
209
149
 
210
150
  @property
@@ -220,9 +160,14 @@ class BaseTransport(metaclass=abc.ABCMeta):
220
160
  def start_listener(self):
221
161
  if self.listener.is_alive():
222
162
  self.finish_listener()
223
- self.listener.join()
163
+ # Avoid indefinite blocking on buggy threads
164
+ self.listener.join(timeout=2.0)
165
+
166
+ # Ensure the close event is cleared before starting a new listener thread.
167
+ if hasattr(self, "closeEvent"):
168
+ self.closeEvent.clear()
224
169
 
225
- self.listener = threading.Thread(target=self.listen)
170
+ self.listener = threading.Thread(target=self.listen, daemon=True)
226
171
  self.listener.start()
227
172
 
228
173
  def finish_listener(self):
@@ -234,7 +179,7 @@ class BaseTransport(metaclass=abc.ABCMeta):
234
179
  frame = self._prepare_request(cmd, *data)
235
180
  self.timing.start()
236
181
  with self.policy_lock:
237
- self.policy.feed(types.FrameCategory.CMD, self.counter_send, self.timestamp.value, frame)
182
+ self.policy.feed(FrameCategory.CMD, self.framing.counter_send, self.timestamp.value, frame)
238
183
  self.send(frame)
239
184
  try:
240
185
  xcpPDU = self.get()
@@ -242,7 +187,10 @@ class BaseTransport(metaclass=abc.ABCMeta):
242
187
  if not ignore_timeout:
243
188
  MSG = f"Response timed out (timeout={self.timeout / 1_000_000_000}s)"
244
189
  with self.policy_lock:
245
- self.policy.feed(types.FrameCategory.METADATA, self.counter_send, self.timestamp.value, bytes(MSG, "ascii"))
190
+ self.policy.feed(
191
+ FrameCategory.METADATA, self.framing.counter_send, self.timestamp.value, bytes(MSG, "ascii")
192
+ ) if self._diagnostics_enabled() else ""
193
+ self.logger.debug("XCP request timeout", extra={"event": "timeout", "command": cmd.name})
246
194
  raise types.XcpTimeoutError(MSG) from None
247
195
  else:
248
196
  self.timing.stop()
@@ -251,7 +199,7 @@ class BaseTransport(metaclass=abc.ABCMeta):
251
199
  pid = types.Response.parse(xcpPDU).type
252
200
  if pid == "ERR" and cmd.name != "SYNCH":
253
201
  with self.policy_lock:
254
- self.policy.feed(types.FrameCategory.ERROR, self.counter_received, self.timestamp.value, xcpPDU[1:])
202
+ self.policy.feed(FrameCategory.ERROR, self.counter_received, self.timestamp.value, xcpPDU[1:])
255
203
  err = types.XcpError.parse(xcpPDU[1:])
256
204
  raise types.XcpResponseError(err)
257
205
  return xcpPDU[1:]
@@ -283,8 +231,8 @@ class BaseTransport(metaclass=abc.ABCMeta):
283
231
  frame = self._prepare_request(cmd, *data)
284
232
  with self.policy_lock:
285
233
  self.policy.feed(
286
- types.FrameCategory.CMD if int(cmd) >= 0xC0 else types.FrameCategory.STIM,
287
- self.counter_send,
234
+ FrameCategory.CMD if int(cmd) >= 0xC0 else FrameCategory.STIM,
235
+ self.framing.counter_send,
288
236
  self.timestamp.value,
289
237
  frame,
290
238
  )
@@ -297,19 +245,7 @@ class BaseTransport(metaclass=abc.ABCMeta):
297
245
  if self._debug:
298
246
  self.logger.debug(cmd.name)
299
247
  self.parent._setService(cmd)
300
-
301
- cmd_len = cmd.bit_length() // 8 # calculate bytes needed for cmd
302
- packet = bytes(flatten(cmd.to_bytes(cmd_len, "big"), data))
303
-
304
- header = self.HEADER.pack(len(packet), self.counter_send)
305
- self.counter_send = (self.counter_send + 1) & 0xFFFF
306
-
307
- frame = header + packet
308
-
309
- remainder = len(frame) % self.alignment
310
- if remainder:
311
- frame += b"\0" * (self.alignment - remainder)
312
-
248
+ frame = self.framing.prepare_request(cmd, *data)
313
249
  if self._debug:
314
250
  self.logger.debug(f"-> {hexDump(frame)}")
315
251
  return frame
@@ -341,7 +277,14 @@ class BaseTransport(metaclass=abc.ABCMeta):
341
277
  block_response += partial_response[1:]
342
278
  else:
343
279
  if self.timestamp.value - start > self.timeout:
344
- raise types.XcpTimeoutError("Response timed out [block_receive].") from None
280
+ waited = (self.timestamp.value - start) / 1e9 if hasattr(self.timestamp, "value") else None
281
+ msg = f"Response timed out [block_receive]: received {len(block_response)} of {length_required} bytes"
282
+ if waited is not None:
283
+ msg += f" after {waited:.3f}s"
284
+ # Attach diagnostics
285
+ # diag = self._build_diagnostics_dump() if self._diagnostics_enabled() else ""
286
+ self.logger.debug("XCP block_receive timeout", extra={"event": "timeout"})
287
+ raise types.XcpTimeoutError(msg) from None
345
288
  short_sleep()
346
289
  return block_response
347
290
 
@@ -368,29 +311,47 @@ class BaseTransport(metaclass=abc.ABCMeta):
368
311
  self.timer_restart_event.set()
369
312
 
370
313
  def process_response(self, response: bytes, length: int, counter: int, recv_timestamp: int) -> None:
371
- if counter == self.counter_received:
372
- self.logger.warning(f"Duplicate message counter {counter} received from the XCP slave")
373
- if self._debug:
374
- self.logger.debug(f"<- L{length} C{counter} {hexDump(response[:512])}")
375
- return
376
- self.counter_received = counter
314
+ # Important: determine PID first so duplicate counter handling can be applied selectively.
377
315
  pid = response[0]
316
+
378
317
  if pid >= 0xFC:
318
+ # Do not drop RESPONSE/EVENT/SERV frames even if the transport counter repeats.
319
+ # Some slaves may reuse the counter while DAQ traffic is active, and we must not lose
320
+ # command responses; otherwise request() can stall until timeout.
379
321
  if self._debug:
380
322
  self.logger.debug(f"<- L{length} C{counter} {hexDump(response)}")
323
+ self.counter_received = counter
324
+ # Record incoming non-DAQ frames for diagnostics
325
+ self._record_pdu(
326
+ "in",
327
+ (FrameCategory.RESPONSE if pid >= 0xFE else FrameCategory.SERV if pid == 0xFC else FrameCategory.EVENT),
328
+ counter,
329
+ recv_timestamp,
330
+ response,
331
+ length,
332
+ )
381
333
  if pid >= 0xFE:
382
334
  self.resQueue.append(response)
383
335
  with self.policy_lock:
384
- self.policy.feed(types.FrameCategory.RESPONSE, self.counter_received, self.timestamp.value, response)
336
+ self.policy.feed(FrameCategory.RESPONSE, self.counter_received, self.timestamp.value, response)
385
337
  self.recv_timestamp = recv_timestamp
386
338
  elif pid == 0xFD:
387
339
  self.process_event_packet(response)
388
340
  with self.policy_lock:
389
- self.policy.feed(types.FrameCategory.EVENT, self.counter_received, self.timestamp.value, response)
341
+ self.policy.feed(FrameCategory.EVENT, self.counter_received, self.timestamp.value, response)
390
342
  elif pid == 0xFC:
391
343
  with self.policy_lock:
392
- self.policy.feed(types.FrameCategory.SERV, self.counter_received, self.timestamp.value, response)
344
+ self.policy.feed(FrameCategory.SERV, self.counter_received, self.timestamp.value, response)
393
345
  else:
346
+ # DAQ traffic: Some transports reuse or do not advance the counter for DAQ frames.
347
+ # Do not drop DAQ frames on duplicate counters to avoid losing measurements.
348
+ if counter == self.counter_received:
349
+ self.logger.debug(f"Duplicate message counter {counter} received (DAQ) - not dropping")
350
+ # DAQ still flowing – reset request timeout window to avoid false timeouts while
351
+ # the slave is busy but has not yet responded to a command.
352
+ self.timer_restart_event.set()
353
+ # Fall through and process the frame as usual.
354
+ self.counter_received = counter
394
355
  if self._debug:
395
356
  self.logger.debug(f"<- L{length} C{counter} ODT_Data[0:8] {hexDump(response[:8])}")
396
357
  if self.first_daq_timestamp is None:
@@ -399,8 +360,91 @@ class BaseTransport(metaclass=abc.ABCMeta):
399
360
  timestamp = recv_timestamp
400
361
  else:
401
362
  timestamp = 0
363
+ # Record DAQ frame (only keep small prefix in payload string later)
364
+ self._record_pdu("in", FrameCategory.DAQ, counter, timestamp, response, length)
365
+ # DAQ activity indicates the slave is alive/busy; keep extending the wait window for any
366
+ # outstanding request, similar to EV_CMD_PENDING behavior on stacks that don't emit it.
367
+ self.timer_restart_event.set()
402
368
  with self.policy_lock:
403
- self.policy.feed(types.FrameCategory.DAQ, self.counter_received, timestamp, response)
369
+ self.policy.feed(FrameCategory.DAQ, self.counter_received, timestamp, response)
370
+
371
+ def _record_pdu(
372
+ self,
373
+ direction: str,
374
+ category: FrameCategory,
375
+ counter: int,
376
+ timestamp: int,
377
+ payload: bytes,
378
+ length: Optional[int] = None,
379
+ ) -> None:
380
+ try:
381
+ entry = {
382
+ "dir": direction,
383
+ "cat": category.name,
384
+ "ctr": int(counter),
385
+ "ts": int(timestamp),
386
+ "len": int(length if length is not None else len(payload)),
387
+ "data": hexDump(payload if category != FrameCategory.DAQ else payload[:8])[:512],
388
+ }
389
+ self._last_pdus.append(entry)
390
+ except Exception:
391
+ pass # nosec
392
+
393
+ def _build_diagnostics_dump(self) -> str:
394
+ import json as _json
395
+
396
+ # transport params
397
+ tp = {"transport": self.__class__.__name__}
398
+ cfg = getattr(self, "config", None)
399
+ # Extract common Eth/Can fields when available
400
+ for key in (
401
+ "host",
402
+ "port",
403
+ "protocol",
404
+ "ipv6",
405
+ "bind_to_address",
406
+ "bind_to_port",
407
+ "fd",
408
+ "bitrate",
409
+ "data_bitrate",
410
+ "can_id_master",
411
+ "can_id_slave",
412
+ ):
413
+ if cfg is not None and hasattr(cfg, key):
414
+ try:
415
+ tp[key] = getattr(cfg, key)
416
+ except Exception:
417
+ pass # nosec
418
+ last_n = 20
419
+ try:
420
+ app = getattr(self.config, "parent", None)
421
+ app = getattr(app, "parent", None)
422
+ if app is not None and hasattr(app, "general") and hasattr(app.general, "diagnostics_last_pdus"):
423
+ last_n = int(app.general.diagnostics_last_pdus or last_n)
424
+ except Exception:
425
+ pass # nosec
426
+ pdus = list(self._last_pdus)[-last_n:]
427
+ payload = {
428
+ "transport_params": tp,
429
+ "last_pdus": pdus,
430
+ }
431
+ try:
432
+ body = _json.dumps(payload, ensure_ascii=False, default=str, indent=2)
433
+ except Exception:
434
+ body = str(payload)
435
+ # Add a small header to explain what follows
436
+ header = "--- Diagnostics (for troubleshooting) ---"
437
+ return f"{header}\n{body}"
438
+
439
+ def _diagnostics_enabled(self) -> bool:
440
+ try:
441
+ app = getattr(self.config, "parent", None)
442
+ app = getattr(app, "parent", None)
443
+ if app is not None and hasattr(app, "general"):
444
+ return bool(getattr(app.general, "diagnostics_on_failure", True))
445
+ except Exception:
446
+ return True
447
+ return True
404
448
 
405
449
  # @abc.abstractproperty
406
450
  # @property
pyxcp/transport/can.py CHANGED
@@ -20,9 +20,14 @@ from can.interface import _get_class_for_interface
20
20
  from rich.console import Console
21
21
 
22
22
  from pyxcp.config import CAN_INTERFACE_MAP
23
- from pyxcp.transport.base import BaseTransport
23
+ from pyxcp.transport.base import (
24
+ BaseTransport,
25
+ ChecksumType,
26
+ XcpFramingConfig,
27
+ XcpTransportLayerType,
28
+ )
24
29
 
25
- from ..utils import seconds_to_nanoseconds
30
+ from ..utils import seconds_to_nanoseconds, short_sleep
26
31
 
27
32
 
28
33
  console = Console()
@@ -292,7 +297,13 @@ class PythonCanWrapper:
292
297
  self.timeout: int = timeout
293
298
  self.parameters = parameters
294
299
  if not self.parent.has_user_supplied_interface:
295
- self.can_interface_class = _get_class_for_interface(self.interface_name)
300
+ try:
301
+ self.can_interface_class = _get_class_for_interface(self.interface_name)
302
+ except Exception as ex:
303
+ # Provide clearer message if interface not supported by python-can on this platform
304
+ raise CanInitializationError(
305
+ f"Unsupported or unavailable CAN interface {self.interface_name!r}: {ex.__class__.__name__}: {ex}"
306
+ ) from ex
296
307
  else:
297
308
  self.can_interface_class = None
298
309
  self.can_interface: BusABC
@@ -320,7 +331,15 @@ class PythonCanWrapper:
320
331
  self.can_interface.set_filters(merged_filters)
321
332
  self.software_filter.set_filters(can_filters) # Filter unwanted traffic.
322
333
  else:
323
- self.can_interface = self.can_interface_class(interface=self.interface_name, can_filters=can_filters, **self.parameters)
334
+ try:
335
+ self.can_interface = self.can_interface_class(
336
+ interface=self.interface_name, can_filters=can_filters, **self.parameters
337
+ )
338
+ except OSError as ex:
339
+ # Typical when selecting socketcan on unsupported OS (e.g., Windows)
340
+ raise CanInitializationError(
341
+ f"OS error while creating CAN interface {self.interface_name!r}: {ex.__class__.__name__}: {ex}"
342
+ ) from ex
324
343
  self.software_filter.accept_all()
325
344
  self.parent.logger.info(f"XCPonCAN - Using Interface: '{self.can_interface!s}'")
326
345
  self.parent.logger.info(f"XCPonCAN - Filters used: {self.can_interface.filters}")
@@ -383,7 +402,15 @@ class Can(BaseTransport):
383
402
  HEADER_SIZE = 0
384
403
 
385
404
  def __init__(self, config, policy=None, transport_layer_interface: Optional[BusABC] = None):
386
- super().__init__(config, policy, transport_layer_interface)
405
+ framing_config = XcpFramingConfig(
406
+ transport_layer_type=XcpTransportLayerType.CAN,
407
+ header_len=0,
408
+ header_ctr=0,
409
+ header_fill=0,
410
+ tail_fill=False,
411
+ tail_cs=ChecksumType.NO_CHECKSUM,
412
+ )
413
+ super().__init__(config, framing_config, policy, transport_layer_interface)
387
414
  self.load_config(config)
388
415
  self.useDefaultListener = self.config.use_default_listener
389
416
  self.can_id_master = Identifier(self.config.can_id_master)
@@ -401,13 +428,33 @@ class Can(BaseTransport):
401
428
  self.padding_value = self.config.padding_value
402
429
  if transport_layer_interface is None:
403
430
  self.interface_name = self.config.interface
404
- self.interface_configuration = detect_available_configs(interfaces=[self.interface_name])
431
+ # On platforms that do not support certain backends (e.g., SocketCAN on Windows),
432
+ # python-can may raise OSError deep inside interface initialization. We want to
433
+ # fail fast with a clearer hint and avoid unhandled low-level errors.
434
+ try:
435
+ self.interface_configuration = detect_available_configs(interfaces=[self.interface_name])
436
+ except Exception as ex:
437
+ # Best-effort graceful message; keep original exception context
438
+ self.logger.critical(
439
+ f"XCPonCAN - Failed to query available configs for interface {self.interface_name!r}: {ex.__class__.__name__}: {ex}"
440
+ )
441
+ self.interface_configuration = []
405
442
  parameters = self.get_interface_parameters()
406
443
  else:
407
444
  self.interface_name = "custom"
408
445
  # print("TRY GET PARAMs", self.get_interface_parameters())
409
446
  parameters = {}
410
- self.can_interface = PythonCanWrapper(self, self.interface_name, config.timeout, **parameters)
447
+ try:
448
+ self.can_interface = PythonCanWrapper(self, self.interface_name, config.timeout, **parameters)
449
+ except OSError as ex:
450
+ # Catch platform-specific socket errors early (e.g., SocketCAN on Windows)
451
+ msg = (
452
+ f"XCPonCAN - Failed to initialize CAN interface {self.interface_name!r}: "
453
+ f"{ex.__class__.__name__}: {ex}.\n"
454
+ f"Hint: Interface may be unsupported on this OS or missing drivers."
455
+ )
456
+ self.logger.critical(msg)
457
+ raise CanInitializationError(msg) from ex
411
458
  self.logger.info(f"XCPonCAN - Interface-Type: {self.interface_name!r} Parameters: {list(parameters.items())}")
412
459
  self.logger.info(
413
460
  f"XCPonCAN - Master-ID (Tx): 0x{self.can_id_master.id:08X}{self.can_id_master.type_str} -- "
@@ -451,23 +498,80 @@ class Can(BaseTransport):
451
498
  )
452
499
 
453
500
  def listen(self):
501
+ """Process CAN frames received from the interface.
502
+
503
+ This method runs in a separate thread and continuously polls the CAN interface
504
+ for new frames. When a frame is received, it extracts the data and timestamp
505
+ and passes them to the data_received method for further processing.
506
+
507
+ The method includes periodic sleep to prevent CPU hogging and error handling
508
+ to ensure the listener thread doesn't crash on exceptions.
509
+ """
510
+ # Cache frequently used methods and attributes for better performance
511
+ close_event_set = self.closeEvent.is_set
512
+ can_interface_read = self.can_interface.read
513
+ data_received = self.data_received
514
+
515
+ # State variables for processing
516
+ last_sleep = self.timestamp.value
517
+ FIVE_MS = 5_000_000 # Five milliseconds in nanoseconds
518
+
454
519
  while True:
455
- if self.closeEvent.is_set():
520
+ # Check if we should exit the loop
521
+ if close_event_set():
456
522
  return
457
- frame = self.can_interface.read()
458
- if frame:
459
- self.data_received(frame.data, frame.timestamp)
523
+
524
+ # Periodically sleep to prevent CPU hogging
525
+ if self.timestamp.value - last_sleep >= FIVE_MS:
526
+ short_sleep()
527
+ last_sleep = self.timestamp.value
528
+
529
+ try:
530
+ # Try to read a frame from the CAN interface
531
+ frame = can_interface_read()
532
+ if frame:
533
+ # Process the frame if one was received
534
+ data_received(frame.data, frame.timestamp)
535
+ else:
536
+ # No frame available, sleep briefly to avoid busy waiting
537
+ short_sleep()
538
+ last_sleep = self.timestamp.value
539
+ except Exception as e:
540
+ # Log any exceptions but continue processing
541
+ self.logger.error(f"Error in CAN listen thread: {e}")
542
+ # Sleep briefly to avoid tight error loops
543
+ short_sleep()
544
+ last_sleep = self.timestamp.value
460
545
 
461
546
  def connect(self):
462
- if self.useDefaultListener:
463
- self.start_listener()
547
+ # Start listener lazily after a successful interface connection to avoid a dangling
548
+ # thread waiting on a not-yet-connected interface if initialization fails.
464
549
  try:
465
550
  self.can_interface.connect()
466
551
  except CanInitializationError:
552
+ # Ensure any previously-started listener is stopped to prevent hangs.
553
+ self.finish_listener()
467
554
  console.print("[red]\nThere may be a problem with the configuration of your CAN-interface.\n")
468
555
  console.print(f"[grey]Current configuration of interface {self.interface_name!r}:")
469
556
  console.print(self.interface_configuration)
470
557
  raise
558
+ except OSError as ex:
559
+ # Ensure any previously-started listener is stopped to prevent hangs.
560
+ self.finish_listener()
561
+ # E.g., attempting to instantiate SocketCAN on Windows raises an OSError from socket layer.
562
+ # Provide a clearer, actionable message and keep the original exception.
563
+ msg = (
564
+ f"XCPonCAN - OS error while initializing interface {self.interface_name!r}: "
565
+ f"{ex.__class__.__name__}: {ex}.\n"
566
+ f"Hint: This interface may not be supported on your platform. "
567
+ f"On Windows, use e.g. 'vector', 'kvaser', 'pcan', or other vendor backends instead of 'socketcan'."
568
+ )
569
+ self.logger.critical(msg)
570
+ raise CanInitializationError(msg) from ex
571
+ else:
572
+ # Only now start the default listener if requested.
573
+ if self.useDefaultListener:
574
+ self.start_listener()
471
575
  self.status = 1 # connected
472
576
 
473
577
  def send(self, frame: bytes) -> None: