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.
@@ -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.23",
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)
@@ -114,9 +114,87 @@ class VirtualSerial:
114
114
  pass
115
115
 
116
116
 
117
+ class TcpSerial:
118
+ """serial.Serial look-alike over a TCP socket to the ESP32's Wi-Fi link
119
+ (firmware's WiFiServer on port 2323). Exposes the same tiny surface
120
+ station_loop and the flow functions use - write / readline / in_waiting /
121
+ read / close / setDTR - so nothing downstream knows it isn't a wire.
122
+ A dead link raises serial.SerialException from in_waiting/write, which is
123
+ what check_serial_alive() / main()'s reconnect loop already expect."""
124
+
125
+ def __init__(self, host, port=2323, connect_timeout=5):
126
+ if host.count(":") == 1 and not host.startswith("["): # "ip:port"
127
+ host, _, p = host.rpartition(":")
128
+ if p.isdigit():
129
+ port = int(p)
130
+ self._sock = socket.create_connection((host, port), timeout=connect_timeout)
131
+ self._sock.settimeout(0.05)
132
+ self._buf = b""
133
+ self._lock = threading.Lock()
134
+ self._alive = True
135
+ self._reader = threading.Thread(target=self._pump, daemon=True)
136
+ self._reader.start()
137
+
138
+ def _pump(self):
139
+ while self._alive:
140
+ try:
141
+ chunk = self._sock.recv(4096)
142
+ except socket.timeout:
143
+ continue
144
+ except OSError:
145
+ break
146
+ if not chunk:
147
+ break
148
+ with self._lock:
149
+ self._buf += chunk
150
+ self._alive = False
151
+
152
+ @property
153
+ def in_waiting(self):
154
+ if not self._alive:
155
+ raise serial.SerialException("Wi-Fi remote link closed")
156
+ with self._lock:
157
+ return len(self._buf)
158
+
159
+ def read(self, n=1):
160
+ with self._lock:
161
+ data, self._buf = self._buf[:n], self._buf[n:]
162
+ return data
163
+
164
+ def readline(self):
165
+ with self._lock:
166
+ idx = self._buf.find(b"\n")
167
+ if idx < 0:
168
+ data, self._buf = self._buf, b""
169
+ return data
170
+ line, self._buf = self._buf[:idx + 1], self._buf[idx + 1:]
171
+ return line
172
+
173
+ def write(self, data):
174
+ if not self._alive:
175
+ raise serial.SerialException("Wi-Fi remote link closed")
176
+ try:
177
+ self._sock.sendall(data)
178
+ return len(data) if data else 0
179
+ except OSError as e:
180
+ self._alive = False
181
+ raise serial.SerialException(f"Wi-Fi remote write failed: {e}") from e
182
+
183
+ def close(self):
184
+ self._alive = False
185
+ try:
186
+ self._sock.close()
187
+ except OSError:
188
+ pass
189
+
190
+ def setDTR(self, value):
191
+ pass
192
+
193
+
117
194
  class _HardwareAttached(Exception):
118
- """Raised out of station_loop when a real ESP32 appears while running on
119
- a VirtualSerial, so main() can hand control over to it."""
195
+ """Raised out of station_loop when a real link (USB serial or the Wi-Fi
196
+ remote) appears while running on a VirtualSerial, so main() can hand
197
+ control over to it."""
120
198
 
121
199
 
122
200
  class _WebHandler(http.server.BaseHTTPRequestHandler):
@@ -825,7 +903,12 @@ _line_buf = b""
825
903
  def read_serial_line(ser, timeout=0.1):
826
904
  global _line_buf
827
905
  deadline = time.monotonic() + timeout
828
- while time.monotonic() < deadline:
906
+ # Always make at least one non-blocking pass, even for timeout<=0 - the
907
+ # `remaining <= 0: break` at the bottom still ends it after that pass.
908
+ # (`while time.monotonic() < deadline` used to skip the body entirely for
909
+ # timeout=0, which is exactly how _check_cancel() calls this - so cancel
910
+ # detection during rips silently never read the port.)
911
+ while True:
829
912
  if _line_buf:
