Fix #9: DTC/freeze-frame parsing (phantom codes, Mode 02, hex frame index)

- parse_dtcs CAN branch is now message-aware: each ECU reply '<svc> <count>
  <pairs>' has its header stripped per-message, instead of flattening all lines
  and stripping svc+count once. With multiple ECUs the old code ate the second
  header as a DTC pair -> phantom codes. Critically, it does NOT blind-scan for
  svc (0x43 is a legal DTC first byte: C03xx) — a numbered ISO-TP continuation
  is distinguished by its 'N:' frame-index prefix, not by value.
- _line_bytes strips hex frame indices A:-F: (ISO-TP index cycles 0-F), not just
  0-9, so consecutive frames past the 10th aren't dropped.
- read_freeze_frame sends the correct '020200' (svc 02, PID 02, frame 00) and
  skips SID+PID+frame (+3), fixing the off-by-one that mis-read the freeze DTC.
- tests/test_dtc_parse.py: single-frame, multi-ECU (no phantom), numbered
  multiframe with a real C03xx continuation, hex index, non-CAN legacy.

Closes #9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
2026-07-01 19:36:35 -04:00
parent 23c92018c1
commit fa7225d6dc
2 changed files with 89 additions and 10 deletions
+26 -10
View File
@@ -23,11 +23,14 @@ def decode_dtc(b1, b2):
return f"{_LETTER[(b1 >> 6) & 3]}{(b1 >> 4) & 3}{b1 & 0xF:X}{b2:02X}"
_HEX = "0123456789ABCDEFabcdef"
def _line_bytes(ln):
ln = ln.replace(" ", "")
if len(ln) >= 2 and ln[1] == ":" and ln[0] in "0123456789":
ln = ln[2:] # drop CAN multiframe index "N:"
if not ln or any(c not in "0123456789ABCDEFabcdef" for c in ln):
if len(ln) >= 2 and ln[1] == ":" and ln[0] in _HEX:
ln = ln[2:] # drop CAN multiframe index "N:" (0-F, cycles)
if not ln or any(c not in _HEX for c in ln):
return []
return [int(ln[i:i + 2], 16) for i in range(0, len(ln) - 1, 2)]
@@ -35,11 +38,24 @@ def _line_bytes(ln):
def parse_dtcs(lines, svc, is_can):
pairs = []
if is_can:
data = [b for ln in lines for b in _line_bytes(ln)]
if svc in data:
data = data[data.index(svc) + 1:]
data = data[1:] if data else data
pairs = data
# Message-aware: multiple ECUs each reply "<svc> <count> <pairs...>", and
# a DTC's own first byte can equal svc (0x43 == C03xx), so we must NOT
# blind-scan the flattened stream for svc. A line whose payload starts
# with svc begins a new ECU message (drop svc + count byte); an ISO-TP
# numbered continuation "N:" (N>=1) appends raw pairs to the current one.
started = False
for ln in lines:
raw = ln.replace(" ", "")
cont = len(raw) >= 2 and raw[1] == ":" and raw[0] in _HEX and raw[0] != "0"
b = _line_bytes(ln)
if not b:
continue
if not cont and b[0] == svc: # header of an ECU message: svc + count
pairs.extend(b[2:])
started = True
elif started: # continuation / pairs of current message
pairs.extend(b)
# else: bytes before any header (ISO-TP length line, stray) -> ignore
else:
for ln in lines:
data = _line_bytes(ln)
@@ -211,9 +227,9 @@ class ElmLink:
"""Mode 02: the DTC that set the freeze frame + the standard PID snapshot."""
from . import obdservices as svc
out = {"dtc": None, "values": []}
d = self._bytes(self.cmd("0202", timeout=timeout))
d = self._bytes(self.cmd("020200", timeout=timeout)) # svc 02, PID 02, frame 00
if 0x42 in d:
r = d[d.index(0x42) + 2:] # after '42 02'
r = d[d.index(0x42) + 3:] # skip 42 (SID) 02 (PID) 00 (frame)
if len(r) >= 2 and (r[0] or r[1]):
out["dtc"] = decode_dtc(r[0], r[1])
for name, pid, nbytes, dec, unit in svc.FREEZE_PIDS: