diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md index ee91ca86..6dd51135 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -105,6 +105,20 @@ Still completely untested (not attempted yet, not known-broken — just unexplor yet — out of scope for the sim-validation work, in scope for the eventual macro- standardization pass. +## gui_tools.py progress fields (deferred on purpose) + +`gui_tools.py` was modernized to flomni's current single-level `.new()` / `_is_deleted()`-guard +pattern (window lifecycle, xray-eye-align GUI, cameras + hard-stop console, `RingProgressBar`). +One piece was deliberately **not** ported: flomni's `self.progress` dict carries +`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` plus a +`_describe_active_hook()` method feeding an ETA/heartbeat line in `_flomnigui_update_progress()`. +omny's `self.progress` (set up in `OMNY.__init__`, `omny.py`) still only has the original 7 fields +(`subtomo`, `subtomo_projection`, `subtomo_total_projections`, `projection`, +`total_projections`, `angle`, `tomo_type`) and `_omnygui_update_progress()` was written against +just those. Mirko's call: keep it simple for this pass, revisit later. Not a crash risk — +`omny_webpage_generator.py` already reads the ETA-ish fields defensively via `.get()`, degrading +to "N/A" when absent, and that file is unwired/dead code anyway (see below). + ## Environment note (not code, but will bite again if forgotten) Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo- diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py index 89ef27f5..d2a41535 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py @@ -1,12 +1,16 @@ import builtins +import time -# from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose +from bec_lib import bec_logger + +logger = bec_logger.logger if builtins.__dict__.get("bec") is not None: bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") scans = builtins.__dict__.get("scans") + def umv(*args): return scans.umv(*args, relative=False) @@ -16,76 +20,184 @@ class OMNYGuiToolsError(Exception): class OMNYGuiTools: + # Screen assumed 2560x1440. Window is right-aligned with a small margin + # from the top, matching flomni's gui_tools.py convention. + _SCREEN_WIDTH = 2560 + _WINDOW_WIDTH = 1500 + _WINDOW_HEIGHT = 850 + _WINDOW_TOP_MARGIN = 50 - def __init__(self, client): - self.gui = getattr(client, "gui", None) - # No "main" BEC-widgets window exists yet in --nogui sessions, or before - # a GUI has actually been opened; fall back to None like flomni's - # GuiTools does (self.flomni_window, lazily populated by - # flomnigui_show_gui()), instead of crashing OMNY's construction. - try: - self.gui_window = self.gui.windows["main"].widget - except (KeyError, AttributeError): - self.gui_window = None + def __init__(self): + self.omny_window = None + self.text_box = None + self.progressbar = None + self.xeyegui = None + self.idle_text_box = None self.fig200 = None self.fig201 = None self.fig202 = None self.fig203 = None - self.progressbar = None - self.text_box = None - self.idle_text_box = None + self.console = None + + def set_client(self, client): + self.client = client + self.gui = getattr(client, "gui", None) def omnygui_show_gui(self): - self.gui_window.show() + if "omny" in self.gui.windows: + self.omny_window = self.gui.windows["omny"] + self.gui.omny.raise_window() + else: + # geometry: (pos_x, pos_y, w, h) + pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH + geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT) + self.omny_window = self.gui.new("omny", geometry=geometry) + time.sleep(1) def omnygui_stop_gui(self): - self.gui_window.hide() + self.gui.omny.hide() + + def omnygui_raise(self): + self.gui.omny.raise_window() + + def _omnygui_is_missing(self, attribute_name): + widget = getattr(self, attribute_name, None) + if widget is None: + return True + if hasattr(widget, "_is_deleted") and widget._is_deleted(): + return True + return False + + def _omnycam_check_device_exists(self, device): + try: + device + except: + return False + else: + return True + + def omnygui_show_xeyealign(self): + self.omnygui_show_gui() + if self._omnygui_is_missing("xeyegui"): + self.omnygui_remove_all_docks() + self.xeyegui = self.gui.omny.new("OMNY_XRayEye", object_name="xrayeye") + # cam_xeye is not (yet) part of omny's device config -- guard rather + # than assume it exists, unlike flomni's unconditional version. + if "cam_xeye" in dev and not dev.cam_xeye.live_mode_enabled.get(): + dev.cam_xeye.live_mode_enabled.put(True) + self.xeyegui.switch_tab("alignment") + + def omnygui_show_xeyealign_fittab(self): + self.omnygui_show_gui() + if self._omnygui_is_missing("xeyegui"): + self.omnygui_remove_all_docks() + self.xeyegui = self.gui.omny.new("OMNY_XRayEye", object_name="xrayeye") + self.xeyegui.switch_tab("fit") def _omnycam_parking(self): self.omnygui_show_omnycam_parking() def omnygui_show_omnycam_parking(self): self.omnygui_show_gui() - if self.fig200 is None: - self._omnycam_clear() - self.fig200 = self.gui_window.add_dock(name="omnycam200").add_widget("BECImageWidget") + if self._omnygui_is_missing("fig200") or self._omnygui_is_missing("fig203"): + self.omnygui_remove_all_docks() + self.fig200 = self.gui.omny.new("BECImageWidget") if self._omnycam_check_device_exists(dev.cam200): fig = self.fig200.image("cam200") fig.set_rotation(deg_90=3) self.fig200.lock_aspect_ratio(True) else: print("Cannot open cam200. Device does not exist.") - self.fig203 = self.gui_window.add_dock(name="omnycam203").add_widget("BECImageWidget") + self.fig203 = self.gui.omny.new("BECImageWidget") if self._omnycam_check_device_exists(dev.cam203): fig = self.fig203.image("cam203") fig.set_rotation(deg_90=3) self.fig203.lock_aspect_ratio(True) else: print("Cannot open cam203. Device does not exist.") - try: - self.gui_window.remove_dock(name="default_figure") - except: - pass + + def _omnycam_samplestage(self): + self.omnygui_show_omnycam_samplestage() + + def omnygui_show_omnycam_samplestage(self): + self.omnygui_show_gui() + if self._omnygui_is_missing("fig201") or self._omnygui_is_missing("fig202"): + self.omnygui_remove_all_docks() + self.fig201 = self.gui.omny.new("BECImageWidget") + if self._omnycam_check_device_exists(dev.cam201): + fig = self.fig201.image("cam201") + fig.set_rotation(deg_90=3) + self.fig201.lock_aspect_ratio(True) + else: + print("Cannot open cam201. Device does not exist.") + self.fig202 = self.gui.omny.new("BECImageWidget") + if self._omnycam_check_device_exists(dev.cam202): + fig = self.fig202.image("cam202") + fig.set_rotation(deg_90=3) + self.fig202.lock_aspect_ratio(True) + else: + print("Cannot open cam202. Device does not exist.") + + def omnygui_show_cameras(self): + """General camera view + hard-stop console, the omny analog of + flomni's flomnigui_show_cameras(). Distinct from + omnygui_show_omnycam_parking()/omnygui_show_omnycam_samplestage() + above, which are omny's own pre-existing, narrower parking/ + samplestage-specific views -- left untouched. + """ + self.omnygui_show_gui() + if ( + self._omnygui_is_missing("fig200") + or self._omnygui_is_missing("fig203") + or self._omnygui_is_missing("console") + ): + self.omnygui_remove_all_docks() + self.fig200 = self.gui.omny.new("BECImageWidget") + if self._omnycam_check_device_exists(dev.cam200): + fig = self.fig200.image("cam200") + fig.set_rotation(deg_90=3) + self.fig200.lock_aspect_ratio(True) + else: + print("Cannot open cam200. Device does not exist.") + self.fig203 = self.gui.omny.new("BECImageWidget") + if self._omnycam_check_device_exists(dev.cam203): + fig = self.fig203.image("cam203") + fig.set_rotation(deg_90=3) + self.fig203.lock_aspect_ratio(True) + else: + print("Cannot open cam203. Device does not exist.") + + # Hard-stop console, docked below the cameras. otransy is the + # closest omny analog to flomni's ftransy (both the transfer-Y + # axis) -- no secondary/extra stop device wired yet (Mirko's + # call, revisit once a second candidate is confirmed). + self.console = self.gui.omny.new( + "z_ConsoleButtonsWidget", + object_name="console", + where="bottom", + hard_stop_device_name="otransy", + hard_stop_label="OMNY Motion Stop", + ) + self.gui.omny.set_layout_ratios(vertical=[5, 1]) def omnygui_remove_all_docks(self): - self.gui_window.clear_all() + if hasattr(self.gui, "omny"): + self.gui.omny.delete_all() + self.progressbar = None + self.text_box = None + self.xeyegui = None + self.idle_text_box = None self.fig200 = None self.fig201 = None self.fig202 = None self.fig203 = None - self.progressbar = None - self.text_box = None - self.idle_text_box = None + self.console = None def omnygui_idle(self): self.omnygui_show_gui() - if self.idle_text_box is None: + if self._omnygui_is_missing("idle_text_box"): self.omnygui_remove_all_docks() - self.idle_text_box = self.gui_window.add_dock(name="idle_text").add_widget("TextBox") - try: - self.gui_window.remove_dock(name="default_figure") - except: - pass + self.idle_text_box = self.gui.omny.new("TextBox") text = ( "
"
             + "██████╗ ███████╗ ██████╗      ██████╗ ███╗   ███╗███╗   ██╗██╗   ██╗\n"
@@ -98,79 +210,50 @@ class OMNYGuiTools:
         )
         self.idle_text_box.set_html_text(text)
 
-    def _omnycam_clear(self):
-        self.omnygui_remove_all_docks()
-
-    def _omnycam_check_device_exists(self, device):
-        try:
-            device
-        except:
-            return False
-        else:
-            return True
-
-    def _omnycam_samplestage(self):
-        self.omnygui_show_omnycam_samplestage()
-
-    def omnygui_show_omnycam_samplestage(self):
-        self.omnygui_show_gui()
-        if self.fig201 is None:
-            self.omnygui_remove_all_docks()
-            self.fig201 = self.gui_window.add_dock(name="omnycam201").add_widget("BECImageWidget")
-            if self._omnycam_check_device_exists(dev.cam201):
-                fig = self.fig201.image("cam201")
-                fig.set_rotation(deg_90=3)
-                self.fig201.lock_aspect_ratio(True)
-            else:
-                print("Cannot open cam201. Device does not exist.")
-            self.fig202 = self.gui_window.add_dock(name="omnycam202").add_widget("BECImageWidget")
-            if self._omnycam_check_device_exists(dev.cam202):
-                fig = self.fig202.image("cam202")
-                fig.set_rotation(deg_90=3)
-                self.fig202.lock_aspect_ratio(True)
-            else:
-                print("Cannot open cam202. Device does not exist.")
-            try:
-                self.gui_window.remove_dock(name="default_figure")
-            except:
-                pass
-
     def omnygui_show_progress(self):
-        self.omnygui_show_gui()
-        if self.progressbar is None:
-            self.omnygui_remove_all_docks()
-            # Add a new dock with a RingProgressBar widget
-            self.progressbar = self.gui_window.add_dock(name="progress").add_widget(
-                "RingProgressBar"
-            )
-            # Customize the size of the progress ring
-            self.progressbar.set_line_widths(20)
-            # Disable automatic updates and manually set the self.progressbar value
-            self.progressbar.enable_auto_updates(False)
-            # Set precision for the self.progressbar display
-            self.progressbar.set_precision(1)  # Display self.progressbar with one decimal places
-            # Setting multiple rigns with different values
-            self.progressbar.set_number_of_bars(3)
-            self.progressbar.rings[2].set_update("scan")
-            # Set the values of the rings to 50, 75, and 25 from outer to inner ring
-            # self.progressbar.set_value([50, 75])
-            # Add a new dock with a TextBox widget
-            self.text_box = self.gui_window.add_dock(name="progress_text").add_widget("TextBox")
-            try:
-                self.gui_window.remove_dock(name="default_figure")
-            except:
-                pass
-        self._omnygui_update_progress()
+        try:
+            self.omnygui_show_gui()
+            if self._omnygui_is_missing("progressbar"):
+                self.omnygui_remove_all_docks()
+                self.progressbar = self.gui.omny.new("RingProgressBar")
+                # Two manually-updated rings: overall tomogram progress and
+                # subtomo progress. Unlike flomni's third, auto-updating
+                # "scan"-linked ring, and unlike flomni's ETA/heartbeat/
+                # active-hook text, omny's progress dict doesn't carry that
+                # data yet (see omny/AI_docs/OPEN_ISSUES.md) -- kept simple
+                # here on purpose, revisit once that's ported.
+                self.progressbar.add_ring().set_update("manual")
+                self.progressbar.add_ring().set_update("manual")
+            self._omnygui_update_progress()
+        except Exception as e:
+            logger.warning(f"Error in omnygui_show_progress: {e}")
 
     def _omnygui_update_progress(self):