830
913
  _line_buf = _line_buf.lstrip(b'\r\n')
831
914
  if _line_buf:
@@ -870,6 +953,10 @@ def read_serial_line(ser, timeout=0.1):
870
953
  return None
871
954
 
872
955
 
956
+ # Let the burn pipeline's check_cancel() share this buffered reader.
957
+ discstation_burn.line_reader = read_serial_line
958
+
959
+
873
960
  def check_serial_alive(ser=None):
874
961
  """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
875
962
  reconnect loop can re-scan for the (possibly renumbered) serial port.
@@ -2526,6 +2613,15 @@ def _check_cancel(ser):
2526
2613
  return False
2527
2614
 
2528
2615
 
2616
+ def _raise_if_cancelled(ser):
2617
+ """Poll for a CANCEL press between blocking phases that have no
2618
+ subprocess loop of their own (metadata lookups, scans, cover-art
2619
+ downloads). Doesn't interrupt a call in progress, but catches the press
2620
+ the moment the phase returns."""
2621
+ if _check_cancel(ser):
2622
+ raise CancelError
2623
+
2624
+
2529
2625
  def iter_process_events(proc, idle_seconds=1.0, ser=None):
2530
2626
  lines = Queue()
2531
2627
  finished = object()
@@ -3221,7 +3317,13 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3221
3317
  except Exception:
3222
3318
  pass
3223
3319
 
3224
- proc = subprocess.Popen(run_as_desktop_user(cmd), env=env)
3320
+ # Discard mpv's own output. Its terminal status line ("A: 00:04 / 00:15
3321
+ # ...") prints several times a second; left inheriting our stdout it
3322
+ # floods journald until the pipe backs up and our own print()/status
3323
+ # writes block, wedging the whole play loop. We drive mpv over the IPC
3324
+ # socket, so none of that output is wanted.
3325
+ proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3326
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3225
3327
 
3226
3328
  try:
3227
3329
  if not wait_for_socket(MPV_SOCKET, proc):
@@ -3275,7 +3377,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3275
3377
  mpv_command(["set_property", "speed", 1.0])
3276
3378
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3277
3379
 
3278
- elif line == "PLAY_STOP":
3380
+ elif line in ("PLAY_STOP", "EJECT"):
3381
+ # EJECT during playback = stop first; the web remote has no
3382
+ # separate stop button, and without this the command is
3383
+ # silently dropped here and playback never ends.
3279
3384
  send(ser, "STATUS:Stopping play")
3280
3385
  discstation_burn.stop_process(proc)
3281
3386
  break
@@ -3760,7 +3865,10 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3760
3865
  time.sleep(3)
3761
3866
  return
3762
3867
 
3868
+ _raise_if_cancelled(ser)
3869
+ send(ser, "STATUS:Scanning disc...")
3763
3870
  scan = handbrake_scan(device)
3871
+ _raise_if_cancelled(ser)
3764
3872
  if scan:
3765
3873
  main = scan["main_feature"]
3766
3874
  mins = next((t["duration_s"] // 60 for t in scan["titles"] if t["index"] == main), 0)
@@ -3863,6 +3971,7 @@ def rip_video_disc(ser, device, kind):
3863
3971
  raise RuntimeError("No video files found")
3864
3972
 
3865
3973
  for index, src in enumerate(files, start=1):
3974
+ _raise_if_cancelled(ser)
3866
3975
  send(ser, f"PROGRESS:File {index}/{len(files)}")
3867
3976
  dest = out_dir / f"{index:02d} - {safe_path_name(src.stem)}.mpg"
3868
3977
  print(f"Ripping {src.name} -> {dest.name}")
@@ -3923,6 +4032,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3923
4032
  if len(wav_files) < len(chapters):
3924
4033
  raise RuntimeError(f"Only ripped {len(wav_files)}/{len(chapters)} tracks")
3925
4034
  for index, wav in enumerate(wav_files[:len(chapters)], start=1):
4035
+ _raise_if_cancelled(ser)
3926
4036
  chapter = chapters[index - 1]
3927
4037
  if metadata and index <= len(metadata["tracks"]):
3928
4038
  track_meta = metadata["tracks"][index - 1]
@@ -3958,8 +4068,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3958
4068
  metadata = None
3959
4069
  cover_path = None
3960
4070
 
4071
+ _raise_if_cancelled(ser)
3961
4072
  send(ser, "STATUS:Looking up CD...")
3962
4073
  metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
4074
+ _raise_if_cancelled(ser)
3963
4075
 
3964
4076
  if metadata:
3965
4077
  album_folder = safe_path_name(f"{metadata['album_artist']} - {metadata['album']}")
@@ -3978,6 +4090,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3978
4090
  out_dir,
3979
4091
  metadata.get("release_group_id"),
3980
4092
  )
4093
+ _raise_if_cancelled(ser)
3981
4094
 
3982
4095
  send(ser, "STATUS:Ripping audio CD")
3983
4096
  if metadata:
@@ -4222,14 +4335,15 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4222
4335
  last_ping = now
4223
4336
  safe_send(ser, "PING")
4224
4337
  if isinstance(ser, VirtualSerial):
4225
- # Safe point (no flow active) to check whether a real ESP32
4226
- # has appeared - hand off to it instead of the web remote.
4338
+ # Safe point (no flow active) to check whether a real link -
4339
+ # USB serial or the Wi-Fi remote - has appeared, and hand off
4340
+ # to it instead of the web remote.
4227
4341
  try:
4228
- port = discstation_host.serial_port()
4342
+ link = discstation_host.serial_port() or discstation_host.remote_host()
4229
4343
  except Exception:
4230
- port = None
4231
- if port:
4232
- raise _HardwareAttached(port)
4344
+ link = None
4345
+ if link:
4346
+ raise _HardwareAttached(link)
4233
4347
  else:
4234
4348
  check_serial_alive(ser)
4235
4349
 
@@ -4348,6 +4462,11 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4348
4462
 
4349
4463
  except KeyboardInterrupt:
4350
4464
  raise
4465
+ except CancelError:
4466
+ safe_send(ser, "CANCELLED:Cancelled")
4467
+ _last_burn_result = "Cancelled"
4468
+ print(f"{mode} cancelled by user")
4469
+ time.sleep(2)
4351
4470
  except Exception as e:
4352
4471
  safe_send(ser, f"ERROR:{str(e)[:50]}")
4353
4472
  _last_burn_result = f"ERROR: {e}"
@@ -4433,6 +4552,9 @@ def main():
4433
4552
  while True:
4434
4553
  try:
4435
4554
  _line_buf = b""
4555
+ ser = None
4556
+
4557
+ # 1. USB serial wins whenever it's present (no mDNS scan then).
4436
4558
  port = discstation_host.serial_port()
4437
4559
  if port:
4438
4560
  print(f"Using ESP32 serial port: {port}")
@@ -4444,10 +4566,23 @@ def main():
4444
4566
  time.sleep(2)
4445
4567
  discstation_burn.reset_serial_state()
4446
4568
  _appliance_mode = "hardware"
4447
- else:
4448
- # No ESP32 found - run fully useful off the on-screen web
4449
- # remote instead of retrying forever (station_loop already
4450
- # publishes status via status_sink regardless of ser).
4569
+
4570
+ # 2. Else look for a Wi-Fi remote (DISC_REMOTE_HOST, default auto/mDNS).
4571
+ if ser is None:
4572
+ remote = discstation_host.remote_host()
4573
+ if remote:
4574
+ try:
4575
+ print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
4576
+ ser = TcpSerial(remote)
4577
+ discstation_burn.reset_serial_state()
4578
+ _appliance_mode = "hardware"
4579
+ print(f"Wi-Fi remote link up ({remote}).")
4580
+ except OSError as e:
4581
+ print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
4582
+ ser = None
4583
+
4584
+ # 3. Else the on-screen web/app remote is the control surface.
4585
+ if ser is None:
4451
4586
  print("No ESP32 found - running in software-only mode (web remote).")
4452
4587
  ser = VirtualSerial()
4453
4588
  _appliance_mode = "software"