36b233f02c
The old 12xx PIDs (1209/1228/120B/...) were wrong addresses -- that's why they returned 'no response' on the truck, NOT a bus/gateway problem. The real Ford-enhanced DIDs are in the 09xx/14xx/16xx families. Confirmed by the truck's own brute-scan: 1446=ICP, 1445=EBP, 1440=MAP, 1442=BARO, 1310=EOT, 11B3=gear, 11B4=TSS all decode to sane on-vehicle values. - Rewrite FORD_60_PIDS with corrected addresses + [VERIFIED]/[DOC]/[TENTATIVE] tags - FICM voltages -> 09D0/09CF/09CE/09CD (09D0 Main = the ~48V no-start metric) - ICP=1446 *0.57, IPR=1434, ICP_V=16AD; EOT scaling fixed to /100-40 - watch --ford now streams 09D0/09CF/1446/1434 (FICM main V + ICP during crank) - Add diagnostics/2026-06-29-no-start/pid-research.md (full workflow findings) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
884 lines
35 KiB
Python
884 lines
35 KiB
Python
#!/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 <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()
|
|
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 -> '<svc> <count> <pairs...>'. 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 <pid> <data...>
|
|
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()
|