""" Write-through interception layer for live device mode. Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient. In offline mode, falls through to the original DOM-only code paths. """ import base64 import binascii from tkinter import messagebox import app_state from sox import BUF_ID, CONFIG, STR_ID, type_id_for_slot # Why the last live_* call failed, in words the user can act on. Every failing call sets # it, so a caller can put it in front of the user with warn_failure(). last_error = "" def is_live(): return (app_state.live_mode and app_state.device_session is not None and app_state.device_session.is_connected) def _fail(reason): global last_error last_error = reason return None def _session(): """The connected DeviceSession, or None with last_error saying why not.""" if not is_live(): _fail("The device is not connected.") return None return app_state.device_session def warn_failure(title, what, detail=None): """Tells the user, in a dialog and the status bar, that `what` did not reach the device. `detail` defaults to last_error. Nothing retries a write that failed, so the dialog is the only record the user gets that the editor and the device may now disagree. """ reason = detail if detail is not None else last_error app_state.set_status(f"{what}: {reason}", warn=True) messagebox.showwarning(title, f"{what}.\n\n{reason}", parent=app_state.window) # ── Property Write ────────────────────────────────────────────── def live_set_prop_value(element, slot_name, value_str): """In live mode, sends a Sox write to the device. Returns True on success.""" ds = _session() if ds is None: return False comp_id = _comp_id(element) if comp_id is None: _fail(f"'{element.get('name', '?')}' has no component id.") return False slot_info = _find_slot(ds, comp_id, slot_name) if slot_info is None: return False type_id = slot_info["type_id"] try: coerced = _coerce_value(type_id, value_str) except (TypeError, ValueError, binascii.Error) as e: _fail(f"'{value_str}' is not a valid value for {slot_name}: {e}") return False try: ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced) return True except Exception as e: _fail(f"The device rejected the write to {slot_name}: {e}") return False # ── Add Component ─────────────────────────────────────────────── def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None): """In live mode, sends a Sox add to the device. Returns new comp_id or None.""" ds = _session() if ds is None: return None parent_id = _comp_id(parent_element) if parent_id is None: return _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.") kit_id = _kit_id(ds, kit_name) if kit_id is None: return _fail(f"Kit '{kit_name}' is not installed on the device.") type_id_val = _type_id(ds, kit_id, type_name) if type_id_val is None: return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.") try: new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, list(config_values or [])) ds.after_add(new_comp.id) return new_comp.id except Exception as e: return _fail(f"The device rejected the add: {e}") # ── Delete Component ──────────────────────────────────────────── def live_delete_component(element): """In live mode, sends a Sox delete to the device. Returns True on success.""" ds = _session() if ds is None: return False comp_id = _comp_id(element) if comp_id is None: _fail(f"'{element.get('name', '?')}' has no component id.") return False descendant_ids = [] for desc in element.iter(): if desc.tag == "comp" and desc is not element: did = _comp_id(desc) if did is not None: descendant_ids.append(did) try: ds.sox_client.delete(comp_id) ds.after_delete(comp_id, descendant_ids) return True except Exception as e: _fail(f"The device rejected the delete: {e}") return False # ── Rename Component ──────────────────────────────────────────── def live_rename_component(element, new_name): """In live mode, sends a Sox rename to the device. Returns True on success.""" ds = _session() if ds is None: return False comp_id = _comp_id(element) if comp_id is None: _fail(f"'{element.get('name', '?')}' has no component id.") return False try: ds.sox_client.rename(comp_id, new_name) return True except Exception as e: _fail(f"The device rejected the rename: {e}") return False # ── Reorder Children ──────────────────────────────────────────── def live_reorder_children(parent_element, ordered_children): """In live mode, sends a Sox reorder to the device. Returns True on success. The device takes the order only when it names every child it has, no more and no fewer, so the list is checked against what the device last said before it is sent. """ ds = _session() if ds is None: return False parent_id = _comp_id(parent_element) if parent_id is None: _fail(f"'{parent_element.get('name', '?')}' has no component id.") return False child_ids = [] for child in ordered_children: cid = _comp_id(child) if cid is None: _fail(f"'{child.get('name', '?')}' has no component id.") return False child_ids.append(cid) client = ds.sox_client cached = client.cache_get(parent_id) if cached is not None and set(cached.children) != set(child_ids): _fail("The editor's list of children no longer matches the device's. " "Reconnect to read the device again.") return False try: client.reorder(parent_id, child_ids) return True except Exception as e: _fail(f"The device rejected the reorder: {e}") return False # ── Link / Unlink ─────────────────────────────────────────────── def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name): """(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set.""" if from_element is None or to_element is None: return _fail("One end of the link is not a component in the loaded tree.") from_id = _comp_id(from_element) to_id = _comp_id(to_element) if from_id is None or to_id is None: return _fail("One end of the link has no component id.") from_slot = _find_slot(ds, from_id, from_slot_name) if from_slot is None: return None to_slot = _find_slot(ds, to_id, to_slot_name) if to_slot is None: return None return from_id, from_slot["id"], to_id, to_slot["id"] def live_add_link(from_element, from_slot_name, to_element, to_slot_name): """In live mode, sends a Sox link command. Returns True on success.""" ds = _session() if ds is None: return False ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name) if ends is None: return False try: ds.sox_client.link(*ends) return True except Exception as e: _fail(f"The device rejected the link: {e}") return False def live_delete_link(from_element, from_slot_name, to_element, to_slot_name): """In live mode, sends a Sox unlink command. Returns True on success.""" ds = _session() if ds is None: return False ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name) if ends is None: return False try: ds.sox_client.unlink(*ends) return True except Exception as e: _fail(f"The device rejected the unlink: {e}") return False # ── Invoke Action ─────────────────────────────────────────────── def live_invoke(element, slot_name, arg=None): """In live mode, sends a Sox invoke. Returns True on success.""" ds = _session() if ds is None: return False comp_id = _comp_id(element) if comp_id is None: _fail(f"'{element.get('name', '?')}' has no component id.") return False slot_info = _find_slot(ds, comp_id, slot_name) if slot_info is None: return False try: ds.sox_client.invoke(comp_id, slot_info["id"]) return True except Exception as e: _fail(f"The device rejected the invoke of {slot_name}: {e}") return False # ── Undo: read a subtree back, put it back ────────────────────── def read_subtree(element): """Everything it takes to put `element` and all it holds back on the device. Read from the device, not the DOM: the DOM only holds what has been opened, and a placeholder child carries neither config nor children of its own. Requests go one component at a time, at the pace the device sets (see used_kit_names). Returns {"tree", "links", "siblings"}, or None with last_error set. A node is {"id", "kit_id", "type_id", "type_name", "name", "values", "element", "children"}, where "element" is the DOM element showing it, if any, so a new id can reach it. A link end, and each of "siblings" (the parent's children, in order), is ("node", node), ("elem", element) or ("id", comp_id): a component outside the subtree is followed by its element when it has one, because its id changes if it is itself deleted and put back. """ ds = _session() if ds is None: return None comp_id = _comp_id(element) if comp_id is None: return _fail(f"'{element.get('name', '?')}' has no component id.") client = ds.sox_client root = app_state.xml_root_element elements_by_id = {} if root is not None: for elem in root.iter("comp"): if elem.get("id") is not None: elements_by_id.setdefault(elem.get("id"), elem) nodes_by_id = {} def read(comp): if comp.type_name not in app_state.final_components_registry: raise _SnapshotError(f"No kit manifest describes {comp.type_name}, so " f"'{comp.name}' could not be put back as it was.") children = ds.load_children(comp.id) if len(children) != len(comp.children): raise _SnapshotError(f"Not every child of '{comp.name}' could be read.") client.update([comp], CONFIG) values = dict(comp.values) # The cache reads every Buf as a Str, which loses bytes of a binary one (a # user's cred); those are read again as what they are. for slot in app_state.final_components_registry[comp.type_name]: if ("c" in slot["flags"] and slot["type"] != "void" and _wire_type(slot["type"], slot["facets"].get("asStr") is True) == BUF_ID): values[slot["name"]] = client.read_prop(comp.id, slot["slot_id"], BUF_ID) node = { "id": comp.id, "kit_id": comp.kit_id, "type_id": comp.type_id, "type_name": comp.type_name, "name": comp.name, "values": values, "element": elements_by_id.get(str(comp.id)), "children": [], } nodes_by_id[comp.id] = node node["children"] = [read(child) for child in children] return node try: top = client.load(comp_id) if top is None: return _fail(f"'{element.get('name', '?')}' could not be read from the device.") tree = read(top) links = [] seen = set() for cid in list(nodes_by_id): for lnk in client.read_links(cid): key = (lnk.from_comp_id, lnk.from_slot_id, lnk.to_comp_id, lnk.to_slot_id) if key in seen: continue seen.add(key) links.append((_link_end(lnk.from_comp_id, nodes_by_id, elements_by_id), lnk.from_slot_id, _link_end(lnk.to_comp_id, nodes_by_id, elements_by_id), lnk.to_slot_id)) except _SnapshotError as e: return _fail(str(e)) except Exception as e: return _fail(f"'{element.get('name', '?')}' could not be read from the device: {e}") parent = client.cache_get(top.parent) siblings = [_link_end(cid, nodes_by_id, elements_by_id) for cid in (parent.children if parent is not None else [])] return {"tree": tree, "links": links, "siblings": siblings} class _SnapshotError(Exception): pass def _link_end(comp_id, nodes_by_id, elements_by_id): if comp_id in nodes_by_id: return ("node", nodes_by_id[comp_id]) elem = elements_by_id.get(str(comp_id)) if elem is not None: return ("elem", elem) return ("id", comp_id) def _link_end_id(end): kind, ref = end if kind == "node": return ref["id"] if kind == "elem": return _comp_id(ref) return ref def recreate_subtree(parent_element, snapshot, build_config): """Adds a subtree from read_subtree back under `parent_element`, with its links. The device hands out new ids. Each node, and the DOM element showing it, takes its new id, so whatever refers to them later still finds them. The component goes back to its old place among its siblings. `build_config(type_name, device_values=...)` makes an add command's config values. Returns (top_created, complete); when not complete, last_error says what is missing. """ ds = _session() if ds is None: return False, False parent_id = _comp_id(parent_element) if parent_id is None: _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.") return False, False client = ds.sox_client tree = snapshot["tree"] def create(node, under_id): config = build_config(node["type_name"], device_values=node["values"]) comp = client.add(under_id, node["kit_id"], node["type_id"], node["name"], config) node["id"] = comp.id if node["element"] is not None: node["element"].set("id", str(comp.id)) def create_children(node): for child in node["children"]: create(child, node["id"]) create_children(child) try: create(tree, parent_id) except Exception as e: _fail(f"The device rejected the add of '{tree['name']}': {e}") return False, False try: create_children(tree) for from_end, from_slot, to_end, to_slot in snapshot["links"]: client.link(_link_end_id(from_end), from_slot, _link_end_id(to_end), to_slot) # The add put it last; the device's order is the order execution runs in. parent = client.cache_get(parent_id) wanted = [_link_end_id(end) for end in snapshot["siblings"]] if (parent is not None and set(wanted) == set(parent.children) and wanted != parent.children): client.reorder(parent_id, wanted) complete = True except Exception as e: _fail(f"'{tree['name']}' is back on the device, but not all of it: {e}") complete = False ds.after_add(tree["id"]) return True, complete def duplicate_subtrees(plans, links, build_config): """Adds copies of read_subtree trees to the device, then the links among them. `plans` is a list of (parent_element, node, new_name, new_meta): the node is a read_subtree tree, copied under `parent_element` with its top renamed and moved to `new_meta` (None keeps it where it was). Children keep their names, which stay unique because their parent is new. `links` holds (from_id, from_slot, to_id, to_slot) between components in the trees, by their original ids. Nothing in a node is changed; the device hands out new ids for the copies. Returns (created, new_ids, complete): `created` lists (parent_element, new_top_id) for every top the device took, `new_ids` maps each original id to its copy's, and when not complete, last_error says what is missing. """ ds = _session() if ds is None: return [], {}, False client = ds.sox_client new_ids = {} created = [] def create(node, under_id, name, meta): values = dict(node["values"]) if meta is not None: values["meta"] = meta config = build_config(node["type_name"], device_values=values) comp = client.add(under_id, node["kit_id"], node["type_id"], name, config) new_ids[node["id"]] = comp.id for child in node["children"]: create(child, comp.id, child["name"], None) complete = True for parent_element, node, new_name, new_meta in plans: parent_id = _comp_id(parent_element) if parent_id is None: _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.") complete = False break try: create(node, parent_id, new_name, new_meta) except Exception as e: if node["id"] in new_ids: created.append((parent_element, new_ids[node["id"]])) _fail(f"'{new_name}' was added to the device, but not all of it: {e}") else: _fail(f"The device rejected the add of '{new_name}': {e}") complete = False break created.append((parent_element, new_ids[node["id"]])) if complete: for from_id, from_slot, to_id, to_slot in links: try: client.link(new_ids[from_id], from_slot, new_ids[to_id], to_slot) except Exception as e: _fail(f"The copies are on the device, but not every link between them: {e}") complete = False break top_ids = {top_id for _, top_id in created} for top_id in top_ids: ds.after_add(top_id) # after_add reads only the top; the children's values are read too, so the DOM # built from the cache carries their slots. children = [client.cache_get(i) for i in new_ids.values() if i not in top_ids] children = [c for c in children if c is not None] if children: try: client.update(children, CONFIG) except Exception: pass return created, new_ids, complete def wire_value(type_id, raw): """A value as the device sent it, in the form encode_value wants for `type_id`.""" if type_id == BUF_ID: return bytes(raw) if isinstance(raw, (bytes, bytearray)) else str(raw).encode("utf-8") if type_id == STR_ID: if isinstance(raw, (bytes, bytearray)): return bytes(raw).decode("utf-8", errors="replace").rstrip("\0") return str(raw) return _coerce_value(type_id, str(raw)) # ── Helpers ───────────────────────────────────────────────────── def _comp_id(element): if element.tag == "app": return 0 cid = element.get("id") if cid is None: return None try: return int(cid) except ValueError: return None def _find_slot(ds, comp_id, slot_name): """{name, id, type_id} for a slot of a cached component, or None with last_error set. A Buf goes over the wire as a Buf only when it is binary; an asStr Buf travels as a Str, which is how the rest of the editor already reads and writes it. """ comp = ds.sox_client.cache_get(comp_id) if comp is None: return _fail(f"Component {comp_id} is not loaded from the device.") type_name = ds.sox_client.type_name_map.get((comp.kit_id, comp.type_id)) if type_name and type_name in app_state.final_components_registry: for s in app_state.final_components_registry[type_name]: if s["name"] == slot_name: slot_type = s.get("type") or "int" as_str = (s.get("facets") or {}).get("asStr") is True return {"name": s["name"], "id": s["slot_id"], "type_id": _wire_type(slot_type, as_str)} return _fail(f"{type_name} has no slot '{slot_name}'.") slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id)) if slots is None: return _fail(f"No kit manifest describes {type_name or 'this component'}'s slots, " "so the slot cannot be addressed on the device.") for s in slots: if s["name"] == slot_name: return {"name": s["name"], "id": s["id"], "type_id": _wire_type(s.get("type_name") or "int", s.get("is_as_str"))} return _fail(f"{type_name or 'This component'} has no slot '{slot_name}'.") def _wire_type(slot_type, as_str): if slot_type in ("sys::Buf", "Buf", "buf") and not as_str: return BUF_ID return type_id_for_slot(slot_type) def _kit_id(ds, kit_name): if ds.sox_client.schema_parts: for i, part in enumerate(ds.sox_client.schema_parts): if part.name == kit_name: return i return None def _type_id(ds, kit_id, type_name): if kit_id is None: return None for (kid, tid), full_name in ds.sox_client.type_name_map.items(): if kid == kit_id and full_name.endswith(f"::{type_name}"): return tid return None def _coerce_value(type_id, value_str): """The Python value encode_value wants for `value_str` in document form. Raises ValueError (or binascii.Error) when the text does not fit the type. """ from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID if type_id == BOOL_ID: return value_str.lower() in ("true", "1", "yes") elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID): try: return int(value_str) except ValueError: return int(value_str, 0) # meta and flags can be written as 0x... elif type_id in (FLOAT_ID, DOUBLE_ID): return float(value_str) elif type_id == STR_ID: return str(value_str) elif type_id == BUF_ID: if isinstance(value_str, (bytes, bytearray)): return bytes(value_str) return base64.b64decode(value_str or "", validate=True) # the .sax form return value_str