Compare commits

...
3 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 95be6613f3 Live mode: load sheets off the UI thread, go offline cleanly on a lost device
Opening a sheet ran its Sox requests on the UI thread, so a device that had
gone away (a closed simulator) froze the window until DASP's 30 s receive
timeout. The load now runs on a background thread and the sheet is painted
when it finishes; a load the user has clicked away from is dropped.

When the session closes, the editor goes offline with "Connection lost:
<cause>" instead of printing a traceback. A send waiting for room in the
send window now fails when the session closes rather than going out after
the CLOSE. The tree-expand error callback no longer reads the exception
after its except block has ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:24:27 +03:00
ArdaandClaude Opus 5.5 6dd01b13b9 DASP trace: color send/receive, decode header fields, hex+ASCII dump
Sent lines are yellow and received lines green when stdout is a console;
pipes and files stay plain. Each message lists the header fields it
carries by name and dumps header and payload separately, 16 bytes a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:52 +03:00
ArdaandClaude Opus 5.5 0dddea53f8 Wiresheet: keep off-sheet input knobs on the sheet
A box with a link arriving from another sheet draws a knob on its left
edge. Tidy Layout moves the first column one grid step in when any of its
boxes has one, and dragging and arrow nudges stop such a box one step
inside the margin, so the knob never sits out in the grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:34:27 +03:00
7 changed files with 227 additions and 58 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.180" VERSION = "0.0.0.183"
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
+49 -9
View File
@@ -92,17 +92,55 @@ def _trace(direction, msg, datagram=None):
import datetime import datetime
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}") name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else "" arrow = {"S": ">> SEND", "R": "<< RECV"}.get(direction, direction)
ack = f" ack={msg.ack}" if msg.ack >= 0 else "" payload = msg.payload or b""
plen = len(msg.payload) if msg.payload else 0 size = f" ({len(datagram)} bytes)" if datagram else ""
hexdump = "" lines = [f"[DASP] {ts} {arrow} {name:<12} {_trace_fields(msg)}{size}"]
if datagram: if datagram:
hexdump = " [" + datagram.hex(" ") + "]" hdr_len = len(datagram) - len(payload)
line = f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}" lines.extend(_hexdump(datagram[:hdr_len], f"header {hdr_len}"))
if payload:
lines.extend(_hexdump(payload, f"payload {len(payload)}"))
color = _TRACE_COLORS.get(direction) color = _TRACE_COLORS.get(direction)
if color and _console_colors(): if color and _console_colors():
line = f"{color}{line}\033[0m" lines = [f"{color}{ln}\033[0m" for ln in lines]
_trace_out(line) _trace_out("\n".join(lines))
def _trace_fields(msg):
"""The message's header fields as name=value pairs, only those it carries."""
f = []
if msg.session_id >= 0: f.append(f"session=0x{msg.session_id:04x}")
if msg.seq_num >= 0: f.append(f"seq={msg.seq_num}")
if msg.ack >= 0: f.append(f"ack={msg.ack}")
if msg.ack_more: f.append(f"ackMore={msg.ack_more.hex()}")
if msg.version >= 0: f.append(f"version=0x{msg.version:04x}")
if msg.remote_id >= 0: f.append(f"remoteId=0x{msg.remote_id:04x}")
if msg.username: f.append(f"user={msg.username}")
if msg.digest_algorithm: f.append(f"digestAlg={msg.digest_algorithm}")
if msg.nonce: f.append(f"nonce=<{len(msg.nonce)} bytes>")
if msg.digest: f.append(f"digest=<{len(msg.digest)} bytes>")
if msg.ideal_max >= 0: f.append(f"idealMax={msg.ideal_max}")
if msg.abs_max >= 0: f.append(f"absMax={msg.abs_max}")
if msg.receive_max >= 0: f.append(f"receiveMax={msg.receive_max}")
if msg.receive_timeout >= 0: f.append(f"receiveTimeout={msg.receive_timeout // 1000}s")
if msg.platform_id: f.append(f"platform={msg.platform_id}")
if msg.error_code >= 0:
hint = _ERROR_HINTS.get(msg.error_code, "")
f.append(f"error=0x{msg.error_code:02x}{hint}")
return " ".join(f)
def _hexdump(data, label):
"""Hex and ASCII, 16 bytes a row, the first row headed by label."""
out = []
for off in range(0, len(data), 16):
chunk = data[off:off + 16]
hexs = chunk.hex(" ").ljust(47)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
head = label if off == 0 else ""
out.append(f" {head:<11} {off:04x} {hexs} |{text}|")
return out
# Sent lines yellow, received lines green, so the two directions read apart at a glance. # Sent lines yellow, received lines green, so the two directions read apart at a glance.
@@ -560,7 +598,9 @@ class SendWindow:
while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)): while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)):
self._cond.wait() self._cond.wait()
if not self._is_alive: if not self._is_alive:
return # Waiting for room when the session closed: it must not go out after
# the CLOSE, and the caller must not take it as sent.
raise DaspException(f"Session closed: {self._session.close_cause}")
if self._tail is None: if self._tail is None:
self._head = self._tail = p self._head = self._tail = p
else: else:
+4
View File
@@ -111,6 +111,10 @@ class DeviceSession:
def is_connected(self): def is_connected(self):
return self.sox_client is not None and not self.sox_client.is_closed return self.sox_client is not None and not self.sox_client.is_closed
@property
def close_cause(self):
return self.sox_client.close_cause if self.sox_client else "disconnected"
# ── Schema → Slot Registry ────────────────────────────────── # ── Schema → Slot Registry ──────────────────────────────────
def _load_slot_registry(self, parts): def _load_slot_registry(self, parts):
+3 -2
View File
@@ -5,10 +5,11 @@ add_timestamp = True
box_width = 250 box_width = 250
left_pane_width = 230 left_pane_width = 230
trace_dasp = True trace_dasp = True
lazy_loading = False lazy_loading = True
show_slot_numbers = False
[Device] [Device]
host = localhost host = 192.168.0.26
port = 1876 port = 1876
user = admin user = admin
+129 -46
View File
@@ -1109,7 +1109,6 @@ def on_tree_select(event):
selected_id = selected_items[0] selected_id = selected_items[0]
element = app_state.tree_element_map.get(selected_id) element = app_state.tree_element_map.get(selected_id)
components_to_render = []
# Remembered so undo and tidy can repaint this same sheet from the DOM. # Remembered so undo and tidy can repaint this same sheet from the DOM.
app_state.current_sheet_parent = element app_state.current_sheet_parent = element
@@ -1118,42 +1117,16 @@ def on_tree_select(event):
if app_state.live_mode and app_state.device_session and element is not None: if app_state.live_mode and app_state.device_session and element is not None:
comp_id_str = element.get("id") comp_id_str = element.get("id")
if comp_id_str is not None: if comp_id_str is not None:
ds = app_state.device_session _load_live_sheet(app_state.device_session, comp_id_str, pending_link)
ds.close_wiresheet() return
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 element by comp id after DOM rebuild (tree item IDs changed)
element = None
for tid, elem in app_state.tree_element_map.items():
if elem.get("id") == comp_id_str:
element = elem
selected_id = tid
break
if element is not None:
_restoring_selection = True
tree.selection_set(selected_id)
tree.see(selected_id)
tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element
except Exception:
import traceback; traceback.print_exc()
# The DOM rebuild replaced every element object, so a pending link from the _paint_sheet(element, selected_id, pending_link)
# old tree can no longer be found by identity. Re-resolve it by from/to.
if pending_link is not None:
old_from = pending_link.get("from", "")
old_to = pending_link.get("to", "")
links_container = app_state.xml_root_element.find("links") if app_state.xml_root_element is not None else None
if links_container is not None:
for lnk in links_container:
if lnk.get("from") == old_from and lnk.get("to") == old_to:
pending_link = lnk
break
def _paint_sheet(element, selected_id, pending_link):
"""Paints the sheet of `element` (or of the tree row `selected_id` when it has none)."""
tree = app_state.tree
components_to_render = []
if element is not None: if element is not None:
components_to_render = [child for child in element if child.tag == "comp"] components_to_render = [child for child in element if child.tag == "comp"]
elif tree.exists(selected_id): elif tree.exists(selected_id):
@@ -1170,6 +1143,98 @@ def on_tree_select(event):
select_link_on_open_sheet(pending_link) select_link_on_open_sheet(pending_link)
# Bumped by every live sheet load, so a load the user has clicked away from is dropped
# when it finishes rather than painted over the sheet they moved on to.
_sheet_load_gen = 0
# One load talks to the device at a time: each starts by unsubscribing the sheet the
# previous one subscribed.
_sheet_load_lock = threading.Lock()
def _load_live_sheet(ds, comp_id_str, pending_link):
"""Reads a folder's sheet from the device on a background thread, then paints it.
Sox requests wait for the device. A device that has gone away holds them until DASP
gives up (30 s), which on the UI thread froze the whole window.
"""
global _sheet_load_gen
_sheet_load_gen += 1
gen = _sheet_load_gen
# Blank until the device answers: the rows on screen belong to the DOM the load
# is about to replace.
wiresheet.render_components([])
app_state.set_status("Loading sheet...")
def _bg():
with _sheet_load_lock:
if gen != _sheet_load_gen:
return # clicked away while waiting for the previous load
try:
ds.close_wiresheet()
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)
except Exception as exc:
app_state.window.after(
0, lambda err=exc: _live_sheet_failed(ds, gen, err))
return
app_state.window.after(
0, lambda: _live_sheet_loaded(ds, gen, comp_id_str, pending_link, sax_root))
threading.Thread(target=_bg, daemon=True).start()
def _live_sheet_failed(ds, gen, exc):
if gen != _sheet_load_gen or app_state.device_session is not ds:
return
if not ds.is_connected:
_on_connection_lost(ds, ds.close_cause)
return
app_state.set_status(f"Could not open sheet: {exc}", warn=True)
def _live_sheet_loaded(ds, gen, comp_id_str, pending_link, sax_root):
"""UI thread: swaps in the freshly read DOM and paints the sheet."""
global _restoring_selection
if gen != _sheet_load_gen or app_state.device_session is not ds:
return
tree = app_state.tree
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# re-find element by comp id after DOM rebuild (tree item IDs changed)
element = None
selected_id = None
for tid, elem in app_state.tree_element_map.items():
if elem.get("id") == comp_id_str:
element = elem
selected_id = tid
break
if element is not None:
_restoring_selection = True
tree.selection_set(selected_id)
tree.see(selected_id)
tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element
# The DOM rebuild replaced every element object, so a pending link from the
# old tree can no longer be found by identity. Re-resolve it by from/to.
if pending_link is not None:
old_from = pending_link.get("from", "")
old_to = pending_link.get("to", "")
links_container = sax_root.find("links")
if links_container is not None:
for lnk in links_container:
if lnk.get("from") == old_from and lnk.get("to") == old_to:
pending_link = lnk
break
app_state.clear_status()
if element is not None:
_paint_sheet(element, selected_id, pending_link)
def delete_selected_link(): def delete_selected_link():
"""Removes every selected link from the DOM, as one undoable step, and repaints.""" """Removes every selected link from the DOM, as one undoable step, and repaints."""
link_elems = [] link_elems = []
@@ -2135,14 +2200,17 @@ def on_canvas_drag(event):
dy = cy - app_state.drag_data["y"] dy = cy - app_state.drag_data["y"]
xs = [entry["origin"][0] for entry in group] xs = [entry["origin"][0] for entry in group]
ys = [entry["origin"][1] for entry in group] ys = [entry["origin"][1] for entry in group]
dx = min(limit - max(xs), max(margin - min(xs), dx)) # A box with an off-sheet input stops a step further in, keeping its knob on the sheet.
left = wiresheet.box_left_limits([entry["tag"] for entry in group], margin, step)
dx = min(limit - max(xs),
max(max(left[e["tag"]] - e["origin"][0] for e in group), dx))
dy = min(limit - max(ys), max(margin - min(ys), dy)) dy = min(limit - max(ys), max(margin - min(ys), dy))
shifted = False shifted = False
for entry in group: for entry in group:
# Land each box on a step every step of the way. # Land each box on a step every step of the way.
start_x, start_y = entry["origin"] start_x, start_y = entry["origin"]
target_x = min(limit, max(margin, round((start_x + dx) / step) * step)) target_x = min(limit, max(left[entry["tag"]], round((start_x + dx) / step) * step))
target_y = min(limit, max(margin, round((start_y + dy) / step) * step)) target_y = min(limit, max(margin, round((start_y + dy) / step) * step))
origin = wiresheet.get_box_origin(entry["tag"]) origin = wiresheet.get_box_origin(entry["tag"])
if origin is None: if origin is None:
@@ -2178,6 +2246,7 @@ def on_canvas_release(event):
changes = [] changes = []
failed = [] failed = []
left = wiresheet.box_left_limits([entry["tag"] for entry in group], margin, step)
for entry in group: for entry in group:
group_tag = entry["tag"] group_tag = entry["tag"]
origin = wiresheet.get_box_origin(group_tag) origin = wiresheet.get_box_origin(group_tag)
@@ -2185,7 +2254,7 @@ def on_canvas_release(event):
continue continue
current_x, current_y = origin current_x, current_y = origin
snapped_x = min(limit, max(margin, round(current_x / step) * step)) snapped_x = min(limit, max(left[group_tag], round(current_x / step) * step))
snapped_y = min(limit, max(margin, round(current_y / step) * step)) snapped_y = min(limit, max(margin, round(current_y / step) * step))
shift_x = snapped_x - current_x shift_x = snapped_x - current_x
@@ -2255,9 +2324,11 @@ def nudge_selected_boxes(dx, dy):
origins = {t: wiresheet.get_box_origin(t) for t in tags} origins = {t: wiresheet.get_box_origin(t) for t in tags}
if any(o is None for o in origins.values()): if any(o is None for o in origins.values()):
return "break" return "break"
# The group stops as one at the margin strip and the sheet's far edge. # The group stops as one at the margin strip and the sheet's far edge; a box with an
# off-sheet input stops a step further in, keeping its knob on the sheet.
left = wiresheet.box_left_limits(tags, margin, step)
shift_x = min(limit - max(o[0] for o in origins.values()), shift_x = min(limit - max(o[0] for o in origins.values()),
max(margin - min(o[0] for o in origins.values()), dx * step)) max(max(left[t] - o[0] for t, o in origins.items()), dx * step))
shift_y = min(limit - max(o[1] for o in origins.values()), shift_y = min(limit - max(o[1] for o in origins.values()),
max(margin - min(o[1] for o in origins.values()), dy * step)) max(margin - min(o[1] for o in origins.values()), dy * step))
if not shift_x and not shift_y: if not shift_x and not shift_y:
@@ -3151,6 +3222,15 @@ def do_device_disconnect(device_down=False):
wiresheet.render_components([]) wiresheet.render_components([])
def _on_connection_lost(ds, cause):
"""Main thread: the device stopped answering (closed simulator, unplugged cable) or
ended the session. Goes offline as Disconnect does, without asking anything of it."""
if app_state.device_session is not ds:
return # already disconnected, or a newer connection has taken its place
do_device_disconnect(device_down=True)
app_state.set_status(f"Connection lost: {cause}", warn=True)
def quit_app(): def quit_app():
"""File > Exit and the window's close box: let go of the device before going. """File > Exit and the window's close box: let go of the device before going.
@@ -3184,11 +3264,13 @@ def _start_cov_polling():
ds = app_state.device_session ds = app_state.device_session
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 ds is not None and app_state.live_mode:
do_device_disconnect() _on_connection_lost(ds, ds.close_cause)
app_state.set_status("Connection lost", warn=True)
return return
# Scheduled first, so one bad event cannot stop the polling for good.
_cov_poll_id = app_state.window.after(100, poll)
events = ds.poll_events() events = ds.poll_events()
if events and app_state.xml_root_element is not None: if events and app_state.xml_root_element is not None:
needs_repaint = False needs_repaint = False
@@ -3206,8 +3288,6 @@ def _start_cov_polling():
_cov_poll_id = app_state.window.after(100, poll) _cov_poll_id = app_state.window.after(100, poll)
_cov_poll_id = app_state.window.after(100, poll)
def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None): def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
"""Called when user expands a tree node in live mode to lazy-load children. """Called when user expands a tree node in live mode to lazy-load children.
@@ -3266,8 +3346,11 @@ def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
app_state.window.after(0, _ui) app_state.window.after(0, _ui)
except Exception as exc: except Exception as exc:
if not ds.is_connected:
# The COV poll sees the closed session, goes offline and says so.
return
app_state.window.after( app_state.window.after(
0, lambda: app_state.set_status(f"Load error: {exc}", warn=True) 0, lambda msg=f"Load error: {exc}": app_state.set_status(msg, warn=True)
) )
threading.Thread(target=_bg, daemon=True).start() threading.Thread(target=_bg, daemon=True).start()
+5
View File
@@ -421,6 +421,11 @@ class SoxClient:
def is_closed(self): def is_closed(self):
return self._session is None or self._session.is_closed return self._session is None or self._session.is_closed
@property
def close_cause(self):
"""Why the session ended, e.g. "receive timeout"."""
return self._session.close_cause if self._session else "closed"
# ── Read Schema ───────────────────────────────────────────── # ── Read Schema ─────────────────────────────────────────────
def read_schema(self): def read_schema(self):
+36
View File
@@ -154,6 +154,36 @@ def suggest_box_width_for_grid(width, grid_size):
return proposed return proposed
def offsheet_input_comps(components):
"""The components with a link arriving from off this sheet, drawn as a knob on
their left edge."""
root = app_state.xml_root_element
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return set()
comp_by_path = {get_component_path(comp, root): comp for comp in components}
found = set()
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
if dst is not None and from_ref.rsplit(".", 1)[0] not in comp_by_path:
found.add(dst)
return found
def box_left_limits(tags, margin, step):
"""Left-most x each box on the open sheet may be moved to, keyed by canvas tag.
The margin, or one step further in for a box with an off-sheet input, so its knob
stays on the sheet rather than out in the grey margin.
"""
knobbed = offsheet_input_comps(list(app_state.canvas_comp_map.values()))
return {tag: margin + step if app_state.canvas_comp_map.get(tag) in knobbed
else margin for tag in tags}
def layout_margin(grid_size=None): def layout_margin(grid_size=None):
"""The top/left edge offset a box may not encroach on, snapped onto the grid.""" """The top/left edge offset a box may not encroach on, snapped onto the grid."""
if grid_size is None: if grid_size is None:
@@ -1575,6 +1605,10 @@ def plan_columns(columns, column_keys, demand, scale, wires=()):
# Column x positions depend only on widths and wire lanes, so they come first. # Column x positions depend only on widths and wire lanes, so they come first.
column_x = [] column_x = []
cursor_x = margin # the left-most column clears the canvas edge by the margin cursor_x = margin # the left-most column clears the canvas edge by the margin
if count and any(item.get("knob_in") for item in columns[column_keys[0]]):
# One step further in, so a knob on a first-column box's left edge stays on
# the sheet instead of out in the margin.
cursor_x = snap_up(margin + app_state.GRID_SIZE, scale)
for index, grid_x in enumerate(column_keys): for index, grid_x in enumerate(column_keys):
column_x.append(cursor_x) column_x.append(cursor_x)
gap = column_gap_for(demand.get(index, 0)) gap = column_gap_for(demand.get(index, 0))
@@ -1868,6 +1902,7 @@ def build_layout_items(components):
linked_slot_index = build_linked_slot_index() linked_slot_index = build_linked_slot_index()
pairs = sheet_link_pairs(components) pairs = sheet_link_pairs(components)
on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)} on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)}
knobbed = offsheet_input_comps(components)
linked = [] linked = []
loose = [] loose = []
@@ -1880,6 +1915,7 @@ def build_layout_items(components):
"comp": comp, "meta": meta_val, "comp": comp, "meta": meta_val,
"grid_x": grid_x, "grid_y": grid_y, "grid_x": grid_x, "grid_y": grid_y,
"width": width, "height": height, "width": width, "height": height,
"knob_in": comp in knobbed,
# Where each slot's wire meets the box, as painted by draw_component_box. # Where each slot's wire meets the box, as painted by draw_component_box.
"slot_offsets": { "slot_offsets": {
raw_name: HEADER_HEIGHT + row * SLOT_ROW_HEIGHT + SLOT_ROW_HEIGHT // 2 raw_name: HEADER_HEIGHT + row * SLOT_ROW_HEIGHT + SLOT_ROW_HEIGHT // 2