discstation 0.1.29 → 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 +200 -148
- 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 -1185
- 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,14 +22,17 @@
|
|
|
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
38
|
#define IDLE_BLANK_MS 45000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
|
|
@@ -218,32 +221,33 @@ bool displayOk = false;
|
|
|
218
221
|
unsigned long returnToHomeAt = 0;
|
|
219
222
|
unsigned long playStatusAt = 0;
|
|
220
223
|
bool playStatusTemp = false;
|
|
221
|
-
unsigned long lastPotRead = 0;
|
|
222
224
|
|
|
223
|
-
//
|
|
224
|
-
unsigned long
|
|
225
|
-
bool
|
|
226
|
-
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
|
|
227
230
|
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
unsigned long upLastDebounce = 0;
|
|
231
|
+
// EJECT button - single press, no long-press timer needed
|
|
232
|
+
bool ejectDown = false;
|
|
233
|
+
unsigned long ejectLastDebounce = 0;
|
|
232
234
|
|
|
233
|
-
//
|
|
234
|
-
unsigned long
|
|
235
|
-
bool
|
|
236
|
-
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;
|
|
237
239
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
// PLAY/PAUSE button - short = pause/resume, long = stop
|
|
241
|
+
unsigned long playpauseDownAt = 0;
|
|
242
|
+
bool playpauseDown = false;
|
|
243
|
+
unsigned long playpauseLastDebounce = 0;
|
|
241
244
|
|
|
242
245
|
int homeIndex = 0;
|
|
243
246
|
int burnModeIndex = 0;
|
|
244
247
|
int burnSpeedIndex = 0;
|
|
245
248
|
bool editingSpeed = false;
|
|
246
249
|
int playVolume = 50;
|
|
250
|
+
bool playSeekMode = false; // false = encoder rotate adjusts volume (default); true = seek/track-skip
|
|
247
251
|
unsigned long standbyStartTime = 0;
|
|
248
252
|
unsigned long lastMsgTime = 0;
|
|
249
253
|
unsigned long lastInputTime = 0; // last button press / screen change; drives the idle screensaver
|
|
@@ -257,20 +261,22 @@ int displayRotation = 0;
|
|
|
257
261
|
int indeterminateCount = 0;
|
|
258
262
|
unsigned long lastIndeterminateAnim = 0;
|
|
259
263
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
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--;
|
|
274
280
|
}
|
|
275
281
|
|
|
276
282
|
void sendHomeMode() {
|
|
@@ -607,13 +613,16 @@ void drawPlay() {
|
|
|
607
613
|
display.setCursor(2, 30);
|
|
608
614
|
printUpper(line2);
|
|
609
615
|
display.setCursor(2, 44);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
display.
|
|
615
|
-
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("%");
|
|
616
622
|
}
|
|
623
|
+
display.setCursor(2, 56);
|
|
624
|
+
display.print("CLICK // ");
|
|
625
|
+
display.print(playSeekMode ? "VOL MODE" : "SEEK MODE");
|
|
617
626
|
|
|
618
627
|
display.display();
|
|
619
628
|
}
|
|
@@ -635,7 +644,6 @@ void parseMessage(String msg) {
|
|
|
635
644
|
Out.println(msg);
|
|
636
645
|
|
|
637
646
|
if (msg.startsWith("HOME:")) {
|
|
638
|
-
homeIndex = readBucket(homeCount);
|
|
639
647
|
drawHome();
|
|
640
648
|
|
|
641
649
|
} else if (msg.startsWith("DISC:")) {
|
|
@@ -669,7 +677,6 @@ void parseMessage(String msg) {
|
|
|
669
677
|
} else if (msg.startsWith("TITLE:")) {
|
|
670
678
|
titleLine = msg.substring(6);
|
|
671
679
|
if (titleLine.length() > 20) titleLine = titleLine.substring(0, 20);
|
|
672
|
-
burnModeIndex = readBucket(BURN_COUNT);
|
|
673
680
|
drawBurnReady();
|
|
674
681
|
|
|
675
682
|
} else if (msg.startsWith("META:")) {
|
|
@@ -685,8 +692,8 @@ void parseMessage(String msg) {
|
|
|
685
692
|
} else if (msg.startsWith("PLAY:")) {
|
|
686
693
|
line1 = msg.substring(5);
|
|
687
694
|
line2 = "Playing disc";
|
|
688
|
-
|
|
689
|
-
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
|
|
690
697
|
Out.println(playVolume);
|
|
691
698
|
drawPlay();
|
|
692
699
|
|
|
@@ -796,16 +803,13 @@ void setup() {
|
|
|
796
803
|
Serial.begin(115200);
|
|
797
804
|
esp_task_wdt_add(NULL);
|
|
798
805
|
Wire.begin(21, 22);
|
|
799
|
-
pinMode(
|
|
800
|
-
pinMode(
|
|
801
|
-
pinMode(
|
|
802
|
-
pinMode(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
homeIndex = readBucket(homeCount);
|
|
807
|
-
burnModeIndex = readBucket(BURN_COUNT);
|
|
808
|
-
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);
|
|
809
813
|
|
|
810
814
|
displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
|
|
811
815
|
|
|
@@ -898,96 +902,137 @@ void handleSelectPress(bool longPress) {
|
|
|
898
902
|
Out.println("CANCEL");
|
|
899
903
|
drawStandby();
|
|
900
904
|
|
|
901
|
-
} else if (uiState == UI_STANDBY) {
|
|
902
|
-
displayRotation = 2;
|
|
903
|
-
drawStandby();
|
|
904
905
|
} else if (uiState == UI_PLAY) {
|
|
905
|
-
ffSpeedIndex = 0;
|
|
906
|
-
rewSpeedIndex = 0;
|
|
907
906
|
if (longPress) {
|
|
908
907
|
Out.println("PLAY_STOP");
|
|
909
908
|
} else {
|
|
910
|
-
|
|
909
|
+
playSeekMode = !playSeekMode; // encoder click swaps what rotating does
|
|
910
|
+
drawPlay();
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
917
923
|
lastInputTime = millis();
|
|
918
924
|
if (uiState == UI_HOME) {
|
|
919
925
|
if (homeCount > 0) {
|
|
920
|
-
homeIndex = (homeIndex
|
|
926
|
+
homeIndex = (homeIndex + 1) % homeCount;
|
|
921
927
|
sendHomeMode();
|
|
922
928
|
drawHome();
|
|
923
929
|
}
|
|
924
930
|
} else if (uiState == UI_BURN_READY) {
|
|
925
931
|
if (editingSpeed) {
|
|
926
|
-
burnSpeedIndex = (burnSpeedIndex
|
|
932
|
+
burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
|
|
927
933
|
Out.print("SPEED:");
|
|
928
934
|
Out.println(SPEED_MODES[burnSpeedIndex]);
|
|
929
935
|
drawBurnReady();
|
|
930
936
|
} else {
|
|
931
|
-
burnModeIndex = (burnModeIndex
|
|
937
|
+
burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
|
|
932
938
|
sendBurnMode();
|
|
933
939
|
drawBurnReady();
|
|
934
940
|
}
|
|
935
941
|
} else if (uiState == UI_STANDBY) {
|
|
936
|
-
displayRotation =
|
|
942
|
+
displayRotation = 2;
|
|
937
943
|
drawStandby();
|
|
938
944
|
} else if (uiState == UI_PLAY) {
|
|
939
|
-
if (
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
if (
|
|
945
|
-
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
|
|
946
952
|
} else {
|
|
947
|
-
|
|
948
|
-
int seek_sec[] = {10, 20, 30, 60};
|
|
949
|
-
Out.print("FF:");
|
|
950
|
-
Out.println(seek_sec[ffSpeedIndex]);
|
|
953
|
+
Out.println("FF:10");
|
|
951
954
|
}
|
|
952
955
|
}
|
|
953
956
|
}
|
|
954
957
|
|
|
955
|
-
void
|
|
956
|
-
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only
|
|
958
|
+
void handleEncoderCCW() {
|
|
959
|
+
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only turn, swallow the action
|
|
957
960
|
lastInputTime = millis();
|
|
958
961
|
if (uiState == UI_HOME) {
|
|
959
962
|
if (homeCount > 0) {
|
|
960
|
-
homeIndex = (homeIndex +
|
|
963
|
+
homeIndex = (homeIndex - 1 + homeCount) % homeCount;
|
|
961
964
|
sendHomeMode();
|
|
962
965
|
drawHome();
|
|
963
966
|
}
|
|
964
967
|
} else if (uiState == UI_BURN_READY) {
|
|
965
968
|
if (editingSpeed) {
|
|
966
|
-
burnSpeedIndex = (burnSpeedIndex +
|
|
969
|
+
burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
|
|
967
970
|
Out.print("SPEED:");
|
|
968
971
|
Out.println(SPEED_MODES[burnSpeedIndex]);
|
|
969
972
|
drawBurnReady();
|
|
970
973
|
} else {
|
|
971
|
-
burnModeIndex = (burnModeIndex +
|
|
974
|
+
burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
|
|
972
975
|
sendBurnMode();
|
|
973
976
|
drawBurnReady();
|
|
974
977
|
}
|
|
975
978
|
} else if (uiState == UI_STANDBY) {
|
|
976
|
-
displayRotation =
|
|
979
|
+
displayRotation = 0;
|
|
977
980
|
drawStandby();
|
|
978
981
|
} else if (uiState == UI_PLAY) {
|
|
979
|
-
if (
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if (
|
|
985
|
-
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");
|
|
986
989
|
} else {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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();
|
|
991
1036
|
}
|
|
992
1037
|
}
|
|
993
1038
|
}
|
|
@@ -1055,31 +1100,60 @@ void loop() {
|
|
|
1055
1100
|
drawPlay();
|
|
1056
1101
|
}
|
|
1057
1102
|
|
|
1058
|
-
// ---
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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);
|
|
1067
1128
|
}
|
|
1068
1129
|
}
|
|
1069
1130
|
|
|
1070
|
-
// ---
|
|
1131
|
+
// --- EJECT button (single press, no long-press timer) ---
|
|
1071
1132
|
{
|
|
1072
|
-
bool
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
|
|
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();
|
|
1076
1150
|
wifiResetArmed = false;
|
|
1077
1151
|
}
|
|
1078
|
-
// 10s hold on HOME
|
|
1079
|
-
// while still held so the eventual release doesn't also trigger
|
|
1080
|
-
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 &&
|
|
1081
1155
|
(uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
|
|
1082
|
-
millis() -
|
|
1156
|
+
millis() - homeDownAt >= WIFI_RESET_HOLD_MS) {
|
|
1083
1157
|
wifiResetArmed = true;
|
|
1084
1158
|
Out.println("WiFi: creds wiped, rebooting");
|
|
1085
1159
|
clearCreds();
|
|
@@ -1087,47 +1161,25 @@ void loop() {
|
|
|
1087
1161
|
delay(800);
|
|
1088
1162
|
ESP.restart();
|
|
1089
1163
|
}
|
|
1090
|
-
if (!
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
if (!wifiResetArmed)
|
|
1094
|
-
handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
|
|
1095
|
-
}
|
|
1164
|
+
if (!hm && homeDown) {
|
|
1165
|
+
homeDown = false;
|
|
1166
|
+
homeLastDebounce = millis();
|
|
1167
|
+
if (!wifiResetArmed) handleHomeButton();
|
|
1096
1168
|
wifiResetArmed = false;
|
|
1097
1169
|
}
|
|
1098
1170
|
}
|
|
1099
1171
|
|
|
1100
|
-
// ---
|
|
1172
|
+
// --- PLAY/PAUSE button (short = pause/resume, long = stop) ---
|
|
1101
1173
|
{
|
|
1102
|
-
bool
|
|
1103
|
-
if (
|
|
1104
|
-
|
|
1105
|
-
|
|
1174
|
+
bool pp = digitalRead(BTN_PLAYPAUSE_PIN) == LOW;
|
|
1175
|
+
if (pp && !playpauseDown && millis() - playpauseLastDebounce > DEBOUNCE_MS) {
|
|
1176
|
+
playpauseDown = true;
|
|
1177
|
+
playpauseDownAt = millis();
|
|
1106
1178
|
}
|
|
1107
|
-
if (!
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (uiState == UI_PLAY || !lp) {
|
|
1112
|
-
handleUp(lp);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
// --- DOWN button ---
|
|
1118
|
-
{
|
|
1119
|
-
bool dn = digitalRead(BTN_DOWN_PIN) == LOW;
|
|
1120
|
-
if (dn && !downDown && millis() - downLastDebounce > DEBOUNCE_MS) {
|
|
1121
|
-
downDown = true;
|
|
1122
|
-
downDownAt = millis();
|
|
1123
|
-
}
|
|
1124
|
-
if (!dn && downDown) {
|
|
1125
|
-
downDown = false;
|
|
1126
|
-
downLastDebounce = millis();
|
|
1127
|
-
bool lp = millis() - downDownAt >= LONG_PRESS_MS;
|
|
1128
|
-
if (uiState == UI_PLAY || !lp) {
|
|
1129
|
-
handleDown(lp);
|
|
1130
|
-
}
|
|
1179
|
+
if (!pp && playpauseDown) {
|
|
1180
|
+
playpauseDown = false;
|
|
1181
|
+
playpauseLastDebounce = millis();
|
|
1182
|
+
handlePlayPauseButton(millis() - playpauseDownAt >= LONG_PRESS_MS);
|
|
1131
1183
|
}
|
|
1132
1184
|
}
|
|
1133
1185
|
|
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:
|