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