tests and prepump added channels.
This commit is contained in:
+43
-6
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"value": 0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"value": 0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"value": 0
|
||||
}
|
||||
@@ -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
@@ -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__()
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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).")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user