sparrow-serial.py
#!/usr/bin/env python3
"""
Sparrow Serial Watcher (macOS, ESP32-C6 native USB) - v6
 
What this solves:
- ESP32-C6 native USB Serial/JTAG on macOS often disconnects and re-enumerates on reset.
- After a hard reset, console output may resume only after a host "touch".
- west espressif monitor (idf_monitor) appears to do a helpful open/reset/line-state dance.
- This script emulates a safe subset of that behavior.
 
Key behaviors:
- Only considers /dev/*usbmodem* ports by default (macOS-safe).
- Avoids connecting to debug-console/Bluetooth ports.
- On reconnect, performs a gentle DTR/RTS "kick" (unless --no-kick).
- Uses settle + probe before accepting a port.
- Optional pinned port mode.
- Graceful Ctrl+C exit.
 
Usage:
  python3 sparrow-serial.py
  python3 sparrow-serial.py --port /dev/cu.usbmodem1101
  python3 sparrow-serial.py --no-kick
  python3 sparrow-serial.py --settle-ms 1200 --probe-ms 2500
"""
 
import argparse
import time
import sys
 
try:
    import serial
    from serial.tools import list_ports
except ImportError:
    print("Missing dependency: pyserial")
    print("Install with: pip install pyserial")
    sys.exit(1)
 
ESPRESSIF_VID = 0x303A
MIN_SCORE = 8
 
 
def score_port(p) -> int:
    """Heuristic scoring for selecting the most likely ESP32-C6 USB Serial/JTAG port."""
    score = 0
    dev = (p.device or "").lower()
    desc = (p.description or "").lower()
    manuf = (p.manufacturer or "").lower()
    hwid = (p.hwid or "").lower()
 
    if "usbmodem" in dev:
        score += 6
 
    # Vendor hint if OS reports VID
    if getattr(p, "vid", None) == ESPRESSIF_VID:
        score += 10
 
    # Text hints (vary across macOS builds)
    if "espressif" in manuf or "espressif" in desc or "espressif" in hwid:
        score += 8
 
    if "usb jtag" in desc or "jtag" in desc or "jtag" in hwid:
        score += 4
 
    # Mild preference for cu.*
    if dev.startswith("/dev/cu."):
        score += 1
 
    return score
 
 
def list_usbmodem_candidates():
    """Return only usbmodem* candidates (macOS-safe)."""
    ports = list(list_ports.comports())
    return [p for p in ports if "usbmodem" in (p.device or "").lower()]
 
 
def pick_best_usbmodem():
    ports = list_usbmodem_candidates()
 
    if not ports:
        print("[scan] no candidates")
        return None
 
    ranked = sorted(ports, key=score_port, reverse=True)
 
    print("[scan] top candidates:")
    for p in ranked[:5]:
        print(f"  - {p.device} (score={score_port(p)}, desc={p.description}, manuf={p.manufacturer})")
 
    best = ranked[0]
    if score_port(best) < MIN_SCORE:
        return None
 
    return best.device
 
 
def normalize_tty_cu(device: str):
    """Given /dev/cu.usbmodemXXXX, return both cu and tty variants."""
    if device.startswith("/dev/cu."):
        suffix = device[len("/dev/cu."):]
        return [device, f"/dev/tty.{suffix}"]
    if device.startswith("/dev/tty."):
        suffix = device[len("/dev/tty."):]
        return [f"/dev/cu.{suffix}", device]
    return [device]
 
 
def try_open(dev: str, baud: int):
    try:
        ser = serial.Serial(dev, baudrate=baud, timeout=0.15)
        time.sleep(0.05)  # small settle after open
        return ser
    except Exception:
        return None
 
 
def kick_lines(ser):
    """
    Best-effort 'wake' sequence.
    On native USB Serial/JTAG this may or may not be honored,
    but empirically helps on macOS after hard resets.
    """
    try:
        ser.dtr = False
        ser.rts = False
        time.sleep(0.05)
 
        ser.dtr = True
        time.sleep(0.05)
        ser.dtr = False
        time.sleep(0.05)
 
        ser.rts = True
        time.sleep(0.05)
        ser.rts = False
        time.sleep(0.05)
 
        ser.dtr = True
        ser.rts = False
    except Exception:
        pass
 
 
