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
+244
View File
@@ -0,0 +1,244 @@
"""App > Get with File: a full backup of the connected device's app as one zip.
The zip follows the layout Ontrol's tools read back on a restore:
device/app.sab the files as the device holds them
device/kits.scode
device/images/<name>.bin
device/fonts/<font file>
device/system/logo.bin
app.sax app.sab decoded, as sedonac would write it
imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
system/logo.png
The device cannot list its files, so which images and fonts to fetch is read from the
app itself: every image name and font its Ox widgets and pages name.
"""
import datetime
import os
import re
import zipfile
import app_state
import sab_app
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
APP_SAB = "app.sab"
KITS_SCODE = "kits.scode"
LOGO_URI = "system/logo.bin"
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
_UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
class _Platform:
def __init__(self, name, image_bank, font_bank, font_extensions):
self.name = name
self.image_bank = image_bank
self.font_bank = font_bank
self.font_extensions = font_extensions
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font.
_ORION = _Platform("ORION", "imageBankXL", "fontBankXL", (".oxfl", ".oxfp"))
_RION = _Platform("RION", "imageBank", "fontBank", (".bin",))
def platform_of(app):
kits = app.kit_names()
if ORION_KIT in kits:
return _ORION
if RION_KIT in kits:
return _RION
return None
def backup_folder():
return os.path.join(app_state.SEDONA_HOME, "store", "apps")
def backup_file_name(device_name, app_name, when=None):
"""'app-<device name>-<app name>-<yyyyMMdd-HHmmss>.zip', made safe for Windows."""
stamp = (when or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S")
parts = [_UNSAFE_FILE_CHARS.sub("_", p).strip(" .") or "unnamed"
for p in (device_name, app_name)]
return f"app-{parts[0]}-{parts[1]}-{stamp}.zip"
class MediaFile:
"""One image or font the app names: where it lives on the device and in the bank.
`required` is False for guesses -- a bold font's plain cut, Orion's other font
orientation, an indexed page image -- whose absence is not worth reporting.
`group` ties the alternatives of one need together: the need is met when any of
them is found.
"""
def __init__(self, device_uri, bank_folder, bank_name, group, required=True):
self.device_uri = device_uri
self.bank_folder = bank_folder
self.bank_name = bank_name
self.group = group
self.required = required
def _font_stem(app, comp):
slot = app.slot(comp, "fontName")
names = [n.strip() for n in (slot.facets.get("range") or "Tahoma").split(",")] if slot else ["Tahoma"]
index = app.prop(comp, "fontName", 0) or 0
family = names[index] if 0 <= index < len(names) else names[0]
size = app.prop(comp, "fontSize", 16)
bold = app.prop(comp, "fontAttributes", 0) == 1
return f"{size}pt {family}", bold
def media_files(app, platform):
"""The images and fonts the app names, each with its device path and bank source."""
files, seen = [], set()
def add(uri, bank_folder, bank_name, group, required=True):
if uri not in seen:
seen.add(uri)
files.append(MediaFile(uri, bank_folder, bank_name, group, required))
def add_image(name, required=True):
if name:
add(f"images/{name}.bin", platform.image_bank, name, f"image {name}", required)
def add_font(stem, group, required):
for i, ext in enumerate(platform.font_extensions):
add(f"fonts/{stem}{ext}", platform.font_bank, stem + ext, group,
required and i == 0)
for comp in app.comps.values():
for slot_name in _IMAGE_SLOTS:
add_image(app.prop(comp, slot_name, ""))
prefix = app.prop(comp, "imageFileNamePrefix", "")
if prefix:
for i in range((app.prop(comp, "maxIndex", 1) or 0) + 1):
add_image(f"{prefix}{i}")
page_image = app.prop(comp, "imageFileName", "")
if page_image:
for i in range(1, (app.prop(comp, "maxImageId", 0) or 0) + 1):
add_image(f"{page_image}{i}", required=False)
if app.slot(comp, "fontSize") is not None:
stem, bold = _font_stem(app, comp)
group = f"font {'bold ' if bold else ''}{stem}"
if bold:
add_font(f"bold {stem}", group, True)
add_font(stem, group, not bold)
add(LOGO_URI, platform.image_bank, "logo", "logo", required=False)
return files
def _bank_source(bank_folder, bank_name, is_image):
"""The local bank file for a device file, or None."""
folder = os.path.join(app_state.SEDONA_HOME, "ontrol", bank_folder)
if not is_image:
path = os.path.join(folder, bank_name)
return path if os.path.isfile(path) else None
for ext in _SOURCE_IMAGE_EXTENSIONS + (".bin",):
path = os.path.join(folder, bank_name + ext)
if os.path.isfile(path):
return path
return None
def _source_entry(media, source_path):
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
file_name = os.path.basename(source_path)
if media.device_uri == LOGO_URI:
return "system/logo" + os.path.splitext(file_name)[1]
return f"{media.bank_folder}/{file_name}"
class BackupResult:
def __init__(self):
self.path = None
self.entries = []
self.missing_on_device = [] # media the app names that the device lacks
self.missing_in_bank = [] # device files with no source in Sedona home
self.sax_error = None # why app.sax could not be written
def get_with_file(client, path, status=None):
"""Reads the app and its media off the device and writes the backup zip to `path`.
Runs off the main thread; status(text, fraction) reports progress. Returns a
BackupResult; a failure to read app.sab or kits.scode raises.
"""
def report(text, fraction=None):
if status:
status(text, fraction)
def fetch(uri, base, span):
def progress(done, total):
report(f"Reading {uri} ...", base + span * (done / total if total else 1.0))
report(f"Reading {uri} ...", base)
try:
return client.get_file(uri, progress)
except Exception as e:
raise RuntimeError(f"Reading {uri}: {e}") from e
result = BackupResult()
entries = [] # (zip name, bytes or local path)
sab = fetch(APP_SAB, 0.0, 0.15)
entries.append(("device/" + APP_SAB, sab))
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
app = None
try:
app = sab_app.SabApp(sab)
entries.append(("app.sax", app.to_sax().encode("ascii")))
except Exception as e:
result.sax_error = str(e)
platform = platform_of(app) if app else None
media = media_files(app, platform) if platform else []
found_groups, wanted = set(), {}
for i, m in enumerate(media):
report(f"Reading {m.device_uri} ...", 0.5 + 0.45 * i / max(1, len(media)))
if m.required:
wanted.setdefault(m.group, m.device_uri)
try:
data = client.get_file(m.device_uri)
except Exception:
if client.is_closed:
raise
continue
found_groups.add(m.group)
entries.append(("device/" + m.device_uri, data))
source = _bank_source(m.bank_folder, m.bank_name, m.device_uri.startswith(("images/", "system/")))
if source:
entries.append((_source_entry(m, source), source))
else:
result.missing_in_bank.append(f"{m.bank_folder}/{m.bank_name}")
result.missing_on_device = [uri for group, uri in wanted.items() if group not in found_groups]
report("Writing the zip ...", 0.97)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
partial = path + ".part"
try:
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z:
for name, content in entries:
if isinstance(content, bytes):
z.writestr(name, content)
else:
z.write(content, name)
os.replace(partial, path)
finally:
if os.path.exists(partial):
os.remove(partial)
result.path = path
result.entries = [name for name, _ in entries]
report("Done", 1.0)
return result
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.141"
VERSION = "0.0.0.142"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+50 -1
View File
@@ -98,7 +98,56 @@ def _trace(direction, msg, datagram=None):
hexdump = ""
if datagram:
hexdump = " [" + datagram.hex(" ") + "]"
print(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}")
_trace_out(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}")
# Trace lines are printed by a thread of their own. The send and receive threads must
# never wait on the console: when stdout is a pipe nobody reads, print blocks once the
# pipe fills, and a file transfer's burst of chunks filled it in seconds -- the
# receiver stopped and every request timed out. A full queue drops lines instead.
_trace_queue = queue.Queue(maxsize=5000)
_trace_thread = None
_trace_dropped = 0
def _trace_out(line):
global _trace_thread, _trace_dropped
if _trace_thread is None:
_trace_thread = threading.Thread(target=_trace_writer, name="DaspTrace", daemon=True)
_trace_thread.start()
try:
_trace_queue.put_nowait(line)
except queue.Full:
_trace_dropped += 1
def _trace_writer():
global _trace_dropped
while True:
line = _trace_queue.get()
if _trace_dropped:
dropped, _trace_dropped = _trace_dropped, 0
line = f"[DASP] ... {dropped} trace lines dropped (console not keeping up)\n{line}"
_write_stdout(line + "\n")
def _write_stdout(text):
"""Writes to the process's stdout handle directly, past Python's buffered writer.
The buffered writer has a lock, and a daemon thread holding it while the
interpreter shuts down aborts the process ("could not acquire lock for stdout").
"""
import sys
stream = sys.__stdout__
if stream is None: # pythonw: no console to write to
return
try:
fd = stream.fileno()
data = text.encode(stream.encoding or "utf-8", errors="replace")
while data:
data = data[os.write(fd, data):]
except (OSError, ValueError):
pass
_ERROR_HINTS = {
+144 -6
View File
@@ -7,6 +7,7 @@ from tkinter import filedialog, font as tkfont, messagebox, ttk
import threading
import app_backup
import app_state
import connect_dialog
import device_session
@@ -579,38 +580,171 @@ def do_download_to_sax():
def _invoke_app_action(slot_name):
"""Invokes an action slot on the app component (comp_id 0) of the connected device."""
"""Invokes an action slot on the app component (comp_id 0) of the connected device.
Returns whether the device took it; why not is on the status bar.
"""
ds = app_state.device_session
if ds is None or not ds.is_connected:
app_state.set_status("Not connected to a device", warn=True)
return
return False
comp = ds.sox_client.cache_get(0)
if comp is None:
app_state.set_status("App component not loaded", warn=True)
return
return False
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None:
app_state.set_status("No slot registry for the app component", warn=True)
return
return False
slot = next((s for s in slots if s["name"] == slot_name and s.get("is_action")), None)
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return
return False
try:
ds.sox_client.invoke(0, slot["id"])
app_state.set_status(f"Invoked '{slot_name}' on device")
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return
return False
if slot_name == "save":
app_state.mark_clean()
elif slot_name in ("hibernate", "quit", "restart", "reboot"):
do_device_disconnect()
return True
_APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
def do_get_with_file():
"""App > Get with File: backs the device's app up to a zip in <Sedona home>/store/apps.
The zip carries the device's app.sab, so changes not yet saved on the device are
offered a save first. The transfer runs off the main thread behind a small progress
window.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
messagebox.showinfo("Get with File", "Connect to a device first.")
return
if not app_state.SEDONA_HOME:
messagebox.showerror("Get with File", "The Sedona home folder is not set (Preferences).")
return
if app_state.is_dirty:
answer = messagebox.askyesnocancel(
"Unsaved Changes",
"The app has changes that are not saved on the device yet." + NL + NL +
"Save the app on the device before getting it?" + NL +
"(No gets the app as it was last saved.)",
)
if answer is None:
return
if answer and not _invoke_app_action("save"):
messagebox.showerror(
"Get with File",
"The device did not save the app, so nothing was fetched. "
"The status bar says why.")
return
path = _ask_backup_path(ds)
if not path:
return
window = app_state.window
dlg = tk.Toplevel(window)
dlg.title("Get with File")
dlg.resizable(False, False)
dlg.transient(window)
dlg.protocol("WM_DELETE_WINDOW", lambda: None) # a transfer cannot be stopped midway
frame = ttk.Frame(dlg, padding=15)
frame.pack(fill=tk.BOTH, expand=True)
label = ttk.Label(frame, text="Starting ...", width=50)
label.pack(anchor="w")
bar = ttk.Progressbar(frame, length=360, maximum=1.0)
bar.pack(fill=tk.X, pady=(8, 0))
dlg.update_idletasks()
dlg.geometry(f"+{window.winfo_x() + (window.winfo_width() - dlg.winfo_width()) // 2}"
f"+{window.winfo_y() + (window.winfo_height() - dlg.winfo_height()) // 2}")
dlg.grab_set()
def status(text, fraction):
def show():
if dlg.winfo_exists():
label.config(text=text)
if fraction is not None:
bar.config(value=fraction)
window.after(0, show)
def finish(result, error):
dlg.grab_release()
dlg.destroy()
if error is not None:
app_state.set_status(f"Get with File failed: {error}", warn=True)
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
return
_report_backup(result)
def work():
try:
result = app_backup.get_with_file(ds.sox_client, path, status)
except Exception as e:
window.after(0, lambda e=e: finish(None, e))
else:
window.after(0, lambda: finish(result, None))
threading.Thread(target=work, daemon=True).start()
def _ask_backup_path(ds):
"""The save dialog for the zip: <Sedona home>/store/apps, the name filled in.
The name takes the app's deviceName and appName, read off the device now -- the
app component's config is not loaded until something asks for it.
"""
device_name, app_name = app_state.device_host, "app"
try:
comp = ds.sox_client.cache_get(0)
if comp is not None:
ds.sox_client.update([comp], device_session.CONFIG)
device_name = comp.values.get("deviceName") or device_name
app_name = comp.values.get("appName") or app_name
except Exception as e:
app_state.set_status(f"Could not read the app's names: {e}", warn=True)
folder = app_backup.backup_folder()
try:
os.makedirs(folder, exist_ok=True)
except OSError:
folder = app_state.SEDONA_HOME
return filedialog.asksaveasfilename(
title="Get with File",
initialdir=folder,
initialfile=app_backup.backup_file_name(device_name, app_name),
defaultextension=".zip",
filetypes=[("Zip archive", "*.zip"), ("All files", "*.*")],
)
def _report_backup(result):
"""Says where the zip went, and warns of anything the app names but could not get."""
lines = [f"Saved {os.path.basename(result.path)}", f"in {os.path.dirname(result.path)}",
"", f"{len(result.entries)} files in the zip."]
warn = False
if result.sax_error:
warn = True
lines += ["", f"app.sax could not be written: {result.sax_error}"]
if result.missing_on_device:
warn = True
lines += ["", "The app names these files, but the device does not have them:"]
lines += [f" {uri}" for uri in result.missing_on_device]
if result.missing_in_bank:
lines += ["", "No source for these in Sedona home's banks; only the device copy is in the zip:"]
lines += [f" {name}" for name in result.missing_in_bank]
app_state.set_status(f"Got {os.path.basename(result.path)}", warn=warn)
(messagebox.showwarning if warn else messagebox.showinfo)("Get with File", NL.join(lines))
NL = chr(10) # newline, for the multi-line message boxes
@@ -2372,6 +2506,7 @@ def _on_connected(ds, root_comp):
app_state.window.title(app_state.base_window_title_live())
menu_device.entryconfig("Disconnect", state="normal")
menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with File...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
sax_root = ds.build_dom_tree(root_comp)
@@ -2438,6 +2573,7 @@ def do_device_disconnect():
menu_device.entryconfig("Discover...", state="normal")
menu_device.entryconfig("Disconnect", state="disabled")
menu_device.entryconfig("Download to SAX...", state="disabled")
menu_app_info.entryconfig("Get with File...", state="disabled")
app_state.set_status("Disconnected")
sax_file.reset_to_startup_state()
@@ -2693,6 +2829,8 @@ menu_bar.add_cascade(label="Kits", menu=menu_kits)
menu_app_info = tk.Menu(menu_bar, tearoff=0)
menu_app_info.add_command(label="App Info", command=show_app_info)
menu_app_info.add_separator()
menu_app_info.add_command(label="Get with File...", command=do_get_with_file, state="disabled")
menu_bar.add_cascade(label="App", menu=menu_app_info)
menu_prefs = tk.Menu(menu_bar, tearoff=0)
+410
View File
@@ -0,0 +1,410 @@
"""Reads a device's app.sab and writes it back out as app.sax.
Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
down to the `<!-- /path -->` comments and Java's float formatting. Kit manifests come
from Sedona home, found the way the editor finds them everywhere else.
A .sab holds config props only, so runtime values cannot come back: like sedonac, the
manifest default stands in for them, and a value equal to its default is not written.
"""
import base64
import math
import struct
import xml.etree.ElementTree as ET
import schema
class SabError(Exception):
pass
class _Reader:
def __init__(self, data):
self.b, self.p = data, 0
def take(self, n):
if self.p + n > len(self.b):
raise SabError(f"truncated sab at offset {self.p}")
v = self.b[self.p:self.p + n]
self.p += n
return v
def u1(self): return self.take(1)[0]
def u2(self): return struct.unpack(">H", self.take(2))[0]
def i4(self): return struct.unpack(">i", self.take(4))[0]
def i8(self): return struct.unpack(">q", self.take(8))[0]
def f4(self): return struct.unpack(">f", self.take(4))[0]
def f8(self): return struct.unpack(">d", self.take(8))[0]
def str_(self):
end = self.b.index(0, self.p)
s = self.b[self.p:end].decode("ascii")
self.p = end + 1
return s
# --- schema ---------------------------------------------------------------------------
class Slot:
__slots__ = ("name", "type", "flags", "default", "facets")
def __init__(self, elem):
self.name = elem.get("name")
self.type = elem.get("type")
self.flags = elem.get("flags", "")
self.default = elem.get("default")
self.facets = {f.get("name"): f.get("val") for f in elem.iter()
if f is not elem and f.get("name") is not None}
def _load_kits(parts):
"""[(kitName, checksumHex)] in kit-id order -> (types by kit, types by qname)."""
kits, types_by_q = [], {}
for name, cks in parts:
path = schema.find_manifest_path(name, cks)
if path is None:
raise SabError(f"no manifest for kit '{name}' in Sedona home")
mroot = ET.parse(path).getroot()
tlist = sorted(mroot.findall("type"), key=lambda t: int(t.get("id")))
for t in tlist:
types_by_q[f"{name}::{t.get('name')}"] = t
kits.append(tlist)
return kits, types_by_q
def _resolve_slots(qname, types_by_q, cache):
"""The type's slots, base chain first, indexed by slot id."""
if qname in cache:
return cache[qname]
t = types_by_q[qname]
base = t.get("base")
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
by_name = {s.name: i for i, s in enumerate(out)}
for elem in sorted(t.findall("slot"), key=lambda s: int(s.get("id"))):
slot = Slot(elem)
if slot.name in by_name: # an action override keeps the inherited id
out[by_name[slot.name]] = slot
else:
by_name[slot.name] = len(out)
out.append(slot)
cache[qname] = out
return out
# --- values ---------------------------------------------------------------------------
def _int(raw): # Integer.decode: supports 0x
if raw in (None, "", "null"):
return 0
return int(raw, 0) if raw.lower().startswith(("0x", "-0x")) else int(raw)
def _flt(raw):
if raw == "null":
return float("nan") # explicit null only
if raw in (None, ""):
return 0.0 # Value.defaultForType -> zero
return float(raw)
def _decode_value(r, vtype, flags):
if "s" in flags: # asStr Buf: u2 len (incl NUL)
n = r.u2()
return r.take(n).split(b"\0")[0].decode("ascii", errors="replace")
if vtype == "bool":
v = r.u1()
return None if v == 2 else (v == 1)
if vtype == "byte": return r.u1()
if vtype == "short": return r.u2()
if vtype == "int": return r.i4()
if vtype == "long": return r.i8()
if vtype == "float": return r.f4()
if vtype == "double": return r.f8()
if vtype in ("sys::Buf", "buf"):
return bytes(r.take(r.u2()))
raise SabError(f"unhandled slot type {vtype}")
def _default_value(slot):
"""The manifest default, or Value.defaultForType's zero."""
vtype, flags, raw = slot.type, slot.flags, slot.default
if "s" in flags:
return raw or ""
if vtype == "bool":
return None if raw == "null" else raw in ("true", "1")
if vtype in ("byte", "short", "int", "long"):
return _int(raw)
if vtype == "float":
# the default is a 32-bit float too: 0.1 as a double is not the file's 0.1
return _to_f32(_flt(raw))
if vtype == "double":
return _flt(raw)
if vtype in ("sys::Buf", "buf"):
return base64.b64decode(raw) if raw else b""
raise SabError(f"unhandled slot type {vtype}")
def _same(a, b):
if isinstance(a, float) and isinstance(b, float):
return (math.isnan(a) and math.isnan(b)) or a == b
return type(a) is type(b) and a == b
# --- Java number formatting -----------------------------------------------------------
_F32_MAX = 3.4028234663852886e38
_F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
def _to_f32(v):
try:
return struct.unpack(">f", struct.pack(">f", v))[0]
except OverflowError:
if abs(v) < _F32_INF_EDGE:
return math.copysign(_F32_MAX, v)
return math.copysign(math.inf, v)
def _shortest(a, single):
"""Fewest digits that still round-trip -- what Java's toString picks."""
for prec in range(0, 17):
s = "%.*e" % (prec, a)
v = float(s)
if single:
v = _to_f32(v)
if v == a:
break
mant, exp = s.split("e")
digits = mant.replace(".", "").rstrip("0") or "0"
return digits, int(exp)
def _java_float(v, single=True):
"""Float/Double.toString: always a decimal point, E-form outside [1e-3, 1e7)."""
if math.isnan(v):
return "NaN"
if math.isinf(v):
return "-Infinity" if v < 0 else "Infinity"
neg = math.copysign(1.0, v) < 0
a = abs(v)
if a == 0.0:
return "-0.0" if neg else "0.0"
digits, exp = _shortest(a, single)
if -3 <= exp < 7:
if exp >= 0:
head = digits[:exp + 1].ljust(exp + 1, "0")
tail = digits[exp + 1:] or "0"
else:
head, tail = "0", "0" * (-exp - 1) + digits
out = head + "." + tail
else:
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
return "-" + out if neg else out
def _encode_string(val, slot):
"""Value.encodeString()."""
vtype, flags = slot.type, slot.flags
if "s" in flags:
return _literal(val)
if vtype == "bool":
return "null" if val is None else ("true" if val else "false")
if vtype == "float":
return "null" if math.isnan(val) else _java_float(val, True)
if vtype == "double":
return "null" if math.isnan(val) else _java_float(val, False)
if vtype in ("sys::Buf", "buf"):
return base64.b64encode(val).decode("ascii")
return str(val)
def _literal(s):
"""TextUtil.toLiteral."""
return "".join({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c) for c in s)
def _safe(s):
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
out = []
for c in s:
o = ord(c)
if o < 0x20 or o > 0x7e or c in "'\"":
out.append("&#x%x;" % o)
elif c == "<":
out.append("&lt;")
elif c == ">":
out.append("&gt;")
elif c == "&":
out.append("&amp;")
else:
out.append(c)
return "".join(out)
# --- the app --------------------------------------------------------------------------
class SabApp:
"""A decoded app.sab: kits, components by id, and links.
Each component is a dict: id, type, name, parent, kids, and props -- a list of
(Slot, value) for every config prop, defaults included.
"""
def __init__(self, data):
r = _Reader(data)
if r.i4() != 0x73617070:
raise SabError("not a .sab (bad magic)")
ver = r.i4()
if ver != 0x0003:
raise SabError(f"unsupported sab version 0x{ver:x}")
self.kit_parts = []
for _ in range(r.u1()):
name = r.str_()
self.kit_parts.append((name, f"{r.i4() & 0xFFFFFFFF:08x}"))
kits, self._types_by_q = _load_kits(self.kit_parts)
self._cache = {}
r.u2() # maxId, recomputed on the way out
qname_of = {}
for ki, ((kname, _), tlist) in enumerate(zip(self.kit_parts, kits)):
for t in tlist:
qname_of[(ki, int(t.get("id")))] = f"{kname}::{t.get('name')}"
by_id = {}
while True:
cid = r.u2()
if cid == 0xFFFF:
break
ki, ti = r.u1(), r.u1()
if (ki, ti) not in qname_of:
raise SabError(f"comp {cid}: no type {ti} in kit {ki}")
qname = qname_of[(ki, ti)]
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
for slot in self.slots(qname):
if "a" in slot.flags or "c" not in slot.flags:
continue # config props only, in slot-id order
c["props"].append((slot, _decode_value(r, slot.type, slot.flags)))
if r.u1() != ord(";"):
raise SabError(f"corrupted component {cid} {c['name']}")
if cid in by_id:
raise SabError(f"duplicate id: {cid}")
by_id[cid] = c
def must(cid, what, owner):
if cid not in by_id:
raise SabError(f"comp {owner['id']} {owner['name']}: missing {what} {cid}")
return by_id[cid]
for c in by_id.values():
c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None
if c["childId"] != 0xFFFF:
kid = must(c["childId"], "child", c)
while True:
c["kids"].append(kid)
if kid["siblingId"] == 0xFFFF:
break
kid = must(kid["siblingId"], "nextSibling", kid)
self.links = []
while True:
fc = r.u2()
if fc == 0xFFFF:
break
fs, tc, ts = r.u1(), r.u2(), r.u1()
for cid in (fc, tc):
if cid not in by_id:
raise SabError(f"link references unknown comp id {cid}")
self.links.append((by_id[fc], fs, by_id[tc], ts))
if r.u1() != ord("."):
raise SabError("invalid app end marker")
if 0 not in by_id:
raise SabError("no app component (id 0)")
self.comps = by_id
self.app = by_id[0]
def slots(self, qname):
return _resolve_slots(qname, self._types_by_q, self._cache)
def kit_names(self):
return [name for name, _ in self.kit_parts]
def prop(self, comp, name, default=None):
for slot, val in comp["props"]:
if slot.name == name:
return val
return default
def slot(self, comp, name):
return next((s for s in self.slots(comp["type"]) if s.name == name), None)
# --- writing app.sax ---
def to_sax(self):
out = []
w = out.append
w("<?xml version='1.0'?>\n")
w("<sedonaApp>\n")
w("<schema>\n")
for name, cks in self.kit_parts:
w(f' <kit name="{_safe(name)}" checksum="{cks}" />\n')
w("</schema>\n")
w("<app>\n")
self._write_props(w, self.app, 2)
for kid in self.app["kids"]:
self._write_comp(w, kid, 2)
w("</app>\n")
w("<links>\n")
for fc, fs, tc, ts in self.links:
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
_safe(_path_of(fc)), _safe(self._slot_name(fc, fs)),
_safe(_path_of(tc)), _safe(self._slot_name(tc, ts))))
w("</links>\n")
w("</sedonaApp>\n")
return "".join(out)
def _slot_name(self, comp, slot_id):
slots = self.slots(comp["type"])
if slot_id >= len(slots):
raise SabError(f"{comp['type']} has no slot id {slot_id}")
return slots[slot_id].name
def _write_props(self, w, c, indent):
"""Only props whose value differs from the slot default get written."""
n = 0
for slot, val in c["props"]:
if _same(val, _default_value(slot)):
continue
w('%s<prop name="%s" val="%s"/>\n' % (
" " * indent, _safe(slot.name), _safe(_encode_string(val, slot))))
n += 1
return n
def _write_comp(self, w, c, indent):
pad = " " * indent
w(f"{pad}<!-- {_path_of(c)} -->\n")
w(f'{pad}<comp name="{_safe(c["name"])}" id="{c["id"]}" type="{_safe(c["type"])}"')
body = []
n = self._write_props(body.append, c, indent + 2)
for kid in c["kids"]:
self._write_comp(body.append, kid, indent + 2)
if not n and not c["kids"]:
w("/>\n")
else:
w(">\n")
w("".join(body))
w(f"{pad}</comp>\n")
def _path_of(c):
parts = []
while c["parent"] is not None:
parts.append(c["name"])
c = c["parent"]
return "/" + "/".join(reversed(parts)) if parts else "/"
+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: