#!/usr/bin/env python3 """ ESP32 Mass Flash Script (filtered & robust) Changes in this version: - Filters out non-USB system serials (e.g., /dev/ttyS*) - Only enumerates typical USB serial ports: /dev/ttyUSB* and /dev/ttyACM* - Further filters to known USB-serial vendor IDs by default: * FTDI 0x0403 * SiliconLabs/CP210x 0x10C4 * QinHeng/CH34x 0x1A86 * Prolific PL2303 0x067B * Espressif native 0x303A - Adds --ports to override selection (glob or comma-separated patterns) - Uses detect_esp32.detect_chip_type(port) for exact chip string - Maps to correct idf.py target before flashing Example: python3 flash_all.py --project /path/to/project --start-ip 192.168.1.50 python3 flash_all.py --project . --ports '/dev/ttyUSB*,/dev/ttyACM*' """ import argparse import fnmatch import glob import os import subprocess import sys from pathlib import Path from typing import List, Dict try: import serial.tools.list_ports as list_ports except Exception: print("pyserial is required: pip install pyserial") raise SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, SCRIPT_DIR) try: import detect_esp32 except Exception as e: print("Error: detect_esp32.py must be in the same directory and importable.") print(f"Import error: {e}") sys.exit(1) # Known USB VIDs commonly used for ESP32 dev boards/adapters KNOWN_VIDS = {0x0403, 0x10C4, 0x1A86, 0x067B, 0x303A} def map_chip_to_idf_target(chip_str: str) -> str: if not chip_str or chip_str == 'Unknown': return 'unknown' s = chip_str.upper() if s.startswith('ESP32-S3'): return 'esp32s3' if s.startswith('ESP32-S2'): return 'esp32s2' if s.startswith('ESP32-C3'): return 'esp32c3' if s.startswith('ESP32-C6'): return 'esp32c6' if s.startswith('ESP32-H2'): return 'esp32h2' if s.startswith('ESP32'): return 'esp32' return 'unknown' def run(cmd: List[str], cwd: str = None, check: bool = True) -> int: print(">>", " ".join(cmd)) proc = subprocess.run(cmd, cwd=cwd) if check and proc.returncode != 0: raise RuntimeError(f"Command failed with code {proc.returncode}: {' '.join(cmd)}") return proc.returncode def ensure_target(project_dir: str, target: str): if not target or target == 'unknown': raise ValueError("Unknown IDF target; cannot set-target.") run(['idf.py', 'set-target', target], cwd=project_dir, check=True) def flash_device(project_dir: str, port: str, idf_target: str, baud: int = 460800) -> bool: try: ensure_target(project_dir, idf_target) run(['idf.py', '-p', port, '-b', str(baud), 'flash'], cwd=project_dir, check=True) return True except Exception as e: print(f" Flash failed on {port}: {e}") return False def match_any(path: str, patterns: List[str]) -> bool: return any(fnmatch.fnmatch(path, pat) for pat in patterns) def list_ports_filtered(patterns: List[str] = None) -> List[object]: """Return a filtered list of pyserial list_ports items.""" ports = list(list_ports.comports()) filtered = [] for p in ports: dev = p.device # Default pattern filter: only /dev/ttyUSB* and /dev/ttyACM* if patterns: if not match_any(dev, patterns): continue else: if not (dev.startswith('/dev/ttyUSB') or dev.startswith('/dev/ttyACM')): continue # VID filter (allow if vid is known or missing (some systems omit it), but exclude obvious non-USB) vid = getattr(p, 'vid', None) if vid is not None and vid not in KNOWN_VIDS: # Skip unknown vendor to reduce noise; user can override with --ports continue filtered.append(p) return filtered def main(): ap = argparse.ArgumentParser(description="Mass flash multiple ESP32 devices with proper chip detection.") ap.add_argument('--project', required=True, help='Path to the ESP-IDF project to flash') ap.add_argument('--ssid', help='WiFi SSID (optional)') ap.add_argument('--password', help='WiFi password (optional)') ap.add_argument('--start-ip', default='192.168.1.50', help='Base IP address for plan display') ap.add_argument('--baud', type=int, default=460800, help='Flashing baud rate') ap.add_argument('--dry-run', action='store_true', help='Plan only; do not flash') ap.add_argument('--ports', help='Comma-separated glob(s), e.g. "/dev/ttyUSB*,/dev/ttyACM*" to override selection') args = ap.parse_args() project_dir = os.path.abspath(args.project) if not os.path.isdir(project_dir): print(f"Project directory not found: {project_dir}") sys.exit(1) patterns = None if args.ports: patterns = [pat.strip() for pat in args.ports.split(',') if pat.strip()] devices = list_ports_filtered(patterns) print(f"Found {len(devices)} USB serial device(s) after filtering") if not devices: print("No candidate USB serial ports found. Try --ports '/dev/ttyUSB*,/dev/ttyACM*' or check permissions.") device_list: List[Dict] = [] for idx, dev in enumerate(devices, 1): port = dev.device print(f"Probing {port} for exact chip...") raw_chip = detect_esp32.detect_chip_type(port) idf_target = map_chip_to_idf_target(raw_chip) if idf_target == 'unknown': print(f" WARNING: Could not determine idf.py target for {port} (got '{raw_chip}')") device_list.append({ 'number': idx, 'port': port, 'raw_chip': raw_chip, 'idf_target': idf_target, 'info': dev, }) # Plan output base = args.start_ip.split('.') try: base0, base1, base2, base3 = int(base[0]), int(base[1]), int(base[2]), int(base[3]) except Exception: base0, base1, base2, base3 = 192, 168, 1, 50 print("\nFlash plan:") for d in device_list: ip_last = base3 + d['number'] - 1 ip = f"{base0}.{base1}.{base2}.{ip_last}" print(f" Device {d['number']:2d}: {d['port']} -> {d['raw_chip']} [{d['idf_target']}] -> {ip}") if args.dry_run: print("\nDry run: not flashing any devices.") return failed = [] for d in device_list: if d['idf_target'] == 'unknown': print(f"\n ERROR: Unknown IDF target for {d['port']} (raw chip '{d['raw_chip']}'). Skipping.") failed.append(d['number']) continue print(f"\nFlashing {d['port']} as target {d['idf_target']}...") ok = flash_device(project_dir, d['port'], d['idf_target'], baud=args.baud) if not ok: failed.append(d['number']) if failed: print(f"\nCompleted with failures on devices: {failed}") sys.exit(2) print("\nAll devices flashed successfully.") if __name__ == '__main__': main()