discstation 0.1.41 → 0.1.43

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
@@ -175,7 +175,11 @@ self-signed cert. On startup the host prints the LAN URL to open
175
175
  — and picking one starts it directly, no separate confirm step. Also has
176
176
  EJECT/CLOSE toggle, a live disc-status readout, and full playback
177
177
  transport. Greys out and auto-collapses the instant a physical remote is
178
- detected, so the two never fight for control.
178
+ detected, so the two never fight for control — including mid-burn/rip/play:
179
+ plugging the remote in hands it control without interrupting whatever's
180
+ running, and its screen picks up the current status instead of starting
181
+ over; unplugging it hands control back to the on-screen remote within a
182
+ second or two.
179
183
 
180
184
  ## Mobile App
181
185
 
@@ -184,7 +188,8 @@ has the same on-screen remote as the web UI, rendered inline in the main
184
188
  screen rather than a popup — same "REMOTE" tag toggle, opens automatically
185
189
  with no ESP32 attached, mode buttons filtered to what the disc actually
186
190
  supports, and picking one starts it directly. Same auto-collapse behavior
187
- when a physical ESP32 shows up, same underlying `/remote/button` protocol.
191
+ when a physical ESP32 shows up — mid-operation and without interruption, same
192
+ as the web UI — and the same underlying `/remote/button` protocol.
188
193
 
189
194
  ## Disc Support
190
195
 
Binary file
@@ -1 +1 @@
1
- {"version":"0.1.41","size":1260592,"md5":"e14a89bd9096d23ad5ca43b0a8d9bea0"}
1
+ {"version":"0.1.43","size":1260592,"md5":"575faf0891e354ecd451d8454228a18e"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
package/scripts/check.sh CHANGED
@@ -66,6 +66,24 @@ d._remote_fw, d._remote_fw_asked = None, 0.0
66
66
  assert d._remote_update_state() == "unknown"
67
67
  d._appliance_mode = "software"
68
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"
69
87
  print("smoke ok")
70
88
  EOF
71
89
  )
@@ -151,6 +151,7 @@ class TcpSerial:
151
151
  self._buf = b""
152
152
  self._lock = threading.Lock()
153
153
  self._alive = True
154
+ self.last_rx = time.monotonic() # link monitor: silent this long = the remote is gone
154
155
  self._reader = threading.Thread(target=self._pump, daemon=True)
155
156
  self._reader.start()
156
157
 
@@ -168,6 +169,7 @@ class TcpSerial:
168
169
  break
169
170
  if not chunk:
170
171
  break
172
+ self.last_rx = time.monotonic()
171
173
  with self._lock:
172
174
  self._buf += chunk
173
175
  self._alive = False
@@ -214,10 +216,199 @@ class TcpSerial:
214
216
  pass
215
217
 
216
218
 
