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 +7 -2
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -1
- package/package.json +1 -1
- package/scripts/check.sh +18 -0
- package/src/discstation.py +251 -112
- package/src/static/app.js +13 -13
- package/src/static/index.html +2 -2
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
|
|
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.
|
|
1
|
+
{"version":"0.1.43","size":1260592,"md5":"575faf0891e354ecd451d8454228a18e"}
|
package/package.json
CHANGED
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
|
)
|
package/src/discstation.py
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
"""
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
3284
|
+
visualizer can't run here (no numpy, no PulseAudio).
|
|
3085
3285
|
Linux only."""
|
|
3086
|
-
if _np is None or
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
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
|
-
|
|
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 =
|
|
57
|
-
fill.style.width =
|
|
58
|
-
fill.classList.toggle("indeterminate",
|
|
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) => {
|
package/src/static/index.html
CHANGED
|
@@ -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=
|
|
148
|
-
<script src="/static/app.js?v=
|
|
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>
|