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>
727 lines
30 KiB
Python
727 lines
30 KiB
Python
"""App > Get with Files / Put with Files: a full backup of the device's app as one
|
|
zip, and putting such a zip back onto a device.
|
|
|
|
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.jpg Orion's boot logo; Rion's is 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.
|
|
|
|
A put stages app.sab and kits.scode for the VM to take up on its next start; see
|
|
put_with_files for the order it goes in.
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
import re
|
|
import zipfile
|
|
|
|
import app_state
|
|
import sab_app
|
|
import sox
|
|
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
|
|
|
|
APP_SAB = "app.sab"
|
|
KITS_SCODE = "kits.scode"
|
|
|
|
_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, kit, service_type, image_bank=None, font_bank=None,
|
|
font_extensions=(), logo_uri=None, screen=None):
|
|
self.name = name
|
|
self.kit = kit # the kit only this platform's apps hold
|
|
self.service_type = service_type # its platform service
|
|
self.platform_id = "ontrol-" + name.lower() # the device's id starts so
|
|
self.image_bank = image_bank
|
|
self.font_bank = font_bank
|
|
self.font_extensions = font_extensions
|
|
self.logo_uri = logo_uri
|
|
self.screen = screen # (width, height) in landscape; None: none
|
|
|
|
|
|
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
|
|
# logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
|
|
# screen: nothing but the app goes to it.
|
|
_ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL",
|
|
(".oxfl", ".oxfp"), "system/logo.jpg", (800, 480))
|
|
_RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank",
|
|
"fontBank", (".bin",), "system/logo.bin", (320, 240))
|
|
_PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService")
|
|
_PLATFORMS = (_ORION, _RION, _PION)
|
|
|
|
|
|
def platform_of(app):
|
|
kits = app.kit_names()
|
|
return next((p for p in _PLATFORMS if p.kit in kits), 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()
|
|
if platform.screen is None:
|
|
return files
|
|
|
|
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(platform.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.startswith("system/"):
|
|
return "system/logo" + os.path.splitext(file_name)[1]
|
|
return f"{media.bank_folder}/{file_name}"
|
|
|
|
|
|
def save_app(client):
|
|
"""Invokes App.save on the device and waits for it to finish.
|
|
|
|
Ontrol's Niagara driver does this before every app.sab get.
|
|
"""
|
|
comp = client.cache_get(0) or client.load(0)
|
|
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
|
|
slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None)
|
|
client.invoke(0, slot["id"] if slot else 0)
|
|
|
|
|
|
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`.
|
|
|
|
The app is saved on the device first, so app.sab holds what is running. Runs off
|
|
the main thread; status(text, fraction) reports progress. Returns a BackupResult;
|
|
a failure to save, or 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)
|
|
|
|
report("Saving the app on the device ...", 0.0)
|
|
try:
|
|
save_app(client)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Saving the app on the device: {e}") from e
|
|
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
|
|
|
|
|
|
# --- Put with Files ---------------------------------------------------------------------
|
|
#
|
|
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
|
|
# PutApp): the backup's app.sax is encoded against the device's kits; app.sab goes as
|
|
# '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and
|
|
# images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona
|
|
# home's bank in the cut the device draws with, images in the form its screen takes.
|
|
|
|
_FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/")
|
|
_JPG = (".jpg", ".jpeg")
|
|
|
|
|
|
def _checksum(value):
|
|
"""A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string."""
|
|
return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF
|
|
|
|
|
|
def kits_fit(app_kits, device_kits):
|
|
"""Whether the device's kits.scode can load an app.sab of `app_kits`.
|
|
|
|
App.loadSchema's rule: the app's kits must appear among the device's, in the same
|
|
order, each with the same checksum; the device may hold more.
|
|
"""
|
|
device = iter((n, _checksum(c)) for n, c in device_kits)
|
|
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
|
|
|
|
|
|
def kit_differences(app_kits, device_kits):
|
|
"""Lines telling why an app's kits do not fit the device's."""
|
|
on_device = {n: _checksum(c) for n, c in device_kits}
|
|
lines = []
|
|
for name, cks in app_kits:
|
|
cks = _checksum(cks)
|
|
if name not in on_device:
|
|
lines.append(f"{name}: not on the device")
|
|
elif on_device[name] != cks:
|
|
lines.append(f"{name}: {on_device[name]:08x} on the device, {cks:08x} in the backup")
|
|
return lines or ["The same kits, in a different order"]
|
|
|
|
|
|
class DeviceTarget:
|
|
"""What a put needs to know of the connected device."""
|
|
|
|
def __init__(self, platform, kits, name=None, portrait=False,
|
|
platform_comp_id=None, delete_slot_id=None):
|
|
self.platform = platform # one of _PLATFORMS, or None
|
|
self.kits = kits # [(name, checksum)] in kit-id order
|
|
self.name = name # the app's deviceName
|
|
self.portrait = portrait # Orion's screenOrientation
|
|
self.platform_comp_id = platform_comp_id
|
|
self.delete_slot_id = delete_slot_id
|
|
|
|
@property
|
|
def font_extension(self):
|
|
if self.platform is _ORION:
|
|
return ".oxfp" if self.portrait else ".oxfl"
|
|
return ".bin"
|
|
|
|
@property
|
|
def screen(self):
|
|
"""(width, height) as the device is set up, as Ontrol's ScreenInfo has it."""
|
|
w, h = self.platform.screen
|
|
return (h, w) if self.portrait else (w, h)
|
|
|
|
|
|
def device_target(ds):
|
|
"""Reads the platform, kits, deviceName and platform service off the device.
|
|
|
|
The platform comes from the platform id, as Ontrol's tools read it, the kits
|
|
standing in for a device that does not give one.
|
|
"""
|
|
client = ds.sox_client
|
|
kits = [(p.name, p.checksum) for p in client.read_schema()]
|
|
kit_names = {n for n, _ in kits}
|
|
platform_id = (client.read_version().platform_id or "").lower()
|
|
platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None)
|
|
or next((p for p in _PLATFORMS if p.kit in kit_names), None))
|
|
|
|
target = DeviceTarget(platform, kits)
|
|
comp = client.cache_get(0)
|
|
if comp is not None:
|
|
client.update([comp], sox.CONFIG)
|
|
target.name = comp.values.get("deviceName") or None
|
|
if platform is not None and platform.screen is not None:
|
|
plat = ds.find_component_of_type(platform.service_type, "screenOrientation")
|
|
if plat is not None:
|
|
target.platform_comp_id = plat.id
|
|
target.portrait = (platform is _ORION and
|
|
str(plat.values.get("screenOrientation")).lower() == "true")
|
|
slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or []
|
|
target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile"
|
|
and s.get("is_action")), None)
|
|
return target
|
|
|
|
|
|
# --- images in the device's form --------------------------------------------------------
|
|
|
|
def native_image(data, name, target):
|
|
"""A png (or any image PIL reads) as the device's native .bin.
|
|
|
|
Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte
|
|
header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a
|
|
portrait screen, with alpha unless the image fills the screen; Rion RGB565, with
|
|
alpha only for a translucent image smaller than the screen.
|
|
"""
|
|
import io
|
|
from PIL import Image
|
|
|
|
img = Image.open(io.BytesIO(data))
|
|
img.load()
|
|
width, height = img.size
|
|
translucent = img.mode in ("RGBA", "LA", "PA") or (
|
|
img.mode == "P" and "transparency" in img.info and
|
|
any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata()))
|
|
background = (width, height) == target.screen
|
|
orion = target.platform is _ORION
|
|
rgb_mode = use_alpha = orion and not background
|
|
if not orion:
|
|
use_alpha = translucent and not background
|
|
rotate = orion and target.portrait
|
|
|
|
rgba = img.convert("RGBA")
|
|
if rotate: # the Java walks columns right to left: a quarter turn anticlockwise
|
|
rgba = rgba.transpose(Image.Transpose.ROTATE_90)
|
|
w, h = rgba.size
|
|
|
|
out = bytearray(name.encode("ascii", errors="replace"))
|
|
out += bytes(max(0, 32 - len(out)))
|
|
out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0),
|
|
w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27)
|
|
pixels = rgba.tobytes()
|
|
if rgb_mode:
|
|
for i in range(0, len(pixels), 4):
|
|
r, g, b, a = pixels[i:i + 4]
|
|
out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r))
|
|
else:
|
|
for i in range(0, len(pixels), 4):
|
|
r, g, b, a = pixels[i:i + 4]
|
|
rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
|
|
if use_alpha:
|
|
out.append(a)
|
|
out += bytes((rgb565 & 0xFF, rgb565 >> 8))
|
|
return bytes(out)
|
|
|
|
|
|
def device_jpg(data, target):
|
|
"""A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion
|
|
(OntOxImageUtil.rotateJpg), otherwise as it is."""
|
|
if not (target.platform is _ORION and target.portrait):
|
|
return data
|
|
import io
|
|
from PIL import Image
|
|
img = Image.open(io.BytesIO(data))
|
|
buf = io.BytesIO()
|
|
img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75)
|
|
return buf.getvalue()
|
|
|
|
|
|
# --- the plan ---------------------------------------------------------------------------
|
|
|
|
class PutFile:
|
|
def __init__(self, device_uri, data, source):
|
|
self.device_uri = device_uri
|
|
self.data = data
|
|
self.source = source # where the bytes come from, for the summary
|
|
|
|
|
|
class PutPlan:
|
|
"""What a put of the zip would do, and whether the zip fits the device."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.problems = [] # any of these stops the put
|
|
self.warnings = [] # worth confirming, but the put can go on
|
|
self.app_source = "" # how app.sab was come by, for the summary
|
|
self.reencoded = [] # kits app.sax was encoded against other builds of
|
|
self.kit_changes = [] # set when the backup's kits.scode has to go too
|
|
self.app_files = [] # PutFile to '<name>.writing', renamed to '.stage'
|
|
self.media = [] # PutFile: images, fonts, logo
|
|
self.converted = 0 # images made from the backup's image bank
|
|
self.images_missing = [] # images neither in device form nor as a source
|
|
self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes
|
|
self.fonts_missing = [] # fonts the app names that neither has
|
|
self.staged = False # set once the device holds app.sab.stage
|
|
self.has_screen = True # False for a P-ION: the app is all that goes
|
|
|
|
@property
|
|
def ok(self):
|
|
return not self.problems
|
|
|
|
|
|
def _label(platform):
|
|
return platform.name if platform else "no platform this editor knows"
|
|
|
|
|
|
def _app_portrait(app):
|
|
comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None)
|
|
return bool(comp and app.prop(comp, "screenOrientation", False))
|
|
|
|
|
|
def _sax_kits(sax):
|
|
import xml.etree.ElementTree as ET
|
|
schema_elem = ET.fromstring(sax).find("schema")
|
|
return [] if schema_elem is None else [k.get("name") for k in schema_elem.findall("kit")]
|
|
|
|
|
|
def plan_put(path, target):
|
|
"""Reads the zip at `path` and checks it against the device `target`."""
|
|
plan = PutPlan(path)
|
|
try:
|
|
z = zipfile.ZipFile(path)
|
|
except (OSError, zipfile.BadZipFile) as e:
|
|
plan.problems.append(f"Not a readable zip: {e}")
|
|
return plan
|
|
with z:
|
|
names = {n for n in z.namelist() if not n.endswith("/")}
|
|
app, backup_portrait = _plan_app(plan, z, names, target)
|
|
if app is None:
|
|
return plan
|
|
|
|
platform = platform_of(app)
|
|
if platform is None or platform is not target.platform:
|
|
plan.problems.append(f"The backup is from {_label(platform)}; the device is "
|
|
f"{_label(target.platform)}.")
|
|
return plan
|
|
|
|
zip_name = app.prop(app.app, "deviceName", "") or ""
|
|
if target.name and zip_name and zip_name != target.name:
|
|
plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is "
|
|
f"'{target.name}'.")
|
|
if platform.screen is None:
|
|
plan.has_screen = False # no images or fonts to put
|
|
return plan
|
|
if target.delete_slot_id is None:
|
|
plan.warnings.append("The device's platform service has no deleteFile action, "
|
|
"so its old fonts and images stay.")
|
|
|
|
same_screen = platform is _RION or backup_portrait == target.portrait
|
|
if not same_screen:
|
|
plan.warnings.append(
|
|
f"The backup is from a {'portrait' if backup_portrait else 'landscape'} "
|
|
f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its "
|
|
"images are made again from the backup's image bank.")
|
|
_plan_images(plan, z, names, platform, target, same_screen)
|
|
_plan_fonts(plan, z, names, app, platform, target.font_extension)
|
|
_plan_logo(plan, z, names, platform, target, same_screen)
|
|
return plan
|
|
|
|
|
|
def _plan_app(plan, z, names, target):
|
|
"""Settles app.sab, Ontrol's way first: the backup's app.sax encoded against the
|
|
device's kits. Failing that, the backup's own app.sab when the device's kits can
|
|
load it, or with the backup's kits.scode. Returns (decoded app, its portrait)."""
|
|
sax_name = "app.sax" if "app.sax" in names else next(
|
|
(n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None)
|
|
sab_name, scode_name = "device/" + APP_SAB, "device/" + KITS_SCODE
|
|
device_cks = {n: f"{_checksum(c):08x}" for n, c in target.kits}
|
|
sax_problem = None
|
|
|
|
if sax_name:
|
|
sax = z.read(sax_name)
|
|
try:
|
|
missing = [k for k in _sax_kits(sax) if k not in device_cks]
|
|
if missing:
|
|
raise sab_app.SabError("the device lacks the kits " + ", ".join(missing))
|
|
sab = sab_app.encode_sax(sax, device_cks)
|
|
app = sab_app.SabApp(sab)
|
|
except Exception as e:
|
|
sax_problem = f"{sax_name} cannot be encoded for this device: {e}"
|
|
else:
|
|
original = sab_app.SabApp(z.read(sab_name)) if sab_name in names else None
|
|
if original is not None:
|
|
plan.reencoded = [line for line in kit_differences(original.kit_parts,
|
|
target.kits)
|
|
if "on the device," in line]
|
|
plan.app_source = f"{sax_name}, encoded for the device's kits"
|
|
plan.app_files.append(PutFile(APP_SAB, sab, sax_name))
|
|
return app, _app_portrait(original or app)
|
|
|
|
if sab_name not in names:
|
|
plan.problems.append(sax_problem or "The zip holds no app: neither app.sax nor "
|
|
f"{sab_name}.")
|
|
return None, False
|
|
sab = z.read(sab_name)
|
|
try:
|
|
app = sab_app.SabApp(sab)
|
|
except Exception as e:
|
|
plan.problems += [p for p in (sax_problem,) if p]
|
|
plan.problems.append(f"{sab_name} cannot be read: {e}")
|
|
return None, False
|
|
if kits_fit(app.kit_parts, target.kits):
|
|
plan.app_source = sab_name
|
|
elif scode_name in names:
|
|
plan.kit_changes = kit_differences(app.kit_parts, target.kits)
|
|
plan.app_source = f"{sab_name}, with the backup's {KITS_SCODE}"
|
|
plan.app_files.append(PutFile(KITS_SCODE, z.read(scode_name), scode_name))
|
|
else:
|
|
plan.problems += [p for p in (sax_problem,) if p]
|
|
plan.problems.append(f"The device's kits cannot load {sab_name}, and the backup "
|
|
f"has no {scode_name}:")
|
|
plan.problems += [" " + line for line in kit_differences(app.kit_parts, target.kits)]
|
|
return None, False
|
|
if sax_problem:
|
|
plan.warnings.append(sax_problem)
|
|
plan.app_files.append(PutFile(APP_SAB, sab, sab_name))
|
|
return app, _app_portrait(app)
|
|
|
|
|
|
def _plan_images(plan, z, names, platform, target, same_screen):
|
|
"""Every image the backup holds, in the form the device's screen takes.
|
|
|
|
The backup's device copy goes when it was taken off the same kind of screen;
|
|
otherwise the image bank's source is made over, as Ontrol's tool always does.
|
|
"""
|
|
native, source = {}, {}
|
|
for name in names:
|
|
folder, _, file_name = name.rpartition("/")
|
|
stem, ext = os.path.splitext(file_name)
|
|
if folder == "device/images":
|
|
native[stem] = name
|
|
elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS:
|
|
source[stem] = name
|
|
for stem in sorted(set(native) | set(source), key=str.lower):
|
|
if same_screen and stem in native:
|
|
name = native[stem]
|
|
plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name),
|
|
"backup"))
|
|
elif stem in source:
|
|
name = source[stem]
|
|
try:
|
|
if name.lower().endswith(_JPG):
|
|
plan.media.append(PutFile(f"images/{stem}.jpg",
|
|
device_jpg(z.read(name), target), name))
|
|
else:
|
|
plan.media.append(PutFile(f"images/{stem}.bin",
|
|
native_image(z.read(name), stem, target), name))
|
|
plan.converted += 1
|
|
except Exception as e:
|
|
plan.images_missing.append(f"{stem} ({name}: {e})")
|
|
else:
|
|
plan.images_missing.append(stem)
|
|
|
|
|
|
def _plan_logo(plan, z, names, platform, target, same_screen):
|
|
"""The boot logo: the device's copy, or the backup's source made over (Ontrol's
|
|
tool sends it into images/ -- system/ is where the device looks for it)."""
|
|
copy = "device/" + platform.logo_uri
|
|
if same_screen and copy in names:
|
|
plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup"))
|
|
return
|
|
source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and
|
|
os.path.splitext(n)[0].endswith("logo")), None)
|
|
if source is None:
|
|
return
|
|
try:
|
|
data = z.read(source)
|
|
if platform is _ORION:
|
|
if not source.lower().endswith(_JPG):
|
|
return
|
|
plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source))
|
|
else:
|
|
plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target),
|
|
source))
|
|
except Exception as e:
|
|
plan.warnings.append(f"The boot logo could not be made from {source}: {e}")
|
|
|
|
|
|
def _plan_fonts(plan, z, names, app, platform, ext):
|
|
"""Every font the backup holds or the app names, in the device's cut `ext`.
|
|
|
|
As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's
|
|
goes only for a font the bank lacks.
|
|
"""
|
|
in_zip = {} # stem -> {extension: entry}
|
|
for name in names:
|
|
folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None)
|
|
if folder and "/" not in name[len(folder):]:
|
|
stem, file_ext = os.path.splitext(name[len(folder):])
|
|
in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name)
|
|
required = {}
|
|
for m in media_files(app, platform):
|
|
if m.device_uri.startswith("fonts/"):
|
|
stem = os.path.splitext(m.device_uri[len("fonts/"):])[0]
|
|
required[stem] = required.get(stem, False) or m.required
|
|
|
|
for stem in sorted(set(in_zip) | set(required)):
|
|
bank = _bank_source(platform.font_bank, stem + ext, False)
|
|
if bank:
|
|
with open(bank, "rb") as f:
|
|
plan.media.append(PutFile("fonts/" + stem + ext, f.read(),
|
|
f"{platform.font_bank}/{stem}{ext}"))
|
|
elif ext in in_zip.get(stem, {}):
|
|
plan.media.append(PutFile("fonts/" + stem + ext,
|
|
z.read(in_zip[stem][ext]), "backup"))
|
|
plan.fonts_from_zip.append(stem + ext)
|
|
elif required.get(stem) or stem in in_zip:
|
|
plan.fonts_missing.append(stem + ext)
|
|
|
|
|
|
def put_with_files(client, plan, target, status=None):
|
|
"""Puts the plan on the device; the device takes the app up when it restarts.
|
|
|
|
Runs off the main thread; status(text, fraction) reports progress. A failure
|
|
raises, naming the step. Once app.sab.stage is in place the app is committed: a
|
|
later failure leaves the new app staged with some of its media missing.
|
|
"""
|
|
def report(text, fraction=None):
|
|
if status:
|
|
status(text, fraction)
|
|
|
|
total = sum(len(f.data) for f in plan.app_files + plan.media) or 1
|
|
done = 0
|
|
|
|
def put(uri, f):
|
|
nonlocal done
|
|
base = done
|
|
|
|
def progress(sent, size):
|
|
report(f"Writing {uri} ...", (base + sent) / total)
|
|
report(f"Writing {uri} ...", base / total)
|
|
try:
|
|
client.put_file(uri, f.data, progress)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Writing {uri}: {e}") from e
|
|
done += len(f.data)
|
|
|
|
for f in plan.app_files:
|
|
put(f.device_uri + ".writing", f)
|
|
for f in plan.app_files:
|
|
writing, stage = f.device_uri + ".writing", f.device_uri + ".stage"
|
|
report(f"Renaming {writing} to {stage} ...", done / total)
|
|
try:
|
|
client.rename_file(writing, stage)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e
|
|
plan.staged = True
|
|
|
|
if target.delete_slot_id is not None:
|
|
for pattern in ("fonts/*", "images/*"):
|
|
report(f"Deleting {pattern} on the device ...", done / total)
|
|
try:
|
|
client.invoke(target.platform_comp_id, target.delete_slot_id,
|
|
sox.STR_ID, pattern) # an asStr Buf goes as a Str
|
|
except Exception as e:
|
|
raise RuntimeError(f"Deleting {pattern}: {e}") from e
|
|
|
|
for f in plan.media:
|
|
put(f.device_uri, f)
|
|
report("Done", 1.0)
|