pyccp 0.10.0__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.
- pyccp/__init__.py +5 -0
- pyccp/ccp.py +116 -0
- pyccp/master.py +556 -0
- pyccp-0.10.0.dist-info/METADATA +87 -0
- pyccp-0.10.0.dist-info/RECORD +7 -0
- pyccp-0.10.0.dist-info/WHEEL +4 -0
- pyccp-0.10.0.dist-info/licenses/LICENSE.md +21 -0
pyccp/__init__.py
ADDED
pyccp/ccp.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import enum
|
|
6
|
+
|
|
7
|
+
MAX_CRO = 0x0008
|
|
8
|
+
MAX_DTO = 0x0008
|
|
9
|
+
|
|
10
|
+
CCP_VERSION = (2, 1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass(frozen=True)
|
|
14
|
+
class Command:
|
|
15
|
+
"""
|
|
16
|
+
Immutable command configuration.
|
|
17
|
+
|
|
18
|
+
Represents a command by its numeric code and the timeout value associated
|
|
19
|
+
with executing or handling that command. Instances are frozen by the data class
|
|
20
|
+
declaration, so the recorded values are intended to remain stable after
|
|
21
|
+
creation.
|
|
22
|
+
|
|
23
|
+
:ivar code: Numeric identifier for the command.
|
|
24
|
+
:ivar timeout: Timeout value associated with the command in milliseconds.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
code: int
|
|
28
|
+
timeout: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Mandatory Commands
|
|
32
|
+
CONNECT = Command(0x01, 25)
|
|
33
|
+
GET_CCP_VERSION = Command(0x1B, 25)
|
|
34
|
+
EXCHANGE_ID = Command(0x17, 25)
|
|
35
|
+
SET_MTA = Command(0x02, 25)
|
|
36
|
+
DNLOAD = Command(0x03, 25)
|
|
37
|
+
UPLOAD = Command(0x04, 25)
|
|
38
|
+
GET_DAQ_SIZE = Command(0x14, 25)
|
|
39
|
+
SET_DAQ_PTR = Command(0x15, 25)
|
|
40
|
+
WRITE_DAQ = Command(0x16, 25)
|
|
41
|
+
START_STOP = Command(0x06, 25)
|
|
42
|
+
DISCONNECT = Command(0x07, 25)
|
|
43
|
+
# Optional Commands
|
|
44
|
+
GET_SEED = Command(0x12, 25)
|
|
45
|
+
UNLOCK = Command(0x13, 25)
|
|
46
|
+
DNLOAD_6 = Command(0x23, 25)
|
|
47
|
+
SHORT_UP = Command(0x0F, 25)
|
|
48
|
+
SELECT_CAL_PAGE = Command(0x11, 25)
|
|
49
|
+
SET_S_STATUS = Command(0x0C, 25)
|
|
50
|
+
GET_S_STATUS = Command(0x0D, 25)
|
|
51
|
+
BUILD_CHKSUM = Command(0x0E, 30000)
|
|
52
|
+
CLEAR_MEMORY = Command(0x10, 30000)
|
|
53
|
+
PROGRAM = Command(0x18, 100)
|
|
54
|
+
PROGRAM_6 = Command(0x22, 100)
|
|
55
|
+
MOVE = Command(0x19, 30000)
|
|
56
|
+
TEST = Command(0x05, 25)
|
|
57
|
+
GET_ACTIVE_CAL_PAGE = Command(0x09, 25)
|
|
58
|
+
START_STOP_ALL = Command(0x08, 25)
|
|
59
|
+
DIAG_SERVICE = Command(0x20, 500)
|
|
60
|
+
ACTION_SERVICE = Command(0x21, 5000)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReturnCodes(enum.IntEnum):
|
|
64
|
+
ACKNOWLEDGE = 0x00
|
|
65
|
+
DAQ_PROCESSOR_OVERLOAD = 0x01 # C0
|
|
66
|
+
COMMAND_PROCESSOR_BUSY = 0x10 # C1 NONE (wait until ACK or timeout)
|
|
67
|
+
DAQ_PROCESSOR_BUSY = 0x11 # C1 NONE (wait until ACK or timeout)
|
|
68
|
+
INTERNAL_TIMEOUT = 0x12 # C1 NONE (wait until ACK or timeout)
|
|
69
|
+
KEY_REQUEST = 0x18 # C1 NONE (embedded seed&key)
|
|
70
|
+
SESSION_STATUS_REQUEST = 0x19 # C1 NONE (embedded SET_S_STATUS)
|
|
71
|
+
COLD_START_REQUEST = 0x20 # C2 COLD START
|
|
72
|
+
CAL_DATA_INIT_REQUEST = 0x21 # C2 cal. data initialization
|
|
73
|
+
DAQ_LIST_INIT_REQUEST = 0x22 # C2 DAQ list initialization
|
|
74
|
+
CODE_UPDATE_REQUEST = 0x23 # C2 (COLD START)
|
|
75
|
+
UNKNOWN_COMMAND = 0x30 # C3 (FAULT)
|
|
76
|
+
COMMAND_SYNTAX = 0x31 # C3 FAULT
|
|
77
|
+
PARAMETER_OUT_OF_RANGE = 0x32 # C3 FAULT
|
|
78
|
+
ACCESS_DENIED = 0x33 # C3 FAULT
|
|
79
|
+
OVERLOAD = 0x34 # C3 FAULT
|
|
80
|
+
ACCESS_LOCKED = 0x35 # C3 FAULT
|
|
81
|
+
RESOURCE_FUNCTION_NOT_AVAILABLE = 0x36 # C3 FAULT
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DisconnectType(enum.IntEnum):
|
|
85
|
+
TEMPORARY = 0x00
|
|
86
|
+
PERMANENT = 0x01
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SecondaryResource(enum.IntEnum):
|
|
90
|
+
"""
|
|
91
|
+
Enumeration for secondary resource types.
|
|
92
|
+
|
|
93
|
+
This enumeration defines constants representing different types of secondary
|
|
94
|
+
resources that can be used or required in various systems or operations.
|
|
95
|
+
|
|
96
|
+
:cvar CAL: Represents the calibration resource.
|
|
97
|
+
:cvar DAQ: Represents the data acquisition resource.
|
|
98
|
+
:cvar PGM: Represents the programming resource.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
CAL = 1 << 0
|
|
102
|
+
DAQ = 1 << 1
|
|
103
|
+
PGM = 1 << 6
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DTO:
|
|
107
|
+
class Type(enum.IntEnum):
|
|
108
|
+
COMMAND_RETURN_MESSAGE = 255
|
|
109
|
+
EVENT_MESSAGE = 254
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_ctr(ctr: int, response: bytes) -> bool:
|
|
113
|
+
if len(response) < 3:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
return ctr == response[2]
|
pyccp/master.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import ctypes
|
|
5
|
+
import logging
|
|
6
|
+
import struct
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from concurrent.futures import Future
|
|
11
|
+
from threading import Event, Thread
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import can
|
|
16
|
+
from can import Message
|
|
17
|
+
|
|
18
|
+
from . import ccp
|
|
19
|
+
from .ccp import ReturnCodes, SecondaryResource
|
|
20
|
+
|
|
21
|
+
MTA0 = 0
|
|
22
|
+
MTA1 = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _unsigned(name: str, value: int, bits: int) -> int:
|
|
26
|
+
if not isinstance(value, int) or not 0 <= value < (1 << bits):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"{name} must be an unsigned {bits}-bit integer, got {value!r}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _payload(data: bytes, minimum: int, maximum: int):
|
|
35
|
+
"""Normalize byte sequences without interpreting integers as zero-filled data."""
|
|
36
|
+
if isinstance(data, (int, str)):
|
|
37
|
+
raise TypeError("Data must be a bytes-like object or byte sequence")
|
|
38
|
+
|
|
39
|
+
payload = bytes(data)
|
|
40
|
+
|
|
41
|
+
if not minimum <= len(payload) <= maximum:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Data length must be between {minimum} and {maximum}, got {len(payload)}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _transfer_size(size: int):
|
|
50
|
+
if not isinstance(size, int) or not 1 <= size <= 5:
|
|
51
|
+
raise ValueError(f"Size must be between 1 and 5, got {size!r}")
|
|
52
|
+
|
|
53
|
+
return size
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pack_address(address: int):
|
|
57
|
+
return struct.pack("<L", _unsigned("Address", address, 32))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CommandResponse:
|
|
61
|
+
__slots__ = ("code", "data")
|
|
62
|
+
|
|
63
|
+
def __init__(self, raw_data: bytes):
|
|
64
|
+
if len(raw_data) < 3:
|
|
65
|
+
raise ValueError("Raw data must be at least 3 bytes long")
|
|
66
|
+
|
|
67
|
+
self.code = ReturnCodes(raw_data[1])
|
|
68
|
+
self.data = raw_data[3:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DAQMessage:
|
|
72
|
+
__slots__ = ("daq_id", "pid", "data")
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: Message):
|
|
75
|
+
self.daq_id = message.arbitration_id
|
|
76
|
+
self.pid = message.data[0]
|
|
77
|
+
self.data = message.data[1:]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
DAQCallback = Callable[[DAQMessage], None]
|
|
81
|
+
EventCallback = Callable[[CommandResponse], None]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Master:
|
|
85
|
+
class _Receiver(Thread):
|
|
86
|
+
def __init__(self, master: "Master"):
|
|
87
|
+
super().__init__(daemon=True)
|
|
88
|
+
self._master = master
|
|
89
|
+
self._stop_event = Event()
|
|
90
|
+
|
|
91
|
+
def run(self):
|
|
92
|
+
while True:
|
|
93
|
+
message = self._master.get_raw_data(timeout=0.1)
|
|
94
|
+
|
|
95
|
+
if message is not None:
|
|
96
|
+
self._master._on_message(message)
|
|
97
|
+
|
|
98
|
+
if self._stop_event.is_set():
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
def stop(self) -> None:
|
|
102
|
+
self._stop_event.set()
|
|
103
|
+
self.join()
|
|
104
|
+
|
|
105
|
+
def __init__(self, bus: can.BusABC, cro: int, dto: int):
|
|
106
|
+
"""CCP Master implementation for sending and receiving messages over
|
|
107
|
+
a CAN Calibration Protocol.
|
|
108
|
+
|
|
109
|
+
:param bus: CAN Bus object for sending and receiving messages.
|
|
110
|
+
:type bus: `can.BusABC`
|
|
111
|
+
:param cro: The 11-bit or 29-bit identifier for outgoing CAN message from
|
|
112
|
+
the host.
|
|
113
|
+
:type cro: int
|
|
114
|
+
:param dto: The 11-bit or 29-bit identifier for incoming CAN message from
|
|
115
|
+
a slave.
|
|
116
|
+
:type dto: int
|
|
117
|
+
"""
|
|
118
|
+
self.transport = bus
|
|
119
|
+
self.ctr = ctypes.c_uint8(0)
|
|
120
|
+
self.logger = logging.getLogger(__name__)
|
|
121
|
+
self._cro = cro
|
|
122
|
+
self._dto = dto
|
|
123
|
+
self._lock = threading.Lock()
|
|
124
|
+
self._receiver: Optional[Master._Receiver] = None
|
|
125
|
+
self._on_event: Optional[EventCallback] = None
|
|
126
|
+
self._on_daq: dict[int, DAQCallback] = {}
|
|
127
|
+
self._pending_requests: dict[int, Future] = {}
|
|
128
|
+
|
|
129
|
+
def __enter__(self) -> "Master":
|
|
130
|
+
if self._receiver is None:
|
|
131
|
+
self._receiver = Master._Receiver(self)
|
|
132
|
+
|
|
133
|
+
self._receiver.start()
|
|
134
|
+
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(
|
|
138
|
+
self,
|
|
139
|
+
exc_type: type[BaseException] | None,
|
|
140
|
+
exc_val: BaseException | None,
|
|
141
|
+
exc_tb: TracebackType | None,
|
|
142
|
+
) -> None:
|
|
143
|
+
self._shutdown()
|
|
144
|
+
|
|
145
|
+
def _shutdown(self) -> None:
|
|
146
|
+
if self._receiver is not None:
|
|
147
|
+
self._receiver.stop()
|
|
148
|
+
self._receiver = None
|
|
149
|
+
|
|
150
|
+
self.transport.shutdown()
|
|
151
|
+
|
|
152
|
+
def on_event(self, callback: EventCallback) -> None:
|
|
153
|
+
with self._lock:
|
|
154
|
+
self._on_event = callback
|
|
155
|
+
|
|
156
|
+
def on_daq(self, daq_id: int, callback: DAQCallback) -> None:
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._on_daq[daq_id] = callback
|
|
159
|
+
|
|
160
|
+
def send_cro(self, cmd: ccp.Command, ctr, *data: int) -> int:
|
|
161
|
+
"""
|
|
162
|
+
Send a Command Receive Object (CRO) message via the transport layer.
|
|
163
|
+
|
|
164
|
+
This method constructs the CRO data frame using the provided
|
|
165
|
+
Command Specifier (cmd), counter value (ctr), and additional data bytes.
|
|
166
|
+
The data frame is then sent using the transport layer. It also ensures
|
|
167
|
+
that the control byte value increments properly after each successful
|
|
168
|
+
transmission.
|
|
169
|
+
|
|
170
|
+
:param cmd: Command Specifier byte used in the CCP protocol.
|
|
171
|
+
:type cmd: int
|
|
172
|
+
:param ctr: Control byte used for controlling the flow of messages.
|
|
173
|
+
:type ctr: int
|
|
174
|
+
:param data: Up to six additional data bytes for the message payload.
|
|
175
|
+
:return: The transmitted counter (the internal counter advances after sending).
|
|
176
|
+
:rtype: int
|
|
177
|
+
:raises ValueError: If more than six data bytes are provided.
|
|
178
|
+
"""
|
|
179
|
+
if len(data) > 6:
|
|
180
|
+
raise ValueError(f"Maximum 6 data bytes allowed, got {len(data)}")
|
|
181
|
+
|
|
182
|
+
data = bytearray((cmd.code, ctr, *data))
|
|
183
|
+
|
|
184
|
+
if len(data) < ccp.MAX_CRO:
|
|
185
|
+
data.extend(bytearray(ccp.MAX_CRO - len(data)))
|
|
186
|
+
|
|
187
|
+
msg = Message(arbitration_id=self._cro, data=data, is_rx=False)
|
|
188
|
+
self.logger.debug(f"Command: [{cmd}]")
|
|
189
|
+
self.logger.debug(f"Sending message: {msg}")
|
|
190
|
+
self.transport.send(msg)
|
|
191
|
+
self.ctr = ctypes.c_uint8(self.ctr.value + 1)
|
|
192
|
+
|
|
193
|
+
return ctr
|
|
194
|
+
|
|
195
|
+
def _on_message(self, message: Message) -> None:
|
|
196
|
+
resource_id = message.data[0]
|
|
197
|
+
|
|
198
|
+
if resource_id == ccp.DTO.Type.COMMAND_RETURN_MESSAGE:
|
|
199
|
+
self._handle_response(message)
|
|
200
|
+
elif resource_id == ccp.DTO.Type.EVENT_MESSAGE:
|
|
201
|
+
self._handle_event(message)
|
|
202
|
+
elif resource_id in self._on_daq:
|
|
203
|
+
self._handle_daq(message)
|
|
204
|
+
|
|
205
|
+
def _handle_response(self, message: Message) -> None:
|
|
206
|
+
"""Handle a response message by verifying the control byte and
|
|
207
|
+
processing the response.
|
|
208
|
+
"""
|
|
209
|
+
ctr = message.data[2]
|
|
210
|
+
|
|
211
|
+
with self._lock:
|
|
212
|
+
future = self._pending_requests.pop(ctr, None)
|
|
213
|
+
|
|
214
|
+
if future is not None:
|
|
215
|
+
future.set_result(CommandResponse(message.data))
|
|
216
|
+
else:
|
|
217
|
+
self.logger.warning(f"Unexpected response received: {message}")
|
|
218
|
+
|
|
219
|
+
def _handle_daq(self, message: Message) -> None:
|
|
220
|
+
"""Handle a DAQ message by invoking the registered callback for the DAQ ID."""
|
|
221
|
+
daq_id = message.arbitration_id
|
|
222
|
+
|
|
223
|
+
with self._lock:
|
|
224
|
+
callback = self._on_daq.get(daq_id)
|
|
225
|
+
|
|
226
|
+
if callback is not None:
|
|
227
|
+
self.logger.debug(f"Handling DAQ message with ID: {daq_id}")
|
|
228
|
+
callback(DAQMessage(message))
|
|
229
|
+
|
|
230
|
+
def _handle_event(self, message: Message) -> None:
|
|
231
|
+
"""Handle an event message by invoking the registered event callback."""
|
|
232
|
+
with self._lock:
|
|
233
|
+
if self._on_event is not None:
|
|
234
|
+
self.logger.debug(f"Handling event message: {message}")
|
|
235
|
+
self._on_event(CommandResponse(message.data))
|
|
236
|
+
|
|
237
|
+
def _get_data(self, timeout: Optional[float] = None) -> Optional[Message]:
|
|
238
|
+
"""Receive the first available message within `timeout` seconds."""
|
|
239
|
+
if timeout is None:
|
|
240
|
+
return self.transport.recv(timeout=None)
|
|
241
|
+
|
|
242
|
+
deadline = time.monotonic() + timeout
|
|
243
|
+
|
|
244
|
+
while True:
|
|
245
|
+
residual = deadline - time.monotonic()
|
|
246
|
+
|
|
247
|
+
if residual <= 0:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
message = self.transport.recv(timeout=residual)
|
|
251
|
+
|
|
252
|
+
if message is not None:
|
|
253
|
+
return message
|
|
254
|
+
|
|
255
|
+
def get_data(self, timeout: Optional[float] = None) -> Optional[Message]:
|
|
256
|
+
"""Return the first message addressed to the DTO id within `timeout` seconds."""
|
|
257
|
+
if timeout is None:
|
|
258
|
+
deadline = None
|
|
259
|
+
else:
|
|
260
|
+
deadline = time.monotonic() + timeout
|
|
261
|
+
|
|
262
|
+
while True:
|
|
263
|
+
if deadline is None:
|
|
264
|
+
residual = None
|
|
265
|
+
else:
|
|
266
|
+
residual = deadline - time.monotonic()
|
|
267
|
+
|
|
268
|
+
if residual <= 0:
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
message = self._get_data(residual)
|
|
272
|
+
|
|
273
|
+
if message is None:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
if message.arbitration_id == self._dto:
|
|
277
|
+
self.logger.debug(f"Received message: {message}")
|
|
278
|
+
return message
|
|
279
|
+
|
|
280
|
+
self.logger.warning(f"Nothing received after: {timeout} seconds")
|
|
281
|
+
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def get_raw_data(self, timeout: Optional[float] = None) -> Optional[Message]:
|
|
285
|
+
return self._get_data(timeout)
|
|
286
|
+
|
|
287
|
+
def _transaction(
|
|
288
|
+
self,
|
|
289
|
+
timeout: int,
|
|
290
|
+
command: ccp.Command,
|
|
291
|
+
ctr: int,
|
|
292
|
+
*data: int,
|
|
293
|
+
) -> Optional[CommandResponse]:
|
|
294
|
+
future = Future()
|
|
295
|
+
|
|
296
|
+
with self._lock:
|
|
297
|
+
ctr = self.send_cro(command, ctr, *data)
|
|
298
|
+
self._pending_requests[ctr] = future
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
return future.result(timeout=timeout / 1000.0)
|
|
302
|
+
except TimeoutError:
|
|
303
|
+
self.logger.warning(f"Transaction timed out after {timeout} ms")
|
|
304
|
+
|
|
305
|
+
with self._lock:
|
|
306
|
+
self._pending_requests.pop(ctr, None)
|
|
307
|
+
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def _execute(
|
|
311
|
+
self, command: ccp.Command, *payload: int
|
|
312
|
+
) -> Optional[CommandResponse]:
|
|
313
|
+
"""Resolve transaction metadata once; public commands only encode payloads."""
|
|
314
|
+
return self._transaction(command.timeout, command, self.ctr.value, *payload)
|
|
315
|
+
|
|
316
|
+
# Mandatory Commands.
|
|
317
|
+
def connect(self, address: int) -> Optional[CommandResponse]:
|
|
318
|
+
address = struct.pack("<H", _unsigned("Station address", address, 16))
|
|
319
|
+
return self._execute(ccp.CONNECT, *address)
|
|
320
|
+
|
|
321
|
+
def get_ccp_version(
|
|
322
|
+
self, major: int = ccp.CCP_VERSION[0], minor: int = ccp.CCP_VERSION[1]
|
|
323
|
+
) -> Optional[CommandResponse]:
|
|
324
|
+
return self._execute(
|
|
325
|
+
ccp.GET_CCP_VERSION,
|
|
326
|
+
major,
|
|
327
|
+
minor,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def exchange_id(self, info: bytes) -> Optional[CommandResponse]:
|
|
331
|
+
if len(info) > 6:
|
|
332
|
+
raise ValueError(f"Maximum 6 bytes allowed, got {len(info)}")
|
|
333
|
+
|
|
334
|
+
return self._execute(ccp.EXCHANGE_ID, *info)
|
|
335
|
+
|
|
336
|
+
def set_mta(
|
|
337
|
+
self, address: int, address_extension: int = 0x00, mta: int = MTA0
|
|
338
|
+
) -> Optional[CommandResponse]:
|
|
339
|
+
address = _pack_address(address)
|
|
340
|
+
_unsigned("Address extension", address_extension, 8)
|
|
341
|
+
|
|
342
|
+
if mta not in (MTA0, MTA1):
|
|
343
|
+
raise ValueError(f"MTA must be 0 or 1, got {mta!r}")
|
|
344
|
+
|
|
345
|
+
return self._execute(
|
|
346
|
+
ccp.SET_MTA,
|
|
347
|
+
mta,
|
|
348
|
+
address_extension,
|
|
349
|
+
*address,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def dnload(self, size: int, data: bytes) -> Optional[CommandResponse]:
|
|
353
|
+
_transfer_size(size)
|
|
354
|
+
data = _payload(data, size, size)
|
|
355
|
+
|
|
356
|
+
return self._execute(
|
|
357
|
+
ccp.DNLOAD,
|
|
358
|
+
size,
|
|
359
|
+
*data,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def upload(self, size: int) -> Optional[CommandResponse]:
|
|
363
|
+
_transfer_size(size)
|
|
364
|
+
|
|
365
|
+
return self._execute(
|
|
366
|
+
ccp.UPLOAD,
|
|
367
|
+
size,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def get_daq_size(
|
|
371
|
+
self, daq_list_number: int, address: int
|
|
372
|
+
) -> Optional[CommandResponse]:
|
|
373
|
+
address = _pack_address(address)
|
|
374
|
+
|
|
375
|
+
return self._execute(
|
|
376
|
+
ccp.GET_DAQ_SIZE,
|
|
377
|
+
daq_list_number,
|
|
378
|
+
0x00,
|
|
379
|
+
*address,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def set_daq_ptr(
|
|
383
|
+
self, daq_list_number: int, odt_number: int, element_number: int
|
|
384
|
+
) -> Optional[CommandResponse]:
|
|
385
|
+
return self._execute(
|
|
386
|
+
ccp.SET_DAQ_PTR,
|
|
387
|
+
daq_list_number,
|
|
388
|
+
odt_number,
|
|
389
|
+
element_number,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def write_daq(
|
|
393
|
+
self, element_size: int, address_extension: int, address: int
|
|
394
|
+
) -> Optional[CommandResponse]:
|
|
395
|
+
address = _pack_address(address)
|
|
396
|
+
_unsigned("Address extension", address_extension, 8)
|
|
397
|
+
return self._execute(
|
|
398
|
+
ccp.WRITE_DAQ,
|
|
399
|
+
element_size,
|
|
400
|
+
address_extension,
|
|
401
|
+
*address,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def start_stop(
|
|
405
|
+
self,
|
|
406
|
+
mode: int,
|
|
407
|
+
daq_list_number: int,
|
|
408
|
+
last_odt_number: int,
|
|
409
|
+
event_channel: int,
|
|
410
|
+
rate_prescaler: int,
|
|
411
|
+
) -> Optional[CommandResponse]:
|
|
412
|
+
rate_prescaler = struct.pack(
|
|
413
|
+
"<H", _unsigned("Rate prescaler", rate_prescaler, 16)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return self._execute(
|
|
417
|
+
ccp.START_STOP,
|
|
418
|
+
mode,
|
|
419
|
+
daq_list_number,
|
|
420
|
+
last_odt_number,
|
|
421
|
+
event_channel,
|
|
422
|
+
*rate_prescaler,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def disconnect(
|
|
426
|
+
self, disconnect_type: ccp.DisconnectType, address: int
|
|
427
|
+
) -> Optional[CommandResponse]:
|
|
428
|
+
address = struct.pack("<H", _unsigned("Station address", address, 16))
|
|
429
|
+
|
|
430
|
+
return self._execute(
|
|
431
|
+
ccp.DISCONNECT,
|
|
432
|
+
disconnect_type,
|
|
433
|
+
0x00,
|
|
434
|
+
*address,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Optional Commands.
|
|
438
|
+
def test(self, station_id: int) -> Optional[CommandResponse]:
|
|
439
|
+
to_send = struct.pack("<H", _unsigned("Station address", station_id, 16))
|
|
440
|
+
|
|
441
|
+
return self._execute(ccp.TEST, *to_send)
|
|
442
|
+
|
|
443
|
+
def dnload6(self, data: bytes) -> Optional[CommandResponse]:
|
|
444
|
+
data = _payload(data, 6, 6)
|
|
445
|
+
return self._execute(
|
|
446
|
+
ccp.DNLOAD_6,
|
|
447
|
+
*data,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def short_up(
|
|
451
|
+
self, size: int, address: int, address_extension: int = 0
|
|
452
|
+
) -> Optional[CommandResponse]:
|
|
453
|
+
_transfer_size(size)
|
|
454
|
+
address = _pack_address(address)
|
|
455
|
+
_unsigned("Address extension", address_extension, 8)
|
|
456
|
+
return self._execute(
|
|
457
|
+
ccp.SHORT_UP,
|
|
458
|
+
size,
|
|
459
|
+
address_extension,
|
|
460
|
+
*address,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def start_stop_all(self, mode: int) -> Optional[CommandResponse]:
|
|
464
|
+
return self._execute(
|
|
465
|
+
ccp.START_STOP_ALL,
|
|
466
|
+
mode,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def set_s_status(self, status: int) -> Optional[CommandResponse]:
|
|
470
|
+
status = struct.pack("<B", _unsigned("Status", status, 8))
|
|
471
|
+
|
|
472
|
+
return self._execute(
|
|
473
|
+
ccp.SET_S_STATUS,
|
|
474
|
+
*status,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def get_s_status(self) -> Optional[CommandResponse]:
|
|
478
|
+
return self._execute(ccp.GET_S_STATUS)
|
|
479
|
+
|
|
480
|
+
def build_chksum(self, block_size: int) -> Optional[CommandResponse]:
|
|
481
|
+
block_size = struct.pack("<L", _unsigned("Block size", block_size, 32))
|
|
482
|
+
|
|
483
|
+
return self._execute(
|
|
484
|
+
ccp.BUILD_CHKSUM,
|
|
485
|
+
*block_size,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def clear_memory(self, block_size: int) -> Optional[CommandResponse]:
|
|
489
|
+
block_size = struct.pack("<L", _unsigned("Block size", block_size, 32))
|
|
490
|
+
|
|
491
|
+
return self._execute(
|
|
492
|
+
ccp.CLEAR_MEMORY,
|
|
493
|
+
*block_size,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def program(self, data: bytes) -> Optional[CommandResponse]:
|
|
497
|
+
"""Program 1–5 bytes at MTA0; empty data ends the programming sequence."""
|
|
498
|
+
data = _payload(data, 0, 5)
|
|
499
|
+
|
|
500
|
+
return self._execute(
|
|
501
|
+
ccp.PROGRAM,
|
|
502
|
+
len(data),
|
|
503
|
+
*data,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def program6(self, data: bytes) -> Optional[CommandResponse]:
|
|
507
|
+
"""Program exactly six bytes at MTA0, with no size byte or implicit retries."""
|
|
508
|
+
data = _payload(data, 6, 6)
|
|
509
|
+
|
|
510
|
+
return self._execute(
|
|
511
|
+
ccp.PROGRAM_6,
|
|
512
|
+
*data,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def diag_service(
|
|
516
|
+
self, service_number: int, parameters: bytes = b""
|
|
517
|
+
) -> Optional[CommandResponse]:
|
|
518
|
+
"""Run a diagnostic service with up to four-parameter bytes.
|
|
519
|
+
|
|
520
|
+
Return the raw CRM (or None on timeout). The slave sets MTA0 to the
|
|
521
|
+
return information; use upload() separately to read that information.
|
|
522
|
+
"""
|
|
523
|
+
service_number = struct.pack(
|
|
524
|
+
"<H", _unsigned("Diagnostic service number", service_number, 16)
|
|
525
|
+
)
|
|
526
|
+
parameters = _payload(parameters, 0, 4)
|
|
527
|
+
|
|
528
|
+
return self._execute(
|
|
529
|
+
ccp.DIAG_SERVICE,
|
|
530
|
+
*service_number,
|
|
531
|
+
*parameters,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def move(self, size: int) -> Optional[CommandResponse]:
|
|
535
|
+
size = struct.pack("<L", _unsigned("Size", size, 32))
|
|
536
|
+
|
|
537
|
+
return self._execute(ccp.MOVE, *size)
|
|
538
|
+
|
|
539
|
+
def get_active_cal_page(self) -> Optional[CommandResponse]:
|
|
540
|
+
return self._execute(ccp.GET_ACTIVE_CAL_PAGE)
|
|
541
|
+
|
|
542
|
+
def select_cal_page(self) -> Optional[CommandResponse]:
|
|
543
|
+
return self._execute(ccp.SELECT_CAL_PAGE)
|
|
544
|
+
|
|
545
|
+
def unlock(self, key: bytes) -> Optional[CommandResponse]:
|
|
546
|
+
key = _payload(key, 0, 6)
|
|
547
|
+
return self._execute(
|
|
548
|
+
ccp.UNLOCK,
|
|
549
|
+
*key,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def get_seed(self, resource: SecondaryResource) -> Optional[CommandResponse]:
|
|
553
|
+
return self._execute(
|
|
554
|
+
ccp.GET_SEED,
|
|
555
|
+
resource,
|
|
556
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyccp
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: CAN Calibration Protocol for Python
|
|
5
|
+
Author-email: Vladimir Petrigo <vladimir.petrigo@gmail.com>
|
|
6
|
+
License-File: LICENSE.md
|
|
7
|
+
Keywords: asam,automotive,ccp,ecu
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: python-can<5,>=4
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
[](https://github.com/vpetrigo/pyccp/actions/workflows/ci.yml)
|
|
23
|
+
[](https://pypi.org/project/pyccp)
|
|
24
|
+

|
|
25
|
+
[](https://pepy.tech/projects/pyccp)
|
|
26
|
+
|
|
27
|
+
# CAN Calibration Protocol for Python
|
|
28
|
+
|
|
29
|
+
Python implementation of the CAN Calibration Protocol (CCP) v2.1 Master, built
|
|
30
|
+
on [python-can](https://python-can.readthedocs.io/). Inspired by [pySART/pyccp](https://github.com/pySART/pyccp).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```toml
|
|
35
|
+
dependencies = [
|
|
36
|
+
"pyccp==0.10.0"
|
|
37
|
+
]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import can
|
|
44
|
+
from pyccp import Master
|
|
45
|
+
from pyccp.ccp import ReturnCodes
|
|
46
|
+
|
|
47
|
+
bus = can.Bus(interface="socketcan", channel="can0")
|
|
48
|
+
|
|
49
|
+
with Master(bus, cro=0x7E1, dto=0x815) as master:
|
|
50
|
+
resp = master.connect(address=0x39)
|
|
51
|
+
assert resp is not None and resp.code == ReturnCodes.ACKNOWLEDGE
|
|
52
|
+
|
|
53
|
+
resp = master.set_mta(address=0x1000)
|
|
54
|
+
assert resp is not None and resp.code == ReturnCodes.ACKNOWLEDGE
|
|
55
|
+
|
|
56
|
+
resp = master.clear_memory(block_size=0x200)
|
|
57
|
+
chunks = [b"abcdef", b"ghi", b""]
|
|
58
|
+
|
|
59
|
+
for chunk in chunks:
|
|
60
|
+
resp = master.program6(chunk)
|
|
61
|
+
assert resp is not None and resp.code == ReturnCodes.ACKNOWLEDGE
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## DAQ & Events
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pyccp import Master, DAQMessage, CommandResponse
|
|
68
|
+
|
|
69
|
+
with Master(bus, cro=0x7E1, dto=0x815) as master:
|
|
70
|
+
# Register a callback for DAQ messages
|
|
71
|
+
master.on_daq(0x816, lambda msg: print(f"DAQ: {msg.daq_id:#04X}, ODT: {msg.pid}, data: {msg.data.hex()}"))
|
|
72
|
+
# Connect and configure DAQ list
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Logging
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import logging
|
|
79
|
+
|
|
80
|
+
logging.getLogger("pyccp").setLevel(logging.DEBUG)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Tests
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
hatch test -py 3.15
|
|
87
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pyccp/__init__.py,sha256=Ys1PlqDDrAVc3Gnhj3hFWd85AEqIyrvFPCDQkoI5ET4,158
|
|
2
|
+
pyccp/ccp.py,sha256=7qJUz9WwvZeJ6PE8a7AOtRCNghOjyCjlhJazrYvSxyY,3317
|
|
3
|
+
pyccp/master.py,sha256=tTfkXdI12WJF44e2nq8n55gElP-QloicylO_ehkp0Ts,17031
|
|
4
|
+
pyccp-0.10.0.dist-info/METADATA,sha256=S8gblKY9AfL3A7zyUEM16O9yY6hcJ8H1AIujHnS7YI8,2709
|
|
5
|
+
pyccp-0.10.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
pyccp-0.10.0.dist-info/licenses/LICENSE.md,sha256=I5-2rJHdNVVOaXNl4aYS3C1CPfA9hDnkyNQkB8ZWlUY,1073
|
|
7
|
+
pyccp-0.10.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladimir Petrigo
|
|
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.
|