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.
- 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 +227 -110
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.1.
|
|
1
|
+
{"version":"0.1.42","size":1260592,"md5":"fc62c97a1f9e0bedc579426771789964"}
|
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,8 @@ 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)
|
|
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
|
|
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
|
-
"""
|
|
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")
|
|
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
|
|
3262
|
+
visualizer can't run here (no numpy, no PulseAudio).
|
|
3085
3263
|
Linux only."""
|
|
3086
|
-
if _np is None or
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
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)
|