discstation 0.1.22 → 0.1.26
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 +43 -8
- package/arduino/c6/DiscStation_C6.ino +293 -41
- package/arduino/c6/secrets.h.example +14 -0
- package/arduino/v1/DiscStation.ino +214 -19
- package/arduino/v1/secrets.h.example +14 -0
- package/discstation.env.example +7 -0
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/scripts/lib/net.mjs +14 -0
- package/scripts/open.mjs +2 -0
- package/scripts/setup.mjs +4 -1
- package/src/discstation.py +150 -15
- package/src/discstation_burn.py +9 -0
- package/src/discstation_host.py +73 -0
package/src/discstation.py
CHANGED
|
@@ -114,9 +114,87 @@ class VirtualSerial:
|
|
|
114
114
|
pass
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
class TcpSerial:
|
|
118
|
+
"""serial.Serial look-alike over a TCP socket to the ESP32's Wi-Fi link
|
|
119
|
+
(firmware's WiFiServer on port 2323). Exposes the same tiny surface
|
|
120
|
+
station_loop and the flow functions use - write / readline / in_waiting /
|
|
121
|
+
read / close / setDTR - so nothing downstream knows it isn't a wire.
|
|
122
|
+
A dead link raises serial.SerialException from in_waiting/write, which is
|
|
123
|
+
what check_serial_alive() / main()'s reconnect loop already expect."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, host, port=2323, connect_timeout=5):
|
|
126
|
+
if host.count(":") == 1 and not host.startswith("["): # "ip:port"
|
|
127
|
+
host, _, p = host.rpartition(":")
|
|
128
|
+
if p.isdigit():
|
|
129
|
+
port = int(p)
|
|
130
|
+
self._sock = socket.create_connection((host, port), timeout=connect_timeout)
|
|
131
|
+
self._sock.settimeout(0.05)
|
|
132
|
+
self._buf = b""
|
|
133
|
+
self._lock = threading.Lock()
|
|
134
|
+
self._alive = True
|
|
135
|
+
self._reader = threading.Thread(target=self._pump, daemon=True)
|
|
136
|
+
self._reader.start()
|
|
137
|
+
|
|
138
|
+
def _pump(self):
|
|
139
|
+
while self._alive:
|
|
140
|
+
try:
|
|
141
|
+
chunk = self._sock.recv(4096)
|
|
142
|
+
except socket.timeout:
|
|
143
|
+
continue
|
|
144
|
+
except OSError:
|
|
145
|
+
break
|
|
146
|
+
if not chunk:
|
|
147
|
+
break
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._buf += chunk
|
|
150
|
+
self._alive = False
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def in_waiting(self):
|
|
154
|
+
if not self._alive:
|
|
155
|
+
raise serial.SerialException("Wi-Fi remote link closed")
|
|
156
|
+
with self._lock:
|
|
157
|
+
return len(self._buf)
|
|
158
|
+
|
|
159
|
+
def read(self, n=1):
|
|
160
|
+
with self._lock:
|
|
161
|
+
data, self._buf = self._buf[:n], self._buf[n:]
|
|
162
|
+
return data
|
|
163
|
+
|
|
164
|
+
def readline(self):
|
|
165
|
+
with self._lock:
|
|
166
|
+
idx = self._buf.find(b"\n")
|
|
167
|
+
if idx < 0:
|
|
168
|
+
data, self._buf = self._buf, b""
|
|
169
|
+
return data
|
|
170
|
+
line, self._buf = self._buf[:idx + 1], self._buf[idx + 1:]
|
|
171
|
+
return line
|
|
172
|
+
|
|
173
|
+
def write(self, data):
|
|
174
|
+
if not self._alive:
|
|
175
|
+
raise serial.SerialException("Wi-Fi remote link closed")
|
|
176
|
+
try:
|
|
177
|
+
self._sock.sendall(data)
|
|
178
|
+
return len(data) if data else 0
|
|
179
|
+
except OSError as e:
|
|
180
|
+
self._alive = False
|
|
181
|
+
raise serial.SerialException(f"Wi-Fi remote write failed: {e}") from e
|
|
182
|
+
|
|
183
|
+
def close(self):
|
|
184
|
+
self._alive = False
|
|
185
|
+
try:
|
|
186
|
+
self._sock.close()
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def setDTR(self, value):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
117
194
|
class _HardwareAttached(Exception):
|
|
118
|
-
"""Raised out of station_loop when a real
|
|
119
|
-
a VirtualSerial, so main() can hand
|
|
195
|
+
"""Raised out of station_loop when a real link (USB serial or the Wi-Fi
|
|
196
|
+
remote) appears while running on a VirtualSerial, so main() can hand
|
|
197
|
+
control over to it."""
|
|
120
198
|
|
|
121
199
|
|
|
122
200
|
class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
@@ -825,7 +903,12 @@ _line_buf = b""
|
|
|
825
903
|
def read_serial_line(ser, timeout=0.1):
|
|
826
904
|
global _line_buf
|
|
827
905
|
deadline = time.monotonic() + timeout
|
|
828
|
-
|
|
906
|
+
# Always make at least one non-blocking pass, even for timeout<=0 - the
|
|
907
|
+
# `remaining <= 0: break` at the bottom still ends it after that pass.
|
|
908
|
+
# (`while time.monotonic() < deadline` used to skip the body entirely for
|
|
909
|
+
# timeout=0, which is exactly how _check_cancel() calls this - so cancel
|
|
910
|
+
# detection during rips silently never read the port.)
|
|
911
|
+
while True:
|
|
829
912
|
if _line_buf:
|
|
830
913
|
_line_buf = _line_buf.lstrip(b'\r\n')
|
|
831
914
|
if _line_buf:
|
|
@@ -870,6 +953,10 @@ def read_serial_line(ser, timeout=0.1):
|
|
|
870
953
|
return None
|
|
871
954
|
|
|
872
955
|
|
|
956
|
+
# Let the burn pipeline's check_cancel() share this buffered reader.
|
|
957
|
+
discstation_burn.line_reader = read_serial_line
|
|
958
|
+
|
|
959
|
+
|
|
873
960
|
def check_serial_alive(ser=None):
|
|
874
961
|
"""Raise serial.SerialException if the ESP32 link looks dead, so main()'s
|
|
875
962
|
reconnect loop can re-scan for the (possibly renumbered) serial port.
|
|
@@ -2526,6 +2613,15 @@ def _check_cancel(ser):
|
|
|
2526
2613
|
return False
|
|
2527
2614
|
|
|
2528
2615
|
|
|
2616
|
+
def _raise_if_cancelled(ser):
|
|
2617
|
+
"""Poll for a CANCEL press between blocking phases that have no
|
|
2618
|
+
subprocess loop of their own (metadata lookups, scans, cover-art
|
|
2619
|
+
downloads). Doesn't interrupt a call in progress, but catches the press
|
|
2620
|
+
the moment the phase returns."""
|
|
2621
|
+
if _check_cancel(ser):
|
|
2622
|
+
raise CancelError
|
|
2623
|
+
|
|
2624
|
+
|
|
2529
2625
|
def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
2530
2626
|
lines = Queue()
|
|
2531
2627
|
finished = object()
|
|
@@ -3221,7 +3317,13 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3221
3317
|
except Exception:
|
|
3222
3318
|
pass
|
|
3223
3319
|
|
|
3224
|
-
|
|
3320
|
+
# Discard mpv's own output. Its terminal status line ("A: 00:04 / 00:15
|
|
3321
|
+
# ...") prints several times a second; left inheriting our stdout it
|
|
3322
|
+
# floods journald until the pipe backs up and our own print()/status
|
|
3323
|
+
# writes block, wedging the whole play loop. We drive mpv over the IPC
|
|
3324
|
+
# socket, so none of that output is wanted.
|
|
3325
|
+
proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
|
|
3326
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3225
3327
|
|
|
3226
3328
|
try:
|
|
3227
3329
|
if not wait_for_socket(MPV_SOCKET, proc):
|
|
@@ -3275,7 +3377,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3275
3377
|
mpv_command(["set_property", "speed", 1.0])
|
|
3276
3378
|
send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
|
|
3277
3379
|
|
|
3278
|
-
elif line
|
|
3380
|
+
elif line in ("PLAY_STOP", "EJECT"):
|
|
3381
|
+
# EJECT during playback = stop first; the web remote has no
|
|
3382
|
+
# separate stop button, and without this the command is
|
|
3383
|
+
# silently dropped here and playback never ends.
|
|
3279
3384
|
send(ser, "STATUS:Stopping play")
|
|
3280
3385
|
discstation_burn.stop_process(proc)
|
|
3281
3386
|
break
|
|
@@ -3760,7 +3865,10 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
3760
3865
|
time.sleep(3)
|
|
3761
3866
|
return
|
|
3762
3867
|
|
|
3868
|
+
_raise_if_cancelled(ser)
|
|
3869
|
+
send(ser, "STATUS:Scanning disc...")
|
|
3763
3870
|
scan = handbrake_scan(device)
|
|
3871
|
+
_raise_if_cancelled(ser)
|
|
3764
3872
|
if scan:
|
|
3765
3873
|
main = scan["main_feature"]
|
|
3766
3874
|
mins = next((t["duration_s"] // 60 for t in scan["titles"] if t["index"] == main), 0)
|
|
@@ -3863,6 +3971,7 @@ def rip_video_disc(ser, device, kind):
|
|
|
3863
3971
|
raise RuntimeError("No video files found")
|
|
3864
3972
|
|
|
3865
3973
|
for index, src in enumerate(files, start=1):
|
|
3974
|
+
_raise_if_cancelled(ser)
|
|
3866
3975
|
send(ser, f"PROGRESS:File {index}/{len(files)}")
|
|
3867
3976
|
dest = out_dir / f"{index:02d} - {safe_path_name(src.stem)}.mpg"
|
|
3868
3977
|
print(f"Ripping {src.name} -> {dest.name}")
|
|
@@ -3923,6 +4032,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
3923
4032
|
if len(wav_files) < len(chapters):
|
|
3924
4033
|
raise RuntimeError(f"Only ripped {len(wav_files)}/{len(chapters)} tracks")
|
|
3925
4034
|
for index, wav in enumerate(wav_files[:len(chapters)], start=1):
|
|
4035
|
+
_raise_if_cancelled(ser)
|
|
3926
4036
|
chapter = chapters[index - 1]
|
|
3927
4037
|
if metadata and index <= len(metadata["tracks"]):
|
|
3928
4038
|
track_meta = metadata["tracks"][index - 1]
|
|
@@ -3958,8 +4068,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
3958
4068
|
metadata = None
|
|
3959
4069
|
cover_path = None
|
|
3960
4070
|
|
|
4071
|
+
_raise_if_cancelled(ser)
|
|
3961
4072
|
send(ser, "STATUS:Looking up CD...")
|
|
3962
4073
|
metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
|
|
4074
|
+
_raise_if_cancelled(ser)
|
|
3963
4075
|
|
|
3964
4076
|
if metadata:
|
|
3965
4077
|
album_folder = safe_path_name(f"{metadata['album_artist']} - {metadata['album']}")
|
|
@@ -3978,6 +4090,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
3978
4090
|
out_dir,
|
|
3979
4091
|
metadata.get("release_group_id"),
|
|
3980
4092
|
)
|
|
4093
|
+
_raise_if_cancelled(ser)
|
|
3981
4094
|
|
|
3982
4095
|
send(ser, "STATUS:Ripping audio CD")
|
|
3983
4096
|
if metadata:
|
|
@@ -4222,14 +4335,15 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4222
4335
|
last_ping = now
|
|
4223
4336
|
safe_send(ser, "PING")
|
|
4224
4337
|
if isinstance(ser, VirtualSerial):
|
|
4225
|
-
# Safe point (no flow active) to check whether a real
|
|
4226
|
-
#
|
|
4338
|
+
# Safe point (no flow active) to check whether a real link -
|
|
4339
|
+
# USB serial or the Wi-Fi remote - has appeared, and hand off
|
|
4340
|
+
# to it instead of the web remote.
|
|
4227
4341
|
try:
|
|
4228
|
-
|
|
4342
|
+
link = discstation_host.serial_port() or discstation_host.remote_host()
|
|
4229
4343
|
except Exception:
|
|
4230
|
-
|
|
4231
|
-
if
|
|
4232
|
-
raise _HardwareAttached(
|
|
4344
|
+
link = None
|
|
4345
|
+
if link:
|
|
4346
|
+
raise _HardwareAttached(link)
|
|
4233
4347
|
else:
|
|
4234
4348
|
check_serial_alive(ser)
|
|
4235
4349
|
|
|
@@ -4348,6 +4462,11 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4348
4462
|
|
|
4349
4463
|
except KeyboardInterrupt:
|
|
4350
4464
|
raise
|
|
4465
|
+
except CancelError:
|
|
4466
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
4467
|
+
_last_burn_result = "Cancelled"
|
|
4468
|
+
print(f"{mode} cancelled by user")
|
|
4469
|
+
time.sleep(2)
|
|
4351
4470
|
except Exception as e:
|
|
4352
4471
|
safe_send(ser, f"ERROR:{str(e)[:50]}")
|
|
4353
4472
|
_last_burn_result = f"ERROR: {e}"
|
|
@@ -4433,6 +4552,9 @@ def main():
|
|
|
4433
4552
|
while True:
|
|
4434
4553
|
try:
|
|
4435
4554
|
_line_buf = b""
|
|
4555
|
+
ser = None
|
|
4556
|
+
|
|
4557
|
+
# 1. USB serial wins whenever it's present (no mDNS scan then).
|
|
4436
4558
|
port = discstation_host.serial_port()
|
|
4437
4559
|
if port:
|
|
4438
4560
|
print(f"Using ESP32 serial port: {port}")
|
|
@@ -4444,10 +4566,23 @@ def main():
|
|
|
4444
4566
|
time.sleep(2)
|
|
4445
4567
|
discstation_burn.reset_serial_state()
|
|
4446
4568
|
_appliance_mode = "hardware"
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4569
|
+
|
|
4570
|
+
# 2. Else look for a Wi-Fi remote (DISC_REMOTE_HOST, default auto/mDNS).
|
|
4571
|
+
if ser is None:
|
|
4572
|
+
remote = discstation_host.remote_host()
|
|
4573
|
+
if remote:
|
|
4574
|
+
try:
|
|
4575
|
+
print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
|
|
4576
|
+
ser = TcpSerial(remote)
|
|
4577
|
+
discstation_burn.reset_serial_state()
|
|
4578
|
+
_appliance_mode = "hardware"
|
|
4579
|
+
print(f"Wi-Fi remote link up ({remote}).")
|
|
4580
|
+
except OSError as e:
|
|
4581
|
+
print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
|
|
4582
|
+
ser = None
|
|
4583
|
+
|
|
4584
|
+
# 3. Else the on-screen web/app remote is the control surface.
|
|
4585
|
+
if ser is None:
|
|
4451
4586
|
print("No ESP32 found - running in software-only mode (web remote).")
|
|
4452
4587
|
ser = VirtualSerial()
|
|
4453
4588
|
_appliance_mode = "software"
|
package/src/discstation_burn.py
CHANGED
|
@@ -192,6 +192,9 @@ BAUD = 115200
|
|
|
192
192
|
|
|
193
193
|
def check_cancel(ser):
|
|
194
194
|
try:
|
|
195
|
+
if line_reader is not None:
|
|
196
|
+
line = line_reader(ser, 0)
|
|
197
|
+
return bool(line) and line in ("CANCEL", "PLAY_STOP")
|
|
195
198
|
if ser and ser.in_waiting:
|
|
196
199
|
line = ser.readline().decode(errors="ignore").strip()
|
|
197
200
|
note_serial_activity()
|
|
@@ -517,6 +520,12 @@ def detect_disc_type(device):
|
|
|
517
520
|
# pipeline emits also updates the web/SSE status in real time.
|
|
518
521
|
status_sink = None
|
|
519
522
|
|
|
523
|
+
# discstation.py sets this to its buffered read_serial_line so check_cancel
|
|
524
|
+
# reads the same way station_loop does (partial lines get buffered and
|
|
525
|
+
# reassembled). A bare ser.readline() here can slice a "CANCEL\n" in half
|
|
526
|
+
# on a mid-transmission read and then never match it.
|
|
527
|
+
line_reader = None
|
|
528
|
+
|
|
520
529
|
|
|
521
530
|
def send(ser, msg):
|
|
522
531
|
global _serial_write_failed
|
package/src/discstation_host.py
CHANGED
|
@@ -8,9 +8,11 @@ import os
|
|
|
8
8
|
import platform
|
|
9
9
|
import re
|
|
10
10
|
import shutil
|
|
11
|
+
import socket
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
15
|
+
import time
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
|
|
16
18
|
from serial.tools import list_ports
|
|
@@ -67,6 +69,77 @@ def serial_port():
|
|
|
67
69
|
return _stable_serial_path(sorted(preferred)[0])
|
|
68
70
|
|
|
69
71
|
|
|
72
|
+
_REMOTE_MDNS_TYPE = "_discstation._tcp.local."
|
|
73
|
+
_REMOTE_DEFAULT_HOST = "discstation.local"
|
|
74
|
+
_remote_cache = {"at": 0.0, "value": None}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def remote_host():
|
|
78
|
+
"""Address of the Wi-Fi appliance remote, or None.
|
|
79
|
+
|
|
80
|
+
`DISC_REMOTE_HOST` unset / `auto` (default) -> mDNS browse for the
|
|
81
|
+
firmware's `_discstation._tcp` advert, falling back to resolving
|
|
82
|
+
`discstation.local` via the OS resolver. Result cached ~60s so the
|
|
83
|
+
reconnect loop / hot-swap poll don't hammer mDNS.
|
|
84
|
+
`DISC_REMOTE_HOST=<ip|host>` -> that address, verbatim, instantly.
|
|
85
|
+
`DISC_REMOTE_HOST=off` / `none` / `0` -> None (never look; USB / web only).
|
|
86
|
+
|
|
87
|
+
Only consulted by main() when no USB serial is present. Best-effort -
|
|
88
|
+
main() tries to connect and falls back on failure."""
|
|
89
|
+
setting = (os.environ.get("DISC_REMOTE_HOST") or "").strip().lower()
|
|
90
|
+
if setting in ("off", "none", "no", "false", "0", "disabled"):
|
|
91
|
+
return None
|
|
92
|
+
if setting and setting != "auto":
|
|
93
|
+
return (os.environ.get("DISC_REMOTE_HOST") or "").strip()
|
|
94
|
+
|
|
95
|
+
if time.time() - _remote_cache["at"] < 60:
|
|
96
|
+
return _remote_cache["value"]
|
|
97
|
+
|
|
98
|
+
value = _discover_remote()
|
|
99
|
+
_remote_cache["at"] = time.time()
|
|
100
|
+
_remote_cache["value"] = value
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _discover_remote():
|
|
105
|
+
try:
|
|
106
|
+
from zeroconf import Zeroconf, ServiceBrowser
|
|
107
|
+
except ImportError:
|
|
108
|
+
Zeroconf = None
|
|
109
|
+
if Zeroconf is not None:
|
|
110
|
+
found = {}
|
|
111
|
+
|
|
112
|
+
class _Listener:
|
|
113
|
+
def add_service(self, zc, type_, name):
|
|
114
|
+
info = zc.get_service_info(type_, name, timeout=1500)
|
|
115
|
+
if info:
|
|
116
|
+
for addr in info.parsed_addresses():
|
|
117
|
+
if "." in addr: # IPv4
|
|
118
|
+
found["addr"] = addr
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
update_service = add_service
|
|
122
|
+
|
|
123
|
+
def remove_service(self, *a):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
zc = Zeroconf()
|
|
127
|
+
try:
|
|
128
|
+
ServiceBrowser(zc, _REMOTE_MDNS_TYPE, _Listener())
|
|
129
|
+
deadline = time.time() + 2.5
|
|
130
|
+
while time.time() < deadline and "addr" not in found:
|
|
131
|
+
time.sleep(0.1)
|
|
132
|
+
finally:
|
|
133
|
+
zc.close()
|
|
134
|
+
if found.get("addr"):
|
|
135
|
+
return found["addr"]
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
return socket.gethostbyname(_REMOTE_DEFAULT_HOST)
|
|
139
|
+
except OSError:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
70
143
|
def _stable_serial_path(device):
|
|
71
144
|
"""Map a volatile /dev/ttyUSBN to its stable /dev/serial/by-id/ symlink so a
|
|
72
145
|
USB re-enumeration (ttyUSB1 -> ttyUSB0) doesn't strand the reconnect loop."""
|