diff --git a/src/firefly/application.py b/src/firefly/application.py index f5ff9d0f..95e84b6d 100644 --- a/src/firefly/application.py +++ b/src/firefly/application.py @@ -15,12 +15,13 @@ from PyQt5.QtWidgets import QStyleFactory from qtpy import QtCore, QtWidgets from qtpy.QtCore import Signal +from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import QAction from haven import load_config from haven import load_instrument as load_haven_instrument from haven import registry -from haven.exceptions import ComponentNotFound +from haven.exceptions import ComponentNotFound, InvalidConfiguration from haven.instrument.device import titelize from .main_window import FireflyMainWindow, PlanMainWindow @@ -43,7 +44,6 @@ class FireflyApplication(PyDMApplication): default_display = None - xafs_scan_window = None # For keeping track of ophyd devices used by the Firefly registry: Registry = None @@ -91,10 +91,12 @@ class FireflyApplication(PyDMApplication): # Signals responding to queueserver changes queue_length_changed = Signal(int) queue_status_changed = Signal(dict) + queue_in_use_changed = Signal(bool) # length > 0, or running queue_environment_opened = Signal(bool) # Opened or closed queue_environment_state_changed = Signal(str) # New state queue_manager_state_changed = Signal(str) # New state queue_re_state_changed = Signal(str) # New state + queue_empty_changed = Signal(bool) # Whether queue is empty # queue_devices_changed = Signal(dict) # New list of devices # Actions for controlling the queueserver @@ -107,6 +109,7 @@ class FireflyApplication(PyDMApplication): halt_runengine_action: QAction start_queue: QAction queue_autostart_action: QAction + queue_stop_action: QAction queue_open_environment_action: QAction check_queue_status_action: QAction @@ -130,11 +133,17 @@ def __del__(self): self._queue_thread.wait(msecs=5000) assert not self._queue_thread.isRunning() - def _setup_window_action(self, action_name: str, text: str, slot: QtCore.Slot): + def _setup_window_action( + self, action_name: str, text: str, slot: QtCore.Slot, shortcut=None, icon=None + ): action = QtWidgets.QAction(self) action.setObjectName(action_name) action.setText(text) action.triggered.connect(slot) + if shortcut is not None: + action.setShortcut(QKeySequence(shortcut)) + if icon is not None: + action.setIcon(qta.icon(icon)) setattr(self, action_name, action) return action @@ -268,15 +277,15 @@ def setup_window_actions(self): ) # Actions for executing plans plans = [ - # (plan_name, text, display file) - ("count", "&Count", "count.py"), - ("move_motor", "&Move motor", "move_motor_window.py"), - ("line_scan", "&Line scan", "line_scan.py"), - ("grid_scan", "&Grid scan", "grid_scan.py"), - ("xafs_scan", "&XAFS Scan", "xafs_scan.py"), + # (plan_name, text, display file, shortcut, icon) + ("count", "&Count", "count.py", "Ctrl+Shift+C", "mdi.counter"), + ("move_motor", "&Move motor", "move_motor_window.py", None, None), + ("line_scan", "&Line scan", "line_scan.py", "Ctrl+Shift+L", None), + ("grid_scan", "&Grid scan", "grid_scan.py", None, None), + ("xafs_scan", "&XAFS Scan", "xafs_scan.py", "Ctrl+Shift+X", None), ] self.plan_actions = [] - for plan_name, text, display_file in plans: + for plan_name, text, display_file, shortcut, icon in plans: slot = partial( self.show_plan_window, name=plan_name, display_file=display_file ) @@ -286,6 +295,10 @@ def setup_window_actions(self): action.setObjectName(action_name) action.setText(text) action.triggered.connect(slot) + if shortcut is not None: + action.setShortcut(QKeySequence(shortcut)) + if icon is not None: + action.setIcon(qta.icon(icon)) self.plan_actions.append(action) # Action for showing the run browser window self._setup_window_action( @@ -298,6 +311,7 @@ def setup_window_actions(self): action_name="launch_queuemonitor_action", text="Queue Monitor", slot=self.launch_queuemonitor, + shortcut="Ctrl+Q", ) # Action for showing the beamline scheduling window self._setup_window_action( @@ -316,6 +330,7 @@ def setup_window_actions(self): action_name="show_voltmeters_window_action", text="&Voltmeters", slot=self.show_voltmeters_window, + icon="ph.faders-horizontal", ) # Launch log window self._setup_window_action( @@ -328,6 +343,7 @@ def setup_window_actions(self): action_name="show_energy_window_action", text="Energy", slot=self.show_energy_window, + shortcut="Ctrl+E", ) self.show_energy_window_action.setIcon(qta.icon("mdi.sine-wave")) # Launch camera overview @@ -356,40 +372,96 @@ def setup_runengine_actions(self): self.check_queue_status_action = QtWidgets.QAction(self) # Navbar actions for controlling the run engine actions = [ - ("pause_runengine_action", "Pause", "fa5s.stopwatch"), - ("pause_runengine_now_action", "Pause Now", "fa5s.pause"), - ("resume_runengine_action", "Resume", "fa5s.play"), - ("stop_runengine_action", "Stop", "fa5s.stop"), - ("abort_runengine_action", "Abort", "fa5s.eject"), - ("halt_runengine_action", "Halt", "fa5s.ban"), - ("start_queue_action", "Start", "fa5s.play"), + # (action_name, text, icon, shortcut, tooltip) + ( + "pause_runengine_action", + "Pause", + "fa5s.stopwatch", + "Ctrl+D", + "Pause the current plan at the next checkpoint.", + ), + ( + "pause_runengine_now_action", + "Pause Now", + "fa5s.pause", + "Ctrl+C", + "Pause the run engine now.", + ), + ( + "resume_runengine_action", + "Resume", + "fa5s.play", + None, + "Resume the previously-paused run engine at the last checkpoint.", + ), + ( + "stop_runengine_action", + "Stop", + "fa5s.stop", + None, + "End the current plan, marking as success.", + ), + ( + "abort_runengine_action", + "Abort", + "fa5s.eject", + None, + "End the current plan, marking as failure.", + ), + ( + "halt_runengine_action", + "Halt", + "fa5s.ban", + None, + "End the current plan immediately, do not clean up.", + ), + ("start_queue_action", "Start", "fa5s.play", None, "Start the queue."), ] self.queue_action_group = QtWidgets.QActionGroup(self) - for name, text, icon_name in actions: + for name, text, icon_name, shortcut, tooltip in actions: action = QtWidgets.QAction(self.queue_action_group) icon = qta.icon(icon_name) action.setText(text) - action.setCheckable(True) + action.setCheckable(False) action.setIcon(icon) + action.setToolTip(tooltip) + if shortcut is not None: + action.setShortcut(QKeySequence(shortcut)) setattr(self, name, action) # Actions that control how the queue operates actions = [ - # Attr, object name, text - ("queue_autostart_action", "queue_autostart_action", "&Autoplay"), + # Attr, object name, text, tooltip + ( + "queue_autostart_action", + "queue_autostart_action", + "&Autoplay", + "If enabled, the queue will start when items are added.", + ), + ( + "queue_stop_action", + "queue_stop_action", + "Stop Queue", + "Instruct the queue to stop after the current item is done.", + ), ( "queue_open_environment_action", "queue_open_environment_action", "&Open Environment", + "If open (checked), the queue server is able to run plans.", ), ] - for attr, obj_name, text in actions: + for attr, obj_name, text, tooltip in actions: action = QAction() action.setObjectName(obj_name) action.setText(text) + action.setToolTip(tooltip) setattr(self, attr, action) # Customize some specific actions self.queue_autostart_action.setCheckable(True) - self.queue_autostart_action.setChecked(True) + self.queue_stop_action.setCheckable(True) + self.queue_stop_action.setToolTip( + "Tell the queueserver to stop after this current plan is done." + ) self.queue_open_environment_action.setCheckable(True) def _prepare_device_windows( @@ -479,7 +551,7 @@ def _prepare_device_windows( action.triggered.connect(slot) window_slots.append(slot) - def prepare_queue_client(self, api=None): + def prepare_queue_client(self, client=None, api=None): """Set up the QueueClient object that talks to the queue server. Parameters @@ -488,10 +560,19 @@ def prepare_queue_client(self, api=None): queueserver API. Used for testing. """ + # Load the API for controlling the queueserver. if api is None: - api = queueserver_api() + try: + api = queueserver_api() + except InvalidConfiguration: + log.error( + "Could not load queueserver API " + "configuration from iconfig.toml file." + ) + return # Create the client object - client = QueueClient(api=api) + if client is None: + client = QueueClient(api=api) self.queue_open_environment_action.triggered.connect(client.open_environment) self._queue_client = client # Connect actions to slots for controlling the queueserver @@ -502,6 +583,11 @@ def prepare_queue_client(self, api=None): partial(client.request_pause, defer=False) ) self.start_queue_action.triggered.connect(client.start_queue) + self.resume_runengine_action.triggered.connect(client.resume_runengine) + self.stop_runengine_action.triggered.connect(client.stop_runengine) + self.halt_runengine_action.triggered.connect(client.halt_runengine) + self.abort_runengine_action.triggered.connect(client.abort_runengine) + self.queue_stop_action.triggered.connect(client.stop_queue) self.check_queue_status_action.triggered.connect( partial(client.check_queue_status, True) ) @@ -510,11 +596,14 @@ def prepare_queue_client(self, api=None): # Connect signals/slots for queueserver state changes client.status_changed.connect(self.queue_status_changed) client.length_changed.connect(self.queue_length_changed) + client.in_use_changed.connect(self.queue_in_use_changed) + client.autostart_changed.connect(self.queue_autostart_action.setChecked) client.environment_opened.connect(self.queue_environment_opened) self.queue_environment_opened.connect(self.set_open_environment_action_state) client.environment_state_changed.connect(self.queue_environment_state_changed) client.manager_state_changed.connect(self.queue_manager_state_changed) client.re_state_changed.connect(self.queue_re_state_changed) + client.queue_stop_changed.connect(self.queue_stop_action.setChecked) client.devices_changed.connect(self.update_devices_allowed) self.queue_autostart_action.toggled.connect( self.check_queue_status_action.trigger @@ -660,9 +749,7 @@ def show_device_window( def show_status_window(self, stylesheet_path=None): """Instantiate a new main window for this application.""" - self.show_window( - FireflyMainWindow, ui_dir / "status.py", name="beamline_status" - ) + self.show_window(PlanMainWindow, ui_dir / "status.py", name="beamline_status") @QtCore.Slot() def show_run_browser_window(self): diff --git a/src/firefly/launcher.py b/src/firefly/launcher.py index e940c606..47a5285b 100644 --- a/src/firefly/launcher.py +++ b/src/firefly/launcher.py @@ -182,7 +182,7 @@ def main(default_fullscreen=False, default_display="status"): asyncio.set_event_loop(event_loop) # Define devices on the beamline (slow!) - app.setup_instrument() + app.setup_instrument(load_instrument=not pydm_args.no_instrument) # Get rid of the splash screen now that we're ready to go diff --git a/src/firefly/main_window.py b/src/firefly/main_window.py index 7f043ec9..d35abd49 100644 --- a/src/firefly/main_window.py +++ b/src/firefly/main_window.py @@ -5,7 +5,7 @@ import qtawesome as qta from pydm import data_plugins from pydm.main_window import PyDMMainWindow -from qtpy import QtCore, QtGui, QtWidgets +from qtpy import QtGui, QtWidgets from haven import load_config @@ -93,6 +93,12 @@ def customize_ui(self): _label = QtWidgets.QLabel() _label.setText("Queue:") bar.addPermanentWidget(_label) + self.ui.queue_length_label = QtWidgets.QLabel() + self.ui.queue_length_label.setToolTip( + "The length of the queue, not including the running plan." + ) + self.ui.queue_length_label.setText("(??)") + bar.addPermanentWidget(self.ui.queue_length_label) self.ui.environment_label = QtWidgets.QLabel() self.ui.environment_label.setToolTip( "The current state of the queue server environment." @@ -108,6 +114,7 @@ def customize_ui(self): bar.addPermanentWidget(self.ui.re_label) # Connect signals to the status bar app.queue_environment_state_changed.connect(self.ui.environment_label.setText) + app.queue_length_changed.connect(self.update_queue_length) app.queue_re_state_changed.connect(self.ui.re_label.setText) # Log viewer window if hasattr(app, "show_logs_window_action"): @@ -124,6 +131,7 @@ def customize_ui(self): self.ui.menubar.addAction(self.ui.queue_menu.menuAction()) for action in app.queue_action_group.actions(): self.ui.queue_menu.addAction(action) + self.ui.queue_menu.addAction(app.queue_stop_action) self.ui.queue_menu.addSeparator() # Queue settings for the queue client self.ui.queue_menu.addAction(app.launch_queuemonitor_action) @@ -221,6 +229,9 @@ def customize_ui(self): self.ui.menuView.addAction(app.show_status_window_action) self.ui.menuSetup.addAction(app.show_bss_window_action) self.ui.menuSetup.addAction(app.show_iocs_window_action) + # Make tooltips show up for menu actions + for menu in [self.ui.menuSetup, self.ui.detectors_menu, self.ui.queue_menu]: + menu.setToolTipsVisible(True) def show_status(self, message, timeout=0): """Show a message in the status bar.""" @@ -240,6 +251,9 @@ def update_window_title(self): title += " [Read Only Mode]" self.setWindowTitle(title) + def update_queue_length(self, new_length: int): + self.ui.queue_length_label.setText(f"({new_length})") + class PlanMainWindow(FireflyMainWindow): """A Qt window that has extra controls for a bluesky runengine.""" @@ -263,7 +277,7 @@ def setup_navbar(self): navbar.addAction(app.resume_runengine_action) navbar.addAction(app.stop_runengine_action) navbar.addAction(app.abort_runengine_action) - navbar.addAction(app.halt_runengine_action) + # navbar.addAction(app.halt_runengine_action) def customize_ui(self): super().customize_ui() @@ -272,14 +286,7 @@ def customize_ui(self): from .application import FireflyApplication app = FireflyApplication.instance() - app.queue_length_changed.connect(self.set_navbar_visibility) - - @QtCore.Slot(int) - def set_navbar_visibility(self, queue_length: int): - """Determine whether to make the navbar be visible.""" - log.debug(f"Setting navbar visibility. Queue length: {queue_length}") - navbar = self.ui.navbar - navbar.setVisible(queue_length > 0) + app.queue_in_use_changed.connect(self.ui.navbar.setVisible) # ----------------------------------------------------------------------------- diff --git a/src/firefly/plans/line_scan.py b/src/firefly/plans/line_scan.py index e837071a..b7ec2c39 100644 --- a/src/firefly/plans/line_scan.py +++ b/src/firefly/plans/line_scan.py @@ -48,7 +48,7 @@ def customize_ui(self): self.ui.num_motor_spin_box.lineEdit().setReadOnly(True) self.ui.num_motor_spin_box.valueChanged.connect(self.update_regions) - self.ui.run_button.setEnabled(True) # for testing + # Connect signals for executing the plan self.ui.run_button.clicked.connect(self.queue_plan) # when selections of detectors changed update_total_time diff --git a/src/firefly/plans/move_motor_window.py b/src/firefly/plans/move_motor_window.py index 866cb75b..1ddbfbfd 100644 --- a/src/firefly/plans/move_motor_window.py +++ b/src/firefly/plans/move_motor_window.py @@ -41,7 +41,6 @@ def customize_ui(self): self.ui.num_motor_spin_box.lineEdit().setReadOnly(True) self.ui.num_motor_spin_box.valueChanged.connect(self.update_regions) - self.ui.run_button.setEnabled(True) # for testing self.ui.run_button.clicked.connect(self.queue_plan) def time_converter(self, total_seconds): diff --git a/src/firefly/plans/xafs_scan.py b/src/firefly/plans/xafs_scan.py index 4adc6aa0..452f28a1 100644 --- a/src/firefly/plans/xafs_scan.py +++ b/src/firefly/plans/xafs_scan.py @@ -257,7 +257,6 @@ def customize_ui(self): self.ui.pushButton.clicked.connect(self.reset_default_regions) # Button to actually execute the plan - self.ui.run_button.setEnabled(True) self.ui.run_button.clicked.connect(self.queue_plan) # connect checkboxes with all regions' check box diff --git a/src/firefly/queue_button.py b/src/firefly/queue_button.py index 2a5f23ef..75beb914 100644 --- a/src/firefly/queue_button.py +++ b/src/firefly/queue_button.py @@ -2,10 +2,16 @@ import qtawesome as qta from qtpy import QtGui, QtWidgets +from strenum import StrEnum from firefly import FireflyApplication +class Colors(StrEnum): + ADD_TO_QUEUE = "rgb(0, 123, 255)" + RUN_QUEUE = "rgb(25, 135, 84)" + + class QueueButton(QtWidgets.QPushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -22,11 +28,10 @@ def handle_queue_status_change(self, status: dict): # Should be disabled because the queue is closed self.setDisabled(True) # Coloration for the whether the item would get run immediately - app = FireflyApplication.instance() - if status["re_state"] == "idle" and app.queue_autostart_action.isChecked(): + if status["re_state"] == "idle" and status["queue_autostart_enabled"]: # Will play immediately self.setStyleSheet( - "background-color: rgb(25, 135, 84);\nborder-color: rgb(25, 135, 84);" + f"background-color: {Colors.RUN_QUEUE};\nborder-color: {Colors.RUN_QUEUE};" ) self.setIcon(qta.icon("fa5s.play")) self.setText("Run") @@ -34,7 +39,7 @@ def handle_queue_status_change(self, status: dict): elif status["worker_environment_exists"]: # Will be added to the queue self.setStyleSheet( - "background-color: rgb(0, 123, 255);\nborder-color: rgb(0, 123, 255);" + f"background-color: {Colors.ADD_TO_QUEUE};\nborder-color: {Colors.ADD_TO_QUEUE};" ) self.setIcon(qta.icon("fa5s.list")) self.setText("Add to Queue") diff --git a/src/firefly/queue_client.py b/src/firefly/queue_client.py index f3a41e96..0213b03d 100644 --- a/src/firefly/queue_client.py +++ b/src/firefly/queue_client.py @@ -1,7 +1,7 @@ import logging import time import warnings -from typing import Optional +from typing import Mapping, Optional from bluesky_queueserver_api import comm_base from bluesky_queueserver_api.zmq.aio import REManagerAPI @@ -9,12 +9,18 @@ from qtpy.QtCore import QObject, QTimer, Signal from haven import load_config +from haven.exceptions import InvalidConfiguration log = logging.getLogger() def queueserver_api(): - config = load_config()["queueserver"] + try: + config = load_config()["queueserver"] + except KeyError: + raise InvalidConfiguration( + "Could not load queueserver info from iconfig.toml file." + ) ctrl_addr = f"tcp://{config['control_host']}:{config['control_port']}" info_addr = f"tcp://{config['info_host']}:{config['info_port']}" api = REManagerAPI(zmq_control_addr=ctrl_addr, zmq_info_addr=info_addr) @@ -31,6 +37,9 @@ class QueueClient(QObject): # Signals responding to queue changes status_changed = Signal(dict) length_changed = Signal(int) + in_use_changed = Signal(bool) # If length > 0, or queue is running + autostart_changed = Signal(bool) + queue_stop_changed = Signal(bool) # If a queue stop has been requested environment_opened = Signal(bool) # Opened (True) or closed (False) environment_state_changed = Signal(str) # New state manager_state_changed = Signal(str) # New state @@ -100,29 +109,83 @@ async def request_pause(self, defer: bool = True): @asyncSlot(object) async def add_queue_item(self, item): log.info(f"Client adding item to queue: {item}") - result = await self.api.item_add(item=item) - if result["success"]: - log.info(f"Item added. New queue length: {result['qsize']}") - new_length = result["qsize"] - self.length_changed.emit(result["qsize"]) + try: + result = await self.api.item_add(item=item) + self.check_result(result) + except (RuntimeError, comm_base.RequestFailedError): + # Request failed, so force a UI update + await self.check_queue_status(force=True) else: - log.error(f"Did not add queue item to queue: {result}") - raise RuntimeError(result) + await self.check_queue_status(force=False) @asyncSlot(bool) async def toggle_autostart(self, enable: bool): - print(f"Toggling auto-start: {enable}") - await self.api.queue_autostart(enable) + log.debug(f"Toggling auto-start: {enable}") + try: + result = await self.api.queue_autostart(enable) + self.check_result(result, task="toggle auto-start") + except (RuntimeError, comm_base.RequestFailedError): + # Request failed, so force a UI update + await self.check_queue_status(force=True) + else: + await self.check_queue_status(force=False) + + @asyncSlot(bool) + async def stop_queue(self, stop: bool): + """Turn on/off whether the queue will stop after the current plan.""" + # Determine which call to usee + if stop: + api_call = self.api.queue_stop() + else: + api_call = self.api.queue_stop_cancel() + # Execute the call + try: + result = await api_call + self.check_result(result, task="toggle stop queue") + except (RuntimeError, comm_base.RequestFailedError): + # Request failed, so force a UI update + await self.check_queue_status(force=True) + else: + await self.check_queue_status(force=False) @asyncSlot() async def start_queue(self): result = await self.api.queue_start() + self.check_result(result, task="start queue") + + @asyncSlot() + async def resume_runengine(self): + result = await self.api.re_resume() + self.check_result(result, task="resume run engine") + + @asyncSlot() + async def stop_runengine(self): + result = await self.api.re_stop() + self.check_result(result, task="stop run engine") + + @asyncSlot() + async def abort_runengine(self): + result = await self.api.re_abort() + self.check_result(result, task="abort run engine") + + @asyncSlot() + async def halt_runengine(self): + result = await self.api.re_halt() + self.check_result(result, task="halt run engine") + + def check_result(self, result: Mapping, task: str = "control queue server"): + """Send the result of an API call to the correct logger. + + Expects *result* to have at least the "success" key. + + """ # Report results if result["success"] is True: - log.debug(f"Started queue server: {result}") + log.debug(f"{task}: {result}") else: - log.error(f"Failed to start queue server: {result}") - raise RuntimeError(result) + msg = f"Failed to {task}: {result}" + log.error(msg) + raise RuntimeError(msg) @asyncSlot() async def check_queue_status(self, force=False, *args, **kwargs): @@ -169,6 +232,15 @@ async def _check_queue_status(self, force: bool = False): """ new_status = await self.api.status() + # Add a new key for whether the queue is busy (length > 0 or running) + has_queue = new_status["items_in_queue"] > 0 + is_running = new_status["manager_state"] in [ + "paused", + "starting_queue", + "executing_queue", + "executing_task", + ] + new_status.setdefault("in_use", has_queue or is_running) # Check individual components of the status if they've changed signals_to_check = [ # (status key, signal to emit) @@ -176,6 +248,10 @@ async def _check_queue_status(self, force: bool = False): ("worker_environment_state", self.environment_state_changed), ("manager_state", self.manager_state_changed), ("re_state", self.re_state_changed), + ("items_in_queue", self.length_changed), + ("in_use", self.in_use_changed), + ("queue_stop_pending", self.queue_stop_changed), + ("queue_autostart_enabled", self.autostart_changed), ] if force: log.debug(f"Forcing queue server status update: {new_status}") diff --git a/src/firefly/tests/test_main_window.py b/src/firefly/tests/test_main_window.py index 654b1868..50b77445 100644 --- a/src/firefly/tests/test_main_window.py +++ b/src/firefly/tests/test_main_window.py @@ -21,12 +21,12 @@ def test_navbar_autohide(ffapp, qtbot): window.show() navbar = window.ui.navbar # Pretend the queue has some things in it - with qtbot.waitSignal(ffapp.queue_length_changed): - ffapp.queue_length_changed.emit(3) + with qtbot.waitSignal(ffapp.queue_in_use_changed): + ffapp.queue_in_use_changed.emit(True) assert navbar.isVisible() # Make the queue be empty - with qtbot.waitSignal(ffapp.queue_length_changed): - ffapp.queue_length_changed.emit(0) + with qtbot.waitSignal(ffapp.queue_in_use_changed): + ffapp.queue_in_use_changed.emit(False) assert not navbar.isVisible() diff --git a/src/firefly/tests/test_queue_button.py b/src/firefly/tests/test_queue_button.py index 725d2c97..6eac47cd 100644 --- a/src/firefly/tests/test_queue_button.py +++ b/src/firefly/tests/test_queue_button.py @@ -1,31 +1,44 @@ -from firefly.queue_button import QueueButton +from firefly.queue_button import Colors, QueueButton def test_queue_button_style(ffapp): - """Does the queue button change color/icon based.""" + """Does the queue button change color/icon based on the queue state.""" btn = QueueButton() # Initial style should be disabled and plain assert not btn.isEnabled() assert btn.styleSheet() == "" - # State when queue server is open and idle + # State when queue server is open and idle (no autostart) + queue_state = { + "worker_environment_exists": True, + "items_in_queue": 0, + "re_state": "idle", + "queue_autostart_enabled": False, + } + ffapp.queue_status_changed.emit(queue_state) + assert btn.isEnabled() + assert Colors.ADD_TO_QUEUE in btn.styleSheet() + assert btn.text() == "Add to Queue" + # State when queue server is open and idle (w/ autostart) queue_state = { "worker_environment_exists": True, "items_in_queue": 0, "re_state": "idle", + "queue_autostart_enabled": True, } ffapp.queue_status_changed.emit(queue_state) assert btn.isEnabled() - assert "rgb(25, 135, 84)" in btn.styleSheet() + assert Colors.RUN_QUEUE in btn.styleSheet() assert btn.text() == "Run" # State when queue server is open and idle queue_state = { "worker_environment_exists": True, "items_in_queue": 0, "re_state": "running", + "queue_autostart_enabled": True, } ffapp.queue_status_changed.emit(queue_state) assert btn.isEnabled() - assert "rgb(0, 123, 255)" in btn.styleSheet() + assert Colors.ADD_TO_QUEUE in btn.styleSheet() assert btn.text() == "Add to Queue" diff --git a/src/firefly/tests/test_queue_client.py b/src/firefly/tests/test_queue_client.py index c16c62b4..ddd20179 100644 --- a/src/firefly/tests/test_queue_client.py +++ b/src/firefly/tests/test_queue_client.py @@ -16,6 +16,7 @@ "running_item_uid": None, "manager_state": "idle", "queue_stop_pending": False, + "queue_autostart_enabled": False, "worker_environment_exists": False, "worker_environment_state": "closed", "worker_background_tasks": 0, @@ -218,9 +219,24 @@ def client(): api.queue_start.return_value = { "success": True, } + api.re_resume.return_value = { + "success": True, + } + api.re_stop.return_value = { + "success": True, + } + api.re_abort.return_value = { + "success": True, + } + api.re_halt.return_value = { + "success": True, + } api.devices_allowed.return_value = {"success": True, "devices_allowed": {}} api.environment_open.return_value = {"success": True} api.environment_close.return_value = {"success": True} + api.queue_autostart.return_value = {"success": True} + api.queue_stop.return_value = {"success": True} + api.queue_stop_cancel.return_value = {"success": True} # Create the client using the fake API autoplay_action = QAction() autoplay_action.setCheckable(True) @@ -230,6 +246,12 @@ def client(): yield client +@pytest.fixture() +def ffapp(ffapp, client): + ffapp.prepare_queue_client(api=client.api, client=client) + return ffapp + + def test_client_timer(client): assert isinstance(client.timer, QTimer) @@ -252,6 +274,22 @@ async def test_queue_re_control(client): await client.start_queue() # Check if the queue started api.queue_start.assert_called_once() + # Resume a paused queue + api.reset_mock() + await client.resume_runengine() + api.re_resume.assert_called_once() + # Stop a paused queue + api.reset_mock() + await client.stop_runengine() + api.re_stop.assert_called_once() + # Abort a paused queue + api.reset_mock() + await client.abort_runengine() + api.re_abort.assert_called_once() + # Halt a paused queue + api.reset_mock() + await client.halt_runengine() + api.re_halt.assert_called_once() @pytest.mark.asyncio @@ -259,6 +297,9 @@ async def test_run_plan(client, qtbot): """Test if a plan can be queued in the queueserver.""" api = client.api api.item_add.return_value = {"success": True, "qsize": 2} + new_status = qs_status.copy() + new_status["items_in_queue"] = 2 + api.status.return_value = new_status # Send a plan with qtbot.waitSignal( client.length_changed, timeout=1000, check_params_cb=lambda l: l == 2 @@ -269,7 +310,7 @@ async def test_run_plan(client, qtbot): @pytest.mark.asyncio -async def test_autoplay(client, qtbot): +async def test_toggle_autostart(client, qtbot): """Test how queuing a plan starts the runengine.""" api = client.api # Check that it doesn't start the queue if the autoplay action is off @@ -279,6 +320,51 @@ async def test_autoplay(client, qtbot): api.queue_autostart.assert_called_once_with(True) +def test_autostart_changed(client, ffapp, qtbot): + """Does the action respond to changes in the queue autostart + status? + + """ + ffapp.queue_autostart_action.setChecked(True) + assert ffapp.queue_autostart_action.isChecked() + with qtbot.waitSignal(client.autostart_changed, timeout=3): + client.autostart_changed.emit(False) + assert not ffapp.queue_autostart_action.isChecked() + with qtbot.waitSignal(client.autostart_changed, timeout=3): + client.autostart_changed.emit(True) + assert ffapp.queue_autostart_action.isChecked() + + +# def test_start_queue(ffapp, client, qtbot): +# ffapp.start_queue_action.trigger() +# qtbot.wait(1000) +# client.api.queue_start.assert_called_once() + + +@pytest.mark.asyncio +async def test_stop_queue(client, qtbot): + """Test how queuing a plan starts the runengine.""" + api = client.api + # Check that it doesn't start the queue if the autoplay action is off + assert not api.queue_autostart.called + # Check the queue stop was requested + await client.stop_queue(True) + api.queue_stop.assert_called_once() + # Check the queue stop can be cancelled + api.clear_mock() + await client.stop_queue(False) + api.queue_stop_cancel.assert_called_once() + + +def test_queue_stopped(client, ffapp): + """Does the action respond to changes in the queue stopped pending?""" + assert not ffapp.queue_stop_action.isChecked() + client.queue_stop_changed.emit(True) + assert ffapp.queue_stop_action.isChecked() + client.queue_stop_changed.emit(False) + assert not ffapp.queue_stop_action.isChecked() + + @pytest.mark.asyncio async def test_check_queue_status(client, qtbot): # Check that the queue length is changed @@ -287,7 +373,9 @@ async def test_check_queue_status(client, qtbot): client.environment_opened, client.environment_state_changed, client.re_state_changed, + client.autostart_changed, client.manager_state_changed, + client.in_use_changed, ] with qtbot.waitSignals(signals): await client.check_queue_status() diff --git a/src/firefly/tests/test_xafs_scan.py b/src/firefly/tests/test_xafs_scan.py index 07bd852d..56d35214 100644 --- a/src/firefly/tests/test_xafs_scan.py +++ b/src/firefly/tests/test_xafs_scan.py @@ -155,6 +155,7 @@ def check_item(item): return True # Click the run button and see if the plan is queued + display.ui.run_button.setEnabled(True) with qtbot.waitSignal( ffapp.queue_item_added, timeout=1000, check_params_cb=check_item ): @@ -262,6 +263,7 @@ def check_item(item): return True # Click the run button and see if the plan is queued + display.ui.run_button.setEnabled(True) with qtbot.waitSignal( ffapp.queue_item_added, timeout=1000, check_params_cb=check_item ): diff --git a/src/haven/exceptions.py b/src/haven/exceptions.py index f11fd642..0e05b443 100644 --- a/src/haven/exceptions.py +++ b/src/haven/exceptions.py @@ -17,24 +17,6 @@ class GainOverflow(RuntimeError): ... -# class ComponentNotFound(IndexError): -# """Registry looked for a component, but it wasn't registered.""" - -# ... - - -# class MultipleComponentsFound(IndexError): -# """Registry looked for a single component, but found more than one.""" - -# ... - - -# class InvalidComponentLabel(TypeError): -# """Registry looked for a component, but the label provided is not vlaid.""" - -# ... - - class FileNotWritable(IOError): """Output file is available but does not have write intent.""" @@ -71,7 +53,13 @@ class IOCTimeout(RuntimeError): ... -class UnknownDeviceConfiguration(ValueError): +class InvalidConfiguration(ValueError): + """The configuration files for Haven are missing keys.""" + + ... + + +class UnknownDeviceConfiguration(InvalidConfiguration): """The configuration for a device does not match the known options.""" ... diff --git a/src/haven/instrument/ion_chamber.py b/src/haven/instrument/ion_chamber.py index 356286ad..65efd05b 100644 --- a/src/haven/instrument/ion_chamber.py +++ b/src/haven/instrument/ion_chamber.py @@ -620,7 +620,7 @@ async def make_ion_chamber_device( ch_num=ch_num, name=name, preamp_prefix=preamp_prefix, - labels={"ion_chambers"}, + labels={"ion_chambers", "detectors"}, ) try: await await_for_connection(ic) @@ -733,7 +733,7 @@ def load_ion_chambers(config=None): name=defn["name"], preamp_prefix=defn["preamp_prefix"], voltmeter_prefix=defn["voltmeter_prefix"], - labels={"ion_chambers", defn["section"]}, + labels={"ion_chambers", defn["section"], "detectors"}, counts_per_volt_second=defn["counts_per_volt_second"], ) ) diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index aec3761c..984dbb15 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -72,7 +72,7 @@ def load_motors( prefix = config["prefix"] num_motors = config["num_motors"] log.info( - f"Preparing {num_motors} motors from IOC: " "{section_name} ({prefix})" + f"Preparing {num_motors} motors from IOC: " f"{section_name} ({prefix})" ) for idx in range(num_motors): motor_prefix = f"{prefix}m{idx+1}"