discstation 0.1.23 → 0.1.27

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.
@@ -3,9 +3,20 @@
3
3
  #include <Adafruit_SSD1306.h>
4
4
  #include "esp_task_wdt.h"
5
5
  #include <WiFi.h>
6
+ #include "esp_mac.h"
7
+ #include <WiFiManager.h>
8
+ #include <ESPmDNS.h>
9
+ #include <Preferences.h>
6
10
  #include <ArduinoOTA.h>
7
11
  #include <QRCode.h>
8
12
 
13
+ // Optional build-time Wi-Fi override for power users: copy secrets.h.example
14
+ // to secrets.h (git-ignored) and set WIFI_SSID / WIFI_PASS / OTA_PASSWORD.
15
+ // If WIFI_SSID is defined the runtime setup portal is skipped entirely.
16
+ #if __has_include("secrets.h")
17
+ #include "secrets.h"
18
+ #endif
19
+
9
20
  #define SCREEN_WIDTH 128
10
21
  #define SCREEN_HEIGHT 64
11
22
  #define OLED_RESET -1
@@ -14,8 +25,6 @@
14
25
  #define BTN_SELECT_PIN 14
15
26
  #define BTN_UP_PIN 13
16
27
  #define BTN_DOWN_PIN 15
17
- #define LED_POWER_PIN 32
18
- #define LED_ACT_PIN 33
19
28
  #define POT_PIN 34
20
29
 
21
30
  #define DEBOUNCE_MS 50
@@ -23,8 +32,12 @@
23
32
  #define POT_READ_MS 200
24
33
  #define DONE_RESET_MS 30000
25
34
  #define STANDBY_BLANK_MS 60000
35
+ #define IDLE_BLANK_MS 45000 // blank the OLED after this long with no input on HOME/STANDBY
26
36
  #define PING_TIMEOUT_MS 30000
27
- #define LED_BLINK_MS 250
37
+
38
+ #define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
39
+ #define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
40
+ #define WIFI_RETRY_MS 15000 // if a live link drops, force a re-join after this
28
41
 
29
42
  #define MAX_HOME_MODES 5
30
43
  #define BURN_COUNT 4
@@ -36,8 +49,17 @@ String homeModes[MAX_HOME_MODES] = {"BURN", "PLAY", "RIP"};
36
49
 
37
50
  WiFiServer tcpServer(TCP_PORT);
38
51
  WiFiClient tcpClient;
52
+ WiFiManager wm;
53
+ Preferences prefs;
39
54
  bool wifiConnected = false;
40
55
  bool wifiInitDone = false;
56
+ bool portalActive = false;
57
+ bool otaEnabled = false;
58
+ bool credsJustSaved = false;
59
+ bool wifiResetArmed = false;
60
+ unsigned long wifiDropAt = 0;
61
+ String apName = "DiscStation";
62
+ String mdnsHost = "discstation";
41
63
 
42
64
  class DualPrint : public Print {
43
65
  public:
@@ -56,23 +78,105 @@ private:
56
78
  WiFiClient* _client = nullptr;
57
79
  } Out;
58
80
 
