"""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/.bin device/fonts/ 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]/.png the sources of the device's images and fonts, taken from fontBank[XL]/ 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---.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 '.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)