Add App > Get with File: full app backup zip from the device

Reads app.sab and kits.scode over Sox file transfer, decodes app.sab to
app.sax (byte-identical to sedonac), and on Orion/Rion also fetches the
images and fonts the app names, with their sources from Sedona home's
banks. Offers to save the app on the device first when it is dirty, and
asks where to save with <Sedona home>/store/apps and
app-<device>-<app>-<timestamp>.zip filled in.

DASP trace lines are now printed by their own thread: printing from the
network threads to an unread stdout pipe blocked the receiver and made
file transfers time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:25:05 +03:00
co-authored by Claude Opus 5.5
parent 9b1d029b14
commit 894fdb7eb5
6 changed files with 970 additions and 10 deletions
+121 -2
View File
@@ -381,6 +381,11 @@ class SoxClient:
# event callback: called on main thread via queue
self.on_event = None # fn(comp_id, what_char)
# file transfer: one at a time; its chunks arrive unasked, so the receiver
# hands them to whichever transfer is open
self._xfer_lock = threading.Lock()
self._xfer = None # _FileGet while a get is open
# ── Connect / Disconnect ────────────────────────────────────
def connect(self):
@@ -902,6 +907,83 @@ class SoxClient:
def _get_slots(self, kit_id, type_id_val):
return self.slot_registry.get((kit_id, type_id_val))
# ── File transfer ───────────────────────────────────────────
def get_file(self, uri, progress=None):
"""Reads a file off the device ('app.sab', 'kits.scode', 'images/x.bin').
Ported from FileTransfer.getFile: an 'f' request opens the transfer, the
device then pushes the file as unsolicited 'k' chunks, and a 'z' closes it.
A file the device does not have comes back as a SoxException from the open.
progress(done_bytes, total_bytes) is called from this thread as chunks land.
"""
if self.is_closed:
raise SoxException("SoxClient closed")
with self._xfer_lock:
chunk_size = max(16, self._session.ideal_max - 18)
req = _prepare_request(ord('f'))
req.write_str("g")
req.write_str(uri)
req.write_i4(0) # fileSize: unknown on a get
req.write_u2(chunk_size)
req.write_u1(0) # end of headers
# Set before the open goes out: the first chunks can beat its reply.
xfer = _FileGet()
self._xfer = xfer
opened = closed = False
try:
try:
res = self.request(req)
except SoxException as e:
# A P-ION on firmware 1.32 never answers a file get -- it drops off
# the network for ~20 s, as a restart would -- even for a file it
# does not have.
raise SoxException(
f"The device did not answer the request for {uri} ({e}). Its "
f"firmware may not support reading files over Sox; some devices "
f"stop responding, or restart, when asked.") from e
res.check_response('F')
opened = True
file_size = res.i4()
chunk_size = res.u2()
while res.remaining > 0: # response headers -- nothing we use
if not res.str():
break
res.str()
num_chunks = -(-file_size // chunk_size) if chunk_size else 0
timeout = getattr(self._session, "receive_timeout", 30.0) or 30.0
while not xfer.done(num_chunks):
if self.is_closed:
raise SoxException(f"Session closed while reading {uri}")
if time.monotonic() - xfer.last_receive > timeout:
raise SoxException(f"Timed out reading {uri}")
xfer.wait(0.5)
if progress:
progress(min(file_size, xfer.count() * chunk_size), file_size)
close = self.request(_prepare_request(ord('z')))
closed = True
close.check_response('Z')
finally:
self._xfer = None
# Left open, the transfer keeps the device answering "busy" to the next.
if opened and not closed and not self.is_closed:
try:
self.request(_prepare_request(ord('z')))
except Exception:
pass
data = bytearray(file_size)
for num, chunk in xfer.chunks.items():
if num < num_chunks:
start = num * chunk_size
data[start:start + len(chunk)] = chunk
if progress:
progress(file_size, file_size)
return bytes(data)
# ── Networking ──────────────────────────────────────────────
def request(self, req):
@@ -986,6 +1068,39 @@ class SoxClient:
pass
class _FileGet:
"""The chunks of the get in progress, filled by the receiver thread."""
def __init__(self):
self.chunks = {}
self.last_receive = time.monotonic()
self._event = threading.Event()
self._lock = threading.Lock()
def receive_chunk(self, msg):
msg.u1() # 'k'
msg.u1() # replyNum
num = msg.u2()
size = msg.u2()
data = msg.read_bytes(size)
with self._lock:
self.chunks[num] = data
self.last_receive = time.monotonic()
self._event.set()
def count(self):
with self._lock:
return len(self.chunks)
def done(self, num_chunks):
with self._lock:
return all(n in self.chunks for n in range(num_chunks))
def wait(self, seconds):
self._event.wait(seconds)
self._event.clear()
# ────────────────────────────────────────────────────────────────
# SoxReceiver — daemon thread
# ────────────────────────────────────────────────────────────────
@@ -1013,8 +1128,12 @@ class _SoxReceiver(threading.Thread):
cmd = payload[0]
if cmd == ord('e'):
client._receive_event(msg)
elif cmd == ord('k') or cmd == ord('z'):
pass # file transfer — not needed for editor
elif cmd == ord('k'):
xfer = client._xfer
if xfer is not None:
xfer.receive_chunk(msg)
elif cmd == ord('z'):
pass # close of a put -- the editor only gets files
else:
client._receive_response(msg)
except DaspException: