6c1ee0c81d
obdcore additions (all standard SAE J1979, vehicle-agnostic, hardware-free tested): - obdservices.py: decode_vin (Mode 09), decode_readiness (Mode 01 PID 01 I-M monitors + MIL + DTC count, spark/diesel monitor sets), freeze-frame PID set. - link.py: ElmLink.read_vehicle_info (VIN/cal/ECU), read_readiness, read_freeze_frame. - trip.py: TripComputer (MAF-based MPG + trip totals) and PerformanceMeter (0-60 / 1/4-mile with launch detection). - mock.py: speed/MAF/readiness + service stubs for GUI mock mode. - tests/test_services.py: VIN, readiness bit decode, trip math, 0-60/quarter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""Standard OBD-II service decoders (vehicle-agnostic, no serial).
|
|
|
|
Covers the generic SAE J1979 services OBDash exposes beyond live PIDs:
|
|
- Mode 09 vehicle info (VIN, calibration IDs, ECU name)
|
|
- Mode 01 PID 01 readiness / I-M monitors (+ MIL, DTC count)
|
|
- Mode 02 freeze-frame (the standard PID set captured when a DTC set)
|
|
|
|
These are pure functions over raw response byte lists so they can be unit
|
|
tested without hardware; ElmLink wraps them with the actual reads.
|
|
"""
|
|
|
|
_VIN_CHARS = set("ABCDEFGHJKLMNPRSTUVWXYZ0123456789") # no I, O, Q
|
|
|
|
|
|
def decode_vin(data):
|
|
"""data: flattened response bytes for '0902' (49 02 <NODI> <17 ASCII>).
|
|
Returns the 17-char VIN or None."""
|
|
if 0x49 not in data:
|
|
return None
|
|
i = data.index(0x49)
|
|
rest = data[i + 1:]
|
|
# drop the service-PID (02) and number-of-data-items byte if present
|
|
if rest and rest[0] == 0x02:
|
|
rest = rest[2:]
|
|
chars = "".join(chr(b) for b in rest if chr(b).upper() in _VIN_CHARS)
|
|
return chars[-17:] if len(chars) >= 17 else (chars or None)
|
|
|
|
|
|
def decode_ascii_block(data, svc_pid):
|
|
"""Generic mode-09 ASCII payload (calibration IDs / ECU name) for svc_pid
|
|
(e.g. 0x04, 0x0A). Returns a printable string or None."""
|
|
if 0x49 not in data:
|
|
return None
|
|
i = data.index(0x49)
|
|
rest = data[i + 1:]
|
|
if rest and rest[0] == svc_pid:
|
|
rest = rest[2:]
|
|
s = "".join(chr(b) for b in rest if 0x20 <= b < 0x7F).strip()
|
|
return s or None
|
|
|
|
|
|
# Readiness monitor names by ignition type (SAE J1979 PID 01, bytes C/D bit map)
|
|
_SPARK = ["Catalyst", "Heated Catalyst", "Evap System", "Secondary Air System",
|
|
"A/C Refrigerant", "O2 Sensor", "O2 Sensor Heater", "EGR System"]
|
|
_COMPRESSION = ["NMHC Catalyst", "NOx/SCR Aftertreatment", "-", "Boost Pressure",
|
|
"-", "Exhaust Gas Sensor", "PM Filter", "EGR/VVT System"]
|
|
_CONTINUOUS = [("Misfire", 0), ("Fuel System", 1), ("Components", 2)]
|
|
|
|
|
|
def decode_readiness(data):
|
|
"""data: 4 bytes [A,B,C,D] from Mode 01 PID 01. Returns a dict:
|
|
{mil, dtc_count, ignition, monitors:[{name, ready}], ready_count, total}."""
|
|
if len(data) < 4:
|
|
return None
|
|
a, b, c, d = data[0], data[1], data[2], data[3]
|
|
compression = bool(b & 0x08)
|
|
monitors = []
|
|
# continuous monitors: B bits 0-2 supported, bits 4-6 = incomplete
|
|
for name, i in _CONTINUOUS:
|
|
if b & (1 << i):
|
|
monitors.append({"name": name, "ready": not (b & (1 << (i + 4)))})
|
|
# non-continuous: C bits = supported, D bits = incomplete
|
|
names = _COMPRESSION if compression else _SPARK
|
|
for i, name in enumerate(names):
|
|
if name == "-":
|
|
continue
|
|
if c & (1 << i):
|
|
monitors.append({"name": name, "ready": not (d & (1 << i))})
|
|
ready = sum(1 for m in monitors if m["ready"])
|
|
return {
|
|
"mil": bool(a & 0x80),
|
|
"dtc_count": a & 0x7F,
|
|
"ignition": "compression" if compression else "spark",
|
|
"monitors": monitors,
|
|
"ready_count": ready,
|
|
"total": len(monitors),
|
|
}
|
|
|
|
|
|
# Standard PID set read from the freeze frame (Mode 02, frame 0).
|
|
# (key, pid hex, nbytes, decoder(bytes)->value, unit)
|
|
FREEZE_PIDS = [
|
|
("Fuel System Status", "03", 2, lambda b: b[0], ""),
|
|
("Engine Load", "04", 1, lambda b: round(b[0] * 100 / 255), "%"),
|
|
("Coolant Temp", "05", 1, lambda b: b[0] - 40, "C"),
|
|
("Short Term Fuel Trim", "06", 1, lambda b: round(b[0] * 100 / 128 - 100), "%"),
|
|
("Long Term Fuel Trim", "07", 1, lambda b: round(b[0] * 100 / 128 - 100), "%"),
|
|
("Intake MAP", "0B", 1, lambda b: b[0], "kPa"),
|
|
("Engine RPM", "0C", 2, lambda b: round((b[0] * 256 + b[1]) / 4), "rpm"),
|
|
("Vehicle Speed", "0D", 1, lambda b: b[0], "km/h"),
|
|
("Intake Air Temp", "0F", 1, lambda b: b[0] - 40, "C"),
|
|
("MAF", "10", 2, lambda b: round((b[0] * 256 + b[1]) / 100, 1), "g/s"),
|
|
("Throttle Position", "11", 1, lambda b: round(b[0] * 100 / 255), "%"),
|
|
]
|