Put with Files: choose kits, as Manage Kits does, and rebuild kits.scode

A kits step after the zip is chosen, laid out as the Manage Kits panel:
installed kits (what the app needs, greyed), installed but not used, kits
available on the computer, not supported; the Action cell offers Keep at /
Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest
version. Kits not in the backup can be added, with their dependencies.

The ticked kits make app.sab's schema (encode_sax takes the kit list, as
Ontrol's PutApp rewrites it). When they are not the device's kits, or
Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's
sedonac from the Niagara install, after checking the natives the kits call
against the device's platform manifest (PutApp.checkNativeCompatibility).

- kit_build.py: platform manifest lookup, native check, sedonac run
- palette.kit_versions: a checksum released more than once stands for its
  highest version (ontrolPlatform 1.0.1, not 1.0.0)
- palette.unsupported_kits takes the kit list to judge against
- sab_app: a type from a kit the app does not list is a clear error

Tested on a P-ION: a rebuild for its own kits is byte-identical to the one
it runs; adding and then removing the logic kit both come back running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 10:37:08 +03:00
co-authored by Claude Opus 5.5
parent 08b4dec532
commit e070bd0f1e
7 changed files with 776 additions and 119 deletions
+266 -84
View File
@@ -26,7 +26,10 @@ import re
import zipfile
import app_state
import kit_build
import palette
import sab_app
import schema
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
@@ -64,8 +67,16 @@ _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)
return platform_of_kits(app.kit_names())
def platform_of_kits(kit_names):
kit_names = set(kit_names)
return next((p for p in _PLATFORMS if p.kit in kit_names), None)
def platform_label(platform):
return platform.name if platform else "no platform this editor knows"
def backup_folder():
@@ -278,7 +289,8 @@ def get_with_file(client, path, status=None):
# --- 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
# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built
# for them with sedonac when they are not the device's (kit_build); both go 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.
@@ -302,25 +314,13 @@ def kits_fit(app_kits, 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):
platform_comp_id=None, delete_slot_id=None, platform_id=""):
self.platform = platform # one of _PLATFORMS, or None
self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9"
self.kits = kits # [(name, checksum)] in kit-id order
self.name = name # the app's deviceName
self.portrait = portrait # Orion's screenOrientation
@@ -353,7 +353,7 @@ def device_target(ds):
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)
target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id)
comp = client.cache_get(0)
if comp is not None:
client.update([comp], sox.CONFIG)
@@ -450,8 +450,8 @@ class PutPlan:
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.kit_changes = [] # each chosen kit build that is not the device's
self.scode_source = "" # where kits.scode comes from, when it goes
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
@@ -466,39 +466,176 @@ class PutPlan:
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):
class KitRow:
"""One kit in the kits step of a put: the device's build, the backup's, and what to put.
`included` rows are what goes: into app.sab's schema and, when it is rebuilt, into
kits.scode. A `required` row -- a kit the app's components name, or one an included
kit depends on -- is always included.
"""
def __init__(self, name, device, backup, builds, used=False, locked=False,
unsupported=False):
self.name = name
self.device = device # checksum hex the device runs, or None
self.backup = backup # checksum hex the backup's schema names, or None
self.builds = builds # [(version, checksum hex)] Sedona home has, newest first
self.used = used # the app's components name a type of it
self.locked = locked # the backup holds only app.sab: nothing can change
self.unsupported = unsupported # cannot run on the device's platform
self.required = used
self.included = used or (device is not None and not unsupported) or \
(backup is not None and not unsupported)
self.use = self._default() # the chosen checksum hex
def _default(self):
"""The device's build, when Sedona home can describe it; then the backup's; then
the newest."""
known = {c for _, c in self.builds}
if self.locked:
return self.backup
for checksum in (self.device, self.backup):
if checksum in known:
return checksum
return self.builds[0][1] if self.builds else self.device or self.backup
@property
def latest(self):
return self.builds[0][1] if self.builds else None
def _hex(checksum):
return f"{_checksum(checksum):08x}"
def _app_source(z, names):
"""(sax entry or None, sab entry or None) of the backup's app."""
sax = "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 = "device/" + APP_SAB
return sax, sab if sab in names else None
def _backup_kits(path):
"""([(kit, checksum or None)] the backup's schema names, {kits its components use},
whether it holds only app.sab). Raises ValueError when the zip holds no app."""
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")]
with zipfile.ZipFile(path) as z:
names = {n for n in z.namelist() if not n.endswith("/")}
sax_name, sab_name = _app_source(z, names)
if sax_name:
root = ET.fromstring(z.read(sax_name))
schema_elem = root.find("schema")
kits = [(k.get("name"), k.get("checksum"))
for k in (schema_elem.findall("kit") if schema_elem is not None else ())]
used = {c.get("type", "").split("::")[0] for c in root.iter("comp")}
return kits, used | {"sys"}, False
if sab_name:
app = sab_app.SabApp(z.read(sab_name))
used = {c["type"].split("::")[0] for c in app.comps.values()}
return app.kit_parts, used, True
raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.")
def plan_put(path, target):
"""Reads the zip at `path` and checks it against the device `target`."""
def kit_rows(path, target):
"""Every kit the kits step lists, in Manage Kits' groups (see KitRow.group).
The kits the backup's app needs, those its schema or the device holds without the
app needing them, and every other kit Sedona home has. Each defaults to the device's
build. Raises ValueError when the zip holds no readable app.
"""
kits, used, locked = _backup_kits(path)
device = {n: _hex(c) for n, c in target.kits}
backup = {n: _hex(c) if c else None for n, c in kits}
names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits()))
unsupported = palette.unsupported_kits(set(backup) | set(device))
rows = []
for name in names:
builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)]
if locked and name not in backup:
continue # nothing can be added to a bare app.sab
rows.append(KitRow(name, device.get(name), backup.get(name), builds,
used=name in used, locked=locked,
unsupported=name in unsupported and name not in used))
settle_required(rows)
return rows
def settle_required(rows):
"""Marks every kit an included kit depends on as required -- and so included, at its
default build -- all the way down, as Manage Kits counts dependencies."""
by_name = {r.name: r for r in rows}
for row in rows:
row.required = row.used
pending = [r for r in rows if r.included or r.used]
while pending:
row = pending.pop()
for dep in schema.read_kit_dependencies(row.name, row.use):
dep_row = by_name.get(dep)
if dep_row is not None and not dep_row.required:
dep_row.required = dep_row.included = True
pending.append(dep_row)
for row in rows:
if row.used:
row.included = True
KIT_GROUPS = ("Installed kits", "Installed but not used kits",
"Kits available on the computer", "Not supported")
def group(row):
"""The Manage Kits section a row is listed under: the app needs it; the device or
the backup holds it without the app needing it; Sedona home has it; or it cannot run
on the device."""
if row.unsupported:
return KIT_GROUPS[3]
if row.required:
return KIT_GROUPS[0]
if row.device or row.backup:
return KIT_GROUPS[1]
return KIT_GROUPS[2]
def chosen_kits(rows):
"""[(name, checksum hex)] of the included rows."""
return [(r.name, r.use) for r in rows if r.included]
def plan_put(path, target, rows=None, rebuild=False):
"""Reads the zip at `path` and checks it against the device `target`.
rows are kit_rows() as the kits step left them; None takes the defaults. The
included kits make app.sab's schema; kits.scode is built for them when they are not
the device's kits, or when `rebuild` asks for it anyway.
"""
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
if rows is None:
try:
rows = kit_rows(path, target)
except Exception as e:
plan.problems.append(str(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)
app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild)
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)}.")
plan.problems.append(f"The backup is from {platform_label(platform)}; the device "
f"is {platform_label(target.platform)}.")
return plan
zip_name = app.prop(app.app, "deviceName", "") or ""
@@ -524,63 +661,108 @@ def plan_put(path, target):
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
def _plan_app(plan, z, names, target, rows, rebuild):
"""Settles app.sab and kits.scode, as Ontrol's PutApp does.
The backup's app.sax is encoded for the chosen kit builds (a backup with only
device/app.sab goes as it is). When the device's kits.scode cannot load that app, or
a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after
a check of their natives against the device's VM; without sedonac, the backup's own
kits.scode can stand in when the chosen builds are the backup's. Returns (decoded
app, whether the backup came from a portrait screen).
"""
sax_name, sab_name = _app_source(z, names)
chosen = chosen_kits(rows)
original = None
if sab_name:
try:
original = sab_app.SabApp(z.read(sab_name))
except Exception as e:
if not sax_name:
plan.problems.append(f"{sab_name} cannot be read: {e}")
return None, False
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)
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
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))
plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}")
return None, False
plan.app_source = f"{sax_name}, encoded for the chosen kits"
source = sax_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)
sab = z.read(sab_name)
plan.app_source = sab_name
source = sab_name
app = sab_app.SabApp(sab)
app_kits = app.kit_parts
plan.kit_changes = [line for line in map(kit_change, rows) if line]
device_kits = {(n, _checksum(c)) for n, c in target.kits}
if sax_name:
same = {(n, _checksum(c)) for n, c in app_kits} == device_kits
else:
same = kits_fit(app_kits, target.kits)
if rebuild or not same:
scode = _plan_scode(plan, z, names, target, app_kits)
if scode is None:
return None, False
plan.app_files.append(scode)
plan.app_files.append(PutFile(APP_SAB, sab, source))
return app, _app_portrait(original or app)
def kit_change(row):
"""How a row changes the device's kits, or None."""
def label(checksum):
version = palette.version_for_checksum(row.name, checksum)
return f"{version} ({checksum})" if version else checksum
if row.device is None:
return f"{row.name}: added at {label(row.use)}" if row.included else None
if not row.included:
return f"{row.name}: removed"
if row.use != row.device:
return f"{row.name}: {label(row.device)} -> {label(row.use)}"
return None
def _plan_scode(plan, z, names, target, app_kits):
"""kits.scode for the app's kits: built by sedonac, or the backup's own."""
manifest = kit_build.platform_manifest(target.platform_id)
backup_scode = "device/" + KITS_SCODE
backup_kits = None
if "device/" + APP_SAB in names:
try:
backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts
except Exception:
pass
why = None
if manifest is None:
why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}"
" (platforms/db), so kits.scode cannot be built or checked for it.")
else:
problems = kit_build.native_problems(app_kits, manifest)
if problems:
plan.problems.append("The chosen kits call native methods the device's VM "
"does not have:")
plan.problems += [" " + p for p in problems]
return None
try:
data = kit_build.build_kits_scode(app_kits, manifest)
except kit_build.BuildError as e:
why = str(e)
else:
plan.scode_source = "built by sedonac for the chosen kits"
return PutFile(KITS_SCODE, data, "sedonac")
if backup_kits is not None and backup_scode in names and \
[(n, _checksum(c)) for n, c in backup_kits] == \
[(n, _checksum(c)) for n, c in app_kits]:
plan.warnings.append(why + " The backup's own kits.scode goes instead.")
plan.scode_source = "the backup's, unchecked against the device's VM"
return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode)
plan.problems.append(why)
return None
def _plan_images(plan, z, names, platform, target, same_screen):