evocode 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.
evocode/swdl.py ADDED
@@ -0,0 +1,423 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SWDL flash choreography — enter bootloader, authenticate L3, transfer a container, finalize.
3
+
4
+ This is the diagnostic-plane flash that coding write already relies on conceptually: same HSFZ
5
+ transport, same L3 key, different session (programming 0x02 instead of coding 0x41) and different
6
+ services (requestDownload / transferData instead of writeDID). The bootloader processes the
7
+ container and writes the enclosed file to the path named in the container's metadata.
8
+
9
+ Reference behaviour: ``nbt-evo-ssh-unlocker/nbt_optdebug_flash.py`` (no LICENSE file; treated as
10
+ behavioural reference for the wire protocol; attribution in CREDITS.md).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from typing import Callable, Optional
16
+
17
+ from . import l3
18
+ from .hsfz import Hsfz, TGT_HU
19
+ from .uds import Uds, NRC_NAMES as _UDS_NRC
20
+
21
+ # ------------------------------------------------------------------------------- UDS / SWDL constants
22
+
23
+ SID_ROUTINE = 0x31
24
+ SID_RD = 0x34 # requestDownload
25
+ SID_TD = 0x36 # transferData
26
+ SID_TX = 0x37 # transferExit
27
+ SID_WDBI = 0x2E # writeDataByIdentifier
28
+
29
+ SA_SEED_FLASH = 0x11 # SecurityAccess requestSeed level for flash
30
+ SA_KEY_FLASH = 0x12 # SecurityAccess sendKey level for flash
31
+
32
+ DID_FINGERPRINT = 0xF15A
33
+ DID_ADS = 0xF186 # activeDiagnosticSession
34
+ DID_ENERGY = 0x100A
35
+ DID_EXTMODE = 0x100E
36
+ DID_FTIMING = 0x2504
37
+ DID_RASS = 0xF100
38
+
39
+ RID_ERASE = 0xFF00
40
+ RID_CHECKMEM = 0x0202
41
+ RID_DEPCHECK = 0xFF01
42
+
43
+ ERASE_METHOD_IE = 0x02
44
+ CHECK_METHOD_ICV = 0x12
45
+ ERASE_ACTIVATION = 0x06
46
+ ALFI = 0x44
47
+ MAX_BLOCKSIZE = 0xFFF1 # 65521
48
+
49
+ # prog-mode UDS frames
50
+ EXT_SESSION = bytes([0x10, 0x03])
51
+ PROG_SESSION = bytes([0x10, 0x02])
52
+ HARD_RESET = bytes([0x11, 0x01])
53
+ ENERGY_ON = bytes.fromhex("31010F0C03")
54
+ ENERGY_OFF = bytes.fromhex("31010F0C00")
55
+ DTC_OFF = bytes.fromhex("8502")
56
+ DTC_ON = bytes.fromhex("8501")
57
+ COMM_QUIET = bytes.fromhex("280301")
58
+ COMM_NORMAL = bytes.fromhex("280001")
59
+ EXT_SET_01 = bytes.fromhex("3101100301")
60
+ EXT_SET_OFF = bytes.fromhex("3101100300")
61
+ ACT_APP_MODE = bytes.fromhex("3101100F")
62
+
63
+ # Shared UDS names (evocode.uds) + the NRCs the flash loop specifically handles.
64
+ NRC_NAMES = {
65
+ **_UDS_NRC,
66
+ 0x12: "subFunctionNotSupported",
67
+ 0x21: "busyRepeatRequest",
68
+ 0x24: "requestSequenceError",
69
+ 0x37: "requiredTimeDelayNotExpired",
70
+ 0x73: "wrongBlockSequenceCounter",
71
+ 0x78: "responsePending",
72
+ }
73
+
74
+
75
+ def is_neg(r: bytes) -> bool:
76
+ return len(r) >= 1 and r[0] == 0x7F
77
+
78
+
79
+ # ------------------------------------------------------------------------------- container format
80
+
81
+ def build_container(data: bytes, target_path: str, name: str,
82
+ tag: str = "FIL", cpu: str = "Omap") -> bytes:
83
+ """Build a SWDL no-signature file-copy container from raw file bytes.
84
+
85
+ Three records: header (0x01), metadata (0x02), file data (0x04).
86
+ """
87
+ meta = "TargetPath=%s;Name=%s;Tag=%s;CPU=%s;" % (target_path, name, tag, cpu)
88
+ meta_b = meta.encode("latin-1")
89
+
90
+ def _rec(marker: int, flags1: int, flags2: int, payload: bytes) -> bytes:
91
+ return (bytes([marker]) + flags1.to_bytes(2, "little") +
92
+ len(payload).to_bytes(4, "little") + flags2.to_bytes(2, "little") + payload)
93
+
94
+ return (_rec(0x01, 1, 1, b"\x01") +
95
+ _rec(0x02, 2, 1, meta_b) +
96
+ _rec(0x04, 2, 1, data))
97
+
98
+
99
+ def container_target(img: bytes) -> tuple[Optional[str], Optional[str], Optional[bool]]:
100
+ """Parse the metadata record of a SWDL container: (full_path, name, is_live_direct)."""
101
+ pos, n = 0, len(img)
102
+ while pos + 9 <= n:
103
+ marker = img[pos]
104
+ length = int.from_bytes(img[pos + 3:pos + 7], "little")
105
+ if marker == 0x02:
106
+ meta = img[pos + 9:pos + 9 + length].decode("latin-1", "replace")
107
+ kv = dict(p.split("=", 1) for p in meta.split(";") if "=" in p)
108
+ tp = kv.get("TargetPath", "")
109
+ nm = kv.get("Name", "")
110
+ return (tp + nm, nm, bool(tp) and "/repository/istep/" not in tp)
111
+ pos += 9 + length
112
+ return None, None, None
113
+
114
+
115
+ # ------------------------------------------------------------------------------- low-level helpers
116
+
117
+ def _tx(h: Hsfz, target: int, uds: bytes, label: str, log: Callable) -> Optional[bytes]:
118
+ try:
119
+ r = h.request(target, uds)
120
+ except Exception as e:
121
+ log(" [tx] %-30s -> ERR %s" % (label, e))
122
+ return None
123
+ if is_neg(r):
124
+ nrc = r[2] if len(r) > 2 else 0
125
+ log(" [tx] %-30s -> NRC 0x%02X %s" % (label, nrc, NRC_NAMES.get(nrc, "?")))
126
+ else:
127
+ log(" [tx] %-30s -> %s" % (label, r[:12].hex()))
128
+ return r
129
+
130
+
131
+ def _ispos(r: Optional[bytes]) -> bool:
132
+ return r is not None and r[0] != 0x7F
133
+
134
+
135
+ def _val(r: Optional[bytes]) -> Optional[int]:
136
+ return r[-1] if _ispos(r) and r[0] == 0x62 else None
137
+
138
+
139
+ def _read_session(h: Hsfz, target: int, label: str, log: Callable) -> Optional[int]:
140
+ r = _tx(h, target, bytes([0x22, 0xF1, 0x86]), label, log)
141
+ return r[3] if _ispos(r) and r[0] == 0x62 and len(r) >= 4 else None
142
+
143
+
144
+ # ------------------------------------------------------------------------------- frame builders
145
+
146
+ def _fr_erase(lbn: int) -> bytes:
147
+ return (bytes([SID_ROUTINE, 0x01, RID_ERASE >> 8, RID_ERASE & 0xFF,
148
+ ERASE_METHOD_IE, ALFI]) + lbn.to_bytes(4, "big") + bytes([ERASE_ACTIVATION]))
149
+
150
+
151
+ def _fr_check(lbn: int) -> bytes:
152
+ return (bytes([SID_ROUTINE, 0x01, RID_CHECKMEM >> 8, RID_CHECKMEM & 0xFF,
153
+ CHECK_METHOD_ICV, ALFI]) + lbn.to_bytes(4, "big") + bytes([0x00, 0x00]))
154
+
155
+
156
+ def _fr_req_download(addr: int, size: int) -> bytes:
157
+ nlen = (ALFI >> 4) & 0x0F
158
+ nsz = ALFI & 0x0F
159
+ return bytes([SID_RD, 0x00, ALFI]) + addr.to_bytes(nlen, "big") + size.to_bytes(nsz, "big")
160
+
161
+
162
+ def _parse_block_length(resp: Optional[bytes], default: int = 0x400) -> int:
163
+ if not resp or resp[0] != SID_RD + 0x40 or len(resp) < 3:
164
+ return default
165
+ nbytes = (resp[1] >> 4) & 0x0F
166
+ if nbytes == 0 or len(resp) < 2 + nbytes:
167
+ return default
168
+ maxblk = int.from_bytes(resp[2:2 + nbytes], "big")
169
+ return max(1, min(maxblk - 2, MAX_BLOCKSIZE)) if maxblk > 2 else default
170
+
171
+
172
+ # ------------------------------------------------------------------------------- prog-mode entry
173
+
174
+ def enter_progmode(h: Hsfz, target: int = TGT_HU, reset_timeout: int = 50,
175
+ log: Callable = print) -> bool:
176
+ """Enter the flash bootloader (programming session 0x02).
177
+
178
+ Returns True if the HU is in session 0x02 (bootloader). The HU self-reboots on 10 02;
179
+ we close/reopen the TCP connection and poll 22 F186 until it answers 0x02.
180
+ """
181
+ log("== STEP 1: extended session + flash timing ==")
182
+ _tx(h, target, EXT_SESSION, "10 03 ExtendedSession", log)
183
+ _tx(h, target, bytes([0x22, 0x25, 0x04]), "22 2504 FlashTiming", log)
184
+
185
+ log("== STEP 2: ActivateFlashMode ==")
186
+ if not _ispos(_tx(h, target, ENERGY_ON, "31 01 0F0C 03 ActivateFlash", log)):
187
+ log(" *** HU refused ActivateFlash — abort. ***")
188
+ return False
189
+ if _val(_tx(h, target, bytes([0x22, 0x10, 0x0A]), "22 100A EnergyMode", log)) in (None, 0x00):
190
+ log(" *** EnergyMode not set — abort. ***")
191
+ return False
192
+
193
+ log("== STEP 3: DTC off + CommunicationControl quiet ==")
194
+ _tx(h, target, DTC_OFF, "85 02 DTC off", log)
195
+ _tx(h, target, COMM_QUIET, "28 03 01 CommControl quiet", log)
196
+
197
+ log("== STEP 4: SetExtendedMode ==")
198
+ r = _tx(h, target, EXT_SET_01, "31 01 1003 01 SetExtMode", log)
199
+ if r is None or is_neg(r):
200
+ log(" *** SetExtendedMode rejected — abort. ***")
201
+ return False
202
+
203
+ log("== STEP 5: programmingSession (HU self-reboots to bootloader) ==")
204
+ _tx(h, target, EXT_SESSION, "10 03", log)
205
+ r = _tx(h, target, PROG_SESSION, "10 02 ProgrammingSession", log)
206
+ if r is not None and is_neg(r) and len(r) > 2 and r[2] == 0x22:
207
+ log(" *** 10 02 conditionsNotCorrect — abort. ***")
208
+ return False
209
+
210
+ log(" [..] HU self-rebooting — polling 22 F186 (<= %ds)" % reset_timeout)
211
+ try:
212
+ h.close()
213
+ except Exception:
214
+ pass
215
+ t0 = time.time()
216
+ sess = None
217
+ while time.time() - t0 < reset_timeout:
218
+ time.sleep(1.0)
219
+ try:
220
+ h.reconnect(timeout=2.0)
221
+ r = h.request(target, bytes([0x22, 0xF1, 0x86]))
222
+ if _ispos(r) and r[0] == 0x62 and len(r) >= 4:
223
+ sess = r[3]
224
+ log(" [..] HU answering after %.0fs: session 0x%02X" % (time.time() - t0, sess))
225
+ break
226
+ except Exception:
227
+ try:
228
+ h.sock.close()
229
+ except Exception:
230
+ pass
231
+ h.reconnect()
232
+ if sess == 0x02:
233
+ log(" *** HU in PROGRAMMING SESSION (bootloader). ***")
234
+ return True
235
+ if sess is not None:
236
+ log(" HU in session 0x%02X (not bootloader). ***" % sess)
237
+ else:
238
+ log(" *** HU did not answer within %ds. ***" % reset_timeout)
239
+ return False
240
+
241
+
242
+ # ------------------------------------------------------------------------------- teardown
243
+
244
+ def teardown(h: Hsfz, target: int = TGT_HU, log: Callable = print) -> bool:
245
+ """Restore the HU to the normal app session after a flash or abort."""
246
+ log("== TEARDOWN: restore HU to app ==")
247
+ try:
248
+ h.reconnect()
249
+ except Exception:
250
+ pass
251
+ _tx(h, target, ACT_APP_MODE, "31 01 100F ActAppMode", log)
252
+
253
+ en_ok = ext_ok = False
254
+ st = None
255
+ for _ in range(6):
256
+ try:
257
+ h.reconnect()
258
+ except Exception:
259
+ pass
260
+ _tx(h, target, EXT_SESSION, "10 03", log)
261
+ _tx(h, target, ENERGY_OFF, "31 01 0F0C 00 EnergyOff", log)
262
+ en_ok = _val(_tx(h, target, bytes([0x22, 0x10, 0x0A]), "22 100A", log)) == 0x00
263
+ _tx(h, target, EXT_SET_OFF, "31 01 1003 00 ExtModeOff", log)
264
+ ext_ok = _val(_tx(h, target, bytes([0x22, 0x10, 0x0E]), "22 100E", log)) == 0x00
265
+ st = _read_session(h, target, "22 F186", log)
266
+ if (en_ok and ext_ok) or st == 0x01:
267
+ break
268
+ time.sleep(2.0)
269
+
270
+ _tx(h, target, COMM_NORMAL, "28 00 01 CommNormal", log)
271
+ _tx(h, target, DTC_ON, "85 01 DTC on", log)
272
+
273
+ if (en_ok and ext_ok) or st == 0x01:
274
+ _tx(h, target, HARD_RESET, "11 01 reset -> app", log)
275
+ return True
276
+ log(" *** not clean — forcing reset ***")
277
+ _tx(h, target, HARD_RESET, "11 01 reset (force)", log)
278
+ try:
279
+ h.close()
280
+ except Exception:
281
+ pass
282
+ t0 = time.time()
283
+ while time.time() - t0 < 25:
284
+ time.sleep(2.0)
285
+ try:
286
+ h.reconnect()
287
+ if _read_session(h, target, "22 F186 (after reset)", log) == 0x01:
288
+ log(" [..] back in app after %.0fs" % (time.time() - t0))
289
+ return True
290
+ except Exception:
291
+ try:
292
+ h.sock.close()
293
+ except Exception:
294
+ pass
295
+ log(" *** HU did not return to app after reset. ***")
296
+ return False
297
+
298
+
299
+ # ------------------------------------------------------------------------------- transfer + auth
300
+
301
+ def _xfer_block(h: Hsfz, target: int, seq: int, chunk: bytes) -> tuple[bool, Optional[str]]:
302
+ frame = bytes([SID_TD, seq & 0xFF]) + chunk
303
+ for attempt in range(6):
304
+ try:
305
+ h.send_only(target, frame)
306
+ while True:
307
+ _ctrl, body = h.recv_frame(timeout=8.0)
308
+ resp = body[2:] if len(body) > 2 else b""
309
+ if not is_neg(resp) and len(resp) >= 2 and resp[0] == 0x76:
310
+ if resp[1] == seq:
311
+ return True, ("resent" if attempt else None)
312
+ continue
313
+ if is_neg(resp):
314
+ nrc = resp[2] if len(resp) > 2 else 0
315
+ if nrc == 0x73:
316
+ return True, "0x73-advance"
317
+ if nrc in (0x21, 0x7F):
318
+ time.sleep(0.2)
319
+ break
320
+ return False, "NRC 0x%02X %s" % (nrc, NRC_NAMES.get(nrc, "?"))
321
+ except TimeoutError:
322
+ time.sleep(0.3)
323
+ except Exception:
324
+ time.sleep(0.4)
325
+ return False, "exhausted retries"
326
+
327
+
328
+ def _l3_auth(h: Hsfz, target: int, key: dict, btld: int, user_id: int,
329
+ log: Callable) -> bool:
330
+ log("\n== SecurityAccess L3 (flash) ==")
331
+ u = Uds(h, target) # same facade coding/fsc use, levels 0x11/0x12
332
+ try:
333
+ seed = u.request_seed(SA_SEED_FLASH, user_id)
334
+ except Exception as e:
335
+ log(" *** no seed — abort, nothing written. (%s) ***" % e)
336
+ return False
337
+ sig = l3.sign_seed(seed, key, btld=btld, user_id=user_id)
338
+ try:
339
+ u.send_key(SA_KEY_FLASH, sig)
340
+ except Exception as e:
341
+ log(" *** auth NOT granted — abort, nothing written. (%s) ***" % e)
342
+ return False
343
+ log(" *** L3 auth GRANTED ***")
344
+ return True
345
+
346
+
347
+ # ------------------------------------------------------------------------------- apply_flash
348
+
349
+ def apply_flash(h: Hsfz, img: bytes, *, target: int = TGT_HU, reset_timeout: int = 50,
350
+ key: dict, btld: int = 0x00001FFD, lbn: int = 0,
351
+ fingerprint: bytes = b"\x00" * 13, user_id: int = 0x00000000,
352
+ log: Callable = print) -> bool:
353
+ """Flash a SWDL container to the HU: enter bootloader → L3 auth → transfer → finalize.
354
+
355
+ Returns True on successful transfer commit. The caller is responsible for teardown.
356
+ """
357
+ if not enter_progmode(h, target, reset_timeout, log=log):
358
+ log("\n *** did not reach bootloader — ABORT, NOTHING written. ***")
359
+ return False
360
+
361
+ if not _l3_auth(h, target, key, btld, user_id, log):
362
+ return False
363
+
364
+ h.start_tester_present(log=log)
365
+
366
+ log("\n== SWDL write (%d bytes) ==" % len(img))
367
+ # fingerprint (optional, may NRC)
368
+ try:
369
+ h.request(target, bytes([SID_WDBI, DID_FINGERPRINT >> 8, DID_FINGERPRINT & 0xFF]) + fingerprint)
370
+ except Exception:
371
+ pass
372
+
373
+ # erase
374
+ r = _tx(h, target, _fr_erase(lbn), "31 FF00 eraseMemory", log)
375
+ if not _ispos(r):
376
+ log(" *** erase rejected — ABORT (live file untouched). ***")
377
+ return False
378
+
379
+ # request download
380
+ r = _tx(h, target, _fr_req_download(0, len(img)), "34 requestDownload", log)
381
+ if not _ispos(r):
382
+ log(" *** requestDownload rejected — ABORT. ***")
383
+ return False
384
+ block_size = _parse_block_length(r)
385
+ nblocks = (len(img) + block_size - 1) // block_size
386
+ log(" block size %d, %d blocks" % (block_size, nblocks))
387
+
388
+ # transfer
389
+ seq = 1
390
+ t0 = time.time()
391
+ recovered = 0
392
+ for i, off in enumerate(range(0, len(img), block_size)):
393
+ chunk = img[off:off + block_size]
394
+ ok, note = _xfer_block(h, target, seq, chunk)
395
+ if not ok:
396
+ log(" *** block %d/%d FAILED: %s — ABORT. ***" % (i + 1, nblocks, note))
397
+ return False
398
+ if note:
399
+ recovered += 1
400
+ if i % 50 == 0 or i == nblocks - 1:
401
+ sent = off + len(chunk)
402
+ elapsed = max(0.001, time.time() - t0)
403
+ log(" [..] block %d/%d (%.0fs, %.1f KB/s, %d recovered)"
404
+ % (i + 1, nblocks, elapsed, sent / 1024.0 / elapsed, recovered))
405
+ seq = (seq + 1) & 0xFF
406
+ log(" transfer complete: %d blocks, %.1fs" % (nblocks, time.time() - t0))
407
+
408
+ # finalize
409
+ r = _tx(h, target, bytes([SID_TX]), "37 transferExit", log)
410
+ if not _ispos(r):
411
+ log(" *** transferExit rejected. ***")
412
+ return False
413
+
414
+ _tx(h, target, _fr_check(lbn), "31 0202 checkMemory", log)
415
+ _tx(h, target, bytes([SID_ROUTINE, 0x01, RID_DEPCHECK >> 8, RID_DEPCHECK & 0xFF]),
416
+ "31 FF01 checkProgDeps", log)
417
+
418
+ tgt, _nm, is_live = container_target(img)
419
+ if is_live:
420
+ log("\n *** WRITE SENT — LIVE-DIRECT to %s. Goes live next boot. ***" % (tgt or "?"))
421
+ else:
422
+ log("\n *** WRITE SENT — staged at %s. ***" % (tgt or "?"))
423
+ return True
evocode/uds.py ADDED
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """UDS (ISO 14229) over HSFZ — only the services coding needs, plus SVK (``22 F1 01``) decoding.
3
+
4
+ The SVK is how you learn a module's bootloader (BTLD) and coding dataset (CAFD) ids and versions;
5
+ almost everything (which signing key, which coding layout) keys off those. Read it first.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional
11
+
12
+ from .hsfz import Hsfz, TGT_HU
13
+
14
+ # service ids
15
+ SID_SESSION = 0x10
16
+ SID_RESET = 0x11
17
+ SID_SECURITY = 0x27
18
+ SID_READ_DID = 0x22
19
+ SID_WRITE_DID = 0x2E
20
+ SID_ROUTINE = 0x31
21
+
22
+ # identifiers used here
23
+ DID_SVK = 0xF101
24
+ DID_VIN = 0xF190
25
+
26
+ # The canonical UDS negative-response names. swdl.py builds on this (adds flash-specific NRCs).
27
+ NRC_NAMES = {
28
+ 0x10: "generalReject", 0x11: "serviceNotSupported",
29
+ 0x13: "incorrectMessageLengthOrInvalidFormat", 0x22: "conditionsNotCorrect",
30
+ 0x31: "requestOutOfRange", 0x33: "securityAccessDenied", 0x35: "invalidKey",
31
+ 0x36: "exceedNumberOfAttempts", 0x7E: "subFunctionNotSupportedInActiveSession",
32
+ 0x7F: "serviceNotSupportedInActiveSession",
33
+ }
34
+
35
+ # SVK process classes (first byte of each 8-byte entry) — the ones coding cares about named.
36
+ _SVK_CLASS = {1: "HWEL", 2: "HWAP", 3: "HWFR", 4: "GWTB", 5: "CAFD", 6: "BTLD", 7: "FLSL",
37
+ 8: "SWFL", 9: "SWFF", 10: "SWPF", 11: "ONPS"}
38
+
39
+
40
+ class UdsError(Exception):
41
+ def __init__(self, sid: int, nrc: int):
42
+ self.sid, self.nrc = sid, nrc
43
+ super().__init__("UDS 0x%02X negative response: 0x%02X (%s)" % (sid, nrc, NRC_NAMES.get(nrc, "unknown")))
44
+
45
+
46
+ def _positive(sid: int, resp: bytes) -> bytes:
47
+ if len(resp) >= 3 and resp[0] == 0x7F and resp[1] == sid:
48
+ raise UdsError(sid, resp[2])
49
+ if not resp or resp[0] != sid + 0x40:
50
+ raise RuntimeError("unexpected UDS response to 0x%02X: %s" % (sid, resp.hex()))
51
+ return resp
52
+
53
+
54
+ @dataclass
55
+ class SgbmId:
56
+ kind: str
57
+ id: int
58
+ main: int
59
+ sub: int
60
+ patch: int
61
+
62
+ @property
63
+ def id_hex(self) -> str:
64
+ return "%08X" % self.id
65
+
66
+ @property
67
+ def version(self) -> str:
68
+ return "%03d.%03d.%03d" % (self.main, self.sub, self.patch)
69
+
70
+ def __str__(self) -> str:
71
+ return "%s-%s-%s" % (self.kind, self.id_hex, self.version)
72
+
73
+
74
+ @dataclass
75
+ class Svk:
76
+ version: int
77
+ prog_dep_checked: int
78
+ entries: List[SgbmId]
79
+
80
+ def of(self, kind: str) -> List[SgbmId]:
81
+ return [e for e in self.entries if e.kind == kind]
82
+
83
+ @property
84
+ def btld(self) -> Optional[SgbmId]:
85
+ got = self.of("BTLD")
86
+ return min(got, key=lambda e: (e.id, e.main, e.sub, e.patch)) if got else None
87
+
88
+ @property
89
+ def cafds(self) -> List[SgbmId]:
90
+ return self.of("CAFD")
91
+
92
+
93
+ def decode_sgbm(b: bytes, at: int = 0) -> SgbmId:
94
+ cls = b[at]
95
+ return SgbmId(_SVK_CLASS.get(cls, "C%02X" % cls),
96
+ int.from_bytes(b[at + 1:at + 5], "big"), b[at + 5], b[at + 6], b[at + 7])
97
+
98
+
99
+ def decode_svk(value: bytes) -> Svk:
100
+ """Decode the bytes after ``62 F1 01``.
101
+
102
+ Layout: ``[version u8][progDepChecked u8][count u16 BE][fingerprint (variable)][N*8 entries]``.
103
+ The fingerprint length varies, so the entries are taken from the final ``8*count`` bytes.
104
+ """
105
+ if len(value) < 4:
106
+ raise ValueError("SVK too short: %d bytes" % len(value))
107
+ count = int.from_bytes(value[2:4], "big")
108
+ need = count * 8
109
+ if 4 + need > len(value):
110
+ raise ValueError("SVK announces %d entries but has only %d bytes" % (count, len(value)))
111
+ first = len(value) - need
112
+ entries = [decode_sgbm(value, first + i * 8) for i in range(count)]
113
+ return Svk(value[0], value[1], entries)
114
+
115
+
116
+ class Uds:
117
+ """A thin UDS facade bound to one target over an :class:`~evocode.hsfz.Hsfz` session."""
118
+
119
+ def __init__(self, hsfz: Hsfz, target: int = TGT_HU):
120
+ self.h = hsfz
121
+ self.target = target
122
+
123
+ def raw(self, uds: bytes) -> bytes:
124
+ return self.h.request(self.target, uds)
125
+
126
+ def session(self, sub: int) -> bytes:
127
+ return _positive(SID_SESSION, self.raw(bytes([SID_SESSION, sub])))
128
+
129
+ def reset(self, sub: int = 0x01) -> bytes:
130
+ return _positive(SID_RESET, self.raw(bytes([SID_RESET, sub])))
131
+
132
+ def request_seed(self, level: int, tester_id: int = 0x00000000) -> bytes:
133
+ # requestSeed carries the 4-byte testerId; a bare 27 xx returns NRC 0x13.
134
+ r = _positive(SID_SECURITY, self.raw(bytes([SID_SECURITY, level]) + tester_id.to_bytes(4, "big")))
135
+ return r[2:] # strip SID echo + level
136
+
137
+ def send_key(self, level: int, key: bytes) -> bytes:
138
+ return _positive(SID_SECURITY, self.raw(bytes([SID_SECURITY, level]) + key))
139
+
140
+ def read_did(self, did: int) -> bytes:
141
+ r = _positive(SID_READ_DID, self.raw(bytes([SID_READ_DID, did >> 8, did & 0xFF])))
142
+ return r[3:] # strip 62 + 2-byte DID echo
143
+
144
+ def write_did(self, did: int, data: bytes) -> bytes:
145
+ return _positive(SID_WRITE_DID, self.raw(bytes([SID_WRITE_DID, did >> 8, did & 0xFF]) + data))
146
+
147
+ def routine(self, sub: int, rid: int, data: bytes = b"") -> bytes:
148
+ return _positive(SID_ROUTINE, self.raw(bytes([SID_ROUTINE, sub, rid >> 8, rid & 0xFF]) + data))
149
+
150
+ def read_vin(self) -> str:
151
+ return self.read_did(DID_VIN).decode("latin1").strip("\x00 ")
152
+
153
+ def read_svk(self) -> Svk:
154
+ return decode_svk(self.read_did(DID_SVK))