8b0eb42fec
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>
454 lines
18 KiB
Python
454 lines
18 KiB
Python
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()
|