This shows you the differences between two versions of the page.
| — |
iothings:laboratoare:2025_code:lab11_1 [2025/12/15 16:58] (current) dan.tudose created |
||
|---|---|---|---|
| Line 1: | Line 1: | ||
| + | <code python 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() | ||
| + | |||
| + | </code> | ||