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/coding.py ADDED
@@ -0,0 +1,106 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The NBT EVO coding signature (BMWSigVerify) — the maths the head unit runs at every boot.
3
+
4
+ A signature covers the content datagroups, is computed with the key the bootloader (BTLD) selects,
5
+ and lives in its own datagroup. Edit any coding byte without recomputing it and the change reverts
6
+ on the next boot. Verifying needs only the public modulus; producing a signature needs the private
7
+ exponent (recover it from your own PSdZData — never shipped here).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from typing import Optional, Tuple
15
+
16
+ from . import srec
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Scheme:
21
+ """Which datagroups are signed, with which digest, and where the signature is stored."""
22
+ name: str
23
+ content_dgs: Tuple[int, ...]
24
+ sig_dg: int
25
+ digest: str # "md5" or "sha256"
26
+
27
+ @property
28
+ def digest_len(self) -> int:
29
+ return hashlib.new(self.digest).digest_size
30
+
31
+
32
+ # NBT EVO (QNX): datagroups 0x3000..0x300F signed with MD5, signature in 0x3010.
33
+ NBTEVO = Scheme("nbtevo", tuple(range(0x3000, 0x3010)), 0x3010, "md5")
34
+ SCHEMES = {NBTEVO.name: NBTEVO}
35
+ DEFAULT_SCHEME = NBTEVO.name
36
+
37
+
38
+ @dataclass
39
+ class Key:
40
+ modulus: int
41
+ public_exponent: int = 3
42
+ private_exponent: Optional[int] = None
43
+ btld: Optional[str] = None
44
+
45
+ @property
46
+ def size(self) -> int:
47
+ return (self.modulus.bit_length() + 7) // 8
48
+
49
+
50
+ def load_key(path: str) -> Key:
51
+ d = json.loads(open(path, encoding="utf-8").read())
52
+ priv = d.get("private_exponent_hex")
53
+ return Key(
54
+ modulus=int(d["modulus_hex"], 16),
55
+ public_exponent=int(d.get("public_exponent", 3)),
56
+ private_exponent=int(priv, 16) if priv else None,
57
+ btld=d.get("btld"),
58
+ )
59
+
60
+
61
+ def _wordswap(b: bytes) -> bytes:
62
+ """Reverse the bytes within each 32-bit word — BMW's big-number storage order."""
63
+ if len(b) % 4:
64
+ raise ValueError("length must be a multiple of 4")
65
+ return b"".join(b[i:i + 4][::-1] for i in range(0, len(b), 4))
66
+
67
+
68
+ def content_digest(mem: srec.Mem, scheme: Scheme = NBTEVO) -> bytes:
69
+ pages = srec.pages(mem, scheme.content_dgs)
70
+ content = b"".join(pages[dg] for dg in scheme.content_dgs if dg in pages)
71
+ return hashlib.new(scheme.digest, content).digest()
72
+
73
+
74
+ def sign(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bytes:
75
+ """The raw signature bytes over the current content (does not modify ``mem``)."""
76
+ if key.private_exponent is None:
77
+ raise ValueError("key has no private exponent; recover it from your own PSdZData")
78
+ m = int.from_bytes(content_digest(mem, scheme), "little")
79
+ s = pow(m, key.private_exponent, key.modulus)
80
+ return _wordswap(s.to_bytes(key.size, "little"))
81
+
82
+
83
+ def signature_page(sig: bytes, key: Key) -> bytes:
84
+ """Signature datagroup payload: big-endian 32-bit word count, then the signature."""
85
+ return (key.size // 4).to_bytes(4, "big") + sig
86
+
87
+
88
+ def resign(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bytes:
89
+ """Recompute the signature and write it into the signature datagroup, in place."""
90
+ sig = sign(mem, key, scheme)
91
+ base = scheme.sig_dg << 8
92
+ for i, b in enumerate(signature_page(sig, key)):
93
+ mem[base + i] = b
94
+ return sig
95
+
96
+
97
+ def verify(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bool:
98
+ """Reproduce the ECU's check: raise the stored signature to ``e``, compare to the content digest."""
99
+ digest = content_digest(mem, scheme)
100
+ page = srec.pages(mem, [scheme.sig_dg]).get(scheme.sig_dg, b"")
101
+ if len(page) < 4 + key.size:
102
+ return False
103
+ sig = page[4:4 + key.size]
104
+ recovered = pow(int.from_bytes(_wordswap(sig), "little"), key.public_exponent, key.modulus)
105
+ return (recovered.to_bytes(scheme.digest_len, "little") == digest
106
+ and recovered.bit_length() <= 8 * scheme.digest_len)
evocode/consent.py ADDED
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Owner-consent gate for anything that writes to a vehicle.
3
+
4
+ A deliberate speed bump: a write refuses unless you have set ``NBTEVO_OWN_VIN`` to the VIN of the
5
+ car you own, and that VIN matches the one the tool reads back from the connected car. This is not
6
+ DRM — it is a guard against writing to the wrong vehicle. Do not route around it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ ENV_VIN = "NBTEVO_OWN_VIN"
13
+
14
+
15
+ class ConsentError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def owned_vin() -> str:
20
+ vin = os.environ.get(ENV_VIN, "").strip()
21
+ if not vin:
22
+ raise ConsentError("set %s to the VIN of the car you own before any write." % ENV_VIN)
23
+ return vin
24
+
25
+
26
+ def assert_owned(connected_vin: str) -> None:
27
+ want = owned_vin()
28
+ if connected_vin.strip() != want:
29
+ raise ConsentError(
30
+ "connected VIN %r != %s (%r) — refusing to write to a car you have not confirmed you own."
31
+ % (connected_vin, ENV_VIN, want)
32
+ )
evocode/fsc.py ADDED
@@ -0,0 +1,274 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """FSC activations — the owner-CA path: your own e=3 trust anchor, and FSCs you mint under it.
3
+
4
+ The NBT EVO verifies every "long" FSC against ONE RSA modulus — **blobB** — compiled into NBTCarHU
5
+ (``e=3``, PKCS#1 v1.5 / MD5). There is no per-feature key and no certificate lookup: an FSC is
6
+ accepted iff its signature verifies under whatever modulus is installed as blobB. This is a separate
7
+ mechanism from coding (which verifies against BMW's fixed Codier-Sign key you can only *recover*).
8
+
9
+ The owner path, on a car you own: generate your **own** e=3 key, install its modulus as blobB (a
10
+ length-preserving firmware patch — see ``find_blobb``/``patch_blobb``), and mint FSCs with your
11
+ private key. No BMW key is forged; you move the unit's trust to a key you hold.
12
+
13
+ ``keygen``/``mint``/``verify``/``parse``/``recover_modulus`` are pure stdlib. ``recover_modulus``
14
+ (the e=3 GCD attack) identifies the anchor already on a used unit from the FSCs you read off it.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import math
20
+ import secrets
21
+ import struct
22
+ from collections import Counter
23
+ from math import gcd
24
+ from typing import List, Tuple
25
+
26
+ MD5_DIGESTINFO = bytes.fromhex("3020300c06082a864886f70d020505000410")
27
+ BODY_LEN = 60
28
+ SIG_LEN = 128
29
+
30
+ # the application numbers this tooling was built around
31
+ APPNOS = {0x0143: "CarPlay", 0x00A0: "Navi Pro ECE", 0x0120: "Map Europe", 0x009C: "A4A / BMW Apps"}
32
+
33
+
34
+ # ---- the 60-byte signed body (byte-exact to the EVO FSC layout) ----
35
+
36
+ def build_body(appno: int, upgrade_idx: int = 1, supplier_id: bytes = b" 1",
37
+ requester_type: bytes = b" 1", counter: int = 0x0001,
38
+ fscs_id: bytes = b"C329361", vin=None, date: bytes = b"201608242124") -> bytes:
39
+ b = bytearray(b"\x01\x01")
40
+ b += struct.pack(">H", appno)
41
+ b += struct.pack(">H", upgrade_idx)
42
+ assert len(supplier_id) == 8
43
+ b += supplier_id + b"\x01"
44
+ assert len(requester_type) == 8
45
+ b += requester_type
46
+ b += struct.pack(">H", counter) + b"\x01"
47
+ assert len(fscs_id) == 7
48
+ b += fscs_id + b"\x01" # VIN-type 1 => a 9-byte VIN follows
49
+ b += (b"\x00" * 9) if vin is None else (vin if isinstance(vin, bytes) else vin.encode())
50
+ b += b"\x04\x02" # date tag/len
51
+ assert len(date) == 12
52
+ b += date + b"Z\x00\x00"
53
+ assert len(b) == BODY_LEN, "body len %d" % len(b)
54
+ return bytes(b)
55
+
56
+
57
+ def _emsa(digest: bytes, modlen: int = SIG_LEN) -> bytes:
58
+ """PKCS#1 v1.5 envelope over an MD5 digest, zero-slack (what the device expects)."""
59
+ t = MD5_DIGESTINFO + digest
60
+ return b"\x00\x01" + b"\xff" * (modlen - 3 - len(t)) + b"\x00" + t
61
+
62
+
63
+ def _emsa_int(body: bytes) -> int:
64
+ return int.from_bytes(_emsa(hashlib.md5(body).digest()), "big")
65
+
66
+
67
+ # ---- mint / resign / verify / parse ----
68
+
69
+ def mint(n: int, d: int, appno: int, **body_kw) -> bytes:
70
+ """A 188-byte FSC (60-byte body + 128-byte signature) signed by private (n, d), e=3."""
71
+ body = build_body(appno, **body_kw)
72
+ return body + pow(_emsa_int(body), d, n).to_bytes(SIG_LEN, "big")
73
+
74
+
75
+ def resign_body(body: bytes, n: int, d: int) -> bytes:
76
+ """Re-sign an existing 60-byte body under (n, d) — reuse a real FSC's fields exactly."""
77
+ return bytes(body[:BODY_LEN]) + pow(_emsa_int(body[:BODY_LEN]), d, n).to_bytes(SIG_LEN, "big")
78
+
79
+
80
+ def verify(fsc: bytes, n: int, e: int = 3):
81
+ """Reproduce the on-device check: m = sig^e mod N, strip PKCS#1, compare MD5(body)."""
82
+ body, sigb = fsc[:-SIG_LEN], fsc[-SIG_LEN:]
83
+ sig = int.from_bytes(sigb, "big")
84
+ if sig >= n:
85
+ return False, "sig>=N"
86
+ m = pow(sig, e, n).to_bytes(SIG_LEN, "big")
87
+ if m[:2] != b"\x00\x01":
88
+ return False, "bad 00 01"
89
+ i = 2
90
+ while i < SIG_LEN and m[i] == 0xFF:
91
+ i += 1
92
+ if m[i] != 0x00:
93
+ return False, "FF run not 00-terminated"
94
+ i += 1
95
+ if m[i:i + 18] != MD5_DIGESTINFO:
96
+ return False, "bad DigestInfo"
97
+ if i + 34 != SIG_LEN:
98
+ return False, "envelope not exactly 128 bytes"
99
+ if m[i + 18:i + 34] != hashlib.md5(body).digest():
100
+ return False, "hash mismatch"
101
+ return True, "OK"
102
+
103
+
104
+ def parse(fsc: bytes) -> dict:
105
+ b = fsc[:-SIG_LEN]
106
+ vin = b[34:43]
107
+ return {
108
+ "version": b[0:2].hex(),
109
+ "appno": "0x%04X" % struct.unpack(">H", b[2:4])[0],
110
+ "upgrade_index": struct.unpack(">H", b[4:6])[0],
111
+ "fscs_id": b[26:33].decode("latin1"),
112
+ "vin": "(VIN-generic)" if vin == b"\x00" * 9 else vin.decode("latin1"),
113
+ "creation_date": b[45:57].decode("latin1"),
114
+ }
115
+
116
+
117
+ # ---- whose anchor signed these? (e=3 modulus recovery) ----
118
+
119
+ def recover_modulus(fscs: List[bytes]) -> int:
120
+ """Recover the signing modulus from >=2 FSCs signed by the SAME anchor (e=3 GCD attack)."""
121
+ if len(fscs) < 2:
122
+ raise ValueError("need >=2 FSCs signed by the same anchor to recover a modulus")
123
+ g = 0
124
+ for f in fscs:
125
+ body, sigb = f[:-SIG_LEN], f[-SIG_LEN:]
126
+ g = gcd(g, abs(int.from_bytes(sigb, "big") ** 3 - _emsa_int(body)))
127
+ p = 2
128
+ while p < 100000: # strip tiny cofactors, keep the ~1024-bit N
129
+ while g % p == 0 and (g // p).bit_length() >= 1000:
130
+ g //= p
131
+ p += 1
132
+ return g
133
+
134
+
135
+ def blobB_le(n: int) -> bytes:
136
+ """The 128-byte blobB form of a modulus: little-endian (as it sits in NBTCarHU)."""
137
+ return n.to_bytes(SIG_LEN, "little")
138
+
139
+
140
+ def fingerprint(n: int) -> str:
141
+ return hashlib.sha256(blobB_le(n)).hexdigest()[:16]
142
+
143
+
144
+ # ---- generate your own e=3 anchor ----
145
+
146
+ def _probable_prime(num: int, rounds: int = 24) -> bool:
147
+ for sp in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
148
+ if num % sp == 0:
149
+ return num == sp
150
+ if num < 2:
151
+ return False
152
+ d, r = num - 1, 0
153
+ while d % 2 == 0:
154
+ d //= 2
155
+ r += 1
156
+ for _ in range(rounds):
157
+ a = 2 + secrets.randbelow(num - 3)
158
+ x = pow(a, d, num)
159
+ if x in (1, num - 1):
160
+ continue
161
+ for _ in range(r - 1):
162
+ x = pow(x, 2, num)
163
+ if x == num - 1:
164
+ break
165
+ else:
166
+ return False
167
+ return True
168
+
169
+
170
+ def _prime_e3(bits: int) -> int:
171
+ """A prime p with (p-1) coprime to 3, so e=3 is invertible."""
172
+ while True:
173
+ cand = secrets.randbits(bits) | (1 << (bits - 1)) | 1
174
+ while not _probable_prime(cand):
175
+ cand += 2
176
+ if (cand - 1) % 3 != 0:
177
+ return cand
178
+
179
+
180
+ def keygen(bits: int = 1024) -> dict:
181
+ """Your owner trust anchor: RSA with e=3 (what the FSC verifier uses)."""
182
+ p = _prime_e3(bits // 2)
183
+ q = _prime_e3(bits // 2)
184
+ while q == p:
185
+ q = _prime_e3(bits // 2)
186
+ n = p * q
187
+ phi = (p - 1) * (q - 1)
188
+ return {"n_hex": "%x" % n, "e": 3, "d_hex": "%x" % pow(3, -1, phi), "bits": n.bit_length()}
189
+
190
+
191
+ # ---- locating / swapping blobB in a NBTCarHU firmware dump ----
192
+
193
+ def _entropy(b: bytes) -> float:
194
+ c = Counter(b)
195
+ n = len(b)
196
+ return -sum((v / n) * math.log2(v / n) for v in c.values())
197
+
198
+
199
+ def _small_primes(limit: int = 50000) -> List[int]:
200
+ s = bytearray([1]) * limit
201
+ s[0] = s[1] = 0
202
+ for i in range(2, int(limit ** 0.5) + 1):
203
+ if s[i]:
204
+ s[i * i::i] = bytearray(len(s[i * i::i]))
205
+ return [i for i in range(limit) if s[i]]
206
+
207
+
208
+ def find_blobb(nbtcarhu: bytes, bits: int = 1024) -> Tuple[int, int]:
209
+ """Locate the installed FSC anchor in a NBTCarHU ELF dump → (file_offset, modulus).
210
+
211
+ The verifier loads blobB's address from a PC-relative literal pool, so the real modulus's
212
+ virtual address appears verbatim as a 4-byte little-endian pointer in the code. Among the
213
+ high-entropy ~1024-bit candidates in the binary, exactly the installed one is code-referenced —
214
+ which pins it with no stock image or disassembler.
215
+ """
216
+ d = nbtcarhu
217
+ if d[:4] != b"\x7fELF" or d[4] != 1:
218
+ raise ValueError("not an ELF32 image (expected a NBTCarHU dump)")
219
+ phoff = struct.unpack("<I", d[28:32])[0]
220
+ phentsize = struct.unpack("<H", d[42:44])[0]
221
+ phnum = struct.unpack("<H", d[44:46])[0]
222
+ segs = []
223
+ for i in range(phnum):
224
+ o = phoff + i * phentsize
225
+ ptype, poff, pvaddr, _ppaddr, pfilesz = struct.unpack("<5I", d[o:o + 20])
226
+ if ptype == 1: # PT_LOAD
227
+ segs.append((poff, pvaddr, pfilesz))
228
+
229
+ def off2va(o):
230
+ for poff, pvaddr, pfilesz in segs:
231
+ if poff <= o < poff + pfilesz:
232
+ return pvaddr + (o - poff)
233
+ return None
234
+
235
+ L = len(d)
236
+ nbytes = bits // 8
237
+
238
+ # This build wraps the anchor in a little key struct { ver(u32)=1, e(u32)=3, len_words(u32),
239
+ # modulus }, and the code references the STRUCT address — so find a code-referenced struct of the
240
+ # wanted width and take the modulus at +12.
241
+ want_words = bits // 32
242
+ o = 0
243
+ while o + 12 + nbytes <= L:
244
+ ver, e3, lw = struct.unpack("<3I", d[o:o + 12])
245
+ if ver == 1 and e3 == 3 and lw == want_words:
246
+ va = off2va(o)
247
+ if va is not None and d.find(struct.pack("<I", va)) >= 0:
248
+ moff = o + 12
249
+ n = int.from_bytes(d[moff:moff + nbytes], "little")
250
+ if (n & 1) and n.bit_length() >= bits - 8:
251
+ return moff, n
252
+ o += 4
253
+
254
+ # Fallback: a build that stores the modulus unwrapped — find the single code-referenced candidate.
255
+ primes = _small_primes()
256
+ o = 0
257
+ while o + nbytes <= L:
258
+ if (d[o] & 1) and (d[o + nbytes - 1] & 0x80):
259
+ w = d[o:o + nbytes]
260
+ if _entropy(w) > 6.4:
261
+ n = int.from_bytes(w, "little")
262
+ if n.bit_length() >= bits - 7 and all(n % p for p in primes):
263
+ va = off2va(o)
264
+ if va is not None and d.find(struct.pack("<I", va)) >= 0:
265
+ return o, n
266
+ o += 4
267
+ raise LookupError("blobB not located by code reference (PIC/GOT? pass an explicit offset)")
268
+
269
+
270
+ def patch_blobb(nbtcarhu: bytes, offset: int, modulus: int) -> bytes:
271
+ """A copy of the dump with the 128 bytes at ``offset`` replaced by ``modulus`` (length-preserving)."""
272
+ d = bytearray(nbtcarhu)
273
+ d[offset:offset + SIG_LEN] = blobB_le(modulus)
274
+ return bytes(d)
evocode/hsfz.py ADDED
@@ -0,0 +1,193 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HSFZ transport for BMW F/early-G ENET diagnostics (the NBT EVO host).
3
+
4
+ These cars speak **HSFZ**, not DoIP, over TCP 6801. A frame is::
5
+
6
+ u32 payload_len | u16 control | u8 source | u8 target | UDS bytes...
7
+
8
+ where ``payload_len`` counts the bytes after the control word (so ``2 + len(UDS)`` for a data
9
+ frame). control 0x0001 = data, 0x0002 = ack. Discovery is a UDP broadcast of control 0x0011 to
10
+ port 6811; each gateway answers, and the reply's source IP is the endpoint. Routing is the target
11
+ byte alone — connect once and set ``target`` per ECU; the gateway forwards it.
12
+
13
+ Addresses: tester source 0xF4, FEM gateway 0x10, NBT EVO head unit 0x63.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import socket
18
+ import struct
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass
22
+ from typing import Callable, List, Optional
23
+
24
+ IDENT_PORT = 6811
25
+ DIAG_PORT = 6801
26
+
27
+ CTRL_DATA = 0x0001
28
+ CTRL_ACK = 0x0002
29
+ CTRL_IDENT = 0x0011
30
+
31
+ SRC_TESTER = 0xF4
32
+ TGT_GATEWAY = 0x10
33
+ TGT_HU = 0x63
34
+
35
+
36
+ class HsfzError(Exception):
37
+ pass
38
+
39
+
40
+ @dataclass
41
+ class Endpoint:
42
+ ip: str
43
+ diag_addr: Optional[int]
44
+ vin: Optional[str]
45
+ raw_ident: str
46
+
47
+
48
+ def _parse_ident(payload: bytes, ip: str) -> Endpoint:
49
+ text = payload.decode("latin1", "replace")
50
+
51
+ def field(tag: str, n: int) -> Optional[str]:
52
+ i = text.find(tag)
53
+ return text[i + len(tag): i + len(tag) + n] if i >= 0 else None
54
+
55
+ da = field("DIAGADR", 2)
56
+ vin = field("BMWVIN", 17)
57
+ return Endpoint(ip, int(da, 16) if da else None, vin.strip("\x00 ") if vin else None, text)
58
+
59
+
60
+ def discover(bind_ip: str, broadcast: str = "169.254.255.255", timeout: float = 3.0) -> List[Endpoint]:
61
+ """Broadcast the HSFZ ident request on the ENET segment and collect endpoint replies.
62
+
63
+ ``bind_ip`` is your host's address on that segment (e.g. a 169.254.x.x link-local), so the probe
64
+ leaves the right interface.
65
+ """
66
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
67
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
68
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
69
+ sock.bind((bind_ip, 0))
70
+ sock.settimeout(timeout)
71
+ sock.sendto(struct.pack(">IH", 0, CTRL_IDENT), (broadcast, IDENT_PORT))
72
+ found: dict[str, Endpoint] = {}
73
+ deadline = time.time() + timeout
74
+ while time.time() < deadline:
75
+ try:
76
+ data, (ip, _) = sock.recvfrom(4096)
77
+ except socket.timeout:
78
+ break
79
+ if len(data) >= 6 and ip not in found:
80
+ found[ip] = _parse_ident(data[6:], ip)
81
+ sock.close()
82
+ return list(found.values())
83
+
84
+
85
+ class Hsfz:
86
+ """One TCP/6801 HSFZ session. Set the target per request; the gateway routes by it."""
87
+
88
+ def __init__(self, ip: str, source: int = SRC_TESTER, timeout: float = 5.0):
89
+ self.ip = ip
90
+ self.source = source
91
+ self._timeout = timeout
92
+ self.sock = socket.create_connection((ip, DIAG_PORT), timeout=timeout)
93
+ self.sock.settimeout(timeout)
94
+ self._tp_stop: Optional[threading.Event] = None
95
+ self._tp_thread: Optional[threading.Thread] = None
96
+
97
+ def close(self) -> None:
98
+ self.stop_tester_present()
99
+ try:
100
+ self.sock.close()
101
+ except OSError:
102
+ pass
103
+
104
+ def reconnect(self, timeout: Optional[float] = None) -> "Hsfz":
105
+ try:
106
+ self.sock.close()
107
+ except OSError:
108
+ pass
109
+ t = timeout or self._timeout
110
+ self.sock = socket.create_connection((self.ip, DIAG_PORT), timeout=t)
111
+ self.sock.settimeout(t)
112
+ return self
113
+
114
+ def __enter__(self) -> "Hsfz":
115
+ return self
116
+
117
+ def __exit__(self, *exc) -> None:
118
+ self.close()
119
+
120
+ def send_only(self, target: int, uds: bytes) -> None:
121
+ self._send(target, uds)
122
+
123
+ def recv_frame(self, timeout: Optional[float] = None) -> tuple[int, bytes]:
124
+ if timeout is not None:
125
+ self.sock.settimeout(timeout)
126
+ return self._recv_frame()
127
+
128
+ def start_tester_present(self, target: int = 0xDF, period: float = 1.0,
129
+ log: Optional[Callable] = None) -> None:
130
+ if self._tp_thread is not None:
131
+ return
132
+ self._tp_stop = threading.Event()
133
+ tp = bytes([0x3E, 0x80])
134
+
135
+ def _loop() -> None:
136
+ assert self._tp_stop is not None
137
+ while not self._tp_stop.wait(period):
138
+ try:
139
+ self._send(target, tp)
140
+ except Exception:
141
+ break
142
+
143
+ self._tp_thread = threading.Thread(target=_loop, name="HSFZ-TP", daemon=True)
144
+ self._tp_thread.start()
145
+ if log:
146
+ log(" [tp] TesterPresent 3E 80 -> 0x%02X every %.1fs" % (target, period))
147
+
148
+ def stop_tester_present(self) -> None:
149
+ if self._tp_stop is not None:
150
+ self._tp_stop.set()
151
+ if self._tp_thread is not None:
152
+ self._tp_thread.join(timeout=1.0)
153
+ self._tp_thread = None
154
+ self._tp_stop = None
155
+
156
+ def _send(self, target: int, uds: bytes) -> None:
157
+ frame = struct.pack(">IH", 2 + len(uds), CTRL_DATA) + bytes([self.source, target]) + uds
158
+ self.sock.sendall(frame)
159
+
160
+ def _recv_exact(self, n: int) -> bytes:
161
+ buf = b""
162
+ while len(buf) < n:
163
+ chunk = self.sock.recv(n - len(buf))
164
+ if not chunk:
165
+ raise HsfzError("HSFZ peer closed the connection")
166
+ buf += chunk
167
+ return buf
168
+
169
+ def _recv_frame(self) -> tuple[int, bytes]:
170
+ length, ctrl = struct.unpack(">IH", self._recv_exact(6))
171
+ body = self._recv_exact(length) if length else b""
172
+ return ctrl, body
173
+
174
+ def request(self, target: int, uds: bytes, p2_star: float = 6.0) -> bytes:
175
+ """Send a UDS request and return the raw UDS response, skipping acks/echoes and riding the
176
+ extended P2* timer across ``7F xx 78`` responsePending frames."""
177
+ self._send(target, uds)
178
+ deadline = time.time() + p2_star
179
+ while time.time() < deadline:
180
+ try:
181
+ ctrl, body = self._recv_frame()
182
+ except socket.timeout:
183
+ break
184
+ if ctrl != CTRL_DATA or len(body) < 2:
185
+ continue # ack / status frame
186
+ resp = body[2:] # strip source, target
187
+ if not resp or resp == uds:
188
+ continue # some gateways echo the request
189
+ if len(resp) >= 3 and resp[0] == 0x7F and resp[2] == 0x78:
190
+ deadline = time.time() + p2_star # responsePending: keep waiting
191
+ continue
192
+ return resp
193
+ raise HsfzError("no UDS response from 0x%02X for %s" % (target, uds.hex()))