diff --git a/app_state.py b/app_state.py index 7124c1e..a902c55 100644 --- a/app_state.py +++ b/app_state.py @@ -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.182" +VERSION = "0.0.0.183" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/dasp.py b/dasp.py index 9e55718..d0827f0 100644 --- a/dasp.py +++ b/dasp.py @@ -598,7 +598,9 @@ class SendWindow: while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)): self._cond.wait() 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: self._head = self._tail = p else: diff --git a/device_session.py b/device_session.py index 8065c00..2576d3c 100644 --- a/device_session.py +++ b/device_session.py @@ -111,6 +111,10 @@ class DeviceSession: def is_connected(self): 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 ────────────────────────────────── def _load_slot_registry(self, parts): diff --git a/main.py b/main.py index 448cea0..0f1c063 100644 --- a/main.py +++ b/main.py @@ -1109,7 +1109,6 @@ def on_tree_select(event): selected_id = selected_items[0] 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. 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: 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 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() + _load_live_sheet(app_state.device_session, comp_id_str, pending_link) + return - # 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 = 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 + _paint_sheet(element, selected_id, pending_link) + +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: components_to_render = [child for child in element if child.tag == "comp"] elif tree.exists(selected_id): @@ -1170,6 +1143,98 @@ def on_tree_select(event): 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(): """Removes every selected link from the DOM, as one undoable step, and repaints.""" link_elems = [] @@ -3157,6 +3222,15 @@ def do_device_disconnect(device_down=False): 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(): """File > Exit and the window's close box: let go of the device before going. @@ -3190,11 +3264,13 @@ def _start_cov_polling(): ds = app_state.device_session if ds is None or not ds.is_connected: _cov_poll_id = None - if app_state.live_mode: - do_device_disconnect() - app_state.set_status("Connection lost", warn=True) + if ds is not None and app_state.live_mode: + _on_connection_lost(ds, ds.close_cause) 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() if events and app_state.xml_root_element is not None: needs_repaint = False @@ -3210,8 +3286,6 @@ def _start_cov_polling(): except Exception: pass - _cov_poll_id = app_state.window.after(100, poll) - _cov_poll_id = app_state.window.after(100, poll) @@ -3272,8 +3346,11 @@ def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None): app_state.window.after(0, _ui) 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( - 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() diff --git a/sox.py b/sox.py index d437bdd..f573af6 100644 --- a/sox.py +++ b/sox.py @@ -421,6 +421,11 @@ class SoxClient: def is_closed(self): 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 ───────────────────────────────────────────── def read_schema(self):