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
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
"""
Setup commands: SSH keys, DHCP server, hostname change, SSH recovery guide.
Extracted from xdj-pi-dev.py (lines 1102-1288).
"""
import subprocess
import textwrap
from pathlib import Path
import paramiko
from xdj_pi_dev._terminal import (
_C, Step, section, ok, warn, fail,
)
# Project-specific SSH key (won't affect other SSH usage)
SSH_KEY_PATH = Path.home() / ".ssh" / "xdj_pi_ed25519"
# ─── SSH key setup ────────────────────────────────────────────────────────────
def setup_ssh_keys(pi_client) -> None:
"""
Generate a project-specific ed25519 key pair and install it on the Pi.
After this, the tool authenticates without a password.
"""
section("SSH Key Setup")
# 1. Generate key locally if not present
if SSH_KEY_PATH.exists():
ok(f"Key already exists: {SSH_KEY_PATH}")
else:
print(f" Generating ed25519 key: {SSH_KEY_PATH}")
r = subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", str(SSH_KEY_PATH),
"-N", "", "-C", "xdj-pi-dev"],
capture_output=True, text=True
)
if r.returncode != 0:
fail(f"ssh-keygen failed: {r.stderr.strip()}")
return
ok(f"Key generated: {SSH_KEY_PATH}")
pub_key = (SSH_KEY_PATH.with_suffix(".pub")).read_text().strip()
# 2. Install on Pi
print(f" Installing public key on Pi ({pi_client.host})…")
install_cmd = (
f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
f"grep -qxF '{pub_key}' ~/.ssh/authorized_keys 2>/dev/null || "
f"echo '{pub_key}' >> ~/.ssh/authorized_keys && "
f"chmod 600 ~/.ssh/authorized_keys && echo installed"
)
r2 = pi_client.exec(install_cmd)
if "installed" in r2["stdout"] or r2["rc"] == 0:
ok("Public key installed on Pi")
else:
fail(f"Install failed: {r2['stderr'].strip()}")
return
# 3. Test key auth works before offering to disable password
print(" Testing key auth…")
test_ssh = paramiko.SSHClient()
test_ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
test_ssh.connect(pi_client.host, username=pi_client.user,
key_filename=str(SSH_KEY_PATH),
look_for_keys=False, allow_agent=False, timeout=8)
test_ssh.close()
ok("Key auth confirmed working")
except Exception as e:
fail(f"Key auth test failed: {e}")
print(" Password auth is still active. Fix the key issue before disabling it.")
return
# 4. Offer to disable password auth (skip prompt in TUI — no stdin available)
from xdj_pi_dev._terminal import _tui_log_fn as _tui_fn
if _tui_fn:
ok("Key auth is working. Password auth left enabled (use CLI to disable).")
return
print()
print(" Key auth is working. Optionally disable SSH password auth on Pi")
print(" (more secure — only connections with this key will be accepted).")
answer = input(" Disable password auth now? [y/N] ").strip().lower()
if answer == "y":
r3 = pi_client.exec(
"sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' "
"/etc/ssh/sshd_config && sudo systemctl reload ssh && echo done"
)
if "done" in r3["stdout"]:
ok("Password auth disabled. Only key auth accepted.")
print()
print(" IMPORTANT: Keep the key file safe:")
print(f" {SSH_KEY_PATH}")
print(f" {SSH_KEY_PATH}.pub")
print()
print(" If you lose the key, use --restore-ssh to recover access.")
else:
fail(f"Could not update sshd_config: {r3['stderr'].strip()}")
else:
ok("Password auth left enabled (safe default)")
# ─── SSH recovery guide ───────────────────────────────────────────────────────
def restore_ssh() -> None:
"""Print step-by-step SSH recovery instructions — no network connection required."""
section("SSH Recovery Guide")
print(textwrap.dedent("""
If you're locked out of the Pi (lost key, disabled password auth accidentally):
── Option A: Physical console ─────────────────────────────────────────────
1. Connect a keyboard and HDMI monitor to the Pi.
2. Press Ctrl+Alt+F2 to switch to tty2 (away from Mixxx on tty1).
3. Log in: username xdj100sx, password xdj100sx
4. Re-enable password auth:
sudo nano /etc/ssh/sshd_config
→ Change: PasswordAuthentication no → yes
sudo systemctl reload ssh
5. Press Ctrl+Alt+F1 to return to Mixxx.
── Option B: Edit the SD card on another machine ──────────────────────────
1. Power off the Pi. Remove the SD card.
2. Mount the SD card on your machine (Linux/macOS native; Windows use DiskGenius or WSL).
3. Edit: /etc/ssh/sshd_config
→ PasswordAuthentication yes
4. Reinstall SD card, power on Pi.
── Option C: Install a new key without password auth ──────────────────────
If you have a different SSH key that still works:
1. ssh -i /path/to/other/key xdj100sx@<pi-ip>
2. Then run --setup-ssh-keys to install the project key.
── Recovering the Pi IP if unknown ────────────────────────────────────────
On the Pi console: ip addr show eth0
From this machine: python3 xdj-pi-dev.py --discover
"""))
# ─── Pi DHCP setup ───────────────────────────────────────────────────────────
def setup_pi_dhcp(pi_client) -> None:
"""
Configure the Pi to act as a DHCP server on eth0.
After this, any machine plugged directly into the Pi gets an IP automatically
— no manual network alias setup needed.
"""
section("Pi DHCP Server Setup")
print(" This installs dnsmasq on the Pi and configures it to auto-assign")
print(" IP addresses to machines connected via direct cable.")
print(" Pi's own IP stays at 192.168.10.2.")
print()
# Install dnsmasq
print(" Installing dnsmasq…")
r = pi_client.exec("sudo apt-get install -y dnsmasq 2>&1 | tail -3", timeout=120)
if r["rc"] != 0:
fail(f"apt-get failed: {r['stderr'].strip()}")
return
ok("dnsmasq installed")
# Write config
conf = textwrap.dedent("""\
# XDJ direct-cable DHCP — managed by xdj-pi-dev.py
interface=eth0
bind-interfaces
dhcp-range=192.168.10.100,192.168.10.200,255.255.255.0,12h
# No gateway, no DNS — pure IP assignment for direct cable
dhcp-option=3
dhcp-option=6
""")
r2 = pi_client.exec(
f"sudo mkdir -p /etc/dnsmasq.d && echo '{conf}' | sudo tee /etc/dnsmasq.d/xdj-direct.conf > /dev/null && echo ok"
)
if "ok" not in r2["stdout"]:
fail(f"Could not write dnsmasq config: {r2['stderr'].strip()}")
return
ok("dnsmasq config written (/etc/dnsmasq.d/xdj-direct.conf)")
# Enable and restart
r3 = pi_client.exec(
"sudo systemctl enable dnsmasq && sudo systemctl restart dnsmasq && echo ok"
)
if "ok" not in r3["stdout"]:
fail(f"dnsmasq restart failed: {r3['stderr'].strip()}")
return
ok("dnsmasq running and enabled on boot")
print()
print(" Done. Next time you plug in via direct cable:")
print(" • Your machine will get an IP in 192.168.10.100200 automatically")
print(" • No manual network config needed")
print(" • XDJ100SX.local still works too")
# ─── Hostname change ──────────────────────────────────────────────────────────
def set_hostname(pi_client, new_hostname: str) -> None:
"""Change the Pi's hostname and restart avahi so mDNS updates immediately."""
section(f"Set Hostname → {new_hostname}")
old = pi_client.exec("hostname").get("stdout", "").strip()
print(f" Current hostname: {old}")
r = pi_client.exec(
f"sudo hostnamectl set-hostname {new_hostname} && "
f"sudo sed -i 's/{old}/{new_hostname}/g' /etc/hosts && "
f"sudo systemctl restart avahi-daemon 2>/dev/null; echo ok"
)
if "ok" in r["stdout"]:
ok(f"Hostname changed to: {new_hostname}")
print(f" New mDNS address: {new_hostname}.local")
else:
fail(f"Failed: {r['stderr'].strip()}")