discstation 0.1.40 → 0.1.42
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 +25 -0
- package/arduino/DiscStation/DiscStation.ino +100 -0
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -0
- package/install-macos.sh +2 -0
- package/install-windows.ps1 +1 -0
- package/install.sh +2 -0
- package/package.json +3 -2
- package/scripts/build-firmware.sh +28 -0
- package/scripts/check.sh +29 -0
- package/scripts/postinstall.mjs +2 -0
- package/src/discstation.py +342 -106
- package/src/static/app.js +27 -0
- package/src/static/index.html +7 -2
package/README.md
CHANGED
|
@@ -16,6 +16,31 @@ use every mode.
|
|
|
16
16
|
(greyed out, auto-collapsing) the moment one appears.
|
|
17
17
|
- **Drive:** ATAPI DVD writer (e.g. iHAS124) over USB
|
|
18
18
|
|
|
19
|
+
### Updating the remote's firmware (over the air)
|
|
20
|
+
|
|
21
|
+
No cable needed once a remote runs this firmware: every host release ships the
|
|
22
|
+
matching firmware image (built by `npm publish` via `scripts/build-firmware.sh`).
|
|
23
|
+
When the attached remote is older, the web page and the phone app show
|
|
24
|
+
**"Remote firmware X — Y is available" + UPDATE REMOTE**. The remote downloads the
|
|
25
|
+
image from the host over Wi-Fi, checks its MD5, writes it to its spare firmware
|
|
26
|
+
slot and reboots (about 10 s; the OLED shows progress). It works whether the
|
|
27
|
+
remote is linked by USB or Wi-Fi, as long as it has joined your network, and it
|
|
28
|
+
is refused while a burn/rip/play is running.
|
|
29
|
+
|
|
30
|
+
- **One cable flash first:** remotes flashed before this feature can't update
|
|
31
|
+
themselves; the page says so. Flash once with
|
|
32
|
+
`arduino-cli compile --upload -b esp32:esp32:esp32 -p <port> arduino/DiscStation`
|
|
33
|
+
and every later update is one click.
|
|
34
|
+
- **Safety:** a corrupt or wrong-checksum image is rejected and the running
|
|
35
|
+
firmware keeps working. There is no automatic rollback for a firmware that
|
|
36
|
+
boots but misbehaves - recover with a cable flash. Over Wi-Fi the remote only
|
|
37
|
+
accepts an image from the host that is already driving it.
|
|
38
|
+
- **Manual fallback (per remote, from any computer):** set `OTA_PASSWORD` in
|
|
39
|
+
`secrets.h`, then
|
|
40
|
+
`arduino-cli upload -b esp32:esp32:esp32 -p discstation-xxxx.local --protocol network --upload-field password=<pw> arduino/DiscStation`.
|
|
41
|
+
- **Headroom:** the image uses ~96% of the 1.31 MB app slot; if it grows past
|
|
42
|
+
that, updates will be refused until the partition scheme is changed (cable flash).
|
|
43
|
+
|
|
19
44
|
### Wi-Fi remote (optional)
|
|
20
45
|
|
|
21
46
|
The firmware also serves the same line protocol over TCP 2323, so the
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
#include <ESPmDNS.h>
|
|
9
9
|
#include <Preferences.h>
|
|
10
10
|
#include <ArduinoOTA.h>
|
|
11
|
+
#include <HTTPClient.h>
|
|
12
|
+
#include <Update.h>
|
|
11
13
|
#include "qrcode_lite.h"
|
|
12
14
|
|
|
13
15
|
// Optional build-time Wi-Fi override for power users: copy secrets.h.example
|
|
@@ -17,6 +19,16 @@
|
|
|
17
19
|
#include "secrets.h"
|
|
18
20
|
#endif
|
|
19
21
|
|
|
22
|
+
// Injected by scripts/build-firmware.sh as -DFW_VERSION=x.y.z (an unquoted pp-number,
|
|
23
|
+
// which survives the build system's shell quoting); ad-hoc builds are "dev".
|
|
24
|
+
#define FW_STR_(x) #x
|
|
25
|
+
#define FW_STR(x) FW_STR_(x)
|
|
26
|
+
#ifdef FW_VERSION
|
|
27
|
+
#define FW_VERSION_STR FW_STR(FW_VERSION)
|
|
28
|
+
#else
|
|
29
|
+
#define FW_VERSION_STR "dev"
|
|
30
|
+
#endif
|
|
31
|
+
|
|
20
32
|
#define SCREEN_WIDTH 128
|
|
21
33
|
#define SCREEN_HEIGHT 64
|
|
22
34
|
#define OLED_RESET -1
|
|
@@ -66,6 +78,7 @@ bool wifiConnected = false;
|
|
|
66
78
|
bool wifiInitDone = false;
|
|
67
79
|
bool portalActive = false;
|
|
68
80
|
bool otaEnabled = false;
|
|
81
|
+
bool msgViaTcp = false; // which link the message being parsed arrived on (set by drainAndDispatch)
|
|
69
82
|
bool credsJustSaved = false;
|
|
70
83
|
bool wifiResetArmed = false;
|
|
71
84
|
unsigned long wifiDropAt = 0;
|
|
@@ -716,6 +729,81 @@ void drawPlay() {
|
|
|
716
729
|
display.display();
|
|
717
730
|
}
|
|
718
731
|
|
|
732
|
+
void drawOtaProgress(int pct) {
|
|
733
|
+
if (!displayOk) return;
|
|
734
|
+
display.clearDisplay();
|
|
735
|
+
drawHeader();
|
|
736
|
+
display.setCursor(2, 18);
|
|
737
|
+
display.print("UPDATING FIRMWARE");
|
|
738
|
+
display.drawRect(4, 32, 120, 10, SSD1306_WHITE);
|
|
739
|
+
display.fillRect(6, 34, (116 * pct) / 100, 6, SSD1306_WHITE);
|
|
740
|
+
display.setCursor(2, 50);
|
|
741
|
+
display.print("DO NOT POWER OFF");
|
|
742
|
+
display.display();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Host-driven update: "OTA_URL:<http url>|<size>|<md5>". Streams the image into the
|
|
746
|
+
// inactive app slot; Update verifies the MD5 and image header before switching, so a
|
|
747
|
+
// bad download never boots and the running firmware stays.
|
|
748
|
+
void runOta(const String& arg) {
|
|
749
|
+
int b = arg.lastIndexOf('|');
|
|
750
|
+
int a = b > 0 ? arg.lastIndexOf('|', b - 1) : -1;
|
|
751
|
+
if (a <= 0) { Out.println("OTA:ERR bad request"); return; }
|
|
752
|
+
String url = arg.substring(0, a);
|
|
753
|
+
size_t size = arg.substring(a + 1, b).toInt();
|
|
754
|
+
String md5 = arg.substring(b + 1);
|
|
755
|
+
if (size == 0 || md5.length() != 32) { Out.println("OTA:ERR bad request"); return; }
|
|
756
|
+
if (!wifiConnected) { Out.println("OTA:ERR remote not on Wi-Fi"); return; }
|
|
757
|
+
if (msgViaTcp) {
|
|
758
|
+
// Over Wi-Fi only take an image from the host that is already driving us.
|
|
759
|
+
String host = url.substring(url.indexOf("//") + 2);
|
|
760
|
+
host = host.substring(0, host.indexOf('/'));
|
|
761
|
+
if (host.indexOf(':') > 0) host = host.substring(0, host.indexOf(':'));
|
|
762
|
+
if (host != tcpClient.remoteIP().toString()) { Out.println("OTA:ERR host mismatch"); return; }
|
|
763
|
+
}
|
|
764
|
+
HTTPClient http;
|
|
765
|
+
http.begin(url);
|
|
766
|
+
if (http.GET() != 200 || (size_t)http.getSize() != size) {
|
|
767
|
+
http.end();
|
|
768
|
+
Out.println("OTA:ERR download");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!Update.begin(size)) { http.end(); Out.println("OTA:ERR no space"); return; }
|
|
772
|
+
Update.setMD5(md5.c_str());
|
|
773
|
+
WiFiClient* stream = http.getStreamPtr();
|
|
774
|
+
uint8_t buf[1024];
|
|
775
|
+
size_t done = 0;
|
|
776
|
+
int lastPct = -1;
|
|
777
|
+
unsigned long lastData = millis();
|
|
778
|
+
drawOtaProgress(0);
|
|
779
|
+
while (done < size) {
|
|
780
|
+
int n = stream->readBytes(buf, min(sizeof(buf), size - done));
|
|
781
|
+
if (n <= 0) {
|
|
782
|
+
if (millis() - lastData > 15000 || !http.connected()) break;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
lastData = millis();
|
|
786
|
+
if (Update.write(buf, n) != (size_t)n) break;
|
|
787
|
+
done += n;
|
|
788
|
+
esp_task_wdt_reset();
|
|
789
|
+
int pct = done * 100 / size;
|
|
790
|
+
if (pct != lastPct && pct % 5 == 0) {
|
|
791
|
+
lastPct = pct;
|
|
792
|
+
Out.print("OTA:"); Out.println(pct);
|
|
793
|
+
drawOtaProgress(pct);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
http.end();
|
|
797
|
+
if (done == size && Update.end(true)) {
|
|
798
|
+
Out.println("OTA:DONE");
|
|
799
|
+
delay(500);
|
|
800
|
+
ESP.restart();
|
|
801
|
+
}
|
|
802
|
+
Update.abort();
|
|
803
|
+
Out.println("OTA:ERR verify failed");
|
|
804
|
+
drawStandby();
|
|
805
|
+
}
|
|
806
|
+
|
|
719
807
|
void parseMessage(String msg) {
|
|
720
808
|
msg.trim();
|
|
721
809
|
|
|
@@ -726,6 +814,17 @@ void parseMessage(String msg) {
|
|
|
726
814
|
return;
|
|
727
815
|
}
|
|
728
816
|
|
|
817
|
+
if (msg == "VERSION") {
|
|
818
|
+
Out.print("VERSION:"); Out.println(FW_VERSION_STR);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (msg.startsWith("OTA_URL:")) {
|
|
823
|
+
lastMsgTime = millis();
|
|
824
|
+
runOta(msg.substring(8));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
729
828
|
if (msg.startsWith("VU:")) {
|
|
730
829
|
// No wakeDisplay()/RCV echo here - this arrives ~15x/sec while playing,
|
|
731
830
|
// wakeDisplay() would draw stale bars a frame early, and echoing it
|
|
@@ -1201,6 +1300,7 @@ void handlePlayPauseButton(bool longPress) {
|
|
|
1201
1300
|
// instead of working through a backlog. Every other message type still gets
|
|
1202
1301
|
// parsed in order; only VU: is collapsible like this.
|
|
1203
1302
|
void drainAndDispatch(Stream &s) {
|
|
1303
|
+
msgViaTcp = (&s == (Stream*)&tcpClient);
|
|
1204
1304
|
String pendingVu = "";
|
|
1205
1305
|
while (s.available()) {
|
|
1206
1306
|
String msg = s.readStringUntil('\n');
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"0.1.42","size":1260592,"md5":"fc62c97a1f9e0bedc579426771789964"}
|
package/install-macos.sh
CHANGED
|
@@ -31,6 +31,8 @@ brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid ha
|
|
|
31
31
|
libdvdcss dvdbackup libcdio-paranoia
|
|
32
32
|
mkdir -p "$APP_DIR" "$VENV_DIR" "$CONFIG_DIR"
|
|
33
33
|
cp -R "$ROOT_DIR/src/." "$APP_DIR/"
|
|
34
|
+
# remote firmware image for over-the-air updates (built by scripts/build-firmware.sh)
|
|
35
|
+
if [[ -d "$ROOT_DIR/arduino/firmware" ]]; then cp -R "$ROOT_DIR/arduino/firmware" "$APP_DIR/firmware"; fi
|
|
34
36
|
python3 -m venv "$VENV_DIR"
|
|
35
37
|
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
|
36
38
|
if [[ -f "$ROOT_DIR/requirements.txt" ]]; then
|
package/install-windows.ps1
CHANGED
|
@@ -98,6 +98,7 @@ if ($winget -and -not $IsWin7) {
|
|
|
98
98
|
# --- 3. App files + venv ------------------------------------------------------
|
|
99
99
|
New-Item -ItemType Directory -Force -Path $Base, $App | Out-Null
|
|
100
100
|
Copy-Item -Recurse -Force (Join-Path $Root "src\*") $App
|
|
101
|
+
if (Test-Path (Join-Path $Root "arduino\firmware")) { Copy-Item -Recurse -Force (Join-Path $Root "arduino\firmware") $App }
|
|
101
102
|
& $PyExe @PyArgs -m venv $Venv
|
|
102
103
|
$Py = Join-Path $Venv "Scripts\python.exe"
|
|
103
104
|
& $Py -m pip install --upgrade pip
|
package/install.sh
CHANGED
|
@@ -26,6 +26,8 @@ fi
|
|
|
26
26
|
|
|
27
27
|
mkdir -p "$APP_DIR" "$CONFIG_DIR" "$VENV_DIR"
|
|
28
28
|
cp -R "$ROOT_DIR/src/." "$APP_DIR/"
|
|
29
|
+
# remote firmware image for over-the-air updates (built by scripts/build-firmware.sh)
|
|
30
|
+
if [[ -d "$ROOT_DIR/arduino/firmware" ]]; then cp -R "$ROOT_DIR/arduino/firmware" "$APP_DIR/firmware"; fi
|
|
29
31
|
if [[ ! -f "$CONFIG_DIR/discstation.env" && -f "$ROOT_DIR/discstation.env.example" ]]; then
|
|
30
32
|
cp "$ROOT_DIR/discstation.env.example" "$CONFIG_DIR/discstation.env"
|
|
31
33
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discstation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "DiscStation optical-media appliance host — one cross-OS installer",
|
|
5
5
|
"bin": {
|
|
6
6
|
"discstation-setup": "scripts/setup.mjs",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node scripts/setup.mjs",
|
|
11
|
-
"postinstall": "node scripts/postinstall.mjs"
|
|
11
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
12
|
+
"prepack": "bash scripts/build-firmware.sh"
|
|
12
13
|
},
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=16"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the release firmware image the host serves to remotes for over-the-air
|
|
3
|
+
# updates (arduino/firmware/discstation.bin + firmware.json). Runs on `npm pack`
|
|
4
|
+
# / `npm publish` (prepack) so a published package always carries an image that
|
|
5
|
+
# matches its version. Needs arduino-cli with the esp32 core.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
command -v arduino-cli >/dev/null || { echo "arduino-cli is required to build the firmware image" >&2; exit 1; }
|
|
9
|
+
|
|
10
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
11
|
+
OUT=arduino/firmware
|
|
12
|
+
TMP=$(mktemp -d)
|
|
13
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
14
|
+
|
|
15
|
+
# Build from a copy without secrets.h so Wi-Fi credentials can never end up in a published image.
|
|
16
|
+
cp -R arduino/DiscStation "$TMP/DiscStation"
|
|
17
|
+
rm -f "$TMP/DiscStation/secrets.h"
|
|
18
|
+
|
|
19
|
+
rm -rf "$OUT" && mkdir -p "$OUT"
|
|
20
|
+
arduino-cli compile -b esp32:esp32:esp32 \
|
|
21
|
+
--build-property "compiler.cpp.extra_flags=-DFW_VERSION=$VERSION" \
|
|
22
|
+
--output-dir "$TMP/out" "$TMP/DiscStation" >/dev/null
|
|
23
|
+
cp "$TMP/out/DiscStation.ino.bin" "$OUT/discstation.bin"
|
|
24
|
+
|
|
25
|
+
SIZE=$(wc -c <"$OUT/discstation.bin" | tr -d ' ')
|
|
26
|
+
MD5=$( (md5sum "$OUT/discstation.bin" 2>/dev/null || md5 -r "$OUT/discstation.bin") | cut -d' ' -f1)
|
|
27
|
+
printf '{"version":"%s","size":%s,"md5":"%s"}\n' "$VERSION" "$SIZE" "$MD5" >"$OUT/firmware.json"
|
|
28
|
+
echo "firmware $VERSION: $SIZE bytes, md5 $MD5"
|
package/scripts/check.sh
CHANGED
|
@@ -55,6 +55,35 @@ try:
|
|
|
55
55
|
raise SystemExit("cancel not raised")
|
|
56
56
|
except d.CancelError:
|
|
57
57
|
pass
|
|
58
|
+
|
|
59
|
+
# Remote OTA state: available only for a real, older release; dev builds never nag.
|
|
60
|
+
d._appliance_mode = "hardware"
|
|
61
|
+
d._firmware_image = lambda: (None, {"version": "0.2.0", "size": 1, "md5": "x"})
|
|
62
|
+
for fw, want in (("0.1.0", "available"), ("dev", "current"), ("0.2.0", "current"), ("0.3.0", "current")):
|
|
63
|
+
d._remote_fw = fw
|
|
64
|
+
assert d._remote_update_state() == want, (fw, d._remote_update_state())
|
|
65
|
+
d._remote_fw, d._remote_fw_asked = None, 0.0
|
|
66
|
+
assert d._remote_update_state() == "unknown"
|
|
67
|
+
d._appliance_mode = "software"
|
|
68
|
+
assert d._remote_update_state() == ""
|
|
69
|
+
|
|
70
|
+
# HybridSerial: web side when no remote, replay keeps the current screen, an unplug detaches quietly.
|
|
71
|
+
d._replay.clear()
|
|
72
|
+
for m in ("DISC:Audio CD", "HOME:Select mode", "PLAY_MODE:AUDIO_CD", "PLAY:PLAYING", "VU:1,2", "PING"):
|
|
73
|
+
d._remember(m)
|
|
74
|
+
assert list(d._replay.values()) == ["DISC:Audio CD", "PLAY_MODE:AUDIO_CD", "PLAY:PLAYING"]
|
|
75
|
+
d._remember("HOME:Select mode")
|
|
76
|
+
assert list(d._replay.values()) == ["DISC:Audio CD", "HOME:Select mode"]
|
|
77
|
+
h = d.HybridSerial()
|
|
78
|
+
h.virtual.push_line("PLAY_BUTTON")
|
|
79
|
+
assert h.in_waiting and h.readline() == b"PLAY_BUTTON\n" and h.write(b"x") == 1
|
|
80
|
+
class Unplugged:
|
|
81
|
+
@property
|
|
82
|
+
def in_waiting(self): raise OSError("gone")
|
|
83
|
+
def close(self): pass
|
|
84
|
+
d._appliance_mode = "hardware"
|
|
85
|
+
h.hw = Unplugged()
|
|
86
|
+
assert h.in_waiting == 0 and h.hw is None and d._appliance_mode == "software"
|
|
58
87
|
print("smoke ok")
|
|
59
88
|
EOF
|
|
60
89
|
)
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -75,6 +75,8 @@ if (!existsSync(appDir) || !alreadyInstalled()) {
|
|
|
75
75
|
|
|
76
76
|
try {
|
|
77
77
|
cpSync(join(ROOT, 'src'), appDir, { recursive: true, force: true });
|
|
78
|
+
const fw = join(ROOT, 'arduino', 'firmware');
|
|
79
|
+
if (existsSync(fw)) cpSync(fw, join(appDir, 'firmware'), { recursive: true, force: true });
|
|
78
80
|
restartService();
|
|
79
81
|
console.log('\ndiscstation: redeployed the updated host and restarted the service.\n');
|
|
80
82
|
} catch (e) {
|
package/src/discstation.py
CHANGED
|
@@ -5,6 +5,7 @@ import collections
|
|
|
5
5
|
import concurrent.futures
|
|
6
6
|
import contextlib
|
|
7
7
|
import errno
|
|
8
|
+
import functools
|
|
8
9
|
try:
|
|
9
10
|
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
10
11
|
except ImportError:
|
|
@@ -61,6 +62,10 @@ _web_op_verb = "BURNING" # progress-bar verb for the current PROGRESS: stream
|
|
|
61
62
|
_operation_active = False # a burn/rip/play flow is holding the drive
|
|
62
63
|
_last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
63
64
|
_active_ser = None
|
|
65
|
+
_remote_fw = None # firmware version the attached remote reported (None = old firmware / not asked yet)
|
|
66
|
+
_remote_fw_asked = 0.0 # when VERSION was requested; a silent remote later than this = pre-OTA firmware
|
|
67
|
+
_ota_msg = "" # last over-the-air update error, shown next to the Update button
|
|
68
|
+
_HTTP_PORT = 0 # plain-HTTP listener port; the remote pulls its firmware image from it
|
|
64
69
|
_appliance_mode = "hardware" # "hardware" (real ESP32) or "software" (web remote only)
|
|
65
70
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
66
71
|
|
|
@@ -146,9 +151,14 @@ class TcpSerial:
|
|
|
146
151
|
self._buf = b""
|
|
147
152
|
self._lock = threading.Lock()
|
|
148
153
|
self._alive = True
|
|
154
|
+
self.last_rx = time.monotonic() # link monitor: silent this long = the remote is gone
|
|
149
155
|
self._reader = threading.Thread(target=self._pump, daemon=True)
|
|
150
156
|
self._reader.start()
|
|
151
157
|
|
|
158
|
+
def local_addr(self):
|
|
159
|
+
"""This machine's address on the connection to the remote."""
|
|
160
|
+
return self._sock.getsockname()[0]
|
|
161
|
+
|
|
152
162
|
def _pump(self):
|
|
153
163
|
while self._alive:
|
|
154
164
|
try:
|
|
@@ -159,6 +169,7 @@ class TcpSerial:
|
|
|
159
169
|
break
|
|
160
170
|
if not chunk:
|
|
161
171
|
break
|
|
172
|
+
self.last_rx = time.monotonic()
|
|
162
173
|
with self._lock:
|
|
163
174
|
self._buf += chunk
|
|
164
175
|
self._alive = False
|
|
@@ -205,10 +216,199 @@ class TcpSerial:
|
|
|
205
216
|
pass
|
|
206
217
|
|
|
207
218
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
219
|
+
# What the remote's screen needs to be told again when it is plugged in mid-run:
|
|
220
|
+
# the last message per prefix, so a remote attached during PLAY lands on the PLAY
|
|
221
|
+
# screen instead of "Starting...". Activity messages belong to one operation and are
|
|
222
|
+
# forgotten when a terminal message (back to a menu/standby, done, error) arrives.
|
|
223
|
+
_REPLAY_ACTIVITY = ("PLAY_MODE", "PLAY_STATUS", "PLAY", "STATUS", "PROGRESS", "WAITING",
|
|
224
|
+
"INFO", "TITLE", "META", "FIT", "WARNING")
|
|
225
|
+
_REPLAY_TERMINAL = ("HOME", "STANDBY", "DONE", "ERROR", "CANCELLED")
|
|
226
|
+
_REPLAY_STICKY = ("DISC", "DISC_NAME", "MENU_ITEMS", "IP")
|
|
227
|
+
_replay = collections.OrderedDict()
|
|
228
|
+
_replay_lock = threading.Lock()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _remember(msg):
|
|
232
|
+
prefix = msg.split(":", 1)[0]
|
|
233
|
+
if prefix in _REPLAY_TERMINAL:
|
|
234
|
+
drop = _REPLAY_ACTIVITY + _REPLAY_TERMINAL
|
|
235
|
+
elif prefix in _REPLAY_ACTIVITY:
|
|
236
|
+
drop = _REPLAY_TERMINAL # the screen left the menu
|
|
237
|
+
elif prefix in _REPLAY_STICKY:
|
|
238
|
+
drop = ()
|
|
239
|
+
else:
|
|
240
|
+
return # VU frames, PING, VERSION, ...
|
|
241
|
+
with _replay_lock:
|
|
242
|
+
for k in drop:
|
|
243
|
+
_replay.pop(k, None)
|
|
244
|
+
_replay.pop(prefix, None) # re-insert so dict order is send order
|
|
245
|
+
_replay[prefix] = msg
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class HybridSerial:
|
|
249
|
+
"""The one link object station_loop and every flow hold for the life of the
|
|
250
|
+
process. It routes to the physical remote (USB serial or Wi-Fi) when one is
|
|
251
|
+
attached and to the web/app remote otherwise, and the link monitor swaps the
|
|
252
|
+
remote in and out underneath running flows - so plugging or unplugging it never
|
|
253
|
+
interrupts a burn/rip/play and never needs station_loop to restart."""
|
|
254
|
+
|
|
255
|
+
def __init__(self):
|
|
256
|
+
self.virtual = VirtualSerial()
|
|
257
|
+
self.hw = None
|
|
258
|
+
|
|
259
|
+
def detach(self, hw):
|
|
260
|
+
global _appliance_mode, _line_buf
|
|
261
|
+
if self.hw is not hw:
|
|
262
|
+
return
|
|
263
|
+
self.hw = None
|
|
264
|
+
_appliance_mode = "software"
|
|
265
|
+
_line_buf = b""
|
|
266
|
+
discstation_burn.reset_serial_state()
|
|
267
|
+
try:
|
|
268
|
+
hw.close()
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
print("Remote disconnected - the web/app remote is active again.")
|
|
272
|
+
_sse_publish(_status_snapshot())
|
|
273
|
+
|
|
274
|
+
def _through(self, method, *args, default=b""):
|
|
275
|
+
hw = self.hw
|
|
276
|
+
if hw is None:
|
|
277
|
+
return getattr(self.virtual, method)(*args)
|
|
278
|
+
try:
|
|
279
|
+
return getattr(hw, method)(*args)
|
|
280
|
+
except (serial.SerialException, OSError):
|
|
281
|
+
self.detach(hw)
|
|
282
|
+
return default
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def in_waiting(self):
|
|
286
|
+
hw = self.hw
|
|
287
|
+
if hw is None:
|
|
288
|
+
return self.virtual.in_waiting
|
|
289
|
+
try:
|
|
290
|
+
return hw.in_waiting
|
|
291
|
+
except (serial.SerialException, OSError):
|
|
292
|
+
self.detach(hw)
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
def read(self, n=1):
|
|
296
|
+
return self._through("read", n)
|
|
297
|
+
|
|
298
|
+
def readline(self):
|
|
299
|
+
return self._through("readline")
|
|
300
|
+
|
|
301
|
+
def write(self, data):
|
|
302
|
+
if self.hw is None:
|
|
303
|
+
return len(data) if data else 0
|
|
304
|
+
return self._through("write", data, default=len(data) if data else 0)
|
|
305
|
+
|
|
306
|
+
def close(self):
|
|
307
|
+
hw = self.hw
|
|
308
|
+
if hw is not None:
|
|
309
|
+
try:
|
|
310
|
+
hw.close()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _open_remote_link(look_for_wifi):
|
|
316
|
+
"""Open the physical remote (USB serial wins, else the Wi-Fi remote) and wait for
|
|
317
|
+
its boot to finish. Returns the link or None. Runs on the monitor thread only."""
|
|
318
|
+
port = discstation_host.serial_port()
|
|
319
|
+
if port:
|
|
320
|
+
print(f"Using ESP32 serial port: {port}")
|
|
321
|
+
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
|
|
322
|
+
if discstation_host.system_name() == "linux":
|
|
323
|
+
ser.setDTR(False)
|
|
324
|
+
time.sleep(0.1)
|
|
325
|
+
ser.setDTR(True)
|
|
326
|
+
deadline = time.monotonic() + 6
|
|
327
|
+
while time.monotonic() < deadline:
|
|
328
|
+
line = ser.readline().decode(errors="ignore").strip()
|
|
329
|
+
if line:
|
|
330
|
+
print(f"ESP32: {line}")
|
|
331
|
+
if "DISCSTATION_READY" in line:
|
|
332
|
+
break
|
|
333
|
+
return ser
|
|
334
|
+
if look_for_wifi:
|
|
335
|
+
remote = discstation_host.remote_host()
|
|
336
|
+
if remote:
|
|
337
|
+
try:
|
|
338
|
+
print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
|
|
339
|
+
link = TcpSerial(remote)
|
|
340
|
+
print(f"Wi-Fi remote link up ({remote}).")
|
|
341
|
+
return link
|
|
342
|
+
except OSError as e:
|
|
343
|
+
print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _attach_remote(hybrid, hw):
|
|
348
|
+
"""Bring a freshly opened remote up to date and make it the active link."""
|
|
349
|
+
global _appliance_mode, _line_buf, _remote_fw, _remote_fw_asked
|
|
350
|
+
with discstation_burn.SERIAL_WRITE_LOCK: # flows' sends wait, so none is lost mid-attach
|
|
351
|
+
with _replay_lock:
|
|
352
|
+
lines = list(_replay.values())
|
|
353
|
+
try:
|
|
354
|
+
for msg in lines + ["VERSION"]:
|
|
355
|
+
hw.write((msg + "\n").encode())
|
|
356
|
+
except (serial.SerialException, OSError):
|
|
357
|
+
try:
|
|
358
|
+
hw.close()
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
return
|
|
362
|
+
_remote_fw, _remote_fw_asked = None, time.time()
|
|
363
|
+
discstation_burn.reset_serial_state()
|
|
364
|
+
_line_buf = b""
|
|
365
|
+
hybrid.hw = hw
|
|
366
|
+
hybrid.attached_at = time.monotonic()
|
|
367
|
+
_appliance_mode = "hardware"
|
|
368
|
+
print("Remote attached - control handed to the hardware remote.")
|
|
369
|
+
_sse_publish(_status_snapshot())
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _link_monitor(hybrid, first_pass=False):
|
|
373
|
+
"""Watches for the remote being plugged in / removed while flows run.
|
|
374
|
+
Not attached: look for USB every second, the Wi-Fi remote (mDNS) every 5 s.
|
|
375
|
+
Attached: keep it alive with PINGs and notice a dead link within seconds
|
|
376
|
+
(USB unplug shows up as an ioctl error at once; a Wi-Fi remote that goes
|
|
377
|
+
silent for 15 s is treated as gone)."""
|
|
378
|
+
last_wifi = last_ping = 0.0
|
|
379
|
+
logged_error = False
|
|
380
|
+
while True:
|
|
381
|
+
try:
|
|
382
|
+
hw = hybrid.hw
|
|
383
|
+
now = time.monotonic()
|
|
384
|
+
if hw is None:
|
|
385
|
+
wifi_due = now - last_wifi >= 5
|
|
386
|
+
if wifi_due:
|
|
387
|
+
last_wifi = now
|
|
388
|
+
link = _open_remote_link(wifi_due)
|
|
389
|
+
logged_error = False
|
|
390
|
+
if link is not None:
|
|
391
|
+
_attach_remote(hybrid, link)
|
|
392
|
+
else:
|
|
393
|
+
hw.in_waiting # raises once the device is gone; does not consume bytes
|
|
394
|
+
silent = now - getattr(hw, "last_rx", now)
|
|
395
|
+
if silent > 15 and now - getattr(hybrid, "attached_at", now) > 15:
|
|
396
|
+
raise serial.SerialException("remote stopped answering")
|
|
397
|
+
if now - last_ping >= 3:
|
|
398
|
+
last_ping = now
|
|
399
|
+
with discstation_burn.SERIAL_WRITE_LOCK:
|
|
400
|
+
hw.write(b"PING\n")
|
|
401
|
+
except (serial.SerialException, OSError):
|
|
402
|
+
if hybrid.hw is not None:
|
|
403
|
+
hybrid.detach(hybrid.hw)
|
|
404
|
+
elif not logged_error:
|
|
405
|
+
logged_error = True
|
|
406
|
+
print("Remote found but could not be opened yet; retrying.")
|
|
407
|
+
except Exception as e:
|
|
408
|
+
print(f"Link monitor error: {e}")
|
|
409
|
+
if first_pass:
|
|
410
|
+
return
|
|
411
|
+
time.sleep(1)
|
|
212
412
|
|
|
213
413
|
|
|
214
414
|
class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
@@ -225,6 +425,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
225
425
|
self._serve_disc_info()
|
|
226
426
|
elif path == '/events':
|
|
227
427
|
self._serve_sse()
|
|
428
|
+
elif path == '/firmware.bin':
|
|
429
|
+
self._serve_firmware()
|
|
228
430
|
elif path == '/sw.js':
|
|
229
431
|
self._serve_sw()
|
|
230
432
|
elif path == '/manifest.json':
|
|
@@ -246,6 +448,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
246
448
|
self._handle_set_label()
|
|
247
449
|
elif path == '/remote/button':
|
|
248
450
|
self._handle_remote_button()
|
|
451
|
+
elif path == '/remote/update':
|
|
452
|
+
self._handle_remote_update()
|
|
249
453
|
else:
|
|
250
454
|
self.send_error(404)
|
|
251
455
|
|
|
@@ -324,12 +528,47 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
324
528
|
if not cmd:
|
|
325
529
|
self._respond(400, 'Missing cmd')
|
|
326
530
|
return
|
|
327
|
-
if
|
|
328
|
-
_active_ser.push_line(cmd)
|
|
531
|
+
if _active_ser is not None and _active_ser.hw is None:
|
|
532
|
+
_active_ser.virtual.push_line(cmd)
|
|
329
533
|
self._respond(200, 'OK')
|
|
330
534
|
else:
|
|
331
535
|
self._respond(409, 'A hardware remote is attached')
|
|
332
536
|
|
|
537
|
+
def _serve_firmware(self):
|
|
538
|
+
image, _ = _firmware_image()
|
|
539
|
+
if not image:
|
|
540
|
+
self.send_error(404)
|
|
541
|
+
return
|
|
542
|
+
data = image.read_bytes()
|
|
543
|
+
self.send_response(200)
|
|
544
|
+
self.send_header('Content-Type', 'application/octet-stream')
|
|
545
|
+
self.send_header('Content-Length', str(len(data)))
|
|
546
|
+
self.end_headers()
|
|
547
|
+
self.wfile.write(data)
|
|
548
|
+
|
|
549
|
+
def _handle_remote_update(self):
|
|
550
|
+
"""Tell the attached remote to download the firmware image this host ships
|
|
551
|
+
(OTA_URL:<url>|<size>|<md5>). The remote verifies the MD5 before switching."""
|
|
552
|
+
global _ota_msg
|
|
553
|
+
ser = _active_ser
|
|
554
|
+
image, meta = _firmware_image()
|
|
555
|
+
if ser is None or ser.hw is None:
|
|
556
|
+
self._respond(409, 'No hardware remote attached')
|
|
557
|
+
elif _operation_active:
|
|
558
|
+
self._respond(409, 'Wait until the current operation finishes')
|
|
559
|
+
elif not image or not _HTTP_PORT:
|
|
560
|
+
self._respond(409, 'This host has no firmware image to send')
|
|
561
|
+
elif _remote_update_state() != "available":
|
|
562
|
+
self._respond(409, 'The remote is already up to date' if _remote_fw else 'This remote needs one cable flash first')
|
|
563
|
+
else:
|
|
564
|
+
# the address the remote reaches us on: for the Wi-Fi link that is the
|
|
565
|
+
# socket's own local address (also what the firmware checks it against)
|
|
566
|
+
host = ser.hw.local_addr() if hasattr(ser.hw, "local_addr") else local_ip()
|
|
567
|
+
_ota_msg = ""
|
|
568
|
+
discstation_burn.safe_send(ser, f"OTA_URL:http://{host}:{_HTTP_PORT}/firmware.bin|{meta['size']}|{meta['md5']}")
|
|
569
|
+
_record_ota("0")
|
|
570
|
+
self._respond(200, 'OK')
|
|
571
|
+
|
|
333
572
|
def _serve_disc_info(self):
|
|
334
573
|
if _operation_active:
|
|
335
574
|
# a burn/rip/play holds the drive — don't probe it, serve last-known.
|
|
@@ -406,7 +645,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
406
645
|
def _serve_sw(self):
|
|
407
646
|
sw = '''self.addEventListener('install', e => {
|
|
408
647
|
self.skipWaiting();
|
|
409
|
-
caches.open('discstation-
|
|
648
|
+
caches.open('discstation-v18').then(c => c.addAll(['/','/static/style.css?v=18','/static/app.js?v=18']));
|
|
410
649
|
});
|
|
411
650
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
412
651
|
self.addEventListener('fetch', e => {
|
|
@@ -415,7 +654,7 @@ self.addEventListener('fetch', e => {
|
|
|
415
654
|
if (path === '/' || path.startsWith('/static/')) {
|
|
416
655
|
e.respondWith(fetch(e.request).then(r => {
|
|
417
656
|
const copy = r.clone();
|
|
418
|
-
caches.open('discstation-
|
|
657
|
+
caches.open('discstation-v18').then(c => c.put(e.request, copy));
|
|
419
658
|
return r;
|
|
420
659
|
}).catch(() => caches.match(e.request)));
|
|
421
660
|
} else {
|
|
@@ -523,6 +762,7 @@ self.addEventListener('fetch', e => {
|
|
|
523
762
|
|
|
524
763
|
|
|
525
764
|
def start_web_server(port=8080):
|
|
765
|
+
global _HTTP_PORT
|
|
526
766
|
global _web_server, _web_port
|
|
527
767
|
if _web_server:
|
|
528
768
|
return _web_server
|
|
@@ -569,6 +809,7 @@ def start_web_server(port=8080):
|
|
|
569
809
|
threading.Thread(target=plain.serve_forever, daemon=True).start()
|
|
570
810
|
print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
|
|
571
811
|
plain_http_up = True
|
|
812
|
+
_HTTP_PORT = http_port
|
|
572
813
|
except OSError as e:
|
|
573
814
|
print(f"Plain HTTP listener not started on {http_port}: {e}")
|
|
574
815
|
|
|
@@ -730,10 +971,65 @@ _sse_subs = set() # of Queue
|
|
|
730
971
|
_sse_lock = threading.Lock()
|
|
731
972
|
|
|
732
973
|
|
|
974
|
+
@functools.lru_cache(maxsize=1)
|
|
975
|
+
def _firmware_image():
|
|
976
|
+
"""(image path, {version,size,md5}) for the remote firmware shipped with this
|
|
977
|
+
host (scripts/build-firmware.sh), or (None, None)."""
|
|
978
|
+
here = Path(__file__).resolve().parent
|
|
979
|
+
for d in (here / "firmware", here.parent / "arduino" / "firmware"):
|
|
980
|
+
try:
|
|
981
|
+
meta = json.loads((d / "firmware.json").read_text())
|
|
982
|
+
if (d / "discstation.bin").is_file():
|
|
983
|
+
return d / "discstation.bin", meta
|
|
984
|
+
except (OSError, ValueError):
|
|
985
|
+
pass
|
|
986
|
+
return None, None
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _remote_update_state():
|
|
990
|
+
"""'' no hardware remote / still asking, 'unknown' the remote never answered
|
|
991
|
+
VERSION (pre-OTA firmware: needs one cable flash), 'current', 'available'."""
|
|
992
|
+
if _appliance_mode != "hardware":
|
|
993
|
+
return ""
|
|
994
|
+
_, meta = _firmware_image()
|
|
995
|
+
if _remote_fw is None:
|
|
996
|
+
return "unknown" if meta and time.time() - _remote_fw_asked > 14 else ""
|
|
997
|
+
try:
|
|
998
|
+
newer = tuple(map(int, meta["version"].split("."))) > tuple(map(int, _remote_fw.split(".")))
|
|
999
|
+
except (TypeError, ValueError): # "dev" builds (or an unparsable version) never nag
|
|
1000
|
+
newer = False
|
|
1001
|
+
return "available" if newer else "current"
|
|
1002
|
+
|
|
1003
|
+
|
|
733
1004
|
def _status_snapshot():
|
|
1005
|
+
_, meta = _firmware_image()
|
|
734
1006
|
return {"status": _web_status or "READY", "progress": _web_progress,
|
|
735
1007
|
"active": _web_progress_active, "appliance": _appliance_mode,
|
|
736
|
-
"playing": _web_playing, "tray_open": _tray_open
|
|
1008
|
+
"playing": _web_playing, "tray_open": _tray_open,
|
|
1009
|
+
"remote_fw": _remote_fw, "remote_fw_latest": meta["version"] if meta else None,
|
|
1010
|
+
"remote_update": _remote_update_state(), "remote_update_msg": _ota_msg}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _set_remote_fw(version):
|
|
1014
|
+
global _remote_fw, _web_status, _web_progress
|
|
1015
|
+
_remote_fw = version
|
|
1016
|
+
if (_web_status or "").startswith("REMOTE UPDATED"):
|
|
1017
|
+
_web_status, _web_progress = "READY", -1
|
|
1018
|
+
_sse_publish(_status_snapshot())
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _record_ota(msg):
|
|
1022
|
+
"""OTA:<pct> | OTA:DONE | OTA:ERR <why> lines from the remote while it updates."""
|
|
1023
|
+
global _web_status, _web_progress, _web_progress_active, _ota_msg, _remote_fw, _remote_fw_asked
|
|
1024
|
+
if msg.isdigit():
|
|
1025
|
+
_web_status, _web_progress, _web_progress_active = f"UPDATING REMOTE {msg}%", int(msg), True
|
|
1026
|
+
elif msg == "DONE":
|
|
1027
|
+
_web_status, _web_progress, _web_progress_active = "REMOTE UPDATED - RESTARTING", 100, False
|
|
1028
|
+
_remote_fw, _remote_fw_asked = None, time.time() # the USB link survives the reboot: ask again
|
|
1029
|
+
else:
|
|
1030
|
+
_web_status, _web_progress, _web_progress_active = "REMOTE UPDATE FAILED", -1, False
|
|
1031
|
+
_ota_msg = msg.removeprefix("ERR").strip()
|
|
1032
|
+
_sse_publish(_status_snapshot())
|
|
737
1033
|
|
|
738
1034
|
|
|
739
1035
|
def _sse_publish(event):
|
|
@@ -760,6 +1056,7 @@ def _set_web_progress(phase, percent=-1):
|
|
|
760
1056
|
|
|
761
1057
|
def _record_web_status(msg):
|
|
762
1058
|
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
1059
|
+
_remember(msg)
|
|
763
1060
|
if msg.startswith("DISC:"):
|
|
764
1061
|
_sse_publish({"type": "disc-changed"})
|
|
765
1062
|
return
|
|
@@ -939,23 +1236,9 @@ discstation_burn.line_reader = read_serial_line
|
|
|
939
1236
|
|
|
940
1237
|
|
|
941
1238
|
def check_serial_alive(ser=None):
|
|
942
|
-
"""
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently).
|
|
946
|
-
|
|
947
|
-
Meaningless (and actively harmful) in pure web/software mode - there's no
|
|
948
|
-
ESP32 to go quiet, and serial_activity_age() only advances on real
|
|
949
|
-
incoming bytes, so a user just reading the screen for >35s before
|
|
950
|
-
clicking the next button on the web remote looked identical to a dead
|
|
951
|
-
link and killed the burn ("ESP32 not responding") with no ESP32 in the
|
|
952
|
-
picture at all."""
|
|
953
|
-
if isinstance(ser, VirtualSerial):
|
|
954
|
-
return
|
|
955
|
-
if discstation_burn.serial_write_failed():
|
|
956
|
-
raise serial.SerialException("serial write failed (ESP32 link lost)")
|
|
957
|
-
if discstation_burn.serial_activity_age() >= 35:
|
|
958
|
-
raise serial.SerialException("ESP32 not responding")
|
|
1239
|
+
"""Kept for the flows that call it inside long poll loops. The link monitor
|
|
1240
|
+
(_link_monitor) now owns liveness and swaps a dead remote for the web remote
|
|
1241
|
+
without raising, so a lost remote no longer aborts a burn/rip/play."""
|
|
959
1242
|
|
|
960
1243
|
|
|
961
1244
|
def send_disc_info(ser, device, status_line=None):
|
|
@@ -2944,7 +3227,7 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
2944
3227
|
if proc.poll() is not None:
|
|
2945
3228
|
break
|
|
2946
3229
|
continue
|
|
2947
|
-
if pause_event.is_set():
|
|
3230
|
+
if pause_event.is_set() or ser.hw is None: # nobody to draw bars for
|
|
2948
3231
|
continue
|
|
2949
3232
|
samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
|
|
2950
3233
|
spectrum = _np.abs(_np.fft.rfft(samples * window))
|
|
@@ -2976,9 +3259,9 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
2976
3259
|
|
|
2977
3260
|
def start_vu_visualizer(ser):
|
|
2978
3261
|
"""Best-effort: returns (stop_event, pause_event), or (None, None) if the
|
|
2979
|
-
visualizer can't run here (no numpy, no PulseAudio
|
|
3262
|
+
visualizer can't run here (no numpy, no PulseAudio).
|
|
2980
3263
|
Linux only."""
|
|
2981
|
-
if _np is None or
|
|
3264
|
+
if _np is None or discstation_host.system_name() != "linux":
|
|
2982
3265
|
return None, None
|
|
2983
3266
|
stop_event = threading.Event()
|
|
2984
3267
|
pause_event = threading.Event()
|
|
@@ -3979,14 +4262,6 @@ def station_loop(ser):
|
|
|
3979
4262
|
except Exception:
|
|
3980
4263
|
device = None # empty drive (e.g. macOS after an eject) — keep the loop alive
|
|
3981
4264
|
|
|
3982
|
-
for _ in range(50):
|
|
3983
|
-
line = read_serial_line(ser, timeout=0.2)
|
|
3984
|
-
if not line:
|
|
3985
|
-
break
|
|
3986
|
-
print(f"ESP32: {line}")
|
|
3987
|
-
if "DISCSTATION_READY" in line:
|
|
3988
|
-
break
|
|
3989
|
-
|
|
3990
4265
|
safe_send(ser, "STANDBY:Starting...")
|
|
3991
4266
|
|
|
3992
4267
|
last_disc_line = None
|
|
@@ -4102,18 +4377,8 @@ def station_loop(ser):
|
|
|
4102
4377
|
if now - last_ping >= 5:
|
|
4103
4378
|
last_ping = now
|
|
4104
4379
|
safe_send(ser, "PING")
|
|
4105
|
-
if
|
|
4106
|
-
#
|
|
4107
|
-
# USB serial or the Wi-Fi remote - has appeared, and hand off
|
|
4108
|
-
# to it instead of the web remote.
|
|
4109
|
-
try:
|
|
4110
|
-
link = discstation_host.serial_port() or discstation_host.remote_host()
|
|
4111
|
-
except Exception:
|
|
4112
|
-
link = None
|
|
4113
|
-
if link:
|
|
4114
|
-
raise _HardwareAttached(link)
|
|
4115
|
-
else:
|
|
4116
|
-
check_serial_alive(ser)
|
|
4380
|
+
if _remote_fw is None and ser.hw is not None:
|
|
4381
|
+
safe_send(ser, "VERSION") # the first ask can land while the remote is still booting
|
|
4117
4382
|
|
|
4118
4383
|
# Slow full classify as a backstop (type changes, stuck "reading...").
|
|
4119
4384
|
if (not _tray_open and _disc_poll_future is None
|
|
@@ -4147,6 +4412,14 @@ def station_loop(ser):
|
|
|
4147
4412
|
if line == "PONG":
|
|
4148
4413
|
continue
|
|
4149
4414
|
|
|
4415
|
+
if line.startswith("VERSION:"):
|
|
4416
|
+
_set_remote_fw(line[8:].strip())
|
|
4417
|
+
continue
|
|
4418
|
+
|
|
4419
|
+
if line.startswith("OTA:"):
|
|
4420
|
+
_record_ota(line[4:].strip())
|
|
4421
|
+
continue
|
|
4422
|
+
|
|
4150
4423
|
if line.startswith("MENU:"):
|
|
4151
4424
|
print(f"Menu: {line.split(':', 1)[1]}")
|
|
4152
4425
|
continue
|
|
@@ -4209,8 +4482,7 @@ def station_loop(ser):
|
|
|
4209
4482
|
# before its own START got consumed (e.g. never uploaded/confirmed a
|
|
4210
4483
|
# URL), that stale START would otherwise sit buffered and fire this
|
|
4211
4484
|
# new, different selection instead. Drop anything unconsumed first.
|
|
4212
|
-
|
|
4213
|
-
ser.clear()
|
|
4485
|
+
ser.virtual.clear()
|
|
4214
4486
|
|
|
4215
4487
|
# The user picked a mode — they want to act on a disc, so the drive is
|
|
4216
4488
|
# fair game again even if it was ejected from the OLED earlier.
|
|
@@ -4297,7 +4569,7 @@ def parse_args():
|
|
|
4297
4569
|
|
|
4298
4570
|
|
|
4299
4571
|
def main():
|
|
4300
|
-
global _active_ser,
|
|
4572
|
+
global _active_ser, _appliance_mode
|
|
4301
4573
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
4302
4574
|
check_pidfile()
|
|
4303
4575
|
|
|
@@ -4308,71 +4580,35 @@ def main():
|
|
|
4308
4580
|
try:
|
|
4309
4581
|
start_web_server(args.port)
|
|
4310
4582
|
|
|
4583
|
+
# One link object for the life of the process; the monitor thread swaps the
|
|
4584
|
+
# physical remote (USB serial / Wi-Fi) in and out underneath running flows.
|
|
4585
|
+
ser = HybridSerial()
|
|
4586
|
+
_active_ser = ser
|
|
4587
|
+
_appliance_mode = "software"
|
|
4588
|
+
_link_monitor(ser, first_pass=True) # a remote already plugged in is up before the menu starts
|
|
4589
|
+
if ser.hw is None:
|
|
4590
|
+
print("No ESP32 found - running in software-only mode (web remote).")
|
|
4591
|
+
_sse_publish(_status_snapshot())
|
|
4592
|
+
threading.Thread(target=_link_monitor, args=(ser,), daemon=True).start()
|
|
4593
|
+
|
|
4311
4594
|
while True:
|
|
4312
4595
|
try:
|
|
4313
|
-
_line_buf = b""
|
|
4314
|
-
ser = None
|
|
4315
|
-
|
|
4316
|
-
# 1. USB serial wins whenever it's present (no mDNS scan then).
|
|
4317
|
-
port = discstation_host.serial_port()
|
|
4318
|
-
if port:
|
|
4319
|
-
print(f"Using ESP32 serial port: {port}")
|
|
4320
|
-
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
|
|
4321
|
-
if discstation_host.system_name() == "linux":
|
|
4322
|
-
ser.setDTR(False)
|
|
4323
|
-
time.sleep(0.1)
|
|
4324
|
-
ser.setDTR(True)
|
|
4325
|
-
time.sleep(2)
|
|
4326
|
-
discstation_burn.reset_serial_state()
|
|
4327
|
-
_appliance_mode = "hardware"
|
|
4328
|
-
|
|
4329
|
-
# 2. Else look for a Wi-Fi remote (DISC_REMOTE_HOST, default auto/mDNS).
|
|
4330
|
-
if ser is None:
|
|
4331
|
-
remote = discstation_host.remote_host()
|
|
4332
|
-
if remote:
|
|
4333
|
-
try:
|
|
4334
|
-
print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
|
|
4335
|
-
ser = TcpSerial(remote)
|
|
4336
|
-
discstation_burn.reset_serial_state()
|
|
4337
|
-
_appliance_mode = "hardware"
|
|
4338
|
-
print(f"Wi-Fi remote link up ({remote}).")
|
|
4339
|
-
except OSError as e:
|
|
4340
|
-
print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
|
|
4341
|
-
ser = None
|
|
4342
|
-
|
|
4343
|
-
# 3. Else the on-screen web/app remote is the control surface.
|
|
4344
|
-
if ser is None:
|
|
4345
|
-
print("No ESP32 found - running in software-only mode (web remote).")
|
|
4346
|
-
ser = VirtualSerial()
|
|
4347
|
-
_appliance_mode = "software"
|
|
4348
|
-
_active_ser = ser
|
|
4349
|
-
# Nothing else publishes an SSE update purely for an appliance-
|
|
4350
|
-
# mode flip (STATUS/PROGRESS/etc. events do, this doesn't) - push
|
|
4351
|
-
# one now so a connected web remote learns about a newly-attached
|
|
4352
|
-
# ESP32 immediately instead of only on its next reload.
|
|
4353
|
-
_sse_publish(_status_snapshot())
|
|
4354
4596
|
station_loop(ser)
|
|
4355
|
-
except _HardwareAttached:
|
|
4356
|
-
print("ESP32 detected - handing off from the web remote to hardware.")
|
|
4357
|
-
except (serial.SerialException, OSError, termios.error) as e:
|
|
4358
|
-
print(f"Disconnected ({e}), reconnecting in 3s...")
|
|
4359
|
-
time.sleep(3)
|
|
4360
4597
|
except KeyboardInterrupt:
|
|
4361
4598
|
raise
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
try:
|
|
4366
|
-
ser.close()
|
|
4367
|
-
except Exception:
|
|
4368
|
-
pass
|
|
4369
|
-
ser = None
|
|
4599
|
+
except (serial.SerialException, OSError, termios.error) as e:
|
|
4600
|
+
print(f"Station loop error ({e}), restarting...")
|
|
4601
|
+
time.sleep(1)
|
|
4370
4602
|
except KeyboardInterrupt:
|
|
4371
4603
|
print("\nStopped.")
|
|
4372
4604
|
exit_code = 130
|
|
4373
4605
|
except Exception as e:
|
|
4374
4606
|
print(f"Error: {e}")
|
|
4375
4607
|
exit_code = 1
|
|
4608
|
+
finally:
|
|
4609
|
+
_active_ser = None
|
|
4610
|
+
if ser:
|
|
4611
|
+
ser.close()
|
|
4376
4612
|
|
|
4377
4613
|
if exit_code:
|
|
4378
4614
|
sys.exit(exit_code)
|
package/src/static/app.js
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function applyRemoteState(progress) {
|
|
93
|
+
applyFirmwareState(progress);
|
|
93
94
|
const hardware = progress.appliance === "hardware";
|
|
94
95
|
if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
|
|
95
96
|
// Software mode has no other control surface at all - don't make it
|
|
@@ -107,6 +108,32 @@
|
|
|
107
108
|
ejectBtn.dataset.cmd = open ? "CONFIRM" : "EJECT";
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
// Firmware row: outside the remote panel on purpose - a hardware remote hides
|
|
112
|
+
// that panel, and hardware is exactly when there is a remote to update.
|
|
113
|
+
function applyFirmwareState(progress) {
|
|
114
|
+
const state = progress.remote_update;
|
|
115
|
+
const msg = progress.remote_update_msg;
|
|
116
|
+
const text = $("fw-update-text");
|
|
117
|
+
const btn = $("fw-update-btn");
|
|
118
|
+
const updating = progress.active && /^UPDATING REMOTE/.test(progress.status || "");
|
|
119
|
+
let line = "";
|
|
120
|
+
if (state === "available") line = `Remote firmware ${progress.remote_fw} — ${progress.remote_fw_latest} is available.`;
|
|
121
|
+
else if (state === "unknown") line = "This remote's firmware can't update itself yet — flash it once with a USB cable, then future updates are one click.";
|
|
122
|
+
if (msg) line += ` Update failed (${msg}).`;
|
|
123
|
+
text.textContent = line;
|
|
124
|
+
btn.hidden = state !== "available";
|
|
125
|
+
btn.disabled = !!updating;
|
|
126
|
+
$("fw-update").hidden = !line;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
$("fw-update-btn").addEventListener("click", async () => {
|
|
130
|
+
$("fw-update-btn").disabled = true;
|
|
131
|
+
try {
|
|
132
|
+
const r = await fetch("/remote/update", { method: "POST" });
|
|
133
|
+
if (!r.ok) $("fw-update-text").textContent = await r.text();
|
|
134
|
+
} catch (_) { /* SSE/poll shows the real state */ }
|
|
135
|
+
});
|
|
136
|
+
|
|
110
137
|
async function sendRemoteCmd(cmd) {
|
|
111
138
|
try {
|
|
112
139
|
await fetch("/remote/button", {
|
package/src/static/index.html
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
</header>
|
|
29
29
|
|
|
30
30
|
<main>
|
|
31
|
+
<section id="fw-update" class="fw-update section-rule" hidden>
|
|
32
|
+
<p id="fw-update-text" class="field-note"></p>
|
|
33
|
+
<button id="fw-update-btn" class="outline-button" type="button" hidden>UPDATE REMOTE</button>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
31
36
|
<section class="capability-strip section-rule">
|
|
32
37
|
<div><strong>01</strong><span>VIDEO DVD</span></div>
|
|
33
38
|
<div><strong>02</strong><span>DATA DVD</span></div>
|
|
@@ -139,7 +144,7 @@
|
|
|
139
144
|
</footer>
|
|
140
145
|
</div>
|
|
141
146
|
<div id="install-slot"></div>
|
|
142
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
143
|
-
<script src="/static/app.js?v=
|
|
147
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=18").catch(() => {});</script>
|
|
148
|
+
<script src="/static/app.js?v=18" defer></script>
|
|
144
149
|
</body>
|
|
145
150
|
</html>
|