diff --git a/README.md b/README.md index df6fe44..d476776 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,5 @@ Note it only works with the Raspberry Pi 3B+ 2025 Marc Monka 2026 Markus Golec + +2026 Jeancarlo Cardoso de Faria Filho (jaianlab) — Raspberry Pi Pico port, MCP developer tool diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..876fdbd --- /dev/null +++ b/tools/README.md @@ -0,0 +1,165 @@ +# XDJ-100SX Developer Tool + +Claude Code MCP server + CLI + TUI for the XDJ-100SX Pi system. +Push skin files, take screenshots, flash firmware, and manage the Pi — all from your editor. + +--- + +## Requirements + +```bash +pip install paramiko watchdog textual +``` + +--- + +## Quick start + +```bash +python3 tools/xdj-pi-dev.py --check # verify connection + environment +python3 tools/xdj-pi-dev.py --status # live Pi + Mixxx status +python3 tools/xdj-pi-dev.py --ui # interactive TUI +python3 tools/xdj-pi-dev.py --about # authors & credits +python3 tools/xdj-pi-dev.py --help # full command reference +``` + +--- + +## Connection + +The tool tries these in order: + +1. `--host` flag +2. `XDJ_HOST` environment variable +3. `XDJ100SX.local` — mDNS, works on any network (WiFi or LAN) +4. `192.168.10.2` — static IP fallback for direct cable + +### Direct cable (no router) + +One-time setup on your machine: + +```bash +# macOS +sudo ifconfig en0 alias 192.168.10.1 255.255.255.0 + +# Linux +sudo ip addr add 192.168.10.1/24 dev eth0 + +# Windows (run as Administrator) +netsh interface ip set address "Ethernet" static 192.168.10.1 255.255.255.0 +``` + +Then on the Pi (once): + +```bash +python3 tools/xdj-pi-dev.py --setup-pi-dhcp +``` + +After that the Pi hands out IPs automatically — no manual config needed on any machine. + +### Multi-unit + +Each Pi should have a unique hostname: + +```bash +python3 tools/xdj-pi-dev.py --set-hostname xdj-unit2 +``` + +Then from Claude Code: +- `discover_units` — lists all reachable XDJ units on the network +- `select_unit` — switches the active connection to a specific unit + +--- + +## Claude Code MCP setup + +Add to `.mcp.json` in your project root: + +```json +{ + "mcpServers": { + "xdj-pi-dev": { + "command": "python3", + "args": ["tools/xdj-pi-dev.py"] + } + } +} +``` + +Add to `.claude/settings.local.json`: + +```json +{ + "enabledMcpjsonServers": ["xdj-pi-dev"] +} +``` + +Restart Claude Code. Once connected, you can say things like: + +- *"push the skin and take a screenshot"* +- *"change the play button color in style.qss, push and screenshot"* +- *"restart Mixxx and navigate to the beat loop panel"* +- *"flash the firmware"* +- *"find all XDJ units on the network"* + +--- + +## CLI reference + +### Setup + +```bash +--check # preflight: deps, SSH, Mixxx, audio, Pico +--discover # scan network for all Pi units +--setup-pi-dhcp # configure Pi as DHCP server on eth0 +--setup-ssh-keys # passwordless SSH (recommended) +--set-hostname NAME # rename Pi (e.g. xdj-unit2) +--backup-image # backup SD card to .tar archive +--restore-ssh # recovery guide if SSH is locked out +``` + +### Skin development + +```bash +--push # push all skin files to Pi +--push "*.qss" # push only matching files +--pull # pull skin files from Pi to repo +--screenshot # capture Pi display +--screenshot --panel ks # navigate to panel first (hc/bl/bj/ks/st) +--restart # restart Mixxx +--watch # watch, auto-push + screenshot on save +--watch --panel hc # watch with panel navigation +``` + +### MIDI + +```bash +--push-midi # push MIDI mapping to Pi +--pull-midi # pull MIDI mapping from Pi +--midi-mon # stream live MIDI messages (Ctrl-C to stop) +``` + +### Pico firmware + +```bash +--setup-pico-cli # install arduino-cli on Pi (one-time) +--pico-compile # compile firmware on Pi +--pico-bootloader # reset Pico to UF2 bootloader +--pico-flash FILE.uf2 # flash a local .uf2 to Pi +--analyze # live GPIO signal analyzer +``` + +### Other + +```bash +--cmd 'CMD' # run arbitrary SSH command on Pi +--host 192.168.1.42 # target a specific IP or hostname +--ui # open interactive TUI (requires textual) +--about # show authors and credits +``` + +--- + +## Skin development guide + +See [`SKIN_DEV.md`](SKIN_DEV.md) for Mixxx widget types, layout rules, ConfigKeys, QSS constraints, and color palette — everything Claude needs to build and modify skin files correctly. diff --git a/tools/SKIN_DEV.md b/tools/SKIN_DEV.md new file mode 100644 index 0000000..963a102 --- /dev/null +++ b/tools/SKIN_DEV.md @@ -0,0 +1,358 @@ +# XDJ-100SX Skin Development Guide + +Context for Claude Code (via MCP) and human contributors working on the Mixxx skin. + +--- + +## Overview + +The skin runs on a **Raspberry Pi** connected to a **480×272 touchscreen** (landscape). +Mixxx renders the skin as a single-deck player — no second deck, no samplers, no vinyl control. + +The skin files live in `mixxx/SKIN/XDJ100SX/`. Push changes with: + +```bash +python3 tools/xdj-pi-dev.py --push # push all files +python3 tools/xdj-pi-dev.py --push "*.qss" # push only QSS +python3 tools/xdj-pi-dev.py --screenshot # see the result +``` + +Or use `--watch` to auto-push and screenshot on every save. + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `skin.xml` | Root — loads style.qss, sets minimum size, defines launch image | +| `config.xml` | Top-level layout: Day/Night toggle + deck area | +| `deck.xml` | Main deck template — info row, waveform, transport, performance panels | +| `deckminimal.xml` | Minimal deck view (collapsed state) | +| `tab.xml` | Reusable tab button template | +| `topbar.xml` | Top bar — BPM, pitch, track info | +| `waveform.xml` / `waveforms.xml` | Waveform display widgets | +| `overview.xml` | Track overview / position bar | +| `hotcues.xml` | Hot cue performance panel | +| `beatloop.xml` | Beat loop performance panel | +| `beatjump.xml` | Beat jump performance panel | +| `keyshift.xml` | Key shift performance panel | +| `stems.xml` | Stems performance panel | +| `beffect.xml` / `ceffectl.xml` / `ceffectr.xml` | Effect panels | +| `effects.xml` | Effects rack | +| `library.xml` | Library browser panel | +| `style.qss` | All QSS styling | + +--- + +## Layout System + +### Size syntax + +```xml +WIDTH,HEIGHT +``` + +Suffixes: +- `f` — fixed (exact pixels, no stretch) +- `me` — minimum, expands +- `max` — maximum (won't grow beyond this) +- `min` — minimum (won't shrink below this) +- No suffix — exact fixed size + +Examples: +```xml +65f,40f +0me,65max +me,me +``` + +### SizePolicy + +```xml +me,me +f,f +``` + +### Layout + +```xml +vertical +horizontal +``` + +### WidgetStack + +Shows one child at a time based on a ConfigKey value. Used for the performance panel tabs (hotcues, beatloop, etc.): + +```xml + + + ... + ... + + +``` + +### Templates + +Reusable XML fragments with variables: + +```xml + + + + TabButton + + [Tab], + + + + + + + HOT CUE + hotcue + +``` + +--- + +## Widget Types + +### PushButton + +```xml + + MyButton + 65f,40f + 2 + + 0 + OFF + + + + 1 + ON + + + [Channel1],play + + +``` + +### Label + +```xml + + TrackTitle + me,20f + No Track + + [Channel1],title + text + + +``` + +### NumberLabel / NumberDisplay + +For numeric values (BPM, pitch, time): + +```xml + + BPM + 60f,25f + + [Channel1],bpm + + +``` + +### WaveformDisplay + +```xml + + Waveform + me,80f + 1 + #000 + + #ff4444 + +``` + +### Overview (track position bar) + +```xml + + TrackOverview + me,30f + 1 + #111 + #ff0000 + +``` + +### WidgetGroup (container) + +```xml + + MyGroup + horizontal + me,40f + + + + +``` + +--- + +## ConfigKeys + +Format: `[Group],control` + +### Deck controls (single deck — always Channel1) + +| ConfigKey | Description | +|-----------|-------------| +| `[Channel1],play` | Play/pause toggle | +| `[Channel1],cue_default` | Cue button | +| `[Channel1],bpm` | Current BPM | +| `[Channel1],rate` | Pitch/rate slider | +| `[Channel1],volume` | Channel volume | +| `[Channel1],quantize` | Quantize on/off | +| `[Channel1],keylock` | Key lock on/off | +| `[Channel1],sync_enabled` | Sync on/off | +| `[Channel1],loop_enabled` | Loop active | +| `[Channel1],beatloop_size` | Current loop size | +| `[Channel1],hotcue_X_activate` | Trigger hot cue X | +| `[Channel1],hotcue_X_clear` | Clear hot cue X | +| `[Channel1],title` | Track title (text) | +| `[Channel1],artist` | Track artist (text) | +| `[Channel1],track_loaded` | 1 if track is loaded | + +### Tab/panel switching (skin-internal) + +The performance panels are driven by `[Channel2]` filter kill controls repurposed as tab flags — this is a hack to use existing bool ConfigKeys for panel visibility without needing Mixxx scripting: + +```xml + + + [Channel2],filterLowKill + visible + +``` + +**Do not change this pattern** — it would require updating both skin XML and the MIDI mapping script. + +### Skin-internal toggles + +```xml +[Skin],daynight_toggle +``` + +--- + +## QSS Styling + +Standard Qt stylesheet — subset of CSS. Applied via `style.qss`. + +### What works + +- `background-color`, `color`, `border`, `border-radius` +- `font-family`, `font-size`, `font-weight` +- `padding`, `margin` +- `min-width`, `max-width`, `min-height`, `max-height` +- `image: url(skin:/images/file.png)` — skin-relative paths +- State selectors: `WPushButton[value="1"]` — styling when button is active + +### What does NOT work + +- CSS Grid, Flexbox — not Qt +- CSS variables (`--my-var`) — not supported +- Animations / transitions +- `::before` / `::after` pseudo-elements +- `url()` with absolute paths — always use `skin:/` prefix for images +- `rgba()` with 4 args sometimes fails — test on device + +### Object name targeting + +```css +#MyButton { background: #333; } /* by ObjectName */ +WPushButton { border: 1px solid white; } /* by widget type */ +WPushButton[value="1"] { color: red; } /* active state */ +``` + +### Color palette (Pioneer style) + +``` +Play green: #6ee128 +Cue orange: #eb870f +Slip red: #d73535 +Tab yellow: #c3d541 +Header dark: #32323c +Title blue bg: #112f5c +Blue accent: #2d85cd +Text white: #e5e6ea +Background: #000000 +``` + +--- + +## Constraints & Rules + +### Screen + +- **Physical display**: 480×272px +- **Skin minimum**: 480×420 (Mixxx scales to fit, Pi display is rotated/scaled) +- Keep UI elements large enough to be finger-tappable (minimum ~40px touch targets) +- No horizontal scrolling — everything must fit in 480px width + +### Single deck only + +- Always use `[Channel1]` — never `[Channel2]`, `[Channel3]`, `[Channel4]` for actual controls +- `[Channel2]` filter kills are repurposed as panel tab visibility flags — don't use them for audio + +### Performance panels + +The skin has 5 performance panels (tabs): Hot Cue, Beat Loop, Beat Jump, Key Shift, Stems. +They are mutually exclusive (WidgetStack). Do not add a 6th tab without updating: +- The tab button row in `deck.xml` +- The WidgetStack in `deck.xml` +- The MIDI mapping in `XDJ100SX.midi.xml` and `XDJ100SX.js` + +### Images + +- Place in `mixxx/SKIN/XDJ100SX/images/` +- Reference as `skin:/images/filename.png` +- Keep image sizes minimal — Pi SD card and RAM are limited +- PNG preferred; avoid large JPEGs + +### Templates + +- Template files must start with `` as root element +- Variables are set with `value` +- Used at call site with `` + +--- + +## Workflow with Claude Code MCP + +When connected via MCP, Claude can: + +``` +"push the skin and take a screenshot" +"change the play button color to green in style.qss, push and screenshot" +"push only the hotcues.xml file" +"restart Mixxx then screenshot the beat loop panel" +"navigate to keyshift panel and screenshot" +``` + +Claude cannot: +- Modify Mixxx source code +- Change MIDI hardware behavior without updating both `.midi.xml` and `.js` +- Add new Mixxx controls that don't exist in the running Mixxx version +- Test interaction — only static screenshots are available via MCP diff --git a/tools/xdj-pi-dev.py b/tools/xdj-pi-dev.py new file mode 100644 index 0000000..7e354dd --- /dev/null +++ b/tools/xdj-pi-dev.py @@ -0,0 +1,2540 @@ +#!/usr/bin/env python3 +""" +XDJ-100SX Pi Developer Tool v3 +================================= +CLI developer tool + MCP server for Claude Code. +Works on macOS, Linux, and Windows. + +AUTHORS +------- + Marc Monka + Creator of the XDJ-100SX — original concept, firmware, Mixxx skin, and + hardware adaptation of the Pioneer DJ CDJ-100S. Uses the original CDJ-100S + PCB with added microcontroller (Teensy) to interface controls with a + Raspberry Pi running Mixxx. + https://github.com/marcmonka + + Jeancarlo Cardoso de Faria Filho (jaianlab) + Raspberry Pi Pico port (replacing Teensy), MCP server, multi-unit + discovery, developer tooling, and Pi setup automation. + https://github.com/jaianlab + +CREDITS & NOTICES +----------------- + Mixxx + Free, open-source DJ software powering the XDJ-100SX skin and MIDI + mapping on the Pi. Mixxx is a project of the Mixxx Development Team + and contributors, licensed under GPLv2+. This tool interacts with + Mixxx but is not part of the Mixxx project. + + Pioneer DJ + The CDJ-100S enclosure and PCB used in this project are products of + Pioneer DJ Co., Ltd. The XDJ-100SX is an independent community project + and is not affiliated with, endorsed by, or sponsored by Pioneer DJ. + All Pioneer DJ trademarks and product names belong to their respective owners. + + Raspberry Pi + Pi hardware and software trademarks are the property of Raspberry Pi Ltd. + This tool targets Raspberry Pi hardware but is not affiliated with + Raspberry Pi Ltd. + +FIRST RUN: + python3 xdj-pi-dev.py --check # diagnose connection + environment + python3 xdj-pi-dev.py --status # Pi + Mixxx live status + +CONNECTION (tried in order): + 1. --host flag + 2. XDJ_HOST environment variable + 3. XDJ100SX.local (mDNS — works with any network topology) + 4. 192.168.10.2 (static IP fallback) + + Direct cable — one-time client setup: + macOS: sudo ifconfig en0 alias 192.168.10.1 255.255.255.0 + Linux: sudo ip addr add 192.168.10.1/24 dev eth0 + Windows: netsh interface ip set address "Ethernet" static 192.168.10.1 255.255.255.0 + + Or run --setup-pi-dhcp once (on Pi) so the client gets an IP automatically — + no manual configuration needed after that. + +SETUP COMMANDS: + python3 xdj-pi-dev.py --check # pre-flight: deps, connection, Mixxx + python3 xdj-pi-dev.py --setup-pi-dhcp # configure Pi to auto-assign IPs (do once) + python3 xdj-pi-dev.py --setup-ssh-keys # set up key-based SSH auth (more secure) + python3 xdj-pi-dev.py --set-hostname NAME # rename Pi (e.g. xdj-unit2 for multi-unit) + python3 xdj-pi-dev.py --backup-image # backup SD card to .tar archive (used blocks only) + python3 xdj-pi-dev.py --backup-image backup.tar # backup to a specific path + python3 xdj-pi-dev.py --restore-ssh # recovery guide if SSH is locked out + +SKIN DEVELOPMENT: + python3 xdj-pi-dev.py --screenshot # grab screen, open in viewer + python3 xdj-pi-dev.py --screenshot --panel ks # navigate to keyshift first + python3 xdj-pi-dev.py --restart # restart Mixxx + python3 xdj-pi-dev.py --push # push all skin files to Pi + python3 xdj-pi-dev.py --push "*.qss" # push only matching files + python3 xdj-pi-dev.py --pull # pull skin files from Pi to repo + python3 xdj-pi-dev.py --push-midi # push MIDI mapping to Pi + python3 xdj-pi-dev.py --pull-midi # pull MIDI mapping from Pi + python3 xdj-pi-dev.py --midi-mon # stream live MIDI messages (Ctrl-C to stop) + python3 xdj-pi-dev.py --watch [--panel PANEL] # watch, auto-push, screenshot on save + +PICO FIRMWARE: + python3 xdj-pi-dev.py --setup-pico-cli # install arduino-cli on Pi (one-time) + python3 xdj-pi-dev.py --pico-compile # compile firmware on Pi (stops/restarts Mixxx) + python3 xdj-pi-dev.py --pico-compile --pico-bootloader # compile + flash in one step + python3 xdj-pi-dev.py --pico-bootloader # reset Pico to bootloader via MIDI + python3 xdj-pi-dev.py --pico-flash FILE.uf2 # flash a local .uf2 via Pi + python3 xdj-pi-dev.py --analyze # live GPIO signal analyzer (curses dashboard) + +OTHER: + python3 xdj-pi-dev.py --discover # find all Pi units on the network + python3 xdj-pi-dev.py --cmd 'CMD' # run arbitrary SSH command + python3 xdj-pi-dev.py --host 192.168.1.42 # target a specific IP/hostname + +PANEL NAMES (--panel): + hotcue | hc beatloop | bl keyshift | ks beatjump | bj stems | st + +MCP SERVER (Claude Code): + Add to ~/.claude.json under "mcpServers": + "xdj-pi": { + "command": "python3", + "args": ["/absolute/path/to/tools/xdj-pi-dev.py"] + } + Run with no arguments to start in MCP server mode. + +DEPENDENCIES: + pip install paramiko + pip install watchdog # optional — faster file watching (falls back to polling) +""" + +from __future__ import annotations + +import argparse +import base64 +import concurrent.futures +import fnmatch +import itertools +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +from pathlib import Path + +# ─── Dependency check ───────────────────────────────────────────────────────── + +try: + import paramiko +except ImportError: + print("Missing dependency: paramiko") + print("Run: pip install paramiko") + sys.exit(1) + +# ─── Configuration ──────────────────────────────────────────────────────────── + +_DEFAULT_HOSTS = ["XDJ100SX.local", "192.168.10.2"] +_TOOL_ABOUT = (__doc__ or "").split("FIRST RUN")[0].strip() + +PI_USER = os.environ.get("XDJ_USER", "xdj100sx") +PI_PASS = os.environ.get("XDJ_PASS", "xdj100sx") +PI_SKIN = f"/home/{PI_USER}/.mixxx/skins/XDJ100SX" +PI_MIXXX_ENV = f"DISPLAY=:0 XAUTHORITY=/home/{PI_USER}/.Xauthority" +PI_PICO_PORT = "/dev/ttyACM0" + +# SSH key generated by --setup-ssh-keys (project-specific, won't affect other SSH usage) +SSH_KEY_PATH = Path.home() / ".ssh" / "xdj_pi_ed25519" + +REPO_ROOT = Path(__file__).resolve().parent.parent +REPO_SKIN = REPO_ROOT / "mixxx" / "SKIN" / "XDJ100SX" + +PANELS = { + "hotcue": (368, 57), "hc": (368, 57), + "beatloop": (464, 57), "bl": (464, 57), + "keyshift": (560, 57), "ks": (560, 57), + "beatjump": (656, 57), "bj": (656, 57), + "stems": (752, 57), "st": (752, 57), +} + +FILE_PANEL = { + "hotcues.xml": "hotcue", + "beatloop.xml": "beatloop", + "keyshift.xml": "keyshift", + "beatjump.xml": "beatjump", + "stems.xml": "stems", +} + +REPO_MIDI = REPO_ROOT / "mixxx" / "MIDI" +PI_MIDI_DIR = f"/home/{PI_USER}/.mixxx/controllers" +MIDI_FILES = ["XDJ100SX.midi.xml", "XDJ100SX.js"] + + +# ─── Terminal UI ────────────────────────────────────────────────────────────── + +from xdj_pi_dev._terminal import _C, _COLOR, Step, section, ok, warn, fail, _log_line # noqa: F401 +from xdj_pi_dev.messages import MSG +from xdj_pi_dev.config import is_pico_board, get_board +import itertools as _itertools # used by Step spinner (re-exported via _terminal) +# _tui_log_fn is managed in _terminal; expose it here for legacy callers +import xdj_pi_dev._terminal as _term +def _set_tui_log_fn(fn) -> None: _term._tui_log_fn = fn # noqa: ANN001 + +# ─── Platform helpers ───────────────────────────────────────────────────────── + +def open_image(path: Path) -> None: + """Open an image in the default viewer — macOS, Linux, Windows.""" + s = str(path) + if sys.platform == "darwin": + subprocess.run(["open", s], check=False) + elif sys.platform == "win32": + os.startfile(s) + else: + subprocess.run(["xdg-open", s], check=False) + + +def tmp_path(name: str) -> Path: + return Path(tempfile.gettempdir()) / name + + +# ─── Network helpers ────────────────────────────────────────────────────────── + +def _port_open(host: str, port: int = 22, timeout: float = 2.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except (socket.timeout, OSError): + return False + + +def resolve_host(candidates: list[str]) -> str | None: + """Return the first candidate with port 22 reachable.""" + for host in candidates: + if _port_open(host): + return host + return None + + +def discover_units(extra_hosts: list[str] | None = None, subnet: str = "192.168.10") -> list[tuple[str, str]]: + """ + Scan for Pi units: mDNS names first, then subnet sweep. + Returns list of (host, hostname) tuples for reachable units. + """ + candidates: list[str] = list(extra_hosts or []) + candidates += ["XDJ100SX.local"] + [f"XDJ100SX-{i}.local" for i in range(1, 9)] + candidates += [f"{subnet}.{i}" for i in range(1, 255)] + + def check(host: str) -> tuple[str, str] | None: + if not _port_open(host, timeout=0.4): + return None + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(host, username=PI_USER, password=PI_PASS, + timeout=3, allow_agent=False, look_for_keys=False) + _, out, _ = ssh.exec_command("hostname", timeout=3) + hostname = out.read().decode().strip() + ssh.close() + return (host, hostname) + except Exception: + return None + + print("Scanning… (this takes a few seconds)") + found: list[tuple[str, str]] = [] + seen: set[str] = set() + with concurrent.futures.ThreadPoolExecutor(max_workers=60) as pool: + for r in pool.map(check, candidates): + if r and r[0] not in seen: + seen.add(r[0]) + found.append(r) + return found + + +def direct_cable_instructions() -> str: + """Platform-specific instructions for setting up the direct-cable network.""" + if sys.platform == "darwin": + iface = "en0 (or en5/en6 if using a USB-C adapter — check: ifconfig | grep -B1 'inet 192')" + cmd = "sudo ifconfig en0 alias 192.168.10.1 255.255.255.0" + note = "Re-run after each Mac reboot (not persistent)." + elif sys.platform == "win32": + iface = "Ethernet (the interface connected to the Pi)" + cmd = 'netsh interface ip set address "Ethernet" static 192.168.10.1 255.255.255.0' + note = "Replace 'Ethernet' with the exact interface name from: ipconfig /all" + else: + iface = "eth0 (or enp3s0 / ens3 — check: ip link)" + cmd = "sudo ip addr add 192.168.10.1/24 dev eth0" + note = "To make it persistent: add to /etc/network/interfaces or create a NetworkManager connection." + return ( + f"Interface: {iface}\n" + f"Command: {cmd}\n" + f"Note: {note}" + ) + + +# ─── Pi SSH/SFTP client ─────────────────────────────────────────────────────── + +class PiClient: + def __init__(self, host: str, user: str, password: str): + self.host = host + self.user = user + self.password = password + self._ssh: paramiko.SSHClient | None = None + self._sftp: paramiko.SFTPClient | None = None + + def _alive(self) -> bool: + try: + t = self._ssh and self._ssh.get_transport() + return bool(t and t.is_active()) + except Exception: + return False + + def _connect(self) -> None: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + # Try project key first if it exists, then fall back to password + if SSH_KEY_PATH.exists(): + try: + ssh.connect( + self.host, username=self.user, + key_filename=str(SSH_KEY_PATH), + look_for_keys=False, allow_agent=False, timeout=10, + ) + self._ssh = ssh + self._sftp = ssh.open_sftp() + return + except paramiko.AuthenticationException: + pass + + ssh.connect( + self.host, username=self.user, password=self.password, + look_for_keys=False, allow_agent=False, timeout=10, + ) + self._ssh = ssh + self._sftp = ssh.open_sftp() + + def _ok(self) -> None: + if not self._alive(): + self._connect() + + def exec(self, command: str, timeout: int = 30) -> dict: + self._ok() + assert self._ssh + _, stdout, stderr = self._ssh.exec_command(command, timeout=timeout) + out = stdout.read().decode(errors="replace") + err = stderr.read().decode(errors="replace") + rc = stdout.channel.recv_exit_status() + return {"stdout": out, "stderr": err, "rc": rc} + + def read_text(self, path: str) -> str: + self._ok() + assert self._sftp + with self._sftp.open(path, "rb") as f: + return f.read().decode(errors="replace") + + def read_bytes(self, path: str) -> bytes: + self._ok() + assert self._sftp + with self._sftp.open(path, "rb") as f: + return f.read() + + def write_text(self, path: str, content: str | bytes) -> None: + self.write_bytes(path, content.encode() if isinstance(content, str) else content) + + def write_bytes(self, path: str, data: bytes) -> None: + self._ok() + assert self._sftp + with self._sftp.open(path, "wb") as f: + f.write(data) + + def listdir(self, path: str) -> list[str]: + self._ok() + assert self._sftp + return sorted(self._sftp.listdir(path)) + + def exec_stream(self, command: str, timeout: int = 600) -> int: + """ + Run command and feed each line through _log_line() as it arrives. + Returns the exit code. + """ + self._ok() + assert self._ssh + transport = self._ssh.get_transport() + assert transport + channel = transport.open_session() + channel.set_combine_stderr(True) + channel.exec_command(command) + + buf = b"" + while True: + if channel.recv_ready(): + buf += channel.recv(4096) + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + _log_line(line.decode(errors="replace").rstrip()) + elif channel.exit_status_ready() and not channel.recv_ready(): + break + else: + time.sleep(0.05) + + if buf.strip(): + _log_line(buf.decode(errors="replace").rstrip()) + + return channel.recv_exit_status() + + +# Module-level client — set by init_client(); None until first successful connection +pi: PiClient | None = None + + +def init_client(host_override: str | None = None) -> str: + """Resolve target host, create the module-level PiClient, return host used.""" + global pi, PI_USER, PI_PASS + from xdj_pi_dev.config import get_ssh_user, get_ssh_pass + PI_USER = get_ssh_user() + PI_PASS = get_ssh_pass() + + if host_override: + candidates = [host_override] + else: + env = os.environ.get("XDJ_HOST") + candidates = [env] if env else _DEFAULT_HOSTS + + host = resolve_host(candidates) + if not host: + tried = ", ".join(candidates) + msg = MSG["cant_reach_pi_detail"].format( + tried=tried, + instructions=textwrap.indent(direct_cable_instructions(), " "), + ) + # Raise so TUI worker catches it — sys.exit kills the whole TUI process + raise ConnectionError(msg) + + pi = PiClient(host, PI_USER, PI_PASS) + return host + + +# ─── Panel navigation ───────────────────────────────────────────────────────── + +def navigate_panel(panel_key: str) -> None: + key = panel_key.lower().strip() + coords = PANELS.get(key) + if not coords: + return + x, y = coords + pi.exec(f"{PI_MIXXX_ENV} xdotool mousemove {x} {y} click 1", timeout=5) + time.sleep(0.4) + + +# ─── Actions ────────────────────────────────────────────────────────────────── + +def restart_mixxx(panel: str | None = None) -> str: + pi.exec("killall mixxx 2>/dev/null; true") + time.sleep(6) + result = pi.exec("pgrep -a mixxx") + pid = result["stdout"].strip() + msg = (f"Mixxx restarted (PID {pid.split()[0]})" if pid + else "WARNING: Mixxx PID not found — may still be starting") + if panel: + time.sleep(1) + navigate_panel(panel) + return msg + + +def take_screenshot(panel: str | None = None) -> tuple[bytes | None, str | None]: + """Navigate to `panel` then capture. Returns (bytes, None) or (None, error).""" + if panel: + navigate_panel(panel) + pi.exec("rm -f /tmp/xdj_dev_screen.png") + r = pi.exec(f"{PI_MIXXX_ENV} scrot /tmp/xdj_dev_screen.png 2>&1", timeout=10) + if r["rc"] != 0: + return None, (r["stdout"] + r["stderr"]).strip() + return pi.read_bytes("/tmp/xdj_dev_screen.png"), None + + +def _history_dir(base: Path, direction: str, ts: str) -> Path: + """direction: 'pushed' or 'pulled'. History lives inside the reference subject.""" + return base / ".history" / direction / ts + + +def push_skin(pattern: str = "*", backup_remote: bool = False) -> list[str]: + ts = time.strftime("%Y%m%d-%H%M%S") + hist = _history_dir(REPO_SKIN.parent, "pushed", ts) + pushed = [] + for f in sorted(REPO_SKIN.rglob("*")): + if f.is_file() and fnmatch.fnmatch(f.name, pattern): + rel = f.relative_to(REPO_SKIN) + remote = f"{PI_SKIN}/{rel.as_posix()}" + if backup_remote: + pi.exec(f"cp -p '{remote}' '{remote}.bak-{ts}' 2>/dev/null || true") + data = f.read_bytes() + hist.mkdir(parents=True, exist_ok=True) + (hist / f.name).write_bytes(data) + pi.write_bytes(remote, data) + pushed.append(str(rel)) + return pushed + + +def push_midi(backup_remote: bool = False) -> list[str]: + """Push MIDI mapping files from docs/midi/ to ~/.mixxx/controllers/ on Pi.""" + ts = time.strftime("%Y%m%d-%H%M%S") + hist = _history_dir(REPO_MIDI, "pushed", ts) + pushed = [] + for fname in MIDI_FILES: + local = REPO_MIDI / fname + if not local.exists(): + warn(f"Not found locally: {local}") + continue + if backup_remote: + pi.exec(f"cp -p '{PI_MIDI_DIR}/{fname}' '{PI_MIDI_DIR}/{fname}.bak-{ts}' 2>/dev/null || true") + data = local.read_bytes() + hist.mkdir(parents=True, exist_ok=True) + (hist / fname).write_bytes(data) + pi.write_bytes(f"{PI_MIDI_DIR}/{fname}", data) + pushed.append(fname) + return pushed + + +def pull_midi(backup: bool = False) -> list[str]: + """Pull MIDI mapping files from ~/.mixxx/controllers/ on Pi to docs/midi/.""" + ts = time.strftime("%Y%m%d-%H%M%S") + hist = _history_dir(REPO_MIDI, "pulled", ts) + pulled = [] + for fname in MIDI_FILES: + remote = f"{PI_MIDI_DIR}/{fname}" + try: + data = pi.read_bytes(remote) + local = REPO_MIDI / fname + if backup and local.exists(): + local.rename(local.parent / f"{local.name}.bak-{ts}") + hist.mkdir(parents=True, exist_ok=True) + (hist / fname).write_bytes(data) + local.write_bytes(data) + pulled.append(fname) + except Exception as e: + warn(f"Skipped {fname}: {e}") + return pulled + + +def pull_skin(backup: bool = False) -> list[str]: + ts = time.strftime("%Y%m%d-%H%M%S") + hist = _history_dir(REPO_SKIN.parent, "pulled", ts) + pulled = [] + for fname in pi.listdir(PI_SKIN): + remote = f"{PI_SKIN}/{fname}" + try: + data = pi.read_bytes(remote) + local = REPO_SKIN / fname + if backup and local.exists(): + local.rename(local.parent / f"{local.name}.bak-{ts}") + hist.mkdir(parents=True, exist_ok=True) + (hist / fname).write_bytes(data) + local.write_bytes(data) + pulled.append(fname) + except Exception as e: + warn(f"Skipped {fname}: {e}") + return pulled + + +# ─── Pico firmware tools ────────────────────────────────────────────────────── + +from xdj_pi_dev.pico_tools import pico_bootloader, pico_flash # noqa: F401 + +# ─── MIDI monitor ───────────────────────────────────────────────────────────── + +from xdj_pi_dev.midi import midi_monitor # noqa: F401 + +# ─── Signal Analyzer ───────────────────────────────────────────────────────── + +from xdj_pi_dev.signal_analyzer import ( + SA_PIN_NAMES, SA_BUTTON_PINS, SA_JOG_PINS, SA_BROWSE_PINS, SA_LED_PINS, SA_ALL_PINS, + run_signal_analyzer_web, run_signal_analyzer_cli, +) + + +# ─── Pico compile ───────────────────────────────────────────────────────────── + +from xdj_pi_dev.pico_tools import ( + setup_pico_cli, pico_compile, DEFAULT_BOARD, BOARD_PROFILES, # noqa: F401 + REPO_FIRMWARE, +) + +# ─── Setup commands ─────────────────────────────────────────────────────────── + +from xdj_pi_dev.setup_cmds import ( # noqa: F401 + setup_ssh_keys, restore_ssh, setup_pi_dhcp, set_hostname, +) + +# ─── Pi image backup ────────────────────────────────────────────────────────── + +from xdj_pi_dev.backup import backup_pi_image, verify_backup # noqa: F401 + +# ─── Watch mode ─────────────────────────────────────────────────────────────── + +from xdj_pi_dev.watch import watch_mode, _watch_loop # noqa: F401 + +# ─── Pre-flight check ───────────────────────────────────────────────────────── + +def run_check(host_override: str | None = None) -> None: + section("XDJ Pi Developer Tool — Pre-flight Check") + + # 1. Python version + pv = sys.version_info + if pv >= (3, 8): + ok(f"Python {pv.major}.{pv.minor}.{pv.micro}") + else: + fail(f"Python {pv.major}.{pv.minor} — need 3.8+") + + # 2. paramiko + ok(f"paramiko {paramiko.__version__}") + + # 3. watchdog (optional) + try: + import watchdog + ver = getattr(watchdog, "__version__", "installed") + ok(f"watchdog {ver} (faster file watching)") + except ImportError: + warn("watchdog not installed — watch mode uses 1s polling. pip install watchdog") + + # 4. ssh-keygen + if shutil.which("ssh-keygen"): + ok("ssh-keygen found") + else: + warn("ssh-keygen not found — --setup-ssh-keys won't work") + + # 5. SSH key + if SSH_KEY_PATH.exists(): + ok(f"Project SSH key: {SSH_KEY_PATH}") + else: + warn(f"No project SSH key yet — run --setup-ssh-keys for passwordless auth") + + # 6. Repo skin dir + if REPO_SKIN.exists(): + files = list(REPO_SKIN.glob("*.xml")) + list(REPO_SKIN.glob("*.qss")) + ok(f"Repo skin dir: {REPO_SKIN} ({len(files)} skin files)") + else: + fail(f"Repo skin dir not found: {REPO_SKIN}") + + print() + section("Network") + + # 7. Resolve Pi + if host_override: + candidates = [host_override] + else: + env = os.environ.get("XDJ_HOST") + candidates = [env] if env else _DEFAULT_HOSTS + + print(f" Trying: {', '.join(candidates)}") + host = resolve_host(candidates) + if not host: + fail("Pi not reachable on port 22") + print() + print(" If using a direct cable, set up the network alias:") + print(textwrap.indent(direct_cable_instructions(), " ")) + print() + print(" Or run --setup-pi-dhcp after connecting via any route.") + return + + ok(f"Pi reachable at: {host}") + + global PI_USER, PI_PASS + pi_c = PiClient(host, PI_USER, PI_PASS) + global pi + pi = pi_c + + # 8. SSH auth + try: + r = pi.exec("echo ssh_ok", timeout=8) + if "ssh_ok" in r["stdout"]: + auth_method = "key" if SSH_KEY_PATH.exists() else "password" + ok(f"SSH connected ({auth_method} auth)") + else: + fail("SSH connected but shell not responding") + return + except Exception as e: + fail(f"SSH auth failed: {e}") + print(" Check username/password or run --setup-ssh-keys") + return + + print() + section("Pi Status") + + # 9. Mixxx + r = pi.exec("pgrep -a mixxx") + if r["stdout"].strip(): + pid = r["stdout"].strip().split()[0] + ok(f"Mixxx running (PID {pid})") + else: + warn("Mixxx not running") + + # 10. Display + r = pi.exec(f"{PI_MIXXX_ENV} xdotool getactivewindow 2>/dev/null && echo display_ok || echo display_fail") + if "display_ok" in r["stdout"]: + ok("Display reachable (xdotool works)") + else: + warn("Display not reachable — screenshot/navigate won't work") + + # 11. scrot + r = pi.exec("which scrot") + if r["rc"] == 0: + ok("scrot installed (screenshots work)") + else: + fail("scrot not installed on Pi — run: sudo apt install scrot") + + # 12. Audio + r = pi.exec("aplay -l 2>/dev/null | grep card") + if r["stdout"].strip(): + for line in r["stdout"].strip().splitlines(): + ok(f"Audio: {line.strip()}") + else: + warn("No ALSA sound cards found") + + # 13. MIDI (Pico) + r = pi.exec("ls /dev/ttyACM* 2>/dev/null || amidi -l 2>/dev/null | grep XDJ || echo none") + if "none" not in r["stdout"]: + ok(f"MIDI/serial device: {r['stdout'].strip()}") + else: + warn("No Pico/MIDI device found — check USB connection") + + # 14. USB media + r = pi.exec("ls /media/ 2>/dev/null") + if r["stdout"].strip(): + ok(f"USB media: /media/{r['stdout'].strip()}") + else: + warn("No USB media mounted (normal if no stick inserted)") + + # 15. mDNS + r = pi.exec("hostname") + hostname = r["stdout"].strip() + ok(f"Pi hostname: {hostname} ({hostname}.local via mDNS)") + + # 16. dnsmasq (Pi DHCP) + r = pi.exec("systemctl is-active dnsmasq 2>/dev/null || echo inactive") + if r["stdout"].strip() == "active": + ok("dnsmasq running (Pi auto-assigns IPs to connected machines)") + else: + warn("dnsmasq not running — connected machines may need manual IP setup") + print(" Run --setup-pi-dhcp to fix this") + + print() + print(f" Connected to: {host}") + print() + + +# ─── CLI status ─────────────────────────────────────────────────────────────── + +def cli_status() -> None: + r = pi.exec( + "echo '=== Pi ===' && hostname && ip addr show eth0 | grep 'inet ' && " + "echo '=== Mixxx ===' && pgrep -a mixxx && " + "echo '=== Audio ===' && aplay -l 2>/dev/null | grep card && " + "echo '=== USB ===' && ls /media/ 2>/dev/null && " + "echo '=== Pico ===' && ls /dev/ttyACM* 2>/dev/null || true" + ) + print(f"Connected to: {pi.host}") + print(r["stdout"]) + + +# ─── MCP server ─────────────────────────────────────────────────────────────── + +MCP_TOOLS = [ + { + "name": "run_command", + "description": "Run a shell command on the Pi via SSH.", + "inputSchema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + { + "name": "read_file", + "description": "Read a text file from the Pi.", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "Absolute path on Pi"}}, + "required": ["path"], + }, + }, + { + "name": "write_file", + "description": "Write a text file to the Pi.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, + { + "name": "list_files", + "description": "List files in a Pi directory (default: skin dir).", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + }, + { + "name": "restart_mixxx", + "description": "Kill Mixxx and wait for xinitrc to restart it.", + "inputSchema": { + "type": "object", + "properties": { + "panel": { + "type": "string", + "description": "Navigate to this panel after restart", + } + }, + }, + }, + { + "name": "take_screenshot", + "description": "Capture the Pi display. Pass panel to navigate first.", + "inputSchema": { + "type": "object", + "properties": { + "panel": { + "type": "string", + "description": "Panel tab to show before screenshotting", + } + }, + }, + }, + { + "name": "navigate_panel", + "description": "Click a skin panel tab.", + "inputSchema": { + "type": "object", + "properties": {"panel": {"type": "string"}}, + "required": ["panel"], + }, + }, + { + "name": "push_skin_file", + "description": "Upload one skin file from the local repo to the Pi.", + "inputSchema": { + "type": "object", + "properties": {"filename": {"type": "string"}}, + "required": ["filename"], + }, + }, + { + "name": "pull_skin_file", + "description": "Download one skin file from the Pi to the local repo.", + "inputSchema": { + "type": "object", + "properties": {"filename": {"type": "string"}}, + "required": ["filename"], + }, + }, + { + "name": "push_skin", + "description": "Push all local skin files (or a glob subset) to the Pi.", + "inputSchema": { + "type": "object", + "properties": { + "glob": {"type": "string", "description": "Filename glob, e.g. '*.qss'. Omit for all files."}, + }, + }, + }, + { + "name": "pull_skin", + "description": "Pull all skin files from Pi to local repo.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "push_midi", + "description": "Push local MIDI mapping files to Pi controllers directory.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "pull_midi", + "description": "Pull MIDI mapping files from Pi to local midi directory.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "check", + "description": "Run preflight: verify SSH connection, Mixxx status, Pico MIDI visibility.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "pico_bootloader", + "description": "Reset Pico into UF2 bootloader mode (MIDI trigger).", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "pico_flash", + "description": "Flash a .uf2 file to the Pico.", + "inputSchema": { + "type": "object", + "properties": {"local_path": {"type": "string"}}, + "required": ["local_path"], + }, + }, + { + "name": "discover_units", + "description": "Scan the network for all reachable XDJ Pi units. Returns hostnames and IPs.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "select_unit", + "description": "Switch the active connection to a specific XDJ Pi unit by hostname or IP.", + "inputSchema": { + "type": "object", + "properties": {"host": {"type": "string", "description": "Hostname or IP of the unit to connect to"}}, + "required": ["host"], + }, + }, +] + + +def _call_tool(name: str, args: dict) -> list[dict]: + if name == "run_command": + r = pi.exec(args["command"]) + parts = [] + if r["stdout"]: parts.append(r["stdout"].rstrip()) + if r["stderr"]: parts.append(f"[stderr] {r['stderr'].rstrip()}") + parts.append(f"[exit {r['rc']}]") + return [{"type": "text", "text": "\n".join(parts)}] + + if name == "read_file": + return [{"type": "text", "text": pi.read_text(args["path"])}] + + if name == "write_file": + pi.write_text(args["path"], args["content"]) + return [{"type": "text", "text": f"Written: {args['path']}"}] + + if name == "list_files": + files = pi.listdir(args.get("path", PI_SKIN)) + return [{"type": "text", "text": "\n".join(files)}] + + if name == "restart_mixxx": + return [{"type": "text", "text": restart_mixxx(panel=args.get("panel"))}] + + if name == "take_screenshot": + data, err = take_screenshot(panel=args.get("panel")) + if err: + return [{"type": "text", "text": f"Screenshot failed: {err}"}] + return [{"type": "image", "data": base64.b64encode(data).decode(), "mimeType": "image/png"}] + + if name == "navigate_panel": + navigate_panel(args["panel"]) + return [{"type": "text", "text": f"Navigated to {args['panel']}"}] + + if name == "push_skin_file": + fname = args["filename"] + local = REPO_SKIN / fname + if not local.exists(): + return [{"type": "text", "text": f"Not found locally: {local}"}] + pi.write_bytes(f"{PI_SKIN}/{fname}", local.read_bytes()) + return [{"type": "text", "text": f"Pushed {fname} → Pi"}] + + if name == "pull_skin_file": + fname = args["filename"] + data = pi.read_bytes(f"{PI_SKIN}/{fname}") + local = REPO_SKIN / fname + local.parent.mkdir(parents=True, exist_ok=True) + local.write_bytes(data) + return [{"type": "text", "text": f"Pulled {fname} → {local}"}] + + if name == "push_skin": + pushed = push_skin(glob=args.get("glob", "*")) + return [{"type": "text", "text": f"Pushed {len(pushed)} file(s): {', '.join(pushed)}"}] + + if name == "pull_skin": + pulled = pull_skin() + return [{"type": "text", "text": f"Pulled {len(pulled)} file(s): {', '.join(pulled)}"}] + + if name == "push_midi": + pushed = push_midi() + return [{"type": "text", "text": f"Pushed {len(pushed)} MIDI file(s): {', '.join(pushed)}"}] + + if name == "pull_midi": + pulled = pull_midi() + return [{"type": "text", "text": f"Pulled {len(pulled)} MIDI file(s): {', '.join(pulled)}"}] + + if name == "check": + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + run_check() + return [{"type": "text", "text": buf.getvalue()}] + + if name == "pico_bootloader": + return [{"type": "text", "text": pico_bootloader(pi)}] + + if name == "pico_flash": + return [{"type": "text", "text": pico_flash(pi, args["local_path"])}] + + if name == "discover_units": + units = discover_units() + if not units: + return [{"type": "text", "text": "No XDJ Pi units found on the network."}] + lines = "\n".join(f" {host} ({hostname})" for host, hostname in units) + return [{"type": "text", "text": f"Found {len(units)} unit(s):\n{lines}"}] + + if name == "select_unit": + host = init_client(host_override=args["host"]) + return [{"type": "text", "text": f"Switched active connection to: {host}"}] + + return [{"type": "text", "text": f"Unknown tool: {name}"}] + + +def _mcp_send(obj: dict) -> None: + sys.stdout.buffer.write(json.dumps(obj).encode() + b"\n") + sys.stdout.buffer.flush() + + +def run_mcp_server() -> None: + print("XDJ Pi MCP server started.", file=sys.stderr) + print("For CLI use: python3 xdj-pi-dev.py --help", file=sys.stderr) + for line in sys.stdin.buffer: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + method = msg.get("method", "") + rid = msg.get("id") + + if method == "initialize": + _mcp_send({ + "jsonrpc": "2.0", "id": rid, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "xdj-pi-dev", "version": "3.0.0"}, + }, + }) + elif method == "notifications/initialized": + pass + elif method == "tools/list": + _mcp_send({"jsonrpc": "2.0", "id": rid, "result": {"tools": MCP_TOOLS}}) + elif method == "tools/call": + p = msg.get("params", {}) + try: + content = _call_tool(p.get("name", ""), p.get("arguments", {})) + _mcp_send({"jsonrpc": "2.0", "id": rid, "result": {"content": content}}) + except Exception as exc: + _mcp_send({ + "jsonrpc": "2.0", "id": rid, + "error": {"code": -32000, "message": str(exc)}, + }) + elif rid is not None: + _mcp_send({ + "jsonrpc": "2.0", "id": rid, + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + }) + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +# ─── Textual TUI ────────────────────────────────────────────────────────────── + +try: + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical, ScrollableContainer + from textual.screen import ModalScreen + from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Static + from textual import work as _twork + _TEXTUAL = True +except ImportError: + _TEXTUAL = False + + +if _TEXTUAL: + from textual.widgets import Select # noqa: E402 + + _BOARD_META = { + "pico": ("Raspberry Pi Pico", "Full support — recommended"), + "pico2": ("Raspberry Pi Pico 2", "Full support"), + "teensy": ("Teensy 4.x", "Skin & MIDI only (design phase)"), + "unknown": ("Other / unknown", "Skin & MIDI tools only"), + } + + _BOARD_SPECS = { + "pico": "MCU: RP2040 · 133 MHz\nRAM: 264 KB · Flash: 2 MB\nUSB boot (UF2)", + "pico2": "MCU: RP2350 · 150 MHz\nRAM: 520 KB · Flash: 4 MB\nUSB boot (UF2)", + "teensy": "MCU: iMXRT1062 · 600 MHz\nRAM: 1 MB · Flash: 2 MB\nUSB native", + "unknown": "Use if you're not sure.\nSkin & MIDI tools work\nfor any board.", + } + + class BoardSelectScreen(ModalScreen): # type: ignore[misc] + """Board selection screen — shows board images with background removed.""" + + DEFAULT_CSS = """ + BoardSelectScreen { align: center middle; } + + #board-dialog { + background: $panel; + border: solid $primary; + padding: 1 2; + width: 88; + height: auto; + } + #board-title { + width: 100%; + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + #board-cards { + layout: horizontal; + height: auto; + width: 100%; + } + .board-card { + width: 1fr; + height: auto; + border: tall $panel-lighten-3; + padding: 0 1; + margin: 0 1; + background: $panel-lighten-1; + } + .board-card.-selected { + border: tall $primary; + background: $panel-lighten-2; + } + .board-specs { + width: 100%; + color: $text-muted; + margin-bottom: 1; + height: 5; + } + .board-name { + width: 100%; + text-align: center; + text-style: bold; + color: $text; + } + .board-desc { + width: 100%; + text-align: center; + color: $text-muted; + } + .btn-sel { + width: 100%; + margin-top: 1; + } + #board-footer { + width: 100%; + align: right middle; + height: 3; + margin-top: 1; + } + """ + + def __init__(self, current: str = "pico") -> None: + super().__init__() + self._selected = current + + def compose(self) -> ComposeResult: + with Vertical(id="board-dialog"): + yield Label("What's inside your DJ unit?", id="board-title") + with Horizontal(id="board-cards"): + for key, (name, desc) in _BOARD_META.items(): + is_sel = key == self._selected + card_cls = "board-card" + (" -selected" if is_sel else "") + with Vertical(classes=card_cls, id=f"card-{key}"): + yield Static( + _BOARD_SPECS.get(key, ""), + classes="board-specs", + ) + yield Label(name, classes="board-name") + yield Label(desc, classes="board-desc") + yield Button( + "✓ Selected" if is_sel else "Select", + id=f"btn-sel-{key}", + variant="primary" if is_sel else "default", + classes="btn-sel", + ) + with Horizontal(id="board-footer"): + yield Button("Save & Continue →", id="btn-board-save", variant="success") + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + bid = event.button.id or "" + + if bid == "btn-board-save": + from xdj_pi_dev.config import update_config + update_config(board=self._selected) + self.dismiss(self._selected) + + elif bid.startswith("btn-sel-"): + self._select(bid[len("btn-sel-"):]) + + def _select(self, key: str) -> None: + if key == self._selected: + return + # Deselect old + try: + self.query_one(f"#card-{self._selected}").remove_class("-selected") + old_btn = self.query_one(f"#btn-sel-{self._selected}", Button) + old_btn.label = "Select" + old_btn.variant = "default" + except Exception: + pass + # Select new + self._selected = key + try: + self.query_one(f"#card-{key}").add_class("-selected") + new_btn = self.query_one(f"#btn-sel-{key}", Button) + new_btn.label = "✓ Selected" + new_btn.variant = "primary" + except Exception: + pass + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) + + class ConfirmScreen(ModalScreen): # type: ignore[misc] + """Generic two-option confirm modal.""" + + def __init__(self, title: str, ok_label: str, ok_key: str, alt_label: str, alt_key: str) -> None: + super().__init__() + self._title = title + self._ok_label = ok_label + self._alt_label = alt_label + self._ok_key = ok_key + self._alt_key = alt_key + + def compose(self) -> ComposeResult: + with Vertical(id="confirm-box"): + yield Label(self._title) + yield Button(self._ok_label, id="cb-ok", variant="primary") + yield Button(self._alt_label, id="cb-alt") + yield Button("Cancel", id="cb-cancel", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() # prevent bubbling to XDJApp.on_button_pressed + if event.button.id == "cb-ok": self.dismiss(self._ok_key) + elif event.button.id == "cb-alt": self.dismiss(self._alt_key) + elif event.button.id == "cb-cancel": self.dismiss(None) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) + + class SettingsScreen(ModalScreen): # type: ignore[misc] + """Full settings: SSH credentials + board selection.""" + + DEFAULT_CSS = """ + SettingsScreen { align: center middle; } + #settings-dialog { + background: $panel; + border: solid $primary; + padding: 1 2; + width: 60; + height: auto; + } + #settings-title { + width: 100%; + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + .settings-section { + color: $primary; + text-style: bold; + margin-top: 1; + } + .settings-row { height: 3; margin-bottom: 0; } + .field-label { width: 12; color: $text-muted; padding-top: 1; } + .field-input { width: 1fr; } + #settings-board { width: 100%; margin-top: 1; margin-bottom: 0; } + #settings-footer { + width: 100%; + align: right middle; + height: 3; + margin-top: 1; + } + #settings-footer Button { width: auto; min-width: 16; margin-left: 1; } + """ + + def __init__(self) -> None: + super().__init__() + from xdj_pi_dev.config import load_config + cfg = load_config() + self._init_user = cfg.get("ssh_user", "") + self._init_pass = cfg.get("ssh_pass", "") + self._init_board = cfg.get("board", "pico") + + def compose(self) -> ComposeResult: + board_options = [(_BOARD_META[k][0], k) for k in _BOARD_META] + with Vertical(id="settings-dialog"): + yield Label("Settings", id="settings-title") + yield Label("SSH CREDENTIALS", classes="settings-section") + with Horizontal(classes="settings-row"): + yield Label("Username:", classes="field-label") + yield Input( + value=self._init_user, + placeholder="xdj100sx", + id="inp-user", + classes="field-input", + ) + with Horizontal(classes="settings-row"): + yield Label("Password:", classes="field-label") + yield Input( + value=self._init_pass, + placeholder="xdj100sx", + password=True, + id="inp-pass", + classes="field-input", + ) + yield Label("BOARD", classes="settings-section") + yield Select(board_options, id="settings-board", value=self._init_board) + with Horizontal(id="settings-footer"): + yield Button("Save & Close", id="btn-st-save", variant="success") + yield Button("Cancel", id="btn-st-cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.id == "btn-st-save": + from xdj_pi_dev.config import update_config + user = self.query_one("#inp-user", Input).value.strip() + passwd = self.query_one("#inp-pass", Input).value.strip() + bsel = self.query_one("#settings-board", Select) + board = str(bsel.value) if bsel.value and bsel.value is not Select.BLANK else "pico" + update_config(ssh_user=user or None, ssh_pass=passwd or None, board=board) + self.dismiss(board) + elif event.button.id == "btn-st-cancel": + self.dismiss(None) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) + + class AboutScreen(ModalScreen): # type: ignore[misc] + """Authors, credits, and legal notices.""" + + DEFAULT_CSS = """ + AboutScreen { align: center middle; } + #about-dialog { + background: $panel; + border: solid $primary; + padding: 1 2; + width: 72; + height: auto; + max-height: 44; + } + #about-title { + width: 100%; + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + #about-body { + height: 30; + overflow-y: auto; + } + #about-footer { + width: 100%; + align: center middle; + height: 3; + margin-top: 1; + } + """ + + _ABOUT = _TOOL_ABOUT + + def compose(self) -> ComposeResult: + with Vertical(id="about-dialog"): + yield Static("XDJ-100SX Pi Developer Tool", id="about-title") + with ScrollableContainer(id="about-body"): + yield Static(self._ABOUT) + with Horizontal(id="about-footer"): + yield Button("Close", id="btn-about-close", variant="primary") + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.id == "btn-about-close": + self.dismiss() + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss() + + class HelpScreen(ModalScreen): # type: ignore[misc] + """First-connection guide — how to reach the Pi.""" + + DEFAULT_CSS = """ + HelpScreen { align: center middle; } + #help-dialog { + background: $panel; + border: solid $primary; + padding: 1 2; + width: 72; + height: auto; + max-height: 44; + } + #help-title { + width: 100%; + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + #help-body { + height: 30; + overflow-y: auto; + } + #help-footer { + width: 100%; + align: center middle; + height: 3; + margin-top: 1; + } + """ + + _GUIDE = """\ +━━━ OPTION 1 — XDJ100SX.local [dim](any network, WiFi or LAN)[/] ━━━ + +Pi and your computer must be on the same network. + +[bold]Pi side[/] — needs avahi-daemon (check it's running): +[dim] sudo systemctl enable --now avahi-daemon[/] +[dim] sudo apt install -y avahi-daemon ← if not installed[/] + +[bold]macOS[/] — Bonjour is built-in. Nothing to install. + +[bold]Windows 10/11[/] — mDNS is built-in but Windows Firewall + often blocks it. If XDJ100SX.local doesn't resolve: + • Use the IP address (192.168.10.2) instead, or + • Install [bold]Bonjour Print Services for Windows[/] (free, from Apple) + +[bold]Linux[/] — install avahi-daemon on your machine too: +[dim] sudo apt install avahi-daemon[/] + + +━━━ OPTION 2 — 192.168.10.2 [dim](direct Ethernet cable)[/] ━━━ + +No router needed. Faster and more stable for development. + +[bold]PI SIDE — configure static IP[/] (skip if already done): +[dim] sudo nmcli con mod eth0 \\[/] +[dim] ipv4.method manual \\[/] +[dim] ipv4.addresses 192.168.10.2/24 \\[/] +[dim] ipv4.gateway "" ipv4.dns "" \\[/] +[dim] connection.autoconnect yes[/] +[dim] sudo nmcli con up eth0[/] + +[bold]YOUR MACHINE — set a matching IP on the cable port:[/] + +macOS (once per reboot — check interface with: ifconfig | grep -B2 'status: active'): +[dim] sudo ifconfig en0 alias 192.168.10.1 255.255.255.0[/] + +Windows (run as Administrator, once per reboot): +[dim] netsh interface ip set address "Ethernet" static 192.168.10.1 255.255.255.0[/] +[dim] ← replace "Ethernet" with your interface name (check: ipconfig /all)[/] + +Linux: +[dim] sudo ip addr add 192.168.10.1/24 dev eth0[/] + +Or run [bold]--setup-pi-dhcp[/] once — Pi assigns IPs automatically, +no local config needed ever again. + + +━━━ OPTION 3 — Custom IP or hostname ━━━ + +Type any address in the top field and press Enter. +It gets added to the dropdown for quick switching. + + +━━━ IT'S NOT CONNECTING? ━━━ + +[bold]1. Enable SSH on the Pi[/] (run on the Pi itself or via its keyboard): +[dim] sudo systemctl enable ssh && sudo systemctl start ssh[/] + +[bold]2. Allow password login[/] (newer Pi OS blocks it by default): +[dim] sudo nano /etc/ssh/sshd_config[/] +[dim] → find or add: PasswordAuthentication yes[/] +[dim] sudo systemctl restart ssh[/] + +[bold]3. Wrong password?[/] Open Settings (⚙) → enter your credentials. + +[bold]4. Full pre-flight check:[/] +[dim] python3 xdj-pi-dev.py --check ← macOS / Linux[/] +[dim] python xdj-pi-dev.py --check ← Windows[/] +""" + + def compose(self) -> ComposeResult: + with Vertical(id="help-dialog"): + yield Label("First Connection Guide", id="help-title") + with ScrollableContainer(id="help-body"): + yield Static(self._GUIDE) + with Horizontal(id="help-footer"): + yield Button("Close", id="btn-help-close", variant="primary") + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(None) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) + + class XDJApp(App): # type: ignore[misc] + TITLE = "XDJ Pi Dev" + + CSS = """ + Screen { layout: vertical; } + + #top-bar { + height: 3; + background: $panel; + border-bottom: solid $primary-darken-2; + padding: 0 1; + align: left middle; + } + #unit-select { width: 34; margin-right: 1; } + #host-input { width: 30; margin-right: 1; } + #status { width: 1fr; color: $text-muted; } + + Input { + background: $panel-lighten-1; + border: tall $panel-lighten-3; + color: $text; + } + Input:focus { border: tall $primary; } + + #body { layout: horizontal; height: 1fr; } + + #sidebar { + width: 20; + background: $panel; + border-right: solid $primary-darken-2; + padding: 1 1; + overflow-y: auto; + } + + /* Section headers */ + .sec { + color: $primary; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + padding: 0 0; + } + .sec-first { margin-top: 0; } + + /* All sidebar buttons */ + Button { + width: 100%; + height: 1; + min-height: 1; + border: none; + padding: 0 1; + background: $panel-lighten-1; + color: $text; + margin-bottom: 0; + } + Button:hover { background: $primary-darken-1; color: $text; } + Button:focus { border: none; } + Button.-primary { background: $primary; color: $text; } + Button.-warning { background: $warning; color: $text-muted; } + Button.-active-watch { background: $warning 80%; color: $text; } + + /* Main log area */ + #main { padding: 0 1; height: 1fr; } + #log { border: solid $primary-darken-3; height: 1fr; } + + /* SSH command bar */ + #ssh-bar { + height: 3; + padding: 0 0; + background: $panel; + border-top: solid $primary-darken-2; + } + #cmd-input { width: 1fr; border: none; } + + /* Bottom log-control strip */ + #log-bar { + height: 1; + background: $panel; + border-top: solid $primary-darken-2; + padding: 0 1; + align: left middle; + } + #log-bar Button { + width: auto; + min-width: 12; + margin-right: 1; + background: $panel-lighten-2; + } + #log-bar Button:hover { background: $primary-darken-1; } + + /* Confirm dialog modal */ + ConfirmScreen, .modal { + align: center middle; + } + #confirm-box { + background: $panel; + border: solid $primary; + padding: 1 2; + width: 44; + height: auto; + } + #confirm-box Label { + width: 100%; + margin-bottom: 1; + text-style: bold; + } + #confirm-box Button { + width: 100%; + margin-bottom: 0; + } + """ + + BINDINGS = [ + Binding("x", "quit", "Quit"), + Binding("p", "push", "Push skin"), + Binding("s", "screenshot", "Screenshot"), + Binding("r", "restart", "Restart"), + Binding("w", "watch", "Watch"), + Binding("d", "discover", "Discover"), + Binding("ctrl+c", "copy_log", "Copy log"), + Binding("ctrl+y", "copy_tail", "Copy tail"), + Binding("l", "clear_log", "Clear log"), + ] + + def __init__(self, host: str | None = None) -> None: + super().__init__() + self._host = host + self._known_hosts: list[str] = [host] if host else list(_DEFAULT_HOSTS) + self._watching = False + self._watch_stop = threading.Event() + self._midi_mon = False + self._midi_mon_stop = threading.Event() + self._signal_an = False + self._signal_an_stop = threading.Event() + # Prevents on_select_changed from firing a reconnect during programmatic updates + self._unit_switching = False + # Prevents concurrent connections (one at a time) + self._connect_lock = threading.Lock() + # Prevents concurrent SSH operations (push, screenshot, flash, etc.) + self._op_lock = threading.Lock() + # Keeps a plain-text copy of every log line for clipboard export + self._log_lines: list[str] = [] + + @staticmethod + def _select_labels(hosts: list[str]) -> list[tuple[str, str]]: + _WELL_KNOWN = { + "XDJ100SX.local": "XDJ100SX.local (mDNS — any network)", + "192.168.10.2": "192.168.10.2 (direct cable)", + } + return [(_WELL_KNOWN.get(h, h), h) for h in hosts] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="top-bar"): + yield Select( + self._select_labels(self._known_hosts), + id="unit-select", + prompt="Select unit…", + # value is set in on_mount after _unit_switching=True to avoid a + # spurious on_select_changed that would trigger a second connect + ) + yield Input(placeholder="or type IP / hostname…", id="host-input") + yield Static("Connecting…", id="status") + with Horizontal(id="body"): + with Vertical(id="sidebar"): + yield Button("⚙ Settings", id="btn-settings", variant="warning") + yield Button("? Connect Help", id="btn-help") + yield Button("ℹ About", id="btn-about") + yield Label("DEPLOY", classes="sec sec-first") + yield Button("Push Skin", id="btn-push", variant="primary") + yield Button("Pull Skin", id="btn-pull") + yield Button("Push MIDI", id="btn-push-midi") + yield Button("Pull MIDI", id="btn-pull-midi") + yield Button("Screenshot", id="btn-screenshot") + yield Button("Restart", id="btn-restart", variant="warning") + yield Label("LIVE", classes="sec") + yield Button("Watch ▶", id="btn-watch") + yield Button("MIDI Mon ▶", id="btn-midi-mon") + # PICO-specific — always mounted, visibility set by _apply_board_visibility + yield Button("Signal An ▶", id="btn-signal-an") + yield Label("PICO", classes="sec", id="lbl-pico") + yield Button("Bootloader", id="btn-bootloader") + yield Button("Flash UF2", id="btn-flash") + yield Label("TOOLS", classes="sec") + yield Button("Discover", id="btn-discover") + yield Button("Check", id="btn-check") + yield Button("Backup Pi", id="btn-backup") + yield Button("Verify Bak", id="btn-verify-backup") + yield Button("SSH Keys", id="btn-ssh-keys") + yield Button("DHCP", id="btn-dhcp") + with Vertical(id="main"): + yield RichLog(id="log", highlight=True, markup=True, max_lines=500) + with Horizontal(id="ssh-bar"): + yield Input(placeholder="$ command on Pi…", id="cmd-input") + with Horizontal(id="log-bar"): + yield Button("Copy Log", id="btn-copy-log") + yield Button("Copy Tail", id="btn-copy-tail") + yield Button("Clear Log", id="btn-clear-log") + yield Footer() + + def on_mount(self) -> None: + _term._tui_log_fn = self._log_from_thread + self._unit_switching = True + initial = self._host or _DEFAULT_HOSTS[0] + self.query_one("#unit-select", Select).value = initial + self._apply_board_visibility() + from xdj_pi_dev.config import load_config + if "board" not in load_config(): + # First run — show board selector before connecting + self.push_screen( + BoardSelectScreen(current="pico"), + lambda choice: self._after_first_board(choice, initial), + ) + else: + self._do_connect(initial) + + def _after_first_board(self, choice: str | None, initial: str) -> None: + # ESC on first-run = accept default (pico). Don't leave config empty + # or the selector would show again on every launch. + from xdj_pi_dev.config import update_config + update_config(board=choice or "pico") + self._apply_board_visibility() + self._do_connect(initial) + + # ── Thread-safe log ─────────────────────────────────────────────────── + + def _log_from_thread(self, level: str, msg: str) -> None: + self.call_from_thread(self._log, level, msg) + + def _log(self, level: str, msg: str) -> None: + icons = { + "ok": "[green]✓[/]", + "warn": "[yellow]⚠[/]", + "fail": "[red]✗[/]", + "info": "[dim]·[/]", + "head": "[bold cyan]╌[/]", + } + self._log_lines.append(f"{level.upper()}: {msg}") + self.query_one("#log", RichLog).write(f"{icons.get(level, '·')} {msg}") + + def _set_status(self, text: str) -> None: + self.query_one("#status", Static).update(text) + + def _clear_switching(self) -> None: + self._unit_switching = False + + def _op_start(self) -> bool: + """Try to acquire the operation lock. Returns False if not ready or busy.""" + if pi is None: + self._log_from_thread("warn", MSG["not_connected"]) + return False + if not self._op_lock.acquire(blocking=False): + self._log_from_thread("warn", MSG["busy"]) + return False + return True + + def _op_end(self) -> None: + try: + self._op_lock.release() + except RuntimeError: + pass + + # ── Unit switcher ───────────────────────────────────────────────────── + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id != "unit-select" or self._unit_switching: + return + if event.value and event.value is not Select.BLANK: + self._unit_switching = True + self._do_connect(str(event.value)) + + def on_input_submitted(self, event: Input.Submitted) -> None: + iid = event.input.id + if iid == "host-input": + host = event.value.strip() + if host: + event.input.value = "" + self._connect_to_custom(host) + elif iid == "cmd-input": + cmd = event.value.strip() + if cmd: + event.input.value = "" + self._run_ssh_cmd(cmd) + + def _connect_to_custom(self, host: str) -> None: + if host not in self._known_hosts: + self._known_hosts.insert(0, host) + sel = self.query_one("#unit-select", Select) + self._unit_switching = True + sel.set_options(self._select_labels(self._known_hosts)) + sel.value = host + self.call_later(self._clear_switching) + self._do_connect(host) + + def _do_connect(self, host: str) -> None: + if not self._connect_lock.acquire(blocking=False): + self._log_from_thread("warn", MSG["already_connecting"]) + return + self._connect_worker(host) + + @_twork(thread=True) + def _connect_worker(self, host: str) -> None: + self.call_from_thread(self._set_status, f"Connecting to {host}…") + try: + resolved = init_client(host) + bits = [] + r = pi.exec("pgrep -c mixxx || true") + if r["stdout"].strip() not in ("", "0"): + bits.append("Mixxx ◉") + r2 = pi.exec("ls /media/ 2>/dev/null") + if r2["stdout"].strip(): + bits.append(f"USB:{r2['stdout'].strip()}") + r3 = pi.exec("[ -e /dev/ttyACM0 ] && echo yes || true") + if "yes" in r3["stdout"]: + bits.append("Pico ◉") + summary = " [dim]│[/] ".join(bits) if bits else "ready" + self.call_from_thread( + self._set_status, f"[green]◉[/] {resolved} [dim]│[/] {summary}" + ) + self._log_from_thread("ok", f"Connected → {resolved}") + except Exception as e: + self.call_from_thread(self._set_status, f"[red]✗[/] {host} — {e}") + self._log_from_thread("fail", f"{host}: {e}") + finally: + self._connect_lock.release() + # Re-enable on_select_changed after connect (deferred one tick) + self.call_from_thread(lambda: self.call_later(self._clear_switching)) + + @_twork(thread=True) + def action_discover(self) -> None: + self._log_from_thread("info", MSG["discovering"]) + try: + units = discover_units( + extra_hosts=[self._host] if self._host else None + ) + if not units: + self._log_from_thread("warn", MSG["no_units_found"]) + return + for host, hostname in units: + self._log_from_thread("ok", f"{hostname} ({host})") + options = [(f"{hostname} ({host})", host) for host, hostname in units] + self.call_from_thread(self._apply_discovered_units, options) + except Exception as e: + self._log_from_thread("fail", str(e)) + + def _apply_discovered_units(self, options: list) -> None: + sel = self.query_one("#unit-select", Select) + current = sel.value + for _, h in options: + if h not in self._known_hosts: + self._known_hosts.append(h) + self._unit_switching = True + sel.set_options(options) + values = [v for _, v in options] + sel.value = current if current in values else values[0] + self.call_later(self._clear_switching) + + # ── Button dispatch ─────────────────────────────────────────────────── + + def on_button_pressed(self, event: Button.Pressed) -> None: + { + "btn-push": self._ask_push, + "btn-pull": self._ask_pull, + "btn-push-midi": self._ask_push_midi, + "btn-pull-midi": self._ask_pull_midi, + "btn-screenshot": self.action_screenshot, + "btn-restart": self.action_restart, + "btn-watch": self.action_watch, + "btn-midi-mon": self.action_midi_mon, + "btn-signal-an": self.action_signal_an, + "btn-bootloader": self._do_bootloader, + "btn-flash": self._do_flash, + "btn-discover": self.action_discover, + "btn-check": self._do_check, + "btn-backup": self._do_backup, + "btn-verify-backup": self._do_verify_backup, + "btn-ssh-keys": self._do_ssh_keys, + "btn-dhcp": self._do_dhcp, + "btn-help": self._open_help, + "btn-about": self._open_about, + "btn-settings": self._open_settings, + "btn-copy-log": self.action_copy_log, + "btn-copy-tail": self.action_copy_tail, + "btn-clear-log": self.action_clear_log, + }.get(event.button.id, lambda: None)() + + # ── Confirm-modal helpers ───────────────────────────────────────────── + + def _ask_push(self) -> None: + self.push_screen( + ConfirmScreen("Push skin to Pi — backup remote first?", + "Push", "push", + "Backup remote then push", "backup"), + self._start_push, + ) + + def _start_push(self, choice: str | None) -> None: + if choice is None: return + self.action_push(backup_remote=(choice == "backup")) + + def _ask_pull(self) -> None: + self.push_screen( + ConfirmScreen("Pull skin from Pi — what to do with local files?", + "Replace", "replace", + "Backup then replace", "backup"), + self._start_pull, + ) + + def _start_pull(self, choice: str | None) -> None: + if choice is None: return + self._do_pull(backup=(choice == "backup")) + + def _ask_push_midi(self) -> None: + self.push_screen( + ConfirmScreen("Push MIDI to Pi — backup remote first?", + "Push", "push", + "Backup remote then push", "backup"), + self._start_push_midi, + ) + + def _start_push_midi(self, choice: str | None) -> None: + if choice is None: return + self._do_push_midi(backup_remote=(choice == "backup")) + + def _ask_pull_midi(self) -> None: + self.push_screen( + ConfirmScreen("Pull MIDI from Pi — what to do with local files?", + "Replace", "replace", + "Backup then replace", "backup"), + self._start_pull_midi, + ) + + def _start_pull_midi(self, choice: str | None) -> None: + if choice is None: return + self._do_pull_midi(backup=(choice == "backup")) + + # ── Actions ─────────────────────────────────────────────────────────── + + @_twork(thread=True) + def action_push(self, backup_remote: bool = False) -> None: + if not self._op_start(): return + try: + label = MSG["pushing_skin_backup"] if backup_remote else MSG["pushing_skin"] + self._log_from_thread("head", label) + pushed = push_skin(backup_remote=backup_remote) + for p in pushed: + self._log_from_thread("ok", p) + self._log_from_thread("ok", MSG["pushed_skin"].format(n=len(pushed))) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_pull(self, backup: bool = False) -> None: + if not self._op_start(): return + try: + label = MSG["pulling_skin_backup"] if backup else MSG["pulling_skin"] + self._log_from_thread("head", label) + pulled = pull_skin(backup=backup) + for p in pulled: + self._log_from_thread("ok", p) + self._log_from_thread("ok", MSG["pulled_skin"].format(n=len(pulled))) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def action_screenshot(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("info", MSG["screenshotting"]) + data, err = take_screenshot() + if err: + self._log_from_thread("fail", err) + else: + out = tmp_path("xdj_screenshot.png") + out.write_bytes(data) + self._log_from_thread("ok", f"Saved → {out}") + open_image(out) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def action_restart(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("info", MSG["restarting_mixxx"]) + self._log_from_thread("ok", restart_mixxx()) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + def action_watch(self) -> None: + btn = self.query_one("#btn-watch", Button) + if self._watching: + self._watch_stop.set() + self._watching = False + btn.label = "Watch ▶" + btn.remove_class("-active-watch") + else: + self._watch_stop.clear() + self._watching = True + btn.label = "Watch ■" + btn.add_class("-active-watch") + self._do_watch() + + @_twork(thread=True) + def _do_watch(self) -> None: + self._log_from_thread("head", MSG["watch_started"]) + from xdj_pi_dev.watch import _REPO_SKIN as _ws + self._log_from_thread("info", f"Folder: {_ws}") + try: + def _watch_shot(_paths: list[str]) -> None: + data, err = take_screenshot() + if err: + self._log_from_thread("warn", f"Screenshot: {err}") + return + out = tmp_path("xdj_watch_screen.png") + out.write_bytes(data) + open_image(out) + self._log_from_thread("info", f"Screenshot → {out}") + + _watch_loop(pi, self._watch_stop, self._log_from_thread, screenshot_fn=_watch_shot) + except Exception as e: + self._log_from_thread("fail", str(e)) + self.call_from_thread(self._watch_done) + + def _watch_done(self) -> None: + self._watching = False + btn = self.query_one("#btn-watch", Button) + btn.label = "Watch ▶" + btn.remove_class("-active-watch") + self._log("info", MSG["watch_stopped"]) + + def action_midi_mon(self) -> None: + btn = self.query_one("#btn-midi-mon", Button) + if self._midi_mon: + self._midi_mon_stop.set() + self._midi_mon = False + btn.label = "MIDI Mon ▶" + btn.remove_class("-active-watch") + else: + self._midi_mon_stop.clear() + self._midi_mon = True + btn.label = "MIDI Mon ■" + btn.add_class("-active-watch") + self._do_midi_mon() + + @_twork(thread=True) + def _do_midi_mon(self) -> None: + try: + midi_monitor(pi, self._midi_mon_stop, self._log_from_thread) + except Exception as e: + self._log_from_thread("fail", str(e)) + self.call_from_thread(self._midi_mon_done) + + def _midi_mon_done(self) -> None: + self._midi_mon = False + btn = self.query_one("#btn-midi-mon", Button) + btn.label = "MIDI Mon ▶" + btn.remove_class("-active-watch") + self._log("info", "MIDI monitor stopped") + + def action_signal_an(self) -> None: + import socket as _sock, webbrowser + if self._signal_an: + self._signal_an_stop.set() + self._signal_an = False + btn = self.query_one("#btn-signal-an", Button) + btn.label = "Signal An ▶" + btn.remove_class("-active-watch") + return + with _sock.socket() as s: + s.bind(("", 0)) + port = s.getsockname()[1] + self._signal_an_stop = threading.Event() + self._signal_an = True + btn = self.query_one("#btn-signal-an", Button) + btn.label = "Signal An ■" + btn.add_class("-active-watch") + url = f"http://localhost:{port}/" + self._log("info", f"Signal Analyzer → {url}") + threading.Thread( + target=run_signal_analyzer_web, + args=(pi, self._signal_an_stop, port), + daemon=True, + ).start() + self.call_later(lambda: webbrowser.open(url)) + + @_twork(thread=True) + def _do_push_midi(self, backup_remote: bool = False) -> None: + if not self._op_start(): return + try: + label = MSG["pushing_midi_backup"] if backup_remote else MSG["pushing_midi"] + self._log_from_thread("head", label) + pushed = push_midi(backup_remote=backup_remote) + for p in pushed: + self._log_from_thread("ok", p) + self._log_from_thread("ok", MSG["midi_reload"]) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_pull_midi(self, backup: bool = False) -> None: + if not self._op_start(): return + try: + label = MSG["pulling_midi_backup"] if backup else MSG["pulling_midi"] + self._log_from_thread("head", label) + pulled = pull_midi(backup=backup) + for p in pulled: + self._log_from_thread("ok", p) + self._log_from_thread("ok", MSG["pulled_midi"].format(n=len(pulled))) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_bootloader(self) -> None: + # MIDI monitor holds the Pi ALSA port — release it before sending the bootloader signal + if self._midi_mon: + self._midi_mon_stop.set() + self._log_from_thread("info", "Stopping MIDI monitor (needed for bootloader)…") + time.sleep(0.8) + self.call_from_thread(self._midi_mon_done) + if not self._op_start(): return + try: + self._log_from_thread("head", MSG["pico_boot_starting"]) + msg = pico_bootloader(pi) + level = "ok" if "update mode" in msg else ("warn" if "signal sent" in msg else "fail") + self._log_from_thread(level, msg) + except Exception as e: + self._log_from_thread("fail", str(e) or f"{type(e).__name__}") + finally: + self._op_end() + + @_twork(thread=True) + def _do_flash(self) -> None: + if not self._op_start(): return + try: + from xdj_pi_dev.config import get_firmware_dir + try: + fw_dir = get_firmware_dir() + except RuntimeError as e: + self._log_from_thread("warn", str(e)) + return + candidates = sorted( + fw_dir.glob("*.uf2"), + key=lambda p: p.stat().st_mtime, reverse=True, + ) + if not candidates: + self._log_from_thread("fail", MSG["flash_fail_nofile"]) + return + uf2 = candidates[0] + self._log_from_thread("head", MSG["flashing"]) + msg = pico_flash(pi, str(uf2)) + is_fail = any(w in msg.lower() for w in ( + "couldn't", "failed", "not found", "cp_failed", "error", "unexpected", + )) + self._log_from_thread("fail" if is_fail else "ok", msg or "No output from flash command") + except Exception as e: + self._log_from_thread("fail", repr(e) if not str(e) else str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_ssh_keys(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("head", MSG["ssh_keys_starting"]) + setup_ssh_keys(pi) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_dhcp(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("head", MSG["dhcp_starting"]) + setup_pi_dhcp(pi) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _run_ssh_cmd(self, cmd: str) -> None: + if pi is None: + self._log_from_thread("warn", MSG["not_connected"]) + return + self._log_from_thread("info", f"$ {cmd}") + try: + r = pi.exec(cmd, timeout=60) + for line in (r["stdout"] or "").splitlines(): + self._log_from_thread("info", line) + for line in (r["stderr"] or "").splitlines(): + self._log_from_thread("warn", line) + if r["rc"] != 0: + self._log_from_thread("info", f"[exit {r['rc']}]") + except Exception as e: + self._log_from_thread("fail", str(e)) + + def _apply_board_visibility(self) -> None: + """Show/hide PICO-specific sidebar items based on current board config.""" + pico = is_pico_board() + for wid in ("btn-signal-an", "lbl-pico", "btn-bootloader", "btn-flash"): + try: + self.query_one(f"#{wid}").display = pico + except Exception: + pass + + def _open_help(self) -> None: + self.push_screen(HelpScreen()) + + def _open_about(self) -> None: + self.push_screen(AboutScreen()) + + def _open_settings(self) -> None: + self.push_screen(SettingsScreen(), self._on_settings_saved) + + def _on_settings_saved(self, chosen: str | None) -> None: + if chosen is None: + return + self._log("ok", f"Settings saved — board: {_BOARD_META.get(chosen, (chosen,))[0]}") + self._apply_board_visibility() + + def action_copy_log(self) -> None: + text = "\n".join(self._log_lines) + try: + if sys.platform == "darwin": + subprocess.run(["pbcopy"], input=text.encode(), check=True) + elif shutil.which("xclip"): + subprocess.run(["xclip", "-selection", "clipboard"], + input=text.encode(), check=True) + elif shutil.which("xsel"): + subprocess.run(["xsel", "--clipboard", "--input"], + input=text.encode(), check=True) + else: + self._log("warn", "No clipboard tool found (pbcopy/xclip/xsel)") + return + self._log("ok", f"Log copied to clipboard ({len(self._log_lines)} lines)") + except Exception as e: + self._log("fail", f"Copy failed: {e}") + + def action_clear_log(self) -> None: + self._log_lines.clear() + self.query_one("#log", RichLog).clear() + self.query_one("#log", RichLog).write("[dim]── log cleared ──[/]") + + def action_copy_tail(self) -> None: + """Copy last 30 log lines to clipboard — useful for grabbing recent output.""" + tail = self._log_lines[-30:] + text = "\n".join(tail) + try: + if sys.platform == "darwin": + subprocess.run(["pbcopy"], input=text.encode(), check=True) + elif shutil.which("xclip"): + subprocess.run(["xclip", "-selection", "clipboard"], + input=text.encode(), check=True) + elif shutil.which("xsel"): + subprocess.run(["xsel", "--clipboard", "--input"], + input=text.encode(), check=True) + else: + self._log("warn", "No clipboard tool found (pbcopy/xclip/xsel)") + return + self._log("ok", f"Last {len(tail)} lines copied to clipboard") + except Exception as e: + self._log("fail", f"Copy failed: {e}") + + @_twork(thread=True) + def _do_check(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("head", MSG["preflight_start"]) + run_check(self._host) + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_backup(self) -> None: + if not self._op_start(): return + try: + self._log_from_thread("head", MSG["backup_starting"]) + self._log_from_thread("warn", MSG["backup_wait"]) + out_path = backup_pi_image(pi, log_fn=self._log_from_thread) + if out_path: + self._log_from_thread("ok", f"Backup saved → {out_path}") + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + @_twork(thread=True) + def _do_verify_backup(self) -> None: + if not self._op_start(): return + # Ask user to provide the file path via the log (TUI can't open file dialogs) + # Find the most recent backup in the repo directory + import glob as _glob + pattern = str(Path(__file__).parent.parent / "xdj-pi-backup-*.tar") + matches = sorted(_glob.glob(pattern)) + if not matches: + self._log_from_thread("warn", "No xdj-pi-backup-*.tar found in repo root") + self._log_from_thread("info", "Run: python3 tools/xdj-pi-dev.py --verify-backup ") + self._op_end() + return + latest = matches[-1] + try: + self._log_from_thread("head", f"Verifying {Path(latest).name}…") + verify_backup(pi, latest) + self._log_from_thread("ok", "Verification complete — check terminal for details") + except Exception as e: + self._log_from_thread("fail", str(e)) + finally: + self._op_end() + + +def main() -> None: + global PI_USER, PI_PASS, pi + + ap = argparse.ArgumentParser( + description="XDJ Pi developer tool — CLI + MCP server for Claude Code", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + # Connection + conn = ap.add_argument_group("connection") + conn.add_argument("--host", metavar="HOST", + help="Pi IP or hostname (overrides XDJ_HOST env)") + conn.add_argument("--user", metavar="USER", default=PI_USER, + help=f"SSH username (default: {PI_USER})") + conn.add_argument("--password", metavar="PASS", default=PI_PASS, + help="SSH password (default: built-in or XDJ_PASS env)") + + # Setup + setup = ap.add_argument_group("setup (run these first)") + setup.add_argument("--check", action="store_true", + help="Pre-flight: check deps, connection, Mixxx, audio") + setup.add_argument("--discover", action="store_true", + help="Scan network for Pi units") + setup.add_argument("--setup-pi-dhcp", action="store_true", + help="Configure Pi as DHCP server — no manual IP config ever again") + setup.add_argument("--setup-ssh-keys", action="store_true", + help="Generate key pair and install on Pi (passwordless auth)") + setup.add_argument("--restore-ssh", action="store_true", + help="Print SSH recovery guide (no connection required)") + setup.add_argument("--set-hostname", metavar="NAME", + help="Rename Pi (e.g. xdj-unit2 for multi-unit setups)") + setup.add_argument("--backup-image", metavar="FILE", nargs="?", const="", + help="Backup Pi SD card to a .tar archive (used blocks only, default: cwd)") + setup.add_argument("--verify-backup", metavar="FILE", + help="Verify a backup .tar: test gzip integrity + compare manifest against live Pi") + + # Skin dev + skin = ap.add_argument_group("skin development") + skin.add_argument("--status", action="store_true", help="Pi + Mixxx live status") + skin.add_argument("--screenshot", action="store_true", help="Capture Pi display") + skin.add_argument("--panel", metavar="PANEL", + help="Panel to show (hotcue/hc, beatloop/bl, keyshift/ks, beatjump/bj, stems/st)") + skin.add_argument("--restart", action="store_true", help="Restart Mixxx") + skin.add_argument("--push", metavar="GLOB", nargs="?", const="*", + help="Push skin files to Pi (default: all)") + skin.add_argument("--push-midi", action="store_true", + help="Push MIDI mapping files (docs/midi/) to Pi controllers dir") + skin.add_argument("--pull-midi", action="store_true", + help="Pull MIDI mapping files from Pi to local midi dir") + skin.add_argument("--midi-mon", action="store_true", + help="Stream live MIDI messages from controller (Ctrl-C to stop)") + skin.add_argument("--pull", action="store_true", help="Pull skin from Pi to repo") + skin.add_argument("--watch", action="store_true", + help="Watch skin dir, auto-push + screenshot on change") + skin.add_argument("--full-restart", action="store_true", + help="Full Mixxx restart in watch mode (default: try Ctrl+F5)") + + # Pico / firmware + pico = ap.add_argument_group("firmware") + pico.add_argument("--setup-pico-cli", action="store_true", + help="Install arduino-cli + RP2040 toolchain on Pi (one-time, needs Pi internet)") + pico.add_argument("--pico-compile", action="store_true", + help="Compile firmware (local arduino-cli first, falls back to Pi)") + pico.add_argument("--pico-bootloader", action="store_true", + help="Trigger UF2 bootloader on Pico via MIDI (or combined with --pico-compile to compile+flash)") + pico.add_argument("--pico-flash", metavar="FILE", + help="Flash a local .uf2/.hex file to Pico via Pi") + pico.add_argument("--board", metavar="BOARD", default=DEFAULT_BOARD, + help=( + f"Board profile or raw FQBN (default: {DEFAULT_BOARD}). " + f"Profiles: {', '.join(BOARD_PROFILES)}" + )) + pico.add_argument("--analyze", action="store_true", + help="Live GPIO signal analyzer — curses dashboard reading Pico Core 1 via /dev/ttyACM0") + + # Other + ap.add_argument("--cmd", metavar="CMD", help="Run arbitrary SSH command on Pi") + ap.add_argument("--ui", action="store_true", help="Open interactive TUI (requires: pip install textual)") + ap.add_argument("--about", action="store_true", help="Show authors and credits") + + args = ap.parse_args() + + # Apply CLI credential overrides + PI_USER = args.user + PI_PASS = args.password + + # TUI — launches before any connection (app handles it internally) + if args.about: + print(_TOOL_ABOUT) + return + + if args.ui: + if not _TEXTUAL: + print("textual not installed. Run: pip install textual") + sys.exit(1) + XDJApp(host=args.host).run() + return + + # Commands that need no Pi connection + if args.restore_ssh: + restore_ssh() + return + + if args.discover: + units = discover_units(extra_hosts=[args.host] if args.host else None) + if units: + print(f"\nFound {len(units)} Pi unit(s):\n") + for host, hostname in units: + print(f" {host:<20} hostname: {hostname}") + print(f"\nConnect: python3 xdj-pi-dev.py --host {units[0][0]} --status") + else: + print("No Pi units found.") + return + + # Pre-flight check (resolves connection internally) + if args.check: + run_check(args.host) + return + + # All remaining commands need a live Pi connection + try: + host = init_client(args.host) + except ConnectionError as e: + print(f"\n{e}") + sys.exit(1) + + if args.status: + cli_status() + + elif args.setup_pi_dhcp: + setup_pi_dhcp(pi) + + elif args.setup_ssh_keys: + setup_ssh_keys(pi) + + elif args.set_hostname: + set_hostname(pi, args.set_hostname) + + elif args.backup_image is not None: + backup_pi_image(pi, args.backup_image or None) + + elif args.verify_backup: + verify_backup(pi, args.verify_backup) + + elif args.screenshot: + data, err = take_screenshot(panel=args.panel) + if err: + print(f"Failed: {err}") + else: + out = tmp_path("xdj_screenshot.png") + out.write_bytes(data) + print(f"Saved: {out}") + open_image(out) + + elif args.restart: + print(restart_mixxx(panel=args.panel)) + + elif args.push is not None: + pushed = push_skin(args.push) + for p in pushed: + print(f" ✓ {p}") + print(f"Done: {len(pushed)} file(s)") + + elif args.push_midi: + pushed = push_midi() + for p in pushed: + ok(p) + print(f"\n Pushed {len(pushed)} MIDI file(s) → {PI_MIDI_DIR}") + print(f" {_C.DIM}In Mixxx: Preferences → Controllers → disable + re-enable mapping{_C.RESET}") + + elif args.pull_midi: + pulled = pull_midi() + for p in pulled: + print(f" ✓ {p}") + print(f"Done: {len(pulled)} MIDI file(s)") + + elif args.midi_mon: + stop = threading.Event() + try: + midi_monitor(pi, stop, lambda t, m: print(f"[{t}] {m}")) + except KeyboardInterrupt: + stop.set() + + elif args.pull: + pulled = pull_skin() + for p in pulled: + print(f" ✓ {p}") + print(f"Done: {len(pulled)} file(s)") + + elif args.watch: + watch_mode(pi, panel=args.panel, restart=args.full_restart) + + elif args.setup_pico_cli: + setup_pico_cli(pi) + + elif args.pico_compile: + pico_compile(pi, flash=args.pico_bootloader, board=args.board) + + elif args.pico_bootloader: + print(pico_bootloader(pi)) + + elif args.pico_flash: + print(pico_flash(pi, args.pico_flash)) + + elif args.analyze: + run_signal_analyzer_cli(pi) + + elif args.cmd: + r = pi.exec(args.cmd) + if r["stdout"]: sys.stdout.write(r["stdout"]) + if r["stderr"]: sys.stderr.write(r["stderr"]) + sys.exit(r["rc"]) + + else: + # No CLI flag → MCP server mode + env = os.environ.get("XDJ_HOST") + candidates = [args.host] if args.host else ([env] if env else _DEFAULT_HOSTS) + resolved = resolve_host(candidates) + if resolved: + pi = PiClient(resolved, PI_USER, PI_PASS) + run_mcp_server() + + +if __name__ == "__main__": + main() diff --git a/tools/xdj_pi_dev/__init__.py b/tools/xdj_pi_dev/__init__.py new file mode 100644 index 0000000..27dd4c3 --- /dev/null +++ b/tools/xdj_pi_dev/__init__.py @@ -0,0 +1 @@ +# xdj_pi_dev — XDJ-100SX Pi Developer Tool modules diff --git a/tools/xdj_pi_dev/_terminal.py b/tools/xdj_pi_dev/_terminal.py new file mode 100644 index 0000000..1472787 --- /dev/null +++ b/tools/xdj_pi_dev/_terminal.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +""" +Shared terminal UI helpers. + +Extracted from xdj-pi-dev.py so all sub-modules can import consistent +colour, spinner, and logging primitives without circular dependencies. +""" + +import itertools +import shutil +import sys +import threading +from typing import Any + + +# ─── ANSI colour ───────────────────────────────────────────────────────────── + +def _ansi_enable() -> bool: + """Enable ANSI on Windows; return True if terminal supports colour.""" + if not sys.stdout.isatty(): + return False + if sys.platform == "win32": + try: + import ctypes + k = ctypes.windll.kernel32 + k.SetConsoleMode(k.GetStdHandle(-11), 7) + except Exception: + return False + return True + + +_COLOR = _ansi_enable() + + +class _C: + RESET = "\033[0m" if _COLOR else "" + BOLD = "\033[1m" if _COLOR else "" + DIM = "\033[2m" if _COLOR else "" + GREEN = "\033[32m" if _COLOR else "" + YELLOW = "\033[33m" if _COLOR else "" + RED = "\033[31m" if _COLOR else "" + CYAN = "\033[36m" if _COLOR else "" + GRAY = "\033[90m" if _COLOR else "" + ERASE = "\r\033[K" if _COLOR else "\r" + + +_SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +# ─── Spinner step ───────────────────────────────────────────────────────────── + +class Step: + """ + Context manager for a named operation with a live spinner. + + with Step("Stopping Mixxx"): + pi_client.exec("killall mixxx") + + Prints ⠹ Stopping Mixxx... while running, + then ✓ Stopping Mixxx on success, + or ✗ Stopping Mixxx on exception. + """ + + def __init__(self, label: str, indent: int = 2) -> None: + self.label = label + self.indent = " " * indent + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def __enter__(self) -> "Step": + self._stop.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + return self + + def _spin(self) -> None: + for frame in itertools.cycle(_SPIN_FRAMES): + sys.stdout.write( + f"{_C.ERASE}{self.indent}{_C.CYAN}{frame}{_C.RESET}" + f" {self.label}…" + ) + sys.stdout.flush() + if self._stop.wait(0.08): + break + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self._stop.set() + if self._thread: + self._thread.join() + if exc_type is None: + sys.stdout.write( + f"{_C.ERASE}{self.indent}{_C.GREEN}✓{_C.RESET}" + f" {self.label}\n" + ) + else: + sys.stdout.write( + f"{_C.ERASE}{self.indent}{_C.RED}✗{_C.RESET}" + f" {self.label} {_C.DIM}({exc_val}){_C.RESET}\n" + ) + sys.stdout.flush() + return False # don't suppress exceptions + + +# ─── Log routing ────────────────────────────────────────────────────────────── + +# Set by XDJApp when TUI is active; (level, msg) -> None where level in ok/warn/fail/info/head +_tui_log_fn: Any = None + + +def section(title: str) -> None: + if _tui_log_fn: + _tui_log_fn("head", title) + return + w = shutil.get_terminal_size((72, 24)).columns + bar = "─" * min(w, 72) + print(f"\n{_C.BOLD}{bar}{_C.RESET}") + print(f" {_C.BOLD}{title}{_C.RESET}") + print(f"{_C.BOLD}{bar}{_C.RESET}") + + +def ok(msg: str) -> None: + if _tui_log_fn: + _tui_log_fn("ok", msg) + return + print(f" {_C.GREEN}✓{_C.RESET} {msg}") + + +def warn(msg: str) -> None: + if _tui_log_fn: + _tui_log_fn("warn", msg) + return + print(f" {_C.YELLOW}⚠{_C.RESET} {msg}") + + +def fail(msg: str) -> None: + if _tui_log_fn: + _tui_log_fn("fail", msg) + return + print(f" {_C.RED}✗{_C.RESET} {msg}") + + +def _log_line(line: str) -> None: + lo = line.lower() + if _tui_log_fn: + if any(x in lo for x in ("error:", "fatal error:", " error ")): + _tui_log_fn("fail", line) + elif "warning:" in lo: + _tui_log_fn("warn", line) + elif line.strip(): + _tui_log_fn("info", line) + return + if any(x in lo for x in ("error:", "fatal error:", " error ")): + print(f" {_C.RED}│{_C.RESET} {_C.RED}{line}{_C.RESET}") + elif "warning:" in lo: + print(f" {_C.YELLOW}│{_C.RESET} {_C.YELLOW}{line}{_C.RESET}") + elif line.strip(): + print(f" {_C.GRAY}│{_C.RESET} {line}") diff --git a/tools/xdj_pi_dev/backup.py b/tools/xdj_pi_dev/backup.py new file mode 100644 index 0000000..bda1702 --- /dev/null +++ b/tools/xdj_pi_dev/backup.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +""" +Pi image backup: manifest collection, archive verification, and full SD-card backup. + +Extracted from xdj-pi-dev.py (lines 1289-1704). +""" + +import sys +import tarfile +import tempfile +import time +from pathlib import Path + +import os + +from xdj_pi_dev._terminal import ( + _C, Step, section, ok, warn, fail, +) + +_PI_USER = os.environ.get("XDJ_USER", "xdj100sx") +_H = f"/home/{_PI_USER}" + +# ─── Config files tracked by the manifest ──────────────────────────────────── + +_MANIFEST_FILES = [ + # Network + "/etc/hostname", "/etc/hosts", "/etc/dhcpcd.conf", + "/etc/network/interfaces", "/etc/resolv.conf", + "/etc/dnsmasq.conf", + # SSH + f"/etc/ssh/sshd_config", f"{_H}/.ssh/authorized_keys", + # Boot / kernel + "/boot/config.txt", "/boot/cmdline.txt", + # System services + "/etc/rc.local", + # USB automount (dev tool) + "/usr/local/bin/usb-mount", "/usr/local/bin/usb-unmount", + "/etc/udev/rules.d/99-usb-automount.rules", + # Mixxx + f"{_H}/.mixxx/mixxx.cfg", + # User shell + f"{_H}/.bashrc", f"{_H}/.profile", +] +_MANIFEST_DIRS = [ + f"{_H}/.mixxx/controllers", # MIDI mappings + "/etc/dnsmasq.d", # dnsmasq includes + "/etc/systemd/system", # custom services + "/usr/local/bin", # custom scripts + "/etc/udev/rules.d", # udev rules +] + + +# ─── Manifest collection ────────────────────────────────────────────────────── + +def _backup_manifest(pi_client) -> bytes: + """SSH to Pi and collect sha256 checksums + directory listings of critical configs.""" + import datetime as _dt + lines = [ + f"# XDJ Pi backup manifest — {_dt.datetime.now().isoformat(timespec='seconds')}", + "# sha256sum of critical config files", + "# Use --verify-backup to check integrity and compare against live Pi", + "", + ] + # Checksum individual files + files_arg = " ".join(f'"{f}"' for f in _MANIFEST_FILES) + r = pi_client.exec(f"sha256sum {files_arg} 2>/dev/null || true") + lines += r["stdout"].splitlines() + + # Checksum everything under key directories + for d in _MANIFEST_DIRS: + r2 = pi_client.exec( + f'find {d} -type f 2>/dev/null | sort | xargs sha256sum 2>/dev/null || true' + ) + if r2["stdout"].strip(): + lines += r2["stdout"].splitlines() + + # Installed package list (snapshot, not checksums) + lines += ["", "# Installed packages (dpkg --get-selections)"] + r3 = pi_client.exec("dpkg --get-selections 2>/dev/null | grep -v deinstall || true") + lines += r3["stdout"].splitlines() + + return ("\n".join(lines) + "\n").encode() + + +# ─── Archive verification ───────────────────────────────────────────────────── + +def verify_backup(pi_client, path: str) -> None: + """Verify a backup .tar archive: test gzip integrity and display manifest.""" + import gzip as _gzip + import io as _io + + section("Backup Verification") + tfile = Path(path) + if not tfile.exists(): + fail(f"File not found: {tfile}") + return + + ok(f"Archive: {tfile} ({tfile.stat().st_size / 1024**2:.0f} MB)") + print() + + with Step("Opening archive"): + try: + tf = tarfile.open(tfile, "r") + except Exception as e: + raise RuntimeError(f"Cannot open tar: {e}") + + members = {m.name: m for m in tf.getmembers()} + expected = {"mbr.bin.gz", "sfdisk.dump", "p1-boot.vfat.gz", "restore.sh"} + + ok(f"Members: {', '.join(sorted(members))}") + missing = expected - set(members) + if missing: + warn(f"Unexpected missing members: {missing}") + + # Test gzip integrity of compressed parts + gz_parts = [n for n in members if n.endswith(".gz")] + for name in sorted(gz_parts): + with Step(f"gzip -t {name}"): + data = tf.extractfile(members[name]) + if data is None: + raise RuntimeError(f"Cannot read {name} from archive (directory entry?)") + raw = data.read() + try: + with _gzip.open(_io.BytesIO(raw)) as gz: + gz.read() + except OSError as _e: + raise RuntimeError(f"Corrupt: {name} ({_e})") + ok(f"{name} {len(raw) / 1024**2:.1f} MB ✓") + + # Show sfdisk partition layout + if "sfdisk.dump" in members: + layout_data = tf.extractfile(members["sfdisk.dump"]) + if layout_data: + print() + ok("Partition layout:") + for line in layout_data.read().decode().splitlines(): + if line.strip() and not line.startswith("#"): + print(f" {line}") + + # Show manifest + if "manifest.txt" in members: + mdata = tf.extractfile(members["manifest.txt"]) + if mdata: + manifest_lines = mdata.read().decode().splitlines() + print() + ok("Manifest (critical config checksums):") + for line in manifest_lines: + if line.startswith("#") or not line.strip(): + print(f" {_C.DIM}{line}{_C.RESET}") + else: + print(f" {line}") + + # If Pi is reachable, compare live checksums + if pi_client._ssh: + print() + with Step("Comparing manifest against live Pi"): + live_checksums: dict[str, str] = {} + saved_checksums: dict[str, str] = {} + for line in manifest_lines: + if line.startswith("#") or " " not in line: + continue + parts = line.split(" ", 1) + if len(parts) == 2: + saved_checksums[parts[1].strip()] = parts[0].strip() + + all_files = list(saved_checksums.keys()) + if all_files: + files_arg = " ".join(f'"{f}"' for f in all_files[:100]) + r_live = pi_client.exec(f"sha256sum {files_arg} 2>/dev/null || true") + for line in r_live["stdout"].splitlines(): + if " " in line: + ck, fp = line.split(" ", 1) + live_checksums[fp.strip()] = ck.strip() + + print() + changed, missing_live, ok_count = [], [], 0 + for fp, saved_ck in saved_checksums.items(): + if fp not in live_checksums: + missing_live.append(fp) + elif live_checksums[fp] != saved_ck: + changed.append(fp) + else: + ok_count += 1 + + ok(f"{ok_count} files match backup exactly") + if changed: + warn(f"{len(changed)} files changed since backup:") + for f in changed: + print(f" {_C.YELLOW}CHANGED{_C.RESET} {f}") + if missing_live: + warn(f"{len(missing_live)} files from backup not found on live Pi:") + for f in missing_live: + print(f" {_C.RED}MISSING{_C.RESET} {f}") + if not changed and not missing_live: + ok("All config files on Pi match the backup — backup is current") + else: + warn("No manifest.txt in archive (backup created before verification was added)") + + tf.close() + print() + ok("Verification complete") + + +# ─── Full SD-card image backup ──────────────────────────────────────────────── + +def backup_pi_image(pi_client, output_path: str | None = None, log_fn=None) -> str | None: + """Back up the Pi SD card using only used filesystem blocks. + + Creates a .tar archive containing: + mbr.bin.gz — first 1 MiB of disk (MBR + partition table bootstrap code) + sfdisk.dump — partition layout (sfdisk text format) + p1-boot.vfat.gz — FAT32 boot partition (~256 MB, full) + p2-root.img.gz — root partition: e2image -r if available (reads used blocks only), + otherwise sequential dd (empty space compresses away with gzip) + restore.sh — restoration script + + Sequential block reads keep SD card at full speed (~20-40 MB/s). + tar is NOT used — it causes random seeks which drop SD throughput to ~1-3 MB/s. + + Restore on a fresh card (Linux): + tar xf backup.tar + sudo bash restore.sh /dev/sdX # or /dev/mmcblkX for a card reader + """ + import datetime + + section("Pi Image Backup") + + # ── Detect disk ────────────────────────────────────────────────────────── + with Step("Detecting SD card"): + r = pi_client.exec( + "test -b /dev/mmcblk0 && echo mmcblk0 || " + "lsblk -ndo NAME,TYPE,TRAN 2>/dev/null | " + "awk '$2==\"disk\" && $3!=\"usb\"{print $1}' | head -1" + ) + disk = r["stdout"].strip() + if not disk: + raise RuntimeError("No suitable disk device found on Pi") + disk_dev = f"/dev/{disk}" + boot_dev = f"{disk_dev}p1" + root_dev = f"{disk_dev}p2" + + ok(f"Disk: {disk_dev} Boot: {boot_dev} Root: {root_dev}") + + # ── Report sizes ───────────────────────────────────────────────────────── + r_used = pi_client.exec( + f"df -BM --output=used {root_dev} 2>/dev/null | tail -1 | tr -d ' M' || echo 0" + ) + r_disk = pi_client.exec(f"sudo blockdev --getsize64 {disk_dev} 2>/dev/null || echo 0") + disk_gb = int(r_disk["stdout"].strip() or 0) / 1024**3 + used_mb = int(r_used["stdout"].strip() or 0) + if disk_gb: + ok(f"Disk: {disk_gb:.1f} GB total, root used: ~{used_mb} MB (archive size ~ used data)") + + # ── Get partition table ─────────────────────────────────────────────────── + r_sfdisk = pi_client.exec(f"sudo sfdisk --dump {disk_dev} 2>/dev/null || echo ''") + sfdisk_dump = r_sfdisk["stdout"].encode() + + # ── Output path ─────────────────────────────────────────────────────────── + if not output_path: + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + output_path = str(Path.cwd() / f"xdj-pi-backup-{ts}.tar") + out = Path(output_path) + if out.suffix != ".tar": + out = out.with_suffix(".tar") + + warn(f"Output: {out}") + warn("Root is archived via tar (used files only) — no extra tools needed on Pi") + print() + + # ── Streaming helper ────────────────────────────────────────────────────── + def _stream(label: str, cmd: str, dest: Path) -> int: + transport = pi_client._ssh.get_transport() + assert transport + ch = transport.open_session() + ch.exec_command(cmd) + received = 0 + cancelled = False + last_log = 0.0 + try: + with open(dest, "wb") as fout: + while True: + if ch.recv_ready(): + chunk = ch.recv(131072) + if not chunk: + break + fout.write(chunk) + received += len(chunk) + if log_fn: + now = time.monotonic() + if now - last_log >= 3.0: + log_fn("info", f"{label}: {received / 1024**2:.0f} MB received…") + last_log = now + else: + sys.stdout.write( + f"\r {_C.CYAN}↓{_C.RESET} {label:<36} {received / 1024**2:6.1f} MB" + ) + sys.stdout.flush() + elif ch.exit_status_ready() and not ch.recv_ready(): + break + else: + time.sleep(0.05) + except KeyboardInterrupt: + cancelled = True + finally: + ch.close() + if cancelled: + raise KeyboardInterrupt + done_msg = f"{label}: done — {received / 1024**2:.0f} MB" + if log_fn: + log_fn("ok", done_msg) + else: + print(f"\r {_C.GREEN}✓{_C.RESET} {label:<36} {received / 1024**2:.1f} MB") + return received + + # ── Choose fastest available compressor ────────────────────────────────── + # pigz = parallel gzip (uses all cores); fall back to gzip -1 (fast, single-core). + # Both beat the default gzip -6 by 2-4x on a Pi 4. + r_pigz = pi_client.exec("which pigz 2>/dev/null || echo ''") + if r_pigz["stdout"].strip(): + GZIP = "pigz -1" + ok("Compressor: pigz -1 (parallel gzip)") + else: + GZIP = "gzip -1" + ok("Compressor: gzip -1 (fast mode; install pigz on Pi for extra speed)") + + # ── Stop Mixxx to free CPU during compression ───────────────────────────── + mixxx_was_running = False + r_mx = pi_client.exec("pgrep -x mixxx || echo ''") + if r_mx["stdout"].strip(): + mixxx_was_running = True + pi_client.exec("sudo pkill -x mixxx 2>/dev/null || true") + if log_fn: + log_fn("info", "Mixxx stopped — will restart after backup") + else: + warn("Mixxx stopped — will restart after backup") + time.sleep(1) + + # ── Choose root backup strategy ─────────────────────────────────────────── + # e2image -r reads ONLY used blocks from disk (fast: ~14 GB reads instead of 59 GB). + # It zero-fills unused blocks in the output stream; those zeros compress to nothing. + # Fall back to sequential dd of the partition — much faster than tar (sequential + # SD reads at 20-40 MB/s vs tar's random seeks at 1-3 MB/s). + e2image_bin = "" + for candidate in ("/sbin/e2image", "/usr/sbin/e2image", "/bin/e2image"): + r_e2 = pi_client.exec(f"test -x {candidate} && echo found || echo ''") + if "found" in r_e2["stdout"]: + e2image_bin = candidate + break + + if e2image_bin: + root_cmd = f"sudo {e2image_bin} -r {root_dev} - 2>/dev/null | {GZIP}" + root_file = "p2-root.img.gz" + root_label = "Root ext4 (used blocks via e2image)" + root_restore = 'gunzip -c p2-root.img.gz | dd of="$ROOT" bs=4M' + ok("Root strategy: e2image -r (reads used blocks only) — fastest") + else: + root_cmd = f"sudo dd if={root_dev} bs=4M 2>/dev/null | {GZIP}" + root_file = "p2-root.img.gz" + root_label = "Root partition (sequential dd — zeros compress away)" + root_restore = 'gunzip -c p2-root.img.gz | dd of="$ROOT" bs=4M' + ok("Root strategy: sequential dd + gzip (e2image not found; zeros compress fine)") + + restore_sh = ( + "#!/bin/bash\n" + "# XDJ Pi Image Restore\n" + "# Usage: sudo bash restore.sh /dev/sdX\n" + "set -e\n" + "\n" + 'DEV="${1:?Usage: sudo bash restore.sh /dev/sdX}"\n' + 'if ! test -b "$DEV"; then echo "Error: $DEV is not a block device" >&2; exit 1; fi\n' + "\n" + 'if [[ "$DEV" == *mmcblk* ]] || [[ "$DEV" == *nvme* ]]; then\n' + ' BOOT="${DEV}p1"; ROOT="${DEV}p2"\n' + "else\n" + ' BOOT="${DEV}1"; ROOT="${DEV}2"\n' + "fi\n" + "\n" + 'echo "==> Restoring partition table"\n' + 'sfdisk "$DEV" < sfdisk.dump\n' + "sleep 2; partprobe \"$DEV\" 2>/dev/null || true; sleep 1\n" + "\n" + 'echo "==> Restoring MBR"\n' + 'gunzip -c mbr.bin.gz | dd of="$DEV" bs=1M count=1 conv=notrunc\n' + "\n" + 'echo "==> Restoring boot partition"\n' + 'gunzip -c p1-boot.vfat.gz | dd of="$BOOT" bs=4M\n' + "\n" + 'echo "==> Restoring root partition (this takes several minutes)"\n' + f"{root_restore}\n" + "\n" + 'echo "==> Checking + resizing root filesystem"\n' + 'e2fsck -f "$ROOT" || true\n' + 'resize2fs "$ROOT"\n' + "\n" + 'echo "==> Done. You can now boot from $DEV"\n' + ).encode() + + cancelled = False + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + try: + _stream("MBR (1 MiB)", f"sudo dd if={disk_dev} bs=1M count=1 2>/dev/null | {GZIP}", tmp / "mbr.bin.gz") + _stream("Boot partition (FAT32)", f"sudo dd if={boot_dev} bs=4M 2>/dev/null | {GZIP}", tmp / "p1-boot.vfat.gz") + _stream(root_label, root_cmd, tmp / root_file) + except KeyboardInterrupt: + cancelled = True + + if cancelled: + if mixxx_was_running: + pi_client.exec(f"sudo -u {_PI_USER} DISPLAY=:0 mixxx --settingsPath {_H}/.mixxx &", timeout=5) + warn("Cancelled — no output written") + return None + + print() + with Step("Building manifest"): + manifest_txt = _backup_manifest(pi_client) + (tmp / "manifest.txt").write_bytes(manifest_txt) + + with Step("Writing archive"): + (tmp / "restore.sh").write_bytes(restore_sh) + (tmp / "sfdisk.dump").write_bytes(sfdisk_dump) + with tarfile.open(out, "w") as tar: + for name in ("mbr.bin.gz", "sfdisk.dump", "p1-boot.vfat.gz", root_file, "restore.sh", "manifest.txt"): + ti = tarfile.TarInfo(name=name) + src = tmp / name + ti.size = src.stat().st_size + if name == "restore.sh": + ti.mode = 0o755 + with open(src, "rb") as fh: + tar.addfile(ti, fh) + + if mixxx_was_running: + pi_client.exec(f"sudo -u {_PI_USER} DISPLAY=:0 mixxx --settingsPath {_H}/.mixxx &", timeout=5) + if log_fn: + log_fn("info", "Mixxx restarted") + else: + ok("Mixxx restarted") + + size_mb = out.stat().st_size / 1024**2 + summary = f"Backup complete — {size_mb:.0f} MB → {out}" + if log_fn: + log_fn("ok", summary) + log_fn("info", "Restore (Linux): tar xf .tar && sudo bash restore.sh /dev/sdX") + else: + print() + ok(summary) + ok("Restore (Linux): tar xf .tar && sudo bash restore.sh /dev/sdX") + return str(out) diff --git a/tools/xdj_pi_dev/config.py b/tools/xdj_pi_dev/config.py new file mode 100644 index 0000000..27943a9 --- /dev/null +++ b/tools/xdj_pi_dev/config.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +""" +Config layer — owns all path resolution and the config file. + +In dev mode (python3 xdj-pi-dev.py): paths resolve from __file__ inside the repo. +In standalone mode (PyInstaller frozen binary): paths come from ~/.xdj-pi-dev/config.json. +""" + +import json +import os +import sys +from pathlib import Path + +# True only when running inside a PyInstaller bundle +_FROZEN: bool = getattr(sys, "frozen", False) + +CONFIG_PATH = Path.home() / ".xdj-pi-dev" / "config.json" + +# Keys stored in the config file +KEYS = ["skin_dir", "midi_dir", "firmware_dir", "backup_dir", "host", "board", "ssh_user", "ssh_pass"] + +# Supported boards — only "pico" has full feature support right now +BOARDS = ["pico", "pico2", "teensy", "arduino", "unknown"] +PICO_BOARDS = {"pico", "pico2"} # boards that support UF2 bootloader + flash + + +def load_config() -> dict: + """Read config file; return {} if missing or corrupt.""" + try: + return json.loads(CONFIG_PATH.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def save_config(data: dict) -> None: + """Write config to disk, creating parent dir if needed.""" + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(data, indent=2)) + + +def update_config(**kwargs) -> None: + """Merge kwargs into existing config and save.""" + cfg = load_config() + cfg.update(kwargs) + save_config(cfg) + + +def config_complete() -> bool: + """True when all required path keys have values.""" + cfg = load_config() + return all(cfg.get(k) for k in ["skin_dir", "midi_dir"]) + + +def get_board() -> str: + """Return board name from config; default 'pico' in dev mode.""" + if _FROZEN: + return load_config().get("board", "unknown") + return load_config().get("board", "pico") + + +def is_pico_board() -> bool: + return get_board() in PICO_BOARDS + + +# ─── Path getters ───────────────────────────────────────────────────────────── + +def _repo_root() -> Path: + """Repo root — only valid in dev mode.""" + return Path(__file__).resolve().parent.parent.parent + + +def get_skin_dir() -> Path: + if _FROZEN: + p = load_config().get("skin_dir") + if not p: + raise RuntimeError("Skin folder not configured — open Settings.") + return Path(p) + return _repo_root() / "mixxx" / "SKIN" / "XDJ100SX" + + +def get_midi_dir() -> Path: + if _FROZEN: + p = load_config().get("midi_dir") + if not p: + raise RuntimeError("MIDI folder not configured — open Settings.") + return Path(p) + return _repo_root() / "mixxx" / "MIDI" + + +def get_firmware_dir() -> Path: + if _FROZEN: + p = load_config().get("firmware_dir") + if not p: + raise RuntimeError("Firmware folder not configured — open Settings.") + return Path(p) + return _repo_root() / "arduino" / "pico" + + +def get_backup_dir() -> Path: + if _FROZEN: + p = load_config().get("backup_dir") + if p: + return Path(p) + # Default: cwd for dev, ~/Downloads/XDJ-Backups for standalone + if _FROZEN: + d = Path.home() / "Downloads" / "XDJ-Backups" + d.mkdir(parents=True, exist_ok=True) + return d + return _repo_root() + + +def get_saved_host() -> str | None: + """Return the remembered Pi host, or None if not set.""" + return load_config().get("host") or None + + +def get_ssh_user() -> str: + """SSH username: config file → XDJ_USER env → built-in default.""" + v = load_config().get("ssh_user") + return v if v else os.environ.get("XDJ_USER", "xdj100sx") + + +def get_ssh_pass() -> str: + """SSH password: config file → XDJ_PASS env → built-in default.""" + v = load_config().get("ssh_pass") + return v if v else os.environ.get("XDJ_PASS", "xdj100sx") diff --git a/tools/xdj_pi_dev/image_utils.py b/tools/xdj_pi_dev/image_utils.py new file mode 100644 index 0000000..309ee97 --- /dev/null +++ b/tools/xdj_pi_dev/image_utils.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +""" +Image utilities: white-background removal and PIL-image → Rich-Text rendering. + +Half-block trick: the ▄ character covers the BOTTOM half of a terminal cell. + • Both halves opaque → bgcolor=top, color=bottom, char=▄ + • Top transparent → color=bottom, no bgcolor (panel bg shows through), char=▄ + • Bottom transparent → color=top, no bgcolor, char=▀ (upper-half block) + • Both transparent → space (panel background shows through) + +This avoids any fixed background-colour guessing, so the image blends naturally +against whatever Textual's panel colour happens to be. +""" + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PIL import Image as _PILImage + +_IMAGES_DIR = Path(__file__).parent / "images" + +# Pixels with alpha below this are treated as fully transparent +_ALPHA_THRESH = 80 + + +def _remove_white_bg(img: "_PILImage.Image", threshold: int = 230) -> "_PILImage.Image": + """ + BFS flood-fill from all four corners: connected white pixels become transparent. + Corner-flood avoids touching white silkscreen text on the board itself. + """ + img = img.convert("RGBA") + w, h = img.size + pixels = img.load() + + def _is_white(px) -> bool: + return px[0] >= threshold and px[1] >= threshold and px[2] >= threshold + + visited: set[tuple[int, int]] = set() + queue: list[tuple[int, int]] = [] + for sx, sy in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]: + if _is_white(pixels[sx, sy]) and (sx, sy) not in visited: + queue.append((sx, sy)) + visited.add((sx, sy)) + + while queue: + x, y = queue.pop() + r, g, b, _ = pixels[x, y] + pixels[x, y] = (r, g, b, 0) + for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in visited: + if _is_white(pixels[nx, ny]): + visited.add((nx, ny)) + queue.append((nx, ny)) + + return img + + +def img_to_rich(img: "_PILImage.Image", target_width: int = 32) -> object: + """ + Convert a PIL RGBA image to a Rich Text object using half-block characters. + Transparent areas render as plain spaces so the panel background shows through. + Returns a rich.text.Text ready to pass to Static() or RichLog.write(). + """ + from PIL import Image + from rich.text import Text + from rich.style import Style + from rich.color import Color + + aspect = img.height / img.width + target_height = max(2, int(target_width * aspect * 0.5) * 2) # must be even + img = img.resize((target_width, target_height), Image.LANCZOS).convert("RGBA") + pixels = img.load() + + result = Text(no_wrap=True) + for row in range(0, target_height, 2): + if row > 0: + result.append("\n") + for col in range(target_width): + tr, tg, tb, ta = pixels[col, row] + br, bg, bb, ba = pixels[col, min(row + 1, target_height - 1)] + top_opaque = ta > _ALPHA_THRESH + bot_opaque = ba > _ALPHA_THRESH + + if not top_opaque and not bot_opaque: + # Both transparent — let the panel background show through + result.append(" ") + elif top_opaque and bot_opaque: + # Both opaque — full half-block encoding + result.append("▄", style=Style( + bgcolor=Color.from_rgb(tr, tg, tb), + color=Color.from_rgb(br, bg, bb), + )) + elif not top_opaque and bot_opaque: + # Bottom opaque, top transparent — ▄ with no background + result.append("▄", style=Style(color=Color.from_rgb(br, bg, bb))) + else: + # Top opaque, bottom transparent — upper-half block, no background + result.append("▀", style=Style(color=Color.from_rgb(tr, tg, tb))) + return result + + +def load_board_rich(board_key: str, target_width: int = 32) -> object | None: + """ + Load, background-remove, and render a board image as Rich Text. + Returns None if Pillow is not installed or the image is missing. + board_key: 'pico' | 'pico2' | 'teensy' + """ + _FILE_MAP = { + "pico": "raspberry pi pico.png", + "pico2": "raspberry pi pico 2.png", + "teensy": "teensy 4-0.png", + } + fname = _FILE_MAP.get(board_key) + if not fname: + return None + path = _IMAGES_DIR / fname + if not path.exists(): + return None + try: + from PIL import Image + img = Image.open(path) + img = _remove_white_bg(img) + return img_to_rich(img, target_width) + except Exception: + return None diff --git a/tools/xdj_pi_dev/messages.py b/tools/xdj_pi_dev/messages.py new file mode 100644 index 0000000..05a5415 --- /dev/null +++ b/tools/xdj_pi_dev/messages.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +""" +All user-visible strings in one place. +Use MSG["key"].format(...) for parametric messages. +""" + +MSG: dict[str, str] = { + # Connection + "connecting": "Connecting to {host}…", + "connected": "Connected to {host}", + "not_connected": "Not connected yet — connect to the Pi first.", + "already_connecting": "Already connecting — please wait.", + "cant_reach_pi": "Can't find the Raspberry Pi. Check that it's powered on and connected.", + "cant_reach_pi_detail": ( + "Can't find the Raspberry Pi.\n" + "Tried: {tried}\n" + "If using a direct cable, set up the network alias first:\n" + "{instructions}" + ), + + # General operation state + "busy": "Still working on the last task — please wait.", + "op_cancelled": "Operation cancelled.", + + # Push / pull + "pushing_skin": "Sending skin files to Pi…", + "pushing_skin_backup": "Sending skin files to Pi (saving remote copy first)…", + "pushed_skin": "Sent {n} file(s) to Pi.", + "pulling_skin": "Getting skin files from Pi…", + "pulling_skin_backup": "Getting skin files from Pi (saving local copy first)…", + "pulled_skin": "Got {n} file(s) from Pi.", + "pushing_midi": "Sending MIDI mapping to Pi…", + "pushing_midi_backup": "Sending MIDI mapping to Pi (saving remote copy first)…", + "pushed_midi": "Sent {n} MIDI file(s) to Pi.", + "pulling_midi": "Getting MIDI mapping from Pi…", + "pulling_midi_backup": "Getting MIDI mapping from Pi (saving local copy first)…", + "pulled_midi": "Got {n} MIDI file(s) from Pi.", + "midi_reload": "Done. To apply: open Mixxx → Preferences → Controllers and reload the mapping.", + + # Screenshot + "screenshotting": "Capturing Pi display…", + "screenshot_saved": "Screenshot saved.", + + # Mixxx + "restarting_mixxx": "Restarting Mixxx…", + "mixxx_restarted": "Mixxx restarted.", + + # Watch mode + "watch_started": ( + "Watch mode ON\n" + "Watching local skin folder for changes.\n" + "Workflow: edit any skin XML on your Mac → save → file is pushed to the Pi\n" + "automatically → Mixxx reloads the skin (Ctrl+F5) → screenshot opens here.\n" + "No manual Push Skin needed while Watch is running." + ), + "watch_stopped": "Watch mode OFF.", + + # Pico bootloader + "pico_boot_starting": "Sending update signal to Pico…", + "pico_boot_ok": "Pico is in update mode. Click Flash UF2.", + "pico_boot_likely": ( + "Update signal sent — waiting for Pico to appear as a USB drive…\n" + "If Flash UF2 fails, wait a few more seconds then try again." + ), + "pico_boot_fail": ( + "Couldn't confirm Pico is in update mode.\n" + "Try clicking Bootloader first, then Flash UF2." + ), + + # Pico flash + "flashing": "Installing firmware on Pico…", + "flash_ok": "Firmware installed successfully.", + "flash_fail_nofile": "No firmware file found. Compile the firmware first.", + "flash_fail_nodev": ( + "Couldn't find Pico in update mode after 30 seconds.\n" + "Click Bootloader first, then try Flash UF2 again." + ), + "flash_fail_copy": "Firmware file could not be written to Pico.", + "pico_alive": "Pico is running on {port}.", + "pico_midi_ok": "MIDI is visible — reload the mapping in Mixxx: Preferences → Controllers", + "pico_midi_wait": "Pico is running but MIDI isn't visible yet — restart Mixxx.", + + # Setup / check + "preflight_start": "Pre-flight check…", + "ssh_keys_starting": "Setting up SSH key login…", + "dhcp_starting": "Configuring Pi DHCP server…", + "discovering": "Scanning network for Pi units…", + "no_units_found": "No Pi units found — check cable / network.", + "backup_starting": "Pi backup — reading used blocks only… (this takes a few minutes)", + "backup_wait": "Do not disconnect while the backup is running.", + "backup_saved": "Backup saved → {path}", + "verifying_backup": "Verifying backup {name}…", + "verify_ok": "Backup file looks good.", + + # Signal analyzer + "signal_an_started": "Signal Analyzer → {url}", + "signal_an_stopped": "Signal Analyzer stopped.", +} diff --git a/tools/xdj_pi_dev/midi.py b/tools/xdj_pi_dev/midi.py new file mode 100644 index 0000000..8172bf8 --- /dev/null +++ b/tools/xdj_pi_dev/midi.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +""" +MIDI monitor — stream live MIDI events from the Pico via aseqdump / amidi. + +Extracted from xdj-pi-dev.py (lines 742-843). +""" + +import re +import threading +import time + +from xdj_pi_dev._terminal import _tui_log_fn # noqa: F401 — re-exported for callers + + +# ─── MIDI line parser ───────────────────────────────────────────────────────── + +def _fmt_midi_line(text: str) -> tuple[str, str] | None: + """Parse one aseqdump or amidi -d line → (log_type, formatted_string) or None.""" + t = text.strip() + if not t or t.startswith("Source") or t.startswith("Waiting") or t.startswith("ALSA"): + return None + + # aseqdump: " 20:0 Control change 1, controller 7, value 64" + m = re.search(r'Control change\s+(\d+),\s*controller\s+(\d+),\s*value\s+(\d+)', t) + if m: + ch, ctrl, val = int(m.group(1)), int(m.group(2)), int(m.group(3)) + bar = "▓" * (val * 16 // 127) + "░" * (16 - val * 16 // 127) + return "info", f"CC ch{ch:2d} ctrl={ctrl:3d} val={val:3d} [{bar}]" + + m = re.search(r'Note on\s+(\d+),\s*note\s+(\d+),\s*velocity\s+(\d+)', t) + if m: + ch, note, vel = int(m.group(1)), int(m.group(2)), int(m.group(3)) + return "ok", f"NON ch{ch:2d} note={note:3d} vel={vel:3d}" + + m = re.search(r'Note off\s+(\d+),\s*note\s+(\d+)', t) + if m: + ch, note = int(m.group(1)), int(m.group(2)) + return "warn", f"NOF ch{ch:2d} note={note:3d}" + + m = re.search(r'Pitch bend\s+(\d+),\s*value\s+(-?\d+)', t) + if m: + ch, val = int(m.group(1)), int(m.group(2)) + return "info", f"PB ch{ch:2d} val={val:6d}" + + # amidi -d raw hex: "B0 07 40" + parts = t.split() + if parts and re.match(r'^[0-9A-Fa-f]{2}$', parts[0]): + try: + status = int(parts[0], 16) + mtype = (status & 0xF0) >> 4 + ch = (status & 0x0F) + 1 + b1 = int(parts[1], 16) if len(parts) > 1 else 0 + b2 = int(parts[2], 16) if len(parts) > 2 else 0 + if mtype == 0xB: + bar = "▓" * (b2 * 16 // 127) + "░" * (16 - b2 * 16 // 127) + return "info", f"CC ch{ch:2d} ctrl={b1:3d} val={b2:3d} [{bar}]" + if mtype == 0x9 and b2 > 0: + return "ok", f"NON ch{ch:2d} note={b1:3d} vel={b2:3d}" + if mtype == 0x8 or (mtype == 0x9 and b2 == 0): + return "warn", f"NOF ch{ch:2d} note={b1:3d}" + if mtype == 0xE: + val = (b2 << 7 | b1) - 8192 + return "info", f"PB ch{ch:2d} val={val:6d}" + except (ValueError, IndexError): + pass + + return "info", t + + +# ─── MIDI monitor ───────────────────────────────────────────────────────────── + +def midi_monitor(pi_client, stop_event: threading.Event, emit=None) -> None: + """Stream live MIDI from the Pico via aseqdump (falls back to amidi -d). + + Parameters + ---------- + pi_client: + A connected PiClient instance. + stop_event: + Threading event; monitoring stops when set. + emit: + Optional callable(level, msg) for TUI output. + """ + # Find the Pico's ALSA sequencer port + ports_out = pi_client.exec("aconnect -l 2>/dev/null") + seq_port = None + for line in ports_out["stdout"].splitlines(): + if "XDJ" in line or "Pico" in line: + m = re.search(r'client (\d+):', line) + if m: + seq_port = f"{m.group(1)}:0" + break + + # Build the monitoring command + if seq_port: + cmd = f"aseqdump -p {seq_port} 2>/dev/null || amidi -p hw:1,0,0 -d 2>&1" + label = f"aseqdump port {seq_port}" + else: + cmd = "amidi -p hw:1,0,0 -d 2>&1" + label = "amidi hw:1,0,0" + + if emit: + emit("head", f"MIDI monitor — {label} (press button to stop)") + + transport = pi_client._ssh.get_transport() + assert transport + channel = transport.open_session() + channel.set_combine_stderr(True) + channel.exec_command(cmd) + + buf = b"" + while not stop_event.is_set(): + if channel.recv_ready(): + buf += channel.recv(4096) + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + parsed = _fmt_midi_line(raw.decode(errors="replace")) + if parsed and emit: + emit(*parsed) + elif channel.exit_status_ready() and not channel.recv_ready(): + break + else: + time.sleep(0.02) + + channel.close() diff --git a/tools/xdj_pi_dev/pico_tools.py b/tools/xdj_pi_dev/pico_tools.py new file mode 100644 index 0000000..11869fe --- /dev/null +++ b/tools/xdj_pi_dev/pico_tools.py @@ -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() diff --git a/tools/xdj_pi_dev/setup_cmds.py b/tools/xdj_pi_dev/setup_cmds.py new file mode 100644 index 0000000..6670223 --- /dev/null +++ b/tools/xdj_pi_dev/setup_cmds.py @@ -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@ + 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.100–200 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()}") diff --git a/tools/xdj_pi_dev/signal_analyzer.py b/tools/xdj_pi_dev/signal_analyzer.py new file mode 100644 index 0000000..37f5b76 --- /dev/null +++ b/tools/xdj_pi_dev/signal_analyzer.py @@ -0,0 +1,906 @@ +""" +Signal Analyzer — live GPIO/ADC event viewer for XDJ-100SX Pico firmware. + +Two execution modes: + Web (TUI button): run_signal_analyzer_web(pi_client, stop_event, port) + CLI (--analyze): run_signal_analyzer_cli(pi_client) + +All public functions take a pi_client as their first argument instead of +relying on a module-level global, following Dependency Inversion. +""" + +from __future__ import annotations + +import collections +import re +import sys +import threading +import time +from typing import Any, Callable + +# ─── Pin map ───────────────────────────────────────────────────────────────── + +SA_PIN_NAMES: dict[int, str] = { + 0: "EJECT", 1: "TRACK PREV", 2: "TRACK NEXT", 3: "SEARCH BACK", + 4: "SEARCH FWD", 5: "CUE", 6: "PLAY", 7: "JET", + 8: "ZIP", 9: "WAH", 10: "HOLD", 11: "TIME", + 12: "MSTR TEMPO", 14: "JOG A", 15: "LED CUE", 16: "LED PLAY", + 17: "LED INTL", 18: "LED CD", 19: "JOG B", 20: "BROWSE A", + 21: "BROWSE B", 22: "LOAD", +} +SA_BUTTON_PINS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 22] +SA_JOG_PINS = [14, 19] +SA_BROWSE_PINS = [20, 21] +SA_LED_PINS = [15, 16, 17, 18] +SA_ALL_PINS = sorted(set(SA_BUTTON_PINS + SA_JOG_PINS + SA_BROWSE_PINS + SA_LED_PINS)) + +_BOUNCE_WINDOW_US = 50_000 # 50 ms: edges within this window on same pin = bounce +_WAVEFORM_LEN = 40 + + +# ─── Shared state ───────────────────────────────────────────────────────────── + +class SAState: + """Signal-analyzer state updated by a reader thread and read by display.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.t_ref_us: int = 0 + self.t_latest_us: int = 0 + self.running: bool = False + self.pin_state: dict[int, int] = {p: 1 for p in SA_ALL_PINS} + self.pin_edges: dict[int, int] = {p: 0 for p in SA_ALL_PINS} + self.pin_waveform: dict[int, Any] = { + p: collections.deque([1] * _WAVEFORM_LEN, maxlen=_WAVEFORM_LEN) + for p in SA_ALL_PINS + } + self.bounce_count: dict[int, int] = {p: 0 for p in SA_BUTTON_PINS} + self.bounce_max_us: dict[int, int] = {p: 0 for p in SA_BUTTON_PINS} + self._press_t0: dict[int, int] = {} + self._press_n: dict[int, int] = {} + self._press_last: dict[int, int] = {} + self.jog_a_state = 1 + self.jog_b_state = 1 + self.jog_net = 0 + self.adc_current = 512 + self.adc_min = 1023 + self.adc_max = 0 + self.adc_sum = 0.0 + self.adc_count = 0 + self.adc_history: Any = collections.deque(maxlen=60) + self.events: Any = collections.deque(maxlen=20) + + def update_gpio(self, t_us: int, pin: int, val: int) -> None: + with self.lock: + if self.t_ref_us == 0: + self.t_ref_us = t_us + self.t_latest_us = t_us + self.running = True + if pin not in SA_ALL_PINS: + return + if val == self.pin_state.get(pin, 1): + return + self.pin_state[pin] = val + self.pin_edges[pin] += 1 + self.pin_waveform[pin].append(val) + rel = t_us - self.t_ref_us + edge = "▔→▁" if val == 0 else "▁→▔" + self.events.append((rel, pin, edge)) + if pin in SA_BUTTON_PINS: + self._track_bounce(pin, t_us, val) + if pin == 14: + self.jog_a_state = val + self.jog_net += 1 if val != self.jog_b_state else -1 + elif pin == 19: + self.jog_b_state = val + + def _track_bounce(self, pin: int, t_us: int, val: int) -> None: + if val == 0 and pin not in self._press_t0: + self._press_t0[pin] = self._press_last[pin] = t_us + self._press_n[pin] = 1 + return + if pin not in self._press_t0: + return + if t_us - self._press_last[pin] > _BOUNCE_WINDOW_US: + del self._press_t0[pin], self._press_n[pin], self._press_last[pin] + if val == 0: + self._press_t0[pin] = self._press_last[pin] = t_us + self._press_n[pin] = 1 + return + self._press_n[pin] += 1 + self._press_last[pin] = t_us + if self._press_n[pin] > 2: + dur = t_us - self._press_t0[pin] + self.bounce_count[pin] += 1 + self.bounce_max_us[pin] = max(self.bounce_max_us.get(pin, 0), dur) + + def update_adc(self, t_us: int, val: int) -> None: + with self.lock: + if self.t_ref_us == 0: + self.t_ref_us = t_us + self.running = True + self.adc_current = val + self.adc_min = min(self.adc_min, val) + self.adc_max = max(self.adc_max, val) + self.adc_sum += val + self.adc_count += 1 + self.adc_history.append(val) + + def reset_counters(self) -> None: + with self.lock: + for p in SA_ALL_PINS: + self.pin_edges[p] = 0 + cur = self.pin_state.get(p, 1) + for _ in range(_WAVEFORM_LEN): + self.pin_waveform[p].append(cur) + for p in SA_BUTTON_PINS: + self.bounce_count[p] = 0 + self.bounce_max_us[p] = 0 + self._press_t0.clear(); self._press_n.clear(); self._press_last.clear() + self.jog_net = 0 + self.adc_min = 1023; self.adc_max = 0 + self.adc_sum = 0.0; self.adc_count = 0 + self.adc_history.clear() + self.events.clear() + + +# ─── Protocol parsing ───────────────────────────────────────────────────────── + +def parse_line(line: str) -> tuple | None: + """Parse one SA: protocol line from Pico Core 1.""" + if not line.startswith("SA:"): + return None + if line == "SA:START": + return ("start",) + if "ADC=" in line: + m = re.search(r'ADC=(\d+).*T=(\d+)', line) + if m: + return ("adc", int(m.group(1)), int(m.group(2))) + return None + m = re.search(r'T=(\d+),P=(\d+),V=(\d+)', line) + if m: + return ("gpio", int(m.group(1)), int(m.group(2)), int(m.group(3))) + return None + + +def open_channel(pi_client: Any) -> Any: + """Open an SSH channel that streams /dev/ttyACM0 from the Pi.""" + transport = pi_client._ssh.get_transport() + assert transport + ch = transport.open_session() + ch.set_combine_stderr(True) + ch.exec_command("stty -F /dev/ttyACM0 115200 raw -echo 2>/dev/null; cat /dev/ttyACM0") + return ch + + +# ─── Web server (SSE) ───────────────────────────────────────────────────────── + +# Embedded HTML — kept in one place so the web UI and the Python server stay in sync. +_WEB_HTML = r""" + +XDJ Signal Analyzer + + + ⬡ XDJ Signal Analyzer + ● connecting… + 00:00:00│ + 0 events + + Reset + Clear Log + + + + ALL + BUTTONS + JOG + LEDs + click row to inspect · SSE live stream + + + + PINNAMESTATEEDGESBOUNCEMAX µsWAVEFORM + + + + + ━ SELECTED PIN ━━━━━━━━━━━━━━━━━━━━━━ + ↑ click a row + + + ━ JOG QUADRATURE ━━━━━━━━━━━━━━━━━━━ + + + + ━ PITCH ADC GP26 ━━━━━━━━━━━━━━━━━━━ + + + + + + + +""" + + +def run_signal_analyzer_web(pi_client: Any, stop_event: threading.Event, port: int) -> None: + """HTTP + SSE server that streams GPIO/ADC events to a browser page. + + Blocks until stop_event is set — run in a daemon thread. + """ + import json as _json + import http.server + import socketserver + + state = SAState() + clients: list = [] + clock = threading.Lock() + + def _broadcast(msg: dict) -> None: + data = ("data: " + _json.dumps(msg) + "\n\n").encode() + with clock: + dead = [] + for w in list(clients): + try: + w.write(data); w.flush() + except Exception: + dead.append(w) + for w in dead: + clients.remove(w) + + def _reader() -> None: + try: + ch = open_channel(pi_client) + buf = b"" + while not stop_event.is_set(): + if ch.recv_ready(): + buf += ch.recv(4096) + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + parsed = parse_line(raw.decode(errors="replace").strip()) + if not parsed: + continue + if parsed[0] == "gpio": + t_us, pin, val = parsed[1], parsed[2], parsed[3] + state.update_gpio(t_us, pin, val) + with state.lock: + _broadcast({ + "type": "gpio", "pin": pin, "val": val, "t": t_us, + "name": SA_PIN_NAMES.get(pin, f"GP{pin}"), + "edges": state.pin_edges.get(pin, 0), + "bounce": state.bounce_count.get(pin, 0), + "bounce_max_us": state.bounce_max_us.get(pin, 0), + "wf": list(state.pin_waveform.get(pin, []))[-24:], + }) + elif parsed[0] == "adc": + state.update_adc(parsed[2], parsed[1]) + with state.lock: + n = state.adc_count or 1 + _broadcast({ + "type": "adc", + "val": state.adc_current, "min": state.adc_min, + "max": state.adc_max, "mean": state.adc_sum / n, + "hist": list(state.adc_history)[-44:], + }) + elif ch.exit_status_ready(): + _broadcast({"type": "error", "msg": "Serial stream closed — Pico disconnected?"}) + break + else: + time.sleep(0.01) + ch.close() + except Exception as exc: + _broadcast({"type": "error", "msg": str(exc)}) + + threading.Thread(target=_reader, daemon=True).start() + + html = (_WEB_HTML + .replace("%%PIN_NAMES%%", _json.dumps({str(k): v for k, v in SA_PIN_NAMES.items()})) + .replace("%%BTN_PINS%%", _json.dumps(SA_BUTTON_PINS)) + .replace("%%JOG_PINS%%", _json.dumps(SA_JOG_PINS + SA_BROWSE_PINS)) + .replace("%%LED_PINS%%", _json.dumps(SA_LED_PINS)) + .replace("%%ALL_PINS%%", _json.dumps(SA_ALL_PINS)) + .replace("%%HOST%%", pi_client.host)) + html_b = html.encode() + + class _Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): pass + + def do_GET(self) -> None: + if self.path in ("/", "/index.html"): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html_b))) + self.end_headers() + self.wfile.write(html_b) + + elif self.path == "/stream": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + with state.lock: + for pin in SA_ALL_PINS: + snap = { + "type": "gpio", "pin": pin, + "val": state.pin_state.get(pin, 1), "t": 0, + "name": SA_PIN_NAMES.get(pin, f"GP{pin}"), + "edges": state.pin_edges.get(pin, 0), + "bounce": state.bounce_count.get(pin, 0), + "bounce_max_us": state.bounce_max_us.get(pin, 0), + "wf": list(state.pin_waveform.get(pin, []))[-24:], + } + self.wfile.write(("data: " + _json.dumps(snap) + "\n\n").encode()) + self.wfile.flush() + with clock: + clients.append(self.wfile) + try: + while not stop_event.is_set(): + time.sleep(1) + self.wfile.write(b": ka\n\n") + self.wfile.flush() + except Exception: + pass + finally: + with clock: + try: clients.remove(self.wfile) + except ValueError: pass + else: + self.send_error(404) + + class _Server(socketserver.TCPServer): + allow_reuse_address = True + + with _Server(("", port), _Handler) as srv: + srv.timeout = 0.5 + while not stop_event.is_set(): + srv.handle_request() + + +# ─── CLI dashboard (Textual) ────────────────────────────────────────────────── + +def run_signal_analyzer_cli(pi_client: Any) -> None: + """Launch the full Textual signal analyzer dashboard (--analyze flag).""" + try: + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical + from textual.widgets import Button, DataTable, Footer, RichLog, Static + from textual import work as _tw + from rich.text import Text + except ImportError: + print("textual not installed — run: pip install textual", file=sys.stderr) + return + + chk = pi_client.exec("ls /dev/ttyACM0 2>/dev/null || echo MISSING") + if "MISSING" in chk["stdout"]: + print("/dev/ttyACM0 not found — Pico not connected or firmware not flashed", file=sys.stderr) + return + + state = SAState() + stop = threading.Event() + + FILTER_GROUPS: dict[str, list[int]] = { + "g-all": SA_ALL_PINS, + "g-buttons": SA_BUTTON_PINS, + "g-jog": SA_JOG_PINS + SA_BROWSE_PINS, + "g-leds": SA_LED_PINS, + } + + class SignalAnalyzerApp(App): # type: ignore[misc] + TITLE = "XDJ Signal Analyzer" + CSS = """ + Screen { background: #0d1117; color: #c9d1d9; } + #hdr { height: 1; padding: 0 2; background: #161b22; color: #58a6ff; content-align: left middle; } + #fbar { height: 3; padding: 0 1; background: #161b22; border-bottom: solid #30363d; align: left middle; } + .gbtn { height: 1; min-width: 11; border: solid #30363d; margin-right: 1; background: #21262d; color: #8b949e; } + .gbtn.-on { background: #1f6feb; color: #e6edf3; border: solid #388bfd; } + #fhint { color: #6e7681; margin-left: 2; } + #body { height: 1fr; } + #left { width: 58; border-right: solid #21262d; } + DataTable { height: 1fr; } + DataTable > .datatable--header { background: #161b22; color: #8b949e; text-style: bold; } + DataTable > .datatable--cursor { background: #1f6feb33; } + DataTable > .datatable--zebra-stripe { background: #161b2244; } + #right { width: 1fr; padding: 0; } + #detail { height: 7; border-bottom: solid #21262d; padding: 1 2; background: #161b22; } + #jog { height: 8; border-bottom: solid #21262d; padding: 1 2; background: #0d1117; } + #adc { height: 1fr; padding: 1 2; background: #161b22; } + #evlog { height: 8; border-top: solid #21262d; } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("r", "reset_all", "Reset"), + Binding("l", "clear_log", "Clear log"), + Binding("space", "toggle_pin", "Toggle"), + Binding("i", "isolate_pin", "Isolate"), + Binding("a", "show_all", "All"), + Binding("1", "grp1", "Buttons"), + Binding("2", "grp2", "Jog"), + Binding("3", "grp3", "LEDs"), + ] + + def __init__(self) -> None: + super().__init__() + self._t0 = time.time() + self._visible: set[int] = set(SA_ALL_PINS) + self._last_chk: dict[int, str] = {} + + def compose(self) -> ComposeResult: + yield Static("", id="hdr") + with Horizontal(id="fbar"): + yield Button("ALL", id="g-all", classes="gbtn -on") + yield Button("BUTTONS", id="g-buttons", classes="gbtn") + yield Button("JOG", id="g-jog", classes="gbtn") + yield Button("LEDs", id="g-leds", classes="gbtn") + yield Static( + " ↑↓ select [bold]Space[/]:toggle [bold]I[/]:isolate " + "[bold]A[/]:all [bold]1-3[/]:group [bold]R[/]:reset [bold]L[/]:clear log", + id="fhint", + ) + with Horizontal(id="body"): + with Vertical(id="left"): + yield DataTable(id="tbl", cursor_type="row", zebra_stripes=True) + with Vertical(id="right"): + yield Static("", id="detail") + yield Static("", id="jog") + yield Static("", id="adc") + yield RichLog(id="evlog", highlight=True, markup=True, max_lines=500) + yield Footer() + + def on_mount(self) -> None: + self._rebuild_table() + self.set_interval(0.15, self._tick) + self._start_reader() + + def on_worker_state_changed(self, event: object) -> None: + pass # suppress app exit on unhandled worker exception + + @_tw(thread=True) + def _start_reader(self) -> None: + try: + evlog = self.query_one("#evlog", RichLog) + channel = open_channel(pi_client) + buf = b"" + while not stop.is_set(): + if channel.recv_ready(): + buf += channel.recv(4096) + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + parsed = parse_line(raw.decode(errors="replace").strip()) + if parsed is None: + continue + if parsed[0] == "gpio": + t_us, pin, val = parsed[1], parsed[2], parsed[3] + state.update_gpio(t_us, pin, val) + if pin in self._visible: + self.call_from_thread(self._log_gpio, t_us, pin, val) + elif parsed[0] == "adc": + state.update_adc(parsed[2], parsed[1]) + elif channel.exit_status_ready(): + self.call_from_thread( + evlog.write, "[red]⚠ Serial stream closed — Pico disconnected?[/]" + ) + break + else: + time.sleep(0.01) + channel.close() + except Exception as exc: + try: + self.call_from_thread( + self.query_one("#evlog", RichLog).write, + f"[red bold]Reader error:[/] [red]{exc}[/]", + ) + except Exception: + pass + + def _log_gpio(self, t_us: int, pin: int, val: int) -> None: + name = SA_PIN_NAMES.get(pin, f"GP{pin}") + edge = "[green]▁→▔[/]" if val else "[yellow]▔→▁[/]" + rel_s = (t_us - state.t_ref_us) / 1_000_000 if state.t_ref_us else 0.0 + with state.lock: + bc = state.bounce_count.get(pin, 0) + bc_s = f" [red bold]⚡ bounce ×{bc}[/]" if bc else "" + self.query_one("#evlog", RichLog).write( + f"[dim]{rel_s:9.3f}s[/] [cyan]GP{pin:02d}[/] {name:<14} {edge}{bc_s}" + ) + + def _rebuild_table(self) -> None: + tbl = self.query_one("#tbl", DataTable) + tbl.clear(columns=True) + tbl.add_column("PIN", key="pin", width=6) + tbl.add_column("NAME", key="name", width=14) + tbl.add_column("STATE", key="st", width=6) + tbl.add_column("EDGES", key="ed", width=7) + tbl.add_column("BOUNCE", key="bc", width=8) + tbl.add_column("MAX µs", key="bm", width=9) + tbl.add_column("WAVEFORM (last 24)", key="wf", width=26) + self._last_chk.clear() + for pin in sorted(p for p in SA_ALL_PINS if p in self._visible): + tbl.add_row( + f"GP{pin:02d}", SA_PIN_NAMES.get(pin, "?"), + "▔ HI", "0", "—", "—", "▔" * 24, + key=str(pin), + ) + + def _tick(self) -> None: + el = time.time() - self._t0 + hh = int(el)//3600; mm = (int(el)%3600)//60; ss = int(el)%60 + with state.lock: + tot = sum(state.pin_edges.values()) + self.query_one("#hdr", Static).update( + f"[bold cyan]XDJ Signal Analyzer[/] │ [green]{pi_client.host}[/] │ " + f"[dim]{hh:02d}:{mm:02d}:{ss:02d}[/] │ total events: [bold]{tot}[/]" + ) + self._update_table() + self._update_right() + + def _update_table(self) -> None: + tbl = self.query_one("#tbl", DataTable) + pins = sorted(p for p in SA_ALL_PINS if p in self._visible) + with state.lock: + snap = { + p: ( + state.pin_state.get(p, 1), + state.pin_edges.get(p, 0), + state.bounce_count.get(p, 0), + state.bounce_max_us.get(p, 0), + list(state.pin_waveform.get(p, []))[-24:], + ) + for p in pins + } + for pin in pins: + s_, ed, bc, bm, wf = snap[pin] + chk = f"{s_},{ed},{bc}" + if self._last_chk.get(pin) == chk: + continue + self._last_chk[pin] = chk + rk = str(pin) + st_c = "green" if s_ else "yellow" + ed_c = "bold white" if ed > 0 else "dim" + bc_c = "red bold" if bc > 0 else "dim" + wf_s = "".join("▔" if x else "▁" for x in wf) + try: + tbl.update_cell(rk, "st", Text("▔ HI" if s_ else "▁ LO", style=st_c), update_width=False) + tbl.update_cell(rk, "ed", Text(str(ed), style=ed_c), update_width=False) + tbl.update_cell(rk, "bc", Text(str(bc) if bc else "—", style=bc_c), update_width=False) + tbl.update_cell(rk, "bm", Text(str(bm) if bm else "—", style="red" if bm else "dim"), update_width=False) + tbl.update_cell(rk, "wf", Text(wf_s, style=st_c), update_width=False) + except Exception: + pass + + def _update_right(self) -> None: + tbl = self.query_one("#tbl", DataTable) + sel_pin: int | None = None + try: + rk = tbl.cursor_row_key + if rk is not None: + sel_pin = int(str(rk)) + except Exception: + pass + + if sel_pin is not None and sel_pin in self._visible: + with state.lock: + s_ = state.pin_state.get(sel_pin, 1) + ed = state.pin_edges.get(sel_pin, 0) + bc = state.bounce_count.get(sel_pin, 0) + bm = state.bounce_max_us.get(sel_pin, 0) + wf = list(state.pin_waveform.get(sel_pin, [])) + name = SA_PIN_NAMES.get(sel_pin, f"GP{sel_pin}") + st_c = "green" if s_ else "yellow" + wf_s = "".join("▔" if x else "▁" for x in wf) + bc_s = (f"[red]⚡ bounce ×{bc} longest: {bm} µs[/]" + if bc else "[dim]no bounce detected[/]") + self.query_one("#detail", Static).update( + f"[bold cyan]━━ GP{sel_pin:02d} {name} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n" + f" [{st_c}]{'▔ HIGH' if s_ else '▁ LOW '}[/] edges: [bold]{ed}[/] {bc_s}\n\n" + f" [{st_c}]{wf_s}[/]" + ) + else: + self.query_one("#detail", Static).update( + "[dim] ↑↓ navigate the table to inspect a pin[/]" + ) + + with state.lock: + wf_a = list(state.pin_waveform.get(14, [])) + wf_b = list(state.pin_waveform.get(19, [])) + a_ed = state.pin_edges.get(14, 0) + b_ed = state.pin_edges.get(19, 0) + net = state.jog_net + wf_a_s = "".join("▔" if x else "▁" for x in wf_a) + wf_b_s = "".join("▔" if x else "▁" for x in wf_b) + nc = "green" if net > 0 else ("yellow" if net < 0 else "dim") + ds = "CW" if net >= 0 else "CCW" + self.query_one("#jog", Static).update( + f"[bold cyan]━━ JOG QUADRATURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n" + f" [green]A GP14[/] [green]{wf_a_s}[/] [dim]edges:[/] [bold]{a_ed}[/]\n" + f" [yellow]B GP19[/] [yellow]{wf_b_s}[/] [dim]edges:[/] [bold]{b_ed}[/]\n\n" + f" Net: [{nc}]{ds} {abs(net):5d}[/] ticks " + f"[dim]A:{a_ed} B:{b_ed} diff:{abs(a_ed - b_ed)}[/]" + ) + + with state.lock: + av = state.adc_current + amin = state.adc_min if state.adc_count > 0 else 512 + amax = state.adc_max if state.adc_count > 0 else 512 + amean = (state.adc_sum / state.adc_count) if state.adc_count > 0 else 512.0 + hist = list(state.adc_history) + cnt = state.adc_count + center = abs(av - 512) <= 8 + adc_c = "green" if center else "yellow" + blen = 34 + filled = max(0, min(blen, av * blen // 1024)) + bar = "█" * filled + "░" * (blen - filled) + spc = " ▁▂▃▄▅▆▇█" + if hist and cnt > 1: + mn, mx = min(hist), max(hist); rng = mx - mn or 1 + spark = "".join(spc[int((v - mn) * 8 // rng)] for v in hist[-44:]) + else: + spark = "[dim](waiting for samples…)[/]" + jitter = (amax - amin) // 2 if cnt > 0 else 0 + ctr_s = "[green]● CENTER[/]" if center else f"[yellow]{av - 512:+d} off center[/]" + self.query_one("#adc", Static).update( + f"[bold cyan]━━ PITCH ADC GP26/ADC0 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n\n" + f" [{adc_c}][{bar}][/] [{adc_c}]{av:4d}[/] {ctr_s}\n\n" + f" [dim]min:[/][cyan]{amin}[/] [dim]max:[/][cyan]{amax}[/] " + f"[dim]mean:[/][cyan]{amean:.1f}[/] " + f"[dim]jitter:[/][{'green' if jitter < 5 else 'yellow'}]±{jitter}[/]\n\n" + f" [cyan]{spark}[/]" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id + if bid not in FILTER_GROUPS: + return + for fid in FILTER_GROUPS: + self.query_one(f"#{fid}", Button).remove_class("-on") + event.button.add_class("-on") + new = set(FILTER_GROUPS[bid]) + if new != self._visible: + self._visible = new + self._rebuild_table() + + def action_toggle_pin(self) -> None: + tbl = self.query_one("#tbl", DataTable) + try: + pin = int(str(tbl.cursor_row_key)) + except Exception: + return + if pin in self._visible: + self._visible.discard(pin) + else: + self._visible.add(pin) + self._rebuild_table() + + def action_isolate_pin(self) -> None: + tbl = self.query_one("#tbl", DataTable) + try: + pin = int(str(tbl.cursor_row_key)) + except Exception: + return + self._visible = {pin} + for fid in FILTER_GROUPS: + self.query_one(f"#{fid}", Button).remove_class("-on") + self._rebuild_table() + + def action_show_all(self) -> None: + self._visible = set(SA_ALL_PINS) + for fid in FILTER_GROUPS: + self.query_one(f"#{fid}", Button).remove_class("-on") + self.query_one("#g-all", Button).add_class("-on") + self._rebuild_table() + + def action_grp1(self) -> None: + self.query_one("#g-buttons", Button).press() + + def action_grp2(self) -> None: + self.query_one("#g-jog", Button).press() + + def action_grp3(self) -> None: + self.query_one("#g-leds", Button).press() + + def action_clear_log(self) -> None: + log = self.query_one("#evlog", RichLog) + log.clear() + log.write("[dim]── log cleared ──[/]") + + def action_reset_all(self) -> None: + state.reset_counters() + self._t0 = time.time() + self._last_chk.clear() + log = self.query_one("#evlog", RichLog) + log.clear() + log.write("[dim]── counters reset ──[/]") + + def on_unmount(self) -> None: + stop.set() + + SignalAnalyzerApp().run() + stop.set() diff --git a/tools/xdj_pi_dev/watch.py b/tools/xdj_pi_dev/watch.py new file mode 100644 index 0000000..6f635bf --- /dev/null +++ b/tools/xdj_pi_dev/watch.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +""" +Watch mode: detect skin file changes and auto-deploy to Pi. + +Extracted from xdj-pi-dev.py (lines 1706-1863). +""" + +import os +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +from xdj_pi_dev._terminal import warn + +# ─── Module-level config ────────────────────────────────────────────────────── + +# 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_SKIN = _REPO_ROOT / "mixxx" / "SKIN" / "XDJ100SX" +_PI_SKIN = f"/home/{_PI_USER}/.mixxx/skins/XDJ100SX" +_PI_MIXXX_ENV = f"DISPLAY=:0 XAUTHORITY=/home/{_PI_USER}/.Xauthority" + +_PANELS = { + "hotcue": (368, 57), "hc": (368, 57), + "beatloop": (464, 57), "bl": (464, 57), + "keyshift": (560, 57), "ks": (560, 57), + "beatjump": (656, 57), "bj": (656, 57), + "stems": (752, 57), "st": (752, 57), +} + +_FILE_PANEL = { + "hotcues.xml": "hotcue", + "beatloop.xml": "beatloop", + "keyshift.xml": "keyshift", + "beatjump.xml": "beatjump", + "stems.xml": "stems", +} + + +# ─── Platform helpers ───────────────────────────────────────────────────────── + +def _tmp_path(name: str) -> Path: + return Path(tempfile.gettempdir()) / name + + +def _open_image(path: Path) -> None: + """Open an image in the default viewer — macOS, Linux, Windows.""" + s = str(path) + if sys.platform == "darwin": + subprocess.run(["open", s], check=False) + elif sys.platform == "win32": + os.startfile(s) + else: + subprocess.run(["xdg-open", s], check=False) + + +# ─── Navigation / screenshot inlined ───────────────────────────────────────── + +def _navigate_panel(pi_client, panel_key: str) -> None: + key = panel_key.lower().strip() + coords = _PANELS.get(key) + if not coords: + return + x, y = coords + pi_client.exec(f"{_PI_MIXXX_ENV} xdotool mousemove {x} {y} click 1", timeout=5) + time.sleep(0.4) + + +def _restart_mixxx(pi_client, panel: str | None = None) -> str: + pi_client.exec("killall mixxx 2>/dev/null; true") + time.sleep(6) + result = pi_client.exec("pgrep -a mixxx") + pid = result["stdout"].strip() + msg = (f"Mixxx restarted (PID {pid.split()[0]})" if pid + else "WARNING: Mixxx PID not found — may still be starting") + if panel: + time.sleep(1) + _navigate_panel(pi_client, panel) + return msg + + +def _take_screenshot(pi_client, panel: str | None = None) -> tuple[bytes | None, str | None]: + """Navigate to `panel` then capture. Returns (bytes, None) or (None, error).""" + if panel: + _navigate_panel(pi_client, panel) + pi_client.exec("rm -f /tmp/xdj_dev_screen.png") + r = pi_client.exec(f"{_PI_MIXXX_ENV} scrot /tmp/xdj_dev_screen.png 2>&1", timeout=10) + if r["rc"] != 0: + return None, (r["stdout"] + r["stderr"]).strip() + return pi_client.read_bytes("/tmp/xdj_dev_screen.png"), None + + +# ─── Deploy helper ──────────────────────────────────────────────────────────── + +def _deploy_changes(pi_client, paths: list[str], panel: str | None, restart: bool) -> None: + changed_names = [Path(p).name for p in paths] + print(f"\n[{time.strftime('%H:%M:%S')}] Changed: {', '.join(changed_names)}") + + for path in paths: + f = Path(path) + if not f.exists(): + continue + remote = f"{_PI_SKIN}/{f.relative_to(_REPO_SKIN).as_posix()}" + pi_client.write_bytes(remote, f.read_bytes()) + print(f" pushed {f.name}") + + target_panel = panel + if not target_panel: + for name in changed_names: + target_panel = _FILE_PANEL.get(name) + if target_panel: + break + + if restart: + print(" restarting Mixxx…") + print(f" {_restart_mixxx(pi_client, panel=target_panel)}") + else: + pi_client.exec(f"{_PI_MIXXX_ENV} xdotool key ctrl+F5", timeout=5) + time.sleep(2) + if target_panel: + _navigate_panel(pi_client, target_panel) + + print(" screenshotting…") + data, err = _take_screenshot(pi_client, panel=None) + if err: + print(f" screenshot failed: {err}") + return + + out = _tmp_path("xdj_watch_screen.png") + out.write_bytes(data) + _open_image(out) + print(f" screenshot → {out}") + + +# ─── Public watch_mode ──────────────────────────────────────────────────────── + +def watch_mode(pi_client, panel: str | None = None, restart: bool = False) -> None: + print(f"Watching {_REPO_SKIN}") + print("Edit any skin file — changes deploy to Pi automatically.") + print("Ctrl-C to stop.\n") + + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + class Handler(FileSystemEventHandler): + def __init__(self) -> None: + self.pending: dict[str, float] = {} + + def on_modified(self, event) -> None: + if not event.is_directory: + self.pending[event.src_path] = time.time() + + def on_created(self, event) -> None: + self.on_modified(event) + + handler = Handler() + observer = Observer() + observer.schedule(handler, str(_REPO_SKIN), recursive=True) + observer.start() + try: + while True: + time.sleep(0.5) + now = time.time() + ready = [p for p, t in list(handler.pending.items()) if now - t > 0.8] + if not ready: + continue + for path in ready: + del handler.pending[path] + _deploy_changes(pi_client, ready, panel, restart) + finally: + observer.stop() + observer.join() + + except ImportError: + print("(watchdog not installed — using 1s polling. pip install watchdog for faster)\n") + mtimes = {f: f.stat().st_mtime for f in _REPO_SKIN.rglob("*") if f.is_file()} + while True: + time.sleep(1) + changed = [] + for f in _REPO_SKIN.rglob("*"): + if not f.is_file(): + continue + mt = f.stat().st_mtime + if mt != mtimes.get(f): + mtimes[f] = mt + changed.append(str(f)) + if changed: + _deploy_changes(pi_client, changed, panel, restart) + + +# ─── Stoppable watch loop (used by TUI) ────────────────────────────────────── + +def _watch_loop(pi_client, stop_event: threading.Event, emit=None, screenshot_fn=None) -> None: + """Watch skin files and push on change. Stops when stop_event is set. + + screenshot_fn: optional callable(paths) invoked after each successful deploy. + """ + _emit = emit or (lambda lvl, msg: None) + _emit("info", f"Watching {_REPO_SKIN}") + + def _deploy(paths: list[str]) -> None: + names = [Path(p).name for p in paths] + _emit("info", f"Changed: {', '.join(names)}") + for path in paths: + f = Path(path) + if not f.exists(): + continue + remote = f"{_PI_SKIN}/{f.relative_to(_REPO_SKIN).as_posix()}" + pi_client.write_bytes(remote, f.read_bytes()) + _emit("ok", f"Pushed {len(paths)} file(s)") + if screenshot_fn: + try: + screenshot_fn(paths) + except Exception as _e: + _emit("warn", f"Screenshot: {_e}") + + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + class _Handler(FileSystemEventHandler): + def __init__(self) -> None: + self.pending: dict[str, float] = {} + + def on_modified(self, event) -> None: + if not event.is_directory: + self.pending[event.src_path] = time.time() + + def on_created(self, event) -> None: + self.on_modified(event) + + handler = _Handler() + observer = Observer() + observer.schedule(handler, str(_REPO_SKIN), recursive=True) + observer.start() + try: + while not stop_event.is_set(): + time.sleep(0.5) + now = time.time() + ready = [p for p, t in list(handler.pending.items()) if now - t > 0.8] + if ready: + for p in ready: + del handler.pending[p] + _deploy(ready) + finally: + observer.stop() + observer.join() + except ImportError: + _emit("warn", "watchdog not installed — using 1s polling") + mtimes = {f: f.stat().st_mtime for f in _REPO_SKIN.rglob("*") if f.is_file()} + while not stop_event.is_set(): + time.sleep(1) + changed = [ + str(f) for f in _REPO_SKIN.rglob("*") + if f.is_file() and f.stat().st_mtime != mtimes.get(f) + ] + for f in [Path(p) for p in changed]: + mtimes[f] = f.stat().st_mtime + if changed: + _deploy(changed)