#!/usr/bin/env python3 import argparse, json, os, platform, re, shutil, subprocess, sys, time from datetime import datetime from pathlib import Path def which(cmd: str) -> str | None: return shutil.which(cmd) def run(cmd, *, cwd=None, capture=False, check=True): if capture: return subprocess.check_output(cmd, cwd=cwd, text=True, stderr=subprocess.STDOUT) p = subprocess.run(cmd, cwd=cwd, text=True) if check and p.returncode != 0: raise SystemExit(p.returncode) return "" def load_config(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def ensure_dir(p: Path): p.mkdir(parents=True, exist_ok=True) def now_tag(): return datetime.now().strftime("%Y%m%d_%H%M%S") def pio_device_list_json(): if not which("pio"): raise SystemExit("ERROR: 'pio' introuvable. Installe PlatformIO Core (ex: pipx install platformio).") out = run(["pio", "device", "list", "--json-output"], capture=True) return json.loads(out) def normalize_ports_macos_prefer_cu(devs, prefer_cu: bool): if platform.system() != "Darwin" or not prefer_cu: return devs by_key = {} for d in devs: hwid = d.get("hwid","") desc = d.get("description","") port = d.get("port","") key = (hwid, desc) cur = by_key.get(key) if cur is None: by_key[key] = d else: if "/dev/cu." in port and "/dev/cu." not in cur.get("port",""): by_key[key] = d return list(by_key.values()) def match_serial_port(devs, pattern: str): rx = re.compile(pattern, re.I) matches = [] for d in devs: blob = f"{d.get('port','')} {d.get('description','')} {d.get('hwid','')}" if rx.search(blob): matches.append(d) return matches def mount_roots(): sysname = platform.system() roots = [] if sysname == "Darwin": roots.append(Path("/Volumes")) elif sysname == "Linux": user = os.environ.get("USER") or "user" roots += [Path("/media")/user, Path("/run/media")/user, Path("/mnt")] elif sysname == "Windows": import string for letter in string.ascii_uppercase: roots.append(Path(f"{letter}:/")) return [r for r in roots if r.exists()] def find_uf2_volume(volume_match: str): rx = re.compile(volume_match, re.I) for root in mount_roots(): try: for p in root.iterdir(): name = p.name if p.is_dir() and rx.search(name): return p except Exception: pass return None def log_path(base_dir: Path, role: str): return base_dir / f"{now_tag()}_{role}" def write_text(p: Path, s: str): p.write_text(s, encoding="utf-8") def do_platformio(role_cfg: dict, port: str | None, logdir: Path): env = role_cfg["pio_env"] cmd = ["pio", "run", "-e", env, "-t", "upload"] if port: cmd += ["--upload-port", port] out = run(cmd, capture=True, check=True) write_text(logdir / "upload.log", out) return cmd def do_arduino_cli(role_cfg: dict, port: str, logdir: Path): if not which("arduino-cli"): raise SystemExit("ERROR: 'arduino-cli' introuvable. Installe via brew/apt/choco.") fqbn = role_cfg["fqbn"] sketch = role_cfg["sketch_dir"] out1 = run(["arduino-cli", "compile", "--fqbn", fqbn, sketch], capture=True, check=True) out2 = run(["arduino-cli", "upload", "-p", port, "--fqbn", fqbn, sketch], capture=True, check=True) write_text(logdir / "compile.log", out1) write_text(logdir / "upload.log", out2) return ["arduino-cli ..."] def do_uf2_copy(role_cfg: dict, logdir: Path): uf2_path = Path(role_cfg["uf2_path"]) if not uf2_path.exists(): raise SystemExit(f"ERROR: UF2 introuvable: {uf2_path}") vol = find_uf2_volume(role_cfg["volume_match"]) if not vol: raise SystemExit("ERROR: volume UF2 non trouvé. Mets la carte RP2040 en BOOTSEL (disque RPI-RP2).") dst = vol / uf2_path.name shutil.copy2(uf2_path, dst) write_text(logdir / "uf2_copy.txt", f"Copied {uf2_path} -> {dst}\n") return ["uf2_copy", str(uf2_path), "->", str(dst)] def pick_one_or_fail(items, what: str, hints: str): if len(items) == 1: return items[0] if len(items) == 0: raise SystemExit(f"ERROR: aucun {what} trouvé.\n{hints}") msg = [f"ERROR: plusieurs {what} possibles:"] for it in items: msg.append(f"- {it}") msg.append(hints) raise SystemExit("\n".join(msg)) def main(): ap = argparse.ArgumentParser(description="Auto-detect + build + upload (PlatformIO / Arduino CLI / UF2)") ap.add_argument("cmd", choices=["list", "flash"]) ap.add_argument("--config", default="tools/dev/flash_config.json") ap.add_argument("--role", help="Nom du rôle (ex: esp32, esp8266, rp2040)") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() cfg = load_config(Path(args.config)) defaults = cfg.get("defaults", {}) artifacts_dir = Path(defaults.get("artifacts_dir", "artifacts/flash")) prefer_cu = bool(defaults.get("prefer_cu_macos", True)) roles = {r["role"]: r for r in cfg.get("roles", [])} if args.cmd == "list": devs = normalize_ports_macos_prefer_cu(pio_device_list_json(), prefer_cu) print("== Serial devices (pio device list) ==") for d in devs: print(f"- {d.get('port')} | {d.get('description')} | {d.get('hwid')}") print("\n== UF2 volumes ==") roots = mount_roots() found_any = False for root in roots: try: for p in root.iterdir(): if p.is_dir(): if any(x in p.name.upper() for x in ["RPI-RP2", "PICOBOOT", "CIRCUITPY"]): print(f"- {p}") found_any = True except Exception: pass if not found_any: print("- (none detected)") print("\n== Roles ==") for name, r in roles.items(): print(f"- {name}: {r['method']}") return if args.cmd == "flash": if not args.role: raise SystemExit("ERROR: --role requis (ex: --role esp32).") if args.role not in roles: raise SystemExit(f"ERROR: rôle inconnu: {args.role} (connus: {', '.join(roles.keys())})") role_cfg = roles[args.role] method = role_cfg["method"] ensure_dir(artifacts_dir) logdir = log_path(artifacts_dir, args.role) ensure_dir(logdir) cmdline = None if method in ("platformio", "arduino_cli"): devs = normalize_ports_macos_prefer_cu(pio_device_list_json(), prefer_cu) matches = match_serial_port(devs, role_cfg.get("match", ".*")) pretty = [f"{m.get('port')} | {m.get('description')} | {m.get('hwid')}" for m in matches] port = None if role_cfg.get("need_serial_port", True): chosen = pick_one_or_fail( pretty, "port série", "Astuce: débranche les autres cartes, ou précise un match plus strict (VID:PID / description / serial)." ) port = chosen.split(" | ")[0].strip() if args.dry_run: print(f"[dry-run] role={args.role} method={method} port={port}") return if method == "platformio": cmdline = do_platformio(role_cfg, port, logdir) else: cmdline = do_arduino_cli(role_cfg, port, logdir) elif method == "uf2_copy": if args.dry_run: print(f"[dry-run] role={args.role} method=uf2_copy uf2={role_cfg.get('uf2_path')}") return cmdline = do_uf2_copy(role_cfg, logdir) else: raise SystemExit(f"ERROR: method non supportée: {method}") write_text(logdir / "meta.json", json.dumps({ "role": args.role, "method": method, "cmdline": cmdline, "time": datetime.now().isoformat() }, indent=2)) print(f"OK ✅ logs: {logdir}") if __name__ == "__main__": main()