python-can-j1939 0.1.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.
@@ -0,0 +1,448 @@
1
+ import j1939
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ class DTC:
7
+ """
8
+ Parser/encoder for J1939 DTC (Diagnostic Trouble Code).
9
+
10
+ Supports the four SAE J1939-73 SPN conversion methods:
11
+ - CM 1: SPN MSBs in byte 1, mid in byte 2, LSBs+FMI in byte 3, CM bit = 1
12
+ - CM 2: SPN mid in byte 1, MSBs in byte 2, LSBs+FMI in byte 3, CM bit = 1
13
+ - CM 3: SPN LSBs/mid/MSBs in bytes 1/2/3 (modern layout), CM bit = 1
14
+ - CM 4: same byte layout as CM 3, CM bit = 0 (current standard)
15
+
16
+ The on-wire CM bit only distinguishes {1,2,3} (bit=1) from {4} (bit=0).
17
+ CM 1 vs CM 2 vs CM 3 are not separable from the bytes alone; when
18
+ decoding raw bytes with CM bit = 1, the caller must indicate which one
19
+ was used (defaults to CM 3 — the most common legacy layout).
20
+ """
21
+ def __init__(self, dtc=None, spn=None, fmi=None, oc=0, cm=4):
22
+ if dtc is not None:
23
+ self._cm = cm
24
+ self._dtc = dtc
25
+ self._oc = ((dtc >> 24) & 0x7F)
26
+ cm_bit = ((dtc >> 31) & 0x01)
27
+ b1 = dtc & 0xFF
28
+ b2 = (dtc >> 8) & 0xFF
29
+ b3 = (dtc >> 16) & 0xFF
30
+ self._fmi = b3 & 0x1F
31
+ spn_low3 = (b3 >> 5) & 0x07
32
+ if cm in (3, 4):
33
+ self._spn = b1 | (b2 << 8) | (spn_low3 << 16)
34
+ elif cm == 1:
35
+ # b1 = SPN[18:11], b2 = SPN[10:3], b3[7:5] = SPN[2:0]
36
+ self._spn = (b1 << 11) | (b2 << 3) | spn_low3
37
+ elif cm == 2:
38
+ # b1 = SPN[10:3], b2 = SPN[18:11], b3[7:5] = SPN[2:0]
39
+ self._spn = (b2 << 11) | (b1 << 3) | spn_low3
40
+ else:
41
+ raise ValueError(f"Invalid conversion method: {cm}. Must be 1, 2, 3, or 4.")
42
+ # Sanity-check the CM bit against the requested method
43
+ expected_cm_bit = 0 if cm == 4 else 1
44
+ if cm_bit != expected_cm_bit:
45
+ logger.warning("DM01: CM bit %d does not match requested conversion method %d", cm_bit, cm)
46
+ else:
47
+ if cm not in (1, 2, 3, 4):
48
+ raise ValueError(f"Invalid conversion method: {cm}. Must be 1, 2, 3, or 4.")
49
+ self._spn = spn
50
+ self._fmi = fmi
51
+ self._oc = oc
52
+ self._cm = cm
53
+ if cm == 1:
54
+ b1 = (spn >> 11) & 0xFF
55
+ b2 = (spn >> 3) & 0xFF
56
+ elif cm == 2:
57
+ b1 = (spn >> 3) & 0xFF
58
+ b2 = (spn >> 11) & 0xFF
59
+ else: # cm in (3, 4)
60
+ b1 = spn & 0xFF
61
+ b2 = (spn >> 8) & 0xFF
62
+ b3 = (((spn >> 16) & 0x07) << 5) | (fmi & 0x1F) if cm in (3, 4) \
63
+ else ((spn & 0x07) << 5) | (fmi & 0x1F)
64
+ b4 = oc & 0x7F
65
+ if cm != 4:
66
+ b4 |= 0x80
67
+ self._dtc = b1 | (b2 << 8) | (b3 << 16) | (b4 << 24)
68
+
69
+ @property
70
+ def spn(self):
71
+ """
72
+ :return:
73
+ SPN Suspect Parameter Number
74
+
75
+ :rtype: int
76
+ """
77
+ return self._spn
78
+
79
+ @property
80
+ def fmi(self):
81
+ """
82
+ :return:
83
+ FMI Failure Mode Identifier
84
+
85
+ :rtype: int
86
+ """
87
+ return self._fmi
88
+
89
+ @property
90
+ def oc(self):
91
+ """
92
+ :return:
93
+ DTC occurrence counter
94
+
95
+ :rtype: int
96
+ """
97
+ return self._oc
98
+
99
+ @property
100
+ def cm(self):
101
+ """
102
+ :return:
103
+ SPN conversion method (1, 2, 3, or 4 per SAE J1939-73)
104
+
105
+ :rtype: int
106
+ """
107
+ return self._cm
108
+
109
+ @property
110
+ def dtc(self):
111
+ """
112
+ :return:
113
+ DTC Diagnostic Trouble Code
114
+
115
+ :rtype: int
116
+ """
117
+ return self._dtc
118
+
119
+ class DtcLamp:
120
+ """Diagnostic trouble code lamp status
121
+ """
122
+ OFF = 0
123
+ ON = 1
124
+ ON_SLOW_FLASH = 2
125
+ ON_FAST_FLASH = 3
126
+ NA = 4
127
+
128
+ _KEYS = ['pl', 'awl', 'rsl', 'mil']
129
+ _DATA_LUT = {OFF: [0,3], ON: [1,3], ON_SLOW_FLASH: [1,0], ON_FAST_FLASH: [1,1], NA: [3,3]}
130
+
131
+ def get_status(self, lamp, flash):
132
+ status = self.NA
133
+ if lamp == 0:
134
+ status = self.OFF
135
+ elif lamp == 1:
136
+ if flash == 0:
137
+ status = self.ON_SLOW_FLASH
138
+ elif flash == 1:
139
+ status = self.ON_FAST_FLASH
140
+ elif flash == 3:
141
+ status = self.ON
142
+ return status
143
+
144
+ def get_data(self, status_dic):
145
+ data = [0]*2
146
+ for idx, lamp_key in enumerate(self._KEYS):
147
+ # initialize not available lamps
148
+ if status_dic.get(lamp_key) == None:
149
+ status_dic[lamp_key] = DtcLamp.OFF
150
+ elif status_dic[lamp_key] not in self._DATA_LUT:
151
+ status_dic[lamp_key] = DtcLamp.OFF
152
+ logger.error("Lamp status n/a")
153
+ lamp, flash = self._DATA_LUT[status_dic[lamp_key]]
154
+
155
+ data[0] |= (lamp << (idx*2))
156
+ data[1] |= (flash << (idx*2))
157
+
158
+ return data
159
+
160
+
161
+ class Dm1:
162
+ """Active Diagnostic Trouble Codes (DM1)
163
+
164
+ Parser for DM1
165
+
166
+ DM1 provides diagnostic lamp status and diagnostic trouble codes (DTCs).
167
+ Together, the lamp and DTC information convey the diagnostic condition
168
+ of the transmitting electronic component to other components on the network.
169
+ Occurrence counts may be provided.
170
+ """
171
+ _msg_subscriber_added = False
172
+
173
+ def __init__(self, ca: j1939.ControllerApplication, rx_cm_bit_set: int = 3):
174
+ """
175
+ :param obj ca: j1939 controller application
176
+ :param int rx_cm_bit_set:
177
+ SPN conversion method (1, 2, or 3) to assume when a received DTC
178
+ has its CM bit set. The on-wire CM bit cannot distinguish CMs 1,
179
+ 2 and 3 — only between {1,2,3} (bit=1) and 4 (bit=0). Defaults to
180
+ 3 (the most common legacy layout). CM 4 is auto-detected.
181
+ """
182
+ if rx_cm_bit_set not in (1, 2, 3):
183
+ raise ValueError(f"rx_cm_bit_set must be 1, 2, or 3 (got {rx_cm_bit_set})")
184
+ self._pgn = j1939.ParameterGroupNumber.PGN.DM01
185
+ self._lamp_status = {}
186
+ self._dtc_dic_list = []
187
+ self._data = []
188
+ self._subscribers = []
189
+ self._ca = ca
190
+ self._rx_cm_bit_set = rx_cm_bit_set
191
+
192
+ def subscribe(self, callback):
193
+ """Add the given callback to the Dm1 message notification stream.
194
+
195
+ :param callback:
196
+ Function to call when Dm1 message is received.
197
+ """
198
+ if self._msg_subscriber_added == False:
199
+ self._ca.subscribe(self._receive)
200
+ self._msg_subscriber_added = True
201
+
202
+ self._subscribers.append(callback)
203
+
204
+ def unsubscribe(self, callback):
205
+ """Stop listening for Dm1 message.
206
+
207
+ :param callback:
208
+ Function to call when Dm1 message is received.
209
+ """
210
+ self._subscribers.remove(callback)
211
+
212
+ def start_send(self, callback, cycletime=1):
213
+ """Start cyclic sending of Dm1 message
214
+
215
+ :param callback:
216
+ Function to call before Dm1 message is sent
217
+ :param int cycletime:
218
+ Optional send cycletime
219
+ cycletime is 1s if not specified
220
+ :param int priority:
221
+ priority of Dm1 message
222
+ """
223
+ cookie = {'cb': callback,}
224
+ self._ca.add_timer(delta_time=cycletime, callback=self._send, cookie=cookie)
225
+
226
+ def stop_send(self):
227
+ """Stop cyclic sending of Dm1 message
228
+ """
229
+ self._ca.remove_timer(callback=self._send)
230
+
231
+ @property
232
+ def dtc_dic_list(self):
233
+ """
234
+ :return:
235
+ list of dictionaries of all DTCs included in DM1
236
+
237
+ :rtype: list of dic: 'spn', 'fmi', 'oc'
238
+ """
239
+ return self._dtc_dic_list
240
+
241
+ @property
242
+ def lamp_status(self):
243
+ """
244
+ :return:
245
+ global lamp status for the DM1
246
+
247
+ :rtype: dic: 'pl', 'awl', 'rsl', 'mil'
248
+ """
249
+ return self._lamp_status
250
+
251
+ @property
252
+ def data(self):
253
+ """
254
+ :return:
255
+ j1939 pdu payload
256
+
257
+ :rtype: list of int
258
+ """
259
+ return self._data
260
+
261
+ def _receive(self, priority, pgn, sa, timestamp, data):
262
+ if pgn == self._pgn:
263
+ self._data = data
264
+ self._parse_dm1_receive_data()
265
+ self._notify_subscribers(sa, timestamp)
266
+
267
+ def _send(self, cookie):
268
+ # get dm1 data
269
+ self._lamp_status, self._dtc_dic_list = cookie['cb']()
270
+
271
+ # create payload - lamp status
272
+ self._data = DtcLamp().get_data(self._lamp_status)
273
+
274
+ # create payload - dtc
275
+ for dtc_dic in self._dtc_dic_list:
276
+ # not optional arguments
277
+ if dtc_dic.get('spn') == None:
278
+ continue
279
+ if dtc_dic.get('fmi') == None:
280
+ continue
281
+ # optional arguments
282
+ if dtc_dic.get('oc') == None:
283
+ dtc_dic['oc'] = 0
284
+ cm = dtc_dic.get('cm', 4)
285
+
286
+ dtc = DTC(spn=dtc_dic['spn'], fmi=dtc_dic['fmi'], oc=dtc_dic['oc'], cm=cm).dtc
287
+ self._data.append(dtc & 0xFF)
288
+ self._data.append((dtc >> 8) & 0xFF)
289
+ self._data.append((dtc >> 16) & 0xFF)
290
+ self._data.append((dtc >> 24) & 0xFF)
291
+
292
+ # no dtcs to report
293
+ if len(self._data) == 2:
294
+ self._data.extend([0x00, 0x00, 0x00, 0x00, 0xff, 0xff])
295
+ # one dtc to report
296
+ elif len(self._data) == 6:
297
+ self._data.extend([0xff, 0xff])
298
+
299
+
300
+ # Default Priority: 6
301
+ # priority should be 7 when transport protocol is used (SAE J1939-21 requirement)
302
+ if len(self._data) > 8:
303
+ priority = 7
304
+ else:
305
+ priority = 6
306
+ # send pgn
307
+ self._ca.send_pgn(0, (self._pgn >> 8) & 0xFF, self._pgn & 0xFF, priority, self._data )
308
+
309
+ # returning true keeps the timer event active
310
+ return True
311
+
312
+ def _parse_dm1_receive_data(self):
313
+ length = len(self._data)
314
+ if length < 6:
315
+ logger.error("DM01: length shorted than 6 bytes")
316
+ return
317
+
318
+ dtc_length = length - 2
319
+ if (length != 8) and (dtc_length % 4) != 0:
320
+ logger.error("DM01: DTC length incorrect")
321
+ return
322
+
323
+ # calculate numboer of DTCs
324
+ number_dtc = int(dtc_length / 4)
325
+
326
+ # get lamp status
327
+ self._lamp_status['pl'] = DtcLamp().get_status( self._data[0] & 0x03, self._data[1] & 0x03)
328
+ self._lamp_status['awl'] = DtcLamp().get_status((self._data[0] >> 2) & 0x03, (self._data[1] >> 2) & 0x03)
329
+ self._lamp_status['rsl'] = DtcLamp().get_status((self._data[0] >> 4) & 0x03, (self._data[1] >> 4) & 0x03)
330
+ self._lamp_status['mil'] = DtcLamp().get_status((self._data[0] >> 6) & 0x03, (self._data[1] >> 6) & 0x03)
331
+
332
+ # get DTC (Diagnostic Trouble Code)
333
+ self._dtc_dic_list = []
334
+ for i in range(number_dtc):
335
+ dtc_int = ( (self._data[i*4+2] & 0xff)
336
+ | ((self._data[i*4+3] & 0xff) << 8)
337
+ | ((self._data[i*4+4] & 0xff) << 16)
338
+ | ((self._data[i*4+5] & 0xff) << 24))
339
+
340
+ if dtc_int == 0x0:
341
+ # according to J1939 standard after 2004, if all these bytes are 0x00 then then no data is available for the dtc
342
+ # so we should not add this to the dtc list since it is not a valid dtc
343
+ continue
344
+
345
+ cm = 4 if ((dtc_int >> 31) & 0x01) == 0 else self._rx_cm_bit_set
346
+ dtc = DTC(dtc=dtc_int, cm=cm)
347
+ self._dtc_dic_list.append( {'spn': dtc.spn, 'fmi': dtc.fmi, 'oc': dtc.oc, 'cm': dtc.cm } )
348
+
349
+ def _notify_subscribers(self, sa, timestamp):
350
+ for callback in self._subscribers:
351
+ callback(sa, self.lamp_status.copy(), self._dtc_dic_list.copy(), timestamp)
352
+
353
+
354
+ class Dm11:
355
+ """Diagnostic Data Clear/Reset for Active DTCs (DM11)
356
+ """
357
+ def __init__(self, ca: j1939.ControllerApplication):
358
+ """
359
+ :param obj ca: j1939 controller application
360
+ """
361
+ self._pgn = j1939.ParameterGroupNumber.PGN.DM11
362
+ self._ca = ca
363
+ self._subscribers_req_clear = []
364
+ self._subscribers_ack_clear = []
365
+ ca.subscribe_request(self._on_request)
366
+ ca.subscribe_acknowledge(self._on_acknowledge)
367
+
368
+ def request_clear_all(self, destination):
369
+ self._ca.send_request(0, self._pgn, destination)
370
+
371
+ def subscribe_request_clear_all(self, callback):
372
+ self._subscribers_req_clear.append(callback)
373
+
374
+ def subscribe_acknowledge_clear_all(self, callback):
375
+ self._subscribers_ack_clear.append(callback)
376
+
377
+ def _on_request(self, src_address, dest_address, pgn):
378
+ for subscriber in self._subscribers_req_clear:
379
+ subscriber(src_address, dest_address, pgn)
380
+ # TODO: send acknowledge
381
+
382
+ def _on_acknowledge(self, src_address, dest_address, pgn):
383
+ for subscriber in self._subscribers_ack_clear:
384
+ # TODO
385
+ pass
386
+
387
+ class Dm22:
388
+ """Individual Clear/Reset of Active and Previously Active DTC (DM22)
389
+ """
390
+ class DTC_CLR_CTRL:
391
+ """Individual DTC Clear/Reset Control Byte
392
+ """
393
+ PA_REQ = 1 # Request to clear/reset a specific previously active DTC
394
+ PA_ACK = 2 # Positive acknowledge of clear/reset of a specific previously active DTC
395
+ PA_NACK = 3 # Negative acknowledge of clear/reset of a specific previously active DTC
396
+ ACT_REQ = 17 # Request to clear/reset a specific active DTC
397
+ ACT_ACK = 18 # Positive acknowledge of clear/reset of a specific active DTC
398
+ ACT_NACK = 19 # Negative acknowledge of clear/reset of a specific active DTC
399
+
400
+ class DTC_CLR_CTRL_SPECIFIC:
401
+ """Control Byte Specific Indicator for Individual DTC Clear
402
+ """
403
+ GENERAL_NACK = 0
404
+ ACCESS_DENIED = 1
405
+ DTC_UNKNOWN = 2
406
+ DTC_PA_NOT_ACTIVE = 3
407
+ DTC_ACT_NOT_ACTIVE = 4
408
+
409
+ def __init__(self, ca: j1939.ControllerApplication):
410
+ """
411
+ :param obj ca: j1939 controller application
412
+ """
413
+ self._pgn = j1939.ParameterGroupNumber.PGN.DM22
414
+ self._ca = ca
415
+
416
+ def request_clear_act_dtc(self, dest_address, spn, fmi):
417
+ """Request to Clear/Reset Active DTC
418
+
419
+ :param dest_address:
420
+ destination address of the node
421
+ :param spn:
422
+ spn of the dtc to be cleared
423
+ :param spn:
424
+ fmi of the dtc to be cleared
425
+ """
426
+ self._send_request(self.DTC_CLR_CTRL.ACT_REQ, dest_address, fmi, spn)
427
+
428
+ def request_clear_pa_dtc(self, dest_address, spn, fmi):
429
+ """Request to Clear/Reset Previously Active DTC
430
+
431
+ :param dest_address:
432
+ destination address of the node
433
+ :param spn:
434
+ spn of the dtc to be cleared
435
+ :param spn:
436
+ fmi of the dtc to be cleared
437
+ """
438
+ self._send_request(self.DTC_CLR_CTRL.PA_REQ, dest_address, fmi, spn)
439
+
440
+ def _send_request(self, control_byte, dest_address, fmi, spn):
441
+ data = [0xFF]*8
442
+ data[0] = control_byte
443
+ data[5] = spn & 0xFF
444
+ data[6] = (spn >> 8) & 0xFF
445
+ data[7] = ((spn >> 22) & 0xE0) | (fmi & 0x1F)
446
+
447
+ # send pgn
448
+ self._ca.send_pgn(0, (self._pgn >> 8) & 0xFF, dest_address & 0xFF, 6, data)