discstation 0.1.23 → 0.1.26

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,12 +9,37 @@ use every mode.
9
9
  ## Hardware
10
10
 
11
11
  - **Brain:** Raspberry Pi 4/5 (or any Linux box)
12
- - **Remote (optional):** ESP32-C6 with SSD1306 OLED, rotary encoder, and
13
- buttons — or none at all; the web UI's on-screen remote covers the same
14
- controls when no ESP32 is detected, and steps aside (greyed out,
15
- auto-collapsing) the moment one is plugged in
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**
14
+ (see below) — or none at all; the web UI / mobile app's on-screen remote
15
+ covers the same controls when no ESP32 is detected, and steps aside
16
+ (greyed out, auto-collapsing) the moment one appears.
16
17
  - **Drive:** ATAPI DVD writer (e.g. iHAS124) over USB
17
18
 
19
+ ### Wi-Fi remote (optional)
20
+
21
+ The firmware also serves the same line protocol over TCP 2323, so the
22
+ remote can talk to the host over Wi-Fi instead of a USB cable.
23
+
24
+ - **Provisioning (no code edit):** on first boot with no stored credentials
25
+ the remote starts an open `DiscStation-XXXX` access point; join it from a
26
+ phone, the captive portal (or `http://192.168.4.1`) lists nearby networks
27
+ — pick yours, enter the password, done. It reconnects on every boot after
28
+ that. Change networks later: **hold SELECT for 10 s** on the home screen
29
+ to wipe the credentials and reopen the portal.
30
+ Power users can instead `cp arduino/<board>/secrets.h.example secrets.h`
31
+ and set `WIFI_SSID` / `WIFI_PASS` at build time (git-ignored).
32
+ - **Host side:** nothing to configure. With no USB cable present the host
33
+ finds the remote by mDNS automatically (`discstation-xxxx.local`). Set
34
+ `DISC_REMOTE_HOST` in `discstation.env` to an explicit IP only if mDNS is
35
+ blocked on your network, or `off` to never look. A USB cable always wins
36
+ when both are present.
37
+ - **Trust model:** TCP 2323 is unauthenticated on the LAN, same as the USB
38
+ link and the LAN web UI — fine for a home appliance. During the ~30 s of
39
+ portal setup the password crosses an open AP; the ESP32 is 2.4 GHz only
40
+ (a "connected to the router but no data" symptom usually means a
41
+ 5 GHz-only / band-steering network or a wrong password).
42
+
18
43
  ## Features
19
44
 
20
45
  | Mode | Description |
@@ -58,10 +83,12 @@ discstation-setup # dispatches to the installer for your OS, then opens
58
83
 
59
84
  The host runs as a background service; the UI is the built-in web app at
60
85
  `http://localhost:8081`. `discstation-setup` opens it when it finishes, and the
61
- `discstation` command re-opens it any time. It's a PWA — use your browser's
62
- **Install DiscStation** (or the in-page **INSTALL APP** button on Chromium) to
63
- get a standalone app window with a dock/taskbar icon on macOS, Linux, and
64
- Windows.
86
+ `discstation` command re-opens it any time — both also print the LAN address
87
+ (e.g. `Also reachable at http://192.168.1.50:8081/ ...`) so you know what to
88
+ open from a phone or another computer, or enter into the mobile app. It's a
89
+ PWA — use your browser's **Install DiscStation** (or the in-page
90
+ **INSTALL APP** button on Chromium) to get a standalone app window with a
91
+ dock/taskbar icon on macOS, Linux, and Windows.
65
92
 
66
93
  `discstation-setup --help` lists the forwarded env vars. From a git clone,
67
94
  `npm run setup` does the same thing.
@@ -123,6 +150,14 @@ self-signed cert. On startup the host prints the LAN URL to open
123
150
  with no ESP32 attached; greys out and auto-collapses the instant a
124
151
  physical remote is detected, so the two never fight for control.
125
152
 