-        if self.progressbar is not None:
+        try:
+            if self.progressbar is None:
+                logger.warning("OMNY progress GUI update skipped: progressbar is not available.")
+                return
+            main_progress_ring = self.progressbar.rings[0]
+            subtomo_progress_ring = self.progressbar.rings[1]
             progress = self.progress["projection"] / self.progress["total_projections"] * 100
-            subotmo_progress = (
+            subtomo_progress = (
                 self.progress["subtomo_projection"]
                 / self.progress["subtomo_total_projections"]
                 * 100
             )
-            self.progressbar.set_value([progress, subotmo_progress])
+            main_progress_ring.set_value(progress)
+            subtomo_progress_ring.set_value(subtomo_progress)
 
-            text = f"Progress report:\n Tomo type: ....................... {self.progress['tomo_type']}\n Projection: ...................... {self.progress['projection']:.0f}\n Total projections expected ....... {self.progress['total_projections']}\n Angle: ........................... {self.progress['angle']}\n Current subtomo: ................. {self.progress['subtomo']}\n Current projection within subtomo: {self.progress['subtomo_projection']}\n Total projections per subtomo: ... {self.progress['subtomo_total_projections']}"
-            self.text_box.set_plain_text(text)
+            text = (
+                f"Progress report:\n"
+                f" Tomo type:  {self.progress['tomo_type']}\n"
+                f" Projection:  {self.progress['projection']:.0f}\n"
+                f" Total projections expected  {self.progress['total_projections']:.1f}\n"
+                f" Angle:  {self.progress['angle']:.1f}\n"
+                f" Current subtomo:  {self.progress['subtomo']}\n"
+                f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
+                f" Total projections per subtomo:  {int(self.progress['subtomo_total_projections'])}"
+            )
+            self.progressbar.set_center_label(text)
+        except Exception as exc:
+            logger.warning(f"OMNY progress GUI update skipped: {exc}")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index b8530a5b..b0df048f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -596,7 +596,8 @@ class OMNY(
         self.OMNYTools = OMNYTools(self.client)
         OMNY_rt_client.__init__(self)
         self.align = XrayEyeAlign(self.client, self)
-        OMNYGuiTools.__init__(self, self.client)
+        OMNYGuiTools.__init__(self)
+        self.set_client(self.client)
 
     def start_x_ray_eye_alignment(self):
         if self.OMNYTools.yesno(
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
index 868d5c6f..55a1ee29 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
@@ -79,7 +79,7 @@ class XrayEyeAlign:
     # ------------------------------------------------------------------
 
     def _reset_init_values(self):
-        self.shift_xy = [0.0, 0.0]   # base shift to bring sample to beam centre [µm]
+        self.shift_xy = [0.0, 0.0]  # base shift to bring sample to beam centre [µm]
         self._xray_fov_xy = [0.0, 0.0]
 
     def tomo_rotate(self, val: float):
@@ -161,7 +161,7 @@ class XrayEyeAlign:
             Compute x/y offsets, push to GUI fit tab, wait for DAP fit,
             then load fit parameters into the global variable store.
         """
-        self.lamni.lamnigui_show_xeyealign()
+        self.lamni.omnygui_show_xeyealign()
         self.send_message("Getting things ready. Please wait...")
 
         self.gui.enable_submit_button(False)
@@ -188,14 +188,8 @@ class XrayEyeAlign:
         k = 0
         while True:
             if dev.omny_xray_gui.submit.get() == 1:
-                val_x = (
-                    getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
-                    * self.PIXEL_CALIBRATION
-                )
-                val_y = (
-                    getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
-                    * self.PIXEL_CALIBRATION
-                )
+                val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.PIXEL_CALIBRATION
+                val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.PIXEL_CALIBRATION
                 self.alignment_values[k] = [val_x, val_y]
                 print(
                     f"Clicked position {k}: "
@@ -242,15 +236,11 @@ class XrayEyeAlign:
                     self.gui.enable_submit_button(False)
 
                     self.scans.lamni_move_to_scan_center(
-                        self.shift_xy[0] / 1000,
-                        self.shift_xy[1] / 1000,
-                        self.get_tomo_angle(),
+                        self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
                     ).wait()
                     time.sleep(1)
                     self.scans.lamni_move_to_scan_center(
-                        self.shift_xy[0] / 1000,
-                        self.shift_xy[1] / 1000,
-                        self.get_tomo_angle(),
+                        self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
                     ).wait()
 
                     dev.omny_xray_gui.angle.set(self.get_tomo_angle())
@@ -274,16 +264,12 @@ class XrayEyeAlign:
                     self._disable_rt_feedback()
                     self.tomo_rotate(approach_angle)
                     self.scans.lamni_move_to_scan_center(
-                        self.shift_xy[0] / 1000,
-                        self.shift_xy[1] / 1000,
-                        self.get_tomo_angle(),
+                        self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
                     ).wait()
                     self._disable_rt_feedback()
                     self.tomo_rotate(target_angle)
                     self.scans.lamni_move_to_scan_center(
-                        self.shift_xy[0] / 1000,
-                        self.shift_xy[1] / 1000,
-                        self.get_tomo_angle(),
+                        self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
                     ).wait()
 
                     dev.omny_xray_gui.angle.set(self.get_tomo_angle())
@@ -317,9 +303,7 @@ class XrayEyeAlign:
                     self.shift_xy[0] += _mvx
                     self.shift_xy[1] += _mvy
                     self.scans.lamni_move_to_scan_center(
-                        self.shift_xy[0] / 1000,
-                        self.shift_xy[1] / 1000,
-                        self.get_tomo_angle(),
+                        self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
                     ).wait()
                     print(
                         f"Current centre: "
@@ -350,7 +334,7 @@ class XrayEyeAlign:
         self.client.set_global_var("tomo_fov_offset", self.shift_xy)
 
         # Switch GUI to fit tab and wait for DAP to finish fitting
-        self.lamni.lamnigui_show_xeyealign_fittab()
+        self.lamni.omnygui_show_xeyealign_fittab()
         print("Waiting 5 s for DAP sinusoidal fit to converge...")
         time.sleep(5)
 
@@ -399,12 +383,8 @@ class XrayEyeAlign:
             f.write("angle\thorizontal\tvertical\n")
             for k in range(1, 9):
                 angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 1]
-                x_off = (
-                    self.alignment_values[1][0] - self.alignment_values[k][0]
-                ) * 1000
-                y_off = (
-                    self.alignment_values[k][1] - self.alignment_values[1][1]
-                ) * 1000
+                x_off = (self.alignment_values[1][0] - self.alignment_values[k][0]) * 1000
+                y_off = (self.alignment_values[k][1] - self.alignment_values[1][1]) * 1000
                 f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n")
                 print(
                     f"  Angle {angle_deg:3d} deg: "
@@ -428,4 +408,4 @@ class XrayEyeAlign:
 
         # Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
         self.gui.submit_fit_array(data)
-        print(f"Fit data submitted with shape {data.shape}:\n{data}")
\ No newline at end of file
+        print(f"Fit data submitted with shape {data.shape}:\n{data}")
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
new file mode 100644
index 00000000..4786e180
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -0,0 +1,106 @@
+"""Unit tests for OMNYGuiTools, ported to flomni's current set_client()/
+_is_deleted()-guard pattern. Focused on the pure-logic pieces that don't
+require a real bec_widgets/Qt GUI: the dock-reuse guard and the
+cam_xeye existence guard in omnygui_show_xeyealign() (omny's device config
+has no cam_xeye device, unlike flomni's)."""
+
+from unittest import mock
+
+from csaxs_bec.bec_ipython_client.plugins.omny.gui_tools import OMNYGuiTools
+
+# pylint: disable=protected-access
+
+GUI_TOOLS = "csaxs_bec.bec_ipython_client.plugins.omny.gui_tools"
+
+
+def _make_gui_tools():
+    tools = OMNYGuiTools()
+    client = mock.MagicMock()
+    tools.set_client(client)
+    return tools, client
+
+
+def test_omnygui_is_missing_true_when_never_set():
+    tools, _ = _make_gui_tools()
+    assert tools._omnygui_is_missing("xeyegui") is True
+
+
+def test_omnygui_is_missing_true_when_deleted():
+    tools, _ = _make_gui_tools()
+    widget = mock.MagicMock()
+    widget._is_deleted.return_value = True
+    tools.xeyegui = widget
+    assert tools._omnygui_is_missing("xeyegui") is True
+
+
+def test_omnygui_is_missing_false_for_live_widget():
+    tools, _ = _make_gui_tools()
+    widget = mock.MagicMock()
+    widget._is_deleted.return_value = False
+    tools.xeyegui = widget
+    assert tools._omnygui_is_missing("xeyegui") is False
+
+
+def test_omnygui_is_missing_false_when_no_is_deleted_method():
+    tools, _ = _make_gui_tools()
+    tools.xeyegui = object()
+    assert tools._omnygui_is_missing("xeyegui") is False
+
+
+def test_omnygui_show_xeyealign_skips_cam_xeye_when_not_configured():
+    """omny has no cam_xeye device configured -- must not raise when dev
+    lacks it, unlike flomni's unconditional live_mode_enabled toggle."""
+    tools, client = _make_gui_tools()
+    client.gui.windows = {"omny": mock.MagicMock()}
+    new_widget = mock.MagicMock()
+    client.gui.omny.new.return_value = new_widget
+
+    dev_mock = mock.MagicMock()
+    dev_mock.__contains__ = mock.Mock(return_value=False)
+
+    with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+        tools.omnygui_show_xeyealign()
+
+    new_widget.switch_tab.assert_called_once_with("alignment")
+
+
+def test_omnygui_show_xeyealign_enables_live_mode_when_cam_xeye_present():
+    tools, client = _make_gui_tools()
+    client.gui.windows = {"omny": mock.MagicMock()}
+    new_widget = mock.MagicMock()
+    client.gui.omny.new.return_value = new_widget
+
+    dev_mock = mock.MagicMock()
+    dev_mock.__contains__ = mock.Mock(return_value=True)
+    dev_mock.cam_xeye.live_mode_enabled.get.return_value = False
+
+    with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+        tools.omnygui_show_xeyealign()
+
+    dev_mock.cam_xeye.live_mode_enabled.put.assert_called_once_with(True)
+    new_widget.switch_tab.assert_called_once_with("alignment")
+
+
+def test_omnygui_show_gui_reuses_existing_window():
+    tools, client = _make_gui_tools()
+    existing_window = mock.MagicMock()
+    client.gui.windows = {"omny": existing_window}
+
+    tools.omnygui_show_gui()
+
+    client.gui.new.assert_not_called()
+    client.gui.omny.raise_window.assert_called_once()
+    assert tools.omny_window is existing_window
+
+
+def test_omnygui_show_gui_creates_new_window_when_absent():
+    tools, client = _make_gui_tools()
+    client.gui.windows = {}
+    new_window = mock.MagicMock()
+    client.gui.new.return_value = new_window
+
+    with mock.patch(f"{GUI_TOOLS}.time.sleep"):
+        tools.omnygui_show_gui()
+
+    client.gui.new.assert_called_once()
+    assert tools.omny_window is new_window