App > Put with Files: restore a backup zip onto the connected device

Follows Ontrol's Workbench tool (BPutWithFilesTool / PutApp): the backup's
app.sax is encoded for the device's kits and put as app.sab.writing, renamed
to app.sab.stage; the platform service's deleteFile clears fonts/* and
images/*, then images, fonts (from Sedona home's bank, in the device's cut)
and the boot logo go. The zip is checked against the device first: same
platform (Orion, Rion or P-ION, from the platform id), and kits the device
can load. Falls back to the backup's app.sab, with its kits.scode if needed.

- sab_app.encode_sax: .sax -> .sab, ported from Py_sab2sax with Java's value
  parsing (fromLiteral, nullbool/nullfloat, Integer.decode); byte-identical
  to sedonac on the saved backups
- app_backup.native_image: port of OntOxImageUtil's png -> native .bin
- sox.put_file / rename_file
- Get with Files: Orion's boot logo is system/logo.jpg
- A restart that goes unanswered is taken as the device going down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 23:37:45 +03:00
co-authored by Claude Opus 5.5
parent 1deb31f3b3
commit 232eea99f4
5 changed files with 932 additions and 41 deletions
+154 -21
View File
@@ -19,6 +19,7 @@ import palette
import properties
import sax_file
import schema
import sox
import undo
import ox_page_view
import user_service_view
@@ -615,15 +616,22 @@ def _invoke_app_action(slot_name):
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return False
goes_down = slot_name in ("hibernate", "quit", "restart", "reboot")
try:
ds.sox_client.invoke(0, slot["id"])
app_state.set_status(f"Invoked '{slot_name}' on device")
except sox.SoxException as e:
# A P-ION restarts before it answers: no reply is what a restart looks like.
if not (goes_down and "timeout" in str(e).lower()):
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
app_state.set_status(f"Invoked '{slot_name}'; the device went down without replying")
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
if slot_name == "save":
app_state.mark_clean()
elif slot_name in ("hibernate", "quit", "restart", "reboot"):
elif goes_down:
do_device_disconnect()
return True
@@ -656,9 +664,28 @@ def do_get_with_file():
if not path:
return
def finish(result, error):
if error is not None:
app_state.set_status(f"Get with Files failed: {error}", warn=True)
messagebox.showerror("Get with Files", f"The backup failed:{NL}{NL}{error}")
return
app_state.mark_clean() # the app was saved on the device first
_report_backup(result)
_run_with_progress("Get with Files",
lambda status: app_backup.get_with_file(ds.sox_client, path, status),
finish)
def _run_with_progress(title, work, finish):
"""Runs work(status) off the main thread behind a small progress window.
status(text, fraction) updates the window; finish(result, error) then runs on the
main thread, error being the exception work raised, or None.
"""
window = app_state.window
dlg = tk.Toplevel(window)
dlg.title("Get with Files")
dlg.title(title)
dlg.resizable(False, False)
dlg.transient(window)
dlg.protocol("WM_DELETE_WINDOW", lambda: None) # a transfer cannot be stopped midway
@@ -681,42 +708,42 @@ def do_get_with_file():
bar.config(value=fraction)
window.after(0, show)
def finish(result, error):
def done(result, error):
dlg.grab_release()
dlg.destroy()
if error is not None:
app_state.set_status(f"Get with Files failed: {error}", warn=True)
messagebox.showerror("Get with Files", f"The backup failed:{NL}{NL}{error}")
return
app_state.mark_clean() # the app was saved on the device first
_report_backup(result)
finish(result, error)
def work():
def run():
try:
result = app_backup.get_with_file(ds.sox_client, path, status)
result = work(status)
except Exception as e:
window.after(0, lambda e=e: finish(None, e))
window.after(0, lambda e=e: done(None, e))
else:
window.after(0, lambda: finish(result, None))
window.after(0, lambda: done(result, None))
threading.Thread(target=work, daemon=True).start()
threading.Thread(target=run, daemon=True).start()
def _ask_backup_path(ds):
"""The save dialog for the zip: <Sedona home>/store/apps, the name filled in.
def _device_app_names(ds):
"""(deviceName, appName) of the device's app, or None for each it does not give.
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.
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
return comp.values.get("deviceName") or None, comp.values.get("appName") or None
except Exception as e:
app_state.set_status(f"Could not read the app's names: {e}", warn=True)
return None, None
def _ask_backup_path(ds):
"""The save dialog for the zip: <Sedona home>/store/apps, named after the app."""
device_name, app_name = _device_app_names(ds)
device_name, app_name = device_name or app_state.device_host, app_name or "app"
folder = app_backup.backup_folder()
try:
@@ -751,6 +778,109 @@ def _report_backup(result):
(messagebox.showwarning if warn else messagebox.showinfo)("Get with Files", NL.join(lines))
def do_put_with_files():
"""App > Put with Files: puts a Get with Files zip back onto the connected device.
The zip is checked against the device first: the same platform, and an app the
device's kits can run -- the backup's app.sax encoded for them, as Ontrol's tool
does, or its app.sab. The app goes staged, so the device takes it up when
restarted, which is offered once the files are in.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
messagebox.showinfo("Put with Files", "Connect to a device first.")
return
if not app_state.SEDONA_HOME:
messagebox.showerror("Put with Files", "The Sedona home folder is not set (Preferences).")
return
folder = app_backup.backup_folder()
path = filedialog.askopenfilename(
title="Put with Files",
initialdir=folder if os.path.isdir(folder) else app_state.SEDONA_HOME,
filetypes=[("Sedona backup files", "*.zip"), ("All files", "*.*")],
)
if not path:
return
try:
target = app_backup.device_target(ds)
except Exception as e:
messagebox.showerror("Put with Files", f"Could not read the device:{NL}{NL}{e}")
return
plan = app_backup.plan_put(path, target)
if not plan.ok:
app_state.set_status(f"{os.path.basename(path)} does not match the device", warn=True)
messagebox.showerror(
"Put with Files",
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" +
NL.join(plan.problems))
return
if not messagebox.askokcancel("Put with Files", _describe_put(plan),
icon=messagebox.WARNING, default=messagebox.CANCEL):
return
def finish(_result, error):
if error is not None:
app_state.set_status(f"Put with Files failed: {error}", warn=True)
messagebox.showerror(
"Put with Files",
f"The put failed:{NL}{NL}{error}{NL}{NL}" +
("The new app is staged, but not all of its fonts and images are on the "
"device. Put the backup again before restarting it."
if plan.staged else
"app.sab.stage was not written; the device keeps its current app."))
return
app_state.set_status(f"Put {os.path.basename(path)}; restart the device to take it up")
if messagebox.askyesno(
"Put with Files",
f"{len(plan.app_files) + len(plan.media)} files written to the device."
f"{NL}{NL}Restart the device now to complete provisioning?"):
if _invoke_app_action("restart"):
app_state.set_status(f"Put {os.path.basename(path)}; the device is restarting")
_run_with_progress("Put with Files",
lambda status: app_backup.put_with_files(ds.sox_client, plan, target,
status),
finish)
def _describe_put(plan):
"""The confirmation for a put: what goes to the device, and what that replaces."""
images = sum(f.device_uri.startswith("images/") for f in plan.media)
fonts = [f for f in plan.media if f.device_uri.startswith("fonts/")]
lines = [f"Put {os.path.basename(plan.path)} on {app_state.device_host}?", "",
"The device's app is replaced; anything not saved on it is lost." +
(" Its fonts and images are deleted and replaced by the backup's."
if plan.has_screen else ""), "",
f" app.sab, from {plan.app_source}"]
lines += [f" {line}" for line in plan.reencoded]
if plan.kit_changes:
lines += [" kits.scode, since the device's kits cannot load the app:"]
lines += [f" {line}" for line in plan.kit_changes]
lines += [" The device's firmware must support these kits, or the app will not "
"start."]
if not plan.has_screen:
return NL.join(lines + ([""] + plan.warnings if plan.warnings else []))
lines.append(f" {images} images" +
(f" ({plan.converted} made from the backup's image bank)"
if plan.converted else ""))
lines.append(f" {len(fonts)} fonts" +
(f" ({len(plan.fonts_from_zip)} from the backup, the rest from Sedona home)"
if plan.fonts_from_zip else " from Sedona home" if fonts else ""))
if any(f.device_uri.startswith("system/") for f in plan.media):
lines.append(" boot logo")
for title, items in (("The backup has no usable copy of these images:",
plan.images_missing),
("Neither Sedona home nor the backup has these fonts:",
plan.fonts_missing)):
if items:
lines += ["", title] + [f" {item}" for item in items]
if plan.warnings:
lines += [""] + plan.warnings
return NL.join(lines)
NL = chr(10) # newline, for the multi-line message boxes
@@ -2653,6 +2783,7 @@ def _on_connected(ds, root_comp):
menu_device.entryconfig("Disconnect", state="normal")
menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with Files...", state="normal")
menu_app_info.entryconfig("Put with Files...", 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)
@@ -2720,6 +2851,7 @@ def do_device_disconnect():
menu_device.entryconfig("Disconnect", state="disabled")
menu_device.entryconfig("Download to SAX...", state="disabled")
menu_app_info.entryconfig("Get with Files...", state="disabled")
menu_app_info.entryconfig("Put with Files...", state="disabled")
app_state.set_status("Disconnected")
sax_file.reset_to_startup_state()
@@ -3008,6 +3140,7 @@ 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 Files...", command=do_get_with_file, state="disabled")
menu_app_info.add_command(label="Put with Files...", command=do_put_with_files, state="disabled")
menu_bar.add_cascade(label="App", menu=menu_app_info)
menu_prefs = tk.Menu(menu_bar, tearoff=0)