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 +43 -8
- package/arduino/c6/DiscStation_C6.ino +293 -41
- package/arduino/c6/secrets.h.example +14 -0
- package/arduino/v1/DiscStation.ino +214 -19
- package/arduino/v1/secrets.h.example +14 -0
- package/discstation.env.example +7 -0
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/discstation.py +150 -15
- package/src/discstation_burn.py +9 -0
- package/src/discstation_host.py +73 -0
|
@@ -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
|
-
|
|
151
|
+
apName = "DiscStation-" + deviceSuffix();
|
|
152
|
+
mdnsHost = "discstation-" + deviceSuffix();
|
|
153
|
+
mdnsHost.toLowerCase();
|
|
154
|
+
|
|
61
155
|
WiFi.mode(WIFI_STA);
|
|
62
|
-
WiFi.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
919
|
-
if (displayOk &&
|
|
920
|
-
(
|
|
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"
|
package/discstation.env.example
CHANGED
|
@@ -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
package/requirements.txt
CHANGED
package/src/discstation.py
CHANGED
|
@@ -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
|
|
119
|
-
a VirtualSerial, so main() can hand
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4226
|
-
#
|
|
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
|
-
|
|
4342
|
+
link = discstation_host.serial_port() or discstation_host.remote_host()
|
|
4229
4343
|
except Exception:
|
|
4230
|
-
|
|
4231
|
-
if
|
|
4232
|
-
raise _HardwareAttached(
|
|
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
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
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"
|