#!/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()
