discstation 0.1.22 → 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.
@@ -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
@@ -23,9 +34,14 @@
23
34
  #define POT_READ_MS 200
24
35
  #define DONE_RESET_MS 30000
25
36
  #define STANDBY_BLANK_MS 60000
37
+ #define IDLE_BLANK_MS 45000 // blank the OLED after this long with no input on HOME/STANDBY
26
38
  #define PING_TIMEOUT_MS 30000
27
39
  #define LED_BLINK_MS 250
28
40
 
41
+ #define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
42
+ #define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
43
+ #define WIFI_RETRY_MS 30000 // if a live link drops, force a re-join after this
44
+
29
45
  #define MAX_HOME_MODES 5
30
46
  #define BURN_COUNT 4
31
47
  #define SPEED_COUNT 4
@@ -36,8 +52,17 @@ String homeModes[MAX_HOME_MODES] = {"BURN", "PLAY", "RIP"};
36
52
 
37
53
  WiFiServer tcpServer(TCP_PORT);
38
54
  WiFiClient tcpClient;
55
+ WiFiManager wm;
56
+ Preferences prefs;
39
57
  bool wifiConnected = false;
40
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";
41
66
 
42
67
  class DualPrint : public Print {
43
68
  public:
@@ -56,23 +81,105 @@ private:
56
81
  WiFiClient* _client = nullptr;
57
82
  } Out;
58
83
 
84
+ void drawSetup(); // fwd decls (defined with the other draw* below)
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); // also wipe the ESP's own persisted creds
114
+ }
115
+
116
+ void wmSaveCallback() {
117
+ saveCreds(wm.getWiFiSSID(), wm.getWiFiPass());
118
+ credsJustSaved = true; // loop() reboots cleanly into STA mode
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()); // open AP at 192.168.4.1
147
+ drawSetup();
148
+ }
149
+
59
150
  void initWiFi() {
60
- Out.print("WiFi Tez...");
151
+ apName = "DiscStation-" + deviceSuffix();
152
+ mdnsHost = "discstation-" + deviceSuffix();
153
+ mdnsHost.toLowerCase();
154
+
61
155
  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);
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) {
172
+ esp_task_wdt_reset();
173
+ delay(200);
174
+ }
66
175
  }
176
+
67
177
  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());
178
+ Out.println(" OK");
179
+ onWifiUp();
74
180
  } else {
75
- Out.println(" fail");
181
+ if (ssid.length() > 0) Out.println(" fail (2.4GHz band / wrong password?)");
182
+ startPortal();
76
183
  }
77
184
  }
78
185
  String discName = "";
@@ -89,7 +196,8 @@ enum UiState {
89
196
  UI_STANDBY,
90
197
  UI_WAITING,
91
198
  UI_IP,
92
- UI_DISCONNECTED
199
+ UI_DISCONNECTED,
200
+ UI_SETUP
93
201
  };
94
202
 
95
203
  const char* BURN_MODES[BURN_COUNT] = {"AUTO", "BEST", "LONG", "TEST"};
@@ -143,6 +251,7 @@ bool editingSpeed = false;
143
251
  int playVolume = 50;
144
252
  unsigned long standbyStartTime = 0;
145
253
  unsigned long lastMsgTime = 0;
254
+ unsigned long lastInputTime = 0; // last button press / screen change; drives OLED idle-blank
146
255
  bool displayBlank = false;
147
256
  bool audioPlayMode = false;
148
257
  int displayRotation = 0;
