Add --watch/--ford/--pid/--scan modes + 2026-06-29 session diagnostics

obd_reader.py:
- Mode 22 plumbing: ELM.mode22() sends a 16-bit PID request, parses both
  positive (62 ..) and negative (7F 22 NRC) responses.
- --ford runs a small TENTATIVE table of community-sourced Ford 6.0 PIDs
  (ICP/IPR/FICM/EBP/EOT). All printed with raw bytes for verification.
- --pid XXXX probes a single PID and prints multiple candidate decodings
  (u8, u16, mV, temp, duty) so we can eyeball the right scaling.
- --watch [N] streams ATRV + module voltage (PID 0142) for N seconds.
  Designed for capturing voltage sag during cranking.
- --scan AAAA-BBBB brute-force scans Mode-22 PIDs with --scan-log PATH
  for output. Uses fast ELM timing (ATAT2, ATST19) for ~3.5 PIDs/sec.

diagnostics/2026-06-29-no-start/:
- Captured cranking voltage trace, full Mode-22 scan (1000-14FF -> 46
  hits), and a session writeup. Working hypothesis: not batteries, not
  fuel -- ICP / FICM / CMP. FICM meter test still owed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:49:18 -04:00
parent 8bdb77cf53
commit e15e22a825
6 changed files with 771 additions and 16 deletions
+338 -11
View File
@@ -11,14 +11,20 @@ Scope (what a generic ELM327 CAN do, engine off / KOEO):
* Read key KOEO live PIDs (coolant, IAT, MAP, module voltage, RPM...)
* Battery voltage at the OBD port (ATRV)
What it CANNOT do: Ford-enhanced diesel PIDs (ICP, FICM sync/main volts, IPR%).
Those need FORScan's Ford Mode-22 PID set. This tool covers the generic basics
so you can read codes and triage tonight.
Experimental: with --ford it also tries a handful of community-sourced
Ford-enhanced Mode-22 PIDs (ICP, IPR%, FICM main/sync/logic voltage, EBP,
EOT). The PID numbers and scaling are TENTATIVE and need to be verified
against a known-good reading (meter or FORScan). Raw bytes are always
printed so you can sanity-check. Wrong PID = "no response", nothing
written to the truck.
Usage (Windows):
python obd_reader.py # auto-detect the COM port
python obd_reader.py COM5 # force a port
python obd_reader.py COM5 9600 # force port + baud
python obd_reader.py # auto-detect the COM port
python obd_reader.py COM5 # force a port
python obd_reader.py COM5 9600 # force port + baud
python obd_reader.py --ford # + try Ford 6.0 Mode-22 PIDs
python obd_reader.py --pid 1430 # probe one Mode-22 PID, show raw
python obd_reader.py --clear # erase stored + pending DTCs
Requires: pip install pyserial
"""
@@ -110,6 +116,61 @@ NO_START_CODES = {
}
# ---------------------------------------------------------------------------
# Ford 6.0L Power Stroke Mode-22 ("enhanced") PIDs.
#
# A generic ELM327 CAN'T natively decode Ford-enhanced data (ICP, FICM, IPR%)
# the way FORScan can, but the wire protocol is the same: we send Mode 22
# ("read data by identifier") with a 16-bit PID, and the PCM replies with
# raw bytes that we scale into engineering units.
#
# WARNING: every entry below is TENTATIVE. The PID numbers and scaling
# formulas are drawn from public 6.0 community references (FORScan extended
# PID dumps, diesel forum threads) and have NOT been verified against this
# specific truck. Treat decoded values as suspect until they're cross-
# checked against a meter or a known-good FORScan reading. To help with
# that, the tool ALWAYS prints the raw response bytes next to the decode.
#
# Wrong PIDs are safe -- the PCM just answers "no data" or a negative
# response (0x7F). Nothing is written to the truck.
#
# Each entry: (pid_hex, short_name, units, decoder|None, notes)
# pid_hex : 4 hex chars (the 16-bit Mode-22 PID)
# decoder : callable(list[int]) -> float, or None to show raw only
# ---------------------------------------------------------------------------
def _u16(b):
return (b[0] << 8) + b[1]
FORD_60_PIDS = [
# --- Injection Control Pressure (need ~500+ psi to fire) ---
("1209", "ICP", "psi", lambda b: _u16(b),
"Injection Control Pressure (need ~500+ psi to fire)"),
("121A", "ICP_DES", "psi", lambda b: _u16(b),
"ICP desired (commanded)"),
("1430", "ICP_V", "V", lambda b: _u16(b) / 1000.0,
"ICP sensor raw voltage"),
# --- Injection Pressure Regulator duty ---
("120B", "IPR", "%", lambda b: round(b[0] * 100 / 255, 1),
"IPR duty (high = trying hard to make ICP)"),
# --- FICM voltages -- THE 6.0 no-start metric (~48V cranking) ---
("1228", "FICM_MPWR", "V", lambda b: _u16(b) / 1000.0,
"FICM Main Power -- want ~48V cranking, <45V = suspect"),
("1229", "FICM_SYNC", "V", lambda b: _u16(b) / 1000.0,
"FICM Sync Power"),
("122A", "FICM_LPWR", "V", lambda b: _u16(b) / 1000.0,
"FICM Logic Power (~12V)"),
# --- Exhaust back pressure / oil temp (handy, not no-start critical) ---
("121C", "EBP", "psi", lambda b: _u16(b) / 100.0,
"Exhaust Back Pressure"),
("1310", "EOT", "C", lambda b: b[0] - 40,
"Engine Oil Temperature"),
]
# ---------------------------------------------------------------------------
# ELM327 plumbing
# ---------------------------------------------------------------------------
@@ -187,6 +248,32 @@ class ELM:
d = self.protocol.replace("A", "").strip()
return d[:1] in ("6", "7", "8", "9")
def mode22(self, pid_hex, timeout=3.0):
"""Send a Mode 22 ("read data by identifier") request.
pid_hex: 4 hex chars (e.g. '1430') -- accepts '0x', spaces, lowercase.
timeout: serial-read timeout in seconds (lower for scanning).
Returns the data bytes AFTER the '62 <hi> <lo>' echo, or None on
no-data / error / negative response (0x7F)."""
pid_hex = pid_hex.replace("0x", "").replace("0X", "").replace(" ", "").strip().upper()
if len(pid_hex) != 4 or any(c not in "0123456789ABCDEF" for c in pid_hex):
return None
lines = self.cmd("22" + pid_hex, read_timeout=timeout)
joined = "".join(lines).upper().replace(" ", "")
if any(s in joined for s in ("NODATA", "ERROR", "UNABLE", "STOPPED", "CANERROR", "BUSERROR")):
return None
data = hexbytes(lines)
p_hi = int(pid_hex[:2], 16)
p_lo = int(pid_hex[2:], 16)
# Positive response: 62 <pid_hi> <pid_lo> <data...>
for i in range(len(data) - 2):
if data[i] == 0x62 and data[i + 1] == p_hi and data[i + 2] == p_lo:
return data[i + 3:]
# Negative response: 7F 22 <nrc>
for i in range(len(data) - 1):
if data[i] == 0x7F and data[i + 1] == 0x22:
return None
return None
def close(self):
try:
self.ser.close()
@@ -315,6 +402,163 @@ def live_data(elm):
return rows
def read_ford_pids(elm, pids=None):
"""Run a batch of Ford 6.0 Mode-22 PIDs and print decoded + raw bytes.
pids: list of FORD_60_PIDS entries to query (default: all)."""
if pids is None:
pids = FORD_60_PIDS
print("\n" + "-" * 64)
print(" FORD 6.0 ENHANCED (Mode 22) -- TENTATIVE / UNVERIFIED")
print("-" * 64)
print(" PID numbers and scaling are community-sourced and NOT yet")
print(" verified on this truck. Raw bytes shown for sanity-checking.")
print(" 'no response' = wrong PID or this PCM doesn't expose it.")
print()
any_response = False
for pid, name, unit, decode, notes in pids:
raw = elm.mode22(pid)
if raw is None or len(raw) == 0:
print(f" {name:10} ({pid}) no response -- {notes}")
continue
any_response = True
raw_hex = " ".join(f"{b:02X}" for b in raw)
decoded = "(no decoder)"
if decode is not None:
try:
val = decode(raw)
decoded = f"{val:>8.2f} {unit}"
except Exception:
decoded = "(decode err)"
print(f" {name:10} ({pid}) {decoded:>14} raw=[{raw_hex}]")
if not any_response:
print("\n >> Nothing answered. Either none of these PID numbers")
print(" match this PCM, or the bus isn't accepting Mode 22 in")
print(" the current state. Try with the key in RUN and the")
print(" ECU connected (protocol negotiated above).")
def probe_pid(elm, pid_hex):
"""One-shot Mode-22 probe for manual exploration. Prints raw + a few
common decodings so you can eyeball which scaling fits."""
print("\n" + "-" * 64)
print(f" Mode-22 probe: PID {pid_hex.upper()}")
print("-" * 64)
raw = elm.mode22(pid_hex)
if raw is None:
print(" no response (wrong PID, or module doesn't expose it).")
return
raw_hex = " ".join(f"{b:02X}" for b in raw)
print(f" raw bytes : [{raw_hex}] (len={len(raw)})")
if len(raw) >= 1:
print(f" as u8 : {raw[0]} ({raw[0] * 100 / 255:.1f}% if duty)")
print(f" as temp : {raw[0] - 40} C (raw-40, OBD-II convention)")
if len(raw) >= 2:
u = (raw[0] << 8) + raw[1]
print(f" as u16 : {u} ({u/1000:.3f} V if mV, {u/100:.2f} if x100)")
if len(raw) >= 4:
u32 = (raw[0] << 24) + (raw[1] << 16) + (raw[2] << 8) + raw[3]
print(f" as u32 : {u32}")
def watch_loop(elm, seconds=20, with_ford=False):
"""Stream battery + module voltage as fast as we can for N seconds.
Designed for cranking: start it, then turn the key.
with_ford=True also streams Mode-22 FICM/ICP PIDs -- only enable once
those PIDs are verified for this PCM, otherwise each 'no response'
timeout starves the sample rate."""
stream_pids = []
if with_ford:
stream_pids = [
("1228", "FICM_M", "V", lambda b: (b[0] << 8 | b[1]) / 1000.0),
("1229", "FICM_S", "V", lambda b: (b[0] << 8 | b[1]) / 1000.0),
("122A", "FICM_L", "V", lambda b: (b[0] << 8 | b[1]) / 1000.0),
("1209", "ICP", "psi", lambda b: (b[0] << 8 | b[1])),
("120B", "IPR", "%", lambda b: round(b[0] * 100 / 255, 1)),
]
print("\n" + "=" * 64)
print(f" WATCH MODE ({seconds}s) -- Ctrl-C to stop early")
print("=" * 64)
print(" ATRV = battery V at OBD port, VPCM = PCM/module V (PID 0142).")
if with_ford:
print(" Mode-22 PIDs included -- '-' means no response.")
print(" TURN KEY TO START NOW (or whenever you're ready).\n")
headers = ["t(s)", "ATRV", "VPCM"] + [f"{n}({u})" for _, n, u, _ in stream_pids]
print(" " + " ".join(f"{h:>8}" for h in headers))
print(" " + " ".join("-" * 8 for _ in headers))
start = time.time()
end = start + seconds
try:
while time.time() < end:
t = time.time() - start
atrv = " ".join(elm.cmd("ATRV", read_timeout=1.0)).replace("V", "").strip()
vpcm_raw = read_pid(elm, "42", 2)
vpcm = "?" if vpcm_raw is None else f"{((vpcm_raw[0] << 8) + vpcm_raw[1]) / 1000:.2f}"
row = [f"{t:6.2f}", atrv or "?", vpcm]
for pid, _, _, decode in stream_pids:
raw = elm.mode22(pid)
if raw is None or len(raw) < 2:
row.append("-")
else:
try:
row.append(f"{decode(raw):.2f}")
except Exception:
row.append("err")
print(" " + " ".join(f"{c:>8}" for c in row), flush=True)
except KeyboardInterrupt:
print("\n (stopped)")
def scan_mode22(elm, start, end, log_path=None):
"""Brute-force scan Mode-22 PIDs over [start, end] (inclusive, ints).
Logs every PID that returns data. Safe: Mode 22 is read-only by
definition. Writes hits to log_path as it goes so we don't lose them
if the bus drops mid-scan."""
# Tune ELM for fast scanning: max adaptive timing, short response wait
elm.cmd("ATAT2")
elm.cmd("ATST19") # 25 * 4ms = 100ms ECU response wait
total = end - start + 1
print("\n" + "=" * 64)
print(f" SCAN Mode-22 PIDs {start:04X} - {end:04X} ({total} PIDs)")
print("=" * 64)
print(f" Logging hits to: {log_path}" if log_path else " (no log file)")
print(" Safe: Mode 22 is read-only. Will print every PID that answers.\n")
if log_path:
with open(log_path, "w") as f:
f.write(f"# Mode-22 scan {start:04X}-{end:04X}\n")
f.write("# pid_hex len raw_bytes\n")
hits = 0
t0 = time.time()
for i, pid in enumerate(range(start, end + 1)):
pid_hex = f"{pid:04X}"
raw = elm.mode22(pid_hex, timeout=0.6)
if raw is not None and len(raw) > 0:
raw_hex = " ".join(f"{b:02X}" for b in raw)
line = f" HIT {pid_hex} len={len(raw):2d} raw=[{raw_hex}]"
print(line, flush=True)
hits += 1
if log_path:
with open(log_path, "a") as f:
f.write(f"{pid_hex} {len(raw)} {raw_hex}\n")
if (i + 1) % 100 == 0:
elapsed = time.time() - t0
rate = (i + 1) / elapsed if elapsed > 0 else 0
remain = (total - i - 1) / rate if rate > 0 else 0
print(f" ... {i+1}/{total} scanned in {elapsed:.0f}s ({rate:.1f}/s), "
f"~{remain:.0f}s remaining, {hits} hits so far", flush=True)
elapsed = time.time() - t0
print(f"\n Done. {hits} responding PIDs in {elapsed:.0f}s.")
# Restore default-ish timing
elm.cmd("ATAT1")
elm.cmd("ATST32")
return hits
def clear_codes(elm):
"""OBD-II mode 04: clear DTCs + freeze frame, reset monitors.
Returns True if the ECU acknowledged ('44')."""
@@ -348,12 +592,79 @@ def find_ports():
# Main
# ---------------------------------------------------------------------------
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
verbose = "-v" in sys.argv or "--verbose" in sys.argv
do_clear = "--clear" in sys.argv
raw_args = sys.argv[1:]
verbose = "-v" in raw_args or "--verbose" in raw_args
do_clear = "--clear" in raw_args
do_ford = "--ford" in raw_args or "--diesel" in raw_args
port = args[0] if len(args) >= 1 else None
baud = int(args[1]) if len(args) >= 2 else 38400
# --pid XXXX : one-shot Mode-22 probe of an arbitrary 16-bit PID
probe = None
if "--pid" in raw_args:
i = raw_args.index("--pid")
if i + 1 < len(raw_args):
probe = raw_args[i + 1]
# --watch [N] : stream voltages + FICM/ICP PIDs for N seconds (default 20)
do_watch = "--watch" in raw_args
watch_secs = 20
if do_watch:
i = raw_args.index("--watch")
if i + 1 < len(raw_args):
try:
watch_secs = int(raw_args[i + 1])
except ValueError:
pass
# --scan START-END : brute-force scan Mode-22 PIDs. Default 1000-14FF.
do_scan = "--scan" in raw_args
scan_start, scan_end = 0x1000, 0x14FF
scan_log = None
if do_scan:
i = raw_args.index("--scan")
if i + 1 < len(raw_args) and "-" in raw_args[i + 1] and not raw_args[i + 1].startswith("-"):
try:
a, b = raw_args[i + 1].split("-", 1)
scan_start, scan_end = int(a, 16), int(b, 16)
except ValueError:
pass
if "--scan-log" in raw_args:
i = raw_args.index("--scan-log")
if i + 1 < len(raw_args):
scan_log = raw_args[i + 1]
# Positional args: [port] [baud] -- skip flags and their values
pos = []
i = 0
while i < len(raw_args):
a = raw_args[i]
if a == "--pid" and i + 1 < len(raw_args):
i += 2 # always consumes next arg
continue
if a == "--scan-log" and i + 1 < len(raw_args):
i += 2
continue
if a == "--watch":
if i + 1 < len(raw_args) and raw_args[i + 1].isdigit():
i += 2
else:
i += 1
continue
if a == "--scan":
# Consume next arg only if it looks like a range "AAAA-BBBB"
if (i + 1 < len(raw_args) and "-" in raw_args[i + 1]
and not raw_args[i + 1].startswith("-")):
i += 2
else:
i += 1
continue
if a.startswith("-"):
i += 1
continue
pos.append(a)
i += 1
port = pos[0] if len(pos) >= 1 else None
baud = int(pos[1]) if len(pos) >= 2 else 38400
print("=" * 64)
print(" OBD-II Code Reader - ELM327 / 6.0 Power Stroke triage")
@@ -431,6 +742,22 @@ def main():
for label, val in live_data(elm):
print(f" {label:22} {val}")
# ---- Ford 6.0 enhanced PIDs (Mode 22) ----
if do_ford:
read_ford_pids(elm)
# ---- One-shot manual Mode-22 probe ----
if probe:
probe_pid(elm, probe)
# ---- Watch mode: stream voltages for N seconds ----
if do_watch:
watch_loop(elm, seconds=watch_secs)
# ---- Brute-force Mode-22 scan ----
if do_scan:
scan_mode22(elm, scan_start, scan_end, log_path=scan_log)
# ---- Optional clear (mode 04), only with --clear ----
if do_clear:
run_clear(elm, all_codes)