153
+ ## Mobile App
154
+
155
+ The `mobile/` Expo app (enter the host's LAN address in its settings screen)
156
+ has the same on-screen remote as the web UI — mode buttons, EJECT/CLOSE,
157
+ disc status, and playback transport, tapped from the "REMOTE" tag next to
158
+ the wordmark. Same auto-collapse behavior when a physical ESP32 shows up,
159
+ same underlying `/remote/button` protocol.
160
+
126
161
  ## Disc Support
127
162
 
128
163
  - DVD-R, DVD+R (single and dual-layer)
@@ -1,12 +1,25 @@
1
1
  #include <Wire.h>
2
2
  #include <Adafruit_GFX.h>
3
3
  #include <Adafruit_SSD1306.h>
4
+ #include <WiFi.h>
5
+ #include "esp_mac.h"
6
+ #include <WiFiManager.h>
7
+ #include <ESPmDNS.h>
8
+ #include <Preferences.h>
9
+ #include <ArduinoOTA.h>
4
10
  #include <QRCode.h>
5
11
 
12
+ // Optional build-time Wi-Fi override for power users: copy secrets.h.example
13
+ // to secrets.h (git-ignored) and set WIFI_SSID / WIFI_PASS / OTA_PASSWORD.
14
+ #if __has_include("secrets.h")
15
+ #include "secrets.h"
16
+ #endif
17
+
6
18
  #define SCREEN_WIDTH 128
7
19
  #define SCREEN_HEIGHT 64
8
20
  #define OLED_RESET -1
9
21
  #define I2C_ADDRESS 0x3C
22
+ #define TCP_PORT 2323
10
23
 
11
24
  #define BTN_SELECT_PIN 21 // D3
12
25
  #define BTN_UP_PIN 20 // D9/MISO
@@ -20,9 +33,14 @@
20
33
  #define POT_READ_MS 200
21
34
  #define DONE_RESET_MS 30000
22
35
  #define STANDBY_BLANK_MS 60000
36
+ #define IDLE_BLANK_MS 45000 // blank the OLED after this long with no input on HOME/STANDBY
23
37
  #define PING_TIMEOUT_MS 30000
24
38
  #define LED_BLINK_MS 250
25
39
 
40
+ #define WIFI_RESET_HOLD_MS 10000
41
+ #define WIFI_CONNECT_TIMEOUT_MS 18000
42
+ #define WIFI_RETRY_MS 30000
43
+
26
44
  #define MAX_HOME_MODES 5
27
45
  #define BURN_COUNT 4
28
46
  #define SPEED_COUNT 4
@@ -31,6 +49,137 @@ int homeCount = 3;
31
49
  String homeModes[MAX_HOME_MODES] = {"BURN", "PLAY", "RIP"};
32
50
  String discName = "";
33
51
 
52
+ // --- Wi-Fi link: mirror every outbound line to Serial AND a TCP client ---
53
+ WiFiServer tcpServer(TCP_PORT);
54
+ WiFiClient tcpClient;
55
+ WiFiManager wm;
56
+ Preferences prefs;
57
+ bool wifiConnected = false;
58
+ bool wifiInitDone = false;
59
+ bool portalActive = false;
60
+ bool otaEnabled = false;
61
+ bool credsJustSaved = false;
62
+ bool wifiResetArmed = false;
63
+ unsigned long wifiDropAt = 0;
64
+ String apName = "DiscStation";
65
+ String mdnsHost = "discstation";
66
+
67
+ class DualPrint : public Print {
68
+ public:
69
+ void setClient(WiFiClient* c) { _client = c; }
70
+ size_t write(uint8_t c) override {
71
+ Serial.write(c);
72
+ if (_client && _client->connected()) _client->write(c);
73
+ return 1;
74
+ }
75
+ size_t write(const uint8_t* buf, size_t len) override {
76
+ Serial.write(buf, len);
77
+ if (_client && _client->connected()) _client->write(buf, len);
78
+ return len;
79
+ }
80
+ private:
81
+ WiFiClient* _client = nullptr;
82
+ } Out;
83
+
84
+ void drawSetup();
85
+ void drawWifiReset();
86
+
87
+ String deviceSuffix() {
88
+ uint8_t mac[6];
89
+ esp_read_mac(mac, ESP_MAC_WIFI_STA); // factory MAC from eFuse - valid before the Wi-Fi driver starts
90
+ char buf[5];
91
+ snprintf(buf, sizeof(buf), "%02X%02X", mac[4], mac[5]);
92
+ return String(buf);
93
+ }
94
+
95
+ void loadCreds(String& ssid, String& pass) {
96
+ prefs.begin("discstation", true);
97
+ ssid = prefs.getString("ssid", "");
98
+ pass = prefs.getString("pass", "");
99
+ prefs.end();
100
+ }
101
+
102
+ void saveCreds(const String& ssid, const String& pass) {
103
+ prefs.begin("discstation", false);
104
+ prefs.putString("ssid", ssid);
105
+ prefs.putString("pass", pass);
106
+ prefs.end();
107
+ }
108
+
109
+ void clearCreds() {
110
+ prefs.begin("discstation", false);
111
+ prefs.clear();
112
+ prefs.end();
113
+ WiFi.disconnect(true, true);
114
+ }
115
+
116
+ void wmSaveCallback() {
117
+ saveCreds(wm.getWiFiSSID(), wm.getWiFiPass());
118
+ credsJustSaved = true;
119
+ }
120
+
121
+ void onWifiUp() {
122
+ portalActive = false;
123
+ wifiConnected = true;
124
+ wifiDropAt = 0;
125
+ WiFi.setSleep(WIFI_PS_MIN_MODEM);
126
+ tcpServer.begin();
127
+ if (MDNS.begin(mdnsHost.c_str())) {
128
+ MDNS.addService("discstation", "tcp", TCP_PORT);
129
+ }
130
+ #ifdef OTA_PASSWORD
131
+ ArduinoOTA.setHostname(mdnsHost.c_str());
132
+ ArduinoOTA.setPassword(OTA_PASSWORD);
133
+ ArduinoOTA.begin();
134
+ otaEnabled = true;
135
+ #endif
136
+ Out.print("WiFi OK "); Out.println(WiFi.localIP());
137
+ }
138
+
139
+ void startPortal() {
140
+ Out.print("WiFi: setup portal '"); Out.print(apName); Out.println("'");
141
+ portalActive = true;
142
+ WiFi.mode(WIFI_AP_STA);
143
+ wm.setConfigPortalBlocking(false);
144
+ wm.setConfigPortalTimeout(0);
145
+ wm.setSaveConfigCallback(wmSaveCallback);
146
+ wm.startConfigPortal(apName.c_str());
147
+ drawSetup();
148
+ }
149
+
150
+ void initWiFi() {
151
+ apName = "DiscStation-" + deviceSuffix();
152
+ mdnsHost = "discstation-" + deviceSuffix();
153
+ mdnsHost.toLowerCase();
154
+
155
+ WiFi.mode(WIFI_STA);
156
+ WiFi.setAutoReconnect(true);
157
+ WiFi.setSleep(WIFI_PS_MIN_MODEM);
158
+
159
+ String ssid, pass;
160
+ #ifdef WIFI_SSID
161
+ ssid = WIFI_SSID;
162
+ pass = WIFI_PASS;
163
+ #else
164
+ loadCreds(ssid, pass);
165
+ #endif
166
+
167
+ if (ssid.length() > 0) {
168
+ Out.print("WiFi "); Out.print(ssid); Out.print("...");
169
+ WiFi.begin(ssid.c_str(), pass.c_str());
170
+ unsigned long t0 = millis();
171
+ while (WiFi.status() != WL_CONNECTED && millis() - t0 < WIFI_CONNECT_TIMEOUT_MS) delay(200);
172
+ }
173
+
174
+ if (WiFi.status() == WL_CONNECTED) {
175
+ Out.println(" OK");
176
+ onWifiUp();
177
+ } else {
178
+ if (ssid.length() > 0) Out.println(" fail (2.4GHz band / wrong password?)");
179
+ startPortal();
180
+ }
181
+ }
182
+
34
183
  Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
35
184
 
36
185
  String ipUrl = "";
@@ -44,7 +193,8 @@ enum UiState {
44
193
  UI_WAITING,
45
194
  UI_IP,
46
195
  UI_DISCONNECTED,
47
- UI_LOADING
196
+ UI_LOADING,
197
+ UI_SETUP
48
198
  };
49
199
 
50
200
  const char* BURN_MODES[BURN_COUNT] = {"AUTO", "BEST", "LONG", "TEST"};
@@ -96,6 +246,7 @@ bool editingSpeed = false;
96
246
  int playVolume = 50;
97
247
  unsigned long standbyStartTime = 0;
98
248
  unsigned long lastMsgTime = 0;
249
+ unsigned long lastInputTime = 0; // last button press / screen change; drives OLED idle-blank
99
250
  bool displayBlank = false;
100
251
  bool audioPlayMode = false;
101
252
  int displayRotation = 0;
@@ -121,14 +272,14 @@ int readPercent() {
121
272
 
122
273
  void sendHomeMode() {
123
274
  if (homeIndex >= 0 && homeIndex < homeCount) {
124
- Serial.print("MENU:");
125
- Serial.println(homeModes[homeIndex]);
275
+ Out.print("MENU:");
276
+ Out.println(homeModes[homeIndex]);
126
277
  }
127
278
  }
128
279
 
129
280
  void sendBurnMode() {
130
- Serial.print("MODE:");
131
- Serial.println(BURN_MODES[burnModeIndex]);
281
+ Out.print("MODE:");
282
+ Out.println(BURN_MODES[burnModeIndex]);
132
283
  }
133
284
 
134
285
  void printUpper(String value) {
@@ -162,6 +313,7 @@ void drawHeader() {
162
313
  void drawHome() {
163
314
  uiState = UI_HOME;
164
315
  returnToHomeAt = 0;
316
+ lastInputTime = millis();
165
317
  if (!displayOk) return;
166
318
 
167
319
  display.clearDisplay();
@@ -311,9 +463,11 @@ void drawIP() {
311
463
  }
312
464
 
313
465
  void drawStandby() {
466
+ if (portalActive) { drawSetup(); return; }
314
467
  uiState = UI_STANDBY;
315
468
  returnToHomeAt = 0;
316
469
  standbyStartTime = millis();
470
+ lastInputTime = millis();
317
471
  displayBlank = false;
318
472
  if (!displayOk) return;
319
473
 
@@ -326,6 +480,38 @@ void drawStandby() {
326
480
  display.display();
327
481
  }
328
482
 
483
+ void drawSetup() {
484
+ uiState = UI_SETUP;
485
+ returnToHomeAt = 0;
486
+ displayBlank = false;
487
+ lastInputTime = millis();
488
+ if (!displayOk) return;
489
+ display.clearDisplay();
490
+ drawHeader();
491
+ display.setCursor(2, 16);
492
+ display.print("WIFI SETUP");
493
+ display.setCursor(2, 28);
494
+ display.print("JOIN:");
495
+ display.setCursor(2, 38);
496
+ display.print(apName);
497
+ display.setCursor(2, 50);
498
+ display.print("THEN 192.168.4.1");
499
+ display.display();
500
+ }
501
+
502
+ void drawWifiReset() {
503
+ uiState = UI_SETUP;
504
+ displayBlank = false;
505
+ if (!displayOk) return;
506
+ display.clearDisplay();
507
+ drawHeader();
508
+ display.setCursor(2, 25);
509
+ display.print("WIFI RESET");
510
+ display.setCursor(2, 40);
511
+ display.print("REBOOTING...");
512
+ display.display();
513
+ }
514
+
329
515
  void drawDisconnected() {
330
516
  uiState = UI_DISCONNECTED;
331
517
  returnToHomeAt = 0;
@@ -378,6 +564,7 @@ void wakeDisplay() {
378
564
  case UI_STANDBY: drawStandby(); break;
379
565
  case UI_DISCONNECTED: drawDisconnected(); break;
380
566
  case UI_LOADING: drawLoading(); break;
567
+ case UI_SETUP: drawSetup(); break;
381
568
  }
382
569
  }
383
570
 
@@ -410,7 +597,7 @@ void parseMessage(String msg) {
410
597
 
411
598
  if (msg == "PING") {
412
599
  lastMsgTime = millis();
413
- Serial.println("PONG");
600
+ Out.println("PONG");
414
601
  if (uiState == UI_DISCONNECTED) drawStandby();
415
602
  return;
416
603
  }
@@ -418,8 +605,8 @@ void parseMessage(String msg) {
418
605
  wakeDisplay();
419
606
  lastMsgTime = millis();
420
607
 
421
- Serial.print("RCV:");
422
- Serial.println(msg);
608
+ Out.print("RCV:");
609
+ Out.println(msg);
423
610
 
424
611
  if (msg.startsWith("HOME:")) {
425
612
  homeIndex = readBucket(homeCount);
@@ -593,6 +780,7 @@ void parseMessage(String msg) {
593
780
  }
594
781
 
595
782
  void setup() {
783
+ setCpuFrequencyMhz(160); // 240 -> 160: ~halves CPU power; I2C/GPIO all fine at 160
596
784
  Serial.begin(115200);
597
785
  Wire.begin(22, 23);
598
786
 
@@ -618,9 +806,9 @@ void setup() {
618
806
  displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
619
807
 
620
808
  if (displayOk) {
621
- Serial.println("Display OK");
809
+ Out.println("Display OK");
622
810
  } else {
623
- Serial.println("Display FAILED");
811
+ Out.println("Display FAILED");
624
812
  delay(3000);
625
813
  }
626
814
 
@@ -633,22 +821,24 @@ void setup() {
633
821
 
634
822
  drawStandby();
635
823
  lastMsgTime = millis();
636
- Serial.println("DISCSTATION_READY");
824
+ lastInputTime = millis();
825
+ Out.println("DISCSTATION_READY");
637
826
  }
638
827
 
639
828
  void handleSelectPress(bool longPress) {
640
829
  wakeDisplay();
830
+ lastInputTime = millis();
641
831
  if (uiState == UI_HOME) {
642
832
  if (longPress) {
643
- Serial.println("EJECT");
833
+ Out.println("EJECT");
644
834
  line1 = "Ejecting...";
645
835
  line2 = "";
646
836
  line3 = "";
647
837
  returnToHomeAt = millis() + 5000;
648
838
  drawStatus();
649
839
  } else if (homeIndex >= 0 && homeIndex < homeCount) {
650
- Serial.print("SELECT:");
651
- Serial.println(homeModes[homeIndex]);
840
+ Out.print("SELECT:");
841
+ Out.println(homeModes[homeIndex]);
652
842
  line1 = "Selected";
653
843
  line2 = homeModes[homeIndex];
654
844
  line3 = "";
@@ -656,7 +846,7 @@ void handleSelectPress(bool longPress) {
656
846
  }
657
847
 
658
848
  } else if (uiState == UI_STANDBY) {
659
- Serial.println("EJECT");
849
+ Out.println("EJECT");
660
850
  line1 = "Ejecting...";
661
851
  line2 = "";
662
852
  line3 = "";
@@ -668,16 +858,16 @@ void handleSelectPress(bool longPress) {
668
858
 
669
859
  } else if (uiState == UI_IP) {
670
860
  if (longPress) {
671
- Serial.println("CANCEL");
861
+ Out.println("CANCEL");
672
862
  drawHome();
673
863
  }
674
864
 
675
865
  } else if (uiState == UI_WAITING) {
676
866
  if (longPress) {
677
- Serial.println("CANCEL");
867
+ Out.println("CANCEL");
678
868
  drawHome();
679
869
  } else {
680
- Serial.println("CONFIRM");
870
+ Out.println("CONFIRM");
681
871
  line1 = "Confirmed!";
682
872
  line2 = "";
683
873
  line3 = "";
@@ -686,8 +876,8 @@ void handleSelectPress(bool longPress) {
686
876
 
687
877
  } else if (uiState == UI_BURN_READY) {
688
878
  if (longPress) {
689
- Serial.print("START:");
690
- Serial.println(BURN_MODES[burnModeIndex]);
879
+ Out.print("START:");
880
+ Out.println(BURN_MODES[burnModeIndex]);
691
881
  line1 = "Starting...";
692
882
  line2 = BURN_MODES[burnModeIndex];
693
883
  line3 = "";
@@ -699,7 +889,7 @@ void handleSelectPress(bool longPress) {
699
889
 
700
890
  } else if (uiState == UI_STATUS) {
701
891
  if (longPress) {
702
- Serial.println("CANCEL");
892
+ Out.println("CANCEL");
703
893
  line1 = "Cancelling...";
704
894
  line2 = "";
705
895
  line3 = "";
@@ -707,22 +897,23 @@ void handleSelectPress(bool longPress) {
707
897
  }
708
898
 
709
899
  } else if (uiState == UI_DISCONNECTED) {
710
- Serial.println("CANCEL");
900
+ Out.println("CANCEL");
711
901
  drawStandby();
712
902
 
713
903
  } else if (uiState == UI_PLAY) {
714
904
  ffSpeedIndex = 0;
715
905
  rewSpeedIndex = 0;
716
906
  if (longPress) {
717
- Serial.println("PLAY_STOP");
907
+ Out.println("PLAY_STOP");
718
908
  } else {
719
- Serial.println("PLAY_BUTTON");
909
+ Out.println("PLAY_BUTTON");
720
910
  }
721
911
  }
722
912
  }
723
913
 
724
914
  void handleUp(bool longPress) {
725
915
  wakeDisplay();
916
+ lastInputTime = millis();
726
917
  if (uiState == UI_HOME) {
727
918
  if (homeCount > 0) {
728
919
  homeIndex = (homeIndex - 1 + homeCount) % homeCount;
@@ -732,8 +923,8 @@ void handleUp(bool longPress) {
732
923
  } else if (uiState == UI_BURN_READY) {
733
924
  if (editingSpeed) {
734
925
  burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
735
- Serial.print("SPEED:");
736
- Serial.println(SPEED_MODES[burnSpeedIndex]);
926
+ Out.print("SPEED:");
927
+ Out.println(SPEED_MODES[burnSpeedIndex]);
737
928
  drawBurnReady();
738
929
  } else {
739
930
  burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
@@ -742,23 +933,24 @@ void handleUp(bool longPress) {
742
933
  }
743
934
  } else if (uiState == UI_PLAY) {
744
935
  if (audioPlayMode) {
745
- Serial.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
936
+ Out.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
746
937
  return;
747
938
  }
748
939
  rewSpeedIndex = 0;
749
940
  if (longPress) {
750
- Serial.println("FF:BIG");
941
+ Out.println("FF:BIG");
751
942
  } else {
752
943
  ffSpeedIndex = (ffSpeedIndex + 1) % 4;
753
944
  int seek_sec[] = {10, 20, 30, 60};
754
- Serial.print("FF:");
755
- Serial.println(seek_sec[ffSpeedIndex]);
945
+ Out.print("FF:");
946
+ Out.println(seek_sec[ffSpeedIndex]);
756
947
  }
757
948
  }
758
949
  }
759
950
 
760
951
  void handleDown(bool longPress) {
761
952
  wakeDisplay();
953
+ lastInputTime = millis();
762
954
  if (uiState == UI_HOME) {
763
955
  if (homeCount > 0) {
764
956
  homeIndex = (homeIndex + 1) % homeCount;
@@ -768,8 +960,8 @@ void handleDown(bool longPress) {
768
960
  } else if (uiState == UI_BURN_READY) {
769
961
  if (editingSpeed) {
770
962
  burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
771
- Serial.print("SPEED:");
772
- Serial.println(SPEED_MODES[burnSpeedIndex]);
963
+ Out.print("SPEED:");
964
+ Out.println(SPEED_MODES[burnSpeedIndex]);
773
965
  drawBurnReady();
774
966
  } else {
775
967
  burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
@@ -778,22 +970,66 @@ void handleDown(bool longPress) {
778
970
  }
779
971
  } else if (uiState == UI_PLAY) {
780
972
  if (audioPlayMode) {
781
- Serial.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
973
+ Out.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
782
974
  return;
783
975
  }
784
976
  ffSpeedIndex = 0;
785
977
  if (longPress) {
786
- Serial.println("REW:BIG");
978
+ Out.println("REW:BIG");
787
979
  } else {
788
980
  rewSpeedIndex = (rewSpeedIndex + 1) % 4;
789
981
  int seek_sec[] = {10, 20, 30, 60};
790
- Serial.print("REW:");
791
- Serial.println(seek_sec[rewSpeedIndex]);
982
+ Out.print("REW:");
983
+ Out.println(seek_sec[rewSpeedIndex]);
792
984
  }
793
985
  }
794
986
  }
795
987
 
796
988
  void loop() {
989
+ if (!wifiInitDone && millis() > 3000) {
990
+ wifiInitDone = true;
991
+ initWiFi();
992
+ }
993
+
994
+ if (credsJustSaved) {
995
+ drawWifiReset();
996
+ delay(600);
997
+ ESP.restart();
998
+ }
999
+
1000
+ if (portalActive) {
1001
+ wm.process();
1002
+ if (WiFi.status() == WL_CONNECTED) onWifiUp();
1003
+ }
1004
+
1005
+ if (wifiConnected && WiFi.status() != WL_CONNECTED) {
1006
+ if (wifiDropAt == 0) wifiDropAt = millis();
1007
+ else if (millis() - wifiDropAt > WIFI_RETRY_MS) {
1008
+ wifiDropAt = millis();
1009
+ WiFi.disconnect();
1010
+ WiFi.begin();
1011
+ }
1012
+ } else if (wifiConnected) {
1013
+ wifiDropAt = 0;
1014
+ }
1015
+
1016
+ if (wifiConnected && WiFi.status() == WL_CONNECTED) {
1017
+ if (otaEnabled) ArduinoOTA.handle();
1018
+ if (tcpServer.hasClient()) {
1019
+ if (tcpClient) tcpClient.stop();
1020
+ tcpClient = tcpServer.available();
1021
+ Out.setClient(&tcpClient);
1022
+ }
1023
+ if (tcpClient && !tcpClient.connected()) {
1024
+ tcpClient.stop();
1025
+ Out.setClient(nullptr);
1026
+ }
1027
+ if (tcpClient && tcpClient.available()) {
1028
+ String tmsg = tcpClient.readStringUntil('\n');
1029
+ if (tmsg.length() > 0) parseMessage(tmsg);
1030
+ }
1031
+ }
1032
+
797
1033
  if (Serial.available()) {
798
1034
  String msg = Serial.readStringUntil('\n');
799
1035
  parseMessage(msg);
@@ -814,8 +1050,8 @@ void loop() {
814
1050
  int nextVolume = readPercent();
815
1051
  if (abs(nextVolume - playVolume) >= 3) {
816
1052
  playVolume = nextVolume;
817
- Serial.print("POT:");
818
- Serial.println(playVolume);
1053
+ Out.print("POT:");
1054
+ Out.println(playVolume);
819
1055
  drawPlay();
820
1056
  }
821
1057
  }
@@ -825,11 +1061,26 @@ void loop() {
825
1061
  if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
826
1062
  selectDown = true;
827
1063
  selectDownAt = millis();
1064
+ wifiResetArmed = false;
1065
+ }
1066
+ // 10s hold on HOME/SETUP/STANDBY = wipe Wi-Fi creds + reboot to portal.
1067
+ if (sel && selectDown && !wifiResetArmed &&
1068
+ (uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
1069
+ millis() - selectDownAt >= WIFI_RESET_HOLD_MS) {
1070
+ wifiResetArmed = true;
1071
+ Out.println("WiFi: creds wiped, rebooting");
1072
+ clearCreds();
1073
+ drawWifiReset();
1074
+ delay(800);
1075
+ ESP.restart();
828
1076
  }
829
1077
  if (!sel && selectDown) {
830
1078
  selectDown = false;
831
1079
  selectLastDebounce = millis();
832
- handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1080
+ if (!wifiResetArmed) {
1081
+ handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1082
+ }
1083
+ wifiResetArmed = false;
833
1084
  }
834
1085
  }
835
1086
 
@@ -878,8 +1129,9 @@ void loop() {
878
1129
  if (!displayBlank) drawLoading();
879
1130
  }
880
1131
 
881
- if (displayOk && uiState == UI_STANDBY && !displayBlank &&
882
- (long)(millis() - standbyStartTime) >= STANDBY_BLANK_MS) {
1132
+ if (displayOk && !displayBlank &&
1133
+ (uiState == UI_HOME || uiState == UI_STANDBY) &&
1134
+ (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
883
1135
  display.ssd1306_command(0xAE);
884
1136
  displayBlank = true;
885
1137
  digitalWrite(LED_POWER_PIN, LOW);
@@ -0,0 +1,14 @@
1
+ // Optional build-time Wi-Fi override for the ESP32-C6 remote.
2
+ //
3
+ // cp secrets.h.example secrets.h (secrets.h is git-ignored)
4
+ //
5
+ // If WIFI_SSID is defined, the firmware connects straight to this network and
6
+ // the runtime setup portal is skipped entirely. Leave secrets.h absent to use
7
+ // the normal captive-portal provisioning ("DiscStation-XXXX" AP).
8
+
9
+ #define WIFI_SSID "YourNetwork"
10
+ #define WIFI_PASS "YourPassword"
11
+
12
+ // Optional: enable Arduino OTA updates with this password. Omit to keep OTA off
13
+ // (recommended unless you actually use it).
14
+ // #define OTA_PASSWORD "change-me"