81
+ void drawSetup(); // fwd decls (defined with the other draw* below)
82
+ void drawWifiReset();
83
+
84
+ String deviceSuffix() {
85
+ uint8_t mac[6];
86
+ esp_read_mac(mac, ESP_MAC_WIFI_STA); // factory MAC from eFuse - valid before the Wi-Fi driver starts
87
+ char buf[5];
88
+ snprintf(buf, sizeof(buf), "%02X%02X", mac[4], mac[5]);
89
+ return String(buf);
90
+ }
91
+
92
+ void loadCreds(String& ssid, String& pass) {
93
+ prefs.begin("discstation", true);
94
+ ssid = prefs.getString("ssid", "");
95
+ pass = prefs.getString("pass", "");
96
+ prefs.end();
97
+ }
98
+
99
+ void saveCreds(const String& ssid, const String& pass) {
100
+ prefs.begin("discstation", false);
101
+ prefs.putString("ssid", ssid);
102
+ prefs.putString("pass", pass);
103
+ prefs.end();
104
+ }
105
+
106
+ void clearCreds() {
107
+ prefs.begin("discstation", false);
108
+ prefs.clear();
109
+ prefs.end();
110
+ WiFi.disconnect(true, true); // also wipe the ESP's own persisted creds
111
+ }
112
+
113
+ void wmSaveCallback() {
114
+ saveCreds(wm.getWiFiSSID(), wm.getWiFiPass());
115
+ credsJustSaved = true; // loop() reboots cleanly into STA mode
116
+ }
117
+
118
+ void onWifiUp() {
119
+ portalActive = false;
120
+ wifiConnected = true;
121
+ wifiDropAt = 0;
122
+ WiFi.setSleep(WIFI_PS_MIN_MODEM);
123
+ tcpServer.begin();
124
+ if (MDNS.begin(mdnsHost.c_str())) {
125
+ MDNS.addService("discstation", "tcp", TCP_PORT);
126
+ }
127
+ #ifdef OTA_PASSWORD
128
+ ArduinoOTA.setHostname(mdnsHost.c_str());
129
+ ArduinoOTA.setPassword(OTA_PASSWORD);
130
+ ArduinoOTA.begin();
131
+ otaEnabled = true;
132
+ #endif
133
+ Out.print("WiFi OK "); Out.println(WiFi.localIP());
134
+ }
135
+
136
+ void startPortal() {
137
+ Out.print("WiFi: setup portal '"); Out.print(apName); Out.println("'");
138
+ portalActive = true;
139
+ WiFi.mode(WIFI_AP_STA);
140
+ wm.setConfigPortalBlocking(false);
141
+ wm.setConfigPortalTimeout(0);
142
+ wm.setSaveConfigCallback(wmSaveCallback);
143
+ wm.startConfigPortal(apName.c_str()); // open AP at 192.168.4.1
144
+ drawSetup();
145
+ }
146
+
59
147
  void initWiFi() {
60
- Out.print("WiFi Tez...");
148
+ apName = "DiscStation-" + deviceSuffix();
149
+ mdnsHost = "discstation-" + deviceSuffix();
150
+ mdnsHost.toLowerCase();
151
+
61
152
  WiFi.mode(WIFI_STA);
62
- WiFi.begin("Tez", "Dellwin8#$");
63
- for (int i = 0; i < 80; i++) {
64
- if (WiFi.status() == WL_CONNECTED) break;
65
- delay(250);
153
+ WiFi.setAutoReconnect(true);
154
+ WiFi.setSleep(WIFI_PS_MIN_MODEM);
155
+
156
+ String ssid, pass;
157
+ #ifdef WIFI_SSID
158
+ ssid = WIFI_SSID;
159
+ pass = WIFI_PASS;
160
+ #else
161
+ loadCreds(ssid, pass);
162
+ #endif
163
+
164
+ if (ssid.length() > 0) {
165
+ Out.print("WiFi "); Out.print(ssid); Out.print("...");
166
+ WiFi.begin(ssid.c_str(), pass.c_str());
167
+ unsigned long t0 = millis();
168
+ while (WiFi.status() != WL_CONNECTED && millis() - t0 < WIFI_CONNECT_TIMEOUT_MS) {
169
+ esp_task_wdt_reset();
170
+ delay(200);
171
+ }
66
172
  }
173
+
67
174
  if (WiFi.status() == WL_CONNECTED) {
68
- wifiConnected = true;
69
- tcpServer.begin();
70
- ArduinoOTA.begin();
71
- ArduinoOTA.setHostname("discstation-v1");
72
- ArduinoOTA.setPassword("dvdstation");
73
- Out.print(" OK "); Out.println(WiFi.localIP());
175
+ Out.println(" OK");
176
+ onWifiUp();
74
177
  } else {
75
- Out.println(" fail");
178
+ if (ssid.length() > 0) Out.println(" fail (2.4GHz band / wrong password?)");
179
+ startPortal();
76
180
  }
77
181
  }
78
182
  String discName = "";
@@ -89,7 +193,8 @@ enum UiState {
89
193
  UI_STANDBY,
90
194
  UI_WAITING,
91
195
  UI_IP,
92
- UI_DISCONNECTED
196
+ UI_DISCONNECTED,
197
+ UI_SETUP
93
198
  };
94
199
 
95
200
  const char* BURN_MODES[BURN_COUNT] = {"AUTO", "BEST", "LONG", "TEST"};
@@ -133,9 +238,6 @@ unsigned long downLastDebounce = 0;
133
238
  int ffSpeedIndex = 0;
134
239
  int rewSpeedIndex = 0;
135
240
 
136
- // LED state
137
- bool actLedState = false;
138
- unsigned long lastLedBlink = 0;
139
241
  int homeIndex = 0;
140
242
  int burnModeIndex = 0;
141
243
  int burnSpeedIndex = 0;
@@ -143,6 +245,7 @@ bool editingSpeed = false;
143
245
  int playVolume = 50;
144
246
  unsigned long standbyStartTime = 0;
145
247
  unsigned long lastMsgTime = 0;
248
+ unsigned long lastInputTime = 0; // last button press / screen change; drives OLED idle-blank
146
249
  bool displayBlank = false;
147
250
  bool audioPlayMode = false;
148
251
  int displayRotation = 0;
@@ -210,6 +313,7 @@ void drawHeader() {
210
313
  void drawHome() {
211
314
  uiState = UI_HOME;
212
315
  returnToHomeAt = 0;
316
+ lastInputTime = millis();
213
317
  if (!displayOk) return;
214
318
 
215
319
  display.clearDisplay();
@@ -360,9 +464,13 @@ void drawIP() {
360
464
  }
361
465
 
362
466
  void drawStandby() {
467
+ // While the Wi-Fi setup portal is up and the appliance is otherwise idle,
468
+ // the standby screen doubles as the setup instructions.
469
+ if (portalActive) { drawSetup(); return; }
363
470
  uiState = UI_STANDBY;
364
471
  returnToHomeAt = 0;
365
472
  standbyStartTime = millis();
473
+ lastInputTime = millis();
366
474
  displayBlank = false;
367
475
  if (!displayOk) return;
368
476
 
@@ -375,6 +483,39 @@ void drawStandby() {
375
483
  display.display();
376
484
  }
377
485
 
486
+ void drawSetup() {
487
+ uiState = UI_SETUP;
488
+ returnToHomeAt = 0;
489
+ displayBlank = false;
490
+ lastInputTime = millis();
491
+ if (!displayOk) return;
492
+
493
+ display.clearDisplay();
494
+ drawHeader();
495
+ display.setCursor(2, 16);
496
+ display.print("WIFI SETUP");
497
+ display.setCursor(2, 28);
498
+ display.print("JOIN:");
499
+ display.setCursor(2, 38);
500
+ printUpper(apName);
501
+ display.setCursor(2, 50);
502
+ display.print("THEN 192.168.4.1");
503
+ display.display();
504
+ }
505
+
506
+ void drawWifiReset() {
507
+ uiState = UI_SETUP;
508
+ displayBlank = false;
509
+ if (!displayOk) return;
510
+ display.clearDisplay();
511
+ drawHeader();
512
+ display.setCursor(2, 25);
513
+ display.print("WIFI RESET");
514
+ display.setCursor(2, 40);
515
+ display.print("REBOOTING...");
516
+ display.display();
517
+ }
518
+
378
519
  void drawDisconnected() {
379
520
  uiState = UI_DISCONNECTED;
380
521
  returnToHomeAt = 0;
@@ -403,6 +544,7 @@ void wakeDisplay() {
403
544
  case UI_WAITING: drawWaiting(); break;
404
545
  case UI_STANDBY: drawStandby(); break;
405
546
  case UI_DISCONNECTED: drawDisconnected(); break;
547
+ case UI_SETUP: drawSetup(); break;
406
548
  }
407
549
  }
408
550
 
@@ -602,16 +744,13 @@ void parseMessage(String msg) {
602
744
  }
603
745
 
604
746
  void setup() {
747
+ setCpuFrequencyMhz(160); // 240 -> 160: ~halves CPU power, Wi-Fi/I2C/OTA all fine at 160
605
748
  Serial.begin(115200);
606
749
  esp_task_wdt_add(NULL);
607
750
  Wire.begin(21, 22);
608
751
  pinMode(BTN_SELECT_PIN, INPUT_PULLUP);
609
752
  pinMode(BTN_UP_PIN, INPUT_PULLUP);
610
753
  pinMode(BTN_DOWN_PIN, INPUT_PULLUP);
611
- pinMode(LED_POWER_PIN, OUTPUT);
612
- pinMode(LED_ACT_PIN, OUTPUT);
613
- digitalWrite(LED_POWER_PIN, HIGH);
614
- digitalWrite(LED_ACT_PIN, LOW);
615
754
  pinMode(POT_PIN, INPUT);
616
755
  analogReadResolution(12);
617
756
  analogSetPinAttenuation(POT_PIN, ADC_11db);
@@ -629,21 +768,15 @@ void setup() {
629
768
  delay(3000);
630
769
  }
631
770
 
632
- // Blink activity LED 3x to confirm it works
633
- for (int i = 0; i < 3; i++) {
634
- digitalWrite(LED_ACT_PIN, HIGH);
635
- delay(150);
636
- digitalWrite(LED_ACT_PIN, LOW);
637
- delay(150);
638
- }
639
-
640
771
  drawStandby();
641
772
  lastMsgTime = millis();
773
+ lastInputTime = millis();
642
774
  Out.println("DISCSTATION_READY");
643
775
  }
644
776
 
645
777
  void handleSelectPress(bool longPress) {
646
778
  wakeDisplay();
779
+ lastInputTime = millis();
647
780
  if (uiState == UI_HOME) {
648
781
  if (longPress) {
649
782
  Out.println("EJECT");
@@ -733,6 +866,7 @@ void handleSelectPress(bool longPress) {
733
866
 
734
867
  void handleUp(bool longPress) {
735
868
  wakeDisplay();
869
+ lastInputTime = millis();
736
870
  if (uiState == UI_HOME) {
737
871
  if (homeCount > 0) {
738
872
  homeIndex = (homeIndex - 1 + homeCount) % homeCount;
@@ -772,6 +906,7 @@ void handleUp(bool longPress) {
772
906
 
773
907
  void handleDown(bool longPress) {
774
908
  wakeDisplay();
909
+ lastInputTime = millis();
775
910
  if (uiState == UI_HOME) {
776
911
  if (homeCount > 0) {
777
912
  homeIndex = (homeIndex + 1) % homeCount;
@@ -814,8 +949,33 @@ void loop() {
814
949
  wifiInitDone = true;
815
950
  initWiFi();
816
951
  }
817
- if (wifiConnected) {
818
- ArduinoOTA.handle();
952
+
953
+ if (credsJustSaved) { // portal saved new creds -> reboot into clean STA
954
+ drawWifiReset();
955
+ delay(600);
956
+ ESP.restart();
957
+ }
958
+
959
+ if (portalActive) {
960
+ wm.process();
961
+ if (WiFi.status() == WL_CONNECTED) onWifiUp();
962
+ }
963
+
964
+ // Live link dropped: WiFi.setAutoReconnect handles most; if still down after
965
+ // WIFI_RETRY_MS, force a fresh join.
966
+ if (wifiConnected && WiFi.status() != WL_CONNECTED) {
967
+ if (wifiDropAt == 0) wifiDropAt = millis();
968
+ else if (millis() - wifiDropAt > WIFI_RETRY_MS) {
969
+ wifiDropAt = millis();
970
+ WiFi.disconnect();
971
+ WiFi.begin();
972
+ }
973
+ } else if (wifiConnected) {
974
+ wifiDropAt = 0;
975
+ }
976
+
977
+ if (wifiConnected && WiFi.status() == WL_CONNECTED) {
978
+ if (otaEnabled) ArduinoOTA.handle();
819
979
  if (tcpServer.hasClient()) {
820
980
  if (tcpClient) tcpClient.stop();
821
981
  tcpClient = tcpServer.available();
@@ -865,11 +1025,27 @@ void loop() {
865
1025
  if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
866
1026
  selectDown = true;
867
1027
  selectDownAt = millis();
1028
+ wifiResetArmed = false;
1029
+ }
1030
+ // 10s hold on HOME (or SETUP) = wipe Wi-Fi creds + reboot to portal. Fires
1031
+ // while still held so the eventual release doesn't also trigger EJECT.
1032
+ if (sel && selectDown && !wifiResetArmed &&
1033
+ (uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
1034
+ millis() - selectDownAt >= WIFI_RESET_HOLD_MS) {
1035
+ wifiResetArmed = true;
1036
+ Out.println("WiFi: creds wiped, rebooting");
1037
+ clearCreds();
1038
+ drawWifiReset();
1039
+ delay(800);
1040
+ ESP.restart();
868
1041
  }
869
1042
  if (!sel && selectDown) {
870
1043
  selectDown = false;
871
1044
  selectLastDebounce = millis();
872
- handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1045
+ if (!wifiResetArmed) {
1046
+ handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1047
+ }
1048
+ wifiResetArmed = false;
873
1049
  }
874
1050
  }
875
1051
 
@@ -915,15 +1091,12 @@ void loop() {
915
1091
  if (!displayBlank) drawStatus();
916
1092
  }
917
1093
 
918
- // --- Standby blanking ---
919
- if (displayOk && uiState == UI_STANDBY && !displayBlank &&
920
- (long)(millis() - standbyStartTime) >= STANDBY_BLANK_MS) {
1094
+ // --- OLED idle blanking (HOME + STANDBY) ---
1095
+ if (displayOk && !displayBlank &&
1096
+ (uiState == UI_HOME || uiState == UI_STANDBY) &&
1097
+ (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
921
1098
  display.ssd1306_command(0xAE);
922
1099
  displayBlank = true;
923
- digitalWrite(LED_POWER_PIN, LOW);
924
- }
925
- if (!displayBlank) {
926
- digitalWrite(LED_POWER_PIN, HIGH);
927
1100
  }
928
1101
 
929
1102
  // --- PING timeout (disconnected) ---
@@ -932,29 +1105,5 @@ void loop() {
932
1105
  drawDisconnected();
933
1106
  }
934
1107
 
935
- // --- Activity LED ---
936
- {
937
- bool ledOn = false;
938
- if (uiState == UI_BURN_READY || uiState == UI_PLAY || uiState == UI_WAITING || uiState == UI_IP) {
939
- ledOn = true;
940
- } else if (uiState == UI_STATUS) {
941
- if (progressPercent >= 0) {
942
- ledOn = (millis() - lastLedBlink > LED_BLINK_MS);
943
- if (ledOn) {
944
- lastLedBlink = millis();
945
- actLedState = !actLedState;
946
- }
947
- ledOn = actLedState;
948
- } else {
949
- ledOn = true;
950
- }
951
- } else if (uiState == UI_HOME || uiState == UI_STANDBY) {
952
- ledOn = false;
953
- } else if (uiState == UI_DISCONNECTED) {
954
- ledOn = true;
955
- }
956
- digitalWrite(LED_ACT_PIN, ledOn ? HIGH : LOW);
957
- }
958
-
959
1108
  delay(20);
960
1109
  }
@@ -0,0 +1,14 @@
1
+ // Optional build-time Wi-Fi override for the ESP32 DevKit 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"
@@ -7,6 +7,13 @@ YTDLP_FRAGMENT_RETRIES=3
7
7
  DISC_OUTPUT_LIMIT_BYTES=4300000000
8
8
  DISC_DL_OUTPUT_LIMIT_BYTES=8000000000
9
9
 
10
+ # Wi-Fi appliance remote. Default (unset) = "auto": if no USB cable is
11
+ # present, find the remote by mDNS / discstation.local. Set an IP or
12
+ # hostname to skip discovery (connects on TCP 2323); set "off" to never
13
+ # look. The remote's OLED shows its IP after you provision its Wi-Fi.
14
+ # DISC_REMOTE_HOST=192.168.1.50
15
+ # DISC_REMOTE_HOST=off
16
+
10
17
  # TMDb API key for video (DVD / burned movie) metadata lookup. Get a free key at
11
18
  # https://www.themoviedb.org/settings/api . Without it, ripped videos keep their
12
19
  # disc-label / filename naming. Alternatively put the key in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.23",
3
+ "version": "0.1.27",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
package/requirements.txt CHANGED
@@ -3,3 +3,4 @@ pyserial>=3.5
3
3
  mutagen>=1.45
4
4
  requests>=2.28
5
5
  yt-dlp>=2024.1.1
6
+ zeroconf>=0.131 # auto-discover a Wi-Fi appliance remote (DISC_REMOTE_HOST=auto, the default)