Live Editor: add DASP/Sox device connection with full CRUD and COV

Port the DASP transport and Sox protocol stack from sedonaOntrol Java
to Python, enabling live device editing alongside the existing offline
SAX editor. Lazy-loads components on navigation, caches them, and
subscribes to COV events for the active wiresheet.

New files: dasp.py, sox.py, device_session.py, connect_dialog.py,
live_hooks.py. Modified: app_state.py (live-mode state), main.py
(Device menu, COV polling, lazy tree expand), properties.py (Sox
write-through), sax_file.py (structural mutations route through
live_hooks), schema.py (find_manifest helper).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 23:31:36 +03:00
co-authored by Claude Opus 4.6
parent 48dfd82832
commit dafebcebda
10 changed files with 3052 additions and 26 deletions
+66 -19
View File
@@ -6,11 +6,13 @@ from tkinter import filedialog, messagebox
import xml.etree.ElementTree as ET
import app_state
import live_hooks
import palette
import schema
import undo
import wiresheet
from sax_dom import (
find_component_by_path,
find_parent_element,
get_component_path,
restore_children,
@@ -253,6 +255,18 @@ def execute_direct_overwrite_save():
messagebox.showerror("Save Error", f"Failed to write direct update stream:\n{str(e)}")
def write_sax_to_path(path):
"""Writes the current DOM to a .sax file at the given path."""
if app_state.xml_root_element is None:
raise RuntimeError("No DOM to write")
sync_live_metadata_to_dom()
with open(path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
ET.ElementTree(app_state.xml_root_element).write(
f, encoding="utf-8", xml_declaration=False
)
def save_file_as():
"""Saves the tracking tree document hierarchy targeting an explicit location choice dialog."""
if not app_state.current_file_path or app_state.xml_root_element is None:
@@ -754,9 +768,16 @@ def add_component(kit_name, type_name, grid_x, grid_y):
reset_to_startup_state()
return None
comp_id = allocate_component_id()
comp_name = unique_child_name(parent, type_name)
if live_hooks.is_live():
new_id = live_hooks.live_add_component(parent, kit_name, type_name, comp_name)
if new_id is None:
return None
comp_id = new_id
else:
comp_id = allocate_component_id()
element = ET.Element("comp")
element.set("name", comp_name)
element.set("id", str(comp_id))
@@ -769,18 +790,19 @@ def add_component(kit_name, type_name, grid_x, grid_y):
index = len(list(parent))
parent.append(element)
undo.push(undo.AddComponentCommand(
parent, index, element,
label=f"add {comp_name}"
))
if not app_state.live_mode:
undo.push(undo.AddComponentCommand(
parent, index, element,
label=f"add {comp_name}"
))
app_state.mark_dirty()
refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
# The schema is only reconciled on save, so the placement says so here rather than
# letting the save dialog be the first the user hears of it.
if kit_name in app_state.pending_schema_kits:
if app_state.live_mode:
app_state.set_status(f"{comp_name} ({kit_name}::{type_name}) added to device.")
elif kit_name in app_state.pending_schema_kits:
app_state.set_status(
f"{comp_name} ({kit_name}::{type_name}) added — '{kit_name}' is not in this "
"app's schema and will be declared when you save.",
@@ -801,20 +823,31 @@ def remove_link(link_element):
if links_elem is None:
return
from_ref = link_element.get("from", "")
to_ref = link_element.get("to", "")
if live_hooks.is_live():
from_comp_path, from_slot_name = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
to_comp_path, to_slot_name = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
if from_elem is not None and to_elem is not None:
if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name):
return
try:
index = list(links_elem).index(link_element)
links_elem.remove(link_element)
except ValueError:
return
from_ref = link_element.get("from", "")
to_ref = link_element.get("to", "")
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
undo.push(undo.DeleteLinkCommand(
links_elem, index, link_element,
label=f"delete link {from_slot}→{to_slot}"
))
if not app_state.live_mode:
undo.push(undo.DeleteLinkCommand(
links_elem, index, link_element,
label=f"delete link {from_slot}→{to_slot}"
))
app_state.selected_link_tag = None
app_state.mark_dirty()
@@ -830,16 +863,26 @@ def add_link(from_path_slot, to_path_slot):
if links_elem is None:
links_elem = ET.SubElement(app_state.xml_root_element, "links")
if live_hooks.is_live():
from_comp_path, from_slot_name = from_path_slot.rsplit(".", 1) if "." in from_path_slot else ("", from_path_slot)
to_comp_path, to_slot_name = to_path_slot.rsplit(".", 1) if "." in to_path_slot else ("", to_path_slot)
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
if from_elem is not None and to_elem is not None:
if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name):
return
link_elem = ET.SubElement(links_elem, "link")
link_elem.set("from", from_path_slot)
link_elem.set("to", to_path_slot)
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
undo.push(undo.AddLinkCommand(
links_elem, link_elem,
label=f"add link {from_slot}→{to_slot}"
))
if not app_state.live_mode:
undo.push(undo.AddLinkCommand(
links_elem, link_elem,
label=f"add link {from_slot}→{to_slot}"
))
app_state.mark_dirty()
wiresheet.render_current_sheet()
@@ -861,6 +904,10 @@ def execute_cascading_deletion(comp_element):
if not confirm:
return False
if live_hooks.is_live():
if not live_hooks.live_delete_component(comp_element):
return False
comp_path = get_component_path(comp_element, app_state.xml_root_element)
all_elements_to_prune = [comp_element] + descendant_comps
@@ -896,7 +943,7 @@ def execute_cascading_deletion(comp_element):
comp_index = list(xml_parent).index(comp_element)
xml_parent.remove(comp_element)
if xml_parent is not None:
if xml_parent is not None and not app_state.live_mode:
undo.push(undo.DeleteComponentCommand(
xml_parent, comp_index, comp_element, link_records,
label=f"delete {comp_name}"