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 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,17 +22,21 @@
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
- #define IDLE_BLANK_MS 45000 // blank the OLED after this long with no input on HOME/STANDBY
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
- // Select button state
223
- unsigned long selectDownAt = 0;
224
- bool selectDown = false;
225
- 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
226
230
 
227
- // Up button state
228
- unsigned long upDownAt = 0;
229
- bool upDown = false;
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
- // Down button state
233
- unsigned long downDownAt = 0;
234
- bool downDown = false;
235
- 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;
236
239
 
237
- // FF/REW speed state (used by UP/DOWN during playback)
238
- int ffSpeedIndex = 0;
239
- 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;
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 OLED idle-blank
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
- int readBucket(int count) {
258
- int raw = analogRead(POT_PIN);
259
- int idx = raw * count / 4096;
260
- if (idx < 0) idx = 0;
261
- if (idx >= count) idx = count - 1;
262
- return idx;
263
- }
264
-
265
- int readPercent() {
266
- int raw = analogRead(POT_PIN);
267
- int pct = raw * 100 / 4095;
268
- if (pct < 0) pct = 0;
269
- if (pct > 100) pct = 100;
270
- 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--;
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
- display.print("VOL // ");
568
- display.print(playVolume);
569
- display.print("%");
570
- if (!audioPlayMode) {
571
- display.setCursor(2, 56);
572
- 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("%");
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
- playVolume = readPercent();
646
- 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
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(BTN_SELECT_PIN, INPUT_PULLUP);
757
- pinMode(BTN_UP_PIN, INPUT_PULLUP);
758
- pinMode(BTN_DOWN_PIN, INPUT_PULLUP);
759
- pinMode(POT_PIN, INPUT);
760
- analogReadResolution(12);
761
- analogSetPinAttenuation(POT_PIN, ADC_11db);
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
- Out.println("PLAY_BUTTON");
909
+ playSeekMode = !playSeekMode; // encoder click swaps what rotating does
910
+ drawPlay();
868
911
  }
869
912
  }
870
913
  }
871
914
 
872
- void handleUp(bool longPress) {
873
- 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
874
923
  lastInputTime = millis();
875
924
  if (uiState == UI_HOME) {
876
925
  if (homeCount > 0) {
877
- homeIndex = (homeIndex - 1 + homeCount) % homeCount;
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 - 1 + SPEED_COUNT) % SPEED_COUNT;
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 - 1 + BURN_COUNT) % BURN_COUNT;
937
+ burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
889
938
  sendBurnMode();
890
939
  drawBurnReady();
891
940
  }
892
941
  } else if (uiState == UI_STANDBY) {
893
- displayRotation = 0;
942
+ displayRotation = 2;
894
943
  drawStandby();
895
944
  } else if (uiState == UI_PLAY) {
896
- if (audioPlayMode) {
897
- Out.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
898
- return;
899
- }
900
- rewSpeedIndex = 0;
901
- if (longPress) {
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
- ffSpeedIndex = (ffSpeedIndex + 1) % 4;
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 handleDown(bool longPress) {
913
- 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
914
960
  lastInputTime = millis();
915
961
  if (uiState == UI_HOME) {
916
962
  if (homeCount > 0) {
917
- homeIndex = (homeIndex + 1) % homeCount;
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 + 1) % SPEED_COUNT;
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 + 1) % BURN_COUNT;
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 = 2;
979
+ displayRotation = 0;
934
980
  drawStandby();
935
981
  } else if (uiState == UI_PLAY) {
936
- if (audioPlayMode) {
937
- Out.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
938
- return;
939
- }
940
- ffSpeedIndex = 0;
941
- if (longPress) {
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
- rewSpeedIndex = (rewSpeedIndex + 1) % 4;
945
- int seek_sec[] = {10, 20, 30, 60};
946
- Out.print("REW:");
947
- 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();
948
1036
  }
949
1037
  }
950
1038
  }
@@ -1012,31 +1100,60 @@ void loop() {
1012
1100
  drawPlay();
1013
1101
  }
1014
1102
 
1015
- // --- Pot reading (PLAY only, volume) ---
1016
- if (uiState == UI_PLAY && millis() - lastPotRead > POT_READ_MS) {
1017
- lastPotRead = millis();
1018
- int nextVolume = readPercent();
1019
- if (abs(nextVolume - playVolume) >= 3) {
1020
- playVolume = nextVolume;
1021
- Out.print("POT:");
1022
- Out.println(playVolume);
1023
- 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);
1024
1128
  }
1025
1129
  }
1026
1130
 
1027
- // --- SELECT button ---
1131
+ // --- EJECT button (single press, no long-press timer) ---
1028
1132
  {
1029
- bool sel = digitalRead(BTN_SELECT_PIN) == LOW;
1030
- if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
1031
- selectDown = true;
1032
- 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();
1033
1150
  wifiResetArmed = false;
1034
1151
  }
1035
- // 10s hold on HOME (or SETUP) = wipe Wi-Fi creds + reboot to portal. Fires
1036
- // while still held so the eventual release doesn't also trigger EJECT.
1037
- 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 &&
1038
1155
  (uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
1039
- millis() - selectDownAt >= WIFI_RESET_HOLD_MS) {
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 (!sel && selectDown) {
1048
- selectDown = false;
1049
- selectLastDebounce = millis();
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
- // --- UP button ---
1172
+ // --- PLAY/PAUSE button (short = pause/resume, long = stop) ---
1058
1173
  {
1059
- bool up = digitalRead(BTN_UP_PIN) == LOW;
1060
- if (up && !upDown && millis() - upLastDebounce > DEBOUNCE_MS) {
1061
- upDown = true;
1062
- upDownAt = millis();
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 (!up && upDown) {
1065
- upDown = false;
1066
- upLastDebounce = millis();
1067
- bool lp = millis() - upDownAt >= LONG_PRESS_MS;
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
- // --- OLED idle blanking (HOME + STANDBY) ---
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.28",
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: