tests and prepump added channels.

This commit is contained in:
2026-09-20 07:44:29 +02:00
parent 6f8ef34c79
commit 8dcf87e2f1
24 changed files with 2060 additions and 140 deletions
+43 -6
View File
@@ -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):
+4
View File
@@ -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
+6 -4
View File
@@ -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
+5 -5
View File
@@ -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},
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+17 -1
View File
@@ -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,
+549 -29
View File
@@ -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: `"<key>: @ memory (<message>)"` if
within deadband (or exactly equal), `"<key>: close to memory
(<message>), deviating by <diff>"` 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__()
+258
View File
@@ -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
},
}
+98 -1
View File
@@ -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):
+7
View File
@@ -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,
+88 -18
View File
@@ -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()
+168 -43
View File
@@ -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 <name>.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__":
+68 -8
View File
@@ -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
+34 -8
View File
@@ -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"))
+20 -1
View File
@@ -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).")
+51
View File
@@ -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,
}
+81 -2
View File
@@ -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()
+102 -11
View File
@@ -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 = {}
+213
View File
@@ -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"}
+167
View File
@@ -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"]}
+70 -1
View File
@@ -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)
+2 -2
View File
@@ -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,