#!/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) 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. 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 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", } # --------------------------------------------------------------------------- # 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 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 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(): 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 port = args[0] if len(args) >= 1 else None baud = int(args[1]) if len(args) >= 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}") # ---- 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()