Compare commits

41 Commits

Author SHA1 Message Date
Marc Monka 61d561c534 Merge pull request #6 from ElJaian/jaianlab
add MCP developer tool, multi-unit support and skin dev guide
2026-05-14 10:18:58 +02:00
Jeancarlo 8b0eb42fec 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>
2026-05-08 01:24:15 -03:00
Marc Monka 3e7b0f0c3f IMG file shrink
Reduced the IMG Pi 3b+ image
2026-05-08 01:23:43 -03:00
Marc Monka c08b7466d1 Revision 1.1
- Updated code blocks appareance.
- Added UTF8 on usbmount
- Added Exfat support
2026-05-08 01:19:57 -03:00
Marc Monka 61c87361bd Edited arduino and xml files so new buttons send MIDI messages and mapped tto Mixxx functions 2026-03-17 18:02:03 +01:00
Markus Golec 2118dc8852 Delete mixxx/SKIN/XDJ100SX directory 2026-03-15 18:53:55 +01:00
Markus Golec a4b14b3d90 Delete mixxx/SKIN/HelveticaNeueUltraLightItalic.otf 2026-03-15 18:53:33 +01:00
Markus Golec 3feacb295e Delete mixxx/SKIN/HelveticaNeueUltraLight.otf 2026-03-15 18:53:26 +01:00
Markus Golec 07c92c0609 Add files via upload 2026-03-15 18:53:02 +01:00
Markus Golec b5b1eee974 Delete mixxx/XDJ200SX directory 2026-03-15 18:52:17 +01:00
Markus Golec 71d6caceda Add files via upload 2026-03-15 18:51:38 +01:00
Markus Golec 19a5340aa7 Delete documentation/PINOUT.xlsx 2026-03-15 18:39:39 +01:00
Markus Golec 75251efa19 Add files via upload 2026-03-15 18:39:18 +01:00
Markus Golec 4f3efc9a2b Add files via upload 2026-03-15 18:35:27 +01:00
Markus Golec b5790f4789 Delete datasheets/TeensyLCcard6b_rev4_web.pdf 2026-03-15 18:35:09 +01:00
Markus Golec 0b42c15ea5 Delete datasheets/TeensyLCcard6a_rev4_web.pdf 2026-03-15 18:34:58 +01:00
Markus Golec ba0b8473cc Add files via upload 2026-03-15 18:34:11 +01:00
Markus Golec e2394053b8 Delete datasheets/pioneer_cdj-100s-sm-rrv2027.pdf 2026-03-15 18:33:26 +01:00
Markus Golec 45b7bc2642 Delete mixxx/XDJ200SX.midi.xml 2026-03-15 18:32:28 +01:00
Markus Golec 19aa8f16fb Delete mixxx/XDJ200SX.js 2026-03-15 18:32:19 +01:00
Markus Golec cd6998d11c Add files via upload 2026-03-15 18:31:57 +01:00
Markus Golec d98aabfa43 Delete mixxx/MIDI 2026-03-15 18:28:14 +01:00
Markus Golec 583c9fbb75 Create MIDI 2026-03-15 18:27:53 +01:00
Markus Golec d581be16fa Add files via upload 2026-03-15 18:26:57 +01:00
Markus Golec 55579314c5 Delete mixxx/MIDI/XDJ100SX.midi.xml 2026-03-15 18:26:27 +01:00
Markus Golec 9e87d2e3d4 Delete mixxx/MIDI/XDJ100SX.js 2026-03-15 18:26:19 +01:00
Markus Golec a2b65ce6ae Delete print-assets/xdj100sx_print.pdf 2026-03-15 17:50:55 +01:00
Markus Golec 17730e4b94 Delete print-assets/xdj100sx_3dprintstl.stl 2026-03-15 17:50:45 +01:00
Markus Golec 77639ead8d Update name.c
XDJ200SX
2026-03-15 17:49:35 +01:00
Markus Golec 0d38935511 Delete arduino/XDJ100SX.ino 2026-03-15 17:49:02 +01:00
Markus Golec ca3afdce5e Arduino Sketch XDJ200SX.ino Version 1 2026-03-15 17:44:06 +01:00
Markus Golec 0df4ebc4f8 Update README.md 2026-01-06 09:34:37 +01:00
Markus Golec ad5153e578 Update README.md 2026-01-06 09:34:08 +01:00
Markus Golec 8ec940a529 Update README.md 2026-01-06 09:33:42 +01:00
Markus Golec 1f658836ca Update README.md 2026-01-06 09:31:56 +01:00
Markus Golec a995e2ba84 Update README.md 2026-01-06 09:30:02 +01:00
Markus Golec 3d373165cd Update README.md 2026-01-06 09:19:12 +01:00
Markus Golec 43338b4c35 Update README.md 2026-01-06 09:15:36 +01:00
Marc Monka 6f07eae7d5 Delete mixxx/helvetica-neue/readme.md 2025-12-04 10:23:19 +01:00
Marc Monka 3ada74d7fa Helvetica Font addition 2025-12-04 10:22:37 +01:00
Marc Monka e88031b0ac Create readme.md 2025-12-04 10:22:06 +01:00
78 changed files with 6361 additions and 247 deletions
Vendored
BIN
View File
Binary file not shown.
+17 -10
View File
@@ -1,19 +1,21 @@
# XDJ100SX
# XDJ200SX
An Open Source Standalone DJ Deck
This project is another experimental project that combines hardware, MIDI and open source software to convert an old Pioneer CDJ-100S to a standalone player using all modern features.
## This repository is a fork of [Marc Monkas XDJ100SX project](https://github.com/marcmonka/XDJ100SX).
- It focuses on adapting and extending the original base to support CDJ200 hardware.
New features and modifications will be added progressively.
- This project is another experimental project that combines hardware,
MIDI and open source software to convert an old Pioneer CDJ200 to a standalone player using all modern features.
![XDJ100SX](images/XDJ100SX-1.jpg)
![XDJ200SX](images/)
## Main Requirements:
- Raspberri Pi 3b+
- Arduino/Teensy with MIDI libraries
- Raspberry Pi 3b+
- Teensy++2.0 with MIDI libraries
## Content:
## Content: #in progress 01.2026
- /arduino/ -> firmware code
- /datasheets/ -> Original CDJ, Teensy and Raspberry datasheets
@@ -23,16 +25,21 @@ This project is another experimental project that combines hardware, MIDI and op
- /docs/ -> Documentation of this project
## Raspberri Pi 3B+ Image file
## Raspberry Pi 3B+ Image file
Note it only works with the Raspberry Pi 3B+
[Image File](https://drive.google.com/file/d/1xF3MYwbI78TJTnMUjNkVMXjbA1-jqFb2/view?usp=sharing).
[Image File](https://drive.google.com/file/d/1fU8ckY35uxCYHJtw1JgclCYaJdQCbCJT/view?usp=sharing).
[![Discord](https://img.shields.io/badge/Discord-Join_the_community-5865F2?logo=discord&logoColor=white)](https://discord.gg/4D3xxvuDTy)
### 📜 License
- All code in this repository is released under the [GNU GPL v3 License](https://www.gnu.org/licenses/gpl-3.0.html).
- All documentation, 3D models, and visual materials are released under the [Creative Commons BY-SA 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/).
2025 Marc Monka
2026 Markus Golec
2026 Jeancarlo Cardoso de Faria Filho (jaianlab) — Raspberry Pi Pico port, MCP developer tool
+177 -84
View File
@@ -1,38 +1,45 @@
#include <Bounce.h>
#include <Bounce2.h> // aktuelle Bounce2-Library nutzen
#include <Encoder.h>
#include <elapsedMillis.h>
#include <ResponsiveAnalogRead.h>
// MIDI IN - botons
const int eject_pin = 0;
const int track_previous_pin = 1;
const int track_next_pin = 2;
const int search_back_pin = 3;
const int search_forward_pin = 4;
const int cue_pin = 5;
const int play_pin = 6;
const int jet_pin = 7;
const int zip_pin = 8;
const int wah_pin = 9;
const int hold_pin = 10;
const int time_pin = 11;
const int mastertempo_pin = 12;
// ---------------- Button Pins ----------------
const int search_back_pin = 12;
const int search_forward_pin = 13;
const int track_previous_pin = 14;
const int track_next_pin = 15;
const int foldersearch_back_pin = 16;
const int foldersearch_forward_pin = 17;
const int tempomode_pin = 20;
const int mastertempo_pin = 21;
const int load_pin = 24; //Encoder Push Button
const int cue_pin = 25;
const int play_pin = 26;
const int autocue_pin = 27;
const int beatloop_pin = 28;
const int eject_pin = 30;
const int jet_pin = 39;
const int zip_pin = 40;
const int wah_pin = 41;
const int hold_pin = 42;
const int loopin_pin = 43;
const int loopout_pin = 44;
const int reloop_pin = 45;
//encoder jog
const int jogA_pin = 20;
const int jogB_pin = 15;
// ---------------- Encoder Jogwheel ----------------
const int jogA_pin = 18;
const int jogB_pin = 19;
const int midiChannel = 2;
const int jogControlNumber = 20;
Encoder jog(jogA_pin, jogB_pin);
long lastPosition_jog = 0;
//encoder browser
const int browseA_pin = 22;
const int browseB_pin = 21;
const int load_pin= 23;
// ---------------- Encoder Browser ----------------
const int browseA_pin = 36;
const int browseB_pin = 37;
const int midiChannelb = 3;
const int DEBOUNCE_MS = 3; // Debounce curt
const int NOTE_SCROLL_DOWN = 70;
@@ -44,35 +51,33 @@ long lastPosition_browse = 0; // Última posició llegida
elapsedMillis msec = 0;
/* Pitch slider old
const int pitch_pin = A0;
const int channel_pitch = 3;
const int controllerA0 = 7;
int previousA0 = -1;
ResponsiveAnalogRead analog(pitch_pin, true);
*/
//Pitch new
// ---------------- Pitch ----------------
const int pitchPin = A0;
ResponsiveAnalogRead analog(pitchPin, true);
int lastMSB = -1;
int lastLSB = -1;
// ---------------- LED Pins ----------------
const int ledCue = 7;
const int ledPlay = 6;
const int ledLoopIn = 8;
const int ledLoopOut = 9;
const int ledLoop = 10;
const int ledMastertempo = 11;
const int ledJog1 = 1;
const int ledJog2 = 2;
const int ledJog3 = 3;
const int ledJog4 = 4;
const int ledJog5 = 5;
const int ledCd = 29;
// MIDI OUT - Leds
const int ledCue = 16;
const int ledPlay = 17;
const int ledIntern = 18;
const int ledCd = 19;
// Canals MIDI
// ---------------- MIDI Channels ----------------
const int channel = 1;
//Notes que rebo de Mixxx pel play, cue, etc
// ---------------- Notes, received from Mixxx ----------------
const int PLAY_NOTE_INDICATOR = 61;
const int CUE_NOTE_INDICATOR = 62;
const int LEDINTERN_NOTE_INDICATOR = 63;
@@ -80,66 +85,77 @@ const int LEDCD_NOTE_INDICATOR = 64;
const int SIESTAPLAY_NOTE_INDICATOR = 65;
bool siestaplay = false;
//Parpadeig final de track
// ---------------- Blink when the track ends ----------------
bool parpadeig = false;
unsigned long tempsAnterior = 0;
//Bounce botons cada 5ms
Bounce eject_boto = Bounce (eject_pin, 50);
Bounce trackprevious_boto = Bounce (track_previous_pin, 50);
Bounce track_next_boto = Bounce (track_next_pin, 50);
// ---------------- Bounce buttons 5ms ----------------
Bounce search_back_boto = Bounce (search_back_pin, 50);
Bounce search_forward_boto = Bounce (search_forward_pin, 50);
Bounce play_boto = Bounce (play_pin, 50);
Bounce trackprevious_boto = Bounce (track_previous_pin, 50);
Bounce track_next_boto = Bounce (track_next_pin, 50);
Bounce foldersearch_back_boto = Bounce (foldersearch_back_pin, 50);
Bounce foldersearch_forward_boto = Bounce (foldersearch_forward_pin, 50);
Bounce tempomode_boto = Bounce (tempomode_pin, 50);
Bounce mastertempo_boto = Bounce (mastertempo_pin, 50);
Bounce load_boto = Bounce (load_pin, 50);
Bounce cue_boto = Bounce (cue_pin, 50);
Bounce play_boto = Bounce (play_pin, 50);
Bounce autocue_boto = Bounce (autocue_pin, 50);
Bounce beatloop_boto = Bounce (beatloop_pin, 50);
Bounce eject_boto = Bounce (eject_pin, 50);
Bounce jet_boto = Bounce (jet_pin, 50);
Bounce zip_boto = Bounce (zip_pin, 50);
Bounce wah_boto = Bounce (wah_pin, 50);
Bounce hold_boto = Bounce (hold_pin, 50);
Bounce time_boto = Bounce (time_pin, 50);
Bounce mastertempo_boto = Bounce (mastertempo_pin, 50);
Bounce load_boto = Bounce (load_pin, 50);
Bounce loopin_boto = Bounce (loopin_pin, 50);
Bounce loopout_boto = Bounce (loopout_pin, 50);
Bounce reloop_boto = Bounce (reloop_pin, 50);
// --- Execute Function if receiving a MIDI Message ---
// --- FUNCIONS QUE S'EXECUtEN QUAN ES REP UN MISSATGE MIDI ---
// Aquesta funció s'executarà automàticament quan es rebi un "Note On"
// Execute Automatically if receiving a "Note On"
void handleNoteOn(byte channel, byte note, byte velocity) {
// Comprovem si la nota rebuda és la del nostre indicador de Play
// Check if the "Note On" is "PLAY"
if (note == PLAY_NOTE_INDICATOR) {
// Si la velocitat és més gran que 0, vol dir "Play", així que encenem el LED
// If the speed is greater than 0, it means "Play", so we turn on the LED
if (velocity > 0) {
digitalWrite(ledPlay, HIGH);
}
// Si la velocitat és 0, es tracta com un Note Off, així que l'apaguem
// If the velocity is 0, it is treated as a Note Off, so we turn the LED off.
else {
digitalWrite(ledPlay, LOW);
}
}
if (note == CUE_NOTE_INDICATOR) {
// Si la velocitat és més gran que 0, vol dir "Play", així que encenem el LED
// If the speed is greater than 0, it means "Cue", so we turn on the LED
if (velocity > 0) {
digitalWrite(ledCue, HIGH);
}
// Si la velocitat és 0, es tracta com un Note Off, així que l'apaguem
// If the velocity is 0, it is treated as a Note Off, so we turn the LED off.
else {
digitalWrite(ledCue, LOW);
}
}
//LED INTERN MARCA EL BPM, NOMÉS SI ESTÀ EN PLAY
// INTERNAL LED MARK THE BPM, ONLY IF IT IS IN PLAY
if(note == SIESTAPLAY_NOTE_INDICATOR){
siestaplay = true;
}
if (note == LEDINTERN_NOTE_INDICATOR && siestaplay == true)
{
digitalWrite(ledIntern, HIGH);
digitalWrite(ledJog1, HIGH);
digitalWrite(ledJog2, HIGH);
digitalWrite(ledJog3, HIGH);
digitalWrite(ledJog4, HIGH);
digitalWrite(ledJog5, HIGH);
}
//LED CD el farem parpadejar quan rebi informació que la cançó s'està acabant (mixxx envia 1)
// make the CD LED blink when it receives information that the song is ending
if (note == LEDCD_NOTE_INDICATOR) {
parpadeig = (velocity > 0);
}
@@ -148,7 +164,7 @@ if (note == LEDINTERN_NOTE_INDICATOR && siestaplay == true)
}
// Aquesta funció s'executarà automàticament quan es rebi un "Note Off"
// Execute Automatically if receiving a "Note Off"
void handleNoteOff(byte channel, byte note, byte velocity) {
// Comprovem si la nota rebuda és la del nostre indicador de Play
if (note == PLAY_NOTE_INDICATOR) {
@@ -168,7 +184,11 @@ void handleNoteOff(byte channel, byte note, byte velocity) {
siestaplay = false;
}
if (note == LEDINTERN_NOTE_INDICATOR || siestaplay == false){
digitalWrite(ledIntern, LOW);
digitalWrite(ledJog1, LOW);
digitalWrite(ledJog2, LOW);
digitalWrite(ledJog3, LOW);
digitalWrite(ledJog4, LOW);
digitalWrite(ledJog5, LOW);
}
}
@@ -190,32 +210,45 @@ void JogNudge(){
}
void setup() {
pinMode(eject_pin, INPUT_PULLUP);
pinMode(track_previous_pin, INPUT_PULLUP);
pinMode(track_next_pin, INPUT_PULLUP);
pinMode(search_back_pin, INPUT_PULLUP);
pinMode(search_forward_pin, INPUT_PULLUP);
pinMode(track_previous_pin, INPUT_PULLUP);
pinMode(track_next_pin, INPUT_PULLUP);
pinMode(foldersearch_back_pin, INPUT_PULLUP);
pinMode(foldersearch_forward_pin, INPUT_PULLUP);
pinMode(tempomode_pin, INPUT_PULLUP);
pinMode(mastertempo_pin, INPUT_PULLUP);
pinMode(load_pin, INPUT_PULLUP);
pinMode(cue_pin, INPUT_PULLUP);
pinMode(play_pin, INPUT_PULLUP);
pinMode(jet_pin, INPUT_PULLUP);
pinMode(autocue_pin, INPUT_PULLUP);
pinMode(beatloop_pin, INPUT_PULLUP);
pinMode(eject_pin, INPUT_PULLUP);
pinMode(jet_pin, INPUT_PULLUP);
pinMode(zip_pin, INPUT_PULLUP);
pinMode(wah_pin, INPUT_PULLUP);
pinMode(hold_pin, INPUT_PULLUP);
pinMode(time_pin, INPUT_PULLUP);
pinMode(mastertempo_pin, INPUT_PULLUP);
pinMode(load_pin, INPUT_PULLUP);
pinMode(loopin_pin, INPUT_PULLUP);
pinMode(loopout_pin, INPUT_PULLUP);
pinMode(reloop_pin, INPUT_PULLUP);
pinMode(ledCue, OUTPUT);
pinMode(ledPlay, OUTPUT);
pinMode(ledIntern, OUTPUT);
pinMode(ledLoopIn, OUTPUT);
pinMode(ledLoopOut, OUTPUT);
pinMode(ledLoop, OUTPUT);
pinMode(ledMastertempo, OUTPUT);
pinMode(ledJog1, OUTPUT);
pinMode(ledJog2, OUTPUT);
pinMode(ledJog3, OUTPUT);
pinMode(ledJog4, OUTPUT);
pinMode(ledJog5, OUTPUT);
pinMode(ledCd, OUTPUT);
// --- CONFIGURació MIDI ---
// Assignem les nostres funcions "callback" als esdeveniments MIDI
usbMIDI.setHandleNoteOn(handleNoteOn);
@@ -228,12 +261,8 @@ pinMode(ledCd, OUTPUT);
browse.write(0);
lastPosition_browse = 0;
}
void loop() {
eject_boto.update();
@@ -247,10 +276,20 @@ jet_boto.update();
zip_boto.update();
wah_boto.update();
hold_boto.update();
time_boto.update();
mastertempo_boto.update();
load_boto.update();
// --- update new buttons for the xdj200sx ---
foldersearch_back_boto.update();
foldersearch_forward_boto.update();
loopin_boto.update();
loopout_boto.update();
reloop_boto.update();
beatloop_boto.update();
autocue_boto.update();
//Play
if(play_boto.fallingEdge()){
@@ -339,11 +378,11 @@ if(hold_boto.fallingEdge()){
if(hold_boto.risingEdge()){
usbMIDI.sendNoteOff(71, 0, channel);
}
//time
if(time_boto.fallingEdge()){
//autocue
if(autocue_boto.fallingEdge()){
usbMIDI.sendNoteOn(72, 127, channel);
}
if(time_boto.risingEdge()){
if(autocue_boto.risingEdge()){
usbMIDI.sendNoteOff(72, 0, channel);
}
//load
@@ -355,6 +394,60 @@ if(load_boto.fallingEdge()){
}
// ---- addiitonal buttons for the xdj200sx -----
// Folder Search left
if(foldersearch_back_boto.fallingEdge()){
usbMIDI.sendNoteOn(74, 127, channel); //0x4A
}
if(foldersearch_back_boto.risingEdge()){
usbMIDI.sendNoteOff(74, 0, channel); //0x4A
}
}
// Folder Search right
if(foldersearch_forward_boto.fallingEdge()){
usbMIDI.sendNoteOn(75, 127, channel); //0x4B
}
if(foldersearch_forward_boto.risingEdge()){
usbMIDI.sendNoteOff(75, 0, channel); //0x4B
}
// Loop IN
if(loopin_boto.fallingEdge()){
usbMIDI.sendNoteOn(76, 127, channel); //0x4C
}
if(loopin_boto.risingEdge()){
usbMIDI.sendNoteOff(76, 0, channel); //0x4C
}
// Loop OUT
if(loopout_boto.fallingEdge()){
usbMIDI.sendNoteOn(77, 127, channel); //0x4D
}
if(loopout_boto.risingEdge()){
usbMIDI.sendNoteOff(77, 0, channel); //0x4D
}
// Reloop
if(reloop_boto.fallingEdge()){
usbMIDI.sendNoteOn(78, 127, channel); //0x4E
}
if(reloop_boto.risingEdge()){
usbMIDI.sendNoteOff(78, 0, channel); //0x4E
}
// Beatloop
if(beatloop_boto.fallingEdge()){
usbMIDI.sendNoteOn(79, 127, channel); //0x4F
}
if(beatloop_boto.risingEdge()){
usbMIDI.sendNoteOff(79, 0, channel); //0x4F
}
// ---- end of new additions to xdj200sx -----
//Jog: cridem la funció JogNudge
JogNudge();
+1 -1
View File
@@ -1,6 +1,6 @@
#include "usb_names.h"
#define MIDI_NAME {'X','D','J','1','0','0','S','X'}
#define MIDI_NAME {'X','D','J','2','0','0','S','X'}
#define MIDI_NAME_LEN 8
struct usb_string_descriptor_struct usb_string_product_name = {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,9 @@
var XDJ100SX = {};
XDJ100SX.currentMode = 0;
var XDJ200SX = {};
XDJ200SX.currentMode = 0;
//Init
XDJ100SX.init = function(){
XDJ200SX.init = function(){
engine.setValue("[Channel2]", "filterLowKill", 1); //This is the default mode
engine.setValue("[Channel2]", "filterMidKill", 0);
engine.setValue("[Channel2]", "filterHighKill", 0);
@@ -14,7 +14,7 @@ XDJ100SX.init = function(){
//Shutdown
XDJ100SX.shutdown = function(){
XDJ200SX.shutdown = function(){
//Turn off all leds
var LedNotes = [0x41, 0x3D, 0x40, 0x3E, 0x3F];
@@ -30,22 +30,22 @@ XDJ100SX.shutdown = function(){
//Jog Wheel
XDJ100SX.JogWheelEnabled = false;
XDJ200SX.JogWheelEnabled = false;
XDJ100SX.nudgeWheelTurn = function (channel, control, value, status, group) {
XDJ200SX.nudgeWheelTurn = function (channel, control, value, status, group) {
XDJ100SX.lastJogMoveTime = Date.now();
XDJ200SX.lastJogMoveTime = Date.now();
var newValue = value - 64;
var deckNumber = script.deckFromGroup(group);
if (engine.isScratching(deckNumber)) {
engine.scratchTick(deckNumber, newValue); // Scratch!
XDJ100SX.JogWheelEnabled = false;
XDJ200SX.JogWheelEnabled = false;
} else {
engine.setValue(group, 'jog', newValue); // Pitch bend
XDJ100SX.JogWheelEnabled = true;
XDJ200SX.JogWheelEnabled = true;
}
@@ -55,11 +55,11 @@ XDJ100SX.nudgeWheelTurn = function (channel, control, value, status, group) {
//Search buttons
XDJ100SX.searchButton = function (channel, control, value, status, group){
XDJ200SX.searchButton = function (channel, control, value, status, group){
const NOTE_SEARCH_FORWARD = 0x43;
const NOTE_SEARCH_BACKWARD = 0x42;
var isJogActive = (Date.now() - XDJ100SX.lastJogMoveTime < 100);
var isJogActive = (Date.now() - XDJ200SX.lastJogMoveTime < 100);
//Si moc el jog wheel
@@ -116,36 +116,36 @@ XDJ100SX.searchButton = function (channel, control, value, status, group){
//Pitch ranges
XDJ100SX.rateRanges = [0.08, 0.10, 0.16, 0.24, 0.50]; //valors de rang del pitch
XDJ100SX.currentRange = 0;
XDJ200SX.rateRanges = [0.08, 0.10, 0.16, 0.24, 0.50]; //valors de rang del pitch
XDJ200SX.currentRange = 0;
//Beat Jump ranges
XDJ100SX.BeatJumpRanges = [4, 8, 16, 32, 64, 128];
XDJ100SX.currentBeatJumpRange = 3;
XDJ200SX.BeatJumpRanges = [4, 8, 16, 32, 64, 128];
XDJ200SX.currentBeatJumpRange = 3;
//Shift
XDJ100SX.shiftPressed = false;
XDJ100SX.shift = function(channel,control,value,status,group){
XDJ200SX.shiftPressed = false;
XDJ200SX.shift = function(channel,control,value,status,group){
if(value === 127){
XDJ100SX.shiftPressed = true;
XDJ200SX.shiftPressed = true;
}
else{
XDJ100SX.shiftPressed = false;
XDJ200SX.shiftPressed = false;
}
};
//Master Tempo & Tempo Range
XDJ100SX.key = function (channel, control, value, status, group){
if(XDJ100SX.shiftPressed){
XDJ200SX.key = function (channel, control, value, status, group){
if(XDJ200SX.shiftPressed){
if (value){
XDJ100SX.currentRange++;
if(XDJ100SX.currentRange >= XDJ100SX.rateRanges.length){
XDJ100SX.currentRange = 0;
XDJ200SX.currentRange++;
if(XDJ200SX.currentRange >= XDJ200SX.rateRanges.length){
XDJ200SX.currentRange = 0;
}
engine.setValue(group, "rateRange", XDJ100SX.rateRanges[XDJ100SX.currentRange]);
engine.setValue(group, "rateRange", XDJ200SX.rateRanges[XDJ200SX.currentRange]);
}
}
else{
@@ -155,11 +155,11 @@ XDJ100SX.key = function (channel, control, value, status, group){
//Button Mode
XDJ100SX.buttonMode = function (channel, control, value, status, group) {
XDJ200SX.buttonMode = function (channel, control, value, status, group) {
if (value > 0) {
// Go to next mode
XDJ100SX.currentMode = (XDJ100SX.currentMode + 1) % 6;
XDJ200SX.currentMode = (XDJ200SX.currentMode + 1) % 6;
// Set all to 0
engine.setValue("[Channel2]", "filterLowKill", 0);
@@ -170,17 +170,17 @@ XDJ100SX.buttonMode = function (channel, control, value, status, group) {
engine.setValue("[Channel3]", "filterHighKill", 0);
// Enable the current one
if (XDJ100SX.currentMode === 0) {
if (XDJ200SX.currentMode === 0) {
engine.setValue("[Channel2]", "filterLowKill", 1); //Mode 1
} else if (XDJ100SX.currentMode === 1) {
} else if (XDJ200SX.currentMode === 1) {
engine.setValue("[Channel2]", "filterMidKill", 1); //Mode 2
} else if (XDJ100SX.currentMode === 2){
} else if (XDJ200SX.currentMode === 2){
engine.setValue("[Channel2]", "filterHighKill", 1); //Mode 3
}
else if (XDJ100SX.currentMode === 3){
else if (XDJ200SX.currentMode === 3){
engine.setValue("[Channel3]", "filterLowKill", 1); //Mode 4
}
else if (XDJ100SX.currentMode === 4){
else if (XDJ200SX.currentMode === 4){
engine.setValue("[Channel3]", "filterMidKill", 1); //Mode 5
}
else{
@@ -200,13 +200,13 @@ XDJ100SX.buttonMode = function (channel, control, value, status, group) {
//Mode 4 = Beat Jump Length Back, Beat Jump Length Forward, Change Beat Jump Length
//Mode 5 = Key Shift -, Key Shift +, Key Reset
XDJ100SX.button = function(buttonNumber){
XDJ200SX.button = function(buttonNumber){
return function (channel, control, value, status, group){
if(value === 127){
//Hot Cue A, B, C
if(XDJ100SX.currentMode === 0){
if(XDJ100SX.shiftPressed){
if(XDJ200SX.currentMode === 0){
if(XDJ200SX.shiftPressed){
engine.setValue(group, "hotcue_" + buttonNumber + "_clear", 1);
}
else{
@@ -214,11 +214,11 @@ XDJ100SX.button = function(buttonNumber){
}
}
// Hot Cue D, E, F
if(XDJ100SX.currentMode === 1){
if(XDJ200SX.currentMode === 1){
var mode = buttonNumber + 3;
if(XDJ100SX.shiftPressed){
if(XDJ200SX.shiftPressed){
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
}
else{
@@ -226,11 +226,11 @@ XDJ100SX.button = function(buttonNumber){
}
}
// Hot Cue G, H
if(XDJ100SX.currentMode === 2){
if(XDJ200SX.currentMode === 2){
var mode = buttonNumber + 6;
if(XDJ100SX.shiftPressed){
if(XDJ200SX.shiftPressed){
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
}
else{
@@ -239,7 +239,7 @@ XDJ100SX.button = function(buttonNumber){
}
//Loop Roll
if (XDJ100SX.currentMode === 3){
if (XDJ200SX.currentMode === 3){
if (buttonNumber === 1){
engine.setValue(group, "beatlooproll_0.125_activate", 1);
}
@@ -252,24 +252,24 @@ XDJ100SX.button = function(buttonNumber){
}
//BeatJump
if (XDJ100SX.currentMode === 4){
if (XDJ200SX.currentMode === 4){
if (buttonNumber === 1){
engine.setValue(group, "beatjump_" + XDJ100SX.BeatJumpRanges[XDJ100SX.currentBeatJumpRange] + "_backward", 1);
engine.setValue(group, "beatjump_" + XDJ200SX.BeatJumpRanges[XDJ200SX.currentBeatJumpRange] + "_backward", 1);
}
if (buttonNumber === 2){
engine.setValue(group, "beatjump_" + XDJ100SX.BeatJumpRanges[XDJ100SX.currentBeatJumpRange] + "_forward", 1);
engine.setValue(group, "beatjump_" + XDJ200SX.BeatJumpRanges[XDJ200SX.currentBeatJumpRange] + "_forward", 1);
}
else if(buttonNumber === 3){
XDJ100SX.currentBeatJumpRange++;
if(XDJ100SX.currentBeatJumpRange >= XDJ100SX.BeatJumpRanges.length){
XDJ100SX.currentBeatJumpRange = 0;
XDJ200SX.currentBeatJumpRange++;
if(XDJ200SX.currentBeatJumpRange >= XDJ200SX.BeatJumpRanges.length){
XDJ200SX.currentBeatJumpRange = 0;
}
engine.setValue(group,"beatjump_size", XDJ100SX.BeatJumpRanges[XDJ100SX.currentBeatJumpRange]);
engine.setValue(group,"beatjump_size", XDJ200SX.BeatJumpRanges[XDJ200SX.currentBeatJumpRange]);
}
}
//Key Shift
if(XDJ100SX.currentMode === 5){
if(XDJ200SX.currentMode === 5){
if (buttonNumber === 1){
engine.setValue(group, "pitch_down", 1);
}
@@ -286,7 +286,7 @@ XDJ100SX.button = function(buttonNumber){
}
//When release button (Note Off) (Must to disable Loop Roll)
else if(value === 0){
if (XDJ100SX.currentMode === 3){
if (XDJ200SX.currentMode === 3){
if (buttonNumber === 1){
engine.setValue(group, "beatlooproll_0.125_activate", 0);
}
@@ -302,16 +302,16 @@ XDJ100SX.button = function(buttonNumber){
}
};
XDJ100SX.button1 = XDJ100SX.button(1);
XDJ100SX.button2 = XDJ100SX.button(2);
XDJ100SX.button3 = XDJ100SX.button(3);
XDJ200SX.button1 = XDJ200SX.button(1);
XDJ200SX.button2 = XDJ200SX.button(2);
XDJ200SX.button3 = XDJ200SX.button(3);
//BOTÓ CUE: shift + cue torna a l'inici
XDJ100SX.cue = function (channel, control, value, status, group){
XDJ200SX.cue = function (channel, control, value, status, group){
if(value === 127){
if(XDJ100SX.shiftPressed){
if(XDJ200SX.shiftPressed){
engine.setValue(group, "start_stop", 1);
}
else{
@@ -327,9 +327,9 @@ XDJ100SX.cue = function (channel, control, value, status, group){
/*
//Pitch Slider old
XDJ100SX.pitchLast = 0;
XDJ200SX.pitchLast = 0;
XDJ100SX.pitch = function (channel, control, value, status, group) {
XDJ200SX.pitch = function (channel, control, value, status, group) {
var midiMin = 0;
var midiMax = 127;
var midiCenter = 60; // ajusta segons el teu fader real
@@ -345,8 +345,8 @@ XDJ100SX.pitch = function (channel, control, value, status, group) {
// Deadzone per evitar soroll
var threshold = 0.015;
if (Math.abs(normalized - XDJ100SX.pitchLast) > threshold) {
XDJ100SX.pitchLast = normalized;
if (Math.abs(normalized - XDJ200SX.pitchLast) > threshold) {
XDJ200SX.pitchLast = normalized;
engine.setValue(group, "rate", normalized);
}
};
@@ -357,19 +357,19 @@ XDJ100SX.pitch = function (channel, control, value, status, group) {
// Pitch slider new
XDJ100SX.pitchMSB = 0;
XDJ100SX.pitchLSB = 0;
XDJ200SX.pitchMSB = 0;
XDJ200SX.pitchLSB = 0;
XDJ100SX.pitch = function (channel, control, value, status, group) {
XDJ200SX.pitch = function (channel, control, value, status, group) {
if (control === 0) { // MSB
XDJ100SX.pitchMSB = value;
XDJ200SX.pitchMSB = value;
} else if (control === 32) { // LSB
XDJ100SX.pitchLSB = value;
XDJ200SX.pitchLSB = value;
}
// Combinar MSB + LSB
var full = (XDJ100SX.pitchMSB << 7) | XDJ100SX.pitchLSB; // 016383
var full = (XDJ200SX.pitchMSB << 7) | XDJ200SX.pitchLSB; // 016383
// Convertir a rang de Mixxx (-1.0 .. +1.0)
var normalized = - (full - 8192) / 8192;
@@ -381,7 +381,7 @@ XDJ100SX.pitch = function (channel, control, value, status, group) {
//Browse Encoder (scroll + canviar vista)
XDJ100SX.browseDown = function(channel, control, value, status, group) {
XDJ200SX.browseDown = function(channel, control, value, status, group) {
if (value === 127) {
// llegim el control actual
var currentTab = engine.getValue("[Tab]", "current");
@@ -393,7 +393,7 @@ XDJ100SX.browseDown = function(channel, control, value, status, group) {
}
};
XDJ100SX.browseUp = function(channel, control, value, status, group) {
XDJ200SX.browseUp = function(channel, control, value, status, group) {
if (value === 127) {
var currentTab = engine.getValue("[Tab]", "current");
if (currentTab === 0) {
@@ -404,7 +404,7 @@ XDJ100SX.browseUp = function(channel, control, value, status, group) {
};
//Botó LOAD (carregar cançó + tornar a vista overview o vista anterior)
XDJ100SX.loadTrack = function(channel, control, value, status, group){
XDJ200SX.loadTrack = function(channel, control, value, status, group){
if (value === 127){
var currentTab = engine.getValue("[Tab]", "current");
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
@@ -423,7 +423,7 @@ XDJ100SX.loadTrack = function(channel, control, value, status, group){
//Botó BACK
XDJ100SX.backButton = function(channel, control, value, status, group){
XDJ200SX.backButton = function(channel, control, value, status, group){
if(value === 127){
var currentTab = engine.getValue("[Tab]", "current");
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
@@ -1,18 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<MixxxControllerPreset mixxxVersion="" schemaVersion="1">
<info>
<name>XDJ100SX</name>
<name>XDJ200SX</name>
</info>
<controller id="">
<scriptfiles>
<file filename="XDJ100SX.js" functionprefix="XDJ100SX" />
<file filename="XDJ200SX.js" functionprefix="XDJ200SX" />
</scriptfiles>
<controls>
<control>
<description>PITCH MSB</description>
<group>[Channel1]</group>
<key>XDJ100SX.pitch</key>
<key>XDJ200SX.pitch</key>
<status>0xB0</status>
<midino>0x00</midino>
<options>
@@ -21,7 +21,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.pitch</key>
<key>XDJ200SX.pitch</key>
<description>PITCH LSB</description>
<status>0xB0</status>
<midino>0x20</midino>
@@ -44,7 +44,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.cue</key>
<key>XDJ200SX.cue</key>
<description>CUE</description>
<status>0x80</status>
<midino>0x3D</midino>
@@ -54,7 +54,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.cue</key>
<key>XDJ200SX.cue</key>
<description>CUE</description>
<status>0x90</status>
<midino>0x3D</midino>
@@ -64,7 +64,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.key</key>
<key>XDJ200SX.key</key>
<description>MASTER TEMPO - TEMPO RANGE</description>
<status>0x90</status>
<midino>0x3E</midino>
@@ -74,8 +74,8 @@
</control>
<control>
<group>[Channel1]</group>
<key>loop_halve</key>
<description>LOOP HALF</description>
<key>beatjump_X_forward</key>
<description>BEAT JUMP BCK 16 BEATS</description>
<status>0x90</status>
<midino>0x40</midino>
<options>
@@ -84,8 +84,8 @@
</control>
<control>
<group>[Channel1]</group>
<key>loop_double</key>
<description>LOOP DOUBLE</description>
<key>beatjump_16_forward</key>
<description>BEAT JUMP FWD 16 BEATS</description>
<status>0x90</status>
<midino>0x41</midino>
<options>
@@ -95,7 +95,7 @@
<!--SEARCH LEFT-->
<control>
<group>[Channel1]</group>
<key>XDJ100SX.searchButton</key>
<key>XDJ200SX.searchButton</key>
<description>SEARCH BACKWARD</description>
<status>0x90</status>
<midino>0x42</midino>
@@ -105,7 +105,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.searchButton</key>
<key>XDJ200SX.searchButton</key>
<description>SEARCH BACKWARD</description>
<status>0x80</status>
<midino>0x42</midino>
@@ -117,7 +117,7 @@
<!--SEARCH RIGHT-->
<control>
<group>[Channel1]</group>
<key>XDJ100SX.searchButton</key>
<key>XDJ200SX.searchButton</key>
<description>SEARCH FORWARD</description>
<status>0x90</status>
<midino>0x43</midino>
@@ -127,7 +127,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.searchButton</key>
<key>XDJ200SX.searchButton</key>
<description>SEARCH FORWARD</description>
<status>0x80</status>
<midino>0x43</midino>
@@ -137,7 +137,7 @@
</control>
<control>
<key>XDJ100SX.browseDown</key>
<key>XDJ200SX.browseDown</key>
<description>BROWSE DOWN</description>
<status>0x92</status>
<midino>0x46</midino>
@@ -147,7 +147,7 @@
<control>
<group>[Library]</group>
<key>XDJ100SX.browseUp</key>
<key>XDJ200SX.browseUp</key>
<description>BROWSE UP</description>
<status>0x92</status>
<midino>0x47</midino>
@@ -157,7 +157,7 @@
<control>
<group>[Channel1]</group>
<key>XDJ100SX.loadTrack</key>
<key>XDJ200SX.loadTrack</key>
<description>LOAD</description>
<status>0x90</status>
<midino>0x49</midino>
@@ -166,7 +166,7 @@
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.backButton</key>
<key>XDJ200SX.backButton</key>
<description>BACK</description>
<status>0x90</status>
<midino>0x3F</midino>
@@ -176,40 +176,40 @@
<!-- Hot Cues i shift - he d'enviar el Note On i el Note off del shift-->
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button1</key> <status>0x90</status> <midino>0x44</midino> <options>
<key>XDJ200SX.button1</key> <status>0x90</status> <midino>0x44</midino> <options>
<script-binding/>
</options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button1</key> <status>0x80</status> <midino>0x44</midino> <options>
<key>XDJ200SX.button1</key> <status>0x80</status> <midino>0x44</midino> <options>
<script-binding/>
</options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button2</key>
<key>XDJ200SX.button2</key>
<status>0x90</status>
<midino>0x45</midino><options>
<script-binding/></options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button2</key>
<key>XDJ200SX.button2</key>
<status>0x80</status>
<midino>0x45</midino><options>
<script-binding/></options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button3</key>
<key>XDJ200SX.button3</key>
<status>0x90</status>
<midino>0x46</midino><options>
<script-binding/></options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.button3</key>
<key>XDJ200SX.button3</key>
<status>0x80</status>
<midino>0x46</midino><options>
<script-binding/></options>
@@ -217,14 +217,14 @@
<control>
<group>[Channel1]</group>
<key>XDJ100SX.shift</key>
<key>XDJ200SX.shift</key>
<status>0x90</status>
<midino>0x47</midino><options>
<script-binding/></options>
</control>
<control>
<group>[Channel1]</group>
<key>XDJ100SX.shift</key>
<key>XDJ200SX.shift</key>
<status>0x80</status> <midino>0x47</midino> <options> <!--Note off-->
<script-binding/>
</options>
@@ -232,18 +232,91 @@
<!--Jog-->
<control>
<group>[Channel1]</group>
<key>XDJ100SX.nudgeWheelTurn</key> <status>0xB1</status> <midino>0x14</midino> <options>
<key>XDJ200SX.nudgeWheelTurn</key> <status>0xB1</status> <midino>0x14</midino> <options>
<script-binding/>
</options>
</control>
<!-- BUTTON MODES -->
<control>
<group>[Channel1]</group>
<key>XDJ100SX.buttonMode</key> <status>0x90</status> <midino>0x48</midino> <options>
<key>XDJ200SX.buttonMode</key> <status>0x90</status> <midino>0x48</midino> <options>
<script-binding/>
</options>
</control>
<!-- NEW BUTTONS FOR THE XDJ200SX-->
<!--Folder search left: LOOP HALF-->
<control>
<group>[Channel1]</group>
<key>loop_halve</key>
<description>LOOP HALF</description>
<status>0x90</status>
<midino>0x4A</midino>
<options>
<normal/>
</options>
</control>
<!--Folder search right: LOOP DOUBLE-->
<control>
<group>[Channel1]</group>
<key>loop_double</key>
<description>LOOP DOUBLE</description>
<status>0x90</status>
<midino>0x4B</midino>
<options>
<normal/>
</options>
</control>
<!--LOOP IN-->
<control>
<group>[Channel1]</group>
<key>loop_in</key>
<description>LOOP IN</description>
<status>0x90</status>
<midino>0x4C</midino>
<options>
<normal/>
</options>
</control>
<!--LOOP OUT-->
<control>
<group>[Channel1]</group>
<key>loop_out</key>
<description>LOOP OUT</description>
<status>0x90</status>
<midino>0x4D</midino>
<options>
<normal/>
</options>
</control>
<!--RELOOP-->
<control>
<group>[Channel1]</group>
<key>reloop_exit</key>
<description>RELOOP</description>
<status>0x90</status>
<midino>0x4E</midino>
<options>
<normal/>
</options>
</control>
<!--BEATLOOP: MAKE A 4 BEAT LOOP AUTOMATICALLY-->
<control>
<group>[Channel1]</group>
<key>beatloop_4_toggle</key>
<description>4 BEAT LOOP</description>
<status>0x90</status>
<midino>0x4F</midino>
<options>
<normal/>
</options>
</control>
<!-- END OF NEW BUTTONS XDJ200SX-->
</controls>
<outputs>
-52
View File
@@ -1,52 +0,0 @@
# C++ objects and libs
*.slo
*.lo
*.o
*.a
*.la
*.lai
*.so
*.so.*
*.dll
*.dylib
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
*.qm
*.prl
# Qt unit tests
target_wrapper.*
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*
# QtCreator 4.8< compilation database
compile_commands.json
# QtCreator local machine specific files for imported projects
*creator.user*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 164 B

Before

Width:  |  Height:  |  Size: 155 B

After

Width:  |  Height:  |  Size: 155 B

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 140 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
@@ -1,6 +1,6 @@
<skin>
<manifest>
<title>XDJ100SX</title>
<title>XDJ200SX</title>
<author>Marc Monka</author>
<version>1.0</version>
<description>Skin for 1 standalone deck with different performance submenus. A lookalike Pioneer DJ / Alphatheta deck player style. </description>
@@ -29,7 +29,7 @@
background-color:#000;
}
QLabel {
image: url(skin:/images/xdj100sx_logo.png);
image: url(skin:/images/xdj200sx_logo.png);
border:none;
min-width:450px;
max-width:450px;

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+165
View File
@@ -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.
+358
View File
@@ -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
<Size>WIDTH,HEIGHT</Size>
```
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
<Size>65f,40f</Size> <!-- 65×40, fixed -->
<Size>0me,65max</Size> <!-- expands horizontally, max 65px tall -->
<Size>me,me</Size> <!-- fills available space -->
```
### SizePolicy
```xml
<SizePolicy>me,me</SizePolicy> <!-- preferred grow behavior -->
<SizePolicy>f,f</SizePolicy> <!-- fixed -->
```
### Layout
```xml
<Layout>vertical</Layout>
<Layout>horizontal</Layout>
```
### WidgetStack
Shows one child at a time based on a ConfigKey value. Used for the performance panel tabs (hotcues, beatloop, etc.):
```xml
<WidgetStack>
<Children>
<WidgetGroup>...</WidgetGroup> <!-- shown when stack index = 0 -->
<WidgetGroup>...</WidgetGroup> <!-- shown when stack index = 1 -->
</Children>
</WidgetStack>
```
### Templates
Reusable XML fragments with variables:
```xml
<!-- Definition (tab.xml) -->
<Template>
<PushButton>
<ObjectName>TabButton<Variable name="tab_name"/></ObjectName>
<Connection>
<ConfigKey>[Tab],<Variable name="config_key"/></ConfigKey>
</Connection>
</PushButton>
</Template>
<!-- Usage -->
<Template src="skin:tab.xml">
<SetVariable name="tab_name">HOT CUE</SetVariable>
<SetVariable name="config_key">hotcue</SetVariable>
</Template>
```
---
## Widget Types
### PushButton
```xml
<PushButton>
<ObjectName>MyButton</ObjectName>
<Size>65f,40f</Size>
<NumberStates>2</NumberStates>
<State>
<Number>0</Number>
<Text>OFF</Text>
<!-- <Pixmap>skin:/images/btn_off.png</Pixmap> -->
</State>
<State>
<Number>1</Number>
<Text>ON</Text>
</State>
<Connection>
<ConfigKey>[Channel1],play</ConfigKey>
</Connection>
</PushButton>
```
### Label
```xml
<Label>
<ObjectName>TrackTitle</ObjectName>
<Size>me,20f</Size>
<Text>No Track</Text>
<Connection>
<ConfigKey>[Channel1],title</ConfigKey>
<BindProperty>text</BindProperty>
</Connection>
</Label>
```
### NumberLabel / NumberDisplay
For numeric values (BPM, pitch, time):
```xml
<NumberLabel>
<ObjectName>BPM</ObjectName>
<Size>60f,25f</Size>
<Connection>
<ConfigKey>[Channel1],bpm</ConfigKey>
</Connection>
</NumberLabel>
```
### WaveformDisplay
```xml
<Visual>
<ObjectName>Waveform</ObjectName>
<Size>me,80f</Size>
<Channel>1</Channel>
<BgColor>#000</BgColor>
<BgPixmap></BgPixmap>
<EndOfTrackColor>#ff4444</EndOfTrackColor>
</Visual>
```
### Overview (track position bar)
```xml
<Overview>
<ObjectName>TrackOverview</ObjectName>
<Size>me,30f</Size>
<Channel>1</Channel>
<BgColor>#111</BgColor>
<EndOfTrackColor>#ff0000</EndOfTrackColor>
</Overview>
```
### WidgetGroup (container)
```xml
<WidgetGroup>
<ObjectName>MyGroup</ObjectName>
<Layout>horizontal</Layout>
<Size>me,40f</Size>
<Children>
<!-- child widgets here -->
</Children>
</WidgetGroup>
```
---
## 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
<!-- Show hotcue panel when this is 1 -->
<Connection>
<ConfigKey>[Channel2],filterLowKill</ConfigKey>
<BindProperty>visible</BindProperty>
</Connection>
```
**Do not change this pattern** — it would require updating both skin XML and the MIDI mapping script.
### Skin-internal toggles
```xml
[Skin],daynight_toggle <!-- Day/Night mode switch -->
```
---
## 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 `<Template>` as root element
- Variables are set with `<SetVariable name="...">value</SetVariable>`
- Used at call site with `<Template src="skin:file.xml">`
---
## 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
+2540
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# xdj_pi_dev — XDJ-100SX Pi Developer Tool modules
+158
View File
@@ -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}")
+449
View File
@@ -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 <file.tar> 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 <backup>.tar && sudo bash restore.sh /dev/sdX")
else:
print()
ok(summary)
ok("Restore (Linux): tar xf <backup>.tar && sudo bash restore.sh /dev/sdX")
return str(out)
+127
View File
@@ -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")
+127
View File
@@ -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
+99
View File
@@ -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.",
}
+126
View File
@@ -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()
+453
View File
@@ -0,0 +1,453 @@
from __future__ import annotations
"""
Pico firmware tools: bootloader, flash, compile, and CLI setup.
Extracted from xdj-pi-dev.py (lines 577-741 and 853-1101).
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from xdj_pi_dev._terminal import (
_C, Step, section, ok, warn, fail, _log_line,
)
from xdj_pi_dev.messages import MSG
# ─── Module-level constants ───────────────────────────────────────────────────
# Resolve relative to this file: xdj_pi_dev/ → tools/ → repo root
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_PI_USER = os.environ.get("XDJ_USER", "xdj100sx")
REPO_FIRMWARE = _REPO_ROOT / "arduino" / "pico" / "pico-XDJ100SX.ino"
PI_FIRMWARE_DIR = f"/home/{_PI_USER}/pico-firmware"
PI_FIRMWARE_INO = f"{PI_FIRMWARE_DIR}/pico-XDJ100SX.ino"
PI_BUILD_DIR = f"{PI_FIRMWARE_DIR}/build"
ARDUINO_CLI_URL = (
"https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARMv7.tar.gz"
)
ARDUINO_BOARD_URL = (
"https://github.com/earlephilhower/arduino-pico/releases/download/global/"
"package_rp2040_index.json"
)
# Board profiles: short name → full FQBN
# rp2040 boards need usbstack=tinyusb for Adafruit TinyUSB to compile
BOARD_PROFILES: dict[str, str] = {
"pico": "rp2040:rp2040:rpipico:usbstack=tinyusb",
"pico2": "rp2040:rp2040:rpipico2:usbstack=tinyusb",
"picow": "rp2040:rp2040:rpipicow:usbstack=tinyusb",
"micro": "arduino:avr:micro",
"leonardo": "arduino:avr:leonardo",
"uno": "arduino:avr:uno",
"nano": "arduino:avr:nano",
}
DEFAULT_BOARD = "pico"
# ─── Pico bootloader ──────────────────────────────────────────────────────────
def pico_bootloader(pi_client) -> str:
"""
Reset the Pico into UF2 bootloader mode via MIDI Note 127 ch16 vel127.
Mixxx holds the ALSA MIDI port while running, so we must stop it first.
amidi (already on Pi) is used — no Python dependencies needed.
Mixxx auto-restarts via the xinitrc while-loop after ~2-3 s.
"""
# Kill Mixxx so it releases the ALSA port; SIGTERM then SIGKILL to be sure
ok("Stopping Mixxx…")
pi_client.exec(
"pkill -x mixxx 2>/dev/null; sleep 2; pkill -9 -x mixxx 2>/dev/null; true",
timeout=10,
)
time.sleep(1) # let ALSA finish releasing the port
# Find Pico MIDI port via amidi -l; default to hw:1,0,0 (card 1 = USB)
ports_r = pi_client.exec("amidi -l 2>&1")
port = "hw:1,0,0"
for line in ports_r["stdout"].splitlines():
if "XDJ" in line or "Pico" in line or "USB" in line.upper():
for tok in line.split():
if tok.startswith("hw:"):
port = tok
break
ok(f"Sending update signal via {port}")
# Note On: status 0x9F = ch16, note 0x7F = 127, vel 0x7F = 127.
# Shell timeout prevents hang if the Pico reboots and amidi stalls on ALSA cleanup.
# rc=124 means shell killed amidi = port vanished during send = likely success.
r = pi_client.exec(f"timeout 6 amidi -p {port} -S '9F 7F 7F' 2>&1", timeout=10)
amidi_out = r["stdout"].strip()
amidi_rc = r["rc"]
if amidi_rc not in (0, 124) and amidi_out:
# amidi returned a real error — port busy, not found, etc.
return f"amidi error (rc={amidi_rc}): {amidi_out}"
time.sleep(3) # give Pico time to enumerate as mass storage
# Broad detection: check by-label symlink (most reliable), then lsblk fallbacks
check = pi_client.exec(
"ls /dev/disk/by-label/RPI-RP2 2>/dev/null && echo BY_LABEL;"
"lsblk -no NAME,LABEL 2>/dev/null | grep -i RPI-RP2 && echo BY_LABEL_LSBLK;"
"lsblk -no NAME,VENDOR 2>/dev/null | grep -i raspberr && echo BY_VENDOR;"
"lsblk -no NAME,MODEL 2>/dev/null | grep -iE 'RP2|RPI' && echo BY_MODEL;"
"mountpoint -q /media/RPI-RP2 2>/dev/null && echo MOUNTED || true"
)
found = any(k in check["stdout"] for k in ("BY_LABEL", "BY_VENDOR", "BY_MODEL", "MOUNTED"))
if found:
return MSG["pico_boot_ok"]
# Port vanished (amidi hung and was killed, or returned "No such") → likely in bootloader
if amidi_rc == 124 or not amidi_out or "No such" in amidi_out or "cannot open" in amidi_out:
ok("Update signal sent — Pico is rebooting into update mode…")
time.sleep(4)
check2 = pi_client.exec(
"ls /dev/disk/by-label/RPI-RP2 2>/dev/null && echo BY_LABEL;"
"lsblk -no NAME,LABEL 2>/dev/null | grep -i RPI-RP2 && echo BY_LABEL_LSBLK || true"
)
if any(k in check2["stdout"] for k in ("BY_LABEL", "BY_LABEL_LSBLK")):
return MSG["pico_boot_ok"]
return MSG["pico_boot_likely"]
return MSG["pico_boot_fail"]
# ─── Pico flash ───────────────────────────────────────────────────────────────
def pico_flash(pi_client, local_uf2_path: str) -> str:
local = Path(local_uf2_path)
if not local.exists():
return f"File not found: {local}"
ok(f"Uploading {local.name} to Pi…")
remote_uf2 = f"/tmp/{local.name}"
pi_client.write_bytes(remote_uf2, local.read_bytes())
# Try picotool first (cleanest method — works if installed on Pi)
r = pi_client.exec(f"picotool load {remote_uf2} --force 2>&1", timeout=8)
if r["rc"] == 0:
return f"Flashed via picotool: {local.name}\n{r['stdout'].strip()}"
ok("Locating RPI-RP2 block device and mounting…")
# Shell script that:
# 1. Finds the Pico block device via lsblk VENDOR (reads sysfs — no blkid probe, no hang)
# 2. Mounts it with uid=1000 so the pi user can write (or uses sudo if already root-mounted)
# 3. Verifies the mount with mountpoint before writing (not just directory existence)
# 4. Runs cp and checks its exit code separately — a leftover empty dir won't fool us
# 5. sync before umount to force page-cache flush to the Pico
flash_script = (
"MPOINT=/media/RPI-RP2\n"
f"UF2={remote_uf2}\n"
"mkdir -p \"$MPOINT\"\n"
"for i in $(seq 1 30); do\n"
# 1. by-label symlink (most reliable, set by udev on Raspberry Pi OS)
" DEV=$(readlink -f /dev/disk/by-label/RPI-RP2 2>/dev/null)\n"
# 2. lsblk by LABEL
" [ -z \"$DEV\" ] && DEV=$(lsblk -no NAME,LABEL 2>/dev/null"
" | awk '$2==\"RPI-RP2\"{print \"/dev/\"$1}'"
" | grep -v mmcblk | head -1)\n"
# 3. lsblk by MODEL (RP2 Boot, RP2, RPI-RP2)
" if [ -z \"$DEV\" ]; then\n"
" DISK=$(lsblk -no NAME,MODEL 2>/dev/null"
" | awk '$2~/^RP2|^RPI/{print $1}'"
" | grep -v mmcblk | head -1)\n"
" [ -n \"$DISK\" ] && { if [ -b \"/dev/${DISK}1\" ]; then DEV=\"/dev/${DISK}1\"; else DEV=\"/dev/$DISK\"; fi; }\n"
" fi\n"
# 4. lsblk by VENDOR (RaspberryPi, Raspberry)
" [ -z \"$DEV\" ] && DEV=$(lsblk -no NAME,VENDOR 2>/dev/null"
" | awk 'tolower($2)~/raspberr/{print \"/dev/\"$1}'"
" | grep -v mmcblk | head -1)\n"
" if [ -n \"$DEV\" ]; then\n"
" if ! mountpoint -q \"$MPOINT\" 2>/dev/null; then\n"
" sudo mount -t vfat -o uid=1000,gid=1000,sync \"$DEV\" \"$MPOINT\" 2>/dev/null"
" || sudo mount -t vfat -o sync \"$DEV\" \"$MPOINT\" 2>/dev/null || true\n"
" fi\n"
" if mountpoint -q \"$MPOINT\" 2>/dev/null; then\n"
" echo \"MOUNTED:$DEV\"\n"
" cp \"$UF2\" \"$MPOINT/fw.uf2\"\n"
" CP_RC=$?\n"
" sync\n"
" sudo umount \"$MPOINT\" 2>/dev/null || true\n"
" if [ \"$CP_RC\" -eq 0 ]; then\n"
" echo FLASHED\n"
" exit 0\n"
" else\n"
" echo \"CP_FAILED:$CP_RC\"\n"
" exit 1\n"
" fi\n"
" fi\n"
" fi\n"
" sleep 1\n"
"done\n"
"echo NOTMOUNTED\n"
"exit 1\n"
)
r2 = pi_client.exec(flash_script, timeout=60)
stdout = r2["stdout"]
if "CP_FAILED" in stdout:
return MSG["flash_fail_copy"]
if "NOTMOUNTED" in stdout:
return MSG["flash_fail_nodev"]
if "FLASHED" not in stdout:
return f"Flash failed — unexpected output:\n{stdout}"
ok("UF2 sent — waiting for Pico to reboot into firmware…")
for i in range(45):
time.sleep(1)
# Match any ttyACM* in case enumeration picks a different index
chk = pi_client.exec(
"ls /dev/ttyACM* 2>/dev/null | head -1 || echo WAITING",
timeout=5,
)
port = chk["stdout"].strip()
if port and port != "WAITING":
ok(MSG["pico_alive"].format(port=port))
midi = pi_client.exec("aconnect -l 2>/dev/null | grep -i xdj || echo NOT_VISIBLE", timeout=5)
if "NOT_VISIBLE" not in midi["stdout"]:
ok(MSG["pico_midi_ok"])
else:
warn(MSG["pico_midi_wait"])
return MSG["flash_ok"]
return (
"UF2 was sent successfully but /dev/ttyACM* did not appear within 45 s.\n"
"The firmware is on the Pico — it is not lost.\n"
"Wait a few more seconds then run: python3 xdj-pi-dev.py --status\n"
"If still missing, unplug and replug the Pico USB cable on the Pi."
)
# ─── Pico CLI setup ───────────────────────────────────────────────────────────
def setup_pico_cli(pi_client) -> None:
"""
One-time setup: install arduino-cli on the Pi, add the arduino-pico core,
and install the required libraries. Downloads ~500 MB of toolchain.
Mixxx is stopped during the download/install to free RAM, then restarted.
"""
section("Pico Toolchain Setup (one-time, ~500 MB download)")
print(f" {_C.DIM}Mixxx will be stopped during this operation.{_C.RESET}\n")
with Step("Stopping Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true", timeout=5)
time.sleep(2)
with Step("Creating firmware directory"):
pi_client.exec(f"mkdir -p {PI_FIRMWARE_DIR} {PI_BUILD_DIR}")
# Download arduino-cli if not present
cli_present = pi_client.exec("which arduino-cli")
if cli_present["rc"] != 0:
with Step("Downloading arduino-cli"):
rc = pi_client.exec_stream(
f"cd /tmp && curl -fsSL {ARDUINO_CLI_URL} | tar xz arduino-cli"
f" && sudo mv /tmp/arduino-cli /usr/local/bin/ && echo ok",
timeout=120,
)
if rc != 0:
fail("arduino-cli download failed — check internet connection on Pi")
with Step("Restarting Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true")
return
else:
ok(f"arduino-cli already installed ({cli_present['stdout'].strip()})")
with Step("Initialising arduino-cli config"):
pi_client.exec("arduino-cli config init --overwrite 2>/dev/null; true")
pi_client.exec(
f"arduino-cli config add board_manager.additional_urls {ARDUINO_BOARD_URL}"
)
with Step("Updating board index"):
pi_client.exec_stream("arduino-cli core update-index 2>&1", timeout=60)
print(f"\n {_C.DIM}Installing RP2040 core + ARM toolchain (~500 MB)…{_C.RESET}")
print(f" {_C.GRAY}{_C.RESET}")
rc = pi_client.exec_stream("arduino-cli core install rp2040:rp2040 2>&1", timeout=900)
if rc != 0:
fail("Core install failed")
else:
ok("RP2040 core installed")
print()
with Step("Installing Adafruit TinyUSB"):
pi_client.exec_stream(
'arduino-cli lib install "Adafruit TinyUSB Library" 2>&1', timeout=120
)
with Step("Installing MIDI Library"):
pi_client.exec_stream('arduino-cli lib install "MIDI Library" 2>&1', timeout=60)
with Step("Installing Bounce2"):
pi_client.exec_stream('arduino-cli lib install "Bounce2" 2>&1', timeout=60)
with Step("Restarting Mixxx"):
time.sleep(4)
pi_client.exec("killall mixxx 2>/dev/null; true")
print()
ok("Toolchain ready — run --pico-compile to build firmware")
# ─── Private compile helpers ──────────────────────────────────────────────────
def _resolve_fqbn(board: str) -> str:
"""Resolve a board name or raw FQBN to a full FQBN string."""
return BOARD_PROFILES.get(board, board)
def _compile_local(fqbn: str, ino: Path) -> Path:
"""
Compile firmware using arduino-cli on this machine.
Returns path to the produced .uf2 (or .hex for AVR).
Raises RuntimeError on failure.
"""
build_dir = Path(tempfile.mkdtemp(prefix="xdj-build-"))
# arduino-cli requires sketch dir name == .ino stem
sketch_dir = build_dir / ino.stem
sketch_dir.mkdir()
shutil.copy(ino, sketch_dir / ino.name)
r = subprocess.run(
[
"arduino-cli", "compile",
"--fqbn", fqbn,
"--output-dir", str(build_dir),
"--warnings", "none",
str(sketch_dir),
],
capture_output=True,
text=True,
)
if r.returncode != 0:
raise RuntimeError((r.stderr or r.stdout).strip())
artifacts = list(build_dir.glob("*.uf2")) or list(build_dir.glob("*.hex"))
if not artifacts:
raise RuntimeError("Compile succeeded but no .uf2/.hex found in output dir")
return artifacts[0]
def _compile_on_pi(pi_client, fqbn: str, ino: Path) -> str:
"""
Push firmware to Pi and compile there.
Returns the remote path of the produced .uf2.
Raises RuntimeError on failure.
"""
pi_sketch_dir = f"{PI_FIRMWARE_DIR}/{ino.stem}"
pi_ino = f"{pi_sketch_dir}/{ino.name}"
pi_client.exec(f"mkdir -p {pi_sketch_dir} {PI_BUILD_DIR}")
pi_client.write_bytes(pi_ino, ino.read_bytes())
w = shutil.get_terminal_size((72, 24)).columns
bar = _C.GRAY + "" * min(w - 4, 68) + _C.RESET
print(f"\n {bar}")
t0 = time.time()
rc = pi_client.exec_stream(
f"arduino-cli compile --fqbn {fqbn}"
f" --output-dir {PI_BUILD_DIR} --warnings none {pi_ino} 2>&1",
timeout=300,
)
print(f" {bar}\n")
if rc != 0:
raise RuntimeError(f"Compile failed on Pi after {time.time()-t0:.0f}s")
check = pi_client.exec(f"ls {PI_BUILD_DIR}/*.uf2 {PI_BUILD_DIR}/*.hex 2>/dev/null | head -1")
path = check["stdout"].strip()
if not path:
raise RuntimeError("Compile finished but no .uf2/.hex found on Pi")
return path
# ─── Pico compile (top-level) ─────────────────────────────────────────────────
def pico_compile(pi_client, flash: bool = False, board: str = DEFAULT_BOARD) -> None:
"""
Compile firmware then optionally flash.
Strategy: local arduino-cli first (faster, no Pi needed); falls back to Pi.
"""
section("Pico Firmware Compile")
if not REPO_FIRMWARE.exists():
fail(f"Firmware not found: {REPO_FIRMWARE}")
return
fqbn = _resolve_fqbn(board)
ok(f"Board: {board}{fqbn}")
local_cli = shutil.which("arduino-cli")
pi_cli = pi_client.exec("which arduino-cli 2>/dev/null")["rc"] == 0
if not local_cli and not pi_cli:
fail("arduino-cli not found — install it first:")
print(f" {_C.CYAN}Mac/Linux:{_C.RESET} brew install arduino-cli")
print(f" {_C.CYAN}Pi:{_C.RESET} {sys.argv[0]} --setup-pico-cli (Pi needs internet)")
return
uf2_local: Path | None = None # set if compiled on this machine
uf2_remote: str | None = None # set if compiled on Pi
if local_cli:
ok(f"Compiling locally ({local_cli})")
t0 = time.time()
try:
uf2_local = _compile_local(fqbn, REPO_FIRMWARE)
except RuntimeError as e:
fail(f"Local compile failed:\n{e}")
return
kb = uf2_local.stat().st_size // 1024
ok(f"Done in {time.time()-t0:.0f}s — {uf2_local.name} ({kb} KB)")
else:
ok("Compiling on Pi (arduino-cli found there, local not available)")
with Step("Stopping Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true", timeout=5)
time.sleep(3)
try:
uf2_remote = _compile_on_pi(pi_client, fqbn, REPO_FIRMWARE)
except RuntimeError as e:
fail(str(e))
with Step("Restarting Mixxx"):
pi_client.exec("killall mixxx 2>/dev/null; true")
return
sz_r = pi_client.exec(f"stat -c%s {uf2_remote} 2>/dev/null")["stdout"].strip()
kb = int(sz_r) // 1024 if sz_r.isdigit() else 0
ok(f"Compiled on Pi — {Path(uf2_remote).name} ({kb} KB)")
if not flash:
if uf2_remote:
# nothing to do locally; restart Mixxx
with Step("Restarting Mixxx"):
time.sleep(2)
pi_client.exec("killall mixxx 2>/dev/null; true")
print()
return
# ── Flash path ──────────────────────────────────────────────────────────────
print()
# If compiled remotely, download UF2 locally so pico_flash() can upload it fresh
if uf2_remote and not uf2_local:
with Step("Downloading UF2 from Pi"):
tmp = Path(tempfile.gettempdir()) / Path(uf2_remote).name
tmp.write_bytes(pi_client.read_bytes(uf2_remote))
uf2_local = tmp
with Step("Triggering Pico bootloader"):
result = pico_bootloader(pi_client)
if "failed" in result.lower() or "error" in result.lower():
fail(result)
return
ok(result)
time.sleep(3)
print(pico_flash(pi_client, str(uf2_local)))
print()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
"""
Setup commands: SSH keys, DHCP server, hostname change, SSH recovery guide.
Extracted from xdj-pi-dev.py (lines 1102-1288).
"""
import subprocess
import textwrap
from pathlib import Path
import paramiko
from xdj_pi_dev._terminal import (
_C, Step, section, ok, warn, fail,
)
# Project-specific SSH key (won't affect other SSH usage)
SSH_KEY_PATH = Path.home() / ".ssh" / "xdj_pi_ed25519"
# ─── SSH key setup ────────────────────────────────────────────────────────────
def setup_ssh_keys(pi_client) -> None:
"""
Generate a project-specific ed25519 key pair and install it on the Pi.
After this, the tool authenticates without a password.
"""
section("SSH Key Setup")
# 1. Generate key locally if not present
if SSH_KEY_PATH.exists():
ok(f"Key already exists: {SSH_KEY_PATH}")
else:
print(f" Generating ed25519 key: {SSH_KEY_PATH}")
r = subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", str(SSH_KEY_PATH),
"-N", "", "-C", "xdj-pi-dev"],
capture_output=True, text=True
)
if r.returncode != 0:
fail(f"ssh-keygen failed: {r.stderr.strip()}")
return
ok(f"Key generated: {SSH_KEY_PATH}")
pub_key = (SSH_KEY_PATH.with_suffix(".pub")).read_text().strip()
# 2. Install on Pi
print(f" Installing public key on Pi ({pi_client.host})…")
install_cmd = (
f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
f"grep -qxF '{pub_key}' ~/.ssh/authorized_keys 2>/dev/null || "
f"echo '{pub_key}' >> ~/.ssh/authorized_keys && "
f"chmod 600 ~/.ssh/authorized_keys && echo installed"
)
r2 = pi_client.exec(install_cmd)
if "installed" in r2["stdout"] or r2["rc"] == 0:
ok("Public key installed on Pi")
else:
fail(f"Install failed: {r2['stderr'].strip()}")
return
# 3. Test key auth works before offering to disable password
print(" Testing key auth…")
test_ssh = paramiko.SSHClient()
test_ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
test_ssh.connect(pi_client.host, username=pi_client.user,
key_filename=str(SSH_KEY_PATH),
look_for_keys=False, allow_agent=False, timeout=8)
test_ssh.close()
ok("Key auth confirmed working")
except Exception as e:
fail(f"Key auth test failed: {e}")
print(" Password auth is still active. Fix the key issue before disabling it.")
return
# 4. Offer to disable password auth (skip prompt in TUI — no stdin available)
from xdj_pi_dev._terminal import _tui_log_fn as _tui_fn
if _tui_fn:
ok("Key auth is working. Password auth left enabled (use CLI to disable).")
return
print()
print(" Key auth is working. Optionally disable SSH password auth on Pi")
print(" (more secure — only connections with this key will be accepted).")
answer = input(" Disable password auth now? [y/N] ").strip().lower()
if answer == "y":
r3 = pi_client.exec(
"sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' "
"/etc/ssh/sshd_config && sudo systemctl reload ssh && echo done"
)
if "done" in r3["stdout"]:
ok("Password auth disabled. Only key auth accepted.")
print()
print(" IMPORTANT: Keep the key file safe:")
print(f" {SSH_KEY_PATH}")
print(f" {SSH_KEY_PATH}.pub")
print()
print(" If you lose the key, use --restore-ssh to recover access.")
else:
fail(f"Could not update sshd_config: {r3['stderr'].strip()}")
else:
ok("Password auth left enabled (safe default)")
# ─── SSH recovery guide ───────────────────────────────────────────────────────
def restore_ssh() -> None:
"""Print step-by-step SSH recovery instructions — no network connection required."""
section("SSH Recovery Guide")
print(textwrap.dedent("""
If you're locked out of the Pi (lost key, disabled password auth accidentally):
── Option A: Physical console ─────────────────────────────────────────────
1. Connect a keyboard and HDMI monitor to the Pi.
2. Press Ctrl+Alt+F2 to switch to tty2 (away from Mixxx on tty1).
3. Log in: username xdj100sx, password xdj100sx
4. Re-enable password auth:
sudo nano /etc/ssh/sshd_config
→ Change: PasswordAuthentication no → yes
sudo systemctl reload ssh
5. Press Ctrl+Alt+F1 to return to Mixxx.
── Option B: Edit the SD card on another machine ──────────────────────────
1. Power off the Pi. Remove the SD card.
2. Mount the SD card on your machine (Linux/macOS native; Windows use DiskGenius or WSL).
3. Edit: /etc/ssh/sshd_config
→ PasswordAuthentication yes
4. Reinstall SD card, power on Pi.
── Option C: Install a new key without password auth ──────────────────────
If you have a different SSH key that still works:
1. ssh -i /path/to/other/key xdj100sx@<pi-ip>
2. Then run --setup-ssh-keys to install the project key.
── Recovering the Pi IP if unknown ────────────────────────────────────────
On the Pi console: ip addr show eth0
From this machine: python3 xdj-pi-dev.py --discover
"""))
# ─── Pi DHCP setup ───────────────────────────────────────────────────────────
def setup_pi_dhcp(pi_client) -> None:
"""
Configure the Pi to act as a DHCP server on eth0.
After this, any machine plugged directly into the Pi gets an IP automatically
— no manual network alias setup needed.
"""
section("Pi DHCP Server Setup")
print(" This installs dnsmasq on the Pi and configures it to auto-assign")
print(" IP addresses to machines connected via direct cable.")
print(" Pi's own IP stays at 192.168.10.2.")
print()
# Install dnsmasq
print(" Installing dnsmasq…")
r = pi_client.exec("sudo apt-get install -y dnsmasq 2>&1 | tail -3", timeout=120)
if r["rc"] != 0:
fail(f"apt-get failed: {r['stderr'].strip()}")
return
ok("dnsmasq installed")
# Write config
conf = textwrap.dedent("""\
# XDJ direct-cable DHCP — managed by xdj-pi-dev.py
interface=eth0
bind-interfaces
dhcp-range=192.168.10.100,192.168.10.200,255.255.255.0,12h
# No gateway, no DNS — pure IP assignment for direct cable
dhcp-option=3
dhcp-option=6
""")
r2 = pi_client.exec(
f"sudo mkdir -p /etc/dnsmasq.d && echo '{conf}' | sudo tee /etc/dnsmasq.d/xdj-direct.conf > /dev/null && echo ok"
)
if "ok" not in r2["stdout"]:
fail(f"Could not write dnsmasq config: {r2['stderr'].strip()}")
return
ok("dnsmasq config written (/etc/dnsmasq.d/xdj-direct.conf)")
# Enable and restart
r3 = pi_client.exec(
"sudo systemctl enable dnsmasq && sudo systemctl restart dnsmasq && echo ok"
)
if "ok" not in r3["stdout"]:
fail(f"dnsmasq restart failed: {r3['stderr'].strip()}")
return
ok("dnsmasq running and enabled on boot")
print()
print(" Done. Next time you plug in via direct cable:")
print(" • Your machine will get an IP in 192.168.10.100200 automatically")
print(" • No manual network config needed")
print(" • XDJ100SX.local still works too")
# ─── Hostname change ──────────────────────────────────────────────────────────
def set_hostname(pi_client, new_hostname: str) -> None:
"""Change the Pi's hostname and restart avahi so mDNS updates immediately."""
section(f"Set Hostname → {new_hostname}")
old = pi_client.exec("hostname").get("stdout", "").strip()
print(f" Current hostname: {old}")
r = pi_client.exec(
f"sudo hostnamectl set-hostname {new_hostname} && "
f"sudo sed -i 's/{old}/{new_hostname}/g' /etc/hosts && "
f"sudo systemctl restart avahi-daemon 2>/dev/null; echo ok"
)
if "ok" in r["stdout"]:
ok(f"Hostname changed to: {new_hostname}")
print(f" New mDNS address: {new_hostname}.local")
else:
fail(f"Failed: {r['stderr'].strip()}")
+906
View File
@@ -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"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>XDJ Signal Analyzer</title>
<style>
:root{--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--brd:#30363d;--txt:#c9d1d9;--dim:#6e7681;--blue:#58a6ff;--grn:#3fb950;--yel:#d29922;--red:#f85149;--cyan:#39c5cf;}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--txt);font-family:'SF Mono','Fira Code','Cascadia Code',monospace;font-size:13px;overflow:hidden;height:100vh;display:flex;flex-direction:column;}
header{background:var(--bg2);border-bottom:1px solid var(--brd);padding:7px 16px;display:flex;align-items:center;gap:14px;flex-shrink:0;}
header h1{color:var(--blue);font-size:13px;font-weight:bold;}
#status{font-size:12px;}#elapsed,#evcount{color:var(--dim);font-size:12px;}
.hbtn{background:var(--bg3);border:1px solid var(--brd);color:var(--txt);padding:2px 10px;cursor:pointer;border-radius:3px;font-family:inherit;font-size:11px;margin-left:4px;}
.hbtn:hover{border-color:var(--blue);}
.fbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:5px 12px;display:flex;gap:6px;align-items:center;flex-shrink:0;}
.fbtn{background:var(--bg3);border:1px solid var(--brd);color:var(--dim);padding:2px 12px;cursor:pointer;border-radius:3px;font-family:inherit;font-size:12px;}
.fbtn.on{background:#1f6feb;border-color:#388bfd;color:#e6edf3;}
.hint{color:var(--dim);font-size:11px;margin-left:8px;}
.main{display:grid;grid-template-columns:1fr 270px;grid-template-rows:1fr 160px;flex:1;min-height:0;}
.tbl-wrap{overflow-y:auto;border-right:1px solid var(--brd);}
table{width:100%;border-collapse:collapse;}
thead th{position:sticky;top:0;background:var(--bg2);color:var(--dim);text-align:left;padding:4px 8px;border-bottom:1px solid var(--brd);font-weight:bold;font-size:11px;text-transform:uppercase;white-space:nowrap;}
tbody tr{border-bottom:1px solid var(--brd)22;}
tbody tr:hover{background:#ffffff09;}
tbody tr.sel{background:#1f6feb18;}
tbody td{padding:3px 8px;white-space:nowrap;}
.c-pin{color:var(--cyan);font-weight:bold;}.c-name{color:var(--dim);}
.c-hi{color:var(--grn);}.c-lo{color:var(--yel);}
.c-ed{color:var(--txt);}.c-bc{color:var(--red);}.c-dim{color:var(--dim);}
.wf{letter-spacing:-1px;}
.rpanel{display:flex;flex-direction:column;border-left:1px solid var(--brd);}
.rsec{padding:10px 12px;border-bottom:1px solid var(--brd);flex-shrink:0;}
.rsec.grow{flex:1;overflow:hidden;}
.rtitle{color:var(--cyan);font-size:10px;font-weight:bold;margin-bottom:6px;letter-spacing:.5px;}
.rcontent{font-size:12px;line-height:1.7;}
#adc-bar-bg{background:var(--bg3);border-radius:2px;height:8px;margin:5px 0;}
#adc-bar{height:8px;border-radius:2px;transition:width .06s;}
#adc-nums{color:var(--dim);font-size:11px;}
#adc-spark{color:var(--cyan);letter-spacing:-1px;font-size:11px;}
.log-wrap{grid-column:1/-1;border-top:1px solid var(--brd);overflow-y:auto;font-size:11px;}
.log-wrap::-webkit-scrollbar{width:4px;}.log-wrap::-webkit-scrollbar-thumb{background:var(--brd);}
.ev{display:flex;gap:10px;padding:1px 12px;}.ev:hover{background:#ffffff06;}
.ev-t{color:var(--dim);min-width:65px;}.ev-p{color:var(--cyan);min-width:36px;}
.ev-n{color:var(--dim);min-width:96px;}.ev-hi{color:var(--grn);}.ev-lo{color:var(--yel);}
.ev-bc{color:var(--red);font-size:10px;}.ev-err{color:var(--red);}
</style></head><body>
<header>
<h1>⬡ XDJ Signal Analyzer</h1>
<span id="status" style="color:#d29922">● connecting…</span>
<span id="elapsed">00:00:00</span>│
<span id="evcount">0 events</span>
<span style="margin-left:auto">
<button class="hbtn" onclick="resetAll()">Reset</button>
<button class="hbtn" onclick="clearLog()">Clear Log</button>
</span>
</header>
<div class="fbar">
<button class="fbtn on" id="fb-all" onclick="setFilter('all',this)">ALL</button>
<button class="fbtn" id="fb-buttons" onclick="setFilter('buttons',this)">BUTTONS</button>
<button class="fbtn" id="fb-jog" onclick="setFilter('jog',this)">JOG</button>
<button class="fbtn" id="fb-leds" onclick="setFilter('leds',this)">LEDs</button>
<span class="hint">click row to inspect · SSE live stream</span>
</div>
<div class="main">
<div class="tbl-wrap"><table>
<thead><tr><th>PIN</th><th>NAME</th><th>STATE</th><th>EDGES</th><th>BOUNCE</th><th>MAX µs</th><th>WAVEFORM</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div class="rpanel">
<div class="rsec" id="det-sec">
<div class="rtitle">━ SELECTED PIN ━━━━━━━━━━━━━━━━━━━━━━</div>
<div class="rcontent" id="det" style="color:var(--dim)">↑ click a row</div>
</div>
<div class="rsec">
<div class="rtitle">━ JOG QUADRATURE ━━━━━━━━━━━━━━━━━━━</div>
<div class="rcontent" id="jog"></div>
</div>
<div class="rsec grow">
<div class="rtitle">━ PITCH ADC GP26 ━━━━━━━━━━━━━━━━━━━</div>
<div id="adc-bar-bg"><div id="adc-bar" style="width:50%;background:var(--blue)"></div></div>
<div id="adc-nums"></div>
<div id="adc-spark"></div>
</div>
</div>
<div class="log-wrap"><div id="evlog"></div></div>
</div>
<script>
const PIN_NAMES = %%PIN_NAMES%%;
const BTN_PINS = %%BTN_PINS%%;
const JOG_PINS = %%JOG_PINS%%;
const LED_PINS = %%LED_PINS%%;
const ALL_PINS = %%ALL_PINS%%;
const HOST = "%%HOST%%";
const FILTERS = {all:ALL_PINS, buttons:BTN_PINS, jog:JOG_PINS, leds:LED_PINS};
let filter = ALL_PINS, selPin = null, jogNet = 0, evCount = 0, logCount = 0, tRef = null;
const t0 = Date.now();
const pins = {};
ALL_PINS.forEach(p => pins[p] = {val:1,edges:0,bounce:0,bm:0,wf:Array(24).fill(1)});
function buildTable() {
const tb = document.getElementById('tbody');
tb.innerHTML = '';
filter.forEach(pin => {
const tr = document.createElement('tr');
tr.id = 'r'+pin; tr.onclick = ()=>selRow(pin);
tr.innerHTML = '<td class="c-pin">GP'+String(pin).padStart(2,'0')+'</td>'
+'<td class="c-name">'+(PIN_NAMES[pin]||'?')+'</td>'
+'<td id="st'+pin+'" class="c-hi">▔ HI</td>'
+'<td id="ed'+pin+'" class="c-ed">0</td>'
+'<td id="bc'+pin+'" class="c-dim">—</td>'
+'<td id="bm'+pin+'" class="c-dim">—</td>'
+'<td id="wf'+pin+'" class="wf c-hi">'+''.repeat(24)+'</td>';
tb.appendChild(tr);
});
}
buildTable();
function updRow(pin) {
const p = pins[pin];
const st = document.getElementById('st'+pin); if(!st)return;
st.textContent = p.val?'▔ HI':'▁ LO'; st.className = p.val?'c-hi':'c-lo';
document.getElementById('ed'+pin).textContent = p.edges;
const bc=document.getElementById('bc'+pin);
bc.textContent=p.bounce||''; bc.className=p.bounce?'c-bc':'c-dim';
const bm=document.getElementById('bm'+pin);
bm.textContent=p.bm||''; bm.className=p.bm?'c-bc':'c-dim';
const wf=document.getElementById('wf'+pin);
wf.textContent=p.wf.map(v=>v?'':'').join(''); wf.className='wf '+(p.val?'c-hi':'c-lo');
}
function selRow(pin) {
selPin=pin;
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
const r=document.getElementById('r'+pin); if(r)r.classList.add('sel');
updDet();
}
function updDet() {
if(selPin===null)return;
const p=pins[selPin], name=PIN_NAMES[selPin]||('GP'+selPin);
const sc=p.val?'var(--grn)':'var(--yel)';
const bc=p.bounce?`<span style="color:var(--red)">⚡ ×${p.bounce} max ${p.bm}µs</span>`:'<span style="color:var(--dim)">no bounce</span>';
const wf=p.wf.map(v=>v?'':'').join('');
document.getElementById('det').innerHTML=
`<div style="color:var(--cyan);font-weight:bold">GP${String(selPin).padStart(2,'0')} ${name}</div>`
+`<div><span style="color:${sc}">${p.val?'▔ HIGH':'▁ LOW'}</span> &nbsp;edges:<b>${p.edges}</b>&nbsp;${bc}</div>`
+`<div style="color:${sc};letter-spacing:-1px;margin-top:4px;font-size:12px">${wf}</div>`;
}
function updJog() {
const a=pins[14]||{val:1,wf:[],edges:0}, b=pins[19]||{val:1,wf:[],edges:0};
const nc=jogNet>0?'var(--grn)':jogNet<0?'var(--yel)':'var(--dim)';
document.getElementById('jog').innerHTML=
`<div><span style="color:var(--grn)">A GP14</span> <span style="color:var(--grn);letter-spacing:-1px">${a.wf.map(v=>v?'':'').join('')}</span> <span style="color:var(--dim)">e:</span>${a.edges}</div>`
+`<div><span style="color:var(--yel)">B GP19</span> <span style="color:var(--yel);letter-spacing:-1px">${b.wf.map(v=>v?'':'').join('')}</span> <span style="color:var(--dim)">e:</span>${b.edges}</div>`
+`<div style="margin-top:4px">Net: <span style="color:${nc}">${jogNet>=0?'CW':'CCW'} ${Math.abs(jogNet)}</span></div>`;
}
function updADC(d) {
const center=Math.abs(d.val-512)<=8;
const pct=d.val/1023*100;
document.getElementById('adc-bar').style.width=pct+'%';
document.getElementById('adc-bar').style.background=center?'var(--grn)':'var(--yel)';
const cs=center?'<span style="color:var(--grn)">● CENTER</span>'
:`<span style="color:var(--yel)">${d.val-512>0?'+':''}${d.val-512} off</span>`;
document.getElementById('adc-nums').innerHTML=
`<span style="color:var(--dim)">val:</span>${d.val} ${cs} `
+`<span style="color:var(--dim)">min:${d.min} max:${d.max} mean:${d.mean.toFixed(1)}</span>`;
if(d.hist&&d.hist.length>1){
const mn=Math.min(...d.hist),mx=Math.max(...d.hist),rng=mx-mn||1;
const ch=' ▁▂▃▄▅▆▇█';
document.getElementById('adc-spark').textContent=d.hist.slice(-44).map(v=>ch[Math.round((v-mn)*8/rng)]).join('');
}
}
function setFilter(name,btn) {
filter=FILTERS[name]||ALL_PINS;
document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
buildTable(); filter.forEach(updRow);
}
function clearLog(){document.getElementById('evlog').innerHTML='';logCount=0;tRef=null;}
function resetAll(){
ALL_PINS.forEach(p=>{pins[p]={val:pins[p]?.val??1,edges:0,bounce:0,bm:0,wf:Array(24).fill(pins[p]?.val??1)};updRow(p);});
evCount=0;jogNet=0;document.getElementById('evcount').textContent='0 events';
clearLog();updDet();updJog();
}
function addLog(ev) {
if(!filter.includes(ev.pin))return;
if(!tRef&&ev.t>0)tRef=ev.t;
const rel=tRef?((ev.t-tRef)/1e6).toFixed(3):'0.000';
const edge=ev.val?'<span class="ev-hi">▁→▔</span>':'<span class="ev-lo">▔→▁</span>';
const bc=ev.bounce>0?`<span class="ev-bc"> ⚡×${ev.bounce}</span>`:'';
const div=document.createElement('div'); div.className='ev';
div.innerHTML=`<span class="ev-t">${rel}s</span><span class="ev-p">GP${String(ev.pin).padStart(2,'0')}</span>`
+`<span class="ev-n">${PIN_NAMES[ev.pin]||''}</span>${edge}${bc}`;
const log=document.getElementById('evlog'); log.appendChild(div);
logCount++; if(logCount>500)log.removeChild(log.firstChild);
log.parentElement.scrollTop=log.parentElement.scrollHeight;
}
setInterval(()=>{
const el=Math.floor((Date.now()-t0)/1000);
document.getElementById('elapsed').textContent=
String(Math.floor(el/3600)).padStart(2,'0')+':'+String(Math.floor((el%3600)/60)).padStart(2,'0')+':'+String(el%60).padStart(2,'0');
},1000);
function connect() {
const es=new EventSource('/stream');
es.onopen=()=>{document.getElementById('status').textContent=''+HOST;document.getElementById('status').style.color='var(--grn)';};
es.onmessage=e=>{
const ev=JSON.parse(e.data);
if(ev.type==='gpio'){
const prev=pins[ev.pin]||{};
pins[ev.pin]={val:ev.val,edges:ev.edges,bounce:ev.bounce,bm:ev.bounce_max_us,wf:ev.wf};
if(ev.pin===19&&prev.val!==undefined&&ev.val===0&&prev.val===1)
jogNet+=(pins[14]?.val===0)?1:-1;
updRow(ev.pin);
if(ev.pin===selPin)updDet();
if(ev.pin===14||ev.pin===19)updJog();
if(ev.t>0){evCount++;document.getElementById('evcount').textContent=evCount+' events';addLog(ev);}
} else if(ev.type==='adc'){
updADC(ev);
} else if(ev.type==='error'){
const div=document.createElement('div');div.className='ev ev-err';
div.textContent=''+ev.msg;document.getElementById('evlog').appendChild(div);
document.getElementById('status').textContent='⚠ disconnected';
document.getElementById('status').style.color='var(--red)';
es.close();setTimeout(connect,3000);
}
};
es.onerror=()=>{
document.getElementById('status').textContent='● reconnecting…';
document.getElementById('status').style.color='var(--yel)';
es.close();setTimeout(connect,3000);
};
}
updJog();connect();
</script></body></html>"""
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()
+267
View File
@@ -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)