discstation 0.1.40 → 0.1.41
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 +11 -0
- package/scripts/postinstall.mjs +2 -0
- package/src/discstation.py +122 -3
- 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.41","size":1260592,"md5":"e14a89bd9096d23ad5ca43b0a8d9bea0"}
|
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.41",
|
|
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,17 @@ 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() == ""
|
|
58
69
|
print("smoke ok")
|
|
59
70
|
EOF
|
|
60
71
|
)
|
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
|
|
|
@@ -149,6 +154,10 @@ class TcpSerial:
|
|
|
149
154
|
self._reader = threading.Thread(target=self._pump, daemon=True)
|
|
150
155
|
self._reader.start()
|
|
151
156
|
|
|
157
|
+
def local_addr(self):
|
|
158
|
+
"""This machine's address on the connection to the remote."""
|
|
159
|
+
return self._sock.getsockname()[0]
|
|
160
|
+
|
|
152
161
|
def _pump(self):
|
|
153
162
|
while self._alive:
|
|
154
163
|
try:
|
|
@@ -225,6 +234,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
225
234
|
self._serve_disc_info()
|
|
226
235
|
elif path == '/events':
|
|
227
236
|
self._serve_sse()
|
|
237
|
+
elif path == '/firmware.bin':
|
|
238
|
+
self._serve_firmware()
|
|
228
239
|
elif path == '/sw.js':
|
|
229
240
|
self._serve_sw()
|
|
230
241
|
elif path == '/manifest.json':
|
|
@@ -246,6 +257,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
246
257
|
self._handle_set_label()
|
|
247
258
|
elif path == '/remote/button':
|
|
248
259
|
self._handle_remote_button()
|
|
260
|
+
elif path == '/remote/update':
|
|
261
|
+
self._handle_remote_update()
|
|
249
262
|
else:
|
|
250
263
|
self.send_error(404)
|
|
251
264
|
|
|
@@ -330,6 +343,41 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
330
343
|
else:
|
|
331
344
|
self._respond(409, 'A hardware remote is attached')
|
|
332
345
|
|
|
346
|
+
def _serve_firmware(self):
|
|
347
|
+
image, _ = _firmware_image()
|
|
348
|
+
if not image:
|
|
349
|
+
self.send_error(404)
|
|
350
|
+
return
|
|
351
|
+
data = image.read_bytes()
|
|
352
|
+
self.send_response(200)
|
|
353
|
+
self.send_header('Content-Type', 'application/octet-stream')
|
|
354
|
+
self.send_header('Content-Length', str(len(data)))
|
|
355
|
+
self.end_headers()
|
|
356
|
+
self.wfile.write(data)
|
|
357
|
+
|
|
358
|
+
def _handle_remote_update(self):
|
|
359
|
+
"""Tell the attached remote to download the firmware image this host ships
|
|
360
|
+
(OTA_URL:<url>|<size>|<md5>). The remote verifies the MD5 before switching."""
|
|
361
|
+
global _ota_msg
|
|
362
|
+
ser = _active_ser
|
|
363
|
+
image, meta = _firmware_image()
|
|
364
|
+
if ser is None or isinstance(ser, VirtualSerial):
|
|
365
|
+
self._respond(409, 'No hardware remote attached')
|
|
366
|
+
elif _operation_active:
|
|
367
|
+
self._respond(409, 'Wait until the current operation finishes')
|
|
368
|
+
elif not image or not _HTTP_PORT:
|
|
369
|
+
self._respond(409, 'This host has no firmware image to send')
|
|
370
|
+
elif _remote_update_state() != "available":
|
|
371
|
+
self._respond(409, 'The remote is already up to date' if _remote_fw else 'This remote needs one cable flash first')
|
|
372
|
+
else:
|
|
373
|
+
# the address the remote reaches us on: for the Wi-Fi link that is the
|
|
374
|
+
# socket's own local address (also what the firmware checks it against)
|
|
375
|
+
host = ser.local_addr() if hasattr(ser, "local_addr") else local_ip()
|
|
376
|
+
_ota_msg = ""
|
|
377
|
+
discstation_burn.safe_send(ser, f"OTA_URL:http://{host}:{_HTTP_PORT}/firmware.bin|{meta['size']}|{meta['md5']}")
|
|
378
|
+
_record_ota("0")
|
|
379
|
+
self._respond(200, 'OK')
|
|
380
|
+
|
|
333
381
|
def _serve_disc_info(self):
|
|
334
382
|
if _operation_active:
|
|
335
383
|
# a burn/rip/play holds the drive — don't probe it, serve last-known.
|
|
@@ -406,7 +454,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
406
454
|
def _serve_sw(self):
|
|
407
455
|
sw = '''self.addEventListener('install', e => {
|
|
408
456
|
self.skipWaiting();
|
|
409
|
-
caches.open('discstation-
|
|
457
|
+
caches.open('discstation-v18').then(c => c.addAll(['/','/static/style.css?v=18','/static/app.js?v=18']));
|
|
410
458
|
});
|
|
411
459
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
412
460
|
self.addEventListener('fetch', e => {
|
|
@@ -415,7 +463,7 @@ self.addEventListener('fetch', e => {
|
|
|
415
463
|
if (path === '/' || path.startsWith('/static/')) {
|
|
416
464
|
e.respondWith(fetch(e.request).then(r => {
|
|
417
465
|
const copy = r.clone();
|
|
418
|
-
caches.open('discstation-
|
|
466
|
+
caches.open('discstation-v18').then(c => c.put(e.request, copy));
|
|
419
467
|
return r;
|
|
420
468
|
}).catch(() => caches.match(e.request)));
|
|
421
469
|
} else {
|
|
@@ -523,6 +571,7 @@ self.addEventListener('fetch', e => {
|
|
|
523
571
|
|
|
524
572
|
|
|
525
573
|
def start_web_server(port=8080):
|
|
574
|
+
global _HTTP_PORT
|
|
526
575
|
global _web_server, _web_port
|
|
527
576
|
if _web_server:
|
|
528
577
|
return _web_server
|
|
@@ -569,6 +618,7 @@ def start_web_server(port=8080):
|
|
|
569
618
|
threading.Thread(target=plain.serve_forever, daemon=True).start()
|
|
570
619
|
print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
|
|
571
620
|
plain_http_up = True
|
|
621
|
+
_HTTP_PORT = http_port
|
|
572
622
|
except OSError as e:
|
|
573
623
|
print(f"Plain HTTP listener not started on {http_port}: {e}")
|
|
574
624
|
|
|
@@ -730,10 +780,65 @@ _sse_subs = set() # of Queue
|
|
|
730
780
|
_sse_lock = threading.Lock()
|
|
731
781
|
|
|
732
782
|
|
|
783
|
+
@functools.lru_cache(maxsize=1)
|
|
784
|
+
def _firmware_image():
|
|
785
|
+
"""(image path, {version,size,md5}) for the remote firmware shipped with this
|
|
786
|
+
host (scripts/build-firmware.sh), or (None, None)."""
|
|
787
|
+
here = Path(__file__).resolve().parent
|
|
788
|
+
for d in (here / "firmware", here.parent / "arduino" / "firmware"):
|
|
789
|
+
try:
|
|
790
|
+
meta = json.loads((d / "firmware.json").read_text())
|
|
791
|
+
if (d / "discstation.bin").is_file():
|
|
792
|
+
return d / "discstation.bin", meta
|
|
793
|
+
except (OSError, ValueError):
|
|
794
|
+
pass
|
|
795
|
+
return None, None
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _remote_update_state():
|
|
799
|
+
"""'' no hardware remote / still asking, 'unknown' the remote never answered
|
|
800
|
+
VERSION (pre-OTA firmware: needs one cable flash), 'current', 'available'."""
|
|
801
|
+
if _appliance_mode != "hardware":
|
|
802
|
+
return ""
|
|
803
|
+
_, meta = _firmware_image()
|
|
804
|
+
if _remote_fw is None:
|
|
805
|
+
return "unknown" if meta and time.time() - _remote_fw_asked > 14 else ""
|
|
806
|
+
try:
|
|
807
|
+
newer = tuple(map(int, meta["version"].split("."))) > tuple(map(int, _remote_fw.split(".")))
|
|
808
|
+
except (TypeError, ValueError): # "dev" builds (or an unparsable version) never nag
|
|
809
|
+
newer = False
|
|
810
|
+
return "available" if newer else "current"
|
|
811
|
+
|
|
812
|
+
|
|
733
813
|
def _status_snapshot():
|
|
814
|
+
_, meta = _firmware_image()
|
|
734
815
|
return {"status": _web_status or "READY", "progress": _web_progress,
|
|
735
816
|
"active": _web_progress_active, "appliance": _appliance_mode,
|
|
736
|
-
"playing": _web_playing, "tray_open": _tray_open
|
|
817
|
+
"playing": _web_playing, "tray_open": _tray_open,
|
|
818
|
+
"remote_fw": _remote_fw, "remote_fw_latest": meta["version"] if meta else None,
|
|
819
|
+
"remote_update": _remote_update_state(), "remote_update_msg": _ota_msg}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _set_remote_fw(version):
|
|
823
|
+
global _remote_fw, _web_status, _web_progress
|
|
824
|
+
_remote_fw = version
|
|
825
|
+
if (_web_status or "").startswith("REMOTE UPDATED"):
|
|
826
|
+
_web_status, _web_progress = "READY", -1
|
|
827
|
+
_sse_publish(_status_snapshot())
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _record_ota(msg):
|
|
831
|
+
"""OTA:<pct> | OTA:DONE | OTA:ERR <why> lines from the remote while it updates."""
|
|
832
|
+
global _web_status, _web_progress, _web_progress_active, _ota_msg, _remote_fw, _remote_fw_asked
|
|
833
|
+
if msg.isdigit():
|
|
834
|
+
_web_status, _web_progress, _web_progress_active = f"UPDATING REMOTE {msg}%", int(msg), True
|
|
835
|
+
elif msg == "DONE":
|
|
836
|
+
_web_status, _web_progress, _web_progress_active = "REMOTE UPDATED - RESTARTING", 100, False
|
|
837
|
+
_remote_fw, _remote_fw_asked = None, time.time() # the USB link survives the reboot: ask again
|
|
838
|
+
else:
|
|
839
|
+
_web_status, _web_progress, _web_progress_active = "REMOTE UPDATE FAILED", -1, False
|
|
840
|
+
_ota_msg = msg.removeprefix("ERR").strip()
|
|
841
|
+
_sse_publish(_status_snapshot())
|
|
737
842
|
|
|
738
843
|
|
|
739
844
|
def _sse_publish(event):
|
|
@@ -3988,6 +4093,10 @@ def station_loop(ser):
|
|
|
3988
4093
|
break
|
|
3989
4094
|
|
|
3990
4095
|
safe_send(ser, "STANDBY:Starting...")
|
|
4096
|
+
if not isinstance(ser, VirtualSerial):
|
|
4097
|
+
global _remote_fw, _remote_fw_asked
|
|
4098
|
+
_remote_fw, _remote_fw_asked = None, time.time()
|
|
4099
|
+
safe_send(ser, "VERSION")
|
|
3991
4100
|
|
|
3992
4101
|
last_disc_line = None
|
|
3993
4102
|
last_disc_poll = 0
|
|
@@ -4102,6 +4211,8 @@ def station_loop(ser):
|
|
|
4102
4211
|
if now - last_ping >= 5:
|
|
4103
4212
|
last_ping = now
|
|
4104
4213
|
safe_send(ser, "PING")
|
|
4214
|
+
if _remote_fw is None and not isinstance(ser, VirtualSerial):
|
|
4215
|
+
safe_send(ser, "VERSION") # the first ask can land while the remote is still booting
|
|
4105
4216
|
if isinstance(ser, VirtualSerial):
|
|
4106
4217
|
# Safe point (no flow active) to check whether a real link -
|
|
4107
4218
|
# USB serial or the Wi-Fi remote - has appeared, and hand off
|
|
@@ -4147,6 +4258,14 @@ def station_loop(ser):
|
|
|
4147
4258
|
if line == "PONG":
|
|
4148
4259
|
continue
|
|
4149
4260
|
|
|
4261
|
+
if line.startswith("VERSION:"):
|
|
4262
|
+
_set_remote_fw(line[8:].strip())
|
|
4263
|
+
continue
|
|
4264
|
+
|
|
4265
|
+
if line.startswith("OTA:"):
|
|
4266
|
+
_record_ota(line[4:].strip())
|
|
4267
|
+
continue
|
|
4268
|
+
|
|
4150
4269
|
if line.startswith("MENU:"):
|
|
4151
4270
|
print(f"Menu: {line.split(':', 1)[1]}")
|
|
4152
4271
|
continue
|
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>
|