def probe_for_data(ser, probe_ms: int) -> bool:
    """Return True if any bytes arrive within the probe window."""
    deadline = time.time() + probe_ms / 1000.0
    try:
        while time.time() < deadline:
            data = ser.read(128)
            if data:
                return True
            time.sleep(0.02)
    except Exception:
        return False
    return False
 
 
def open_with_probe(device: str, baud: int, probe_ms: int):
    """
    Try opening cu/tty variants and only accept if we see data.
    """
    for dev in normalize_tty_cu(device):
        ser = try_open(dev, baud)
        if not ser:
            continue
 
        if probe_for_data(ser, probe_ms):
            print(f"[connected] {dev} @ {baud}")
            return ser, dev
 
        try:
            ser.close()
        except Exception:
            pass
 
    return None, None
 
 
def settle_present(port: str, settle_ms: int) -> bool:
    """True if port is currently listed by OS; waits settle time if present."""
    for p in list_ports.comports():
        if p.device == port:
            time.sleep(settle_ms / 1000.0)
            return True
    return False
 
 
def do_kick_cycle(port: str, baud: int, settle_ms: int):
    """
    Open -> kick -> close. Used after hard reset when output
    often resumes only after a host "touch".
    """
    if not settle_present(port, settle_ms):
        return
 
    for dev in normalize_tty_cu(port):
        ser = try_open(dev, baud)
        if not ser:
            continue
        print(f"[kick] touching {dev}")
        kick_lines(ser)
        try:
            ser.close()
        except Exception:
            pass
        time.sleep(0.1)
        return
 
 
def read_lines(ser):
    """Read and print lines until disconnect or Ctrl+C."""
    buf = b""
    while True:
        data = ser.read(256)
        if not data:
            continue
        buf += data
        while b"\n" in buf:
            line, buf = buf.split(b"\n", 1)
            try:
                print(line.decode(errors="replace"))
            except Exception:
                print(line)
 
 
def auto_connect(baud: int, settle_ms: int, probe_ms: int, kick: bool):
    while True:
        device = pick_best_usbmodem()
        if not device:
            print("[waiting] no Espressif usbmodem console detected yet")
            time.sleep(0.4)
            continue
 
        # Give the OS time to finish re-enumeration
        time.sleep(settle_ms / 1000.0)
 
        if kick:
            do_kick_cycle(device, baud, settle_ms)
 
        ser, _opened = open_with_probe(device, baud, probe_ms)
        if ser:
            return ser
 
        print("[waiting] candidate present but no data yet")
        time.sleep(0.4)
 
 
def pinned_connect(port: str, baud: int, settle_ms: int, probe_ms: int, kick: bool):
    while True:
        if not settle_present(port, settle_ms):
            print(f"[waiting] {port} not present yet")
            time.sleep(0.3)
            continue
 
        if kick:
            do_kick_cycle(port, baud, settle_ms)
 
        ser, _opened = open_with_probe(port, baud, probe_ms)
        if ser:
            return ser
 
        print(f"[waiting] {port} present but no data yet")
        time.sleep(0.4)
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--baud", type=int, default=115200)
    ap.add_argument("--port", type=str, default="",
                    help="Optional: pin to an exact usbmodem port.")
    # Updated stable defaults for your Sparrow/macOS setup:
    ap.add_argument("--settle-ms", type=int, default=900,
                    help="Wait after port appears before attempting open.")
    ap.add_argument("--probe-ms", type=int, default=2000,
                    help="How long to wait for first bytes before accepting port.")
    ap.add_argument("--no-kick", action="store_true",
                    help="Disable the host-side 'kick' cycle.")
 
    args = ap.parse_args()
    kick = not args.no_kick
 
    ser = None
 
    try:
        while True:
            try:
                if args.port:
                    ser = pinned_connect(args.port, args.baud, args.settle_ms, args.probe_ms, kick)
                else:
                    ser = auto_connect(args.baud, args.settle_ms, args.probe_ms, kick)
 
                read_lines(ser)
 
            except (serial.SerialException, OSError) as e:
                print(f"[disconnected] {e}")
                try:
                    if ser:
                        ser.close()
                except Exception:
                    pass
                time.sleep(0.2)
 
    except KeyboardInterrupt:
        print("\n[exit] serial monitor stopped.")
        try:
            if ser:
                ser.close()
        except Exception:
            pass
        return
 
 
if __name__ == "__main__":
    main()