#!/usr/bin/env python3 """ obd_reader.py -- Minimal ELM327 OBD-II code reader + no-start triage. Built for a Ford 6.0L Power Stroke (2003-2007 Super Duty/Excursion) using a generic CH340 ELM327 adapter, but works on any OBD-II vehicle. Scope (what a generic ELM327 CAN do, engine off / KOEO): * Read STORED (mode 03), PENDING (mode 07) and PERMANENT (mode 0A) DTCs * Decode P/C/B/U codes, flag the ones that commonly cause a no-start * Read key KOEO live PIDs (coolant, IAT, MAP, module voltage, RPM...) * Battery voltage at the OBD port (ATRV) Ford enhanced (--ford): reads Ford-enhanced Mode-22 PIDs (ICP, IPR%, FICM main/logic/vehicle voltage + sync, MAP/BARO/EBP, EOT, gear). Addresses were corrected 2026-06-29 (see diagnostics/2026-06-29-no-start/pid-research.md): 14xx/13xx/11Bx are VERIFIED on this truck; the 09xx FICM family is documented but not yet read here -- re-probe to confirm. Raw bytes are always printed. ICP_DES (desired ICP) has no public Mode-22 DID -> FORScan-only. 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 --ford # + read Ford 6.0 Mode-22 PIDs python obd_reader.py --pid 1446 # probe one Mode-22 PID (1446=ICP), show raw python obd_reader.py --clear # erase stored + pending DTCs Requires: pip install pyserial """ import sys import time try: import serial from serial.tools import list_ports except ImportError: print("ERROR: pyserial is not installed. Run: pip install pyserial") sys.exit(1) # --------------------------------------------------------------------------- # DTC description table. Generic SAE codes + notable 6.0 Power Stroke codes. # Codes not listed still print (the 5-char code itself is searchable). # --------------------------------------------------------------------------- DTC_DB = { # --- Fuel / rail --- "P0087": "Fuel rail/system pressure too LOW", "P0088": "Fuel rail/system pressure too HIGH", "P0148": "Fuel delivery error (6.0: low fuel pressure / HPOP / IPR)", "P0191": "Fuel rail pressure sensor range/performance", # --- Air / sensors --- "P0101": "MAF sensor range/performance", "P0102": "MAF sensor circuit LOW", "P0103": "MAF sensor circuit HIGH", "P0107": "MAP/baro sensor circuit LOW", "P0108": "MAP/baro sensor circuit HIGH", "P0112": "Intake air temp (IAT) circuit LOW", "P0113": "Intake air temp (IAT) circuit HIGH", "P0117": "Engine coolant temp (ECT) circuit LOW", "P0118": "Engine coolant temp (ECT) circuit HIGH", "P0122": "Throttle/pedal position sensor LOW", "P0123": "Throttle/pedal position sensor HIGH", "P0128": "Coolant thermostat (below regulating temp)", # --- Position sensors (NO-START critical) --- "P0335": "Crankshaft position (CKP) sensor circuit <-- NO-START", "P0336": "Crankshaft position (CKP) range/performance <-- NO-START", "P0340": "Camshaft position (CMP) sensor circuit <-- NO-START", "P0341": "Camshaft position (CMP) range/performance <-- NO-START", "P0344": "Camshaft position sensor intermittent", # --- Glow plugs / cold start --- "P0380": "Glow plug / heater circuit 'A'", "P0670": "Glow plug control module circuit", "P0671": "Glow plug cylinder 1 circuit", "P0672": "Glow plug cylinder 2 circuit", "P0673": "Glow plug cylinder 3 circuit", "P0674": "Glow plug cylinder 4 circuit", "P0675": "Glow plug cylinder 5 circuit", "P0676": "Glow plug cylinder 6 circuit", "P0677": "Glow plug cylinder 7 circuit", "P0678": "Glow plug cylinder 8 circuit", # --- Injector electrical (6.0 uses these for the FICM/injectors) --- "P0263": "Cyl 1 contribution/balance", "P0266": "Cyl 2 contribution/balance", "P0269": "Cyl 3 contribution/balance", "P0272": "Cyl 4 contribution/balance", "P0275": "Cyl 5 contribution/balance", "P0278": "Cyl 6 contribution/balance", "P0281": "Cyl 7 contribution/balance", "P0284": "Cyl 8 contribution/balance", "P0261": "Cyl 1 injector circuit LOW", "P0264": "Cyl 2 injector circuit LOW", "P0267": "Cyl 3 injector circuit LOW", "P0270": "Cyl 4 injector circuit LOW", "P0273": "Cyl 5 injector circuit LOW", "P0276": "Cyl 6 injector circuit LOW", "P0279": "Cyl 7 injector circuit LOW", "P0282": "Cyl 8 injector circuit LOW", # --- Control modules / power --- "P0606": "PCM processor fault", "P0611": "Fuel injector control module (FICM) performance <-- 6.0 no-start", "P1316": "Injector circuit/FICM codes detected (check FICM) <-- 6.0 no-start", # --- Communication (a dead module can cause a no-start) --- "U0073": "Control module communication bus 'A' off", "U0100": "Lost communication with PCM/ECM <-- NO-START possible", "U0101": "Lost communication with TCM", "U0107": "Lost communication with throttle actuator control module", } # Codes we explicitly call out as no-start suspects NO_START_CODES = { "P0335", "P0336", "P0340", "P0341", "P0344", "P0611", "P1316", "P0148", "P0087", "U0100", "U0073", "P0606", } # --------------------------------------------------------------------------- # 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] # Confidence tags in the notes column: # [VERIFIED] multi-source AND confirmed by this truck's own brute-scan # (the 14xx/13xx/11Bx PIDs returned sane values on-vehicle) # [DOC] corroborated in >=2 independent sources but NOT yet read on # this truck (the 09xx FICM family was outside the scan window) # [TENTATIVE] single-source or disputed scaling -- sanity-check before trust # # These addresses were corrected 2026-06-29 by the ford-60-pid-hunt workflow. # The OLD 12xx numbers (1209/1228/120B/...) were WRONG -- not real Mode-22 # DIDs -- which is why they all returned "no response" on the truck. See # diagnostics/2026-06-29-no-start/pid-research.md for sources and the full # verification. (ICP_DES / desired ICP has no public Mode-22 DID -> FORScan-only.) FORD_60_PIDS = [ # --- Injection Control Pressure (need ~500+ psi to fire) --- ("1446", "ICP", "psi", lambda b: round(_u16(b) * 0.57, 1), "[VERIFIED] Injection Control Pressure -- need ~500+ psi to fire"), ("16AD", "ICP_V", "V", lambda b: round(_u16(b) * 0.000072, 4), "[TENTATIVE] ICP sensor raw voltage (single-source)"), # --- Injection Pressure Regulator duty --- ("1434", "IPR", "%", lambda b: round(b[0] * 13.53 / 35, 1), "[TENTATIVE] IPR duty -- KOEO ~14-15%, cranking ~30-40%, idle ~25-30%"), # --- FICM voltages -- THE 6.0 no-start metric (~48V cranking) --- ("09D0", "FICM_MPWR", "V", lambda b: round(_u16(b) / 256.0, 1), "[DOC] FICM Main Power -- want ~48V cranking, <45V = suspect"), ("09CF", "FICM_LPWR", "V", lambda b: round(_u16(b) / 256.0, 1), "[DOC] FICM Logic Power (~12V)"), ("09CE", "FICM_VPWR", "V", lambda b: round(_u16(b) / 256.0, 1), "[DOC] FICM Vehicle Power (battery, ~12-14V)"), ("09CD", "FICM_SYNC", "", lambda b: (b[0] >> 1) & 1, "[DOC] FICM Sync -- 1=in sync, 0=no sync (bit 1 of A)"), # --- Pressures (all raw*0.03625 -> psi ABSOLUTE) --- ("1440", "MAP", "psi", lambda b: round(_u16(b) * 0.03625, 2), "[VERIFIED] Manifold Absolute Pressure (psi abs; MGP boost = MAP-BARO)"), ("1442", "BARO", "psi", lambda b: round(_u16(b) * 0.03625, 2), "[VERIFIED] Barometric Pressure (psi abs)"), ("1445", "EBP", "psi", lambda b: round(_u16(b) * 0.03625, 2), "[VERIFIED] Exhaust Back Pressure (psi abs; minus BARO = gauge)"), # --- Oil temp / driveline --- ("1310", "EOT", "C", lambda b: round(_u16(b) / 100.0 - 40, 1), "[VERIFIED] Engine Oil Temperature"), ("11B3", "GEAR", "", lambda b: b[0] // 2, "[VERIFIED] Current gear (5R110W TorqShift)"), ("11B4", "TSS", "rpm", lambda b: _u16(b) / 4, "[VERIFIED] Trans input/turbine shaft speed"), ] # --------------------------------------------------------------------------- # ELM327 plumbing # --------------------------------------------------------------------------- class ELM: PROMPT = b">" def __init__(self, port, baud, verbose=False): self.verbose = verbose self.ser = serial.Serial(port, baud, timeout=0.2) self.protocol = "?" time.sleep(0.3) self.ser.reset_input_buffer() def cmd(self, s, settle=0.0, read_timeout=4.0): """Send a command, read until the '>' prompt, return clean lines.""" self.ser.reset_input_buffer() self.ser.write((s + "\r").encode()) if settle: time.sleep(settle) buf = bytearray() deadline = time.time() + read_timeout while time.time() < deadline: chunk = self.ser.read(256) if chunk: buf += chunk if self.PROMPT in buf: break else: if self.PROMPT in buf: break raw = buf.decode("ascii", "replace") if self.verbose: print(f" [TX {s!r}] -> {raw!r}") # Strip echo, prompt, blank lines lines = [] for ln in raw.replace(">", "").split("\r"): ln = ln.strip() if not ln or ln == s: continue lines.append(ln) return lines def init(self): self.cmd("ATZ", settle=1.0) # reset self.cmd("ATE0") # echo off (clean parsing) self.cmd("ATL0") # linefeeds off self.cmd("ATS0") # spaces off self.cmd("ATH0") # headers off self.cmd("ATAT1") # adaptive timing self.cmd("ATSP0") # auto protocol def identify(self): ver = " ".join(self.cmd("ATI")) or "?" volts = " ".join(self.cmd("ATRV")) or "?" return ver, volts def connect_vehicle(self): """Send 0100 to force protocol negotiation. Returns True if the ECU answers.""" for _ in range(3): resp = self.cmd("0100", read_timeout=8.0) joined = "".join(resp).upper() if "41" in joined and "00" in joined: pn = " ".join(self.cmd("ATDPN")) self.protocol = pn or "?" return True if any(x in joined for x in ("UNABLE", "NODATA", "NO DATA", "ERROR", "SEARCHING")): time.sleep(0.5) continue # last protocol description even if no 0100 support self.protocol = " ".join(self.cmd("ATDPN")) or "?" return False def is_can(self): # ATDPN returns e.g. "6" (or "A6" if auto). Protocols 6-9 are CAN. 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 ' 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 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 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() except Exception: pass # --------------------------------------------------------------------------- # DTC decoding # --------------------------------------------------------------------------- LETTER = {0: "P", 1: "C", 2: "B", 3: "U"} def decode_dtc(b1, b2): letter = LETTER[(b1 >> 6) & 0x3] d1 = (b1 >> 4) & 0x3 d2 = b1 & 0xF return f"{letter}{d1}{d2:X}{b2:02X}" def line_bytes(ln): """Convert one response line to byte ints. Strips a CAN frame-index prefix like '0:' / '1:' and ignores any non-hex line (status text).""" ln = ln.replace(" ", "") if len(ln) >= 2 and ln[1] == ":" and ln[0] in "0123456789": ln = ln[2:] # drop 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 hexbytes(lines): """Flatten response lines into a list of byte ints, dropping non-hex noise.""" out = [] for ln in lines: out.extend(line_bytes(ln)) return out def parse_dtcs(lines, service_byte, is_can): """ service_byte: 0x43 for mode 03, 0x47 for 07, 0x4A for 0A. Returns list of DTC strings. CAN : one logical message -> ' '. Reassemble all frames, strip svc byte + count, then read pairs. Legacy (ISO 9141 / KWP / J1850): EACH line is its own message that REPEATS the service byte and carries up to 3 DTC pairs. Parse per line so the repeated svc headers aren't mistaken for DTC data. """ pairs = [] if is_can: data = hexbytes(lines) if service_byte in data: data = data[data.index(service_byte) + 1:] if data: data = data[1:] # drop DTC-count byte pairs = data else: for ln in lines: data = line_bytes(ln) if service_byte in data: data = data[data.index(service_byte) + 1:] elif data and data[0] == service_byte: data = data[1:] else: continue # not a DTC data line for this mode pairs.extend(data) dtcs = [] for i in range(0, len(pairs) - 1, 2): b1, b2 = pairs[i], pairs[i + 1] if b1 == 0 and b2 == 0: continue dtcs.append(decode_dtc(b1, b2)) # de-dup, keep order seen, uniq = set(), [] for d in dtcs: if d not in seen: seen.add(d) uniq.append(d) return uniq # --------------------------------------------------------------------------- # Live PIDs (mode 01) # --------------------------------------------------------------------------- def read_pid(elm, pid, nbytes): lines = elm.cmd(f"01{pid}") data = hexbytes(lines) # response: 41 if 0x41 in data: i = data.index(0x41) payload = data[i + 2:i + 2 + nbytes] if len(payload) == nbytes: return payload return None def live_data(elm): rows = [] def add(label, payload, fn, unit): if payload is not None: try: rows.append((label, f"{fn(payload)} {unit}")) return except Exception: pass rows.append((label, "n/a")) add("Engine RPM", read_pid(elm, "0C", 2), lambda p: round(((p[0] << 8) + p[1]) / 4), "rpm") add("Coolant temp", read_pid(elm, "05", 1), lambda p: p[0] - 40, "C") add("Intake air temp", read_pid(elm, "0F", 1), lambda p: p[0] - 40, "C") add("Intake MAP", read_pid(elm, "0B", 1), lambda p: p[0], "kPa") add("Engine load", read_pid(elm, "04", 1), lambda p: round(p[0] * 100 / 255), "%") add("Accel/throttle pos", read_pid(elm, "11", 1), lambda p: round(p[0] * 100 / 255), "%") add("Module voltage", read_pid(elm, "42", 2), lambda p: round(((p[0] << 8) + p[1]) / 1000, 2), "V") 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)") print("-" * 64) print(" [VERIFIED] = confirmed on this truck's scan; [DOC] = corroborated") print(" in sources but not yet read here (re-probe to confirm);") print(" [TENTATIVE] = single-source/disputed scaling. Raw bytes shown.") 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. 09D0 (FICM Main) is the no-start metric -- watch it sag during cranking (<45V = failing FICM). The 09xx PIDs are [DOC]-grade (not yet read on this truck) -- if they 'no response', the timeout will starve the sample rate, so drop --ford.""" stream_pids = [] if with_ford: stream_pids = [ ("09D0", "FICM_M", "V", lambda b: round((b[0] << 8 | b[1]) / 256.0, 1)), ("09CF", "FICM_L", "V", lambda b: round((b[0] << 8 | b[1]) / 256.0, 1)), ("1446", "ICP", "psi", lambda b: round((b[0] << 8 | b[1]) * 0.57, 1)), ("1434", "IPR", "%", lambda b: round(b[0] * 13.53 / 35, 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').""" lines = elm.cmd("04", read_timeout=6.0) data = hexbytes(lines) joined = "".join(lines).upper() if 0x44 in data: return True if "OK" in joined and "NODATA" not in joined: return True return False # --------------------------------------------------------------------------- # Port discovery # --------------------------------------------------------------------------- def find_ports(): ports = list(list_ports.comports()) # Prioritise CH340 / serial converters def score(p): s = (p.description + " " + (p.manufacturer or "")).lower() if "ch340" in s or "1a86" in s: return 0 if "serial" in s or "usb" in s: return 1 return 2 return sorted(ports, key=score) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): 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 # --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") print("=" * 64) if not port: cands = find_ports() if not cands: print("\nNo serial ports found. Plug in the adapter, install the") print("CH340 driver, then pass the port: python obd_reader.py COM5") return port = cands[0].device print(f"\nAuto-selected port: {port} ({cands[0].description})") if len(cands) > 1: others = ", ".join(p.device for p in cands[1:]) print(f"(other ports seen: {others} -- pass one as an argument if wrong)") print(f"Opening {port} @ {baud} baud ...") try: elm = ELM(port, baud, verbose=verbose) except Exception as e: print(f" Could not open {port}: {e}") print(" Try another COM port, or check the CH340 driver in Device Manager.") return try: elm.init() ver, volts = elm.identify() print(f" Adapter : {ver}") print(f" Battery : {volts} (key ON; healthy 6.0 KOEO ~12.4-12.7V)") print("\nConnecting to vehicle (turn key to ON / RUN, engine off is fine)...") ok = elm.connect_vehicle() print(f" Protocol: {elm.protocol} (CAN={elm.is_can()})") if not ok: print(" >> No response from the ECU on 0100.") print(" - Make sure the key is in RUN (not just ACC).") print(" - Reseat the adapter in the OBD port under the dash.") print(" - We'll still try to read codes below anyway.\n") is_can = elm.is_can() # ---- DTCs ---- print("\n" + "-" * 64) print(" TROUBLE CODES") print("-" * 64) groups = [ ("STORED (mode 03)", "03", 0x43), ("PENDING (mode 07)", "07", 0x47), ("PERMANENT (mode 0A)", "0A", 0x4A), ] all_codes = [] for title, mode, svc in groups: lines = elm.cmd(mode, read_timeout=5.0) joined = "".join(lines).upper() if "NODATA" in joined or "NO DATA" in joined: print(f"\n {title}: none reported") continue dtcs = parse_dtcs(lines, svc, is_can) if not dtcs: print(f"\n {title}: none") continue print(f"\n {title}:") for d in dtcs: desc = DTC_DB.get(d, "(look up this code)") flag = " *** NO-START SUSPECT ***" if d in NO_START_CODES else "" print(f" {d} - {desc}{flag}") all_codes.append(d) # ---- Live data ---- print("\n" + "-" * 64) print(" KEY LIVE VALUES (key ON)") print("-" * 64) 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) # ---- Triage ---- print_triage(all_codes) finally: elm.close() print("\nDone. (Re-run any time; codes persist until cleared.)") def run_clear(elm, codes): print("\n" + "=" * 64) print(" CLEAR CODES (OBD-II mode 04)") print("=" * 64) print(""" This erases stored + pending codes AND freeze-frame data, and resets emissions monitors. Read on a no-start first: * If the fault is still present, the code comes RIGHT BACK. * Don't clear before you've written the codes down (shown above). * Permanent codes (mode 0A) will NOT clear until the fault is fixed and the vehicle self-clears them over several drive cycles. * Engine should be OFF, key in RUN, for a clean clear. """) if codes: print(" Codes currently set: " + ", ".join(codes)) else: print(" No stored codes were read above (nothing to clear, or key not in RUN).") try: ans = input('\n Type "CLEAR" to confirm (anything else cancels): ').strip() except (EOFError, KeyboardInterrupt): ans = "" if ans != "CLEAR": print(" Cancelled. No codes cleared.") return print(" Sending clear command...") if clear_codes(elm): print(" >> ECU acknowledged. Codes cleared.") # Re-read to show what (if anything) came straight back again = parse_dtcs(elm.cmd("03", read_timeout=5.0), 0x43, elm.is_can()) if again: print(" >> These codes RETURNED immediately (active fault present):") for d in again: print(f" {d} - {DTC_DB.get(d, '(look up this code)')}") else: print(" >> No stored codes on re-read.") else: print(" >> No clear acknowledgement from the ECU.") print(" Make sure the key is in RUN and the vehicle is connected,") print(" then try again.") def print_triage(codes): print("\n" + "=" * 64) print(" 6.0 POWER STROKE -- NO-START QUICK TRIAGE") print("=" * 64) cset = set(codes) if cset & {"P0335", "P0336", "P0340", "P0341", "P0344"}: print(""" >> CAM/CRANK SENSOR code present. The 6.0 needs BOTH the CKP and CMP signal to fire injectors. A failed CMP (very common) = crank, no-start. Check: CMP sensor on front of engine, its connector, and wiring.""") if cset & {"P0611", "P1316"} or any(c in cset for c in ("P0263","P0266","P0269","P0272","P0275","P0278","P0281","P0284")): print(""" >> FICM / INJECTOR codes. The FICM (Fuel Injector Control Module) drives the injectors at ~48V. Weak FICM = hard/no start, esp. when cold. Check FICM Main & Sync voltage (needs FORScan or a meter at the FICM); should be ~48V cranking. <45V is a classic 6.0 no-start.""") if cset & {"P0087", "P0148", "P0191"}: print(""" >> FUEL PRESSURE code. Check low-pressure fuel (HFCM/lift pump, filters) AND high-pressure oil (HPOP/IPR) -- the 6.0 needs ~500+ psi ICP to fire.""") if cset & {"U0100", "U0073", "P0606"}: print(""" >> MODULE COMMUNICATION / PCM fault. A PCM that isn't talking can prevent start entirely. Check PCM power/grounds and the connector.""") print(""" NO-CODE no-start basics to check by hand on a 6.0: 1. BATTERIES: both must be strong. Low voltage -> FICM won't boost -> no injector fire. Load-test both; ~12.5V+ at rest, hold while cranking. 2. FICM voltage while cranking (~48V). The #1 6.0 cold no-start cause. 3. ICP (Injection Control Pressure): needs ~500 psi to fire. Big leaks = STC fitting, oil rail O-rings, high-pressure oil hoses. 4. FUEL: lift pump (HFCM) priming, fuel filters, water-in-fuel. 5. CMP/CKP sensors (see codes above). 6. Glow plugs/relay if it's cold out (won't stop start, but hard start). """) if __name__ == "__main__": main()