add MCP developer tool with multi-unit support
First developer tool for the XDJ-100SX project. Connects Claude Code directly to the Pi over SSH — push skin files, take screenshots, restart Mixxx, flash Pico firmware, and more without leaving the editor. Available MCP tools: - run_command, read_file, write_file, list_files - push_skin, pull_skin, push_skin_file, pull_skin_file - push_midi, pull_midi - take_screenshot, navigate_panel - restart_mixxx - check (preflight: SSH, Mixxx, Pico, audio) - pico_bootloader, pico_flash - discover_units — scan network for all reachable XDJ Pi units - select_unit — switch active connection mid-session (multi-unit support) Also adds --about flag and TUI About modal with authors and credits, and fixes scrolling/close behavior on Help and About modals. By: Jeancarlo Cardoso de Faria Filho (jaianlab) <jaianlabworks@gmail.com>
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user