217
- class _HardwareAttached(Exception):
218
- """Raised out of station_loop when a real link (USB serial or the Wi-Fi
219
- remote) appears while running on a VirtualSerial, so main() can hand
220
- 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)
221
412
 
222
413
 
223
414
  class _WebHandler(http.server.BaseHTTPRequestHandler):
@@ -337,8 +528,16 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
337
528
  if not cmd:
338
529
  self._respond(400, 'Missing cmd')
339
530
  return
340
- if isinstance(_active_ser, VirtualSerial):
341
- _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)
533
+ # One-click burn: chain START right behind SELECT:BURN* in the SAME
534
+ # request, atomically - station_loop's SELECT handler clears any
535
+ # stale buffered input the instant it reads the SELECT line, and a
536
+ # START sent as a second, separate HTTP call can land in that exact
537
+ # gap and get wiped (intermittent, timing-dependent "burn never
538
+ # starts" bug). Queuing both lines from one call removes the gap.
539
+ if cmd.upper().startswith("SELECT:BURN"):
540
+ _active_ser.virtual.push_line("START")
342
541
  self._respond(200, 'OK')
343
542
  else:
344
543
  self._respond(409, 'A hardware remote is attached')
@@ -361,7 +560,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
361
560
  global _ota_msg
362
561
  ser = _active_ser
363
562
  image, meta = _firmware_image()
364
- if ser is None or isinstance(ser, VirtualSerial):
563
+ if ser is None or ser.hw is None:
365
564
  self._respond(409, 'No hardware remote attached')
366
565
  elif _operation_active:
367
566
  self._respond(409, 'Wait until the current operation finishes')
@@ -372,7 +571,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
372
571
  else:
373
572
  # the address the remote reaches us on: for the Wi-Fi link that is the
374
573
  # 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()
574
+ host = ser.hw.local_addr() if hasattr(ser.hw, "local_addr") else local_ip()
376
575
  _ota_msg = ""
377
576
  discstation_burn.safe_send(ser, f"OTA_URL:http://{host}:{_HTTP_PORT}/firmware.bin|{meta['size']}|{meta['md5']}")
378
577
  _record_ota("0")
@@ -454,7 +653,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
454
653
  def _serve_sw(self):
455
654
  sw = '''self.addEventListener('install', e => {
456
655
  self.skipWaiting();
457
- caches.open('discstation-v18').then(c => c.addAll(['/','/static/style.css?v=18','/static/app.js?v=18']));
656
+ caches.open('discstation-v20').then(c => c.addAll(['/','/static/style.css?v=20','/static/app.js?v=20']));
458
657
  });
459
658
  self.addEventListener('activate', e => e.waitUntil(clients.claim()));
460
659
  self.addEventListener('fetch', e => {
@@ -463,7 +662,7 @@ self.addEventListener('fetch', e => {
463
662
  if (path === '/' || path.startsWith('/static/')) {
464
663
  e.respondWith(fetch(e.request).then(r => {
465
664
  const copy = r.clone();
466
- caches.open('discstation-v18').then(c => c.put(e.request, copy));
665
+ caches.open('discstation-v20').then(c => c.put(e.request, copy));
467
666
  return r;
468
667
  }).catch(() => caches.match(e.request)));
469
668
  } else {
@@ -654,6 +853,20 @@ def wait_for_web_url(ser):
654
853
  url = f"{protocol}://{ip}:{_web_port}"
655
854
  safe_send(ser, f"IP:{url}")
656
855
  print(f"URL displayed: {url}")
856
+ # The one-click remote buttons queue START right behind SELECT:BURN* (see
857
+ # _handle_remote_button) - when files were uploaded BEFORE the mode was
858
+ # picked (the normal one-click order), that START can already be sitting
859
+ # in the buffer by the time this runs. Check the queue before touching the
860
+ # serial line at all: the loop below reads and discards one line per pass
861
+ # looking for CANCEL/HOME, and would otherwise eat that START and throw it
862
+ # away, leaving the burn waiting forever for a START that already came.
863
+ try:
864
+ got = _burn_url_queue.get_nowait()
865
+ except Empty:
866
+ got = None
867
+ if got:
868
+ safe_send(ser, "STATUS:Got URL, starting...")
869
+ return got
657
870
  while True:
658
871
  line = read_serial_line(ser, timeout=0.5)
659
872
  if line and line.strip().upper() in ("CANCEL", "HOME"):
@@ -865,6 +1078,7 @@ def _set_web_progress(phase, percent=-1):
865
1078
 
866
1079
  def _record_web_status(msg):
867
1080
  global _web_status, _web_progress, _web_progress_active, _web_playing
1081
+ _remember(msg)
868
1082
  if msg.startswith("DISC:"):
869
1083
  _sse_publish({"type": "disc-changed"})
870
1084
  return
@@ -1044,23 +1258,9 @@ discstation_burn.line_reader = read_serial_line
1044
1258
 
1045
1259
 
1046
1260
  def check_serial_alive(ser=None):
1047
- """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
1048
- reconnect loop can re-scan for the (possibly renumbered) serial port.
1049
- Call this inside any long poll loop that would otherwise spin forever on a
1050
- stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently).
1051
-
1052
- Meaningless (and actively harmful) in pure web/software mode - there's no
1053
- ESP32 to go quiet, and serial_activity_age() only advances on real
1054
- incoming bytes, so a user just reading the screen for >35s before
1055
- clicking the next button on the web remote looked identical to a dead
1056
- link and killed the burn ("ESP32 not responding") with no ESP32 in the
1057
- picture at all."""
1058
- if isinstance(ser, VirtualSerial):
1059
- return
1060
- if discstation_burn.serial_write_failed():
1061
- raise serial.SerialException("serial write failed (ESP32 link lost)")
1062
- if discstation_burn.serial_activity_age() >= 35:
1063
- raise serial.SerialException("ESP32 not responding")
1261
+ """Kept for the flows that call it inside long poll loops. The link monitor
1262
+ (_link_monitor) now owns liveness and swaps a dead remote for the web remote
1263
+ without raising, so a lost remote no longer aborts a burn/rip/play."""
1064
1264
 
1065
1265
 
1066
1266
  def send_disc_info(ser, device, status_line=None):
@@ -3049,7 +3249,7 @@ def _vu_loop(ser, stop_event, pause_event):
3049
3249
  if proc.poll() is not None:
3050
3250
  break
3051
3251
  continue
3052
- if pause_event.is_set():
3252
+ if pause_event.is_set() or ser.hw is None: # nobody to draw bars for
3053
3253
  continue
3054
3254
  samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
3055
3255
  spectrum = _np.abs(_np.fft.rfft(samples * window))
@@ -3081,9 +3281,9 @@ def _vu_loop(ser, stop_event, pause_event):
3081
3281
 
3082
3282
  def start_vu_visualizer(ser):
3083
3283
  """Best-effort: returns (stop_event, pause_event), or (None, None) if the
