Implement live device add/delete component and app actions

Fix add component from palette in live mode: resolve <app> element
as comp_id 0, fix type_id lookup to use type_name_map, set type_name
on newly added SoxComponent, sync device values into DOM after add,
write meta position back to device, and subscribe new component to COV.

Fix delete component in live mode: clean up subscriptions for deleted
component and all its descendants.

Add tree root context menu with device app actions (save, hibernate,
quit, restart, reboot) in live mode. Device save clears dirty state.

Clean up connection lost handling to do a full disconnect.
Fix slot registry to copy base slots and renumber IDs correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 16:30:42 +03:00
co-authored by Claude Opus 4.6
parent 9a483a0b0e
commit 2530bbd92c
7 changed files with 102 additions and 19 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.103" VERSION = "0.0.0.108"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+30 -2
View File
@@ -96,13 +96,17 @@ class DeviceSession:
if kit_name == "sys": if kit_name == "sys":
base_slots = self._extract_component_base_slots(manifest) base_slots = self._extract_component_base_slots(manifest)
self._register_kit_types(registry, kit_id, kit_name, manifest) self._register_kit_types(registry, kit_id, kit_name, manifest)
# Prepend inherited Component base slots (meta, etc.) to every type # Prepend inherited Component base slots (meta, etc.) to every type,
# then renumber IDs to match the wire slot index (positional in the
# flattened inherited + own list — see sedona Type.java resolveSlots).
if base_slots: if base_slots:
for key in registry: for key in registry:
existing_names = {s["name"] for s in registry[key]} existing_names = {s["name"] for s in registry[key]}
for bs in base_slots: for bs in base_slots:
if bs["name"] not in existing_names: if bs["name"] not in existing_names:
registry[key].insert(0, bs) registry[key].insert(0, dict(bs))
for i, slot in enumerate(registry[key]):
slot["id"] = i
self.sox_client.slot_registry = registry self.sox_client.slot_registry = registry
def _extract_component_base_slots(self, manifest): def _extract_component_base_slots(self, manifest):
@@ -238,6 +242,30 @@ class DeviceSession:
pass pass
self._current_subs.clear() self._current_subs.clear()
# ── Post-add / Post-delete bookkeeping ─────────────────────
def after_add(self, comp_id):
"""Subscribes a newly added component and reads its initial values."""
comp = self.sox_client.cache_get(comp_id)
if comp is None or not self.is_connected:
return
try:
self.sox_client.update([comp], CONFIG | RUNTIME)
except Exception:
pass
try:
self.sox_client.subscribe([comp], ALL)
self._current_subs.add(comp_id)
except Exception:
pass
def after_delete(self, comp_id, descendant_ids=None):
"""Removes a deleted component (and its descendants) from the subscription set."""
self._current_subs.discard(comp_id)
if descendant_ids:
for did in descendant_ids:
self._current_subs.discard(did)
# ── DOM Synthesis ─────────────────────────────────────────── # ── DOM Synthesis ───────────────────────────────────────────
def build_dom_tree(self, root_comp): def build_dom_tree(self, root_comp):
+1 -1
View File
@@ -7,7 +7,7 @@ left_pane_width = 230
trace_dasp = True trace_dasp = True
[Device] [Device]
host = 192.168.0.26 host = 192.168.1.251
port = 1876 port = 1876
user = admin user = admin
+13 -8
View File
@@ -66,6 +66,7 @@ def live_add_component(parent_element, kit_name, type_name, comp_name, config_va
try: try:
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, sox_config) new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, sox_config)
ds.after_add(new_comp.id)
return new_comp.id return new_comp.id
except Exception as e: except Exception as e:
app_state.set_status(f"Add failed: {e}", warn=True) app_state.set_status(f"Add failed: {e}", warn=True)
@@ -84,8 +85,16 @@ def live_delete_component(element):
if comp_id is None: if comp_id is None:
return False 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: try:
ds.sox_client.delete(comp_id) ds.sox_client.delete(comp_id)
ds.after_delete(comp_id, descendant_ids)
return True return True
except Exception as e: except Exception as e:
app_state.set_status(f"Delete failed: {e}", warn=True) app_state.set_status(f"Delete failed: {e}", warn=True)
@@ -189,6 +198,8 @@ def live_invoke(element, slot_name, arg=None):
# ── Helpers ───────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────
def _comp_id(element): def _comp_id(element):
if element.tag == "app":
return 0
cid = element.get("id") cid = element.get("id")
if cid is None: if cid is None:
return None return None
@@ -222,15 +233,9 @@ def _kit_id(ds, kit_name):
def _type_id(ds, kit_id, type_name): def _type_id(ds, kit_id, type_name):
if kit_id is None: if kit_id is None:
return None return None
for (kid, tid), slots in ds.sox_client.slot_registry.items(): for (kid, tid), full_name in ds.sox_client.type_name_map.items():
if kid == kit_id: if kid == kit_id and full_name.endswith(f"::{type_name}"):
comp = ds.sox_client.cache_get(0)
if comp and comp.type_name.endswith(f"::{type_name}"):
return tid return tid
# fallback: search all cached components for a matching type_name
for c in ds.sox_client.cache:
if c is not None and c.kit_id == kit_id and c.type_name.endswith(f"::{type_name}"):
return c.type_id
return None return None
+43 -2
View File
@@ -445,6 +445,39 @@ def do_download_to_sax():
messagebox.showerror("Download Failed", str(e)) messagebox.showerror("Download Failed", str(e))
def _invoke_app_action(slot_name):
"""Invokes an action slot on the app component (comp_id 0) of the connected device."""
ds = app_state.device_session
if ds is None or not ds.is_connected:
app_state.set_status("Not connected to a device", warn=True)
return
comp = ds.sox_client.cache_get(0)
if comp is None:
app_state.set_status("App component not loaded", warn=True)
return
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None:
app_state.set_status("No slot registry for the app component", warn=True)
return
slot = next((s for s in slots if s["name"] == slot_name and s.get("is_action")), None)
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return
try:
ds.sox_client.invoke(0, slot["id"])
app_state.set_status(f"Invoked '{slot_name}' on device")
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return
if slot_name == "save":
app_state.mark_clean()
elif slot_name in ("hibernate", "quit", "restart", "reboot"):
do_device_disconnect()
_APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
NL = chr(10) # newline, for the multi-line message boxes NL = chr(10) # newline, for the multi-line message boxes
@@ -876,6 +909,15 @@ def on_tree_right_click(event):
parent_id = tree.parent(item_id) parent_id = tree.parent(item_id)
if parent_id == "": if parent_id == "":
tree_root_context_menu.delete(0, tk.END)
if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected:
for action_name in _APP_ACTIONS:
tree_root_context_menu.add_command(
label=action_name.capitalize(),
command=lambda a=action_name: _invoke_app_action(a))
else:
tree_root_context_menu.add_command(
label="Save", command=sax_file.execute_direct_overwrite_save)
tree_root_context_menu.post(event.x_root, event.y_root) tree_root_context_menu.post(event.x_root, event.y_root)
return return
@@ -2086,7 +2128,7 @@ def _start_cov_polling():
if ds is None or not ds.is_connected: if ds is None or not ds.is_connected:
_cov_poll_id = None _cov_poll_id = None
if app_state.live_mode: if app_state.live_mode:
app_state.connection_status = "error" do_device_disconnect()
app_state.set_status("Connection lost", warn=True) app_state.set_status("Connection lost", warn=True)
return return
@@ -2192,7 +2234,6 @@ def _rebuild_link_context_menu():
tree_comp_context_menu = tk.Menu(window, tearoff=0) tree_comp_context_menu = tk.Menu(window, tearoff=0)
tree_root_context_menu = tk.Menu(window, tearoff=0) tree_root_context_menu = tk.Menu(window, tearoff=0)
tree_root_context_menu.add_command(label="Save", command=sax_file.execute_direct_overwrite_save)
# --- Keyboard shortcuts --- # --- Keyboard shortcuts ---
# Command and Control are both bound on every platform, so muscle memory from either # Command and Control are both bound on every platform, so muscle memory from either
+12 -4
View File
@@ -796,13 +796,21 @@ def add_component(kit_name, type_name, grid_x, grid_y):
element.set("id", str(comp_id)) element.set("id", str(comp_id))
element.set("type", f"{kit_name}::{type_name}") element.set("type", f"{kit_name}::{type_name}")
meta_prop = ET.SubElement(element, "prop")
meta_prop.set("name", "meta")
update_meta_property(element, None, grid_x, grid_y)
index = len(list(parent)) index = len(list(parent))
parent.append(element) parent.append(element)
if live_hooks.is_live():
ds = app_state.device_session
if ds is not None:
ds.update_element_from_cache(comp_id, app_state.xml_root_element)
update_meta_property(element, None, grid_x, grid_y)
if live_hooks.is_live():
meta_prop = element.find("prop[@name='meta']")
if meta_prop is not None:
live_hooks.live_set_prop_value(element, "meta", meta_prop.get("val", "0"))
if not app_state.live_mode: if not app_state.live_mode:
undo.push(undo.AddComponentCommand( undo.push(undo.AddComponentCommand(
parent, index, element, parent, index, element,
+1
View File
@@ -652,6 +652,7 @@ class SoxClient:
comp.name = name comp.name = name
comp.parent = parent_id comp.parent = parent_id
comp.children = [] comp.children = []
comp.type_name = self._resolve_type_name(kit_id, type_id_val)
self.cache_add(comp) self.cache_add(comp)
parent = self.cache_get(parent_id) parent = self.cache_get(parent_id)