Make every live edit reach the device, and warn when it doesn't
In live mode the device is now written first and the document only takes what the device accepted. Every live_hooks failure records a reason, and warn_failure() shows it in a dialog and the status bar instead of failing silently. - Move, properties, OX page editor: rejected values are rolled back and listed - Add/remove link: no longer edits the DOM alone when an end can't be resolved - Add/delete component: report why the device refused - Tidy Layout: sends each new position to the device, reverts refusals, marks the document dirty, and pushes no undo step in live mode - User Manager: add (one Sox add carrying cred/perm/prov), edit and delete now go to the device - Binary Buf slots (e.g. User.cred) are sent as Buf bytes, not as Str text - Bump version to 0.0.0.136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
||||
import configparser
|
||||
import os
|
||||
|
||||
VERSION = "0.0.0.135"
|
||||
VERSION = "0.0.0.136"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
|
||||
+127
-69
@@ -5,8 +5,17 @@ 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 type_id_for_slot
|
||||
from sox import BUF_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():
|
||||
@@ -15,30 +24,59 @@ def is_live():
|
||||
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. Live mode has no undo, 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."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
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 = type_id_for_slot(slot_info["type_name"])
|
||||
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:
|
||||
app_state.set_status(f"Write failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the write to {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -46,43 +84,39 @@ def live_set_prop_value(element, slot_name, value_str):
|
||||
|
||||
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."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return None
|
||||
|
||||
ds = app_state.device_session
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
return 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 kit_id is None or type_id_val is None:
|
||||
return None
|
||||
|
||||
sox_config = []
|
||||
if config_values:
|
||||
for tid, val in config_values:
|
||||
sox_config.append((tid, val))
|
||||
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, sox_config)
|
||||
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:
|
||||
app_state.set_status(f"Add failed: {e}", warn=True)
|
||||
return None
|
||||
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."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
descendant_ids = []
|
||||
@@ -97,7 +131,7 @@ def live_delete_component(element):
|
||||
ds.after_delete(comp_id, descendant_ids)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Delete failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the delete: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -105,69 +139,70 @@ def live_delete_component(element):
|
||||
|
||||
def live_rename_component(element, new_name):
|
||||
"""In live mode, sends a Sox rename to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
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:
|
||||
app_state.set_status(f"Rename failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the rename: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Link / Unlink ───────────────────────────────────────────────
|
||||
|
||||
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."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
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 False
|
||||
|
||||
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 from_slot is None or to_slot is None:
|
||||
return False
|
||||
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(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
ds.sox_client.link(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Link failed: {e}", warn=True)
|
||||
_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."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
|
||||
if ends is None:
|
||||
return False
|
||||
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if from_slot is None or to_slot is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.unlink(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
ds.sox_client.unlink(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Unlink failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the unlink: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -175,12 +210,12 @@ def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
|
||||
def live_invoke(element, slot_name, arg=None):
|
||||
"""In live mode, sends a Sox invoke. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
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)
|
||||
@@ -191,7 +226,7 @@ def live_invoke(element, slot_name, arg=None):
|
||||
ds.sox_client.invoke(comp_id, slot_info["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Invoke failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the invoke of {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -210,22 +245,38 @@ def _comp_id(element):
|
||||
|
||||
|
||||
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 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_name": s.get("type", "int")}
|
||||
"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 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 s
|
||||
return None
|
||||
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):
|
||||
@@ -246,17 +297,24 @@ def _type_id(ds, kit_id, type_name):
|
||||
|
||||
|
||||
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):
|
||||
elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID):
|
||||
try:
|
||||
return int(value_str)
|
||||
elif type_id == LONG_ID:
|
||||
return int(value_str)
|
||||
elif type_id == FLOAT_ID:
|
||||
return float(value_str)
|
||||
elif type_id == DOUBLE_ID:
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ import ox_page_view
|
||||
import user_service_view
|
||||
import wiresheet
|
||||
from sax_dom import (find_component_by_path, find_parent_element, get_component_path,
|
||||
read_meta_property, update_meta_property)
|
||||
read_meta_property, remove_prop, set_prop_value, update_meta_property)
|
||||
|
||||
# Tree column measurements, in pixels. The padding covers the expander arrow and the
|
||||
# cell's own inset, which sit ahead of the text ttk lets us measure.
|
||||
@@ -620,6 +620,8 @@ def do_tidy_layout(event=None):
|
||||
return "break"
|
||||
|
||||
moved, clamped = wiresheet.tidy_layout()
|
||||
if moved is None:
|
||||
return "break"
|
||||
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
||||
|
||||
if moved == 0:
|
||||
@@ -1543,11 +1545,21 @@ def on_canvas_release(event):
|
||||
if orig_x != raw_x or orig_y != raw_y:
|
||||
meta_before = app_state.drag_data.get("meta_before", meta_str)
|
||||
update_meta_property(comp_element, meta_str, raw_x, raw_y)
|
||||
if live_hooks.is_live():
|
||||
if app_state.live_mode:
|
||||
new_meta = comp_element.find("prop[@name='meta']")
|
||||
if new_meta is not None:
|
||||
live_hooks.live_set_prop_value(
|
||||
comp_element, "meta", new_meta.get("val", "0"))
|
||||
if new_meta is None or not live_hooks.live_set_prop_value(
|
||||
comp_element, "meta", new_meta.get("val", "0")):
|
||||
# Back where the device still has it, so the sheet tells the truth.
|
||||
if meta_str is None:
|
||||
remove_prop(comp_element, "meta")
|
||||
else:
|
||||
set_prop_value(comp_element, "meta", meta_str)
|
||||
app_state.drag_data["tag"] = None
|
||||
wiresheet.render_current_sheet()
|
||||
live_hooks.warn_failure(
|
||||
"Move", f"{comp_element.get('name', 'The component')} was not "
|
||||
"moved on the device")
|
||||
return
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.MetaMoveCommand(
|
||||
[(comp_element, meta_before, raw_x, raw_y)],
|
||||
|
||||
+11
-4
@@ -552,11 +552,13 @@ class OxPageViewPanel:
|
||||
if not changes:
|
||||
return False
|
||||
|
||||
# Live: the device first, and the page only shows what the device accepted.
|
||||
live_failures = []
|
||||
for element, name, _before, after in changes:
|
||||
if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after):
|
||||
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||||
continue
|
||||
set_prop_value(element, name, after)
|
||||
if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after):
|
||||
live_failures.append(name)
|
||||
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.SlotValueCommand(changes))
|
||||
@@ -566,8 +568,13 @@ class OxPageViewPanel:
|
||||
|
||||
who = comp.get("name", "?")
|
||||
if live_failures:
|
||||
app_state.set_status(
|
||||
f"{who}: written locally but failed on device: {', '.join(live_failures)}")
|
||||
live_hooks.warn_failure(
|
||||
"Page Editor",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return len(live_failures) < len(changes)
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
|
||||
|
||||
+25
-7
@@ -35,7 +35,7 @@ import app_state
|
||||
import live_hooks
|
||||
import schema
|
||||
import undo
|
||||
from sax_dom import (get_component_path, read_meta_property, set_prop_value,
|
||||
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
|
||||
update_meta_groups, update_meta_property)
|
||||
|
||||
# Sedona slot types, grouped by how a value of that type is parsed and bounded.
|
||||
@@ -1167,13 +1167,22 @@ class PropertiesPanel:
|
||||
self.status_var.set("No changes to save.")
|
||||
return
|
||||
|
||||
# In live mode the device is written first and a value only lands in the document
|
||||
# once the device took it, so the panel never shows what the device does not hold.
|
||||
live_failures = []
|
||||
for element, prop_name, _before, after in changes:
|
||||
for element, prop_name, before, after in changes:
|
||||
if app_state.live_mode and not live_hooks.live_set_prop_value(
|
||||
element, prop_name, after):
|
||||
live_failures.append(f"{prop_name}: {live_hooks.last_error}")
|
||||
if prop_name == "meta": # applied above, ahead of the write
|
||||
if before is None:
|
||||
remove_prop(element, "meta")
|
||||
else:
|
||||
set_prop_value(element, "meta", before)
|
||||
continue
|
||||
if prop_name != "meta":
|
||||
set_prop_value(element, prop_name, after)
|
||||
if live_hooks.is_live():
|
||||
if not live_hooks.live_set_prop_value(element, prop_name, after):
|
||||
live_failures.append(prop_name)
|
||||
written = len(changes) - len(live_failures)
|
||||
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.SlotValueCommand(list(changes)))
|
||||
@@ -1183,11 +1192,20 @@ class PropertiesPanel:
|
||||
if self.on_change is not None:
|
||||
self.on_change()
|
||||
|
||||
count = len(changes)
|
||||
count = written
|
||||
name = self.comp.get('name', '?')
|
||||
if live_failures:
|
||||
self.status_var.set(
|
||||
f"Written locally but failed on device: {', '.join(live_failures)}"
|
||||
f"{written} written, {len(live_failures)} rejected by the device — "
|
||||
"those still show the device's value."
|
||||
)
|
||||
live_hooks.warn_failure(
|
||||
"Properties",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {name} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return
|
||||
elif app_state.live_mode:
|
||||
self.status_var.set(
|
||||
f"{count} slot{'' if count == 1 else 's'} written to device."
|
||||
|
||||
+37
-17
@@ -760,10 +760,13 @@ def reconcile_schema_kits_before_save():
|
||||
return True
|
||||
|
||||
|
||||
def _build_config_values(full_type):
|
||||
"""Builds SOX config_values list from manifest defaults for an add command."""
|
||||
from sox import type_id_for_slot
|
||||
from live_hooks import _coerce_value
|
||||
def build_config_values(full_type, overrides=None):
|
||||
"""Builds SOX config_values list from manifest defaults for an add command.
|
||||
|
||||
`overrides` maps slot name to the document-form value to send instead of the default.
|
||||
Raises ValueError when a value does not fit its slot's type.
|
||||
"""
|
||||
from live_hooks import _coerce_value, _wire_type
|
||||
|
||||
slots = app_state.final_components_registry.get(full_type, [])
|
||||
config_values = []
|
||||
@@ -772,12 +775,12 @@ def _build_config_values(full_type):
|
||||
continue
|
||||
if slot["type"] == "void":
|
||||
continue
|
||||
val = slot["default"]
|
||||
val = (overrides or {}).get(slot["name"], slot["default"])
|
||||
if val is None:
|
||||
val = properties.type_zero(slot["type"])
|
||||
if val is None:
|
||||
continue
|
||||
tid = type_id_for_slot(slot["type"])
|
||||
tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True)
|
||||
config_values.append((tid, _coerce_value(tid, val)))
|
||||
return config_values
|
||||
|
||||
@@ -811,12 +814,18 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
||||
|
||||
comp_name = unique_child_name(parent, type_name)
|
||||
|
||||
if live_hooks.is_live():
|
||||
if app_state.live_mode:
|
||||
full_type = f"{kit_name}::{type_name}"
|
||||
config_values = _build_config_values(full_type)
|
||||
try:
|
||||
config_values = build_config_values(full_type)
|
||||
except (ValueError, TypeError) as e:
|
||||
live_hooks.warn_failure("Add Component", f"{comp_name} was not added to the device",
|
||||
f"A default value in the kit manifest does not fit its slot: {e}")
|
||||
return None
|
||||
new_id = live_hooks.live_add_component(
|
||||
parent, kit_name, type_name, comp_name, config_values or None)
|
||||
if new_id is None:
|
||||
live_hooks.warn_failure("Add Component", f"{comp_name} was not added to the device")
|
||||
return None
|
||||
comp_id = new_id
|
||||
else:
|
||||
@@ -847,17 +856,19 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
||||
index = len(list(parent))
|
||||
parent.append(element)
|
||||
|
||||
if live_hooks.is_live():
|
||||
if app_state.live_mode:
|
||||
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():
|
||||
position_failed = False
|
||||
if app_state.live_mode:
|
||||
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"))
|
||||
position_failed = not live_hooks.live_set_prop_value(
|
||||
element, "meta", meta_prop.get("val", "0"))
|
||||
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.AddComponentCommand(
|
||||
@@ -869,7 +880,13 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
||||
refresh_treeview_from_dom(app_state.xml_root_element)
|
||||
wiresheet.render_current_sheet()
|
||||
|
||||
if app_state.live_mode:
|
||||
if position_failed:
|
||||
# The component exists on the device, so it stays; only where it sits is unsaved.
|
||||
live_hooks.warn_failure(
|
||||
"Add Component",
|
||||
f"{comp_name} was added to the device, but its position on the sheet was not "
|
||||
"written, so the device still has it at its default position")
|
||||
elif 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(
|
||||
@@ -895,13 +912,14 @@ def remove_link(link_element):
|
||||
from_ref = link_element.get("from", "")
|
||||
to_ref = link_element.get("to", "")
|
||||
|
||||
if live_hooks.is_live():
|
||||
if app_state.live_mode:
|
||||
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):
|
||||
live_hooks.warn_failure("Delete Link",
|
||||
f"The link {from_ref} → {to_ref} was not removed from the device")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -932,13 +950,14 @@ 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():
|
||||
if app_state.live_mode:
|
||||
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):
|
||||
live_hooks.warn_failure("Add Link",
|
||||
f"The link {from_path_slot} → {to_path_slot} was not made on the device")
|
||||
return
|
||||
|
||||
link_elem = ET.SubElement(links_elem, "link")
|
||||
@@ -973,8 +992,9 @@ def execute_cascading_deletion(comp_element):
|
||||
if not confirm:
|
||||
return False
|
||||
|
||||
if live_hooks.is_live():
|
||||
if app_state.live_mode:
|
||||
if not live_hooks.live_delete_component(comp_element):
|
||||
live_hooks.warn_failure("Delete Component", f"{comp_name} was not deleted from the device")
|
||||
return False
|
||||
|
||||
comp_path = get_component_path(comp_element, app_state.xml_root_element)
|
||||
|
||||
+57
-3
@@ -17,6 +17,8 @@ from tkinter import font as tkfont, messagebox, ttk
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import sax_file
|
||||
import undo
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
@@ -425,9 +427,32 @@ class _UserDialog:
|
||||
def _apply_add(self, username, password):
|
||||
elem = ET.Element("comp", name=username, type="sys::User")
|
||||
cred = _hash_credentials(username, password)
|
||||
ET.SubElement(elem, "prop", name="cred", val=cred)
|
||||
ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val))
|
||||
ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val))
|
||||
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
|
||||
|
||||
if app_state.live_mode:
|
||||
# One Sox add carrying every config value, so the user never exists on the
|
||||
# device without its password and rights.
|
||||
try:
|
||||
config = sax_file.build_config_values("sys::User", values)
|
||||
except (ValueError, TypeError) as e:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
|
||||
f"A value does not fit its slot: {e}")
|
||||
return
|
||||
new_id = live_hooks.live_add_component(
|
||||
self._service_comp, "sys", "User", username, config)
|
||||
if new_id is None:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
|
||||
return
|
||||
elem.set("id", str(new_id))
|
||||
|
||||
for name, val in values.items():
|
||||
ET.SubElement(elem, "prop", name=name, val=val)
|
||||
|
||||
if app_state.live_mode:
|
||||
self._service_comp.append(elem)
|
||||
app_state.mark_dirty()
|
||||
app_state.set_status(f"User '{username}' added to device.")
|
||||
return
|
||||
|
||||
index = len(list(self._service_comp))
|
||||
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
|
||||
@@ -458,6 +483,25 @@ class _UserDialog:
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
failures = []
|
||||
for elem, prop, _old, new in changes:
|
||||
if live_hooks.live_set_prop_value(elem, prop, new):
|
||||
set_prop_value(elem, prop, new)
|
||||
app_state.mark_dirty()
|
||||
else:
|
||||
failures.append(f"{prop}: {live_hooks.last_error}")
|
||||
if failures:
|
||||
live_hooks.warn_failure(
|
||||
"Edit User",
|
||||
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
|
||||
"not reach the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in failures),
|
||||
)
|
||||
else:
|
||||
app_state.set_status(f"User '{username}' updated on device.")
|
||||
return
|
||||
|
||||
cmd = undo.SlotValueCommand(changes, label="edit user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
@@ -593,6 +637,16 @@ class UserServicePanel:
|
||||
parent=self.frame.winfo_toplevel()):
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
if not live_hooks.live_delete_component(user_elem):
|
||||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||||
return
|
||||
self.comp.remove(user_elem)
|
||||
app_state.mark_dirty()
|
||||
app_state.set_status(f"User '{name}' deleted from device.")
|
||||
self._after_change()
|
||||
return
|
||||
|
||||
index = list(self.comp).index(user_elem)
|
||||
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
|
||||
cmd.undo()
|
||||
|
||||
+31
-2
@@ -7,9 +7,11 @@ import tkinter as tk
|
||||
import tkinter.font as tkfont
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import schema
|
||||
import undo
|
||||
from sax_dom import get_component_path, read_meta_property, update_meta_property
|
||||
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
|
||||
update_meta_property)
|
||||
|
||||
|
||||
LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn
|
||||
@@ -1303,7 +1305,8 @@ def tidy_layout():
|
||||
the first column and row clear the canvas edge by at least TIDY_MARGIN, and
|
||||
neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet
|
||||
too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather
|
||||
than being squashed against the limit. Returns (moved_count, clamped_count).
|
||||
than being squashed against the limit. Returns (moved_count, clamped_count); in live
|
||||
mode moved_count is None when the device refused every move, already reported.
|
||||
"""
|
||||
if not app_state.canvas_comp_map:
|
||||
return 0, 0
|
||||
@@ -1327,6 +1330,32 @@ def tidy_layout():
|
||||
|
||||
command = undo.MetaMoveCommand(changes, label="tidy layout")
|
||||
command.redo() # apply it; the command is the single writer of meta
|
||||
|
||||
if app_state.live_mode:
|
||||
# No undo in live mode: each new position goes to the device, and a box the
|
||||
# device refused goes back to where the device still has it.
|
||||
failures = []
|
||||
for element, old_val, _, _ in changes:
|
||||
new_val = element.find("prop[@name='meta']").get("val", "0")
|
||||
if not live_hooks.live_set_prop_value(element, "meta", new_val):
|
||||
failures.append(f"{element.get('name', '?')}: {live_hooks.last_error}")
|
||||
if old_val is None:
|
||||
remove_prop(element, "meta")
|
||||
else:
|
||||
set_prop_value(element, "meta", old_val)
|
||||
render_current_sheet()
|
||||
if failures:
|
||||
live_hooks.warn_failure(
|
||||
"Tidy Layout",
|
||||
f"{len(failures)} of {len(changes)} component(s) could not be moved on the "
|
||||
"device and were left where they were",
|
||||
"\n".join(f" • {failure}" for failure in failures),
|
||||
)
|
||||
if len(failures) == len(changes):
|
||||
return None, clamped # already reported; nothing moved
|
||||
app_state.mark_dirty()
|
||||
return len(changes) - len(failures), clamped
|
||||
|
||||
undo.push(command)
|
||||
app_state.mark_dirty()
|
||||
render_current_sheet()
|
||||
|
||||
Reference in New Issue
Block a user