d435384b58
Profile-defined UDS action sequences, run safely -- the framework for #2 (real per-vehicle actuator tests/resets are follow-on, added as verified profile data). - obdcore/actions.py: Action model + run_action() executing session (Mode 10) -> security (Mode 27 seed->key) -> command steps (2F/31/11/3E/... any hex) with positive/negative response checks. Security KEY algorithms are per-vehicle secrets and NOT bundled -- only trivial transforms (xor-ff/invert/add-ff) known; an action naming an unknown algorithm is BLOCKED (fails safe). Never synthesizes bytes -- runs only what the profile defines. validate_action() rejects malformed hex at load. - profile.py: load/save an actions[] block; ElmLink/MockLink read_raw(hex). - GUI: Diagnostics -> Service & Bi-directional dialog -- lists the profile's actions with risk badges; caution/danger gated behind a warning confirmation. - generic-obd2: two safe STANDARD actions (Tester-Present ping; ECU-Reset, caution + engine-off warning). PROFILE_SPEC.md documents the actions schema + safety rules. - tests/test_actions.py: runner, session+reset, security handshake, unknown-algo block, hex validation, profile load. All 5 suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
235 lines
8.1 KiB
Python
235 lines
8.1 KiB
Python
"""ElmLink -- ELM327 serial transport for obdcore.
|
|
|
|
Ported from the validated obd_reader.py: the cmd/read loop, Mode-01/Mode-22
|
|
reads, ATRV, protocol negotiation, and DTC read/clear. Returns raw byte
|
|
lists (decoding happens in the registry). No GUI, no scheduler.
|
|
|
|
The terminal tool's ELM class will be migrated to import this (see
|
|
ARCHITECTURE.md, P0) once the GUI work stabilises.
|
|
"""
|
|
import time
|
|
|
|
try:
|
|
import serial
|
|
from serial.tools import list_ports
|
|
except ImportError: # allow import on machines w/o pyserial
|
|
serial = None
|
|
list_ports = None
|
|
|
|
_LETTER = {0: "P", 1: "C", 2: "B", 3: "U"}
|
|
|
|
|
|
def decode_dtc(b1, b2):
|
|
return f"{_LETTER[(b1 >> 6) & 3]}{(b1 >> 4) & 3}{b1 & 0xF:X}{b2:02X}"
|
|
|
|
|
|
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):
|
|
return []
|
|
return [int(ln[i:i + 2], 16) for i in range(0, len(ln) - 1, 2)]
|
|
|
|
|
|
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
|
|
else:
|
|
for ln in lines:
|
|
data = _line_bytes(ln)
|
|
if svc in data:
|
|
data = data[data.index(svc) + 1:]
|
|
elif data and data[0] == svc:
|
|
data = data[1:]
|
|
else:
|
|
continue
|
|
pairs.extend(data)
|
|
out, seen = [], set()
|
|
for i in range(0, len(pairs) - 1, 2):
|
|
b1, b2 = pairs[i], pairs[i + 1]
|
|
if b1 == 0 and b2 == 0:
|
|
continue
|
|
d = decode_dtc(b1, b2)
|
|
if d not in seen:
|
|
seen.add(d)
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
def find_ports():
|
|
if list_ports is None:
|
|
return []
|
|
def score(p):
|
|
s = (p.description + " " + (p.manufacturer or "")).lower()
|
|
return 0 if ("ch340" in s or "1a86" in s) else 1 if ("serial" in s or "usb" in s) else 2
|
|
return sorted(list_ports.comports(), key=score)
|
|
|
|
|
|
class ElmLink:
|
|
PROMPT = b">"
|
|
|
|
def __init__(self, transport, verbose=False):
|
|
"""transport: any object with write/read/reset_input_buffer/close.
|
|
Use the .serial() / .tcp() / .ble() factory helpers to build one."""
|
|
self.io = transport
|
|
self.verbose = verbose
|
|
self.protocol = "?"
|
|
time.sleep(0.3)
|
|
self.io.reset_input_buffer()
|
|
|
|
@classmethod
|
|
def serial(cls, port, baud=38400, **kw):
|
|
from . import transport as tp
|
|
return cls(tp.SerialTransport(port, baud), **kw)
|
|
|
|
@classmethod
|
|
def tcp(cls, host, port=35000, **kw):
|
|
from . import transport as tp
|
|
return cls(tp.TcpTransport(host, port), **kw)
|
|
|
|
@classmethod
|
|
def ble(cls, address, **kw):
|
|
from . import transport as tp
|
|
return cls(tp.BleTransport(address), **kw)
|
|
|
|
# -- low-level --
|
|
def cmd(self, s, settle=0.0, timeout=4.0):
|
|
self.io.reset_input_buffer()
|
|
self.io.write((s + "\r").encode())
|
|
if settle:
|
|
time.sleep(settle)
|
|
buf = bytearray()
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
chunk = self.io.read(256)
|
|
if chunk:
|
|
buf += chunk
|
|
if self.PROMPT in buf:
|
|
break
|
|
elif self.PROMPT in buf:
|
|
break
|
|
raw = buf.decode("ascii", "replace")
|
|
if self.verbose:
|
|
print(f" [TX {s!r}] -> {raw!r}")
|
|
return [ln.strip() for ln in raw.replace(">", "").split("\r")
|
|
if ln.strip() and ln.strip() != s]
|
|
|
|
def init(self):
|
|
self.cmd("ATZ", settle=1.0)
|
|
for c in ("ATE0", "ATL0", "ATS0", "ATH0", "ATAT1", "ATSP0"):
|
|
self.cmd(c)
|
|
|
|
def fast_timing(self, on=True):
|
|
"""Tighten ELM response wait for live polling (on) or restore (off)."""
|
|
if on:
|
|
self.cmd("ATAT2"); self.cmd("ATST19")
|
|
else:
|
|
self.cmd("ATAT1"); self.cmd("ATST32")
|
|
|
|
def connect(self):
|
|
for _ in range(3):
|
|
resp = "".join(self.cmd("0100", timeout=8.0)).upper()
|
|
if "41" in resp and "00" in resp:
|
|
self.protocol = " ".join(self.cmd("ATDPN")) or "?"
|
|
return True
|
|
if any(x in resp for x in ("UNABLE", "NODATA", "NO DATA", "ERROR", "SEARCHING")):
|
|
time.sleep(0.5)
|
|
self.protocol = " ".join(self.cmd("ATDPN")) or "?"
|
|
return False
|
|
|
|
def is_can(self):
|
|
d = self.protocol.replace("A", "").strip()
|
|
return d[:1] in ("6", "7", "8", "9")
|
|
|
|
# -- reads (return list[int] or None) --
|
|
def _bytes(self, lines):
|
|
return [b for ln in lines for b in _line_bytes(ln)]
|
|
|
|
def read_m01(self, pid, nbytes, timeout=0.6):
|
|
data = self._bytes(self.cmd(f"01{pid}", timeout=timeout))
|
|
if 0x41 in data:
|
|
i = data.index(0x41)
|
|
payload = data[i + 2:i + 2 + nbytes]
|
|
if len(payload) == nbytes:
|
|
return payload
|
|
return None
|
|
|
|
def read_m22(self, pid, timeout=0.5):
|
|
data = self._bytes(self.cmd(f"22{pid}", timeout=timeout))
|
|
# response: 62 <pid hi> <pid lo> <data...>
|
|
if 0x62 in data:
|
|
i = data.index(0x62)
|
|
return data[i + 3:] or None
|
|
return None
|
|
|
|
def read_atrv(self, timeout=0.8):
|
|
s = " ".join(self.cmd("ATRV", timeout=timeout)).replace("V", "").strip()
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return None
|
|
|
|
def read_raw(self, hexcmd, timeout=2.0):
|
|
"""Send an arbitrary hex command and return the flattened response
|
|
bytes (for bi-directional actions / service routines)."""
|
|
return self._bytes(self.cmd(hexcmd, timeout=timeout))
|
|
|
|
# -- DTCs --
|
|
def read_dtcs(self, mode, svc, timeout=5.0):
|
|
lines = self.cmd(mode, timeout=timeout)
|
|
if "NODATA" in "".join(lines).upper().replace(" ", ""):
|
|
return []
|
|
return parse_dtcs(lines, svc, self.is_can())
|
|
|
|
def clear_dtcs(self):
|
|
lines = self.cmd("04", timeout=6.0)
|
|
data = self._bytes(lines)
|
|
return 0x44 in data or ("OK" in "".join(lines).upper())
|
|
|
|
# -- standard OBD services (Mode 09 / 01-01 / 02) --
|
|
def read_vehicle_info(self, timeout=2.0):
|
|
"""Mode 09: VIN + calibration IDs + ECU name. Returns a dict."""
|
|
from . import obdservices as svc
|
|
vin = svc.decode_vin(self._bytes(self.cmd("0902", timeout=timeout)))
|
|
cal = svc.decode_ascii_block(self._bytes(self.cmd("0904", timeout=timeout)), 0x04)
|
|
ecu = svc.decode_ascii_block(self._bytes(self.cmd("090A", timeout=timeout)), 0x0A)
|
|
return {"vin": vin, "calibration": cal, "ecu_name": ecu}
|
|
|
|
def read_readiness(self, timeout=1.0):
|
|
"""Mode 01 PID 01: MIL, DTC count, and I-M readiness monitors."""
|
|
from . import obdservices as svc
|
|
data = self.read_m01("01", 4, timeout=timeout)
|
|
return svc.decode_readiness(data) if data else None
|
|
|
|
def read_freeze_frame(self, timeout=0.6):
|
|
"""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))
|
|
if 0x42 in d:
|
|
r = d[d.index(0x42) + 2:] # after '42 02'
|
|
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:
|
|
dd = self._bytes(self.cmd(f"02{pid}00", timeout=timeout))
|
|
if 0x42 in dd:
|
|
payload = dd[dd.index(0x42) + 3:dd.index(0x42) + 3 + nbytes]
|
|
if len(payload) == nbytes:
|
|
try:
|
|
out["values"].append((name, dec(payload), unit))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
def close(self):
|
|
try:
|
|
self.io.close()
|
|
except Exception:
|
|
pass
|