discstation 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -8
- package/arduino/{v1 → DiscStation}/DiscStation.ino +255 -153
- package/package.json +1 -1
- package/src/discstation.py +1 -1
- package/src/discstation_burn.py +15 -2
- package/arduino/c6/DiscStation_C6.ino +0 -1134
- package/arduino/c6/secrets.h.example +0 -14
- /package/arduino/{v1 → DiscStation}/secrets.h.example +0 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ use every mode.
|
|
|
9
9
|
## Hardware
|
|
10
10
|
|
|
11
11
|
- **Brain:** Raspberry Pi 4/5 (or any Linux box)
|
|
12
|
-
- **Remote (optional):** ESP32 DevKit
|
|
13
|
-
|
|
12
|
+
- **Remote (optional):** ESP32 DevKit (WROOM-32) with an SSD1306 OLED, a
|
|
13
|
+
rotary encoder, and 3 buttons — connected by **USB serial** or over **Wi-Fi**
|
|
14
14
|
(see below) — or none at all; the web UI / mobile app's on-screen remote
|
|
15
15
|
covers the same controls when no ESP32 is detected, and steps aside
|
|
16
16
|
(greyed out, auto-collapsing) the moment one appears.
|
|
@@ -61,9 +61,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
61
61
|
│ ├── discstation_meta.py # TMDb video metadata
|
|
62
62
|
│ └── static/ # Built-in web UI (index.html, app.js, style.css)
|
|
63
63
|
├── mobile/ # Expo (React Native) companion app
|
|
64
|
-
├── arduino/
|
|
65
|
-
│ ├── c6/ # ESP32-C6 firmware
|
|
66
|
-
│ └── v1/ # ESP32 DevKit firmware
|
|
64
|
+
├── arduino/DiscStation/ # ESP32 DevKit remote firmware
|
|
67
65
|
├── scripts/setup.mjs # `discstation-setup` — cross-OS installer dispatch
|
|
68
66
|
├── docs/ # Platform support notes
|
|
69
67
|
├── install.sh / install-macos.sh / install-windows.ps1
|
|
@@ -113,10 +111,9 @@ Support by OS:
|
|
|
113
111
|
# Linux host
|
|
114
112
|
./install.sh
|
|
115
113
|
|
|
116
|
-
# Arduino
|
|
114
|
+
# Arduino (ESP32 DevKit remote)
|
|
117
115
|
arduino-cli lib install QRCode
|
|
118
|
-
|
|
119
|
-
# to the matching ESP32 board
|
|
116
|
+
arduino-cli compile --upload -b esp32:esp32:esp32 -p /dev/ttyUSB0 arduino/DiscStation
|
|
120
117
|
|
|
121
118
|
# macOS host
|
|
122
119
|
./install-macos.sh
|
|
@@ -22,17 +22,21 @@
|
|
|
22
22
|
#define OLED_RESET -1
|
|
23
23
|
#define I2C_ADDRESS 0x3C
|
|
24
24
|
|
|
25
|
-
#define
|
|
26
|
-
#define
|
|
27
|
-
#define
|
|
28
|
-
#define
|
|
25
|
+
#define BTN_EJECT_PIN 14 // was SELECT
|
|
26
|
+
#define BTN_HOME_PIN 13 // was UP; also carries the 10s Wi-Fi-reset hold
|
|
27
|
+
#define BTN_PLAYPAUSE_PIN 15 // was DOWN
|
|
28
|
+
#define ENC_CLK_PIN 32 // freed ex-LED pin
|
|
29
|
+
#define ENC_DT_PIN 33 // freed ex-LED pin
|
|
30
|
+
#define ENC_SW_PIN 4 // encoder's click switch
|
|
29
31
|
|
|
30
32
|
#define DEBOUNCE_MS 50
|
|
31
33
|
#define LONG_PRESS_MS 1000
|
|
32
|
-
#define
|
|
34
|
+
#define ENC_CLICK_GUARD_MS 200 // ignore new SW presses this soon after a rotation tick (rotation vibration can bounce SW low)
|
|
35
|
+
#define ENC_DEBOUNCE_US 2000 // ignore encoder interrupts closer together than this (contact bounce)
|
|
33
36
|
#define DONE_RESET_MS 30000
|
|
34
37
|
#define STANDBY_BLANK_MS 60000
|
|
35
|
-
#define IDLE_BLANK_MS 45000 //
|
|
38
|
+
#define IDLE_BLANK_MS 45000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
|
|
39
|
+
#define SAVER_FRAME_MS 90 // screensaver frame interval (~11fps)
|
|
36
40
|
#define PING_TIMEOUT_MS 30000
|
|
37
41
|
|
|
38
42
|
#define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
|
|
@@ -217,36 +221,39 @@ bool displayOk = false;
|
|
|
217
221
|
unsigned long returnToHomeAt = 0;
|
|
218
222
|
unsigned long playStatusAt = 0;
|
|
219
223
|
bool playStatusTemp = false;
|
|
220
|
-
unsigned long lastPotRead = 0;
|
|
221
224
|
|
|
222
|
-
//
|
|
223
|
-
unsigned long
|
|
224
|
-
bool
|
|
225
|
-
unsigned long
|
|
225
|
+
// Encoder click (SW) - short/long press state, same shape as the old SELECT button
|
|
226
|
+
unsigned long encClickDownAt = 0;
|
|
227
|
+
bool encClickDown = false;
|
|
228
|
+
unsigned long encClickLastDebounce = 0;
|
|
229
|
+
unsigned long lastEncRotateMs = 0; // set whenever a rotation tick is drained; guards SW against rotation noise
|
|
226
230
|
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
unsigned long upLastDebounce = 0;
|
|
231
|
+
// EJECT button - single press, no long-press timer needed
|
|
232
|
+
bool ejectDown = false;
|
|
233
|
+
unsigned long ejectLastDebounce = 0;
|
|
231
234
|
|
|
232
|
-
//
|
|
233
|
-
unsigned long
|
|
234
|
-
bool
|
|
235
|
-
unsigned long
|
|
235
|
+
// HOME/BACK button - single press; also carries the 10s Wi-Fi-reset hold
|
|
236
|
+
unsigned long homeDownAt = 0;
|
|
237
|
+
bool homeDown = false;
|
|
238
|
+
unsigned long homeLastDebounce = 0;
|
|
236
239
|
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
// PLAY/PAUSE button - short = pause/resume, long = stop
|
|
241
|
+
unsigned long playpauseDownAt = 0;
|
|
242
|
+
bool playpauseDown = false;
|
|
243
|
+
unsigned long playpauseLastDebounce = 0;
|
|
240
244
|
|
|
241
245
|
int homeIndex = 0;
|
|
242
246
|
int burnModeIndex = 0;
|
|
243
247
|
int burnSpeedIndex = 0;
|
|
244
248
|
bool editingSpeed = false;
|
|
245
249
|
int playVolume = 50;
|
|
250
|
+
bool playSeekMode = false; // false = encoder rotate adjusts volume (default); true = seek/track-skip
|
|
246
251
|
unsigned long standbyStartTime = 0;
|
|
247
252
|
unsigned long lastMsgTime = 0;
|
|
248
|
-
unsigned long lastInputTime = 0; // last button press / screen change; drives
|
|
249
|
-
bool displayBlank = false;
|
|
253
|
+
unsigned long lastInputTime = 0; // last button press / screen change; drives the idle screensaver
|
|
254
|
+
bool displayBlank = false; // true = real UI hidden, disc screensaver running; any input restores it
|
|
255
|
+
unsigned long lastSaverFrame = 0;
|
|
256
|
+
int saverStep = 0;
|
|
250
257
|
bool audioPlayMode = false;
|
|
251
258
|
int displayRotation = 0;
|
|
252
259
|
|
|
@@ -254,20 +261,22 @@ int displayRotation = 0;
|
|
|
254
261
|
int indeterminateCount = 0;
|
|
255
262
|
unsigned long lastIndeterminateAnim = 0;
|
|
256
263
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
264
|
+
// --- Rotary encoder decode ---
|
|
265
|
+
// Interrupt only on CLK's falling edge (one detent = one interrupt, not four
|
|
266
|
+
// - a mechanical encoder's contact bounce can otherwise fire far more often
|
|
267
|
+
// than that and starve other tasks, including the USB-serial link); DT's
|
|
268
|
+
// level at that instant gives direction. A short time debounce rejects
|
|
269
|
+
// contact chatter. Interrupt-driven because the main loop's ~20ms cadence is
|
|
270
|
+
// too slow to reliably catch a fast spin without missing ticks.
|
|
271
|
+
volatile int16_t encTicks = 0; // whole detents ready for loop() to drain
|
|
272
|
+
volatile uint32_t encLastIsrUs = 0;
|
|
273
|
+
|
|
274
|
+
void IRAM_ATTR encoderISR() {
|
|
275
|
+
uint32_t now = micros();
|
|
276
|
+
if (now - encLastIsrUs < ENC_DEBOUNCE_US) return;
|
|
277
|
+
encLastIsrUs = now;
|
|
278
|
+
if (digitalRead(ENC_CLK_PIN) != digitalRead(ENC_DT_PIN)) encTicks++;
|
|
279
|
+
else encTicks--;
|
|
271
280
|
}
|
|
272
281
|
|
|
273
282
|
void sendHomeMode() {
|
|
@@ -531,6 +540,46 @@ void drawDisconnected() {
|
|
|
531
540
|
display.display();
|
|
532
541
|
}
|
|
533
542
|
|
|
543
|
+
// 24-step sine table x64: sin(i*15deg)*64. Drives the idle disc screensaver.
|
|
544
|
+
const int8_t SIN24[24] = {
|
|
545
|
+
0, 17, 32, 45, 55, 62, 64, 62, 55, 45, 32, 17,
|
|
546
|
+
0, -17, -32, -45, -55, -62, -64, -62, -55, -45, -32, -17
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Spinning-disc screensaver frame - shown after IDLE_BLANK_MS on HOME/STANDBY
|
|
550
|
+
// instead of powering the panel off. Keeps the OLED lit and the I2C bus busy so
|
|
551
|
+
// a USB power-bank feeding the remote doesn't hit its no-load auto-shutoff.
|
|
552
|
+
void drawDiscSaver(int step) {
|
|
553
|
+
if (!displayOk) return;
|
|
554
|
+
const int cx = 64, cy = 32, R = 30;
|
|
555
|
+
display.clearDisplay();
|
|
556
|
+
display.fillCircle(cx, cy, R, SSD1306_WHITE); // vinyl body
|
|
557
|
+
|
|
558
|
+
for (int cl = 0; cl < 2; cl++) { // 2 groove clusters, 180 apart
|
|
559
|
+
int base = step + cl * 12;
|
|
560
|
+
for (int g = 0; g < 3; g++) { // 3 nested "sound wave" grooves
|
|
561
|
+
for (int t = 0; t < 3; t++) { // 3px-thick stroke
|
|
562
|
+
int r = 16 + g * 5 + t;
|
|
563
|
+
int px = -100, py = -100;
|
|
564
|
+
for (int a = 0; a <= 4; a++) { // ~60deg arc, 4 chords
|
|
565
|
+
int i = (base + a) % 24;
|
|
566
|
+
int x = cx + r * SIN24[(i + 6) % 24] / 64;
|
|
567
|
+
int y = cy + r * SIN24[i] / 64;
|
|
568
|
+
if (px > -100) display.drawLine(px, py, x, y, SSD1306_BLACK);
|
|
569
|
+
px = x; py = y;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
display.fillCircle(cx, cy, 13, SSD1306_BLACK); // concentric label
|
|
576
|
+
display.fillCircle(cx, cy, 11, SSD1306_WHITE);
|
|
577
|
+
display.fillCircle(cx, cy, 8, SSD1306_BLACK);
|
|
578
|
+
display.fillCircle(cx, cy, 5, SSD1306_WHITE);
|
|
579
|
+
display.fillCircle(cx, cy, 2, SSD1306_BLACK); // center hole
|
|
580
|
+
display.display();
|
|
581
|
+
}
|
|
582
|
+
|
|
534
583
|
// Returns true if this call actually woke a blanked screen - callers use that
|
|
535
584
|
// to swallow the wake press so it doesn't also trigger a menu action.
|
|
536
585
|
bool wakeDisplay() {
|
|
@@ -564,13 +613,16 @@ void drawPlay() {
|
|
|
564
613
|
display.setCursor(2, 30);
|
|
565
614
|
printUpper(line2);
|
|
566
615
|
display.setCursor(2, 44);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
display.
|
|
572
|
-
display.print("
|
|
616
|
+
if (playSeekMode) {
|
|
617
|
+
display.print(audioPlayMode ? "TURN // SKIP TRACK" : "TURN // SEEK");
|
|
618
|
+
} else {
|
|
619
|
+
display.print("VOL // ");
|
|
620
|
+
display.print(playVolume);
|
|
621
|
+
display.print("%");
|
|
573
622
|
}
|
|
623
|
+
display.setCursor(2, 56);
|
|
624
|
+
display.print("CLICK // ");
|
|
625
|
+
display.print(playSeekMode ? "VOL MODE" : "SEEK MODE");
|
|
574
626
|
|
|
575
627
|
display.display();
|
|
576
628
|
}
|
|
@@ -592,7 +644,6 @@ void parseMessage(String msg) {
|
|
|
592
644
|
Out.println(msg);
|
|
593
645
|
|
|
594
646
|
if (msg.startsWith("HOME:")) {
|
|
595
|
-
homeIndex = readBucket(homeCount);
|
|
596
647
|
drawHome();
|
|
597
648
|
|
|
598
649
|
} else if (msg.startsWith("DISC:")) {
|
|
@@ -626,7 +677,6 @@ void parseMessage(String msg) {
|
|
|
626
677
|
} else if (msg.startsWith("TITLE:")) {
|
|
627
678
|
titleLine = msg.substring(6);
|
|
628
679
|
if (titleLine.length() > 20) titleLine = titleLine.substring(0, 20);
|
|
629
|
-
burnModeIndex = readBucket(BURN_COUNT);
|
|
630
680
|
drawBurnReady();
|
|
631
681
|
|
|
632
682
|
} else if (msg.startsWith("META:")) {
|
|
@@ -642,8 +692,8 @@ void parseMessage(String msg) {
|
|
|
642
692
|
} else if (msg.startsWith("PLAY:")) {
|
|
643
693
|
line1 = msg.substring(5);
|
|
644
694
|
line2 = "Playing disc";
|
|
645
|
-
|
|
646
|
-
Out.print("POT:");
|
|
695
|
+
playSeekMode = false; // always start in volume mode
|
|
696
|
+
Out.print("POT:"); // push the last-used volume so playback starts at it
|
|
647
697
|
Out.println(playVolume);
|
|
648
698
|
drawPlay();
|
|
649
699
|
|
|
@@ -753,16 +803,13 @@ void setup() {
|
|
|
753
803
|
Serial.begin(115200);
|
|
754
804
|
esp_task_wdt_add(NULL);
|
|
755
805
|
Wire.begin(21, 22);
|
|
756
|
-
pinMode(
|
|
757
|
-
pinMode(
|
|
758
|
-
pinMode(
|
|
759
|
-
pinMode(
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
homeIndex = readBucket(homeCount);
|
|
764
|
-
burnModeIndex = readBucket(BURN_COUNT);
|
|
765
|
-
playVolume = readPercent();
|
|
806
|
+
pinMode(BTN_EJECT_PIN, INPUT_PULLUP);
|
|
807
|
+
pinMode(BTN_HOME_PIN, INPUT_PULLUP);
|
|
808
|
+
pinMode(BTN_PLAYPAUSE_PIN, INPUT_PULLUP);
|
|
809
|
+
pinMode(ENC_SW_PIN, INPUT_PULLUP);
|
|
810
|
+
pinMode(ENC_CLK_PIN, INPUT_PULLUP);
|
|
811
|
+
pinMode(ENC_DT_PIN, INPUT_PULLUP);
|
|
812
|
+
attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encoderISR, FALLING);
|
|
766
813
|
|
|
767
814
|
displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
|
|
768
815
|
|
|
@@ -855,96 +902,137 @@ void handleSelectPress(bool longPress) {
|
|
|
855
902
|
Out.println("CANCEL");
|
|
856
903
|
drawStandby();
|
|
857
904
|
|
|
858
|
-
} else if (uiState == UI_STANDBY) {
|
|
859
|
-
displayRotation = 2;
|
|
860
|
-
drawStandby();
|
|
861
905
|
} else if (uiState == UI_PLAY) {
|
|
862
|
-
ffSpeedIndex = 0;
|
|
863
|
-
rewSpeedIndex = 0;
|
|
864
906
|
if (longPress) {
|
|
865
907
|
Out.println("PLAY_STOP");
|
|
866
908
|
} else {
|
|
867
|
-
|
|
909
|
+
playSeekMode = !playSeekMode; // encoder click swaps what rotating does
|
|
910
|
+
drawPlay();
|
|
868
911
|
}
|
|
869
912
|
}
|
|
870
913
|
}
|
|
871
914
|
|
|
872
|
-
|
|
873
|
-
|
|
915
|
+
// Encoder rotation replaces UP/DOWN. One tick = one detent (see encoderISR).
|
|
916
|
+
// STANDBY's displayRotation flip and BURN_READY's mode/speed scroll are
|
|
917
|
+
// otherwise unchanged from the old UP/DOWN behavior. PLAY defaults to
|
|
918
|
+
// adjusting volume; a short encoder click (see handleSelectPress) swaps it
|
|
919
|
+
// to seek/track-skip instead - there's only one rotate axis now, where
|
|
920
|
+
// before the pot (volume) and UP/DOWN (seek) were independent.
|
|
921
|
+
void handleEncoderCW() {
|
|
922
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only turn, swallow the action
|
|
874
923
|
lastInputTime = millis();
|
|
875
924
|
if (uiState == UI_HOME) {
|
|
876
925
|
if (homeCount > 0) {
|
|
877
|
-
homeIndex = (homeIndex
|
|
926
|
+
homeIndex = (homeIndex + 1) % homeCount;
|
|
878
927
|
sendHomeMode();
|
|
879
928
|
drawHome();
|
|
880
929
|
}
|
|
881
930
|
} else if (uiState == UI_BURN_READY) {
|
|
882
931
|
if (editingSpeed) {
|
|
883
|
-
burnSpeedIndex = (burnSpeedIndex
|
|
932
|
+
burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
|
|
884
933
|
Out.print("SPEED:");
|
|
885
934
|
Out.println(SPEED_MODES[burnSpeedIndex]);
|
|
886
935
|
drawBurnReady();
|
|
887
936
|
} else {
|
|
888
|
-
burnModeIndex = (burnModeIndex
|
|
937
|
+
burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
|
|
889
938
|
sendBurnMode();
|
|
890
939
|
drawBurnReady();
|
|
891
940
|
}
|
|
892
941
|
} else if (uiState == UI_STANDBY) {
|
|
893
|
-
displayRotation =
|
|
942
|
+
displayRotation = 2;
|
|
894
943
|
drawStandby();
|
|
895
944
|
} else if (uiState == UI_PLAY) {
|
|
896
|
-
if (
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (
|
|
902
|
-
Out.println("FF:BIG");
|
|
945
|
+
if (!playSeekMode) {
|
|
946
|
+
playVolume = min(100, playVolume + 5);
|
|
947
|
+
Out.print("POT:");
|
|
948
|
+
Out.println(playVolume);
|
|
949
|
+
drawPlay();
|
|
950
|
+
} else if (audioPlayMode) {
|
|
951
|
+
Out.println(displayRotation == 0 ? "FF:BIG" : "REW:BIG"); // next/prev track, screen-flip-aware
|
|
903
952
|
} else {
|
|
904
|
-
|
|
905
|
-
int seek_sec[] = {10, 20, 30, 60};
|
|
906
|
-
Out.print("FF:");
|
|
907
|
-
Out.println(seek_sec[ffSpeedIndex]);
|
|
953
|
+
Out.println("FF:10");
|
|
908
954
|
}
|
|
909
955
|
}
|
|
910
956
|
}
|
|
911
957
|
|
|
912
|
-
void
|
|
913
|
-
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only
|
|
958
|
+
void handleEncoderCCW() {
|
|
959
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only turn, swallow the action
|
|
914
960
|
lastInputTime = millis();
|
|
915
961
|
if (uiState == UI_HOME) {
|
|
916
962
|
if (homeCount > 0) {
|
|
917
|
-
homeIndex = (homeIndex +
|
|
963
|
+
homeIndex = (homeIndex - 1 + homeCount) % homeCount;
|
|
918
964
|
sendHomeMode();
|
|
919
965
|
drawHome();
|
|
920
966
|
}
|
|
921
967
|
} else if (uiState == UI_BURN_READY) {
|
|
922
968
|
if (editingSpeed) {
|
|
923
|
-
burnSpeedIndex = (burnSpeedIndex +
|
|
969
|
+
burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
|
|
924
970
|
Out.print("SPEED:");
|
|
925
971
|
Out.println(SPEED_MODES[burnSpeedIndex]);
|
|
926
972
|
drawBurnReady();
|
|
927
973
|
} else {
|
|
928
|
-
burnModeIndex = (burnModeIndex +
|
|
974
|
+
burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
|
|
929
975
|
sendBurnMode();
|
|
930
976
|
drawBurnReady();
|
|
931
977
|
}
|
|
932
978
|
} else if (uiState == UI_STANDBY) {
|
|
933
|
-
displayRotation =
|
|
979
|
+
displayRotation = 0;
|
|
934
980
|
drawStandby();
|
|
935
981
|
} else if (uiState == UI_PLAY) {
|
|
936
|
-
if (
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (
|
|
942
|
-
Out.println("REW:BIG");
|
|
982
|
+
if (!playSeekMode) {
|
|
983
|
+
playVolume = max(0, playVolume - 5);
|
|
984
|
+
Out.print("POT:");
|
|
985
|
+
Out.println(playVolume);
|
|
986
|
+
drawPlay();
|
|
987
|
+
} else if (audioPlayMode) {
|
|
988
|
+
Out.println(displayRotation == 0 ? "REW:BIG" : "FF:BIG");
|
|
943
989
|
} else {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
990
|
+
Out.println("REW:10");
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// EJECT: fixed shortcut, any idle-ish state. Not during an active burn/rip -
|
|
996
|
+
// don't pull the disc mid-operation (matches the old SELECT-EJECT's implicit
|
|
997
|
+
// restriction, since that branch only ever existed on HOME/STANDBY).
|
|
998
|
+
void handleEjectButton() {
|
|
999
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; }
|
|
1000
|
+
lastInputTime = millis();
|
|
1001
|
+
if (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_DISCONNECTED || uiState == UI_PLAY) {
|
|
1002
|
+
Out.println("EJECT");
|
|
1003
|
+
line1 = "Ejecting...";
|
|
1004
|
+
line2 = "";
|
|
1005
|
+
line3 = "";
|
|
1006
|
+
returnToHomeAt = millis() + 5000;
|
|
1007
|
+
drawStatus();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// HOME/BACK: universal escape from anywhere back to the top menu.
|
|
1012
|
+
void handleHomeButton() {
|
|
1013
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; }
|
|
1014
|
+
lastInputTime = millis();
|
|
1015
|
+
if (uiState == UI_HOME) return;
|
|
1016
|
+
Out.println(uiState == UI_PLAY ? "PLAY_STOP" : "CANCEL");
|
|
1017
|
+
drawHome();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// PLAY/PAUSE: toggles pause while playing; from HOME, jumps straight into
|
|
1021
|
+
// PLAY without needing to scroll the menu there first.
|
|
1022
|
+
void handlePlayPauseButton(bool longPress) {
|
|
1023
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; }
|
|
1024
|
+
lastInputTime = millis();
|
|
1025
|
+
if (uiState == UI_PLAY) {
|
|
1026
|
+
Out.println(longPress ? "PLAY_STOP" : "PLAY_BUTTON");
|
|
1027
|
+
} else if (uiState == UI_HOME && !longPress) {
|
|
1028
|
+
bool hasPlay = false;
|
|
1029
|
+
for (int i = 0; i < homeCount; i++) if (homeModes[i] == "PLAY") hasPlay = true;
|
|
1030
|
+
if (hasPlay) {
|
|
1031
|
+
Out.println("SELECT:PLAY");
|
|
1032
|
+
line1 = "Selected";
|
|
1033
|
+
line2 = "PLAY";
|
|
1034
|
+
line3 = "";
|
|
1035
|
+
drawStatus();
|
|
948
1036
|
}
|
|
949
1037
|
}
|
|
950
1038
|
}
|
|
@@ -1012,31 +1100,60 @@ void loop() {
|
|
|
1012
1100
|
drawPlay();
|
|
1013
1101
|
}
|
|
1014
1102
|
|
|
1015
|
-
// ---
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1103
|
+
// --- Encoder rotation: drain ticks accumulated by encoderISR ---
|
|
1104
|
+
{
|
|
1105
|
+
noInterrupts();
|
|
1106
|
+
int16_t ticks = encTicks;
|
|
1107
|
+
encTicks = 0;
|
|
1108
|
+
interrupts();
|
|
1109
|
+
if (ticks != 0) lastEncRotateMs = millis();
|
|
1110
|
+
while (ticks > 0) { handleEncoderCW(); ticks--; }
|
|
1111
|
+
while (ticks < 0) { handleEncoderCCW(); ticks++; }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// --- Encoder click (SW) - same debounce/long-press shape as the old SELECT.
|
|
1115
|
+
// Also guarded against rotation: spinning the knob can momentarily bounce
|
|
1116
|
+
// SW low too, which was being misread as a real click and firing SELECT/PLAY. ---
|
|
1117
|
+
{
|
|
1118
|
+
bool sw = digitalRead(ENC_SW_PIN) == LOW;
|
|
1119
|
+
if (sw && !encClickDown && millis() - encClickLastDebounce > DEBOUNCE_MS &&
|
|
1120
|
+
millis() - lastEncRotateMs > ENC_CLICK_GUARD_MS) {
|
|
1121
|
+
encClickDown = true;
|
|
1122
|
+
encClickDownAt = millis();
|
|
1123
|
+
}
|
|
1124
|
+
if (!sw && encClickDown) {
|
|
1125
|
+
encClickDown = false;
|
|
1126
|
+
encClickLastDebounce = millis();
|
|
1127
|
+
handleSelectPress(millis() - encClickDownAt >= LONG_PRESS_MS);
|
|
1024
1128
|
}
|
|
1025
1129
|
}
|
|
1026
1130
|
|
|
1027
|
-
// ---
|
|
1131
|
+
// --- EJECT button (single press, no long-press timer) ---
|
|
1028
1132
|
{
|
|
1029
|
-
bool
|
|
1030
|
-
if (
|
|
1031
|
-
|
|
1032
|
-
|
|
1133
|
+
bool ej = digitalRead(BTN_EJECT_PIN) == LOW;
|
|
1134
|
+
if (ej && !ejectDown && millis() - ejectLastDebounce > DEBOUNCE_MS) {
|
|
1135
|
+
ejectDown = true;
|
|
1136
|
+
}
|
|
1137
|
+
if (!ej && ejectDown) {
|
|
1138
|
+
ejectDown = false;
|
|
1139
|
+
ejectLastDebounce = millis();
|
|
1140
|
+
handleEjectButton();
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// --- HOME/BACK button (single press; 10s hold = Wi-Fi reset) ---
|
|
1145
|
+
{
|
|
1146
|
+
bool hm = digitalRead(BTN_HOME_PIN) == LOW;
|
|
1147
|
+
if (hm && !homeDown && millis() - homeLastDebounce > DEBOUNCE_MS) {
|
|
1148
|
+
homeDown = true;
|
|
1149
|
+
homeDownAt = millis();
|
|
1033
1150
|
wifiResetArmed = false;
|
|
1034
1151
|
}
|
|
1035
|
-
// 10s hold on HOME
|
|
1036
|
-
// while still held so the eventual release doesn't also trigger
|
|
1037
|
-
if (
|
|
1152
|
+
// 10s hold on HOME/SETUP/STANDBY = wipe Wi-Fi creds + reboot to portal. Fires
|
|
1153
|
+
// while still held so the eventual release doesn't also trigger the normal action.
|
|
1154
|
+
if (hm && homeDown && !wifiResetArmed &&
|
|
1038
1155
|
(uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
|
|
1039
|
-
millis() -
|
|
1156
|
+
millis() - homeDownAt >= WIFI_RESET_HOLD_MS) {
|
|
1040
1157
|
wifiResetArmed = true;
|
|
1041
1158
|
Out.println("WiFi: creds wiped, rebooting");
|
|
1042
1159
|
clearCreds();
|
|
@@ -1044,47 +1161,25 @@ void loop() {
|
|
|
1044
1161
|
delay(800);
|
|
1045
1162
|
ESP.restart();
|
|
1046
1163
|
}
|
|
1047
|
-
if (!
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
if (!wifiResetArmed)
|
|
1051
|
-
handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
|
|
1052
|
-
}
|
|
1164
|
+
if (!hm && homeDown) {
|
|
1165
|
+
homeDown = false;
|
|
1166
|
+
homeLastDebounce = millis();
|
|
1167
|
+
if (!wifiResetArmed) handleHomeButton();
|
|
1053
1168
|
wifiResetArmed = false;
|
|
1054
1169
|
}
|
|
1055
1170
|
}
|
|
1056
1171
|
|
|
1057
|
-
// ---
|
|
1172
|
+
// --- PLAY/PAUSE button (short = pause/resume, long = stop) ---
|
|
1058
1173
|
{
|
|
1059
|
-
bool
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
|
|
1174
|
+
bool pp = digitalRead(BTN_PLAYPAUSE_PIN) == LOW;
|
|
1175
|
+
if (pp && !playpauseDown && millis() - playpauseLastDebounce > DEBOUNCE_MS) {
|
|
1176
|
+
playpauseDown = true;
|
|
1177
|
+
playpauseDownAt = millis();
|
|
1063
1178
|
}
|
|
1064
|
-
if (!
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (uiState == UI_PLAY || !lp) {
|
|
1069
|
-
handleUp(lp);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// --- DOWN button ---
|
|
1075
|
-
{
|
|
1076
|
-
bool dn = digitalRead(BTN_DOWN_PIN) == LOW;
|
|
1077
|
-
if (dn && !downDown && millis() - downLastDebounce > DEBOUNCE_MS) {
|
|
1078
|
-
downDown = true;
|
|
1079
|
-
downDownAt = millis();
|
|
1080
|
-
}
|
|
1081
|
-
if (!dn && downDown) {
|
|
1082
|
-
downDown = false;
|
|
1083
|
-
downLastDebounce = millis();
|
|
1084
|
-
bool lp = millis() - downDownAt >= LONG_PRESS_MS;
|
|
1085
|
-
if (uiState == UI_PLAY || !lp) {
|
|
1086
|
-
handleDown(lp);
|
|
1087
|
-
}
|
|
1179
|
+
if (!pp && playpauseDown) {
|
|
1180
|
+
playpauseDown = false;
|
|
1181
|
+
playpauseLastDebounce = millis();
|
|
1182
|
+
handlePlayPauseButton(millis() - playpauseDownAt >= LONG_PRESS_MS);
|
|
1088
1183
|
}
|
|
1089
1184
|
}
|
|
1090
1185
|
|
|
@@ -1096,12 +1191,19 @@ void loop() {
|
|
|
1096
1191
|
if (!displayBlank) drawStatus();
|
|
1097
1192
|
}
|
|
1098
1193
|
|
|
1099
|
-
// ---
|
|
1194
|
+
// --- Idle disc screensaver (HOME + STANDBY) ---
|
|
1100
1195
|
if (displayOk && !displayBlank &&
|
|
1101
1196
|
(uiState == UI_HOME || uiState == UI_STANDBY) &&
|
|
1102
1197
|
(long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
|
|
1103
|
-
display.ssd1306_command(0xAE);
|
|
1104
1198
|
displayBlank = true;
|
|
1199
|
+
saverStep = 0;
|
|
1200
|
+
lastSaverFrame = 0;
|
|
1201
|
+
}
|
|
1202
|
+
if (displayBlank && displayOk &&
|
|
1203
|
+
(long)(millis() - lastSaverFrame) >= SAVER_FRAME_MS) {
|
|
1204
|
+
lastSaverFrame = millis();
|
|
1205
|
+
drawDiscSaver(saverStep);
|
|
1206
|
+
saverStep = (saverStep + 1) % 24;
|
|
1105
1207
|
}
|
|
1106
1208
|
|
|
1107
1209
|
// --- PING timeout (disconnected) ---
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -4558,7 +4558,7 @@ def main():
|
|
|
4558
4558
|
port = discstation_host.serial_port()
|
|
4559
4559
|
if port:
|
|
4560
4560
|
print(f"Using ESP32 serial port: {port}")
|
|
4561
|
-
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=
|
|
4561
|
+
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
|
|
4562
4562
|
if discstation_host.system_name() == "linux":
|
|
4563
4563
|
ser.setDTR(False)
|
|
4564
4564
|
time.sleep(0.1)
|
package/src/discstation_burn.py
CHANGED
|
@@ -538,9 +538,23 @@ def send(ser, msg):
|
|
|
538
538
|
pass
|
|
539
539
|
if not ser:
|
|
540
540
|
return False
|
|
541
|
+
payload = (msg + '\n').encode()
|
|
541
542
|
try:
|
|
542
543
|
with SERIAL_WRITE_LOCK:
|
|
543
|
-
|
|
544
|
+
# pyserial's Serial.write() on Linux waits on select() for the fd
|
|
545
|
+
# to report writable before it calls the real write() - some
|
|
546
|
+
# USB-CDC-ACM devices (e.g. the ESP32-C6's native USB peripheral,
|
|
547
|
+
# unlike a CP2102/CH340 bridge chip) never signal that through
|
|
548
|
+
# select() even though a plain write succeeds instantly, so
|
|
549
|
+
# ser.write() times out forever on those. Write through the raw
|
|
550
|
+
# fd directly for a real serial port; other transports
|
|
551
|
+
# (TcpSerial/VirtualSerial) keep their own write().
|
|
552
|
+
fileno = getattr(ser, "fileno", None)
|
|
553
|
+
if isinstance(ser, serial.Serial) and fileno:
|
|
554
|
+
os.write(fileno(), payload)
|
|
555
|
+
else:
|
|
556
|
+
ser.write(payload)
|
|
557
|
+
return True
|
|
544
558
|
except (serial.SerialException, OSError) as e:
|
|
545
559
|
if not _serial_write_failed:
|
|
546
560
|
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
@@ -549,7 +563,6 @@ def send(ser, msg):
|
|
|
549
563
|
except Exception as e:
|
|
550
564
|
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
551
565
|
return False
|
|
552
|
-
return True
|
|
553
566
|
|
|
554
567
|
def safe_send(ser, msg):
|
|
555
568
|
if not ser:
|