add MCP developer tool with multi-unit support

First developer tool for the XDJ-100SX project. Connects Claude Code
directly to the Pi over SSH — push skin files, take screenshots, restart
Mixxx, flash Pico firmware, and more without leaving the editor.

Available MCP tools:
- run_command, read_file, write_file, list_files
- push_skin, pull_skin, push_skin_file, pull_skin_file
- push_midi, pull_midi
- take_screenshot, navigate_panel
- restart_mixxx
- check (preflight: SSH, Mixxx, Pico, audio)
- pico_bootloader, pico_flash
- discover_units — scan network for all reachable XDJ Pi units
- select_unit — switch active connection mid-session (multi-unit support)

Also adds --about flag and TUI About modal with authors and credits,
and fixes scrolling/close behavior on Help and About modals.

By: Jeancarlo Cardoso de Faria Filho (jaianlab) <jaianlabworks@gmail.com>
This commit is contained in:
Jeancarlo
2026-05-08 01:14:17 -03:00
parent 3e7b0f0c3f
commit 8b0eb42fec
15 changed files with 5995 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
from __future__ import annotations
"""
Pico firmware tools: bootloader, flash, compile, and CLI setup.
Extracted from xdj-pi-dev.py (lines 577-741 and 853-1101).
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from xdj_pi_dev._terminal import (
_C, Step, section, ok, warn, fail, _log_line,
)
from xdj_pi_dev.messages import MSG
# ─── Module-level constants ───────────────────────────────────────────────────
# Resolve relative to this file: xdj_pi_dev/ → tools/ → repo root
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_PI_USER = os.environ.get("XDJ_USER", "xdj100sx")
REPO_FIRMWARE = _REPO_ROOT / "arduino" / "pico" / "pico-XDJ100SX.ino"
PI_FIRMWARE_DIR = f"/home/{_PI_USER}/pico-firmware"
PI_FIRMWARE_INO = f"{PI_FIRMWARE_DIR}/pico-XDJ100SX.ino"
PI_BUILD_DIR = f"{PI_FIRMWARE_DIR}/build"
ARDUINO_CLI_URL = (
"https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARMv7.tar.gz"
)
ARDUINO_BOARD_URL = (
"https://github.com/earlephilhower/arduino-pico/releases/download/global/"
"package_rp2040_index.json"
)
# Board profiles: short name → full FQBN
# rp2040 boards need usbstack=tinyusb for Adafruit TinyUSB to compile
BOARD_PROFILES: dict[str, str] = {
"pico": "rp2040:rp2040:rpipico:usbstack=tinyusb",
"pico2": "rp2040:rp2040:rpipico2:usbstack=tinyusb",
"picow": "rp2040:rp2040:rpipicow:usbstack=tinyusb",
"micro": "arduino:avr:micro",
"leonardo": "arduino:avr:leonardo",
"uno": "arduino:avr:uno",
"nano": "arduino:avr:nano",
}
DEFAULT_BOARD = "pico"
# ─── Pico bootloader ──────────────────────────────────────────────────────────
def pico_bootloader(pi_client) -> str:
"""
Reset the Pico into UF2 bootloader mode via MIDI Note 127 ch16 vel127.
Mixxx holds the ALSA MIDI port while running, so we must stop it first.
amidi (already on Pi) is used — no Python dependencies needed.
Mixxx auto-restarts via the xinitrc while-loop after ~2-3 s.
"""
# Kill Mixxx so it releases the ALSA port; SIGTERM then SIGKILL to be sure
ok("Stopping Mixxx…")
pi_client.exec(
"pkill -x mixxx 2>/dev/null; sleep 2; pkill -9 -x mixxx 2>/dev/null; true",
timeout=10,
)
time.sleep(1) # let ALSA finish releasing the port
# Find Pico MIDI port via amidi -l; default to hw:1,0,0 (card 1 = USB)
ports_r = pi_client.exec("amidi -l 2>&1")
port = "hw:1,0,0"
for line in ports_r["stdout"].splitlines():
if "XDJ" in line or "Pico" in line or "USB" in line.upper():
for tok in line.split():
if tok.startswith("hw:"):
port = tok
break
ok(f"Sending update signal via {port}")
# Note On: status 0x9F = ch16, note 0x7F = 127, vel 0x7F = 127.
# Shell timeout prevents hang if the Pico reboots and amidi stalls on ALSA cleanup.
# rc=124 means shell killed amidi = port vanished during send = likely success.
r = pi_client.exec(f"timeout 6 amidi -p {port} -S '9F 7F 7F' 2>&1", timeout=10)
amidi_out = r["stdout"].strip()
amidi_rc = r["rc"]
if amidi_rc not in (0, 124) and amidi_out:
# amidi returned a real error — port busy, not found, etc.
return f"amidi error (rc={amidi_rc}): {amidi_out}"
time.sleep(3) # give Pico time to enumerate as mass storage
# Broad detection: check by-label symlink (most reliable), then lsblk fallbacks
check = pi_client.exec(
"ls /dev/disk/by-label/RPI-RP2 2>/dev/null && echo BY_LABEL;"
"lsblk -no NAME,LABEL 2>/dev/null | grep -i RPI-RP2 && echo BY_LABEL_LSBLK;"
"lsblk -no NAME,VENDOR 2>/dev/null | grep -i raspberr && echo BY_VENDOR;"
"lsblk -no NAME,MODEL 2>/dev/null | grep -iE 'RP2|RPI' && echo BY_MODEL;"
"mountpoint -q /media/RPI-RP2 2>/dev/null && echo MOUNTED || true"
)
found = any(k in check["stdout"] for k in ("BY_LABEL", "BY_VENDOR", "BY_MODEL", "MOUNTED"))
if found:
return MSG["pico_boot_ok"]
# Port vanished (amidi hung and was killed, or returned "No such") → likely in bootloader
if amidi_rc == 124 or not amidi_out or "No such" in amidi_out or "cannot open" in amidi_out:
ok("Update signal sent — Pico is rebooting into update mode…")
time.sleep(4)
check2 = pi_client.exec(
"ls /dev/disk/by-label/RPI-RP2 2>/dev/null && echo BY_LABEL;"
"lsblk -no NAME,LABEL 2>/dev/null | grep -i RPI-RP2 && echo BY_LABEL_LSBLK || true"
)
if any(k in check2["stdout"] for k in ("BY_LABEL", "BY_LABEL_LSBLK")):
return MSG["pico_boot_ok"]
return MSG["pico_boot_likely"]
return MSG["pico_boot_fail"]
# ─── Pico flash ───────────────────────────────────────────────────────────────
def pico_flash(pi_client, local_uf2_path: str) -> str:
local = Path(local_uf2_path)
if not local.exists():
return f"File not found: {local}"
ok(f"Uploading {local.name} to Pi…")
remote_uf2 = f"/tmp/{local.name}"
pi_client.write_bytes(remote_uf2, local.read_bytes())
# Try picotool first (cleanest method — works if installed on Pi)
r = pi_client.exec(f"picotool load {remote_uf2} --force 2>&1", timeout=8)
if r["rc"] == 0:
return f"Flashed via picotool: {local.name}\n{r['stdout'].strip()}"
ok("Locating RPI-RP2 block device and mounting…")
# Shell script that:
# 1. Finds the Pico block device via lsblk VENDOR (reads sysfs — no blkid probe, no hang)
# 2. Mounts it with uid=1000 so the pi user can write (or uses sudo if already root-mounted)
# 3. Verifies the mount with mountpoint before writing (not just directory existence)
# 4. Runs cp and checks its exit code separately — a leftover empty dir won't fool us
# 5. sync before umount to force page-cache flush to the Pico
flash_script = (
"MPOINT=/media/RPI-RP2\n"
f"UF2={remote_uf2}\n"
"mkdir -p \"$MPOINT\"\n"
"for i in $(seq 1 30); do\n"
# 1. by-label symlink (most reliable, set by udev on Raspberry Pi OS)
" DEV=$(readlink -f /dev/disk/by-label/RPI-RP2 2>/dev/null)\n"
# 2. lsblk by LABEL
" [ -z \"$DEV\" ] && DEV=$(lsblk -no NAME,LABEL 2>/dev/null"
" | awk '$2==\"RPI-RP2\"{print \"/dev/\"$1}'"
" | grep -v mmcblk | head -1)\n"
# 3. lsblk by MODEL (RP2 Boot, RP2, RPI-RP2)
" if [ -z \"$DEV\" ]; then\n"
" DISK=$(lsblk -no NAME,MODEL 2>/dev/null"
" | awk '$2~/^RP2|^RPI/{print $1}'"
" | grep -v mmcblk | head -1)\n"
" [ -n \"$DISK\" ] && { if [ -b \"/dev/${DISK}1\" ]; then DEV=\"/dev/${DISK}1\"; else DEV=\"/dev/$DISK\"; fi; }\n"
" fi\n"
# 4. lsblk by VENDOR (RaspberryPi, Raspberry)
" [ -z \"$DEV\" ] && DEV=$(lsblk -no NAME,VENDOR 2>/dev/null"
" | awk 'tolower($2)~/raspberr/{print \"/dev/\"$1}'"
" | grep -v mmcblk | head -1)\n"
" if [ -n \"$DEV\" ]; then\n"
" if ! mountpoint -q \"$MPOINT\" 2>/dev/null; then\n"
" sudo mount -t vfat -o uid=1000,gid=1000,sync \"$DEV\" \"$MPOINT\" 2>/dev/null"
" || sudo mount -t vfat -o sync \"$DEV\" \"$MPOINT\" 2>/dev/null || true\n"
" fi\n"
" if mountpoint -q \"$MPOINT\" 2>/dev/null; then\n"
" echo \"MOUNTED:$DEV\"\n"
" cp \"$UF2\" \"$MPOINT/fw.uf2\"\n"
" CP_RC=$?\n"
" sync\n"
" sudo umount \"$MPOINT\" 2>/dev/null || true\n"
" if [ \"$CP_RC\" -eq 0 ]; then\n"
" echo FLASHED\n"
" exit 0\n"
" else\n"
" echo \"CP_FAILED:$CP_RC\"\n"
" exit 1\n"
" fi\n"
" fi\n"
" fi\n"
" sleep 1\n"
"done\n"
"echo NOTMOUNTED\n"
"exit 1\n"
)
r2 = pi_client.exec(flash_script, timeout=60)
stdout = r2["stdout"]
if "CP_FAILED" in stdout:
return MSG["flash_fail_copy"]
if "NOTMOUNTED" in stdout:
return MSG["flash_fail_nodev"]
if "FLASHED" not in stdout:
return f"Flash failed — unexpected output:\n{stdout}"
ok("UF2 sent — waiting for Pico to reboot into firmware…")
for i in range(45):
time.sleep(1)
# Match any ttyACM* in case enumeration picks a different index
chk = pi_client.exec(
"ls /dev/ttyACM* 2>/dev/null | head -1 || echo WAITING",
timeout=5,
)
port = chk["stdout"].strip()
if port and port != "WAITING":
ok(MSG["pico_alive"].format(port=port))
midi = pi_client.exec("aconnect -l 2>/dev/null | grep -i xdj || echo NOT_VISIBLE", timeout=5)
if "NOT_VISIBLE" not in midi["stdout"]:
ok(MSG["pico_midi_ok"])
else:
warn(MSG["pico_midi_wait"])
return MSG["flash_ok"]
return (
"UF2 was sent successfully but /dev/ttyACM* did not appear within 45 s.\n"
"The firmware is on the Pico — it is not lost.\n"
"Wait a few more seconds then run: python3 xdj-pi-dev.py --status\n"
"If still missing, unplug and replug the Pico USB cable on the Pi."
)
# ─── Pico CLI setup ───────────────────────────────────────────────────────────
def setup_pico_cli(pi_client) -> None:
"""
One-time setup: install arduino-cli on the Pi, add the arduino-pico core,
and install the required libraries. Downloads ~500 MB of toolchain.
Mixxx is stopped during the download/install to free RAM, then restarted.
"""
section("Pico Toolchain Setup (one-time, ~500 MB download)")
print(f" {_C.DIM}Mixxx will be stopped during this operation.{_C.RESET}\n")
with Step("Stopping Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true", timeout=5)
time.sleep(2)
with Step("Creating firmware directory"):
pi_client.exec(f"mkdir -p {PI_FIRMWARE_DIR} {PI_BUILD_DIR}")
# Download arduino-cli if not present
cli_present = pi_client.exec("which arduino-cli")
if cli_present["rc"] != 0:
with Step("Downloading arduino-cli"):
rc = pi_client.exec_stream(
f"cd /tmp && curl -fsSL {ARDUINO_CLI_URL} | tar xz arduino-cli"
f" && sudo mv /tmp/arduino-cli /usr/local/bin/ && echo ok",
timeout=120,
)
if rc != 0:
fail("arduino-cli download failed — check internet connection on Pi")
with Step("Restarting Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true")
return
else:
ok(f"arduino-cli already installed ({cli_present['stdout'].strip()})")
with Step("Initialising arduino-cli config"):
pi_client.exec("arduino-cli config init --overwrite 2>/dev/null; true")
pi_client.exec(
f"arduino-cli config add board_manager.additional_urls {ARDUINO_BOARD_URL}"
)
with Step("Updating board index"):
pi_client.exec_stream("arduino-cli core update-index 2>&1", timeout=60)
print(f"\n {_C.DIM}Installing RP2040 core + ARM toolchain (~500 MB)…{_C.RESET}")
print(f" {_C.GRAY}{_C.RESET}")
rc = pi_client.exec_stream("arduino-cli core install rp2040:rp2040 2>&1", timeout=900)
if rc != 0:
fail("Core install failed")
else:
ok("RP2040 core installed")
print()
with Step("Installing Adafruit TinyUSB"):
pi_client.exec_stream(
'arduino-cli lib install "Adafruit TinyUSB Library" 2>&1', timeout=120
)
with Step("Installing MIDI Library"):
pi_client.exec_stream('arduino-cli lib install "MIDI Library" 2>&1', timeout=60)
with Step("Installing Bounce2"):
pi_client.exec_stream('arduino-cli lib install "Bounce2" 2>&1', timeout=60)
with Step("Restarting Mixxx"):
time.sleep(4)
pi_client.exec("killall mixxx 2>/dev/null; true")
print()
ok("Toolchain ready — run --pico-compile to build firmware")
# ─── Private compile helpers ──────────────────────────────────────────────────
def _resolve_fqbn(board: str) -> str:
"""Resolve a board name or raw FQBN to a full FQBN string."""
return BOARD_PROFILES.get(board, board)
def _compile_local(fqbn: str, ino: Path) -> Path:
"""
Compile firmware using arduino-cli on this machine.
Returns path to the produced .uf2 (or .hex for AVR).
Raises RuntimeError on failure.
"""
build_dir = Path(tempfile.mkdtemp(prefix="xdj-build-"))
# arduino-cli requires sketch dir name == .ino stem
sketch_dir = build_dir / ino.stem
sketch_dir.mkdir()
shutil.copy(ino, sketch_dir / ino.name)
r = subprocess.run(
[
"arduino-cli", "compile",
"--fqbn", fqbn,
"--output-dir", str(build_dir),
"--warnings", "none",
str(sketch_dir),
],
capture_output=True,
text=True,
)
if r.returncode != 0:
raise RuntimeError((r.stderr or r.stdout).strip())
artifacts = list(build_dir.glob("*.uf2")) or list(build_dir.glob("*.hex"))
if not artifacts:
raise RuntimeError("Compile succeeded but no .uf2/.hex found in output dir")
return artifacts[0]
def _compile_on_pi(pi_client, fqbn: str, ino: Path) -> str:
"""
Push firmware to Pi and compile there.
Returns the remote path of the produced .uf2.
Raises RuntimeError on failure.
"""
pi_sketch_dir = f"{PI_FIRMWARE_DIR}/{ino.stem}"
pi_ino = f"{pi_sketch_dir}/{ino.name}"
pi_client.exec(f"mkdir -p {pi_sketch_dir} {PI_BUILD_DIR}")
pi_client.write_bytes(pi_ino, ino.read_bytes())
w = shutil.get_terminal_size((72, 24)).columns
bar = _C.GRAY + "" * min(w - 4, 68) + _C.RESET
print(f"\n {bar}")
t0 = time.time()
rc = pi_client.exec_stream(
f"arduino-cli compile --fqbn {fqbn}"
f" --output-dir {PI_BUILD_DIR} --warnings none {pi_ino} 2>&1",
timeout=300,
)
print(f" {bar}\n")
if rc != 0:
raise RuntimeError(f"Compile failed on Pi after {time.time()-t0:.0f}s")
check = pi_client.exec(f"ls {PI_BUILD_DIR}/*.uf2 {PI_BUILD_DIR}/*.hex 2>/dev/null | head -1")
path = check["stdout"].strip()
if not path:
raise RuntimeError("Compile finished but no .uf2/.hex found on Pi")
return path
# ─── Pico compile (top-level) ─────────────────────────────────────────────────
def pico_compile(pi_client, flash: bool = False, board: str = DEFAULT_BOARD) -> None:
"""
Compile firmware then optionally flash.
Strategy: local arduino-cli first (faster, no Pi needed); falls back to Pi.
"""
section("Pico Firmware Compile")
if not REPO_FIRMWARE.exists():
fail(f"Firmware not found: {REPO_FIRMWARE}")
return
fqbn = _resolve_fqbn(board)
ok(f"Board: {board}{fqbn}")
local_cli = shutil.which("arduino-cli")
pi_cli = pi_client.exec("which arduino-cli 2>/dev/null")["rc"] == 0
if not local_cli and not pi_cli:
fail("arduino-cli not found — install it first:")
print(f" {_C.CYAN}Mac/Linux:{_C.RESET} brew install arduino-cli")
print(f" {_C.CYAN}Pi:{_C.RESET} {sys.argv[0]} --setup-pico-cli (Pi needs internet)")
return
uf2_local: Path | None = None # set if compiled on this machine
uf2_remote: str | None = None # set if compiled on Pi
if local_cli:
ok(f"Compiling locally ({local_cli})")
t0 = time.time()
try:
uf2_local = _compile_local(fqbn, REPO_FIRMWARE)
except RuntimeError as e:
fail(f"Local compile failed:\n{e}")
return
kb = uf2_local.stat().st_size // 1024
ok(f"Done in {time.time()-t0:.0f}s — {uf2_local.name} ({kb} KB)")
else:
ok("Compiling on Pi (arduino-cli found there, local not available)")
with Step("Stopping Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true", timeout=5)
time.sleep(3)
try:
uf2_remote = _compile_on_pi(pi_client, fqbn, REPO_FIRMWARE)
except RuntimeError as e:
fail(str(e))
with Step("Restarting Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true")
return
sz_r = pi_client.exec(f"stat -c%s {uf2_remote} 2>/dev/null")["stdout"].strip()
kb = int(sz_r) // 1024 if sz_r.isdigit() else 0
ok(f"Compiled on Pi — {Path(uf2_remote).name} ({kb} KB)")
if not flash:
if uf2_remote:
# nothing to do locally; restart Mixxx
with Step("Restarting Mixxx"):
time.sleep(2)
pi_client.exec("killall mixxx 2>/dev/null; true")
print()
return
# ── Flash path ──────────────────────────────────────────────────────────────
print()
# If compiled remotely, download UF2 locally so pico_flash() can upload it fresh
if uf2_remote and not uf2_local:
with Step("Downloading UF2 from Pi"):
tmp = Path(tempfile.gettempdir()) / Path(uf2_remote).name
tmp.write_bytes(pi_client.read_bytes(uf2_remote))
uf2_local = tmp
with Step("Triggering Pico bootloader"):
result = pico_bootloader(pi_client)
if "failed" in result.lower() or "error" in result.lower():
fail(result)
return
ok(result)
time.sleep(3)
print(pico_flash(pi_client, str(uf2_local)))
print()