Files
obdash/obd_reader.py
T
justin a9b2e133c0 Add dedicated --crank monitor for no-start diagnosis
Big ICP readout focused on the cranking scenario:
- Wide ICP bar with the 500-psi firing threshold marked (|)
- Rolling ASCII trace chart of the ICP build-up (10 rows; renders anywhere,
  no unicode) -- clearly shows ICP climbing above/below the 500 firing line
- Peak-hold (the crank's max ICP, the money number) + pass/fail verdict
- FICM main / battery / RPM secondaries with sag (min) tracking
- --dash-log writes a CSV (t,icp,ficm,batt,rpm) while you watch
- On exit prints peak ICP + verdict (reached 500 / suspect oil bleed-off)

Validated end-to-end via a mock crank: ICP ramp past 500, peak capture,
battery-sag capture, trace resolution, CSV logging, clean terminal restore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 09:15:14 -04:00

1404 lines
54 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 --crank # dedicated CRANK monitor (big ICP + trace)
python obd_reader.py --dash # LIVE gauge dashboard (real-time)
python obd_reader.py --dash crank # live dash, cranking preset (ICP/FICM/batt/rpm)
python obd_reader.py --dash full --dash-log run.csv # all gauges + CSV log
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 os
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)")
# ---------------------------------------------------------------------------
# Live dashboard -- real-time gauges that update as you crank / run
# ---------------------------------------------------------------------------
ESC = "\x1b["
C = {
"reset": ESC + "0m", "bold": ESC + "1m", "dim": ESC + "2m",
"green": ESC + "32m", "yellow": ESC + "33m", "red": ESC + "31m",
"cyan": ESC + "36m",
}
def enable_ansi():
"""Enable ANSI/VT processing on Windows 10+ consoles (no-op elsewhere)."""
if os.name == "nt":
try:
import ctypes
k = ctypes.windll.kernel32
k.SetConsoleMode(k.GetStdHandle(-11), 7) # ENABLE_VT_PROCESSING
except Exception:
pass
def _key_io():
"""(get_key, restore) for non-blocking single-key reads; no-ops if unsupported."""
try:
import msvcrt
def get():
if msvcrt.kbhit():
try:
return msvcrt.getch().decode("ascii", "ignore").lower()
except Exception:
return None
return None
return get, (lambda: None)
except ImportError:
try:
import termios, tty, select
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
tty.setcbreak(fd)
def get():
if select.select([sys.stdin], [], [], 0)[0]:
return sys.stdin.read(1).lower()
return None
def restore():
termios.tcsetattr(fd, termios.TCSADRAIN, old)
return get, restore
except Exception:
return (lambda: None), (lambda: None)
class Gauge:
def __init__(self, key, label, unit, kind, pid=None, nbytes=2,
decode=None, status=None, bar_max=None, doc=False):
self.key, self.label, self.unit = key, label, unit
self.kind, self.pid, self.nbytes = kind, pid, nbytes
self.decode, self.status, self.bar_max, self.doc = decode, status, bar_max, doc
self.cur = self.lo = self.hi = None
self.fails = 0
self.active = True
def feed(self, val):
self.cur = val
if val is not None:
self.fails = 0
self.lo = val if self.lo is None else min(self.lo, val)
self.hi = val if self.hi is None else max(self.hi, val)
else:
self.fails += 1
if self.fails >= 4:
self.active = False # stop polling a dead PID
def reset_minmax(self):
self.lo = self.hi = self.cur
def _gauge_set(preset):
u16 = lambda b: (b[0] << 8) + b[1]
icp_st = lambda v: "ok" if v >= 500 else "crit"
fm_st = lambda v: "ok" if v >= 46 else "warn" if v >= 45 else "crit"
fl_st = lambda v: "ok" if v >= 11 else "warn"
bat_st = lambda v: "ok" if v >= 12.2 else "warn" if v >= 11.0 else "crit"
G = {
"ICP": Gauge("ICP", "ICP", "psi", "m22", "1446",
decode=lambda b: round(u16(b) * 0.57, 1),
status=icp_st, bar_max=600),
"FICM_M": Gauge("FICM_M", "FICM Main", "V", "m22", "09D0",
decode=lambda b: round(u16(b) / 256.0, 1),
status=fm_st, bar_max=50, doc=True),
"FICM_L": Gauge("FICM_L", "FICM Logic", "V", "m22", "09CF",
decode=lambda b: round(u16(b) / 256.0, 1),
status=fl_st, doc=True),
"IPR": Gauge("IPR", "IPR", "%", "m22", "1434",
decode=lambda b: round(b[0] * 13.53 / 35, 1), bar_max=100),
"BATT": Gauge("BATT", "Batt", "V", "atrv", status=bat_st),
"RPM": Gauge("RPM", "RPM", "rpm", "m01", "0C", nbytes=2,
decode=lambda b: round(u16(b) / 4)),
"ECT": Gauge("ECT", "ECT", "C", "m01", "05", nbytes=1,
decode=lambda b: b[0] - 40),
"EOT": Gauge("EOT", "EOT", "C", "m22", "1310",
decode=lambda b: round(u16(b) / 100.0 - 40, 1)),
"IAT": Gauge("IAT", "IAT", "C", "m01", "0F", nbytes=1,
decode=lambda b: b[0] - 40),
"VPCM": Gauge("VPCM", "VPCM", "V", "m01", "42", nbytes=2,
decode=lambda b: round(u16(b) / 1000.0, 2)),
"MAP": Gauge("MAP", "MAP", "psia", "m22", "1440",
decode=lambda b: round(u16(b) * 0.03625, 2)),
"BARO": Gauge("BARO", "BARO", "psia", "m22", "1442",
decode=lambda b: round(u16(b) * 0.03625, 2)),
"BOOST": Gauge("BOOST", "Boost", "psi", "derived", ("MAP", "BARO")),
"EBP": Gauge("EBP", "EBP", "psia", "m22", "1445",
decode=lambda b: round(u16(b) * 0.03625, 2)),
"GEAR": Gauge("GEAR", "Gear", "", "m22", "11B3",
decode=lambda b: b[0] // 2),
"TSS": Gauge("TSS", "TSS", "rpm", "m22", "11B4",
decode=lambda b: round(u16(b) / 4)),
}
presets = {
"crank": ["ICP", "FICM_M", "BATT", "RPM"],
"vitals": ["ICP", "FICM_M", "FICM_L", "IPR", "BATT", "RPM",
"ECT", "EOT", "IAT", "VPCM"],
"full": list(G.keys()),
}
keys = presets.get(preset, presets["vitals"])
return [G[k] for k in keys]
def _poll_m01(elm, pid, nbytes):
data = hexbytes(elm.cmd(f"01{pid}", read_timeout=0.6))
if 0x41 in data:
i = data.index(0x41)
payload = data[i + 2:i + 2 + nbytes]
if len(payload) == nbytes:
return payload
return None
def _poll(elm, g, frame_vals):
if g.kind == "derived":
a, b = frame_vals.get(g.pid[0]), frame_vals.get(g.pid[1])
return None if (a is None or b is None) else round(a - b, 2)
if g.kind == "atrv":
s = " ".join(elm.cmd("ATRV", read_timeout=0.8)).replace("V", "").strip()
try:
return float(s)
except ValueError:
return None
raw = _poll_m01(elm, g.pid, g.nbytes) if g.kind == "m01" else elm.mode22(g.pid, timeout=0.5)
if not raw:
return None
try:
return g.decode(raw)
except Exception:
return None
def _color_for(g):
if g.cur is None:
return C["dim"]
if g.status:
try:
s = g.status(g.cur)
except Exception:
s = None
return {"ok": C["green"], "warn": C["yellow"], "crit": C["red"]}.get(s, C["cyan"])
return C["cyan"]
def _bar(val, maxv, width=12):
if val is None or not maxv:
return " " * (width + 2)
n = int(round(width * max(0.0, min(1.0, val / maxv))))
return "[" + "#" * n + "-" * (width - n) + "]"
def _fmt(g):
if g.cur is None:
return f"{'--':>8} {g.unit}"
if isinstance(g.cur, float):
return f"{g.cur:>8.1f} {g.unit}"
return f"{g.cur:>8} {g.unit}"
def _compact(g):
if g.cur is None:
return "--"
v = f"{g.cur:.1f}" if isinstance(g.cur, float) else f"{g.cur}"
return f"{v}{g.unit}"
def _render(gauges, preset, elapsed, last_dt, frame):
hz = (1.0 / last_dt) if last_dt > 0 else 0.0
clock = f"{int(elapsed)//60:02d}:{int(elapsed)%60:02d}"
lines = [
f"{C['bold']}== 6.0 LIVE DASH =={C['reset']} {clock} {hz:4.1f} Hz "
f"frame {frame} [{preset}]",
f"{C['dim']}q=quit r=reset min/max green=ok yellow=warn red=low/crit"
f" [DOC]=PID not yet confirmed on this truck{C['reset']}",
"",
]
vitals = [g for g in gauges if g.status or g.bar_max]
others = [g for g in gauges if not (g.status or g.bar_max)]
if vitals:
lines.append(f"{C['bold']} NO-START VITALS{C['reset']}")
for g in vitals:
col = _color_for(g)
val = f"{col}{_fmt(g)}{C['reset']}"
bar = f" {col}{_bar(g.cur, g.bar_max)}{C['reset']}" if g.bar_max else ""
mm = f" {C['dim']}min {g.lo:g} / max {g.hi:g}{C['reset']}" if g.lo is not None else ""
doc = f" {C['dim']}[DOC]{C['reset']}" if g.doc else ""
lines.append(f" {g.label:11}{val}{bar}{mm}{doc}")
lines.append("")
if others:
lines.append(f"{C['bold']} ENGINE / DRIVELINE{C['reset']}")
cells = []
for g in others:
plain = f"{g.label:6}{_compact(g)}"
cells.append(_color_for(g) + f"{plain:<22}" + C["reset"])
for i in range(0, len(cells), 3):
lines.append(" " + "".join(cells[i:i + 3]))
body = "\n".join(ln + ESC + "K" for ln in lines)
sys.stdout.write(ESC + "H" + body + ESC + "J")
sys.stdout.flush()
def dashboard(elm, preset="vitals", log_path=None):
"""Real-time gauge dashboard. Polls a focused PID set in a loop and
redraws in place. min/max persist so a crank's PEAK ICP is captured."""
gauges = _gauge_set(preset)
enable_ansi()
get_key, restore = _key_io()
logf = open(log_path, "w") if log_path else None
if logf:
logf.write("t," + ",".join(g.key for g in gauges) + "\n")
elm.cmd("ATAT2") # max adaptive timing
elm.cmd("ATST19") # ~100ms ECU response wait -> snappier polling
sys.stdout.write(ESC + "2J" + ESC + "H" + ESC + "?25l") # clear, home, hide cursor
t0 = time.time()
frame, last_dt, last_recheck = 0, 0.0, t0
try:
while True:
k = get_key()
if k in ("q", "\x03"):
break
if k == "r":
for g in gauges:
g.reset_minmax()
now = time.time()
if now - last_recheck > 5.0: # periodically revive dead PIDs
for g in gauges:
if not g.active:
g.active, g.fails = True, 0
last_recheck = now
fstart = time.time()
frame_vals = {}
for g in gauges: # non-derived first
if g.kind == "derived" or not g.active:
frame_vals[g.key] = g.cur if not g.active else None
continue
v = _poll(elm, g, frame_vals)
g.feed(v)
frame_vals[g.key] = v
for g in gauges: # then derived (needs others)
if g.kind == "derived":
v = _poll(elm, g, frame_vals)
g.feed(v)
last_dt = time.time() - fstart
frame += 1
if logf:
row = [f"{now - t0:.2f}"] + ["" if g.cur is None else str(g.cur) for g in gauges]
logf.write(",".join(row) + "\n")
logf.flush()
_render(gauges, preset, now - t0, last_dt, frame)
except KeyboardInterrupt:
pass
finally:
restore()
if logf:
logf.close()
sys.stdout.write(ESC + "?25h" + C["reset"] + "\n")
sys.stdout.flush()
elm.cmd("ATAT1")
elm.cmd("ATST32")
print("Dashboard closed." + (f" CSV log: {log_path}" if log_path else ""))
# ---------------------------------------------------------------------------
# Crank monitor -- dedicated big-ICP view for diagnosing a no-start crank
# ---------------------------------------------------------------------------
def _icp_color(v):
if v is None:
return C["dim"]
return C["green"] if v >= 500 else C["yellow"] if v >= 350 else C["red"]
def _wide_bar(val, maxv, width, mark=None):
n = 0 if val is None else int(round(width * max(0.0, min(1.0, val / maxv))))
cells = ["#" if i < n else "-" for i in range(width)]
if mark is not None:
mi = int(width * mark / maxv)
if 0 <= mi < width:
cells[mi] = "|" # firing-threshold marker
return "".join(cells)
def _trace_rows(hist, width=50, height=10, vmax=600, thresh=500):
"""Filled-area ASCII chart of ICP over recent samples (climbs as it builds)."""
data = hist[-width:]
data = [None] * (width - len(data)) + data
thr_level = int(round(thresh / vmax * height))
rows = []
for r in range(height): # r=0 = top (high psi)
level = height - r # column filled here if fill_h >= level
cells = []
for v in data:
if v is None:
cells.append(" ")
else:
fh = int(round(min(1.0, v / vmax) * height))
cells.append("#" if fh >= level else " ")
line = "".join(cells)
is_thr = (level == thr_level)
if is_thr: # draw the 500-psi firing line
line = "".join(c if c == "#" else "-" for c in line)
label = " 500"
elif r == 0:
label = f"{vmax:4d}"
else:
label = " "
rows.append((label, line, is_thr))
return rows
def _render_crank(elapsed, last_dt, frame, icp, peak, ficm, ficm_min,
batt, batt_min, rpm, hist):
hz = (1.0 / last_dt) if last_dt > 0 else 0.0
clock = f"{int(elapsed)//60:02d}:{int(elapsed)%60:02d}"
icpc = _icp_color(icp)
L = [
f"{C['bold']}===== 6.0 CRANK MONITOR ====={C['reset']} {clock} "
f"{hz:4.1f} Hz frame {frame}",
f"{C['dim']}crank the engine q=quit r=reset firing threshold = 500 psi{C['reset']}",
"",
]
icpval = f"{icp:6.1f}" if icp is not None else " -- "
L.append(f" {C['bold']}ICP {C['reset']} {icpc}[{_wide_bar(icp, 600, 40, mark=500)}]"
f"{C['reset']} {icpc}{C['bold']}{icpval} psi{C['reset']}")
fired = peak >= 500
pc = C["green"] if fired else C["red"]
verdict = ("FIRING PRESSURE REACHED" if fired
else "BELOW 500 - keep cranking" if peak > 0 else "waiting for crank...")
L.append(f" {C['bold']}PEAK{C['reset']} {pc}{peak:6.0f} psi {verdict}{C['reset']}")
L.append("")
fc = (C["dim"] if ficm is None else
C["green"] if ficm >= 46 else C["yellow"] if ficm >= 45 else C["red"])
fmv = f"{ficm:.1f}V" if ficm is not None else "--"
fmm = f" (min {ficm_min:.1f})" if ficm_min is not None else ""
bc = (C["dim"] if batt is None else
C["green"] if batt >= 12.2 else C["yellow"] if batt >= 11 else C["red"])
bv = f"{batt:.1f}V" if batt is not None else "--"
bm = f" (min {batt_min:.1f})" if batt_min is not None else ""
rv = f"{rpm}" if rpm is not None else "--"
L.append(f" FICM Main {fc}{fmv}{C['reset']}{C['dim']}{fmm} [DOC]{C['reset']} "
f"Batt {bc}{bv}{C['reset']}{C['dim']}{bm}{C['reset']} RPM {rv}")
L.append("")
L.append(f" {C['dim']}ICP trace (psi vs time, last {min(len(hist), 50)} samples){C['reset']}")
for label, line, is_thr in _trace_rows(hist):
color = C["yellow"] if is_thr else icpc
L.append(f" {C['dim']}{label}{C['reset']} |{color}{line}{C['reset']}")
L.append(f" +{'-' * 50}")
body = "\n".join(ln + ESC + "K" for ln in L)
sys.stdout.write(ESC + "H" + body + ESC + "J")
sys.stdout.flush()
def crank_monitor(elm, log_path=None):
"""Dedicated cranking view: big ICP readout + firing-line bar + rolling
trace, with peak-hold and a pass/fail on the 500-psi threshold. Start it,
then crank -- watch whether ICP builds past 500 psi or stalls (oil leak)."""
enable_ansi()
get_key, restore = _key_io()
logf = open(log_path, "w") if log_path else None
if logf:
logf.write("t,icp_psi,ficm_v,batt_v,rpm\n")
elm.cmd("ATAT2")
elm.cmd("ATST19")
u16 = lambda b: (b[0] << 8) + b[1]
hist = []
peak, batt_min, ficm_min = 0.0, None, None
icp_dead, ficm_dead = 0, 0
sys.stdout.write(ESC + "2J" + ESC + "H" + ESC + "?25l")
t0 = time.time()
frame, last_dt = 0, 0.0
try:
while True:
k = get_key()
if k in ("q", "\x03"):
break
if k == "r":
hist.clear()
peak, batt_min, ficm_min = 0.0, None, None
fstart = time.time()
icp = ficm = batt = rpm = None
if icp_dead < 4:
raw = elm.mode22("1446", timeout=0.5)
if raw:
icp = round(u16(raw) * 0.57, 1); icp_dead = 0
else:
icp_dead += 1
if ficm_dead < 4:
raw = elm.mode22("09D0", timeout=0.4)
if raw:
ficm = round(u16(raw) / 256.0, 1); ficm_dead = 0
else:
ficm_dead += 1
s = " ".join(elm.cmd("ATRV", read_timeout=0.8)).replace("V", "").strip()
try:
batt = float(s)
except ValueError:
batt = None
raw = _poll_m01(elm, "0C", 2)
if raw:
rpm = round(u16(raw) / 4)
now = time.time()
last_dt = now - fstart
frame += 1
if icp is not None:
hist.append(icp)
del hist[:-120]
peak = max(peak, icp)
if batt is not None:
batt_min = batt if batt_min is None else min(batt_min, batt)
if ficm is not None:
ficm_min = ficm if ficm_min is None else min(ficm_min, ficm)
if logf:
logf.write(f"{now - t0:.2f},{'' if icp is None else icp},"
f"{'' if ficm is None else ficm},"
f"{'' if batt is None else batt},"
f"{'' if rpm is None else rpm}\n")
logf.flush()
_render_crank(now - t0, last_dt, frame, icp, peak, ficm, ficm_min,
batt, batt_min, rpm, hist)
except KeyboardInterrupt:
pass
finally:
restore()
if logf:
logf.close()
sys.stdout.write(ESC + "?25h" + C["reset"] + "\n")
sys.stdout.flush()
elm.cmd("ATAT1")
elm.cmd("ATST32")
verdict = "REACHED firing pressure (>=500 psi)" if peak >= 500 else \
"did NOT reach 500 psi -- suspect high-pressure oil bleed-off"
print(f"Crank monitor closed. Peak ICP {peak:.0f} psi -- {verdict}."
+ (f" CSV: {log_path}" if log_path else ""))
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]
# --dash [crank|vitals|full] : real-time live gauge dashboard
do_dash = "--dash" in raw_args
dash_preset = "vitals"
if do_dash:
i = raw_args.index("--dash")
if i + 1 < len(raw_args) and raw_args[i + 1] in ("crank", "vitals", "full"):
dash_preset = raw_args[i + 1]
dash_log = None
if "--dash-log" in raw_args:
i = raw_args.index("--dash-log")
if i + 1 < len(raw_args):
dash_log = raw_args[i + 1]
# --crank : dedicated cranking monitor (big ICP + trace). --dash-log = CSV.
do_crank = "--crank" in raw_args
# 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 == "--dash":
if i + 1 < len(raw_args) and raw_args[i + 1] in ("crank", "vitals", "full"):
i += 2
else:
i += 1
continue
if a == "--dash-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()
# ---- Crank monitor: dedicated big-ICP cranking view ----
if do_crank:
print("\nEntering CRANK MONITOR. Start it, then crank the engine.")
print("Watch ICP build toward 500 psi (firing line). q=quit, r=reset.")
time.sleep(1.2)
crank_monitor(elm, log_path=dash_log)
return
# ---- Live dashboard: real-time gauges, skips the static report ----
if do_dash:
print(f"\nEntering live dashboard (preset: {dash_preset}).")
print("Crank or run the engine and watch. q=quit, r=reset min/max.")
time.sleep(1.2)
dashboard(elm, preset=dash_preset, log_path=dash_log)
return
# ---- 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()