@@ -210,6 +319,7 @@ void drawHeader() {
210
319
  void drawHome() {
211
320
  uiState = UI_HOME;
212
321
  returnToHomeAt = 0;
322
+ lastInputTime = millis();
213
323
  if (!displayOk) return;
214
324
 
215
325
  display.clearDisplay();
@@ -360,9 +470,13 @@ void drawIP() {
360
470
  }
361
471
 
362
472
  void drawStandby() {
473
+ // While the Wi-Fi setup portal is up and the appliance is otherwise idle,
474
+ // the standby screen doubles as the setup instructions.
475
+ if (portalActive) { drawSetup(); return; }
363
476
  uiState = UI_STANDBY;
364
477
  returnToHomeAt = 0;
365
478
  standbyStartTime = millis();
479
+ lastInputTime = millis();
366
480
  displayBlank = false;
367
481
  if (!displayOk) return;
368
482
 
@@ -375,6 +489,39 @@ void drawStandby() {
375
489
  display.display();
376
490
  }
377
491
 
492
+ void drawSetup() {
493
+ uiState = UI_SETUP;
494
+ returnToHomeAt = 0;
495
+ displayBlank = false;
496
+ lastInputTime = millis();
497
+ if (!displayOk) return;
498
+
499
+ display.clearDisplay();
500
+ drawHeader();
501
+ display.setCursor(2, 16);
502
+ display.print("WIFI SETUP");
503
+ display.setCursor(2, 28);
504
+ display.print("JOIN:");
505
+ display.setCursor(2, 38);
506
+ printUpper(apName);
507
+ display.setCursor(2, 50);
508
+ display.print("THEN 192.168.4.1");
509
+ display.display();
510
+ }
511
+
512
+ void drawWifiReset() {
513
+ uiState = UI_SETUP;
514
+ displayBlank = false;
515
+ if (!displayOk) return;
516
+ display.clearDisplay();
517
+ drawHeader();
518
+ display.setCursor(2, 25);
519
+ display.print("WIFI RESET");
520
+ display.setCursor(2, 40);
521
+ display.print("REBOOTING...");
522
+ display.display();
523
+ }
524
+
378
525
  void drawDisconnected() {
379
526
  uiState = UI_DISCONNECTED;
380
527
  returnToHomeAt = 0;
@@ -403,6 +550,7 @@ void wakeDisplay() {
403
550
  case UI_WAITING: drawWaiting(); break;
404
551
  case UI_STANDBY: drawStandby(); break;
405
552
  case UI_DISCONNECTED: drawDisconnected(); break;
553
+ case UI_SETUP: drawSetup(); break;
406
554
  }
407
555
  }
408
556
 
@@ -602,6 +750,7 @@ void parseMessage(String msg) {
602
750
  }
603
751
 
604
752
  void setup() {
753
+ setCpuFrequencyMhz(160); // 240 -> 160: ~halves CPU power, Wi-Fi/I2C/OTA all fine at 160
605
754
  Serial.begin(115200);
606
755
  esp_task_wdt_add(NULL);
607
756
  Wire.begin(21, 22);
@@ -639,11 +788,13 @@ void setup() {
639
788
 
640
789
  drawStandby();
641
790
  lastMsgTime = millis();
791
+ lastInputTime = millis();
642
792
  Out.println("DISCSTATION_READY");
643
793
  }
644
794
 
645
795
  void handleSelectPress(bool longPress) {
646
796
  wakeDisplay();
797
+ lastInputTime = millis();
647
798
  if (uiState == UI_HOME) {
648
799
  if (longPress) {
649
800
  Out.println("EJECT");
@@ -733,6 +884,7 @@ void handleSelectPress(bool longPress) {
733
884
 
734
885
  void handleUp(bool longPress) {
735
886
  wakeDisplay();
887
+ lastInputTime = millis();
736
888
  if (uiState == UI_HOME) {
737
889
  if (homeCount > 0) {
738
890
  homeIndex = (homeIndex - 1 + homeCount) % homeCount;
@@ -772,6 +924,7 @@ void handleUp(bool longPress) {
772
924
 
773
925
  void handleDown(bool longPress) {
774
926
  wakeDisplay();
927
+ lastInputTime = millis();
775
928
  if (uiState == UI_HOME) {
776
929
  if (homeCount > 0) {
777
930
  homeIndex = (homeIndex + 1) % homeCount;
@@ -814,8 +967,33 @@ void loop() {
814
967
  wifiInitDone = true;
815
968
  initWiFi();
816
969
  }
817
- if (wifiConnected) {
818
- ArduinoOTA.handle();
970
+
971
+ if (credsJustSaved) { // portal saved new creds -> reboot into clean STA
972
+ drawWifiReset();
973
+ delay(600);
974
+ ESP.restart();
975
+ }
976
+
977
+ if (portalActive) {
978
+ wm.process();
979
+ if (WiFi.status() == WL_CONNECTED) onWifiUp();
980
+ }
981
+
982
+ // Live link dropped: WiFi.setAutoReconnect handles most; if still down after
983
+ // WIFI_RETRY_MS, force a fresh join.
984
+ if (wifiConnected && WiFi.status() != WL_CONNECTED) {
985
+ if (wifiDropAt == 0) wifiDropAt = millis();
986
+ else if (millis() - wifiDropAt > WIFI_RETRY_MS) {
987
+ wifiDropAt = millis();
988
+ WiFi.disconnect();
989
+ WiFi.begin();
990
+ }
991
+ } else if (wifiConnected) {
992
+ wifiDropAt = 0;
993
+ }
994
+
995
+ if (wifiConnected && WiFi.status() == WL_CONNECTED) {
996
+ if (otaEnabled) ArduinoOTA.handle();
819
997
  if (tcpServer.hasClient()) {
820
998
  if (tcpClient) tcpClient.stop();
821
999
  tcpClient = tcpServer.available();
@@ -865,11 +1043,27 @@ void loop() {
865
1043
  if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
866
1044
  selectDown = true;
867
1045
  selectDownAt = millis();
1046
+ wifiResetArmed = false;
1047
+ }
1048
+ // 10s hold on HOME (or SETUP) = wipe Wi-Fi creds + reboot to portal. Fires
1049
+ // while still held so the eventual release doesn't also trigger EJECT.
1050
+ if (sel && selectDown && !wifiResetArmed &&
1051
+ (uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
1052
+ millis() - selectDownAt >= WIFI_RESET_HOLD_MS) {
1053
+ wifiResetArmed = true;
1054
+ Out.println("WiFi: creds wiped, rebooting");
1055
+ clearCreds();
1056
+ drawWifiReset();
1057
+ delay(800);
1058
+ ESP.restart();
868
1059
  }
869
1060
  if (!sel && selectDown) {
870
1061
  selectDown = false;
871
1062
  selectLastDebounce = millis();
872
- handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1063
+ if (!wifiResetArmed) {
1064
+ handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1065
+ }
1066
+ wifiResetArmed = false;
873
1067
  }
874
1068
  }
875
1069
 
@@ -915,9 +1109,10 @@ void loop() {
915
1109
  if (!displayBlank) drawStatus();
916
1110
  }
917
1111
 
918
- // --- Standby blanking ---
919
- if (displayOk && uiState == UI_STANDBY && !displayBlank &&
920
- (long)(millis() - standbyStartTime) >= STANDBY_BLANK_MS) {
1112
+ // --- OLED idle blanking (HOME + STANDBY) ---
1113
+ if (displayOk && !displayBlank &&
1114
+ (uiState == UI_HOME || uiState == UI_STANDBY) &&
1115
+ (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
921
1116
  display.ssd1306_command(0xAE);
922
1117
  displayBlank = true;
923
1118
  digitalWrite(LED_POWER_PIN, LOW);
@@ -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.22",
3
+ "version": "0.1.26",
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)
@@ -0,0 +1,14 @@
1
+ import { networkInterfaces } from 'node:os';
2
+
3
+ /** First non-internal IPv4 LAN address, so other devices on the network know
4
+ * what to open — mirrors discstation.py's local_ip(). Falls back to
5
+ * 'localhost' if nothing's found (e.g. no network connection). */
6
+ export function lanIp() {
7
+ const nets = networkInterfaces();
8
+ for (const name of Object.keys(nets)) {
9
+ for (const net of nets[name] || []) {
10
+ if (net.family === 'IPv4' && !net.internal) return net.address;
11
+ }
12
+ }
13
+ return 'localhost';
14
+ }
package/scripts/open.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
7
  import { get } from 'node:http';
8
+ import { lanIp } from './lib/net.mjs';
8
9
 
9
10
  const port = process.env.DISCSTATION_HTTP_PORT || '8081';
10
11
  const url = process.argv[2] || `http://localhost:${port}/`;
@@ -24,6 +25,7 @@ function open(target) {
24
25
  // Best-effort liveness check so a stopped host gives a clear hint.
25
26
  const probe = get(url, { timeout: 1500 }, (res) => {
26
27
  res.resume();
28
+ console.log(`Also reachable at http://${lanIp()}:${port}/ from any phone/computer on your network.`);
27
29
  open(url);
28
30
  });
29
31
  probe.on('timeout', () => probe.destroy());
package/scripts/setup.mjs CHANGED
@@ -8,6 +8,7 @@ import { spawnSync } from 'node:child_process';
8
8
  import { existsSync } from 'node:fs';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { lanIp } from './lib/net.mjs';
11
12
 
12
13
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
14
  const args = process.argv.slice(2);
@@ -45,9 +46,11 @@ function run(cmd, cmdArgs) {
45
46
  }
46
47
  if ((r.status ?? 1) === 0) {
47
48
  const port = process.env.DISCSTATION_HTTP_PORT || '8081';
49
+ const ip = lanIp();
48
50
  console.log(
49
51
  `\nDiscStation is installed and running.\n` +
50
- ` Web UI: http://localhost:${port}/ (run "discstation" to open it any time)\n` +
52
+ ` Web UI: http://${ip}:${port}/ (run "discstation" to open it any time)\n` +
53
+ ` Open that address from any phone/computer on your network too.\n` +
51
54
  ` Install it as an app from the browser menu, or the in-page INSTALL APP button.\n`,
52
55
  );
53
56
  spawnSync(process.execPath, [join(ROOT, 'scripts', 'open.mjs')], { stdio: 'ignore' });