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 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 or ESP32-C6 with an SSD1306 OLED, 3
13
- buttons, and a volume pot — connected by **USB serial** or over **Wi-Fi**
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
- # Upload arduino/c6/DiscStation_C6.ino or arduino/v1/DiscStation.ino
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 BTN_SELECT_PIN 14
26
- #define BTN_UP_PIN 13
27
- #define BTN_DOWN_PIN 15
28
- #define POT_PIN 34
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 POT_READ_MS 200
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
- // Select button state
224
- unsigned long selectDownAt = 0;
225
- bool selectDown = false;
226
- unsigned long selectLastDebounce = 0;
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
- // Up button state
229
- unsigned long upDownAt = 0;
230
- bool upDown = false;
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
- // Down button state
234
- unsigned long downDownAt = 0;
235
- bool downDown = false;
236
- unsigned long downLastDebounce = 0;
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
- // FF/REW speed state (used by UP/DOWN during playback)
239
- int ffSpeedIndex = 0;
240
- int rewSpeedIndex = 0;
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
- int readBucket(int count) {
261
- int raw = analogRead(POT_PIN);
262
- int idx = raw * count / 4096;
263
- if (idx < 0) idx = 0;
264
- if (idx >= count) idx = count - 1;
265
- return idx;
266
- }
267
-
268
- int readPercent() {
269
- int raw = analogRead(POT_PIN);
270
- int pct = raw * 100 / 4095;
271
- if (pct < 0) pct = 0;
272
- if (pct > 100) pct = 100;
273
- return ((pct + 2) / 5) * 5;
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
- display.print("VOL // ");
611
- display.print(playVolume);
612
- display.print("%");
613
- if (!audioPlayMode) {
614
- display.setCursor(2, 56);
615
- display.print("SELECT // PLAY");
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
- playVolume = readPercent();
689
- Out.print("POT:"); // push the knob's current level so playback starts at it
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(BTN_SELECT_PIN, INPUT_PULLUP);
800
- pinMode(BTN_UP_PIN, INPUT_PULLUP);
801
- pinMode(BTN_DOWN_PIN, INPUT_PULLUP);
802
- pinMode(POT_PIN, INPUT);
803
- analogReadResolution(12);
804
- analogSetPinAttenuation(POT_PIN, ADC_11db);
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
- Out.println("PLAY_BUTTON");
909
+ playSeekMode = !playSeekMode; // encoder click swaps what rotating does
910
+ drawPlay();
911
911
  }
912
912
  }
913
913
  }
914
914
 
915
- void handleUp(bool longPress) {
916
- if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only press, swallow the action
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 - 1 + homeCount) % homeCount;
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 - 1 + SPEED_COUNT) % SPEED_COUNT;
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 - 1 + BURN_COUNT) % BURN_COUNT;
937
+ burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
932
938
  sendBurnMode();
933
939
  drawBurnReady();
934
940
  }
935
941
  } else if (uiState == UI_STANDBY) {
936
- displayRotation = 0;
942
+ displayRotation = 2;
937
943
  drawStandby();
938
944
  } else if (uiState == UI_PLAY) {
939
- if (audioPlayMode) {
940
- Out.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
941
- return;
942
- }
943
- rewSpeedIndex = 0;
944
- if (longPress) {
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
- ffSpeedIndex = (ffSpeedIndex + 1) % 4;
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 handleDown(bool longPress) {
956
- if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only press, swallow the action
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 + 1) % homeCount;
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 + 1) % SPEED_COUNT;
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 + 1) % BURN_COUNT;
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 = 2;
979
+ displayRotation = 0;
977
980
  drawStandby();
978
981
  } else if (uiState == UI_PLAY) {
979
- if (audioPlayMode) {
980
- Out.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
981
- return;
982
- }
983
- ffSpeedIndex = 0;
984
- if (longPress) {
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
- rewSpeedIndex = (rewSpeedIndex + 1) % 4;
988
- int seek_sec[] = {10, 20, 30, 60};
989
- Out.print("REW:");
990
- Out.println(seek_sec[rewSpeedIndex]);
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
- // --- Pot reading (PLAY only, volume) ---
1059
- if (uiState == UI_PLAY && millis() - lastPotRead > POT_READ_MS) {
1060
- lastPotRead = millis();
1061
- int nextVolume = readPercent();
1062
- if (abs(nextVolume - playVolume) >= 3) {
1063
- playVolume = nextVolume;
1064
- Out.print("POT:");
1065
- Out.println(playVolume);
1066
- drawPlay();
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
- // --- SELECT button ---
1131
+ // --- EJECT button (single press, no long-press timer) ---
1071
1132
  {
1072
- bool sel = digitalRead(BTN_SELECT_PIN) == LOW;
1073
- if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
1074
- selectDown = true;
1075
- selectDownAt = millis();
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 (or SETUP) = wipe Wi-Fi creds + reboot to portal. Fires
1079
- // while still held so the eventual release doesn't also trigger EJECT.
1080
- if (sel && selectDown && !wifiResetArmed &&
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() - selectDownAt >= WIFI_RESET_HOLD_MS) {
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 (!sel && selectDown) {
1091
- selectDown = false;
1092
- selectLastDebounce = millis();
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
- // --- UP button ---
1172
+ // --- PLAY/PAUSE button (short = pause/resume, long = stop) ---
1101
1173
  {
1102
- bool up = digitalRead(BTN_UP_PIN) == LOW;
1103
- if (up && !upDown && millis() - upLastDebounce > DEBOUNCE_MS) {
1104
- upDown = true;
1105
- upDownAt = millis();
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 (!up && upDown) {
1108
- upDown = false;
1109
- upLastDebounce = millis();
1110
- bool lp = millis() - upDownAt >= LONG_PRESS_MS;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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=1)
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)
@@ -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
- ser.write((msg + '\n').encode())
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: