discstation 0.1.41 → 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.
Binary file
@@ -1 +1 @@
1
- {"version":"0.1.41","size":1260592,"md5":"e14a89bd9096d23ad5ca43b0a8d9bea0"}
1
+ {"version":"0.1.42","size":1260592,"md5":"fc62c97a1f9e0bedc579426771789964"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.41",
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",
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,8 @@ 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)
342
533
  self._respond(200, 'OK')
343
534
  else:
344
535
  self._respond(409, 'A hardware remote is attached')
@@ -361,7 +552,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
361
552
  global _ota_msg
362
553
  ser = _active_ser
363
554
  image, meta = _firmware_image()
364
- if ser is None or isinstance(ser, VirtualSerial):
555
+ if ser is None or ser.hw is None:
365
556
  self._respond(409, 'No hardware remote attached')
366
557
  elif _operation_active:
367
558
  self._respond(409, 'Wait until the current operation finishes')
@@ -372,7 +563,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
372
563
  else:
373
564
  # the address the remote reaches us on: for the Wi-Fi link that is the
374
565
  # 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()
566
+ host = ser.hw.local_addr() if hasattr(ser.hw, "local_addr") else local_ip()
376
567
  _ota_msg = ""
377
568
  discstation_burn.safe_send(ser, f"OTA_URL:http://{host}:{_HTTP_PORT}/firmware.bin|{meta['size']}|{meta['md5']}")
378
569
  _record_ota("0")
@@ -865,6 +1056,7 @@ def _set_web_progress(phase, percent=-1):
865
1056
 
866
1057
  def _record_web_status(msg):
867
1058
  global _web_status, _web_progress, _web_progress_active, _web_playing
1059
+ _remember(msg)
868
1060
  if msg.startswith("DISC:"):
869
1061
  _sse_publish({"type": "disc-changed"})
870
1062
  return
@@ -1044,23 +1236,9 @@ discstation_burn.line_reader = read_serial_line
1044
1236
 
1045
1237
 
1046
1238
  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")
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."""
1064
1242
 
1065
1243
 
1066
1244
  def send_disc_info(ser, device, status_line=None):
@@ -3049,7 +3227,7 @@ def _vu_loop(ser, stop_event, pause_event):
3049
3227
  if proc.poll() is not None:
3050
3228
  break
3051
3229
  continue
3052
- if pause_event.is_set():
3230
+ if pause_event.is_set() or ser.hw is None: # nobody to draw bars for
3053
3231
  continue
3054
3232
  samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
3055
3233
  spectrum = _np.abs(_np.fft.rfft(samples * window))
@@ -3081,9 +3259,9 @@ def _vu_loop(ser, stop_event, pause_event):
3081
3259
 
3082
3260
  def start_vu_visualizer(ser):
3083
3261
  """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).
3262
+ visualizer can't run here (no numpy, no PulseAudio).
3085
3263
  Linux only."""
3086
- 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":
3087
3265
  return None, None
3088
3266
  stop_event = threading.Event()
3089
3267
  pause_event = threading.Event()
@@ -4084,19 +4262,7 @@ def station_loop(ser):
4084
4262
  except Exception:
4085
4263
  device = None # empty drive (e.g. macOS after an eject) — keep the loop alive
4086
4264
 
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
4265
  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
4266
 
4101
4267
  last_disc_line = None
4102
4268
  last_disc_poll = 0
@@ -4211,20 +4377,8 @@ def station_loop(ser):
4211
4377
  if now - last_ping >= 5:
4212
4378
  last_ping = now
4213
4379
  safe_send(ser, "PING")
4214
- if _remote_fw is None and not isinstance(ser, VirtualSerial):
4380
+ if _remote_fw is None and ser.hw is not None:
4215
4381
  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
4382
 
4229
4383
  # Slow full classify as a backstop (type changes, stuck "reading...").
4230
4384
  if (not _tray_open and _disc_poll_future is None
@@ -4328,8 +4482,7 @@ def station_loop(ser):
4328
4482
  # before its own START got consumed (e.g. never uploaded/confirmed a
4329
4483
  # URL), that stale START would otherwise sit buffered and fire this
4330
4484
  # new, different selection instead. Drop anything unconsumed first.
4331
- if isinstance(ser, VirtualSerial):
4332
- ser.clear()
4485
+ ser.virtual.clear()
4333
4486
 
4334
4487
  # The user picked a mode — they want to act on a disc, so the drive is
4335
4488
  # fair game again even if it was ejected from the OLED earlier.
@@ -4416,7 +4569,7 @@ def parse_args():
4416
4569
 
4417
4570
 
4418
4571
  def main():
4419
- global _active_ser, _line_buf, _appliance_mode
4572
+ global _active_ser, _appliance_mode
4420
4573
  signal.signal(signal.SIGTERM, signal.SIG_IGN)
4421
4574
  check_pidfile()
4422
4575
 
@@ -4427,71 +4580,35 @@ def main():
4427
4580
  try:
4428
4581
  start_web_server(args.port)
4429
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
+
4430
4594
  while True:
4431
4595
  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
4596
  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
4597
  except KeyboardInterrupt:
4480
4598
  raise
4481
- finally:
4482
- _active_ser = None
4483
- if ser:
4484
- try:
4485
- ser.close()
4486
- except Exception:
4487
- pass
4488
- ser = None
4599
+ except (serial.SerialException, OSError, termios.error) as e:
4600
+ print(f"Station loop error ({e}), restarting...")
4601
+ time.sleep(1)
4489
4602
  except KeyboardInterrupt:
4490
4603
  print("\nStopped.")
4491
4604
  exit_code = 130
4492
4605
  except Exception as e:
4493
4606
  print(f"Error: {e}")
4494
4607
  exit_code = 1
4608
+ finally:
4609
+ _active_ser = None
4610
+ if ser:
4611
+ ser.close()
4495
4612
 
4496
4613
  if exit_code:
4497
4614
  sys.exit(exit_code)