discstation 0.1.39 → 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 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
@@ -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.39",
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
  )
@@ -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) {
@@ -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-v17').then(c => c.addAll(['/','/static/style.css?v=17','/static/app.js?v=17']));
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-v17').then(c => c.put(e.request, copy));
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
@@ -184,6 +184,22 @@ def _mac_optical_device():
184
184
 
185
185
  _last_disc_device = None
186
186
 
187
+
188
+ class NoMediaError(FileNotFoundError):
189
+ """The optical drive is present but has no disc it can see."""
190
+
191
+
192
+ def _mac_drive_name():
193
+ """Drive model from ioreg, which lists it whether or not a disc is loaded
194
+ (the /dev/diskN node only exists while media is)."""
195
+ try:
196
+ out = subprocess.run(["/usr/sbin/ioreg", "-r", "-c", "IODVDServices", "-l"],
197
+ capture_output=True, text=True, check=False, timeout=3).stdout
198
+ except (OSError, subprocess.TimeoutExpired):
199
+ return None
200
+ m = re.search(r'"Product Name"="([^"]+)"', out)
201
+ return m.group(1) if m else None
202
+
187
203
  # --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
188
204
  _WIN_DIR = Path(__file__).resolve().parent / "win"
189
205
  _win_info_cache = (0.0, None)
@@ -259,6 +275,9 @@ def disc_device():
259
275
  # known node so the caller can still probe it (it just reports no media).
260
276
  if _last_disc_device:
261
277
  return _last_disc_device
278
+ drive = _mac_drive_name()
279
+ if drive:
280
+ raise NoMediaError(f"Drive found ({drive}) but no disc is readable")
262
281
  elif override:
263
282
  return override
264
283
 
@@ -272,7 +291,7 @@ def disc_device():
272
291
  return str(drives[0])
273
292
  elif system == "darwin":
274
293
  try:
275
- status = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
294
+ status = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=15)
276
295
  except (OSError, subprocess.TimeoutExpired):
277
296
  status = None
278
297
  if status:
@@ -304,7 +323,7 @@ def drive_status():
304
323
  return "unknown"
305
324
  try:
306
325
  r = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True,
307
- text=True, check=False, timeout=4)
326
+ text=True, check=False, timeout=15)
308
327
  except (OSError, subprocess.TimeoutExpired):
309
328
  return "unknown"
310
329
  text = (r.stdout + r.stderr).lower()
@@ -330,7 +349,7 @@ def _tag_rewritable(props, media_type):
330
349
 
331
350
 
332
351
  def _drutil_media_properties():
333
- result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
352
+ result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=15)
334
353
  text = result.stdout + result.stderr
335
354
  lowered = text.lower()
336
355
  if result.returncode != 0 or "no media" in lowered:
@@ -382,7 +401,9 @@ def media_properties(device):
382
401
  mount_point = fields.get("Mount Point", "")
383
402
  if mount_point and mount_point != "Not applicable":
384
403
  props["ID_MOUNT_POINT"] = mount_point
385
- if label.lower() == "audio cd":
404
+ if label.lower() == "audio cd" or props.get("ID_FS_TYPE") == "cddafs":
405
+ # cddafs is macOS's filesystem for Red Book audio - the only signal
406
+ # that works for a burned CD-R, whose media type ("CD-R") has an "r".
386
407
  props["ID_CDROM_MEDIA_TYPE"] = "audio"
387
408
  elif "cd" in optical.lower() and "r" not in optical.lower():
388
409
  props["ID_CDROM_MEDIA_TYPE"] = "audio"
@@ -430,7 +451,7 @@ def media_capacity_bytes(device):
430
451
  try:
431
452
  result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
432
453
  except subprocess.TimeoutExpired:
433
- result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
454
+ result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=15)
434
455
  match = re.search(r"blocks:\s*(\d+)\s*/", result.stdout + result.stderr, re.IGNORECASE)
435
456
  if match:
436
457
  return int(match.group(1)) * 2048
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", {
@@ -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=17").catch(() => {});</script>
143
- <script src="/static/app.js?v=17" defer></script>
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>