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
|
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:
|
## Main Requirements:
|
||||||
|
|
||||||
- Raspberri Pi 3b+
|
- Raspberry Pi 3b+
|
||||||
- Arduino/Teensy with MIDI libraries
|
- Teensy++2.0 with MIDI libraries
|
||||||
|
|
||||||
|
## Content: #in progress 01.2026
|
||||||
## Content:
|
|
||||||
|
|
||||||
- /arduino/ -> firmware code
|
- /arduino/ -> firmware code
|
||||||
- /datasheets/ -> Original CDJ, Teensy and Raspberry datasheets
|
- /datasheets/ -> Original CDJ, Teensy and Raspberry datasheets
|
||||||
@@ -23,16 +25,21 @@ This project is another experimental project that combines hardware, MIDI and op
|
|||||||
- /docs/ -> Documentation of this project
|
- /docs/ -> Documentation of this project
|
||||||
|
|
||||||
|
|
||||||
## Raspberri Pi 3B+ Image file
|
## Raspberry Pi 3B+ Image file
|
||||||
|
|
||||||
Note it only works with the Raspberry Pi 3B+
|
Note it only works with the Raspberry Pi 3B+
|
||||||
|
|
||||||
[Image File](https://drive.google.com/file/d/1xF3MYwbI78TJTnMUjNkVMXjbA1-jqFb2/view?usp=sharing).
|
[Image File](https://drive.google.com/file/d/1fU8ckY35uxCYHJtw1JgclCYaJdQCbCJT/view?usp=sharing).
|
||||||
|
|
||||||
|
|
||||||
|
[](https://discord.gg/4D3xxvuDTy)
|
||||||
|
|
||||||
### 📜 License
|
### 📜 License
|
||||||
- All code in this repository is released under the [GNU GPL v3 License](https://www.gnu.org/licenses/gpl-3.0.html).
|
- All code in this repository is released under the [GNU GPL v3 License](https://www.gnu.org/licenses/gpl-3.0.html).
|
||||||
- All documentation, 3D models, and visual materials are released under the [Creative Commons BY-SA 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/).
|
- 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
|
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 <Encoder.h>
|
||||||
#include <elapsedMillis.h>
|
#include <elapsedMillis.h>
|
||||||
#include <ResponsiveAnalogRead.h>
|
#include <ResponsiveAnalogRead.h>
|
||||||
|
|
||||||
|
|
||||||
// MIDI IN - botons
|
// ---------------- Button Pins ----------------
|
||||||
const int eject_pin = 0;
|
const int search_back_pin = 12;
|
||||||
const int track_previous_pin = 1;
|
const int search_forward_pin = 13;
|
||||||
const int track_next_pin = 2;
|
const int track_previous_pin = 14;
|
||||||
const int search_back_pin = 3;
|
const int track_next_pin = 15;
|
||||||
const int search_forward_pin = 4;
|
const int foldersearch_back_pin = 16;
|
||||||
const int cue_pin = 5;
|
const int foldersearch_forward_pin = 17;
|
||||||
const int play_pin = 6;
|
const int tempomode_pin = 20;
|
||||||
const int jet_pin = 7;
|
const int mastertempo_pin = 21;
|
||||||
const int zip_pin = 8;
|
const int load_pin = 24; //Encoder Push Button
|
||||||
const int wah_pin = 9;
|
const int cue_pin = 25;
|
||||||
const int hold_pin = 10;
|
const int play_pin = 26;
|
||||||
const int time_pin = 11;
|
const int autocue_pin = 27;
|
||||||
const int mastertempo_pin = 12;
|
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;
|
// ---------------- Encoder Jogwheel ----------------
|
||||||
const int jogB_pin = 15;
|
const int jogA_pin = 18;
|
||||||
|
const int jogB_pin = 19;
|
||||||
const int midiChannel = 2;
|
const int midiChannel = 2;
|
||||||
const int jogControlNumber = 20;
|
const int jogControlNumber = 20;
|
||||||
Encoder jog(jogA_pin, jogB_pin);
|
Encoder jog(jogA_pin, jogB_pin);
|
||||||
long lastPosition_jog = 0;
|
long lastPosition_jog = 0;
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------- Encoder Browser ----------------
|
||||||
//encoder browser
|
const int browseA_pin = 36;
|
||||||
const int browseA_pin = 22;
|
const int browseB_pin = 37;
|
||||||
const int browseB_pin = 21;
|
|
||||||
const int load_pin= 23;
|
|
||||||
const int midiChannelb = 3;
|
const int midiChannelb = 3;
|
||||||
const int DEBOUNCE_MS = 3; // Debounce curt
|
const int DEBOUNCE_MS = 3; // Debounce curt
|
||||||
const int NOTE_SCROLL_DOWN = 70;
|
const int NOTE_SCROLL_DOWN = 70;
|
||||||
@@ -44,35 +51,33 @@ long lastPosition_browse = 0; // Última posició llegida
|
|||||||
elapsedMillis msec = 0;
|
elapsedMillis msec = 0;
|
||||||
|
|
||||||
|
|
||||||
/* Pitch slider old
|
// ---------------- Pitch ----------------
|
||||||
const int pitch_pin = A0;
|
|
||||||
const int channel_pitch = 3;
|
|
||||||
const int controllerA0 = 7;
|
|
||||||
int previousA0 = -1;
|
|
||||||
ResponsiveAnalogRead analog(pitch_pin, true);
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
//Pitch new
|
|
||||||
|
|
||||||
const int pitchPin = A0;
|
const int pitchPin = A0;
|
||||||
ResponsiveAnalogRead analog(pitchPin, true);
|
ResponsiveAnalogRead analog(pitchPin, true);
|
||||||
int lastMSB = -1;
|
int lastMSB = -1;
|
||||||
int lastLSB = -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;
|
// ---------------- MIDI Channels ----------------
|
||||||
const int ledPlay = 17;
|
|
||||||
const int ledIntern = 18;
|
|
||||||
const int ledCd = 19;
|
|
||||||
|
|
||||||
// Canals MIDI
|
|
||||||
const int channel = 1;
|
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 PLAY_NOTE_INDICATOR = 61;
|
||||||
const int CUE_NOTE_INDICATOR = 62;
|
const int CUE_NOTE_INDICATOR = 62;
|
||||||
const int LEDINTERN_NOTE_INDICATOR = 63;
|
const int LEDINTERN_NOTE_INDICATOR = 63;
|
||||||
@@ -80,66 +85,77 @@ const int LEDCD_NOTE_INDICATOR = 64;
|
|||||||
const int SIESTAPLAY_NOTE_INDICATOR = 65;
|
const int SIESTAPLAY_NOTE_INDICATOR = 65;
|
||||||
bool siestaplay = false;
|
bool siestaplay = false;
|
||||||
|
|
||||||
//Parpadeig final de track
|
|
||||||
|
// ---------------- Blink when the track ends ----------------
|
||||||
bool parpadeig = false;
|
bool parpadeig = false;
|
||||||
unsigned long tempsAnterior = 0;
|
unsigned long tempsAnterior = 0;
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------- Bounce buttons 5ms ----------------
|
||||||
//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 search_back_boto = Bounce (search_back_pin, 50);
|
Bounce search_back_boto = Bounce (search_back_pin, 50);
|
||||||
Bounce search_forward_boto = Bounce (search_forward_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 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 jet_boto = Bounce (jet_pin, 50);
|
||||||
Bounce zip_boto = Bounce (zip_pin, 50);
|
Bounce zip_boto = Bounce (zip_pin, 50);
|
||||||
Bounce wah_boto = Bounce (wah_pin, 50);
|
Bounce wah_boto = Bounce (wah_pin, 50);
|
||||||
Bounce hold_boto = Bounce (hold_pin, 50);
|
Bounce hold_boto = Bounce (hold_pin, 50);
|
||||||
Bounce time_boto = Bounce (time_pin, 50);
|
Bounce loopin_boto = Bounce (loopin_pin, 50);
|
||||||
Bounce mastertempo_boto = Bounce (mastertempo_pin, 50);
|
Bounce loopout_boto = Bounce (loopout_pin, 50);
|
||||||
Bounce load_boto = Bounce (load_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 ---
|
// Execute Automatically if receiving a "Note On"
|
||||||
|
|
||||||
|
|
||||||
// Aquesta funció s'executarà automàticament quan es rebi un "Note On"
|
|
||||||
void handleNoteOn(byte channel, byte note, byte velocity) {
|
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) {
|
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) {
|
if (velocity > 0) {
|
||||||
digitalWrite(ledPlay, HIGH);
|
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 {
|
else {
|
||||||
digitalWrite(ledPlay, LOW);
|
digitalWrite(ledPlay, LOW);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (note == CUE_NOTE_INDICATOR) {
|
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) {
|
if (velocity > 0) {
|
||||||
digitalWrite(ledCue, HIGH);
|
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 {
|
else {
|
||||||
digitalWrite(ledCue, LOW);
|
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){
|
if(note == SIESTAPLAY_NOTE_INDICATOR){
|
||||||
siestaplay = true;
|
siestaplay = true;
|
||||||
}
|
}
|
||||||
if (note == LEDINTERN_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) {
|
if (note == LEDCD_NOTE_INDICATOR) {
|
||||||
parpadeig = (velocity > 0);
|
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) {
|
void handleNoteOff(byte channel, byte note, byte velocity) {
|
||||||
// Comprovem si la nota rebuda és la del nostre indicador de Play
|
// Comprovem si la nota rebuda és la del nostre indicador de Play
|
||||||
if (note == PLAY_NOTE_INDICATOR) {
|
if (note == PLAY_NOTE_INDICATOR) {
|
||||||
@@ -168,7 +184,11 @@ void handleNoteOff(byte channel, byte note, byte velocity) {
|
|||||||
siestaplay = false;
|
siestaplay = false;
|
||||||
}
|
}
|
||||||
if (note == LEDINTERN_NOTE_INDICATOR || 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() {
|
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_back_pin, INPUT_PULLUP);
|
||||||
pinMode(search_forward_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(cue_pin, INPUT_PULLUP);
|
||||||
pinMode(play_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(eject_pin, INPUT_PULLUP);
|
||||||
|
pinMode(jet_pin, INPUT_PULLUP);
|
||||||
pinMode(zip_pin, INPUT_PULLUP);
|
pinMode(zip_pin, INPUT_PULLUP);
|
||||||
pinMode(wah_pin, INPUT_PULLUP);
|
pinMode(wah_pin, INPUT_PULLUP);
|
||||||
pinMode(hold_pin, INPUT_PULLUP);
|
pinMode(hold_pin, INPUT_PULLUP);
|
||||||
pinMode(time_pin, INPUT_PULLUP);
|
pinMode(loopin_pin, INPUT_PULLUP);
|
||||||
pinMode(mastertempo_pin, INPUT_PULLUP);
|
pinMode(loopout_pin, INPUT_PULLUP);
|
||||||
pinMode(load_pin, INPUT_PULLUP);
|
pinMode(reloop_pin, INPUT_PULLUP);
|
||||||
|
|
||||||
|
|
||||||
pinMode(ledCue, OUTPUT);
|
pinMode(ledCue, OUTPUT);
|
||||||
pinMode(ledPlay, 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);
|
pinMode(ledCd, OUTPUT);
|
||||||
|
|
||||||
|
|
||||||
// --- CONFIGURació MIDI ---
|
// --- CONFIGURació MIDI ---
|
||||||
// Assignem les nostres funcions "callback" als esdeveniments MIDI
|
// Assignem les nostres funcions "callback" als esdeveniments MIDI
|
||||||
usbMIDI.setHandleNoteOn(handleNoteOn);
|
usbMIDI.setHandleNoteOn(handleNoteOn);
|
||||||
@@ -228,12 +261,8 @@ pinMode(ledCd, OUTPUT);
|
|||||||
browse.write(0);
|
browse.write(0);
|
||||||
lastPosition_browse = 0;
|
lastPosition_browse = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
|
||||||
eject_boto.update();
|
eject_boto.update();
|
||||||
@@ -247,10 +276,20 @@ jet_boto.update();
|
|||||||
zip_boto.update();
|
zip_boto.update();
|
||||||
wah_boto.update();
|
wah_boto.update();
|
||||||
hold_boto.update();
|
hold_boto.update();
|
||||||
time_boto.update();
|
|
||||||
mastertempo_boto.update();
|
mastertempo_boto.update();
|
||||||
load_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
|
//Play
|
||||||
if(play_boto.fallingEdge()){
|
if(play_boto.fallingEdge()){
|
||||||
@@ -339,11 +378,11 @@ if(hold_boto.fallingEdge()){
|
|||||||
if(hold_boto.risingEdge()){
|
if(hold_boto.risingEdge()){
|
||||||
usbMIDI.sendNoteOff(71, 0, channel);
|
usbMIDI.sendNoteOff(71, 0, channel);
|
||||||
}
|
}
|
||||||
//time
|
//autocue
|
||||||
if(time_boto.fallingEdge()){
|
if(autocue_boto.fallingEdge()){
|
||||||
usbMIDI.sendNoteOn(72, 127, channel);
|
usbMIDI.sendNoteOn(72, 127, channel);
|
||||||
}
|
}
|
||||||
if(time_boto.risingEdge()){
|
if(autocue_boto.risingEdge()){
|
||||||
usbMIDI.sendNoteOff(72, 0, channel);
|
usbMIDI.sendNoteOff(72, 0, channel);
|
||||||
}
|
}
|
||||||
//load
|
//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
|
//Jog: cridem la funció JogNudge
|
||||||
JogNudge();
|
JogNudge();
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "usb_names.h"
|
#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
|
#define MIDI_NAME_LEN 8
|
||||||
|
|
||||||
struct usb_string_descriptor_struct usb_string_product_name = {
|
struct usb_string_descriptor_struct usb_string_product_name = {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
var XDJ100SX = {};
|
var XDJ200SX = {};
|
||||||
XDJ100SX.currentMode = 0;
|
XDJ200SX.currentMode = 0;
|
||||||
|
|
||||||
//Init
|
//Init
|
||||||
|
|
||||||
XDJ100SX.init = function(){
|
XDJ200SX.init = function(){
|
||||||
engine.setValue("[Channel2]", "filterLowKill", 1); //This is the default mode
|
engine.setValue("[Channel2]", "filterLowKill", 1); //This is the default mode
|
||||||
engine.setValue("[Channel2]", "filterMidKill", 0);
|
engine.setValue("[Channel2]", "filterMidKill", 0);
|
||||||
engine.setValue("[Channel2]", "filterHighKill", 0);
|
engine.setValue("[Channel2]", "filterHighKill", 0);
|
||||||
@@ -14,7 +14,7 @@ XDJ100SX.init = function(){
|
|||||||
|
|
||||||
//Shutdown
|
//Shutdown
|
||||||
|
|
||||||
XDJ100SX.shutdown = function(){
|
XDJ200SX.shutdown = function(){
|
||||||
//Turn off all leds
|
//Turn off all leds
|
||||||
|
|
||||||
var LedNotes = [0x41, 0x3D, 0x40, 0x3E, 0x3F];
|
var LedNotes = [0x41, 0x3D, 0x40, 0x3E, 0x3F];
|
||||||
@@ -30,22 +30,22 @@ XDJ100SX.shutdown = function(){
|
|||||||
|
|
||||||
|
|
||||||
//Jog Wheel
|
//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 newValue = value - 64;
|
||||||
|
|
||||||
var deckNumber = script.deckFromGroup(group);
|
var deckNumber = script.deckFromGroup(group);
|
||||||
|
|
||||||
if (engine.isScratching(deckNumber)) {
|
if (engine.isScratching(deckNumber)) {
|
||||||
engine.scratchTick(deckNumber, newValue); // Scratch!
|
engine.scratchTick(deckNumber, newValue); // Scratch!
|
||||||
XDJ100SX.JogWheelEnabled = false;
|
XDJ200SX.JogWheelEnabled = false;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
engine.setValue(group, 'jog', newValue); // Pitch bend
|
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
|
//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_FORWARD = 0x43;
|
||||||
const NOTE_SEARCH_BACKWARD = 0x42;
|
const NOTE_SEARCH_BACKWARD = 0x42;
|
||||||
|
|
||||||
var isJogActive = (Date.now() - XDJ100SX.lastJogMoveTime < 100);
|
var isJogActive = (Date.now() - XDJ200SX.lastJogMoveTime < 100);
|
||||||
|
|
||||||
|
|
||||||
//Si moc el jog wheel
|
//Si moc el jog wheel
|
||||||
@@ -116,36 +116,36 @@ XDJ100SX.searchButton = function (channel, control, value, status, group){
|
|||||||
|
|
||||||
|
|
||||||
//Pitch ranges
|
//Pitch ranges
|
||||||
XDJ100SX.rateRanges = [0.08, 0.10, 0.16, 0.24, 0.50]; //valors de rang del pitch
|
XDJ200SX.rateRanges = [0.08, 0.10, 0.16, 0.24, 0.50]; //valors de rang del pitch
|
||||||
XDJ100SX.currentRange = 0;
|
XDJ200SX.currentRange = 0;
|
||||||
|
|
||||||
//Beat Jump ranges
|
//Beat Jump ranges
|
||||||
XDJ100SX.BeatJumpRanges = [4, 8, 16, 32, 64, 128];
|
XDJ200SX.BeatJumpRanges = [4, 8, 16, 32, 64, 128];
|
||||||
XDJ100SX.currentBeatJumpRange = 3;
|
XDJ200SX.currentBeatJumpRange = 3;
|
||||||
|
|
||||||
|
|
||||||
//Shift
|
//Shift
|
||||||
|
|
||||||
XDJ100SX.shiftPressed = false;
|
XDJ200SX.shiftPressed = false;
|
||||||
XDJ100SX.shift = function(channel,control,value,status,group){
|
XDJ200SX.shift = function(channel,control,value,status,group){
|
||||||
if(value === 127){
|
if(value === 127){
|
||||||
XDJ100SX.shiftPressed = true;
|
XDJ200SX.shiftPressed = true;
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
XDJ100SX.shiftPressed = false;
|
XDJ200SX.shiftPressed = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
//Master Tempo & Tempo Range
|
//Master Tempo & Tempo Range
|
||||||
|
|
||||||
XDJ100SX.key = function (channel, control, value, status, group){
|
XDJ200SX.key = function (channel, control, value, status, group){
|
||||||
if(XDJ100SX.shiftPressed){
|
if(XDJ200SX.shiftPressed){
|
||||||
if (value){
|
if (value){
|
||||||
XDJ100SX.currentRange++;
|
XDJ200SX.currentRange++;
|
||||||
if(XDJ100SX.currentRange >= XDJ100SX.rateRanges.length){
|
if(XDJ200SX.currentRange >= XDJ200SX.rateRanges.length){
|
||||||
XDJ100SX.currentRange = 0;
|
XDJ200SX.currentRange = 0;
|
||||||
}
|
}
|
||||||
engine.setValue(group, "rateRange", XDJ100SX.rateRanges[XDJ100SX.currentRange]);
|
engine.setValue(group, "rateRange", XDJ200SX.rateRanges[XDJ200SX.currentRange]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -155,11 +155,11 @@ XDJ100SX.key = function (channel, control, value, status, group){
|
|||||||
|
|
||||||
//Button Mode
|
//Button Mode
|
||||||
|
|
||||||
XDJ100SX.buttonMode = function (channel, control, value, status, group) {
|
XDJ200SX.buttonMode = function (channel, control, value, status, group) {
|
||||||
|
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
// Go to next mode
|
// Go to next mode
|
||||||
XDJ100SX.currentMode = (XDJ100SX.currentMode + 1) % 6;
|
XDJ200SX.currentMode = (XDJ200SX.currentMode + 1) % 6;
|
||||||
|
|
||||||
// Set all to 0
|
// Set all to 0
|
||||||
engine.setValue("[Channel2]", "filterLowKill", 0);
|
engine.setValue("[Channel2]", "filterLowKill", 0);
|
||||||
@@ -170,17 +170,17 @@ XDJ100SX.buttonMode = function (channel, control, value, status, group) {
|
|||||||
engine.setValue("[Channel3]", "filterHighKill", 0);
|
engine.setValue("[Channel3]", "filterHighKill", 0);
|
||||||
|
|
||||||
// Enable the current one
|
// Enable the current one
|
||||||
if (XDJ100SX.currentMode === 0) {
|
if (XDJ200SX.currentMode === 0) {
|
||||||
engine.setValue("[Channel2]", "filterLowKill", 1); //Mode 1
|
engine.setValue("[Channel2]", "filterLowKill", 1); //Mode 1
|
||||||
} else if (XDJ100SX.currentMode === 1) {
|
} else if (XDJ200SX.currentMode === 1) {
|
||||||
engine.setValue("[Channel2]", "filterMidKill", 1); //Mode 2
|
engine.setValue("[Channel2]", "filterMidKill", 1); //Mode 2
|
||||||
} else if (XDJ100SX.currentMode === 2){
|
} else if (XDJ200SX.currentMode === 2){
|
||||||
engine.setValue("[Channel2]", "filterHighKill", 1); //Mode 3
|
engine.setValue("[Channel2]", "filterHighKill", 1); //Mode 3
|
||||||
}
|
}
|
||||||
else if (XDJ100SX.currentMode === 3){
|
else if (XDJ200SX.currentMode === 3){
|
||||||
engine.setValue("[Channel3]", "filterLowKill", 1); //Mode 4
|
engine.setValue("[Channel3]", "filterLowKill", 1); //Mode 4
|
||||||
}
|
}
|
||||||
else if (XDJ100SX.currentMode === 4){
|
else if (XDJ200SX.currentMode === 4){
|
||||||
engine.setValue("[Channel3]", "filterMidKill", 1); //Mode 5
|
engine.setValue("[Channel3]", "filterMidKill", 1); //Mode 5
|
||||||
}
|
}
|
||||||
else{
|
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 4 = Beat Jump Length Back, Beat Jump Length Forward, Change Beat Jump Length
|
||||||
//Mode 5 = Key Shift -, Key Shift +, Key Reset
|
//Mode 5 = Key Shift -, Key Shift +, Key Reset
|
||||||
|
|
||||||
XDJ100SX.button = function(buttonNumber){
|
XDJ200SX.button = function(buttonNumber){
|
||||||
return function (channel, control, value, status, group){
|
return function (channel, control, value, status, group){
|
||||||
if(value === 127){
|
if(value === 127){
|
||||||
|
|
||||||
//Hot Cue A, B, C
|
//Hot Cue A, B, C
|
||||||
if(XDJ100SX.currentMode === 0){
|
if(XDJ200SX.currentMode === 0){
|
||||||
if(XDJ100SX.shiftPressed){
|
if(XDJ200SX.shiftPressed){
|
||||||
engine.setValue(group, "hotcue_" + buttonNumber + "_clear", 1);
|
engine.setValue(group, "hotcue_" + buttonNumber + "_clear", 1);
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -214,11 +214,11 @@ XDJ100SX.button = function(buttonNumber){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Hot Cue D, E, F
|
// Hot Cue D, E, F
|
||||||
if(XDJ100SX.currentMode === 1){
|
if(XDJ200SX.currentMode === 1){
|
||||||
|
|
||||||
var mode = buttonNumber + 3;
|
var mode = buttonNumber + 3;
|
||||||
|
|
||||||
if(XDJ100SX.shiftPressed){
|
if(XDJ200SX.shiftPressed){
|
||||||
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
|
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -226,11 +226,11 @@ XDJ100SX.button = function(buttonNumber){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Hot Cue G, H
|
// Hot Cue G, H
|
||||||
if(XDJ100SX.currentMode === 2){
|
if(XDJ200SX.currentMode === 2){
|
||||||
|
|
||||||
var mode = buttonNumber + 6;
|
var mode = buttonNumber + 6;
|
||||||
|
|
||||||
if(XDJ100SX.shiftPressed){
|
if(XDJ200SX.shiftPressed){
|
||||||
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
|
engine.setValue(group, "hotcue_" + mode + "_clear", 1);
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -239,7 +239,7 @@ XDJ100SX.button = function(buttonNumber){
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Loop Roll
|
//Loop Roll
|
||||||
if (XDJ100SX.currentMode === 3){
|
if (XDJ200SX.currentMode === 3){
|
||||||
if (buttonNumber === 1){
|
if (buttonNumber === 1){
|
||||||
engine.setValue(group, "beatlooproll_0.125_activate", 1);
|
engine.setValue(group, "beatlooproll_0.125_activate", 1);
|
||||||
}
|
}
|
||||||
@@ -252,24 +252,24 @@ XDJ100SX.button = function(buttonNumber){
|
|||||||
}
|
}
|
||||||
|
|
||||||
//BeatJump
|
//BeatJump
|
||||||
if (XDJ100SX.currentMode === 4){
|
if (XDJ200SX.currentMode === 4){
|
||||||
if (buttonNumber === 1){
|
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){
|
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){
|
else if(buttonNumber === 3){
|
||||||
XDJ100SX.currentBeatJumpRange++;
|
XDJ200SX.currentBeatJumpRange++;
|
||||||
if(XDJ100SX.currentBeatJumpRange >= XDJ100SX.BeatJumpRanges.length){
|
if(XDJ200SX.currentBeatJumpRange >= XDJ200SX.BeatJumpRanges.length){
|
||||||
XDJ100SX.currentBeatJumpRange = 0;
|
XDJ200SX.currentBeatJumpRange = 0;
|
||||||
}
|
}
|
||||||
engine.setValue(group,"beatjump_size", XDJ100SX.BeatJumpRanges[XDJ100SX.currentBeatJumpRange]);
|
engine.setValue(group,"beatjump_size", XDJ200SX.BeatJumpRanges[XDJ200SX.currentBeatJumpRange]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Key Shift
|
//Key Shift
|
||||||
if(XDJ100SX.currentMode === 5){
|
if(XDJ200SX.currentMode === 5){
|
||||||
if (buttonNumber === 1){
|
if (buttonNumber === 1){
|
||||||
engine.setValue(group, "pitch_down", 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)
|
//When release button (Note Off) (Must to disable Loop Roll)
|
||||||
else if(value === 0){
|
else if(value === 0){
|
||||||
if (XDJ100SX.currentMode === 3){
|
if (XDJ200SX.currentMode === 3){
|
||||||
if (buttonNumber === 1){
|
if (buttonNumber === 1){
|
||||||
engine.setValue(group, "beatlooproll_0.125_activate", 0);
|
engine.setValue(group, "beatlooproll_0.125_activate", 0);
|
||||||
}
|
}
|
||||||
@@ -302,16 +302,16 @@ XDJ100SX.button = function(buttonNumber){
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
XDJ100SX.button1 = XDJ100SX.button(1);
|
XDJ200SX.button1 = XDJ200SX.button(1);
|
||||||
XDJ100SX.button2 = XDJ100SX.button(2);
|
XDJ200SX.button2 = XDJ200SX.button(2);
|
||||||
XDJ100SX.button3 = XDJ100SX.button(3);
|
XDJ200SX.button3 = XDJ200SX.button(3);
|
||||||
|
|
||||||
|
|
||||||
//BOTÓ CUE: shift + cue torna a l'inici
|
//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(value === 127){
|
||||||
if(XDJ100SX.shiftPressed){
|
if(XDJ200SX.shiftPressed){
|
||||||
engine.setValue(group, "start_stop", 1);
|
engine.setValue(group, "start_stop", 1);
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -327,9 +327,9 @@ XDJ100SX.cue = function (channel, control, value, status, group){
|
|||||||
/*
|
/*
|
||||||
//Pitch Slider old
|
//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 midiMin = 0;
|
||||||
var midiMax = 127;
|
var midiMax = 127;
|
||||||
var midiCenter = 60; // ajusta segons el teu fader real
|
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
|
// Deadzone per evitar soroll
|
||||||
var threshold = 0.015;
|
var threshold = 0.015;
|
||||||
if (Math.abs(normalized - XDJ100SX.pitchLast) > threshold) {
|
if (Math.abs(normalized - XDJ200SX.pitchLast) > threshold) {
|
||||||
XDJ100SX.pitchLast = normalized;
|
XDJ200SX.pitchLast = normalized;
|
||||||
engine.setValue(group, "rate", normalized);
|
engine.setValue(group, "rate", normalized);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -357,19 +357,19 @@ XDJ100SX.pitch = function (channel, control, value, status, group) {
|
|||||||
// Pitch slider new
|
// Pitch slider new
|
||||||
|
|
||||||
|
|
||||||
XDJ100SX.pitchMSB = 0;
|
XDJ200SX.pitchMSB = 0;
|
||||||
XDJ100SX.pitchLSB = 0;
|
XDJ200SX.pitchLSB = 0;
|
||||||
|
|
||||||
XDJ100SX.pitch = function (channel, control, value, status, group) {
|
XDJ200SX.pitch = function (channel, control, value, status, group) {
|
||||||
|
|
||||||
if (control === 0) { // MSB
|
if (control === 0) { // MSB
|
||||||
XDJ100SX.pitchMSB = value;
|
XDJ200SX.pitchMSB = value;
|
||||||
} else if (control === 32) { // LSB
|
} else if (control === 32) { // LSB
|
||||||
XDJ100SX.pitchLSB = value;
|
XDJ200SX.pitchLSB = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combinar MSB + LSB
|
// 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)
|
// Convertir a rang de Mixxx (-1.0 .. +1.0)
|
||||||
var normalized = - (full - 8192) / 8192;
|
var normalized = - (full - 8192) / 8192;
|
||||||
@@ -381,7 +381,7 @@ XDJ100SX.pitch = function (channel, control, value, status, group) {
|
|||||||
|
|
||||||
|
|
||||||
//Browse Encoder (scroll + canviar vista)
|
//Browse Encoder (scroll + canviar vista)
|
||||||
XDJ100SX.browseDown = function(channel, control, value, status, group) {
|
XDJ200SX.browseDown = function(channel, control, value, status, group) {
|
||||||
if (value === 127) {
|
if (value === 127) {
|
||||||
// llegim el control actual
|
// llegim el control actual
|
||||||
var currentTab = engine.getValue("[Tab]", "current");
|
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) {
|
if (value === 127) {
|
||||||
var currentTab = engine.getValue("[Tab]", "current");
|
var currentTab = engine.getValue("[Tab]", "current");
|
||||||
if (currentTab === 0) {
|
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)
|
//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){
|
if (value === 127){
|
||||||
var currentTab = engine.getValue("[Tab]", "current");
|
var currentTab = engine.getValue("[Tab]", "current");
|
||||||
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
|
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
|
||||||
@@ -423,7 +423,7 @@ XDJ100SX.loadTrack = function(channel, control, value, status, group){
|
|||||||
|
|
||||||
//Botó BACK
|
//Botó BACK
|
||||||
|
|
||||||
XDJ100SX.backButton = function(channel, control, value, status, group){
|
XDJ200SX.backButton = function(channel, control, value, status, group){
|
||||||
if(value === 127){
|
if(value === 127){
|
||||||
var currentTab = engine.getValue("[Tab]", "current");
|
var currentTab = engine.getValue("[Tab]", "current");
|
||||||
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
|
var currentLibrary = engine.getValue("[Sidebar]", "sidebar_visible");
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<MixxxControllerPreset mixxxVersion="" schemaVersion="1">
|
<MixxxControllerPreset mixxxVersion="" schemaVersion="1">
|
||||||
<info>
|
<info>
|
||||||
<name>XDJ100SX</name>
|
<name>XDJ200SX</name>
|
||||||
</info>
|
</info>
|
||||||
<controller id="">
|
<controller id="">
|
||||||
<scriptfiles>
|
<scriptfiles>
|
||||||
<file filename="XDJ100SX.js" functionprefix="XDJ100SX" />
|
<file filename="XDJ200SX.js" functionprefix="XDJ200SX" />
|
||||||
</scriptfiles>
|
</scriptfiles>
|
||||||
<controls>
|
<controls>
|
||||||
|
|
||||||
<control>
|
<control>
|
||||||
<description>PITCH MSB</description>
|
<description>PITCH MSB</description>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.pitch</key>
|
<key>XDJ200SX.pitch</key>
|
||||||
<status>0xB0</status>
|
<status>0xB0</status>
|
||||||
<midino>0x00</midino>
|
<midino>0x00</midino>
|
||||||
<options>
|
<options>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.pitch</key>
|
<key>XDJ200SX.pitch</key>
|
||||||
<description>PITCH LSB</description>
|
<description>PITCH LSB</description>
|
||||||
<status>0xB0</status>
|
<status>0xB0</status>
|
||||||
<midino>0x20</midino>
|
<midino>0x20</midino>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.cue</key>
|
<key>XDJ200SX.cue</key>
|
||||||
<description>CUE</description>
|
<description>CUE</description>
|
||||||
<status>0x80</status>
|
<status>0x80</status>
|
||||||
<midino>0x3D</midino>
|
<midino>0x3D</midino>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.cue</key>
|
<key>XDJ200SX.cue</key>
|
||||||
<description>CUE</description>
|
<description>CUE</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x3D</midino>
|
<midino>0x3D</midino>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.key</key>
|
<key>XDJ200SX.key</key>
|
||||||
<description>MASTER TEMPO - TEMPO RANGE</description>
|
<description>MASTER TEMPO - TEMPO RANGE</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x3E</midino>
|
<midino>0x3E</midino>
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>loop_halve</key>
|
<key>beatjump_X_forward</key>
|
||||||
<description>LOOP HALF</description>
|
<description>BEAT JUMP BCK 16 BEATS</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x40</midino>
|
<midino>0x40</midino>
|
||||||
<options>
|
<options>
|
||||||
@@ -84,8 +84,8 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>loop_double</key>
|
<key>beatjump_16_forward</key>
|
||||||
<description>LOOP DOUBLE</description>
|
<description>BEAT JUMP FWD 16 BEATS</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x41</midino>
|
<midino>0x41</midino>
|
||||||
<options>
|
<options>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<!--SEARCH LEFT-->
|
<!--SEARCH LEFT-->
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.searchButton</key>
|
<key>XDJ200SX.searchButton</key>
|
||||||
<description>SEARCH BACKWARD</description>
|
<description>SEARCH BACKWARD</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x42</midino>
|
<midino>0x42</midino>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.searchButton</key>
|
<key>XDJ200SX.searchButton</key>
|
||||||
<description>SEARCH BACKWARD</description>
|
<description>SEARCH BACKWARD</description>
|
||||||
<status>0x80</status>
|
<status>0x80</status>
|
||||||
<midino>0x42</midino>
|
<midino>0x42</midino>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
<!--SEARCH RIGHT-->
|
<!--SEARCH RIGHT-->
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.searchButton</key>
|
<key>XDJ200SX.searchButton</key>
|
||||||
<description>SEARCH FORWARD</description>
|
<description>SEARCH FORWARD</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x43</midino>
|
<midino>0x43</midino>
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.searchButton</key>
|
<key>XDJ200SX.searchButton</key>
|
||||||
<description>SEARCH FORWARD</description>
|
<description>SEARCH FORWARD</description>
|
||||||
<status>0x80</status>
|
<status>0x80</status>
|
||||||
<midino>0x43</midino>
|
<midino>0x43</midino>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
</control>
|
</control>
|
||||||
|
|
||||||
<control>
|
<control>
|
||||||
<key>XDJ100SX.browseDown</key>
|
<key>XDJ200SX.browseDown</key>
|
||||||
<description>BROWSE DOWN</description>
|
<description>BROWSE DOWN</description>
|
||||||
<status>0x92</status>
|
<status>0x92</status>
|
||||||
<midino>0x46</midino>
|
<midino>0x46</midino>
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
<control>
|
<control>
|
||||||
<group>[Library]</group>
|
<group>[Library]</group>
|
||||||
<key>XDJ100SX.browseUp</key>
|
<key>XDJ200SX.browseUp</key>
|
||||||
<description>BROWSE UP</description>
|
<description>BROWSE UP</description>
|
||||||
<status>0x92</status>
|
<status>0x92</status>
|
||||||
<midino>0x47</midino>
|
<midino>0x47</midino>
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.loadTrack</key>
|
<key>XDJ200SX.loadTrack</key>
|
||||||
<description>LOAD</description>
|
<description>LOAD</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x49</midino>
|
<midino>0x49</midino>
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.backButton</key>
|
<key>XDJ200SX.backButton</key>
|
||||||
<description>BACK</description>
|
<description>BACK</description>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x3F</midino>
|
<midino>0x3F</midino>
|
||||||
@@ -176,40 +176,40 @@
|
|||||||
<!-- Hot Cues i shift - he d'enviar el Note On i el Note off del shift-->
|
<!-- Hot Cues i shift - he d'enviar el Note On i el Note off del shift-->
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<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/>
|
<script-binding/>
|
||||||
</options>
|
</options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<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/>
|
<script-binding/>
|
||||||
</options>
|
</options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.button2</key>
|
<key>XDJ200SX.button2</key>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x45</midino><options>
|
<midino>0x45</midino><options>
|
||||||
<script-binding/></options>
|
<script-binding/></options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.button2</key>
|
<key>XDJ200SX.button2</key>
|
||||||
<status>0x80</status>
|
<status>0x80</status>
|
||||||
<midino>0x45</midino><options>
|
<midino>0x45</midino><options>
|
||||||
<script-binding/></options>
|
<script-binding/></options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.button3</key>
|
<key>XDJ200SX.button3</key>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x46</midino><options>
|
<midino>0x46</midino><options>
|
||||||
<script-binding/></options>
|
<script-binding/></options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.button3</key>
|
<key>XDJ200SX.button3</key>
|
||||||
<status>0x80</status>
|
<status>0x80</status>
|
||||||
<midino>0x46</midino><options>
|
<midino>0x46</midino><options>
|
||||||
<script-binding/></options>
|
<script-binding/></options>
|
||||||
@@ -217,14 +217,14 @@
|
|||||||
|
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.shift</key>
|
<key>XDJ200SX.shift</key>
|
||||||
<status>0x90</status>
|
<status>0x90</status>
|
||||||
<midino>0x47</midino><options>
|
<midino>0x47</midino><options>
|
||||||
<script-binding/></options>
|
<script-binding/></options>
|
||||||
</control>
|
</control>
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<group>[Channel1]</group>
|
||||||
<key>XDJ100SX.shift</key>
|
<key>XDJ200SX.shift</key>
|
||||||
<status>0x80</status> <midino>0x47</midino> <options> <!--Note off-->
|
<status>0x80</status> <midino>0x47</midino> <options> <!--Note off-->
|
||||||
<script-binding/>
|
<script-binding/>
|
||||||
</options>
|
</options>
|
||||||
@@ -232,17 +232,90 @@
|
|||||||
<!--Jog-->
|
<!--Jog-->
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<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/>
|
<script-binding/>
|
||||||
</options>
|
</options>
|
||||||
</control>
|
</control>
|
||||||
<!-- BUTTON MODES -->
|
<!-- BUTTON MODES -->
|
||||||
<control>
|
<control>
|
||||||
<group>[Channel1]</group>
|
<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/>
|
<script-binding/>
|
||||||
</options>
|
</options>
|
||||||
</control>
|
</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>
|
</controls>
|
||||||
@@ -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>
|
<skin>
|
||||||
<manifest>
|
<manifest>
|
||||||
<title>XDJ100SX</title>
|
<title>XDJ200SX</title>
|
||||||
<author>Marc Monka</author>
|
<author>Marc Monka</author>
|
||||||
<version>1.0</version>
|
<version>1.0</version>
|
||||||
<description>Skin for 1 standalone deck with different performance submenus. A lookalike Pioneer DJ / Alphatheta deck player style. </description>
|
<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;
|
background-color:#000;
|
||||||
}
|
}
|
||||||
QLabel {
|
QLabel {
|
||||||
image: url(skin:/images/xdj100sx_logo.png);
|
image: url(skin:/images/xdj200sx_logo.png);
|
||||||
border:none;
|
border:none;
|
||||||
min-width:450px;
|
min-width:450px;
|
||||||
max-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)
|
||||||