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 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
@@ -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.40",
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
  )
@@ -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
 
@@ -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
- class _HardwareAttached(Exception):
209
- """Raised out of station_loop when a real link (USB serial or the Wi-Fi
210
- remote) appears while running on a VirtualSerial, so main() can hand
211
- control over to it."""
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 isinstance(_active_ser, VirtualSerial):
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-v17').then(c => c.addAll(['/','/static/style.css?v=17','/static/app.js?v=17']));
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-v17').then(c => c.put(e.request, copy));
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
- """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
943
- reconnect loop can re-scan for the (possibly renumbered) serial port.
944
- Call this inside any long poll loop that would otherwise spin forever on a
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, or a web-only link).
3262
+ visualizer can't run here (no numpy, no PulseAudio).
2980
3263
  Linux only."""
2981
- if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
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 isinstance(ser, VirtualSerial):
4106
- # Safe point (no flow active) to check whether a real link -
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
- if isinstance(ser, VirtualSerial):
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, _line_buf, _appliance_mode
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
- finally:
4363
- _active_ser = None
4364
- if ser:
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", {
@@ -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>