Compare commits
41 Commits
main
..
contribution
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d561c534 | |||
| 8b0eb42fec | |||
| 3e7b0f0c3f | |||
| c08b7466d1 | |||
| 61c87361bd | |||
| 2118dc8852 | |||
| a4b14b3d90 | |||
| 3feacb295e | |||
| 07c92c0609 | |||
| b5b1eee974 | |||
| 71d6caceda | |||
| 19a5340aa7 | |||
| 75251efa19 | |||
| 4f3efc9a2b | |||
| b5790f4789 | |||
| 0b42c15ea5 | |||
| ba0b8473cc | |||
| e2394053b8 | |||
| 45b7bc2642 | |||
| 19aa8f16fb | |||
| cd6998d11c | |||
| d98aabfa43 | |||
| 583c9fbb75 | |||
| d581be16fa | |||
| 55579314c5 | |||
| 9e87d2e3d4 | |||
| a2b65ce6ae | |||
| 17730e4b94 | |||
| 77639ead8d | |||
| 0d38935511 | |||
| ca3afdce5e | |||
| 0df4ebc4f8 | |||
| ad5153e578 | |||
| 8ec940a529 | |||
| 1f658836ca | |||
| a995e2ba84 | |||
| 3d373165cd | |||
| 43338b4c35 | |||
| 6f07eae7d5 | |||
| 3ada74d7fa | |||
| e88031b0ac |
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -27,8 +29,6 @@ This project is another experimental project that combines hardware, MIDI and op
|
||||
|
||||
Note it only works with the Raspberry Pi 3B+
|
||||
|
||||
Please read: I identified the mapping included in the image may not work 100% - several users mentioned pitch is not working properly, so the XML and JS mapping files must be replaced with the ones attached in project content under /mixxx
|
||||
|
||||
[Image File](https://drive.google.com/file/d/1fU8ckY35uxCYHJtw1JgclCYaJdQCbCJT/view?usp=sharing).
|
||||
|
||||
|
||||
@@ -39,3 +39,7 @@ Please read: I identified the mapping included in the image may not work 100% -
|
||||
- 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
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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; // 0–16383
|
||||
var full = (XDJ200SX.pitchMSB << 7) | XDJ200SX.pitchLSB; // 0–16383
|
||||
|
||||
// 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>
|
||||
@@ -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*
|
||||
|
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 |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# xdj_pi_dev — XDJ-100SX Pi Developer Tool modules
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.100–200 automatically")
|
||||
print(" • No manual network config needed")
|
||||
print(" • XDJ100SX.local still works too")
|
||||
|
||||
|
||||
# ─── Hostname change ──────────────────────────────────────────────────────────
|
||||
|
||||
def set_hostname(pi_client, new_hostname: str) -> None:
|
||||
"""Change the Pi's hostname and restart avahi so mDNS updates immediately."""
|
||||
section(f"Set Hostname → {new_hostname}")
|
||||
|
||||
old = pi_client.exec("hostname").get("stdout", "").strip()
|
||||
print(f" Current hostname: {old}")
|
||||
|
||||
r = pi_client.exec(
|
||||
f"sudo hostnamectl set-hostname {new_hostname} && "
|
||||
f"sudo sed -i 's/{old}/{new_hostname}/g' /etc/hosts && "
|
||||
f"sudo systemctl restart avahi-daemon 2>/dev/null; echo ok"
|
||||
)
|
||||
if "ok" in r["stdout"]:
|
||||
ok(f"Hostname changed to: {new_hostname}")
|
||||
print(f" New mDNS address: {new_hostname}.local")
|
||||
else:
|
||||
fail(f"Failed: {r['stderr'].strip()}")
|
||||
@@ -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> edges:<b>${p.edges}</b> ${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()
|
||||
@@ -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)
|
||||