diff --git a/eco/__init__.py b/eco/__init__.py index 07154d5..048bbbb 100644 --- a/eco/__init__.py +++ b/eco/__init__.py @@ -34,26 +34,63 @@ def ioc_finder(*args, **kwargs): return make_ioc_finder_qt_window(*args, **kwargs) -def start_desktop(*args, **kwargs): +def start_desktop( + theme=None, + touch=None, + link_terminal=True, + scope=None, + lazy=True, + with_console=True, + with_namespace_panel=True, +): """Open the Qt desktop workbench (a Spyder/MATLAB-like window: a dockable device-widget launcher, plus by default an embedded IPython console) from a running terminal session, for easy testing without leaving it. + with_console=True (default): the desktop window has an embedded + console/kernel. False: no console at all -- the calling terminal + stays the one and only "master" session; the Namespace panel still + works fully (opening a widget never needed the console), you just + can't type Python directly into the desktop window itself. + + with_namespace_panel=True (default): the dockable "Namespace" launcher + panel is shown. False: it's skipped -- the namespace itself is still + built and usable (in the console, and to reopen widgets from a loaded + workspace), just without the browsable panel taking up screen space. + + touch=True (or ECO_QT_TOUCH=1 in the environment): wider dock/splitter + grab handles and bigger buttons/checkboxes/scrollbars, for touch-screen + use -- layers on top of `theme` (including theme=None/native), doesn't + change colors. See eco.widgets.qt_theme.apply_modern_theme. + Equivalent to:: from eco.widgets.app_launchers import start_desktop start_desktop() start_desktop(with_console=False) # calling terminal stays the only "master" session + start_desktop(with_namespace_panel=False) # console only, no browsable launcher panel + start_desktop(touch=True) # bigger grab handles/buttons/scrollbars - See eco.widgets.app_launchers.start_desktop for the full signature - (theme, link_terminal, scope, lazy, with_console) -- imported lazily, - like ioc_finder above, so plain `import eco` doesn't pay for - qtconsole/Qt. See also STARTUP_MODES.md. + See eco.widgets.app_launchers.start_desktop's own docstring for the + rest (link_terminal, scope, lazy) and for how the namespace used is + chosen. This wrapper mirrors that function's signature exactly rather + than taking *args/**kwargs so tab-completion/`?` on `eco.start_desktop` + itself shows every switch, not just "()" -- imported lazily, like + ioc_finder above, so plain `import eco` doesn't pay for qtconsole/Qt. + See also STARTUP_MODES.md. """ from eco.widgets.app_launchers import start_desktop as _start_desktop - return _start_desktop(*args, **kwargs) + return _start_desktop( + theme=theme, + touch=touch, + link_terminal=link_terminal, + scope=scope, + lazy=lazy, + with_console=with_console, + with_namespace_panel=with_namespace_panel, + ) def start_console(*args, **kwargs): diff --git a/eco/acquisition/daq_client.py b/eco/acquisition/daq_client.py index 76695dd..fbb62cb 100644 --- a/eco/acquisition/daq_client.py +++ b/eco/acquisition/daq_client.py @@ -300,6 +300,10 @@ class Daq(Assembly): # None (default) keeps the existing, unchanged local behaviour. self._status_server = status_server self._status_server_client = None + if self._status_server: + print(f"daq: taking run status from status server {self._status_server} " + "(set ECO_STATUS_SERVER=off in the shell before starting this " + "session to always use the local namespace instead)") # Master on/off switch, independent of _status_server itself: unlike # setting _status_server = None, toggling this off and back on again # does not lose the configured URL/client - status_client (and so diff --git a/eco/bernina/bernina_daq.py b/eco/bernina/bernina_daq.py index bec7ac2..4453b2d 100644 --- a/eco/bernina/bernina_daq.py +++ b/eco/bernina/bernina_daq.py @@ -91,10 +91,12 @@ _ECO_STATUS_SERVER_DEFAULT = "http://saresb-cons-04:8091" _status_server = os.environ.get("ECO_STATUS_SERVER", _ECO_STATUS_SERVER_DEFAULT) if _status_server.strip().lower() in ("", "off", "none", "false", "0"): _status_server = None -if _status_server: - print(f"daq: taking run status from status server {_status_server} " - "(set ECO_STATUS_SERVER=off in the shell before starting this " - "session to always use the local namespace instead)") +# The "daq: taking run status from status server ..." message itself is +# printed by Daq.__init__ (eco/acquisition/daq_client.py), not here - this +# module runs at namespace-build time regardless of Daq's lazy=True below, so +# printing here would announce the status server on every session (e.g. one +# that only ever opens camera widgets and never touches bernina.daq) instead +# of only when Daq is actually instantiated. # Namespace-level handle for the same server: bernina.status_server.status()/ # .gui()/.restart()/.reinit(), plus the recording settings (mode, throttle diff --git a/eco/bernina/bernina_vacuum.py b/eco/bernina/bernina_vacuum.py index 5dd56c9..f1da79a 100644 --- a/eco/bernina/bernina_vacuum.py +++ b/eco/bernina/bernina_vacuum.py @@ -50,12 +50,12 @@ _prepump_config = { "valve_prevac": "SARES21-VVPG142-330", # TODO: was noted as "...142-330" "valve_vent": "SARES21-VVPG143-350", # TODO: was noted as "...143-350" }, - # "line4": { - # "gauge": None, # TODO: G4 gauge base - # "valve_prevac": None, # TODO: not yet noted - # "valve_vent": None, # TODO: was noted as "...142-340" -- looked + "line4": { + # "gauge": SARES21-VVPG142-360 , # TODO: G4 gauge base + "valve_prevac": "SARES21-VVPG142-360", # TODO: not yet noted + "valve_vent": "SARES21-VVPG142-280", # TODO: was noted as "...142-340" -- looked # # identical to line1_usd's, double check - # }, + }, # two more optional slots, not yet cabled: # "line5": {"gauge": None, "valve_prevac": None, "valve_vent": None}, # "line6": {"gauge": None, "valve_prevac": None, "valve_vent": None}, diff --git a/eco/delme_x b/eco/delme_x new file mode 100644 index 0000000..7ec9b51 --- /dev/null +++ b/eco/delme_x @@ -0,0 +1,3 @@ +{ + "value": 0 +} \ No newline at end of file diff --git a/eco/delme_y b/eco/delme_y new file mode 100644 index 0000000..7ec9b51 --- /dev/null +++ b/eco/delme_y @@ -0,0 +1,3 @@ +{ + "value": 0 +} \ No newline at end of file diff --git a/eco/delme_z b/eco/delme_z new file mode 100644 index 0000000..7ec9b51 --- /dev/null +++ b/eco/delme_z @@ -0,0 +1,3 @@ +{ + "value": 0 +} \ No newline at end of file diff --git a/eco/devices_general/cameras_ptz.py b/eco/devices_general/cameras_ptz.py index e4d930d..3dd5b7f 100644 --- a/eco/devices_general/cameras_ptz.py +++ b/eco/devices_general/cameras_ptz.py @@ -83,6 +83,15 @@ class AxisPTZ(Assembly): # launcher) opens the live-video viewer instead of the generic # property grid -- see Assembly._default_widget/_widget_viewer() below. _default_widget = "_widget_viewer" + # Stays a standalone window unless the caller explicitly opts in with + # widget(dock_in=True) (or dock_in="auto") -- docking by default + # surprised users starting eco plainly (a viewer they never asked to + # dock, docking anyway). dock_in=True/"auto" still gets the shared + # EcoDesktopApp workbench auto-created if none is open yet (see + # Assembly._maybe_dock -> get_or_create_default_container()). Mirrors + # eco.devices_general.cameras_swissfel.CameraBasler._default_dock_in -- + # same rationale, same convention. + _default_dock_in = None def __init__( self, @@ -345,7 +354,14 @@ class AxisPTZ(Assembly): normal pan/tilt/zoom/iris/focus slider display) and a "Help" button describing the mouse controls. See show_qt/show_widget for the environment-specific versions directly, and show() for the - plain terminal snapshot.""" + plain terminal snapshot. + + The Qt window is resizable -- it rescales the live image to fit + whatever size you drag it to, rather than being pinned to the + camera's native resolution. It stays a standalone window unless + you call widget(dock_in=True) (or dock_in="auto") to dock it into + the shared EcoDesktopApp workbench instead -- see + AxisPTZ._default_dock_in.""" if _is_notebook(): return self.show_widget( codec=codec, diff --git a/eco/elements/memory.py b/eco/elements/memory.py index 27a4b56..22007ff 100644 --- a/eco/elements/memory.py +++ b/eco/elements/memory.py @@ -117,6 +117,16 @@ def get_memory(name): class Memory: + # `_log_deadband_sample` keeps only the most recent + # `DEADBAND_LOG_MAX_SAMPLES` convergence misses per component (a rolling + # window rather than an all-time max, so one freak outlier -- a stalled + # motor, a comms glitch -- ages out instead of permanently inflating the + # tolerance). `get_deadband` derives the tolerance actually used for + # comparisons as `DEADBAND_MULTIPLIER` times the largest sample still in + # that window. + DEADBAND_LOG_MAX_SAMPLES = 20 + DEADBAND_MULTIPLIER = 1.5 + def __init__( self, obj, @@ -177,6 +187,24 @@ class Memory: default_value={}, group_writable=group_writable, ) + # deadband_log.json: {dotted_name: [abs(readback-target), ...]} -- + # rolling window of observed post-recall convergence misses, per + # component, feeding `get_deadband`. last_recall.json: {dotted_name: + # {"value", "date", "memory_date", "message"}} -- the most recent + # value `recall()` actually applied to that component, feeding + # `recall_status_str`. Both are best-effort (see `_log_deadband_sample`/ + # `_record_last_recall`): a write failure here must never abort an + # otherwise-successful recall. + self._deadband_log = AdjustableFS( + self.dir / Path("deadband_log.json"), + default_value={}, + group_writable=group_writable, + ) + self._last_recall = AdjustableFS( + self.dir / Path("last_recall.json"), + default_value={}, + group_writable=group_writable, + ) def memories(self, indices=None, search_key=None): self.setup_path() @@ -734,6 +762,281 @@ class Memory: rec.update(mem.get(name, {})) return rec + def get_deadband(self, key, default=0): + """Learned comparison tolerance for component `key`: `DEADBAND_MULTIPLIER` + times the largest convergence miss recorded for it in the rolling + window `_log_deadband_sample` maintains, or `default` (0 -- exact + equality only) if nothing has been logged for it yet. + """ + try: + self.setup_path() + samples = self._deadband_log().get(key, []) + if not samples: + return default + return max(samples) * self.DEADBAND_MULTIPLIER + except Exception: + return default + + def _compare_to_memory(self, key, present_value, recall_value): + """Compare a present value against a memorized one, treating + anything within `key`'s learned deadband (`get_deadband`) as + unchanged rather than requiring exact equality -- used both for + display (`get_memory_difference_str`, `select_from_memory`) and for + `recall()`'s own `set_changes_only` skip. + + Returns `(changed, symbol, diff)`: + - exact match: `(False, "==", 0)` + - within deadband but not exact: `(False, "@", diff)` -- `diff` is + `recall_value - present_value`, kept so a caller can still show + the residual even though this counts as "unchanged". + - genuinely different: `(True, None, diff)`. + - not subtractable (e.g. strings/enums): `(True, None, None)` if + different, matching the exact-equality-only behavior this had + before deadbands existed. + """ + if present_value == recall_value: + return False, "==", 0 + try: + diff = recall_value - present_value + except TypeError: + return True, None, None + deadband = self.get_deadband(key) + if deadband and abs(diff) <= deadband: + return False, "@", diff + return True, None, diff + + def _record_last_recall(self, key, value, mem): + """Best-effort record of the value `recall()` just applied to + component `key`, so `recall_status_str` can later report how close + a live readback still is to it. A write failure (e.g. permissions + on a shared memory tree) is reported but never raised -- it must + not abort an otherwise-successful recall. + """ + try: + data = self._last_recall() + message = None + memory_date = mem.get("date") if isinstance(mem, dict) else None + if memory_date: + message = self._memories().get(memory_date, {}).get("message") + data[key] = { + "value": value, + "date": datetime.now().isoformat(), + "memory_date": memory_date, + "message": message, + } + self._last_recall(data) + except Exception as e: + print(f"Could not record last-recall value for {key}: {e}") + + def _log_deadband_sample(self, key, diff): + """Best-effort append of one observed |readback-target| convergence + sample for `key`, capping the kept history at + `DEADBAND_LOG_MAX_SAMPLES` most recent samples. Never raises.""" + try: + data = self._deadband_log() + samples = data.get(key, []) + samples.append(diff) + data[key] = samples[-self.DEADBAND_LOG_MAX_SAMPLES :] + self._deadband_log(data) + except Exception as e: + print(f"Could not log deadband sample for {key}: {e}") + + def _log_convergence(self, change_meta): + """For every (key, target, adjustable) `recall()` just finished + waiting on, compare the final readback to the requested target and + log the miss (see `_log_deadband_sample`) -- learning, over repeated + recalls, how far off a "successful" move for that specific + component actually tends to land. Skipped per-item for anything + unreadable or not subtractable; never raises into `recall()`. + """ + for key, target, to in change_meta: + try: + diff = abs(to.get_current_value() - target) + except Exception: + continue + self._log_deadband_sample(key, diff) + + def _own_recall_status(self, keys=None): + """Structured `(key, changed, symbol, diff, label, error)` tuples -- + one per component of *this* object's own `last_recall` record + (`_record_last_recall`) -- comparing its current live value against + the value most recently recalled onto it, via `_compare_to_memory` + (so the deadband logged for that same key, on this same `Memory`, + applies). `error` (a message string) is set instead when the + current value can't be read; `symbol`/`diff` are `None`-safe as in + `_compare_to_memory`. Never raises. See `recall_status_str` for the + public, tree-aware, formatted entry point built on top of this. + """ + self.setup_path() + last = self._last_recall() + if keys is None: + keys = list(last.keys()) + elif isinstance(keys, str): + keys = [keys] + out = [] + for key in keys: + entry = last.get(key) + if entry is None: + continue + label = entry.get("message") or entry.get("memory_date") or "?" + try: + current = name2obj(self.obj_parent(), key).get_current_value() + except Exception as e: + out.append((key, None, None, None, label, str(e))) + continue + changed, symbol, diff = self._compare_to_memory(key, current, entry["value"]) + out.append((key, changed, symbol, diff, label, None)) + return out + + def _iter_descendant_memories(self): + """`(relative_prefix, memory)` for every descendant of this object, + at any depth, that has its own `Memory` -- reusing + `status_collection.get_list()` (already flattens the whole subtree + recursively, the same way `get_status`/`get_failed_components` do) + rather than walking `__dict__` by hand. `relative_prefix` is that + descendant's dotted name relative to this object + (`Alias.get_full_name(base=...)`). Only reaches components actually + appended via `Assembly._append` -- same limitation the reused + `get_list()` already has. Best-effort: a broken alias/status_collection + on one item is skipped rather than aborting the whole walk. + """ + obj = self.obj_parent() + try: + items = obj.status_collection.get_list() + except Exception: + return + seen = set() + for item in items: + submem = getattr(item, "memory", None) + if submem is None or submem is self or id(submem) in seen: + continue + seen.add(id(submem)) + try: + prefix = item.alias.get_full_name(base=obj) + except Exception: + continue + yield prefix, submem + + def _ancestor_recall_status(self): + """`[[(rel_key, changed, symbol, diff, label, error), ...], ...]`, + one inner list per ancestor assembly (`ancestor_memories()`) that + has recalled *into this object's own subtree* as part of a bigger, + higher-level recall -- e.g. some `beamline.memory.recall(...)` + covering many sub-devices, rather than this object's own + `.memory.recall(...)`. Slices that ancestor's `last_recall` down to + keys prefixed by this object's dotted path under it (mirroring + `get_ancestor_recall_dict`'s prefix logic, applied to + `last_recall.json` instead of a stored memory entry), and re-keys + them relative to this object. + + The comparison (and therefore the deadband used) is done via the + *ancestor's* `_compare_to_memory`, keyed by the ancestor-relative + name -- that's the `Memory` instance whose `recall()` actually ran + and whose deadband log actually has samples for that name; this + object's own (likely empty) deadband log for the same component, + under its own shorter relative name, is a different key entirely. + """ + obj = self.obj_parent() + out = [] + for ancestor, ancestor_memory in self.ancestor_memories(): + if ancestor_memory is self: + continue + try: + ancestor_memory.setup_path() + anc_last = ancestor_memory._last_recall() + prefix = obj.alias.get_full_name(base=ancestor) + "." + except Exception: + continue + entries = [] + for full_key, entry in anc_last.items(): + if not full_key.startswith(prefix): + continue + rel_key = full_key[len(prefix) :] + label = entry.get("message") or entry.get("memory_date") or "?" + try: + current = name2obj(obj, rel_key).get_current_value() + except Exception as e: + entries.append((rel_key, None, None, None, label, str(e))) + continue + changed, symbol, diff = ancestor_memory._compare_to_memory( + full_key, current, entry["value"] + ) + entries.append((rel_key, changed, symbol, diff, label, None)) + if entries: + out.append(entries) + return out + + @staticmethod + def _format_status_line(key, changed, symbol, diff, label, error): + if error is not None: + return f"{key}: (could not read current value: {error})" + if not changed: + return f"{key}: @ memory ({label})" + if diff is None: + return f"{key}: differs from memory ({label})" + return f"{key}: close to memory ({label}), deviating by {diff:+g}" + + def recall_status_str(self, keys=None, include_subassemblies=True, include_parents=True): + """Compare current live values against the memory value most + recently recalled onto them, using each component's learned + deadband (`get_deadband`) to decide whether it's still effectively + "at" that memory or has since drifted away from it. + + Looks in three places, so a sub-assembly shows as "at a memory" + whether it was recalled through its own `.memory` or as part of a + bigger, higher-level assembly's recall: + - this object's own `last_recall` record (`_own_recall_status`); + - every descendant sub-assembly's own `last_recall` record, at any + depth (`_iter_descendant_memories`), prefixed with its relative + name; + - the slice of any ancestor assembly's `last_recall` record that + falls under this object's own subtree (`_ancestor_recall_status`), + re-keyed relative to this object. + A dotted name that appears in more than one source keeps only the + first (own record, then sub-assembly, then ancestor slice -- most + specific/direct first). + + keys (str, iterable of str, or None, optional): restrict this to + specific dotted component names on *this* object only -- skips + the sub-assembly/ancestor walk entirely, same as before those + existed. `None` (default) reports on everything found. + include_subassemblies / include_parents (bool, optional): opt out + of either extra source; both default to True. Ignored (treated + as if both False) when `keys` is given. + + Returns one line per component: `": @ memory ()"` if + within deadband (or exactly equal), `": close to memory + (), deviating by "` otherwise. Components whose + current value can't be read are noted rather than skipped; nothing + with a recorded last recall anywhere in scope returns + `"No recall history recorded."`. + """ + lines = [] + seen_keys = set() + + def _add(key, changed, symbol, diff, label, error): + if key in seen_keys: + return + seen_keys.add(key) + lines.append(self._format_status_line(key, changed, symbol, diff, label, error)) + + for key, changed, symbol, diff, label, error in self._own_recall_status(keys): + _add(key, changed, symbol, diff, label, error) + + if keys is None: + if include_subassemblies: + for prefix, submem in self._iter_descendant_memories(): + for key, changed, symbol, diff, label, error in submem._own_recall_status(): + full_key = f"{prefix}.{key}" if prefix else key + _add(full_key, changed, symbol, diff, label, error) + + if include_parents: + for entries in self._ancestor_recall_status(): + for rel_key, changed, symbol, diff, label, error in entries: + _add(rel_key, changed, symbol, diff, label, error) + + return "\n".join(lines) if lines else "No recall history recorded." + def recall( self, memory_index=None, @@ -782,6 +1085,12 @@ class Memory: Returns: _type_: _description_ """ + # unconditionally (not just via get_memory's own no-input_obj + # branch): _record_last_recall/_log_convergence below need + # self._last_recall/self._deadband_log set up even when this recall + # comes from an input_obj dict/file rather than this object's own + # stored memory list. + self.setup_path() if change_serially is None: change_serially = self.change_serially # if input_obj: @@ -832,22 +1141,30 @@ class Memory: return changes = [] + change_meta = [] # (key, target, adjustable), parallel to changes for sel, (key, val) in zip(select, rec.items()): if sel: to = name2obj(self.obj_parent(), key) + current = to.get_current_value() if set_changes_only: - if to.get_current_value() == val: + changed, _, _ = self._compare_to_memory(key, current, val) + if not changed: continue - print(f"Changing {key} from {to.get_current_value()} to {val}") + print(f"Changing {key} from {current} to {val}") if "check" in getargspec(to.set_target_value).args: changes.append(to.set_target_value(val, check=check_limits)) else: changes.append(to.set_target_value(val)) + change_meta.append((key, val, to)) + self._record_last_recall(key, val, mem) if change_serially: changes[-1].wait() if wait: for change in changes: change.wait() + # only after waiting is the readback meaningful -- see + # `_log_convergence`. + self._log_convergence(change_meta) return else: return changes @@ -895,31 +1212,31 @@ class Memory: tselstr = "x" else: tselstr = " " - if present_value == recall_value: - changed = False + changed, symbol, diff = self._compare_to_memory( + key, present_value, recall_value + ) + if not changed: + # "==" exact match, "@" within this component's learned + # deadband (get_deadband) -- still shown with the residual + # so it's clear it's not a bit-for-bit match. + disp = "==" if symbol == "==" else f"@{diff:+g}" if tablefmt == "html": - comp_indicator = "==" + comp_indicator = disp else: comp_indicator = ( colorama.Fore.GREEN + colorama.Style.BRIGHT - + "==" + + disp + colorama.Style.RESET_ALL ) else: - changed = True if not tsel: - try: - comp_indicator = ( - f"not changed ({recall_value-present_value:+g})" - ) - except: - comp_indicator = f"not changed" + if diff is None: + comp_indicator = "not changed" + else: + comp_indicator = f"not changed ({diff:+g})" else: - try: - tdiff = f"{recall_value - present_value:+g}" - except TypeError: - tdiff = "special" + tdiff = f"{diff:+g}" if diff is not None else "special" if tablefmt == "html": comp_indicator = f"{tdiff:s}" else: @@ -980,15 +1297,24 @@ class Memory: # rows actually shown, post show_changes_only filtering: (orig # index into `names`, name, present, recall_value, changed, - # available) + # available, symbol, diff) -- symbol/diff from `_compare_to_memory`, + # "@" meaning within the component's learned deadband rather than + # an exact match. rows = [] for i, (name, recall_value) in enumerate(recall_dict.items()): available = name in present_values present_value = present_values.get(name) - changed = available and present_value != recall_value + if available: + changed, symbol, diff = self._compare_to_memory( + name, present_value, recall_value + ) + else: + changed, symbol, diff = True, None, None if show_changes_only and not changed: continue - rows.append((i, name, present_value, recall_value, changed, available)) + rows.append( + (i, name, present_value, recall_value, changed, available, symbol, diff) + ) if not rows: print("No changes compared to memory!") @@ -997,23 +1323,22 @@ class Memory: name_w = max(len(r[1]) for r in rows) present_w = max(len(str(r[2])) for r in rows) - def _row_text(name, present, recall_value, changed, available): + def _row_text(name, present, recall_value, changed, available, symbol, diff): if not available: - diff = "?" + diffstr = "?" elif not changed: - diff = "==" + diffstr = "==" if symbol == "==" else f"@{diff:+g}" else: - try: - diff = f"{recall_value - present:+g}" - except TypeError: - diff = "changed" + diffstr = f"{diff:+g}" if diff is not None else "changed" present_str = str(present) if available else "?" return ( f"{name:<{name_w}} present: {present_str:>{present_w}} " - f"diff: {diff:^9} memory: {recall_value}" + f"diff: {diffstr:^9} memory: {recall_value}" ) - entries = [_row_text(r[1], r[2], r[3], r[4], r[5]) for r in rows] + entries = [ + _row_text(r[1], r[2], r[3], r[4], r[5], r[6], r[7]) for r in rows + ] picked = _checklist_menu( entries, @@ -1030,6 +1355,201 @@ class Memory: select[row[0]] = vis_idx in picked return select + def get_memory_status( + self, selection=None, top_n=None, max_entries=50, tablefmt="plain" + ): + """Rank *every* stored memory entry by how close the current live + state actually is to it -- unlike `recall_status_str` (which only + knows about the value most recently *recalled*), this reads and + compares against this object's entire `memorize()` history, so it + also finds a good match that was captured but never actually + recalled, or notices the live state has drifted onto some other + old memory entirely. + + Ranking priority (matching how a human would eyeball "which memory + am I basically at right now"): + 1. most matching values first -- exact or within that component's + learned deadband (`get_deadband`/`_compare_to_memory`); + 2. most recent memory first, among ties on (1); + 3. smallest aggregate deviation among the *non*-matching values, as + the final tiebreak. Each mismatch is scored as `|diff| / + deadband` when a deadband has been learned for that component + (a dimensionless "how many tolerances off" number, comparable + across differently-scaled quantities) or the raw `|diff|` + otherwise -- a cruder, unit-mixing fallback used only because + nothing better is available yet for that component -- summed + per entry. + + selection (optional): passed to `get_recall_dict` for every entry + (default `None` -- this object's own `categories["recall"]`, + exactly what `recall()` itself would consider; pass `"all"` to + also weigh in track/display-only fields a given entry + captured). + top_n (int, optional): only rank/display this many top rows. + Default: every scanned entry (see `max_entries`). + max_entries (int or None, optional): only *scan* the `max_entries` + most recently written stored entries (default 50), rather than + this object's entire memorize() history -- see the cost note + below for why. Pass `None` to scan every stored entry + regardless of how far back it goes. + tablefmt: passed straight to `format_table` (e.g. "html" for the + eventual memory-widget use of this; colored ANSI otherwise). + + Returns a table (one row per ranked entry: date, message, "matches + m/n" and an aggregate "deviation" score, plus the single biggest + remaining miss) -- a summary first, terminal-only for now; per-key + detail for any one entry is still `get_memory_difference_str( + memory.get_memory(key=...))`. + + Cost note: the dominant cost is one JSON file read per *scanned* + entry (bounded by `max_entries`, not by the full history size -- + a heavily-memorized device like a KB mirror can accumulate + hundreds of entries over its lifetime, which made every call scan + and reread all of them before `max_entries` existed), plus one + concurrent prefetch (`_prefetch_present_values`) of every distinct + live value referenced by a scanned entry. Entries already read + once by a previous call on this same `Memory` instance are cached + (`_entry_full_cache`) for the rest of the process's lifetime -- + safe because a stored entry's file is never rewritten once + `memorize()` has written it -- so only entries not seen before + (typically just newly memorized ones) actually cost a file read on + a repeat call; those are read concurrently, same idea as + `_prefetch_present_values`. + """ + self.setup_path() + mem_index = self._memories() + if not mem_index: + return "No stored memories." + + try: + keys = sorted( + mem_index.keys(), key=lambda k: datetime.fromisoformat(k), reverse=True + ) + except ValueError: + keys = list(reversed(mem_index.keys())) + if max_entries: + keys = keys[:max_entries] + + if not hasattr(self, "_entry_full_cache"): + self._entry_full_cache = {} + + def _load(key): + if key in self._entry_full_cache: + return self._entry_full_cache[key] + try: + full = self.get_memory(key=key) + except Exception: + return None + self._entry_full_cache[key] = full + return full + + to_fetch = [k for k in keys if k not in self._entry_full_cache] + if to_fetch: + from concurrent.futures import ThreadPoolExecutor + + with ThreadPoolExecutor(max_workers=min(8, len(to_fetch))) as ex: + list(ex.map(_load, to_fetch)) + + loaded = [] # (key, message, recall_dict) + for key in keys: + full = _load(key) + if full is None: + continue + try: + rec = self.get_recall_dict(full, selection=selection) + except Exception: + continue + loaded.append((key, mem_index[key].get("message", "") or "", rec)) + + if not loaded: + return "No stored memories could be read." + + present_values = self._prefetch_present_values( + itertools.chain.from_iterable(rec.keys() for _, _, rec in loaded) + ) + + ranked = [] + for key, message, rec in loaded: + matches = 0 + total = 0 + penalty = 0.0 + mismatches = [] # (name, diff_or_None, penalty_contribution) + for name, recall_value in rec.items(): + if name not in present_values: + continue + total += 1 + present_value = present_values[name] + changed, symbol, diff = self._compare_to_memory( + name, present_value, recall_value + ) + if not changed: + matches += 1 + continue + if diff is not None: + deadband = self.get_deadband(name) + contribution = abs(diff) / deadband if deadband else abs(diff) + else: + contribution = 1.0 # non-numeric mismatch: fixed unit penalty + penalty += contribution + mismatches.append((name, diff, contribution)) + try: + date = datetime.fromisoformat(key) + except ValueError: + date = datetime.min + ranked.append( + { + "date": date, + "message": message, + "matches": matches, + "total": total, + "penalty": penalty, + "mismatches": mismatches, + } + ) + + ranked.sort(key=lambda r: (-r["matches"], -r["date"].timestamp(), r["penalty"])) + if top_n: + ranked = ranked[:top_n] + + table = [] + for rank, r in enumerate(ranked): + frac = r["matches"] / r["total"] if r["total"] else 0.0 + match_str = f"{r['matches']}/{r['total']}" + if frac == 1.0: + color = colorama.Fore.GREEN + elif frac >= 0.5: + color = colorama.Fore.YELLOW + else: + color = colorama.Fore.RED + if tablefmt != "html": + match_str = color + colorama.Style.BRIGHT + match_str + colorama.Style.RESET_ALL + + if r["matches"] == r["total"]: + dev_str = "==" + biggest_miss = "" + else: + dev_str = f"{r['penalty']:.3g}" + name, diff, _ = max(r["mismatches"], key=lambda m: m[2]) + biggest_miss = f"{name} ({diff:+g})" if diff is not None else f"{name} (changed)" + + table.append( + [ + rank + 1, + r["date"].strftime("%Y-%m-%d %a %H:%M"), + r["message"], + match_str, + dev_str, + biggest_miss, + ] + ) + + return format_table( + table, + headers=["#", "Date", "Message", "matches", "deviation", "biggest miss"], + colalign=("center", "left", "left", "center", "decimal", "left"), + tablefmt=tablefmt, + ) + def __repr__(self): return self.__str__() diff --git a/eco/epics_utils/ioc_topology.py b/eco/epics_utils/ioc_topology.py new file mode 100644 index 0000000..e0854e0 --- /dev/null +++ b/eco/epics_utils/ioc_topology.py @@ -0,0 +1,258 @@ +"""Which physical IOC serves which namespace component - a small, +independent record, not a replacement for anything else that happens to +know something adjacent: + +* `eco.devices_general.controllers.bernina_controllers.MOTOR_CONTROLLER_BOXES` + hand-maps a few controller *prefixes* to their IOC, for an opt-in + diagnostic "physical box" branch of the namespace. That code is expected + to keep changing on its own timeline, and it only covers the boxes + someone bothered to add there - coupling this module to it would mean + this safety mechanism silently loses coverage every time that one is + refactored, and never covers anything else. This module reads and writes + neither that table nor its file. +* `eco.epics_utils.iocinfo` is the live lookup this module uses underneath + (the `iocinfo.psi.ch` REST API) - a real network call, too slow to make + on the fast path of every `init_all()`. + +Why this exists +---------------- +`Namespace.init_all()`'s concurrent pass (`_run_init_pass` in +eco.utilities.config) builds up to `max_workers` components at once, with no +notion of which physical hardware any of them talk to. Two components that +happen to be scheduled together and both live on the same small/embedded IOC +(e.g. a Moxa-hosted MForce box) hit it with simultaneous connection/get +traffic purely by thread-pool scheduling luck - suspected (not confirmed) as +a contributor to sporadic crashes seen on Bernina's moxa-based motion +controllers (tt_kb/clic/prof_kb on SARES20-MF2; other moxaboxes for +las/xrd). See the project's status-server/init_all investigation notes. + +This module is the persisted answer to "which IOC(s) does component X's PVs +live on" - resolved once per component (the first time +`Namespace.init_all(check_object_crosstalk=True)` sees it, or on an explicit +re-check) via `iocinfo`, then cached to disk so every later `init_all()` +pass - including the fast, default, no-network-calls one - can consult it +for free to decide which components must never be built at the same time. +A name with no cached entry (the common case until it has been explicitly +checked) is simply unconstrained, identical to the behaviour before this +module existed. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Per-user, like ca_tuning.py's learned "fast channels" list and for the same +# reason: a per-user file has none of the group-permission problems a shared +# one under /sf/... would bring, and a personal-account write to a *shared* +# file has broken a live server before (see the eco/status_server memory +# notes on shared adjustables-fs ownership). This is a local performance/ +# safety hint, not beamline configuration. +IOC_TOPOLOGY_STATE_FILE = Path( + os.environ.get("ECO_IOC_TOPOLOGY_FILE") + or (Path.home() / ".eco" / "ioc_topology.json") +) + +_lock = threading.RLock() +_cache: dict = {} # component_name -> {"iocs": [...], "checked_at": t, "n_pvs": n} +_loaded = False +_dirty = False + + +def _load(): + global _loaded + with _lock: + if _loaded: + return + try: + with open(IOC_TOPOLOGY_STATE_FILE) as f: + data = json.load(f) + if isinstance(data, dict): + _cache.update(data) + except FileNotFoundError: + pass + except Exception: + logger.debug("ioc_topology: could not read %s", IOC_TOPOLOGY_STATE_FILE, + exc_info=True) + _loaded = True + + +def save(): + """Persist the cache. Called automatically by `record_iocs`; exposed so + a caller doing many updates in a row can also call it once at the end - + though there is little reason to, since `record_iocs` already does.""" + global _dirty + with _lock: + if not _dirty: + return + data = dict(_cache) + _dirty = False + try: + IOC_TOPOLOGY_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = IOC_TOPOLOGY_STATE_FILE.with_suffix(".tmp") + with open(tmp, "w") as f: + json.dump(data, f, indent=1, sort_keys=True) + os.replace(tmp, IOC_TOPOLOGY_STATE_FILE) + except Exception: + logger.debug("ioc_topology: could not write %s", IOC_TOPOLOGY_STATE_FILE, + exc_info=True) + + +def known_iocs(component_name): + """The cached IOC set for `component_name`, or None if never checked.""" + _load() + with _lock: + entry = _cache.get(component_name) + return frozenset(entry["iocs"]) if entry else None + + +def known_iocs_for(names): + """{name: frozenset(iocs)} for every name in `names` with a cached, + non-empty entry. Names never checked (the common case until + `init_all(check_object_crosstalk=True)` has run) are simply left out - + not an error, and not a reason to constrain anything: see + `eco.utilities.config._run_init_pass`'s per-IOC locks, which treat an + absent name as "no known constraint", exactly today's behaviour.""" + _load() + with _lock: + return { + name: frozenset(_cache[name]["iocs"]) + for name in names + if name in _cache and _cache[name].get("iocs") + } + + +def has_been_checked(component_name): + _load() + with _lock: + return component_name in _cache + + +def record_iocs(component_name, iocs, n_pvs=None): + """Remember (persist) that `component_name`'s PVs live on `iocs`. `iocs` + may be empty - that is itself a real, useful answer ("checked, shares + nothing with anything") - distinct from `component_name` never having + been checked at all (`has_been_checked` returns False).""" + global _dirty + _load() + with _lock: + _cache[component_name] = { + "iocs": sorted(set(iocs)), + "checked_at": time.time(), + "n_pvs": n_pvs, + } + _dirty = True + save() + + +def forget(component_name): + """Un-check a single component, so the next check_object_crosstalk pass + resolves it again from scratch - e.g. after its hardware was moved to a + different IOC. Safe to call for a name that was never checked.""" + global _dirty + with _lock: + _load() + if component_name not in _cache: + return False + del _cache[component_name] + _dirty = True + save() + return True + + +def clear(): + """Forget everything learned. For when the whole cache is suspect (e.g. + a large-scale IOC re-hosting) rather than one component.""" + global _dirty + with _lock: + _load() + _cache.clear() + _dirty = True + save() + + +def _prefix(pvname): + """The part of a PV name before its first ':' - the unit most eco PV + names share with everything else on the same physical controller (e.g. + "SARES20-MF2:MOT_3.RBV" -> "SARES20-MF2"). Good enough to de-duplicate + `iocinfo` lookups: a component's PVs overwhelmingly share one, or a + couple of, such prefixes, not one per PV.""" + return pvname.split(":", 1)[0] + + +def component_pv_names(obj): + """Every PV name under `obj`'s alias subtree, or () if it has none. + + Same `Alias.get_all()` walk `NamespaceMonitorStore.compare_channels` / + `Namespace.status_collection` already use, just without filtering by + channeltype - here the point is "every distinct piece of hardware this + component touches", not "every status-worthy value". + """ + alias = getattr(obj, "alias", None) + if alias is None: + return () + try: + return tuple(sorted({a["channel"] for a in alias.get_all() if a.get("channel")})) + except Exception: + logger.debug("ioc_topology: could not enumerate PVs for an object", + exc_info=True) + return () + + +def discover_iocs(component_name, pv_names, timeout=10.0): + """Resolve `pv_names` to their owning IOC(s) via the live `iocinfo` + lookup, record the result, and return it as a frozenset. + + One `iocinfo.find_ioc` call per distinct PV *prefix* (see `_prefix`) + rather than per PV, since a component's PVs overwhelmingly share a + controller - normally one or two network calls, not hundreds. + Best-effort per prefix: one prefix failing to resolve does not stop the + others. If *none* resolve at all (e.g. the API is unreachable), nothing + is recorded and this returns None, leaving `component_name` uncached - + to be retried on the next explicit check, rather than permanently + remembered as "shares nothing with anything", which an empty recorded + result would otherwise indistinguishably mean. + """ + from eco.epics_utils import iocinfo + + prefixes = sorted({_prefix(pv) for pv in pv_names if pv}) + if not prefixes: + return None + iocs = set() + any_ok = False + for prefix in prefixes: + try: + matches = iocinfo.find_ioc(prefix, timeout=timeout) + except Exception: + logger.debug("ioc_topology: could not resolve IOC for prefix %s", + prefix, exc_info=True) + continue + any_ok = True + iocs.update(m.ioc for m in matches if m.ioc) + if not any_ok: + return None + record_iocs(component_name, iocs, n_pvs=len(pv_names)) + return frozenset(iocs) + + +def report(): + """What this module currently knows, for looking at from a session.""" + _load() + with _lock: + by_ioc: dict = {} + for name, entry in _cache.items(): + for ioc in entry.get("iocs", ()): + by_ioc.setdefault(ioc, []).append(name) + return { + "state_file": str(IOC_TOPOLOGY_STATE_FILE), + "n_checked": len(_cache), + "shared_iocs": { + ioc: sorted(names) for ioc, names in by_ioc.items() if len(names) > 1 + }, + } diff --git a/eco/utilities/config.py b/eco/utilities/config.py index 4dd8811..759fd4f 100644 --- a/eco/utilities/config.py +++ b/eco/utilities/config.py @@ -1609,6 +1609,48 @@ class Namespace(Assembly): init_fn() first_exception = None + + # Per-IOC locks: components already *known* (from a previous + # init_all(check_object_crosstalk=True) pass - see + # eco.epics_utils.ioc_topology) to share a physical IOC are not + # allowed to build at the same time, even within the same dependency + # layer below. Unlike the dependency layering, this is not about + # software order, it is about not hitting one small/embedded IOC + # (a Moxa-hosted MForce box, say) with simultaneous connection/get + # traffic from two unrelated components purely by thread-pool + # scheduling luck - suspected contributor to sporadic crashes on + # Bernina's moxa-based motion controllers. A name absent from the + # cache (the common case until it has been explicitly checked) is + # simply left unconstrained, identical to today's behaviour - this + # is additive and best-effort, never a reason to fail or slow down + # the pass for anything not already known to need it. + try: + from eco.epics_utils import ioc_topology + ioc_of_name = ioc_topology.known_iocs_for(names_to_init) + except Exception: + logger.debug("could not load the ioc_topology cache", exc_info=True) + ioc_of_name = {} + ioc_locks = {} + ioc_locks_guard = Lock() + + def _ioc_lock(ioc): + with ioc_locks_guard: + lk = ioc_locks.get(ioc) + if lk is None: + lk = Lock() + ioc_locks[ioc] = lk + return lk + + def _init_name_serialized_by_ioc(name, **kwargs): + locks = [_ioc_lock(ioc) for ioc in sorted(ioc_of_name.get(name, ()))] + for lk in locks: + lk.acquire() + try: + return self.init_name(name, **kwargs) + finally: + for lk in reversed(locks): + lk.release() + # Dependency-ordered: a name is only submitted once everything it # declared a NamespaceComponent dependency on has been built, so # consumers of a shared component no longer race each other to build @@ -1634,7 +1676,7 @@ class Namespace(Assembly): cycles_left -= 1 futs = { exc.submit( - self.init_name, + _init_name_serialized_by_ioc, name, verbose=verbose, raise_errors=True, @@ -1706,6 +1748,43 @@ class Namespace(Assembly): if raise_errors and first_exception is not None: raise first_exception + def _maybe_check_object_crosstalk(self, names_to_init, requested): + """Backing implementation for `init_all(check_object_crosstalk=True)` + - see that parameter's docstring for what and why. + + Deliberately synchronous and serial (one name at a time, even when + the pass itself ran in background=True's own thread): this does real + network calls to the iocinfo.psi.ch service, and nobody asked for it + to run fast, only for it to run. Best-effort throughout - a failure + here must never surface as an init_all() failure. + """ + if not requested: + return + try: + from eco.epics_utils import ioc_topology + except Exception: + logger.debug("check_object_crosstalk: could not import ioc_topology", + exc_info=True) + return + checked = 0 + for name in sorted(self.initialized_names & set(names_to_init)): + if ioc_topology.has_been_checked(name): + continue + try: + pvs = ioc_topology.component_pv_names(self.get_obj(name)) + if not pvs: + continue + if ioc_topology.discover_iocs(name, pvs) is not None: + checked += 1 + except Exception: + logger.debug("check_object_crosstalk: could not check %s", name, + exc_info=True) + if checked: + logger.info( + "check_object_crosstalk: resolved IOC(s) for %d new " + "component(s) in namespace %s", checked, self.name, + ) + def _print_init_summary(self, names_to_init, starttime, cap=None): """The one thing an init_all() pass always reports, silent or not. @@ -1791,6 +1870,7 @@ class Namespace(Assembly): giveup_failed=True, exclude_names=[], background=True, + check_object_crosstalk=False, ): """Initialize namespace items. @@ -1872,6 +1952,19 @@ class Namespace(Assembly): Maximum number of retry passes over names that reported an in-progress initialization (IsInitialisingError). Caps what would otherwise be an unbounded loop - see `_run_init_pass`. + check_object_crosstalk : bool (default False) + On-demand deep check, off by default: after this pass finishes, + for every name it just built that has never been checked before, + resolve which physical IOC(s) its PVs live on (a live + eco.epics_utils.iocinfo lookup) and remember it via + eco.epics_utils.ioc_topology - so this and every future + init_all() call, on any namespace, can avoid ever scheduling two + components known to share an IOC at the same time (see + `_run_init_pass`'s per-IOC locks). Does real network calls, one + name at a time, so this deliberately never runs unless asked - + call it once after adding new components, or occasionally as + maintenance, not as part of a routine session/scan-start + init_all(). """ def log(*args, **kwargs): @@ -1919,6 +2012,9 @@ class Namespace(Assembly): log, N_cycles, ) + self._maybe_check_object_crosstalk( + names_to_init, check_object_crosstalk + ) thread = Thread( target=worker, name=f"init_all_background[{self.name}]", daemon=True @@ -1947,6 +2043,7 @@ class Namespace(Assembly): log, N_cycles, ) + self._maybe_check_object_crosstalk(names_to_init, check_object_crosstalk) return None def wait_for_init(self, timeout=None): diff --git a/eco/widgets/app_launchers.py b/eco/widgets/app_launchers.py index e01d3eb..6a12b17 100644 --- a/eco/widgets/app_launchers.py +++ b/eco/widgets/app_launchers.py @@ -50,6 +50,7 @@ def _calling_namespace(): def start_desktop( theme=None, + touch=None, link_terminal=True, scope=None, lazy=True, @@ -87,6 +88,11 @@ def start_desktop( workspace), just without the browsable panel taking up screen space. Both the Console and Namespace docks are also closable at any time from their own title bar, if you only want to drop one mid-session. + + touch=True (or ECO_QT_TOUCH=1 in the environment): wider dock/splitter + grab handles and bigger buttons/checkboxes/scrollbars, for touch-screen + use -- layers on top of `theme` (including theme=None/native), doesn't + change colors. See eco.widgets.qt_theme.apply_modern_theme. """ from eco.widgets.desktop_app import EcoDesktopApp, build_namespace @@ -98,6 +104,7 @@ def start_desktop( return EcoDesktopApp( namespace, theme=theme, + touch=touch, link_terminal=link_terminal, auto_start=True, scope=scope, diff --git a/eco/widgets/camera_stream_qt.py b/eco/widgets/camera_stream_qt.py index cda3f30..59a45f4 100644 --- a/eco/widgets/camera_stream_qt.py +++ b/eco/widgets/camera_stream_qt.py @@ -28,6 +28,8 @@ import threading from qtpy import QtCore, QtGui, QtWidgets +from .camserver_stream_qt import compute_fit_scale + _app_ref = None # keep a strong reference to any QApplication we create ourselves @@ -42,10 +44,15 @@ _ZOOM_OUT_STEP = 67 # ~100/150, so wheel-in then wheel-out roughly cancels class _StreamLabel(QtWidgets.QLabel): """QLabel that reports clicks, drag-rectangles and zoom gestures in its - own (unscaled) pixel coordinates -- since the label is never scaled - relative to the pixmap it displays, those map 1:1 onto the source video - frame. Left click/drag zoom in (see clicked/dragged); the wheel and - right-click are the only way to zoom back out (see zoomed).""" + own pixel coordinates -- always the coordinate space of whatever + pixmap is currently displayed (see AxisPTZStreamQt._render_tick, + which keeps the label's size exactly matched to that pixmap), not + necessarily the raw video frame's own resolution. AxisPTZStreamQt's + _on_click/_on_drag/_on_zoom pass the matching displayed size through + to AxisPTZ, which only uses it proportionally, so this stays correct + at any display scale. Left click/drag zoom in (see clicked/dragged); + the wheel and right-click are the only way to zoom back out (see + zoomed).""" clicked = QtCore.Signal(int, int) dragged = QtCore.Signal(int, int, int, int) @@ -104,6 +111,12 @@ class _StreamLabel(QtWidgets.QLabel): class AxisPTZStreamQt: """Live-video Qt window for one AxisPTZ camera. See module docstring.""" + #: how often _render_tick redraws the latest frame at the window's + #: *current* size -- independent of the stream's own fps (see + #: _render_tick), so dragging the window border rescales smoothly even + #: if the camera stream stalls or fps is low. + _RENDER_INTERVAL_MS = 100 + def __init__( self, cam, codec="mjpeg", resolution=None, compression=50, fps=10, auto_start=True ): @@ -113,7 +126,17 @@ class AxisPTZStreamQt: self.compression = compression self.fps = fps self.window = None - self._frame_size = None # (w, h) of the most recent frame + self._frame_size = None # (w, h) of the most recent raw frame + # (w, h) of the pixmap actually on screen right now -- the + # coordinate space _StreamLabel reports clicks in, and so what + # _on_click/_on_drag/_on_zoom must pass as image_width/ + # image_height (see _render_tick and AxisPTZ._to_sensor_xy's own + # docstring: those commands are resolution-independent, just + # proportional within whatever (x, y, image_width, image_height) + # they're given, so scaling the display doesn't break them as + # long as this stays in sync with what's actually drawn) + self._displayed_size = None + self._last_qimage = None # most recent raw (unscaled) frame self._stop_event = threading.Event() self._stream_thread = None self._memory_browser = None # lazily-built "Memories" sub-window @@ -140,7 +163,19 @@ class AxisPTZStreamQt: self._label.clicked.connect(self._on_click) self._label.dragged.connect(self._on_drag) self._label.zoomed.connect(self._on_zoom) - layout.addWidget(self._label) + + # Scrollable viewport, same technique (and same reason) as + # eco.widgets.camserver_stream_qt.CamServerStreamQt: the label + # itself is kept at a fixed size (see _render_tick) matching + # whatever's actually being displayed, so it has to sit in + # something whose own size is free to track the window/dock + # instead -- .viewport().width()/height() is what _render_tick + # fits the image into on every resize, decoupled from the label's + # own (fixed) size entirely. + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setWidget(self._label) + self._scroll_area.setWidgetResizable(False) + layout.addWidget(self._scroll_area) controls = QtWidgets.QHBoxLayout() home_btn = QtWidgets.QPushButton("Home") @@ -195,6 +230,14 @@ class AxisPTZStreamQt: self._stream_thread = threading.Thread(target=self._stream_loop, daemon=True) self._stream_thread.start() + # Redraws the latest frame at the scroll area's *current* size on + # a fixed schedule -- not driven by frame arrival -- so dragging + # the window border rescales the image smoothly even between + # frames (or if the stream stalls). See _render_tick. + self._render_timer = QtCore.QTimer(self.window) + self._render_timer.timeout.connect(self._render_tick) + self._render_timer.start(self._RENDER_INTERVAL_MS) + self.window.show() def _stream_loop(self): @@ -226,19 +269,35 @@ class AxisPTZStreamQt: self._stop_event.wait(2.0) def _apply_frame(self, payload): - # runs on the GUI thread (queued signal) - safe to touch widgets here, - # *except* a frame emitted just before stop()/window close can still - # be delivered after the window (and self._label's underlying Qt - # object) is gone -- both guards below are belt-and-suspenders - # against that harmless-but-noisy teardown race + # runs on the GUI thread (queued signal) -- just caches the raw + # frame; _render_tick (a fixed-schedule QTimer, not this signal) + # is what actually draws it, so a resize between frames still + # rescales the same last frame instead of waiting for the next one if self.window is None: return qimage, w, h = payload self._frame_size = (w, h) - pixmap = QtGui.QPixmap.fromImage(qimage) + self._last_qimage = qimage + + def _render_tick(self): + # *except* this can still fire once just after stop()/window close + # (a queued QTimer tick racing the window teardown) -- both guards + # below are belt-and-suspenders against that harmless-but-noisy race + if self.window is None or self._last_qimage is None: + return + w, h = self._frame_size + viewport = self._scroll_area.viewport() + scale = compute_fit_scale(viewport.width(), viewport.height(), w, h) + pixmap = QtGui.QPixmap.fromImage(self._last_qimage).scaled( + max(1, round(w * scale)), + max(1, round(h * scale)), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) try: self._label.setPixmap(pixmap) self._label.setFixedSize(pixmap.size()) + self._displayed_size = (pixmap.width(), pixmap.height()) except RuntimeError: pass # label's underlying Qt object was deleted mid-teardown @@ -255,21 +314,26 @@ class AxisPTZStreamQt: threading.Thread(target=fn, args=args, daemon=True).start() def _on_click(self, x, y): - if self._frame_size is None: + # x, y come from _StreamLabel in *its own* (displayed, possibly + # scaled) pixel space -- pass the matching displayed size, not the + # raw frame's, as image_width/image_height. AxisPTZ.click_center + # (via _to_sensor_xy) only uses these proportionally, so this is + # correct at any display scale (see _render_tick). + if self._displayed_size is None: return - w, h = self._frame_size + w, h = self._displayed_size self._dispatch(self.cam.click_center, x, y, w, h) def _on_drag(self, x0, y0, x1, y1): - if self._frame_size is None: + if self._displayed_size is None: return - w, h = self._frame_size + w, h = self._displayed_size self._dispatch(self.cam.zoom_to_rectangle, x0, y0, x1, y1, w, h) def _on_zoom(self, x, y, z): - if self._frame_size is None: + if self._displayed_size is None: return - w, h = self._frame_size + w, h = self._displayed_size self._dispatch(self.cam.area_zoom, x, y, z, w, h) def _open_settings(self): @@ -384,6 +448,12 @@ class AxisPTZStreamQt: the same for its own memory browser).""" self._stop_event.set() self._stream_thread = None + render_timer = getattr(self, "_render_timer", None) + if render_timer is not None: + try: + render_timer.stop() + except RuntimeError: + pass # underlying Qt object already gone if self._memory_browser is not None: try: self._memory_browser.window.close() diff --git a/eco/widgets/desktop_app.py b/eco/widgets/desktop_app.py index a2f0127..a78d8f4 100644 --- a/eco/widgets/desktop_app.py +++ b/eco/widgets/desktop_app.py @@ -84,6 +84,7 @@ display) before relying on it day to day. """ import argparse import base64 +import inspect import json import logging import threading @@ -183,6 +184,36 @@ def _resolve_namespace_item(namespace, name): ) +def _widget_accepts_separate_process(item): + """Whether `item.widget(separate_process=...)` is actually a valid + call for this item, used by _open_widget's workspace-restore safety + kwarg. Several unrelated classes share the literal string + `_default_widget = "_widget_viewer"` (CameraBasler/CameraPCO in + eco.devices_general.cameras_swissfel accept separate_process; AxisPTZ + in eco.devices_general.cameras_ptz does NOT -- its own _widget_viewer + takes codec/resolution/compression/fps/auto_start instead, so blindly + keying off the _default_widget *name* raised + `TypeError: AxisPTZ._widget_viewer() got an unexpected keyword + argument 'separate_process'`, confirmed live against cam_west + 2026-09-19). Inspecting the actual bound override's signature (or a + **kwargs passthrough, e.g. QioptiqMicroscope._widget_viewer) is the + only way to tell these apart without hardcoding a class list here + that would silently go stale as new camera-like classes are added.""" + override_name = getattr(type(item), "_default_widget", None) + if not override_name: + return False + override = getattr(item, override_name, None) + if not callable(override): + return False + try: + params = inspect.signature(override).parameters + except (TypeError, ValueError): + return False + return "separate_process" in params or any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + + def sorted_namespace_entries(namespace): """(name, state) pairs for every name registered on `namespace`, state one of "initialized" | "lazy" | "failed", sorted alphabetically @@ -605,6 +636,7 @@ class EcoDesktopApp: self, namespace=None, theme=None, + touch=None, link_terminal=True, auto_start=True, scope="bernina", @@ -614,6 +646,12 @@ class EcoDesktopApp: ): self.namespace = namespace self.theme = theme + # touch=True (or ECO_QT_TOUCH=1 in the environment -- see + # eco.widgets.qt_theme.resolve_touch): wider dock/splitter grab + # handles and bigger buttons/checkboxes/scrollbars, layered on top + # of `theme` (including theme=None/native) rather than being a + # theme choice itself -- see apply_modern_theme's own docstring. + self.touch = touch # link_terminal=True (default): if this is being called from # inside an already-running IPython session (the normal case for # eco.start_desktop() -- see eco.widgets.app_launchers) AND no @@ -672,6 +710,15 @@ class EcoDesktopApp: # with this empty (nothing auto-restores) -- reloading a past # workspace is only ever an explicit menu action. self._opened_names = [] + # Names currently being reopened by load_workspace/load_workspace_data + # (populated right before that reopen loop, one-shot-consumed by + # _open_widget as each name actually opens -- see both for why: a + # workspace reload has no live user present to notice/work around + # the known X11-embedding fragility a camera viewer's default + # separate_process=True can hit, so those reopens ask for + # separate_process=False instead, without changing the interactive + # Namespace-panel-click default everyone else gets). + self._workspace_restore_names = set() # QDockWidgets currently holding an opened device widget (see # _open_widget/_dock_widget_object), tiled into the window rather # than left as separate top-level windows -- not persisted across @@ -695,7 +742,7 @@ class EcoDesktopApp: _app_ref = QtWidgets.QApplication([]) from eco.widgets.qt_theme import apply_modern_theme - apply_modern_theme(self.theme) + apply_modern_theme(self.theme, self.touch) self.window = _DesktopMainWindow(on_close=self._on_window_closing) self.window.setWindowTitle("eco desktop") @@ -802,7 +849,7 @@ class EcoDesktopApp: def _on_save_startup_script(self): path = self.save_startup_script() if path is not None: - print(f"eco desktop: wrote startup script {path} (and {path.with_suffix('.json')})") + print(f"eco desktop: wrote startup script {path}") def _on_new_workspace(self): self._opened_names = [] @@ -1096,9 +1143,29 @@ class EcoDesktopApp: stays a plain standalone window, exactly as before.""" if name not in self._opened_names: self._opened_names.append(name) + # One-shot: only the reopen that load_workspace_data itself + # triggered for this name counts as "restoring" -- a later manual + # reopen of the same name (e.g. closing and reclicking it in the + # Namespace panel) goes through the normal interactive path below. + restoring = name in self._workspace_restore_names + self._workspace_restore_names.discard(name) try: item = _resolve_namespace_item(self.namespace, name) - widget_obj = item.widget() + widget_kwargs = {} + if restoring and _widget_accepts_separate_process(item): + # Camera viewers default to separate_process=True, which + # X11-embeds the subprocess's window via QWindow.fromWinId + + # createWindowContainer (see CameraBasler/CameraPCO. + # _widget_viewer's own docstring) -- documented as fragile + # under XWayland (drifting/detached embedded windows), and a + # confirmed hard segfault when several cameras restore back + # to back with no interactive session present to notice and + # work around it. A workspace reload has exactly that + # profile, so it asks for separate_process=False instead, + # trading the "keeps updating while this session blocks" + # benefit for one that always actually opens. + widget_kwargs["separate_process"] = False + widget_obj = item.widget(**widget_kwargs) except Exception: logger.exception("opening %r's widget failed", name) return @@ -1263,13 +1330,12 @@ class EcoDesktopApp: return True def load_workspace(self, path=None): - """Restore a previously eco.widgets.desktop_app-saved workspace: - the dock layout, then reopen every entry that was open (each via - the same initialized-open-immediately / lazy-init-then-open path - as clicking it in the Namespace panel would). Only ever called - explicitly (the Workspace menu's "Reload Last Workspace", or - directly) -- a fresh window never auto-restores. Returns True if - a workspace file was found and applied.""" + """Restore a previously eco.widgets.desktop_app-saved workspace + from a JSON *file*. Only ever called explicitly (the Workspace + menu's "Reload Last Workspace", or directly) -- a fresh window + never auto-restores. Returns True if a workspace file was found + and applied. See load_workspace_data for the part shared with + --workspace-json (no separate file).""" path = Path(path) if path else DEFAULT_WORKSPACE_FILE if not path.exists(): print(f"eco desktop: no saved workspace at {path}") @@ -1279,7 +1345,18 @@ class EcoDesktopApp: except Exception: logger.exception("failed to read workspace file %s", path) return False + self.load_workspace_data(data) + return True + def load_workspace_data(self, data): + """Apply an already-parsed workspace dict (geometry/state/ + opened_names, the same shape save_workspace writes): restore the + dock layout, then reopen every entry that was open (each via the + same initialized-open-immediately / lazy-init-then-open path as + clicking it in the Namespace panel would). Shared by load_workspace + (reads the dict from a file) and the CLI's --workspace-json (the + dict is embedded directly in the startup script, no companion file + -- see save_startup_script).""" if self.window is not None: if data.get("geometry"): self.window.restoreGeometry(_str_to_qbytearray(data["geometry"])) @@ -1287,23 +1364,27 @@ class EcoDesktopApp: self.window.restoreState(_str_to_qbytearray(data["state"])) if self._launcher is not None: - for name in data.get("opened_names", []): + names = data.get("opened_names", []) + self._workspace_restore_names.update(names) + for name in names: self._launcher.open_by_name(name) - return True def save_startup_script(self, sh_path=None): - """Write a standalone, executable shell script that relaunches - `eco desktop` with only THIS session's currently-open namespace - entries reopened -- everything else in the namespace stays - untouched/lazy (see -l below), so the result is a fast, minimal - "dashboard" for just those components instead of the full + """Write a single, standalone, executable shell script that + relaunches `eco desktop` with only THIS session's currently-open + namespace entries reopened -- everything else in the namespace + stays untouched/lazy (see -l below), so the result is a fast, + minimal "dashboard" for just those components instead of the full namespace. Prompts for where to save (a file dialog) if `sh_path` - isn't given, same as any other "Save As" action. Writes a - companion .json workspace file next to the script (the same - format/mechanism as save_workspace) and points the script at it - via --workspace. Returns the script's path if written, None if - the save dialog was cancelled or there's no window yet to save - from.""" + isn't given, same as any other "Save As" action. + + The workspace data (dock layout + opened names, the same shape + save_workspace writes) is embedded directly in the script as a + heredoc and passed via --workspace-json -- no companion .json + file, so there's only one file to move/share/delete instead of two + that have to be kept together. Returns the script's path if + written, None if the save dialog was cancelled or there's no + window yet to save from.""" if self.window is None: return None if sh_path is None: @@ -1321,8 +1402,16 @@ class EcoDesktopApp: if sh_path.suffix != ".sh": sh_path = sh_path.with_suffix(".sh") - workspace_path = sh_path.with_suffix(".json") - self.save_workspace(workspace_path) + workspace_data = { + "geometry": _qbytearray_to_str(self.window.saveGeometry()), + "state": _qbytearray_to_str(self.window.saveState()), + "opened_names": list(self._opened_names), + } + # A quoted heredoc delimiter ('EOF', not EOF) disables all shell + # expansion of its body, so the JSON's own $, `, \, and " are safe + # verbatim -- no escaping needed regardless of what's in geometry/ + # state/opened_names. + workspace_json = json.dumps(workspace_data, indent=2) scope_arg = "-s {} ".format(self.scope) if self.scope else "" lazy_flag = "-l" if self.lazy else "--no-lazy" @@ -1345,9 +1434,12 @@ class EcoDesktopApp: "# lazy loading (see -l/--no-lazy below) means nothing else in the\n" "# namespace gets touched, so this is a fast, minimal dashboard rather\n" "# than the full namespace. Edit freely; re-running the menu action\n" - "# overwrites both this file and its companion .json workspace file.\n" - 'exec eco desktop {}{}{}{} --workspace "{}" "$@"\n'.format( - scope_arg, lazy_flag, console_flag, namespace_panel_flag, workspace_path + "# overwrites this file (it's self-contained, no companion file).\n" + "read -r -d '' ECO_WORKSPACE_JSON <<'EOF'\n" + "{}\n" + "EOF\n" + 'exec eco desktop {}{}{}{} --workspace-json "$ECO_WORKSPACE_JSON" "$@"\n'.format( + workspace_json, scope_arg, lazy_flag, console_flag, namespace_panel_flag ) ) sh_path.write_text(script) @@ -1359,19 +1451,21 @@ class EcoDesktopApp: # -- lifecycle (mirrors CamServerPanelQt/CamServerStreamQt) ----------- - def run(self, workspace=None): + def run(self, workspace=None, workspace_data=None): """Build the window (if not already built) and block in - QApplication.exec_() until something quits the app. `workspace`, - if given, is loaded (see load_workspace) right after the window - exists but before exec_() -- callers that need this (the CLI's - --workspace) must go through here rather than calling - _build_window() themselves first: doing that separately creates - the QApplication as a side effect, so by the time run() got to - its own "did I create the app" check below it would already be - False, and the exec_() call -- along with everything past it -- - would be skipped entirely. Confirmed for real: `eco desktop` - opened its window and then exited immediately, before the CLI's - --workspace support restructured this into two separate calls.""" + QApplication.exec_() until something quits the app. `workspace` + (a file path) or `workspace_data` (an already-parsed dict), if + given, is loaded (see load_workspace/load_workspace_data) right + after the window exists but before exec_() -- callers that need + this (the CLI's --workspace/--workspace-json) must go through here + rather than calling _build_window() themselves first: doing that + separately creates the QApplication as a side effect, so by the + time run() got to its own "did I create the app" check below it + would already be False, and the exec_() call -- along with + everything past it -- would be skipped entirely. Confirmed for + real: `eco desktop` opened its window and then exited immediately, + before the CLI's --workspace support restructured this into two + separate calls.""" app = QtWidgets.QApplication.instance() created_app = app is None if created_app: @@ -1380,6 +1474,8 @@ class EcoDesktopApp: self._build_window() if workspace: self.load_workspace(workspace) + if workspace_data: + self.load_workspace_data(workspace_data) if created_app: # This call blocks in app.exec_() below until something quits # the app -- but WA_QuitOnClose is set False in _build_window @@ -1560,13 +1656,35 @@ def _main(argv=None): "-- see eco.widgets.qt_theme", ) parser.add_argument( + "--touch", + action="store_true", + default=None, + help="bigger dock/splitter grab handles, buttons, checkboxes and " + "scrollbars, for touch-screen use -- layers on top of --theme " + "(including 'none'), doesn't change colors. Omitting this " + "flag defers to ECO_QT_TOUCH in the environment (default: off) " + "-- see eco.widgets.qt_theme.resolve_touch", + ) + workspace_grp = parser.add_mutually_exclusive_group() + workspace_grp.add_argument( "--workspace", default=None, metavar="PATH", help="load this workspace file on startup (dock layout + which " "namespace entries to reopen) -- see the Workspace menu's " - "'Save Startup Script...', which generates a command exactly " - "like this one pointed at its own saved workspace file", + "'Save Workspace Now'", + ) + workspace_grp.add_argument( + "--workspace-json", + default=None, + metavar="JSON", + help="same as --workspace, but the workspace data (dock layout + " + "which namespace entries to reopen) is given inline as a JSON " + "string instead of a file path -- this is what the Workspace " + "menu's 'Save Startup Script...' generates, embedded in the " + "script itself via a heredoc, so the script is a single " + "self-contained file rather than needing a companion " + "workspace file next to it", ) args = parser.parse_args(argv) theme = None if args.theme == "none" else args.theme @@ -1583,6 +1701,7 @@ def _main(argv=None): app = EcoDesktopApp( namespace, theme=theme, + touch=args.touch, link_terminal=False, auto_start=False, scope=args.scope, @@ -1590,7 +1709,13 @@ def _main(argv=None): with_console=args.with_console, with_namespace_panel=args.with_namespace_panel, ) - app.run(workspace=args.workspace) + workspace_data = None + if args.workspace_json: + try: + workspace_data = json.loads(args.workspace_json) + except Exception: + logger.exception("failed to parse --workspace-json") + app.run(workspace=args.workspace, workspace_data=workspace_data) if __name__ == "__main__": diff --git a/eco/widgets/qt_theme.py b/eco/widgets/qt_theme.py index 5d30e51..decb41a 100644 --- a/eco/widgets/qt_theme.py +++ b/eco/widgets/qt_theme.py @@ -14,6 +14,15 @@ Usage:: from eco.widgets.qt_theme import apply_modern_theme apply_modern_theme("dark") # or "light", or None to clear + apply_modern_theme(touch=True) # bigger grab handles/buttons/scrollbars, + # layered on top of any theme (incl. "none") + +touch=True is a separate, additive switch, not a fourth theme choice: it +only widens dock/splitter separators and grows touch-target sizes +(buttons, checkboxes, scrollbars, form fields), it never changes colors -- +so it composes with "none" (native colors, just bigger hit targets) the +same way it does with "dark"/"light". See eco.widgets.desktop_app's +--touch CLI flag / EcoDesktopApp(touch=...). For eco.widgets.camserver_panel_qt / camserver_stream_qt: pass theme="dark"/"light" to make_camserver_panel_qt(...)/make_camserver_stream_qt(...). @@ -22,13 +31,15 @@ module docstring), the panel's choice is propagated to every spawned viewer subprocess via the ECO_QT_THEME environment variable so the whole panel-plus-viewers picture stays visually consistent -- apply_modern_theme records its resolved choice there, and a subprocess that doesn't get an -explicit --theme falls back to reading it. +explicit --theme falls back to reading it. ECO_QT_TOUCH does the same for +touch=True. """ import os from qtpy import QtWidgets ENV_VAR = "ECO_QT_THEME" +TOUCH_ENV_VAR = "ECO_QT_TOUCH" # qt-material's default dark/light themes are teal-accented -- these # approximate that exact palette so the fallback (no qt-material @@ -106,6 +117,30 @@ QScrollBar::handle:hover { background: #00897b; } _QSS = {"dark": _DARK_QSS, "light": _LIGHT_QSS} +# Additive on top of whatever theme (including "none"/no stylesheet at +# all) -- structural sizing only, no colors changed except the grab-handle +# hover highlight, so it reads correctly whether layered on native colors, +# _DARK_QSS or qt-material's own stylesheet. Sizes are rough physical +# touch-target minimums (~9mm), not just "bigger than default": dock/ +# splitter separators are the specific thing reported hard to grab. +_TOUCH_QSS = """ +QMainWindow::separator { width: 10px; height: 10px; background: #888888; } +QMainWindow::separator:hover { background: #00bfa5; } +QSplitter::handle { background: #888888; } +QSplitter::handle:horizontal { width: 10px; } +QSplitter::handle:vertical { height: 10px; } +QSplitter::handle:hover { background: #00bfa5; } +QPushButton, QToolButton { min-height: 34px; padding: 8px 16px; } +QCheckBox::indicator, QRadioButton::indicator { width: 22px; height: 22px; } +QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox { min-height: 30px; padding: 5px 6px; } +QScrollBar:vertical { width: 22px; } +QScrollBar:horizontal { height: 22px; } +QScrollBar::handle { min-height: 32px; min-width: 32px; border-radius: 6px; } +QTabBar::tab { padding: 10px 18px; } +QDockWidget::title { padding: 10px; } +QAbstractItemView::item { padding: 6px; } +""" + def resolve_theme(theme=None): """theme: "dark" | "light" | None -> effective theme name. None means @@ -118,6 +153,17 @@ def resolve_theme(theme=None): return theme if theme in ("dark", "light") else None +def resolve_touch(touch=None): + """touch: True/False/None -> effective touch-mode bool, mirroring + resolve_theme's "defer to the environment" convention: None means + "defer to ECO_QT_TOUCH" (so a viewer subprocess spawned by a + touch-enabled panel picks up the same choice automatically), falling + back to False (off) if that's unset too. Pure function.""" + if touch is None: + return os.environ.get(TOUCH_ENV_VAR) == "1" + return bool(touch) + + def _apply_qt_material(app, theme): """Try the real qt-material package first. Returns True if applied.""" try: @@ -128,25 +174,39 @@ def _apply_qt_material(app, theme): return True -def apply_modern_theme(theme=None): +def apply_modern_theme(theme=None, touch=None): """Apply (or, with an unresolvable theme, clear) the skin on the current QApplication. Call once, right after creating/obtaining the QApplication instance -- a no-op if there isn't one yet. Also records - the resolved choice in os.environ[ECO_QT_THEME] so any subprocess - this process later spawns inherits the same look by default. Uses the - real qt-material package if installed, else a close hand-built - approximation of its default teal theme (see module docstring).""" + the resolved choices in os.environ[ECO_QT_THEME]/[ECO_QT_TOUCH] so any + subprocess this process later spawns inherits the same look by + default. Uses the real qt-material package for theme if installed, + else a close hand-built approximation of its default teal theme (see + module docstring). + + touch=True (or ECO_QT_TOUCH=1 in the environment) layers _TOUCH_QSS + (bigger dock/splitter grab handles, buttons, checkboxes, scrollbars -- + see its own docstring) on top of whatever `theme` resolves to, + appended last so it wins on the sizing properties it sets regardless + of which base stylesheet (native/hand-built/qt-material) is under it. + Still returns just the resolved theme (unchanged contract) -- nothing + today reads apply_modern_theme's return value, and resolve_touch is + there directly for anything that later needs the touch choice too.""" resolved = resolve_theme(theme) + resolved_touch = resolve_touch(touch) os.environ[ENV_VAR] = resolved or "" + os.environ[TOUCH_ENV_VAR] = "1" if resolved_touch else "" app = QtWidgets.QApplication.instance() if app is None: return resolved if resolved is None: - app.setStyleSheet("") + app.setStyleSheet(_TOUCH_QSS if resolved_touch else "") return resolved if _apply_qt_material(app, resolved): + if resolved_touch: + app.setStyleSheet(app.styleSheet() + _TOUCH_QSS) return resolved app.setStyle("Fusion") - app.setStyleSheet(_QSS[resolved]) + app.setStyleSheet(_QSS[resolved] + (_TOUCH_QSS if resolved_touch else "")) return resolved diff --git a/eco/widgets/widget_tray.py b/eco/widgets/widget_tray.py index f9538fb..b29af90 100644 --- a/eco/widgets/widget_tray.py +++ b/eco/widgets/widget_tray.py @@ -386,10 +386,29 @@ class NamespaceLauncherWidget(widgets.VBox): grid_template_columns="1fr 90px", grid_gap="2px 8px", align_items="center", + # Bounded height + its own scrollbar rather than growing + # the page forever -- matches the Qt desktop's own + # QDockWidget, which is a fixed-size panel with an internal + # scroll area, not something that pushes other docks around + # as the namespace grows. + max_height="70vh", + overflow="auto", ) ) - super().__init__([toolbar, self._grid]) + # Fixed, narrow width -- a real side panel like the desktop's own + # "Namespace" QDockWidget (see EcoDesktopApp._build_window's + # QtCore.Qt.LeftDockWidgetArea dock), not a full-page-width table. + # In a Voila page, an ipywidgets container with no width of its own + # stretches to 100% of the browser width, which is what made this + # a full-width bar instead of a side panel (see the layout= this + # class is built into in make_namespace_dashboard for the other + # half of that fix -- an HBox next to the widget tray, not a VBox + # stacked above it). + super().__init__( + [toolbar, self._grid], + layout=widgets.Layout(width="320px", min_width="320px", flex="0 0 auto"), + ) self._render() def _render(self) -> None: @@ -469,16 +488,20 @@ class NamespaceLauncherWidget(widgets.VBox): # dashboard # --------------------------------------------------------------------------- # def make_namespace_dashboard(namespace: Any, mode: str = "panels", - cap: int = 6) -> widgets.VBox: + cap: int = 6) -> widgets.HBox: """Build the full Voila dashboard for a namespace -- NOT for JupyterLab, which has a real Lumino shell to dock into instead (see eco.widgets.jupyter_sidecar.open_namespace_dashboard). - Header: a NamespaceLauncherWidget (Name/Required table -- browse every + Left: a NamespaceLauncherWidget (Name/Required table -- browse every registered name including still-lazy or failed ones, same as the - desktop UI's launcher panel) and a layout toggle (panels / detail). - Body: the WidgetTray, everything rendered inline on one flat page -- - Voila has no docking shell to put a launcher/opened-widgets split into. + desktop UI's launcher panel), in a fixed-width side panel -- mirrors + the desktop UI's own "Namespace" QDockWidget rather than a full-page- + width table (NamespaceLauncherWidget itself carries the fixed width; + see its own docstring/layout). + Right: the layout toggle (panels / detail), status line, log viewer + and the WidgetTray -- everything Voila has no docking shell to put in + separate panels, stacked in the remaining page width instead. """ tray = WidgetTray(mode=mode, cap=cap) status = widgets.HTML() @@ -517,5 +540,8 @@ def make_namespace_dashboard(namespace: Any, mode: str = "panels", log_btn.on_click(_open_log_viewer) - header = widgets.VBox([launcher, status, widgets.HBox([layout_toggle, log_btn]), log_output]) - return widgets.VBox([header, tray.box]) + main_column = widgets.VBox( + [status, widgets.HBox([layout_toggle, log_btn]), log_output, tray.box], + layout=widgets.Layout(flex="1 1 auto", min_width="0"), + ) + return widgets.HBox([launcher, main_column], layout=widgets.Layout(align_items="flex-start")) diff --git a/eco_cli.py b/eco_cli.py index dd8f347..911a15f 100644 --- a/eco_cli.py +++ b/eco_cli.py @@ -179,8 +179,12 @@ def _run_desktop(args): cmd += ["--no-namespace-panel"] if args.theme: cmd += ["--theme", args.theme] + if args.touch: + cmd += ["--touch"] if args.workspace: cmd += ["--workspace", args.workspace] + if args.workspace_json: + cmd += ["--workspace-json", args.workspace_json] _exec( cmd, "The desktop UI needs qtconsole and a Qt binding (qtpy + PyQt5/PySide6) " @@ -745,10 +749,25 @@ def main(argv=None): help="modern skin, or 'none' for native OS style (default: dark)", ) p_desktop.add_argument( + "--touch", action="store_true", default=None, + help="bigger dock/splitter grab handles, buttons, checkboxes and " + "scrollbars, for touch-screen use -- layers on top of --theme " + "(including 'none'), doesn't change colors", + ) + _workspace_grp = p_desktop.add_mutually_exclusive_group() + _workspace_grp.add_argument( "--workspace", default=None, metavar="PATH", help="load this workspace file on startup (dock layout + which " "namespace entries to reopen) -- see the desktop window's " - "Workspace menu, 'Save Startup Script...'", + "Workspace menu, 'Save Workspace Now'", + ) + _workspace_grp.add_argument( + "--workspace-json", default=None, metavar="JSON", + help="same as --workspace, but the workspace data is given inline " + "as a JSON string instead of a file path -- this is what the " + "desktop window's Workspace menu, 'Save Startup Script...', " + "generates, embedded in the script itself so it's a single " + "self-contained file", ) p_webapp = subparsers.add_parser("webapp", help="Voila dashboard (read-only widgets).") diff --git a/tests/test_app_launchers.py b/tests/test_app_launchers.py index 834e250..2ac60e2 100644 --- a/tests/test_app_launchers.py +++ b/tests/test_app_launchers.py @@ -236,3 +236,54 @@ def test_start_jupyterlab_console_spawns_fresh_with_scope_env(monkeypatch, tmp_p assert "console_two" in captured["cmd"][2] assert captured["env"]["ECO_SCOPE"] == "alignment" assert captured["env"]["ECO_LAZY"] == "0" + + +def test_eco_start_desktop_wrapper_signature_matches_the_real_one(): + """eco.start_desktop (eco/__init__.py) is a lazily-importing wrapper + around eco.widgets.app_launchers.start_desktop -- it must spell out + the same switches (theme, link_terminal, scope, lazy, with_console, + with_namespace_panel) itself rather than a bare *args/**kwargs, so + tab-completion/`?` on eco.start_desktop shows them without having to + go find the real implementation.""" + import inspect + + import eco + + wrapper_params = inspect.signature(eco.start_desktop).parameters + real_params = inspect.signature(app_launchers.start_desktop).parameters + assert wrapper_params.keys() == real_params.keys() + for name, param in wrapper_params.items(): + assert param.default == real_params[name].default, name + + +def test_eco_start_desktop_wrapper_forwards_every_kwarg(monkeypatch): + captured = {} + + def fake_start_desktop(**kwargs): + captured.update(kwargs) + return "the-window" + + monkeypatch.setattr(app_launchers, "start_desktop", fake_start_desktop) + + import eco + + result = eco.start_desktop( + theme="dark", + touch=True, + link_terminal=False, + scope="alignment", + lazy=False, + with_console=False, + with_namespace_panel=False, + ) + + assert result == "the-window" + assert captured == { + "theme": "dark", + "touch": True, + "link_terminal": False, + "scope": "alignment", + "lazy": False, + "with_console": False, + "with_namespace_panel": False, + } diff --git a/tests/test_camera_stream_qt.py b/tests/test_camera_stream_qt.py index b52871c..49553b2 100644 --- a/tests/test_camera_stream_qt.py +++ b/tests/test_camera_stream_qt.py @@ -1,13 +1,18 @@ """Regression tests for a real bug: closing AxisPTZStreamQt's window via its own native close (X) button used to leave the stream thread running and its "Memories" sub-window's stale reference intact -- see -eco.widgets.qt_lifecycle for the underlying fix and why.""" +eco.widgets.qt_lifecycle for the underlying fix and why. Also covers the +live-video display actually being resizable (see _render_tick) -- it used +to setFixedSize the label to the raw camera frame on every frame, so +neither the image nor the window could ever be made smaller than the +camera's native resolution (reported live against cam_west, an AxisPTZ).""" import time +import numpy as np import pytest pytest.importorskip("qtpy") -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtGui, QtWidgets from eco.widgets.camera_stream_qt import AxisPTZStreamQt @@ -22,6 +27,21 @@ def _run_event_loop(app, ms=200): app.exec_() +def _pump(app, predicate, timeout=5.0, interval=0.02): + t0 = time.time() + while time.time() - t0 < timeout: + app.processEvents() + if predicate(): + return True + time.sleep(interval) + return False + + +def _fake_qimage(w, h): + arr = np.zeros((h, w, 3), dtype=np.uint8) + return QtGui.QImage(arr.tobytes(), w, h, w * 3, QtGui.QImage.Format_RGB888).copy() + + class _FakeCam: name = "cam_west" @@ -89,3 +109,62 @@ def test_axisptz_native_close_also_closes_the_memory_browser(): assert gui._memory_browser is None _run_event_loop(app) assert fake_browser.window is None + + +def test_axisptz_stream_scales_display_to_fit_a_small_window(qapp): + """The image itself has to actually shrink when the window/dock is + smaller than the camera's native resolution -- previously the label + was setFixedSize'd to the raw frame on every frame, which fought any + attempt to resize the window smaller than that. See _render_tick, + which redraws the last frame at the scroll area's current viewport + size instead.""" + gui = AxisPTZStreamQt(_FakeCam(), auto_start=False) + gui._build_window() + try: + gui.window.resize(400, 300) + gui.window.show() + gui._apply_frame((_fake_qimage(1920, 1080), 1920, 1080)) + gui._render_tick() + + assert gui._frame_size == (1920, 1080) + assert gui._displayed_size is not None + assert gui._displayed_size != gui._frame_size + viewport = gui._scroll_area.viewport() + assert gui._displayed_size[0] <= viewport.width() + assert gui._displayed_size[1] <= viewport.height() + finally: + gui.stop() + + +def test_axisptz_click_uses_the_displayed_scale_not_the_raw_frame(qapp): + """click_center/zoom_to_rectangle/area_zoom must be told the size of + the coordinate space the click actually happened in (the displayed, + possibly scaled, pixmap) -- passing the raw frame's own resolution + there would silently mis-point the camera any time the display isn't + shown 1:1 (i.e. essentially always, now that it's resizable).""" + + class _RecordingCam(_FakeCam): + def __init__(self): + self.calls = [] + + def click_center(self, x, y, w, h): + self.calls.append((x, y, w, h)) + + cam = _RecordingCam() + gui = AxisPTZStreamQt(cam, auto_start=False) + gui._build_window() + try: + gui.window.resize(400, 300) + gui.window.show() + gui._apply_frame((_fake_qimage(1920, 1080), 1920, 1080)) + gui._render_tick() + assert gui._displayed_size != gui._frame_size # precondition: actually scaled + + gui._on_click(10, 20) + assert _pump(QtWidgets.QApplication.instance(), lambda: cam.calls) + + x, y, w, h = cam.calls[0] + assert (x, y) == (10, 20) + assert (w, h) == gui._displayed_size + finally: + gui.stop() diff --git a/tests/test_desktop_app.py b/tests/test_desktop_app.py index 1c96ba3..a584d43 100644 --- a/tests/test_desktop_app.py +++ b/tests/test_desktop_app.py @@ -61,13 +61,54 @@ class _FakeItem: self.name = name self.widget_calls = 0 self.last_widget = None + self.last_kwargs = {} - def widget(self): + def widget(self, **kwargs): self.widget_calls += 1 + self.last_kwargs = kwargs self.last_widget = _FakeWidgetWrapper() return self.last_widget +class _FakeCameraItem(_FakeItem): + """A namespace item shaped like a real CameraBasler/CameraPCO: its + _default_widget override (_widget_viewer) accepts separate_process -- + see EcoDesktopApp._open_widget's separate_process=False-during- + workspace-restore special case, and _widget_accepts_separate_process, + which inspects this method's signature (not just the _default_widget + name -- see _FakePTZItem below for why the name alone isn't enough). + Note: _widget_viewer itself is never called here -- .widget() (plain + _FakeItem.widget, inherited) is what _open_widget actually invokes and + what test assertions read back via last_kwargs; _widget_viewer exists + purely to give _widget_accepts_separate_process a realistic signature + to introspect, mirroring how the real Assembly.widget() dispatches to + a separate _default_widget-named method instead of being it.""" + + _default_widget = "_widget_viewer" + + def _widget_viewer(self, separate_process=True, dockable=True): + raise AssertionError("not meant to be called directly in tests") + + +class _FakePTZItem(_FakeItem): + """Mirrors AxisPTZ (eco.devices_general.cameras_ptz): the SAME + _default_widget = "_widget_viewer" literal string as _FakeCameraItem, + but its _widget_viewer has an incompatible signature -- no + separate_process, no **kwargs -- exactly like the real + AxisPTZ._widget_viewer(self, codec=..., resolution=..., ...). A + workspace restore that keyed off the _default_widget *name* alone + crashed here with `TypeError: AxisPTZ._widget_viewer() got an + unexpected keyword argument 'separate_process'` (confirmed live + against cam_west 2026-09-19, an AxisPTZ camera) -- + _widget_accepts_separate_process fixes that by inspecting the real + signature instead.""" + + _default_widget = "_widget_viewer" + + def _widget_viewer(self, codec="mjpeg", resolution=None, compression=50, fps=10, auto_start=True): + raise AssertionError("not meant to be called directly in tests") + + class _FakeNamespace: def __init__(self, initialized=(), lazy=(), failed=(), init_delay=0.0): self.initialized_names = set(initialized) @@ -1160,6 +1201,52 @@ def test_load_workspace_missing_file_returns_false(tmp_path): assert gui.load_workspace(tmp_path / "does_not_exist.json") is False +def test_workspace_restore_opens_camera_items_with_separate_process_false(): + """A saved workspace's reopen is exactly the scenario with no + interactive user present to notice/work around a camera viewer's + default separate_process=True X11-embedding a subprocess window -- + documented as fragile under XWayland (CameraBasler/CameraPCO. + _widget_viewer's own docstring) and confirmed to segfault when a + startup script reopens more than one camera back to back. Workspace + restore asks for separate_process=False instead, but only for the + reopen it itself triggered -- a later, ordinary interactive reopen of + the same name is unaffected.""" + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + ns = _FakeNamespace(initialized=["cam_west"]) + ns._items["cam_west"] = _FakeCameraItem("cam_west") + gui = EcoDesktopApp(namespace=ns, auto_start=False) + gui._build_window() + try: + gui.load_workspace_data({"opened_names": ["cam_west"]}) + item = ns._items["cam_west"] + assert item.widget_calls == 1 + assert item.last_kwargs == {"separate_process": False} + + gui._open_widget("cam_west") # an ordinary interactive reopen + assert item.last_kwargs == {} + finally: + gui.stop() + + +def test_workspace_restore_does_not_force_separate_process_onto_incompatible_widget(): + """The AxisPTZ regression: its _default_widget is also literally + "_widget_viewer" (see _FakePTZItem), but its _widget_viewer doesn't + accept separate_process at all. Restoring a workspace containing such + an item must not raise/crash the reopen by forcing that kwarg on it.""" + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + ns = _FakeNamespace(initialized=["cam_west"]) + ns._items["cam_west"] = _FakePTZItem("cam_west") + gui = EcoDesktopApp(namespace=ns, auto_start=False) + gui._build_window() + try: + gui.load_workspace_data({"opened_names": ["cam_west"]}) + item = ns._items["cam_west"] + assert item.widget_calls == 1 + assert item.last_kwargs == {} # no separate_process forced on it + finally: + gui.stop() + + def test_autosave_on_window_close_writes_workspace_file(tmp_path): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) ns = _FakeNamespace(initialized=["cam_west"]) @@ -1286,7 +1373,7 @@ def test_log_viewer_stopped_on_window_close(monkeypatch): # namespace gets touched. -def test_save_startup_script_writes_executable_sh_and_companion_json(tmp_path): +def test_save_startup_script_writes_a_single_self_contained_executable_file(tmp_path): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) ns = _FakeNamespace(initialized=["cam_west"]) gui = EcoDesktopApp(namespace=ns, scope="bernina", lazy=True, auto_start=False) @@ -1299,16 +1386,20 @@ def test_save_startup_script_writes_executable_sh_and_companion_json(tmp_path): assert sh_path.exists() assert sh_path.stat().st_mode & 0o111 # executable - json_path = tmp_path / "my_dashboard.json" - assert json_path.exists() - import json as _json - - data = _json.loads(json_path.read_text()) - assert data["opened_names"] == ["cam_west"] + # No companion file -- the workspace data is embedded in the script + # itself, so this is the only file "Save Startup Script..." writes. + assert not (tmp_path / "my_dashboard.json").exists() + assert list(tmp_path.iterdir()) == [sh_path] script = sh_path.read_text() - assert "eco desktop -s bernina -l --workspace" in script - assert str(json_path) in script + assert "eco desktop -s bernina -l --workspace-json" in script + assert "<<'EOF'" in script + + import json as _json + + json_text = script.split("<<'EOF'\n", 1)[1].split("\nEOF\n", 1)[0] + data = _json.loads(json_text) + assert data["opened_names"] == ["cam_west"] finally: gui.stop() @@ -1371,7 +1462,7 @@ def test_main_theme_defaults_to_native(monkeypatch): == 'none' else args.theme` translation.""" class _StubApp: - def run(self, workspace=None): + def run(self, workspace=None, workspace_data=None): pass captured = {} diff --git a/tests/test_init_all_ioc_crosstalk.py b/tests/test_init_all_ioc_crosstalk.py new file mode 100644 index 0000000..8ac4034 --- /dev/null +++ b/tests/test_init_all_ioc_crosstalk.py @@ -0,0 +1,213 @@ +"""Namespace.init_all()'s use of eco.epics_utils.ioc_topology: + +* `_run_init_pass` must serialize two components against each other when + they are *known* (from the cache) to share a physical IOC, and must leave + everything else exactly as concurrent as before. +* `init_all(check_object_crosstalk=True)` is the on-demand deep check that + populates that cache for newly-built, never-checked components, and must + never do so unless asked. +""" + +import threading +import time + +import pytest + +from eco.epics_utils import ioc_topology +from eco.utilities.config import Namespace + + +@pytest.fixture(autouse=True) +def _isolated_ioc_topology_cache(monkeypatch, tmp_path): + """No test here may read or write the real per-user ioc_topology.json.""" + monkeypatch.setattr(ioc_topology, "IOC_TOPOLOGY_STATE_FILE", tmp_path / "ioc_topology.json") + monkeypatch.setattr(ioc_topology, "_cache", {}) + monkeypatch.setattr(ioc_topology, "_loaded", False) + monkeypatch.setattr(ioc_topology, "_dirty", False) + yield + + +class DependencyThing: + def __init__(self, name=None): + self.name = name + + +def _spy_on_init_name(ns): + """Track how many of ns.init_name's calls are in flight at once.""" + active = {"n": 0, "max": 0} + guard = threading.Lock() + original = ns.init_name + + def spy(name, **kwargs): + with guard: + active["n"] += 1 + active["max"] = max(active["max"], active["n"]) + time.sleep(0.05) + try: + return original(name, **kwargs) + finally: + with guard: + active["n"] -= 1 + + ns.init_name = spy + return active + + +def test_names_sharing_a_cached_ioc_never_build_concurrently(monkeypatch): + monkeypatch.setattr( + ioc_topology, "known_iocs_for", + lambda names: { + "tt_kb": frozenset({"SARES20-CSSU-MF2"}), + "clic": frozenset({"SARES20-CSSU-MF2"}), + }, + ) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + ns.append_obj(DependencyThing, lazy=True, name="clic") + active = _spy_on_init_name(ns) + + ns.init_all(required_only=False, background=False, silent=True, max_workers=4) + + assert active["max"] == 1 + assert ns.initialized_names == {"tt_kb", "clic"} + + +def test_names_sharing_no_known_ioc_still_build_concurrently(monkeypatch): + monkeypatch.setattr(ioc_topology, "known_iocs_for", lambda names: {}) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="a") + ns.append_obj(DependencyThing, lazy=True, name="b") + active = _spy_on_init_name(ns) + + ns.init_all(required_only=False, background=False, silent=True, max_workers=4) + + assert active["max"] == 2 + + +def test_a_third_unrelated_name_is_not_held_up_by_a_shared_ioc_pair(monkeypatch): + """Only the two names that actually share an IOC serialize - a third, + unrelated one must still run concurrently with them.""" + monkeypatch.setattr( + ioc_topology, "known_iocs_for", + lambda names: { + "tt_kb": frozenset({"SARES20-CSSU-MF2"}), + "clic": frozenset({"SARES20-CSSU-MF2"}), + }, + ) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + ns.append_obj(DependencyThing, lazy=True, name="clic") + ns.append_obj(DependencyThing, lazy=True, name="unrelated") + active = _spy_on_init_name(ns) + + ns.init_all(required_only=False, background=False, silent=True, max_workers=4) + + # tt_kb/clic serialize (max 1 of that pair at a time), but "unrelated" + # still overlaps with whichever of them is running - so overall more + # than one name is in flight at once. + assert active["max"] == 2 + + +def test_no_cached_entries_behaves_exactly_like_before_this_existed(monkeypatch): + """An empty/never-populated cache (the state of every namespace before + check_object_crosstalk has ever run) must not change behaviour at all.""" + monkeypatch.setattr(ioc_topology, "known_iocs_for", lambda names: {}) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="a") + + ns.init_all(required_only=False, background=False, silent=True) + + assert ns.initialized_names == {"a"} + + +def test_a_broken_ioc_topology_lookup_does_not_break_init_all(monkeypatch): + """Best-effort: any failure reading the cache must not stop the pass.""" + def boom(names): + raise RuntimeError("cache unreadable") + + monkeypatch.setattr(ioc_topology, "known_iocs_for", boom) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="a") + + ns.init_all(required_only=False, background=False, silent=True) + + assert ns.initialized_names == {"a"} + + +def test_check_object_crosstalk_is_off_by_default(monkeypatch): + calls = [] + monkeypatch.setattr( + ioc_topology, "discover_iocs", + lambda *a, **k: calls.append(a) or frozenset(), + ) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + ns.init_all(required_only=False, background=False, silent=True) + + assert calls == [] + + +def test_check_object_crosstalk_only_checks_newly_built_uncached_names(monkeypatch): + monkeypatch.setattr(ioc_topology, "has_been_checked", lambda name: name == "clic") + monkeypatch.setattr(ioc_topology, "component_pv_names", lambda obj: ("FAKE:PV",)) + checked = [] + monkeypatch.setattr( + ioc_topology, "discover_iocs", + lambda name, pvs, **k: checked.append(name) or frozenset(), + ) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + ns.append_obj(DependencyThing, lazy=True, name="clic") + + ns.init_all( + required_only=False, background=False, silent=True, + check_object_crosstalk=True, + ) + + assert checked == ["tt_kb"] + + +def test_check_object_crosstalk_skips_a_component_with_no_pvs(monkeypatch): + monkeypatch.setattr(ioc_topology, "has_been_checked", lambda name: False) + monkeypatch.setattr(ioc_topology, "component_pv_names", lambda obj: ()) + checked = [] + monkeypatch.setattr( + ioc_topology, "discover_iocs", + lambda name, pvs, **k: checked.append(name) or frozenset(), + ) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + + ns.init_all( + required_only=False, background=False, silent=True, + check_object_crosstalk=True, + ) + + assert checked == [] + + +def test_check_object_crosstalk_never_raises_out_of_init_all(monkeypatch): + monkeypatch.setattr(ioc_topology, "has_been_checked", lambda name: False) + + def boom(obj): + raise RuntimeError("no alias here") + + monkeypatch.setattr(ioc_topology, "component_pv_names", boom) + + ns = Namespace(name="test") + ns.append_obj(DependencyThing, lazy=True, name="tt_kb") + + ns.init_all( + required_only=False, background=False, silent=True, + check_object_crosstalk=True, + ) + + assert ns.initialized_names == {"tt_kb"} diff --git a/tests/test_ioc_topology.py b/tests/test_ioc_topology.py new file mode 100644 index 0000000..d9e29db --- /dev/null +++ b/tests/test_ioc_topology.py @@ -0,0 +1,167 @@ +"""eco.epics_utils.ioc_topology: the persisted "which IOC serves this +component" cache used by Namespace.init_all()'s per-IOC locks (see +tests/test_init_all_ioc_crosstalk.py) and its on-demand deep check +(check_object_crosstalk=True).""" + +import pytest + +from eco.epics_utils import ioc_topology + + +@pytest.fixture(autouse=True) +def _isolated_cache(monkeypatch, tmp_path): + """Every test gets its own empty, unloaded cache and its own state + file - module-level globals would otherwise leak state between tests + (and onto the real ~/.eco/ioc_topology.json).""" + monkeypatch.setattr(ioc_topology, "IOC_TOPOLOGY_STATE_FILE", tmp_path / "ioc_topology.json") + monkeypatch.setattr(ioc_topology, "_cache", {}) + monkeypatch.setattr(ioc_topology, "_loaded", False) + monkeypatch.setattr(ioc_topology, "_dirty", False) + yield + + +def test_unknown_component_has_no_cached_iocs(): + assert ioc_topology.known_iocs("nope") is None + assert ioc_topology.has_been_checked("nope") is False + + +def test_record_and_read_back_iocs(): + ioc_topology.record_iocs("tt_kb", ["SARES20-CSSU-MF2"], n_pvs=12) + + assert ioc_topology.known_iocs("tt_kb") == frozenset({"SARES20-CSSU-MF2"}) + assert ioc_topology.has_been_checked("tt_kb") + + +def test_recording_an_empty_ioc_set_is_still_a_checked_result(): + """A component genuinely sharing nothing with anything is a real, + useful answer - distinct from "never checked".""" + ioc_topology.record_iocs("standalone_thing", []) + + assert ioc_topology.has_been_checked("standalone_thing") + assert ioc_topology.known_iocs("standalone_thing") == frozenset() + + +def test_known_iocs_for_only_returns_checked_and_nonempty_entries(): + ioc_topology.record_iocs("tt_kb", ["SARES20-CSSU-MF2"]) + ioc_topology.record_iocs("clic", ["SARES20-CSSU-MF2"]) + ioc_topology.record_iocs("standalone_thing", []) + + result = ioc_topology.known_iocs_for(["tt_kb", "clic", "standalone_thing", "never_checked"]) + + assert result == { + "tt_kb": frozenset({"SARES20-CSSU-MF2"}), + "clic": frozenset({"SARES20-CSSU-MF2"}), + } + + +def test_persists_across_a_fresh_load(tmp_path, monkeypatch): + ioc_topology.record_iocs("tt_kb", ["SARES20-CSSU-MF2"]) + + # simulate a brand-new process: drop the in-memory cache, force a reload + monkeypatch.setattr(ioc_topology, "_cache", {}) + monkeypatch.setattr(ioc_topology, "_loaded", False) + + assert ioc_topology.known_iocs("tt_kb") == frozenset({"SARES20-CSSU-MF2"}) + + +def test_forget_removes_one_entry_and_clear_removes_everything(): + ioc_topology.record_iocs("tt_kb", ["SARES20-CSSU-MF2"]) + ioc_topology.record_iocs("clic", ["SARES20-CSSU-MF2"]) + + assert ioc_topology.forget("tt_kb") is True + assert ioc_topology.forget("tt_kb") is False # already gone + assert ioc_topology.has_been_checked("tt_kb") is False + assert ioc_topology.has_been_checked("clic") is True + + ioc_topology.clear() + assert ioc_topology.has_been_checked("clic") is False + + +class _FakeAlias: + def __init__(self, channels): + self._channels = channels + + def get_all(self): + return [{"alias": f"a{i}", "channel": c} for i, c in enumerate(self._channels)] + + +class _FakeComponent: + def __init__(self, channels): + self.alias = _FakeAlias(channels) + + +def test_component_pv_names_walks_the_alias_tree(): + obj = _FakeComponent(["SARES20-MF2:MOT_1.RBV", "SARES20-MF2:MOT_2.RBV"]) + + assert ioc_topology.component_pv_names(obj) == ( + "SARES20-MF2:MOT_1.RBV", "SARES20-MF2:MOT_2.RBV", + ) + + +def test_component_pv_names_of_an_object_without_an_alias_is_empty(): + assert ioc_topology.component_pv_names(object()) == () + + +class _FakeIocMatch: + def __init__(self, ioc): + self.ioc = ioc + + +def test_discover_iocs_dedupes_by_prefix_and_records_the_result(monkeypatch): + calls = [] + + def fake_find_ioc(pattern, timeout=10.0): + calls.append(pattern) + return [_FakeIocMatch("SARES20-CSSU-MF2")] + + from eco.epics_utils import iocinfo + monkeypatch.setattr(iocinfo, "find_ioc", fake_find_ioc) + + result = ioc_topology.discover_iocs( + "tt_kb", + ["SARES20-MF2:MOT_1.RBV", "SARES20-MF2:MOT_1.VAL", "SARES20-MF2:MOT_2.RBV"], + ) + + assert result == frozenset({"SARES20-CSSU-MF2"}) + assert calls == ["SARES20-MF2"] # one call, not one per PV + assert ioc_topology.known_iocs("tt_kb") == frozenset({"SARES20-CSSU-MF2"}) + + +def test_discover_iocs_survives_one_prefix_failing(monkeypatch): + def fake_find_ioc(pattern, timeout=10.0): + if pattern == "BROKEN": + raise RuntimeError("iocinfo unreachable") + return [_FakeIocMatch("SOME-IOC")] + + from eco.epics_utils import iocinfo + monkeypatch.setattr(iocinfo, "find_ioc", fake_find_ioc) + + result = ioc_topology.discover_iocs( + "mixed", ["BROKEN:PV1", "OK:PV2"], + ) + + assert result == frozenset({"SOME-IOC"}) + + +def test_discover_iocs_returns_none_and_records_nothing_if_everything_fails(monkeypatch): + def fake_find_ioc(pattern, timeout=10.0): + raise RuntimeError("iocinfo unreachable") + + from eco.epics_utils import iocinfo + monkeypatch.setattr(iocinfo, "find_ioc", fake_find_ioc) + + result = ioc_topology.discover_iocs("tt_kb", ["SARES20-MF2:MOT_1.RBV"]) + + assert result is None + assert ioc_topology.has_been_checked("tt_kb") is False + + +def test_report_groups_components_by_shared_ioc(): + ioc_topology.record_iocs("tt_kb", ["SARES20-CSSU-MF2"]) + ioc_topology.record_iocs("clic", ["SARES20-CSSU-MF2"]) + ioc_topology.record_iocs("standalone_thing", ["SOME-OTHER-IOC"]) + + rep = ioc_topology.report() + + assert rep["n_checked"] == 3 + assert rep["shared_iocs"] == {"SARES20-CSSU-MF2": ["clic", "tt_kb"]} diff --git a/tests/test_qt_theme.py b/tests/test_qt_theme.py index da98b8f..1580e7f 100644 --- a/tests/test_qt_theme.py +++ b/tests/test_qt_theme.py @@ -4,17 +4,22 @@ import pytest pytest.importorskip("qtpy") -from eco.widgets.qt_theme import ENV_VAR, resolve_theme +from eco.widgets.qt_theme import ENV_VAR, TOUCH_ENV_VAR, resolve_theme, resolve_touch @pytest.fixture(autouse=True) def _clean_env(): old = os.environ.pop(ENV_VAR, None) + old_touch = os.environ.pop(TOUCH_ENV_VAR, None) yield if old is None: os.environ.pop(ENV_VAR, None) else: os.environ[ENV_VAR] = old + if old_touch is None: + os.environ.pop(TOUCH_ENV_VAR, None) + else: + os.environ[TOUCH_ENV_VAR] = old_touch def test_resolve_theme_explicit_dark(): @@ -42,3 +47,67 @@ def test_resolve_theme_none_with_unset_env_is_no_theme(): def test_resolve_theme_env_var_invalid_value_is_no_theme(): os.environ[ENV_VAR] = "solarized" assert resolve_theme(None) is None + + +def test_resolve_touch_explicit_true(): + assert resolve_touch(True) is True + + +def test_resolve_touch_explicit_false_overrides_env(): + os.environ[TOUCH_ENV_VAR] = "1" + assert resolve_touch(False) is False + + +def test_resolve_touch_none_reads_env_var(): + os.environ[TOUCH_ENV_VAR] = "1" + assert resolve_touch(None) is True + + +def test_resolve_touch_none_with_unset_env_is_false(): + assert resolve_touch(None) is False + + +def test_resolve_touch_env_var_anything_but_1_is_false(): + os.environ[TOUCH_ENV_VAR] = "true" + assert resolve_touch(None) is False + + +def test_apply_modern_theme_touch_layers_onto_no_theme(): + """touch=True must still do something even with theme=None ("none" is + the desktop's own CLI default) -- it's an additive switch, not a + fourth theme choice, so it can't rely on a base theme's stylesheet + already being set.""" + from qtpy import QtWidgets + + from eco.widgets.qt_theme import _TOUCH_QSS, apply_modern_theme + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + apply_modern_theme(theme=None, touch=True) + try: + assert _TOUCH_QSS in app.styleSheet() + finally: + apply_modern_theme(theme=None, touch=False) # leave global app state clean + + +def test_apply_modern_theme_touch_false_clears_touch_only(): + from qtpy import QtWidgets + + from eco.widgets.qt_theme import _TOUCH_QSS, apply_modern_theme + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + apply_modern_theme(theme=None, touch=True) + apply_modern_theme(theme=None, touch=False) + assert _TOUCH_QSS not in app.styleSheet() + + +def test_apply_modern_theme_records_touch_choice_in_env(): + from qtpy import QtWidgets + + from eco.widgets.qt_theme import apply_modern_theme + + QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + apply_modern_theme(theme=None, touch=True) + try: + assert os.environ[TOUCH_ENV_VAR] == "1" + finally: + apply_modern_theme(theme=None, touch=False) diff --git a/tests/test_widget_tray.py b/tests/test_widget_tray.py index 70cb7ea..cbf95ef 100644 --- a/tests/test_widget_tray.py +++ b/tests/test_widget_tray.py @@ -43,8 +43,8 @@ def test_log_viewer_button_calls_eco_logs_widget_prefer_jupyter(monkeypatch): monkeypatch.setattr(eco.logs, "widget", lambda prefer: calls.append(prefer)) dash = make_namespace_dashboard(_FakeNamespace()) - header, _tray_box = dash.children - _launcher, _status, btn_row, log_output = header.children + _launcher, main_column = dash.children + _status, btn_row, log_output, _tray_box = main_column.children _layout_toggle, log_btn = btn_row.children # A real ipykernel-backed InteractiveShell.instance() left behind by an # earlier, unrelated test in this same pytest process (a real,