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
+205 -1
View File
@@ -5,7 +5,11 @@ import sys
import tkinter as tk
from tkinter import filedialog, font as tkfont, messagebox, ttk
import threading
import app_state
import connect_dialog
import device_session
import link_view
import manage_kits
import palette
@@ -393,6 +397,9 @@ def _clear_restoring_flag():
def do_undo(event=None):
if app_state.live_mode:
app_state.set_status("Undo is not available in live mode", warn=True)
return "break"
label = undo.undo()
if label is None:
return "break"
@@ -401,6 +408,9 @@ def do_undo(event=None):
def do_redo(event=None):
if app_state.live_mode:
app_state.set_status("Redo is not available in live mode", warn=True)
return "break"
label = undo.redo()
if label is None:
return "break"
@@ -409,10 +419,32 @@ def do_redo(event=None):
def do_save(event=None):
if app_state.live_mode:
do_download_to_sax()
return "break"
sax_file.execute_direct_overwrite_save()
return "break"
def do_download_to_sax():
"""In live mode, saves the current device DOM snapshot as a .sax file."""
if app_state.xml_root_element is None:
return
path = filedialog.asksaveasfilename(
title="Download to SAX",
defaultextension=".sax",
filetypes=[("Sedona SAX", "*.sax"), ("All files", "*.*")],
initialfile=f"{app_state.device_host}.sax"
)
if not path:
return
try:
sax_file.write_sax_to_path(path)
app_state.set_status(f"Downloaded to {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("Download Failed", str(e))
NL = chr(10) # newline, for the multi-line message boxes
@@ -578,6 +610,23 @@ def on_tree_select(event):
app_state.current_sheet_parent = element
record_visit(VIEW_SHEET, element)
if app_state.live_mode and app_state.device_session and element is not None:
comp_id_str = element.get("id")
if comp_id_str is not None:
ds = app_state.device_session
ds.close_wiresheet()
try:
ds.open_wiresheet(int(comp_id_str))
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# re-find the element after DOM rebuild
element = app_state.tree_element_map.get(selected_id)
app_state.current_sheet_parent = element
except Exception:
pass
if element is not None:
components_to_render = [child for child in element if child.tag == "comp"]
else:
@@ -1290,7 +1339,18 @@ def attach_tree_scrollbars(container, tree):
# Resizing, and opening or closing a branch, both change what the widest row is.
tree.bind("<Configure>", lambda event: fit_tree_column(tree), add="+")
tree.bind("<<TreeviewOpen>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
def _on_tree_open(event):
tree.after_idle(fit_tree_column, tree)
if app_state.live_mode:
sel = tree.selection()
if sel:
elem = app_state.tree_element_map.get(sel[0])
if elem is not None:
cid = elem.get("id")
if cid is not None:
_live_tree_expand(int(cid))
tree.bind("<<TreeviewOpen>>", _on_tree_open, add="+")
tree.bind("<<TreeviewClose>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
@@ -1838,6 +1898,143 @@ def verify_startup_configurations():
wiresheet.render_components(app_state.active_components_list)
# ── Live device connection ──────────────────────────────────────
_cov_poll_id = None
def do_device_connect():
"""Opens the connect dialog and initiates a device connection."""
connect_dialog.ConnectDialog(app_state.window, _connect_to_device)
def _connect_to_device(host, port, user, password):
"""Called from the connect dialog's background thread."""
ds = device_session.DeviceSession()
root_comp = ds.connect(host, port, user, password)
app_state.device_session = ds
app_state.device_host = host
app_state.device_port = port
app_state.live_mode = True
app_state.connection_status = "connected"
app_state.window.after(0, lambda: _on_connected(ds, root_comp))
return root_comp
def _on_connected(ds, root_comp):
"""Main-thread callback after successful connect."""
app_state.window.title(app_state.base_window_title_live())
menu_device.entryconfig("Connect...", state="disabled")
menu_device.entryconfig("Disconnect", state="normal")
menu_device.entryconfig("Download to SAX...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
sax_root = ds.build_dom_tree(root_comp)
app_state.xml_root_element = sax_root
app_state.current_file_path = f"device://{app_state.device_host}"
_load_live_schema(ds)
sax_file.refresh_treeview_from_dom(sax_root)
undo.reset()
app_state.mark_clean()
_start_cov_polling()
def _load_live_schema(ds):
"""Loads schema kits from the device's schema parts into the editor registry."""
app_state.active_schema_kits.clear()
app_state.final_components_registry.clear()
if ds.sox_client and ds.sox_client.schema_parts:
for part in ds.sox_client.schema_parts:
app_state.active_schema_kits[part.name] = f"{part.checksum:08x}"
try:
schema.load_schema_kits_and_manifests()
except Exception:
pass
def do_device_disconnect():
"""Disconnects from the live device."""
global _cov_poll_id
if _cov_poll_id is not None:
app_state.window.after_cancel(_cov_poll_id)
_cov_poll_id = None
ds = app_state.device_session
if ds is not None:
ds.disconnect()
app_state.device_session = None
app_state.live_mode = False
app_state.connection_status = "offline"
app_state.device_host = ""
app_state.device_port = 1876
app_state.window.title(app_state.base_window_title())
menu_device.entryconfig("Connect...", state="normal")
menu_device.entryconfig("Disconnect", state="disabled")
menu_device.entryconfig("Download to SAX...", state="disabled")
app_state.set_status("Disconnected")
sax_file.reset_to_startup_state()
sax_file.refresh_treeview_from_dom(None)
wiresheet.render_components([])
def _start_cov_polling():
"""Starts the 100ms COV event polling loop on the main thread."""
global _cov_poll_id
def poll():
global _cov_poll_id
ds = app_state.device_session
if ds is None or not ds.is_connected:
_cov_poll_id = None
if app_state.live_mode:
app_state.connection_status = "error"
app_state.set_status("Connection lost", warn=True)
return
events = ds.poll_events()
if events and app_state.xml_root_element is not None:
needs_repaint = False
for comp_id, what_char in events:
elem = ds.update_element_from_cache(comp_id, app_state.xml_root_element)
if elem is not None:
needs_repaint = True
if needs_repaint:
wiresheet.render_current_sheet()
if properties_panel is not None:
try:
properties_panel.draw()
except Exception:
pass
_cov_poll_id = app_state.window.after(100, poll)
_cov_poll_id = app_state.window.after(100, poll)
def _live_tree_expand(comp_id):
"""Called when user expands a tree node in live mode to lazy-load children."""
ds = app_state.device_session
if ds is None or not ds.is_connected:
return
children = ds.load_children(comp_id)
if not children:
return
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# --- UI Setup ---
window = tk.Tk()
app_state.window = window
@@ -1976,6 +2173,13 @@ menu_app_info = tk.Menu(menu_bar, tearoff=0)
menu_app_info.add_command(label="App Info", command=show_app_info)
menu_bar.add_cascade(label="App", menu=menu_app_info)
menu_device = tk.Menu(menu_bar, tearoff=0)
menu_device.add_command(label="Connect...", command=do_device_connect)
menu_device.add_command(label="Disconnect", command=do_device_disconnect, state="disabled")
menu_device.add_separator()
menu_device.add_command(label="Download to SAX...", command=do_download_to_sax, state="disabled")
menu_bar.add_cascade(label="Device", menu=menu_device)
menu_prefs = tk.Menu(menu_bar, tearoff=0)
menu_prefs.add_command(label="Preferences", command=open_preferences_dialog)
menu_bar.add_cascade(label="Preferences", menu=menu_prefs)