3084
- visualizer can't run here (no numpy, no PulseAudio, or a web-only link).
3284
+ visualizer can't run here (no numpy, no PulseAudio).
3085
3285
  Linux only."""
3086
- if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
3286
+ if _np is None or discstation_host.system_name() != "linux":
3087
3287
  return None, None
3088
3288
  stop_event = threading.Event()
3089
3289
  pause_event = threading.Event()
@@ -4084,19 +4284,7 @@ def station_loop(ser):
4084
4284
  except Exception:
4085
4285
  device = None # empty drive (e.g. macOS after an eject) — keep the loop alive
4086
4286
 
4087
- for _ in range(50):
4088
- line = read_serial_line(ser, timeout=0.2)
4089
- if not line:
4090
- break
4091
- print(f"ESP32: {line}")
4092
- if "DISCSTATION_READY" in line:
4093
- break
4094
-
4095
4287
  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")
4100
4288
 
4101
4289
  last_disc_line = None
4102
4290
  last_disc_poll = 0
@@ -4211,20 +4399,8 @@ def station_loop(ser):
4211
4399
  if now - last_ping >= 5:
4212
4400
  last_ping = now
4213
4401
  safe_send(ser, "PING")
4214
- if _remote_fw is None and not isinstance(ser, VirtualSerial):
4402
+ if _remote_fw is None and ser.hw is not None:
4215
4403
  safe_send(ser, "VERSION") # the first ask can land while the remote is still booting
4216
- if isinstance(ser, VirtualSerial):
4217
- # Safe point (no flow active) to check whether a real link -
4218
- # USB serial or the Wi-Fi remote - has appeared, and hand off
4219
- # to it instead of the web remote.
4220
- try:
4221
- link = discstation_host.serial_port() or discstation_host.remote_host()
4222
- except Exception:
4223
- link = None
4224
- if link:
4225
- raise _HardwareAttached(link)
4226
- else:
4227
- check_serial_alive(ser)
4228
4404
 
4229
4405
  # Slow full classify as a backstop (type changes, stuck "reading...").
4230
4406
  if (not _tray_open and _disc_poll_future is None
@@ -4328,8 +4504,7 @@ def station_loop(ser):
4328
4504
  # before its own START got consumed (e.g. never uploaded/confirmed a
4329
4505
  # URL), that stale START would otherwise sit buffered and fire this
4330
4506
  # new, different selection instead. Drop anything unconsumed first.
4331
- if isinstance(ser, VirtualSerial):
4332
- ser.clear()
4507
+ ser.virtual.clear()
4333
4508
 
4334
4509
  # The user picked a mode — they want to act on a disc, so the drive is
4335
4510
  # fair game again even if it was ejected from the OLED earlier.
@@ -4416,7 +4591,7 @@ def parse_args():
4416
4591
 
4417
4592
 
4418
4593
  def main():
4419
- global _active_ser, _line_buf, _appliance_mode
4594
+ global _active_ser, _appliance_mode
4420
4595
  signal.signal(signal.SIGTERM, signal.SIG_IGN)
4421
4596
  check_pidfile()
4422
4597
 
@@ -4427,71 +4602,35 @@ def main():
4427
4602
  try:
4428
4603
  start_web_server(args.port)
4429
4604
 
4605
+ # One link object for the life of the process; the monitor thread swaps the
4606
+ # physical remote (USB serial / Wi-Fi) in and out underneath running flows.
4607
+ ser = HybridSerial()
4608
+ _active_ser = ser
4609
+ _appliance_mode = "software"
4610
+ _link_monitor(ser, first_pass=True) # a remote already plugged in is up before the menu starts
4611
+ if ser.hw is None:
4612
+ print("No ESP32 found - running in software-only mode (web remote).")
4613
+ _sse_publish(_status_snapshot())
4614
+ threading.Thread(target=_link_monitor, args=(ser,), daemon=True).start()
4615
+
4430
4616
  while True:
4431
4617
  try:
4432
- _line_buf = b""
4433
- ser = None
4434
-
4435
- # 1. USB serial wins whenever it's present (no mDNS scan then).
4436
- port = discstation_host.serial_port()
4437
- if port:
4438
- print(f"Using ESP32 serial port: {port}")
4439
- ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
4440
- if discstation_host.system_name() == "linux":
4441
- ser.setDTR(False)
4442
- time.sleep(0.1)
4443
- ser.setDTR(True)
4444
- time.sleep(2)
4445
- discstation_burn.reset_serial_state()
4446
- _appliance_mode = "hardware"
4447
-
4448
- # 2. Else look for a Wi-Fi remote (DISC_REMOTE_HOST, default auto/mDNS).
4449
- if ser is None:
4450
- remote = discstation_host.remote_host()
4451
- if remote:
4452
- try:
4453
- print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
4454
- ser = TcpSerial(remote)
4455
- discstation_burn.reset_serial_state()
4456
- _appliance_mode = "hardware"
4457
- print(f"Wi-Fi remote link up ({remote}).")
4458
- except OSError as e:
4459
- print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
4460
- ser = None
4461
-
4462
- # 3. Else the on-screen web/app remote is the control surface.
4463
- if ser is None:
4464
- print("No ESP32 found - running in software-only mode (web remote).")
4465
- ser = VirtualSerial()
4466
- _appliance_mode = "software"
4467
- _active_ser = ser
4468
- # Nothing else publishes an SSE update purely for an appliance-
4469
- # mode flip (STATUS/PROGRESS/etc. events do, this doesn't) - push
4470
- # one now so a connected web remote learns about a newly-attached
4471
- # ESP32 immediately instead of only on its next reload.
4472
- _sse_publish(_status_snapshot())
4473
4618
  station_loop(ser)
4474
- except _HardwareAttached:
4475
- print("ESP32 detected - handing off from the web remote to hardware.")
4476
- except (serial.SerialException, OSError, termios.error) as e:
4477
- print(f"Disconnected ({e}), reconnecting in 3s...")
4478
- time.sleep(3)
4479
4619
  except KeyboardInterrupt:
4480
4620
  raise
4481
- finally:
4482
- _active_ser = None
4483
- if ser:
4484
- try:
4485
- ser.close()
4486
- except Exception:
4487
- pass
4488
- ser = None
4621
+ except (serial.SerialException, OSError, termios.error) as e:
4622
+ print(f"Station loop error ({e}), restarting...")
4623
+ time.sleep(1)
4489
4624
  except KeyboardInterrupt:
4490
4625
  print("\nStopped.")
4491
4626
  exit_code = 130
4492
4627
  except Exception as e:
4493
4628
  print(f"Error: {e}")
4494
4629
  exit_code = 1
4630
+ finally:
4631
+ _active_ser = None
4632
+ if ser:
4633
+ ser.close()
4495
4634
 
4496
4635
  if exit_code:
4497
4636
  sys.exit(exit_code)
package/src/static/app.js CHANGED
@@ -51,11 +51,16 @@
51
51
  function setProgress(phase, percent, active = true) {
52
52
  const panel = $("global-progress");
53
53
  const fill = $("progress-fill");
54
- panel.hidden = !active && percent < 0;
54
+ // Clamp here, the one place this renders - a browser's own upload
55
+ // "progress" event isn't guaranteed to stop exactly at loaded===total
56
+ // (seen reading past 100%), and this is the single choke point every
57
+ // caller (upload, burn/rip progress, SSE) goes through either way.
58
+ const shown = percent >= 0 ? Math.min(100, Math.max(0, Math.round(percent))) : -1;
59
+ panel.hidden = !active && shown < 0;
55
60
  $("progress-phase").textContent = (phase || "READY").toUpperCase();
56
- $("progress-value").textContent = percent >= 0 ? `${percent}%` : "...";
57
- fill.style.width = percent >= 0 ? `${Math.min(100, percent)}%` : "34%";
58
- fill.classList.toggle("indeterminate", percent < 0);
61
+ $("progress-value").textContent = shown >= 0 ? `${shown}%` : "...";
62
+ fill.style.width = shown >= 0 ? `${shown}%` : "34%";
63
+ fill.classList.toggle("indeterminate", shown < 0);
59
64
  }
60
65
 
61
66
  async function pollStatus() {
@@ -154,16 +159,11 @@
154
159
  const button = event.target.closest("[data-cmd]");
155
160
  if (!button || button.disabled) return;
156
161
  const cmd = button.dataset.cmd;
162
+ // One-click burn: the server queues START right behind SELECT:BURN* in the
163
+ // same request now (was two separate POSTs from here, which raced against
164
+ // the server clearing stale input the instant it read SELECT and could
165
+ // silently drop the START - see _handle_remote_button in discstation.py).
157
166
  await sendRemoteCmd(cmd);
158
- // On real hardware START is a long-press of the encoder on the burn-ready
159
- // review screen, not its own button - a one-click "BURN AUDIO"/"BURN
160
- // DATA"/"BURN" here is the whole point of the web remote, so chase the
161
- // mode select straight through to starting the burn instead of leaving
162
- // the user stuck with nothing left to press. (The backend queues this
163
- // harmlessly if files/URL aren't in yet - it's only consumed once the
164
- // burn's actually at its own "waiting for start" step, and gets flushed
165
- // if a different mode gets selected first so it can't fire the wrong burn.)
166
- if (cmd.startsWith("SELECT:BURN")) await sendRemoteCmd("START");
167
167
  });
168
168
  let volumeTimer;
169
169
  $("remote-volume").addEventListener("input", (event) => {
@@ -144,7 +144,7 @@
144
144
  </footer>
145
145
  </div>
146
146
  <div id="install-slot"></div>
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>
147
+ <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=20").catch(() => {});</script>
148
+ <script src="/static/app.js?v=20" defer></script>
149
149
  </body>
150
150
  </html>