From 61830da1939423fdc166eb506005e6c5d9aaad19 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:02:19 -0700 Subject: [PATCH 01/30] move pygame functionality to dedicated module bapsf_motion.gui.configure.pygame_ --- .../gui/configure/motion_group_widget.py | 165 +--------------- bapsf_motion/gui/configure/pygame_.py | 180 ++++++++++++++++++ 2 files changed, 181 insertions(+), 164 deletions(-) create mode 100644 bapsf_motion/gui/configure/pygame_.py diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 962da994..d83acce5 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -20,8 +20,6 @@ from abc import abstractmethod from PySide6.QtCore import ( - QObject, - QRunnable, QSize, Qt, QThreadPool, @@ -55,6 +53,7 @@ from bapsf_motion.gui.configure.message_boxes import WarningMessageBox from bapsf_motion.gui.configure.motion_builder_overlay import MotionBuilderConfigOverlay from bapsf_motion.gui.configure.motion_space_display import MotionSpaceDisplay +from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner from bapsf_motion.gui.configure.transform_overlay import TransformConfigOverlay from bapsf_motion.gui.icons import icon_name_dict from bapsf_motion.gui.widgets import ( @@ -228,168 +227,6 @@ def exec(self) -> bool: return True -class PyGameJoystickRunnerSignals(QObject): - buttonPressed = Signal(int) - hatPressed = Signal(int, int) - axisMoved = Signal(int, float) - joystickConnected = Signal(bool) - shutdownLoop = Signal() - stopMovement = Signal() - - -class PyGameJoystickRunner(QRunnable): - # signals must be patterned in separate class, otherwise we can not - # connect the signals in out __init__ - signals = PyGameJoystickRunnerSignals() - - def __init__(self, joystick: pygame.joystick.JoystickType): - super().__init__() - - self._logger = gui_logger - self._axis_dead_zone = 0.25 - self._run_loop = False - - # Re-instantiate the joystick since the given joystick was probably - # instantiated in a different thread. - self._joystick = joystick - - self.signals.shutdownLoop.connect(self.run_shutdown) - - def run(self) -> None: - self.logger.info("Starting PyGame Joystick runner") - - if not pygame.get_init(): - pygame.init() - - if not pygame.joystick.get_init(): - pygame.joystick.init() - - js = self.joystick - if not isinstance(self.joystick, pygame.joystick.JoystickType): - pygame.quit() - return - - js.init() - self.run_loop = js.get_init() - self.signals.joystickConnected.emit(self.run_loop) - - clock = pygame.time.Clock() - screen = pygame.display.set_mode((100, 100), flags=pygame.HIDDEN) - - # pygame while loop - # - joystick events - # https://www.pygame.org/docs/ref/event.html - # - # JOYAXISMOTION - # JOYBALLMOTION - # JOYHATMOTION - # JOYBUTTONUP - # JOYBUTTONDOWN - # JOYDEVICEADDED - # JOYDEVICEREMOVED - # - # _joy_axis_values = {} - while self.run_loop: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - self.run_loop = False - elif event.type == pygame.JOYBUTTONDOWN: - self.signals.buttonPressed.emit(event.dict["button"]) - - # TODO: add an immediate caller to handle emergency - # stop scenarios - elif event.type == pygame.JOYHATMOTION: - value = event.dict["value"] - axis_id = 0 if value[0] != 0 else 1 - direction = value[axis_id] - self.signals.hatPressed.emit(axis_id, direction) - - elif event.type == pygame.JOYAXISMOTION: - jaxis = event.dict["axis"] - value = event.dict["value"] - - if np.abs(value) <= self.axis_dead_zone: - continue - - value2 = self.joystick.get_axis(jaxis) - if np.abs(value2) - np.abs(value) < -0.01: - # joystick is moving back towards the neutral position - value = 0.0 - - self.signals.axisMoved.emit(jaxis, value) - - # self.logger.info( - # f"PyGame event {event.type} - Data = {event.dict}." - # ) - - clock.tick(20) - - self.logger.info("PyGame loop ended.") - self.run_shutdown() - - @Slot() - def run_shutdown(self): - self.signals.stopMovement.emit() - - if self.run_loop: - self.quit() - self.signals.shutdownLoop.emit() - return - - try: - pygame.quit() - except pygame.error as err: - self.logger.warning( - "The pygame event loop did not safely shut down and was " - "forced to shut down.", - exc_info=err, - ) - - self.signals.joystickConnected.emit(self.run_loop) - - @property - def axis_dead_zone(self) -> float: - return self._axis_dead_zone - - @axis_dead_zone.setter - def axis_dead_zone(self, value: float) -> None: - try: - value = float(value) - except TypeError: - return - - if -1.0 >= value >= 1.0: - self._axis_dead_zone = np.absolute(value) - - @property - def joystick(self) -> pygame.joystick.JoystickType: - return self._joystick - - @property - def logger(self) -> logging.Logger: - return self._logger - - @property - def run_loop(self) -> bool: - return self._run_loop - - @run_loop.setter - def run_loop(self, value: bool) -> None: - if isinstance(value, bool): - self._run_loop = value - - def set_immediate_handler(self, func, event_type): ... - - def quit(self) -> None: - if pygame.get_init(): - pygame.joystick.quit() - pygame.event.clear() - pygame.event.post(pygame.event.Event(pygame.QUIT)) - self.run_loop = False - - self.signals.joystickConnected.emit(self.run_loop) - - class AxisControlWidget(QWidget): axisLinked = Signal() axisUnlinked = Signal() diff --git a/bapsf_motion/gui/configure/pygame_.py b/bapsf_motion/gui/configure/pygame_.py new file mode 100644 index 00000000..7c7b9cb5 --- /dev/null +++ b/bapsf_motion/gui/configure/pygame_.py @@ -0,0 +1,180 @@ +import logging +import numpy as np +import os + +# ensure joystick events are monitored when the pygame window +# is not in focus ... this needs to be done before importing pygame +os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" + +import pygame # noqa + +from PySide6.QtCore import ( + QObject, + QRunnable, + Signal, + Slot, +) + +from bapsf_motion.gui.configure.helpers import gui_logger + + +class PyGameJoystickRunnerSignals(QObject): + buttonPressed = Signal(int) + hatPressed = Signal(int, int) + axisMoved = Signal(int, float) + joystickConnected = Signal(bool) + shutdownLoop = Signal() + stopMovement = Signal() + + +class PyGameJoystickRunner(QRunnable): + # signals must be patterned in separate class, otherwise we can not + # connect the signals in out __init__ + signals = PyGameJoystickRunnerSignals() + + def __init__(self, joystick: pygame.joystick.JoystickType): + super().__init__() + + self._logger = gui_logger + self._axis_dead_zone = 0.25 + self._run_loop = False + + # Re-instantiate the joystick since the given joystick was probably + # instantiated in a different thread. + self._joystick = joystick + + self.signals.shutdownLoop.connect(self.run_shutdown) + + def run(self) -> None: + self.logger.info("Starting PyGame Joystick runner") + + if not pygame.get_init(): + pygame.init() + + if not pygame.joystick.get_init(): + pygame.joystick.init() + + js = self.joystick + if not isinstance(self.joystick, pygame.joystick.JoystickType): + pygame.quit() + return + + js.init() + self.run_loop = js.get_init() + self.signals.joystickConnected.emit(self.run_loop) + + clock = pygame.time.Clock() + screen = pygame.display.set_mode((100, 100), flags=pygame.HIDDEN) + + # pygame while loop + # - joystick events + # https://www.pygame.org/docs/ref/event.html + # + # JOYAXISMOTION + # JOYBALLMOTION + # JOYHATMOTION + # JOYBUTTONUP + # JOYBUTTONDOWN + # JOYDEVICEADDED + # JOYDEVICEREMOVED + # + # _joy_axis_values = {} + while self.run_loop: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.run_loop = False + elif event.type == pygame.JOYBUTTONDOWN: + self.signals.buttonPressed.emit(event.dict["button"]) + + # TODO: add an immediate caller to handle emergency + # stop scenarios + elif event.type == pygame.JOYHATMOTION: + value = event.dict["value"] + axis_id = 0 if value[0] != 0 else 1 + direction = value[axis_id] + self.signals.hatPressed.emit(axis_id, direction) + + elif event.type == pygame.JOYAXISMOTION: + jaxis = event.dict["axis"] + value = event.dict["value"] + + if np.abs(value) <= self.axis_dead_zone: + continue + + value2 = self.joystick.get_axis(jaxis) + if np.abs(value2) - np.abs(value) < -0.01: + # joystick is moving back towards the neutral position + value = 0.0 + + self.signals.axisMoved.emit(jaxis, value) + + # self.logger.info( + # f"PyGame event {event.type} - Data = {event.dict}." + # ) + + clock.tick(20) + + self.logger.info("PyGame loop ended.") + self.run_shutdown() + + @Slot() + def run_shutdown(self): + self.signals.stopMovement.emit() + + if self.run_loop: + self.quit() + self.signals.shutdownLoop.emit() + return + + try: + pygame.quit() + except pygame.error as err: + self.logger.warning( + "The pygame event loop did not safely shut down and was " + "forced to shut down.", + exc_info=err, + ) + + self.signals.joystickConnected.emit(self.run_loop) + + @property + def axis_dead_zone(self) -> float: + return self._axis_dead_zone + + @axis_dead_zone.setter + def axis_dead_zone(self, value: float) -> None: + try: + value = float(value) + except TypeError: + return + + if -1.0 >= value >= 1.0: + self._axis_dead_zone = np.absolute(value) + + @property + def joystick(self) -> pygame.joystick.JoystickType: + return self._joystick + + @property + def logger(self) -> logging.Logger: + return self._logger + + @property + def run_loop(self) -> bool: + return self._run_loop + + @run_loop.setter + def run_loop(self, value: bool) -> None: + if isinstance(value, bool): + self._run_loop = value + + def set_immediate_handler(self, func, event_type): ... + + def quit(self) -> None: + if pygame.get_init(): + pygame.joystick.quit() + pygame.event.clear() + pygame.event.post(pygame.event.Event(pygame.QUIT)) + self.run_loop = False + + self.signals.joystickConnected.emit(self.run_loop) From b534c2253a5a4689b79f680660c87e3e4afd537b Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:03:30 -0700 Subject: [PATCH 02/30] add docstring --- bapsf_motion/gui/configure/pygame_.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bapsf_motion/gui/configure/pygame_.py b/bapsf_motion/gui/configure/pygame_.py index 7c7b9cb5..df16174b 100644 --- a/bapsf_motion/gui/configure/pygame_.py +++ b/bapsf_motion/gui/configure/pygame_.py @@ -1,3 +1,8 @@ +""" +Module contains functionality related to interfacing with `pygame-ce` +joysticks. +""" + import logging import numpy as np import os From 3fa100063d0c63f383b09547b0fc8c063f48f2a3 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:07:27 -0700 Subject: [PATCH 03/30] move MSpaceMessageBox to bapsf_motion.gui.configure.message_boxes --- bapsf_motion/gui/configure/message_boxes.py | 76 ++++++++++++++++++- .../gui/configure/motion_group_widget.py | 70 +---------------- 2 files changed, 75 insertions(+), 71 deletions(-) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index 25728965..c32d0aa1 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -1,8 +1,12 @@ -__all__ = ["WarningMessageBox"] +__all__ = [ + "WarningMessageBox", + "MSpaceMessageBox", +] from pathlib import Path +from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QDialog, QMessageBox, QWidget +from PySide6.QtWidgets import QDialog, QMessageBox, QWidget, QCheckBox from typing import Union _HERE = Path(__file__).parent @@ -102,3 +106,71 @@ def exec(self, /) -> int: return self._acknowledge_exec() return self._approve_exec() + + +class MSpaceMessageBox(QMessageBox): + """ + Modal warning dialog box to warn the user the motion space has yet + to be defined. Thus, there are no restrictions on probe drive + movement, and it is up to the user to prevent any collisions. + """ + + def __init__(self, parent: QWidget): + super().__init__(parent) + + self._display_dialog = True + + self.setWindowTitle("Motion Space NOT Defined") + self.setText( + "Motion Space is NOT defined, so there are no restrictions " + "on probe drive motion. It is up to the user to avoid " + "collisions.\n\n" + "Proceed with movement?" + ) + self.setIcon(QMessageBox.Icon.Warning) + self.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Abort + ) + self.setDefaultButton(QMessageBox.StandardButton.Abort) + + _cb = QCheckBox("Suppress future warnings for this motion group.") + self.setCheckBox(_cb) + + self.checkBox().checkStateChanged.connect(self._update_display_dialog) + + @property + def display_dialog(self) -> bool: + return self._display_dialog + + @display_dialog.setter + def display_dialog(self, value: bool) -> None: + if not isinstance(value, bool): + return + + # ensure the display boolean (display_dialog) is in sync + # with the dialog check box ... these two values are supposed + # to be NOTs of each other + check_state = self.checkBox().checkState() + if check_state is Qt.CheckState.Checked is value: + self.checkBox().setChecked(not value) + + self._display_dialog = value + + @Slot(Qt.CheckState) + def _update_display_dialog(self, state: Qt.CheckState) -> None: + self.display_dialog = not (state is Qt.CheckState.Checked) + + def exec(self) -> bool: + if not self.display_dialog: + return True + + button = super().exec() + + if button == QMessageBox.StandardButton.Yes: + # Make sure the Abort button always remains the default choice + self.setDefaultButton(QMessageBox.StandardButton.Abort) + return True + elif button == QMessageBox.StandardButton.Abort: + return False + + return False diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index d83acce5..9db4620d 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -50,7 +50,7 @@ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger -from bapsf_motion.gui.configure.message_boxes import WarningMessageBox +from bapsf_motion.gui.configure.message_boxes import WarningMessageBox, MSpaceMessageBox from bapsf_motion.gui.configure.motion_builder_overlay import MotionBuilderConfigOverlay from bapsf_motion.gui.configure.motion_space_display import MotionSpaceDisplay from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner @@ -80,74 +80,6 @@ import qtawesome as qta # noqa -class MSpaceMessageBox(QMessageBox): - """ - Modal warning dialog box to warn the user the motion space has yet - to be defined. Thus, there are no restrictions on probe drive - movement, and it is up to the user to prevent any collisions. - """ - - def __init__(self, parent: QWidget): - super().__init__(parent) - - self._display_dialog = True - - self.setWindowTitle("Motion Space NOT Defined") - self.setText( - "Motion Space is NOT defined, so there are no restrictions " - "on probe drive motion. It is up to the user to avoid " - "collisions.\n\n" - "Proceed with movement?" - ) - self.setIcon(QMessageBox.Icon.Warning) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Abort - ) - self.setDefaultButton(QMessageBox.StandardButton.Abort) - - _cb = QCheckBox("Suppress future warnings for this motion group.") - self.setCheckBox(_cb) - - self.checkBox().checkStateChanged.connect(self._update_display_dialog) - - @property - def display_dialog(self) -> bool: - return self._display_dialog - - @display_dialog.setter - def display_dialog(self, value: bool) -> None: - if not isinstance(value, bool): - return - - # ensure the display boolean (display_dialog) is in sync - # with the dialog check box ... these two values are supposed - # to be NOTs of each other - check_state = self.checkBox().checkState() - if check_state is Qt.CheckState.Checked is value: - self.checkBox().setChecked(not value) - - self._display_dialog = value - - @Slot(Qt.CheckState) - def _update_display_dialog(self, state: Qt.CheckState) -> None: - self.display_dialog = not (state is Qt.CheckState.Checked) - - def exec(self) -> bool: - if not self.display_dialog: - return True - - button = super().exec() - - if button == QMessageBox.StandardButton.Yes: - # Make sure the Abort button always remains the default choice - self.setDefaultButton(QMessageBox.StandardButton.Abort) - return True - elif button == QMessageBox.StandardButton.Abort: - return False - - return False - - class LostConnectionMessageBox(QMessageBox): """ Modal warning dialog box to warn the user that the TCP connection From 193b851e9b4ee972f83383906a005b2b6a550447 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:10:08 -0700 Subject: [PATCH 04/30] move LostConnectionMessageBox to bapsf_motion.gui.configure.message_boxes --- bapsf_motion/gui/configure/message_boxes.py | 82 +++++++++++++++++- .../gui/configure/motion_group_widget.py | 85 ++----------------- 2 files changed, 86 insertions(+), 81 deletions(-) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index c32d0aa1..20e1131d 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -1,6 +1,7 @@ __all__ = [ - "WarningMessageBox", + "LostConnectionMessageBox", "MSpaceMessageBox", + "WarningMessageBox", ] from pathlib import Path @@ -108,6 +109,85 @@ def exec(self, /) -> int: return self._approve_exec() +class LostConnectionMessageBox(QMessageBox): + """ + Modal warning dialog box to warn the user that the TCP connection + to a physical motor was lost. + """ + + def __init__(self, parent: QWidget): + super().__init__(parent) + + self._display_dialog = True + + self.setWindowTitle("Lost TCP Connection to Motor") + self._base_message = "Lost TCP connection to physical motor." + self._lost_motors = {} + font = self.font() + font.setPointSize(14) + self.setFont(font) + self.setText(self._base_message) + + self.setIcon(QMessageBox.Icon.Warning) + self.setStandardButtons(QMessageBox.StandardButton.Discard) + self.setDefaultButton(QMessageBox.StandardButton.Discard) + + @property + def display_dialog(self) -> bool: + return self._display_dialog + + @display_dialog.setter + def display_dialog(self, value: bool) -> None: + if not isinstance(value, bool): + return + + self._display_dialog = value + + def _update_display_dialog(self) -> None: + if len(self._lost_motors) == 0: + self.setText(self._base_message) + + if self.isVisible(): + self.defaultButton().click() + return None + + msg = self._base_message + "\n\n" + for name, ip in self._lost_motors.items(): + msg += f" {name} : {ip}\n" + + self.setText(msg) + + if not self.isVisible(): + self.exec() + + return None + + def register_lost_motor(self, name: str, ip: str) -> None: + if name in self._lost_motors: + return + + self._lost_motors[name] = ip + self._update_display_dialog() + + def register_resolved_motor(self, name): + if name not in self._lost_motors: + return + + self._lost_motors.pop(name) + self._update_display_dialog() + + def exec(self) -> bool: + if not self.display_dialog: + return True + + if not self.isEnabled(): + return True + + super().exec() + + return True + + class MSpaceMessageBox(QMessageBox): """ Modal warning dialog box to warn the user the motion space has yet diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 9db4620d..bbc31b00 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -50,7 +50,11 @@ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger -from bapsf_motion.gui.configure.message_boxes import WarningMessageBox, MSpaceMessageBox +from bapsf_motion.gui.configure.message_boxes import ( + LostConnectionMessageBox, + MSpaceMessageBox, + WarningMessageBox, +) from bapsf_motion.gui.configure.motion_builder_overlay import MotionBuilderConfigOverlay from bapsf_motion.gui.configure.motion_space_display import MotionSpaceDisplay from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner @@ -80,85 +84,6 @@ import qtawesome as qta # noqa -class LostConnectionMessageBox(QMessageBox): - """ - Modal warning dialog box to warn the user that the TCP connection - to a physical motor was lost. - """ - - def __init__(self, parent: QWidget): - super().__init__(parent) - - self._display_dialog = True - - self.setWindowTitle("Lost TCP Connection to Motor") - self._base_message = "Lost TCP connection to physical motor." - self._lost_motors = {} - font = self.font() - font.setPointSize(14) - self.setFont(font) - self.setText(self._base_message) - - self.setIcon(QMessageBox.Icon.Warning) - self.setStandardButtons(QMessageBox.StandardButton.Discard) - self.setDefaultButton(QMessageBox.StandardButton.Discard) - - @property - def display_dialog(self) -> bool: - return self._display_dialog - - @display_dialog.setter - def display_dialog(self, value: bool) -> None: - if not isinstance(value, bool): - return - - self._display_dialog = value - - def _update_display_dialog(self) -> None: - if len(self._lost_motors) == 0: - self.setText(self._base_message) - - if self.isVisible(): - self.defaultButton().click() - return None - - msg = self._base_message + "\n\n" - for name, ip in self._lost_motors.items(): - msg += f" {name} : {ip}\n" - - self.setText(msg) - - if not self.isVisible(): - self.exec() - - return None - - def register_lost_motor(self, name: str, ip: str) -> None: - if name in self._lost_motors: - return - - self._lost_motors[name] = ip - self._update_display_dialog() - - def register_resolved_motor(self, name): - if name not in self._lost_motors: - return - - self._lost_motors.pop(name) - self._update_display_dialog() - - def exec(self) -> bool: - if not self.display_dialog: - return True - - if not self.isEnabled(): - return True - - super().exec() - - return True - - class AxisControlWidget(QWidget): axisLinked = Signal() axisUnlinked = Signal() From 3b48ce55ad857605d0b64d57f696a0c045234315 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:11:49 -0700 Subject: [PATCH 05/30] add docstring --- bapsf_motion/gui/configure/message_boxes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index 20e1131d..7c405708 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -1,3 +1,7 @@ +""" +Module containg custom `QDialog` and `QMessageBox` classes. +""" + __all__ = [ "LostConnectionMessageBox", "MSpaceMessageBox", From fe269d52043d0f8cb92cc139338802652dc022f0 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:27:20 -0700 Subject: [PATCH 06/30] move AxisControlWidget to bapsf_motion.gui.configure.controllers --- bapsf_motion/gui/configure/controllers.py | 672 ++++++++++++++++++ .../gui/configure/motion_group_widget.py | 644 +---------------- 2 files changed, 673 insertions(+), 643 deletions(-) create mode 100644 bapsf_motion/gui/configure/controllers.py diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py new file mode 100644 index 00000000..4fcaf0ad --- /dev/null +++ b/bapsf_motion/gui/configure/controllers.py @@ -0,0 +1,672 @@ + + +import logging +import numpy as np + +from PySide6.QtCore import Signal, QTimer, Qt, Slot +from PySide6.QtGui import QDoubleValidator +from PySide6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QVBoxLayout, + QHBoxLayout, + QGridLayout, +) + +from bapsf_motion.actors import MotionGroup, Drive, Axis, Motor +from bapsf_motion.gui.configure.helpers import gui_logger +from bapsf_motion.gui.configure.message_boxes import LostConnectionMessageBox +from bapsf_motion.gui.icons import icon_name_dict +from bapsf_motion.gui.widgets import ( + EnableIndicator, + IconButton, + ValidButton, + StyleButton, + ZeroButton, + HLinePlain, +) +from bapsf_motion.utils import units as u + + +class AxisControlWidget(QWidget): + axisLinked = Signal() + axisUnlinked = Signal() + movementStarted = Signal(int) + movementStopped = Signal(int) + axisStatusChanged = Signal() + targetPositionChanged = Signal(float) + lostConnection = Signal() + establishedConnection = Signal() + + def __init__( + self, + axis_display_mode="interactive", + parent=None, + ): + super().__init__(parent) + + self._logger = gui_logger + + self._mg = None + self._axis_index = None + + self._update_display_interval = 250 # in msec + self._update_display_timer = QTimer() + self._update_display_timer.setSingleShot(True) + self._display_timer_issue_new_single_shot = False + + if axis_display_mode not in ("interactive", "readonly"): + self._logger.info( + f"Forcing display mode of {self.__class__.__name__} to be" + f" interactive." + ) + axis_display_mode = "interactive" + self._interactive_display_mode = ( + True if axis_display_mode == "interactive" else False + ) + + self.setFixedWidth(120) + + # Define BUTTONS + _btn = IconButton(icon_name_dict["arrow-up"], parent=self) + _btn.setIconSize(42) + self.jog_forward_btn = _btn + + _btn = IconButton(icon_name_dict["arrow-down"], parent=self) + _btn.setIconSize(42) + self.jog_backward_btn = _btn + + _btn = ValidButton("FWD LIMIT", parent=self) + _btn.update_style_sheet( + {"background-color": "rgb(255, 95, 95)"}, + action="checked", + ) + self.limit_fwd_btn = _btn + + _btn = ValidButton("BWD LIMIT", parent=self) + _btn.update_style_sheet( + {"background-color": "rgb(255, 95, 95)"}, + action="checked", + ) + self.limit_bwd_btn = _btn + + _btn = StyleButton("HOME", parent=self) + _btn.setEnabled(False) + self.home_btn = _btn + self.home_btn.setHidden(True) + + _btn = ZeroButton("ZERO", parent=self) + self.zero_btn = _btn + + _btn = EnableIndicator(parent=self) + font = self.font() + font.setPointSize(8) + font.setBold(True) + _btn.setFont(font) + _btn.setFixedHeight(24) + _btn.setFixedWidth(70) + self.enable_btn = _btn + + # Define TEXT WIDGETS + _txt = QLabel("Name", parent=self) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setFixedHeight(18) + self.axis_name_label = _txt + + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setReadOnly(True) + _txt.setToolTip("Motor Position") + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + self.position_label = _txt + + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setReadOnly(True) + _txt.setToolTip( + "Encoder read position.\n\n If different than motor position, " + "then the motor is likely slipping / stalling." + ) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + self.encoder_label = _txt + + _txt = QLabel("E", parent=self) + _txt.setObjectName("encoder_icon") + _txt.setStyleSheet(""" + QLabel#encoder_icon { + color: grey; + padding: 2px; + } + """) + font = _txt.font() + font.setPointSize(8) + font.setBold(True) + _txt.setFont(font) + _txt.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + self.encoder_label_icon = _txt + + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setValidator(QDoubleValidator(decimals=2)) + self.target_position_label = _txt + + _txt = QLineEdit("0", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setValidator(QDoubleValidator(decimals=2)) + self.jog_delta_label = _txt + + # Define ADVANCED WIDGETS + + self.mspace_warning_dialog = None + if hasattr(parent, "mspace_warning_dialog"): + self.mspace_warning_dialog = parent.mspace_warning_dialog + + self.lost_connection_dialog = None # type: LostConnectionMessageBox | None + if hasattr(parent, "lost_connection_dialog"): + self.lost_connection_dialog = parent.lost_connection_dialog + + self.setLayout(self._define_layout()) + self._connect_signals() + + def _connect_signals(self): + # Note: Connecting/disconnecting of SimpleSignals happens in + # the link_axis and unlink_axis methods respectively + # + self._update_display_timer.timeout.connect(self._update_display_of_axis_status) + + self.limit_fwd_btn.clicked.connect(self._move_off_limit) + self.limit_bwd_btn.clicked.connect(self._move_off_limit) + + self.jog_forward_btn.clicked.connect(self.jog_forward) + self.jog_backward_btn.clicked.connect(self.jog_backward) + self.zero_btn.clicked.connect(self._zero_axis) + self.jog_delta_label.editingFinished.connect(self._validate_jog_value) + self.target_position_label.editingFinished.connect( + self._validate_target_position_value + ) + self.enable_btn.clicked.connect(self._set_motor_enabled_state) + self.movementStopped.connect(self._disable_motor) + self.movementStopped.connect(self._update_display_of_axis_status) + + self.establishedConnection.connect(self._handle_connection_established) + self.lostConnection.connect(self._handle_connection_lost) + + def _define_layout(self): + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + if self.interactive_display_mode: + layout = self._define_interactive_layout(layout) + else: + layout = self._define_readonly_layout() + + return layout + + def _define_interactive_layout(self, layout: QVBoxLayout = None): + if layout is None: + layout = QVBoxLayout() + + layout.addLayout(self._define_title_and_enable_btn_layout()) + layout.addWidget( + self.position_label, + alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, + ) + layout.addLayout(self._define_encoder_label_layout()) + layout.addWidget(HLinePlain(parent=self)) + layout.addWidget( + self.target_position_label, + alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, + ) + layout.addWidget(self.limit_fwd_btn, alignment=Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.jog_forward_btn) + layout.addStretch(1) + layout.addWidget(self.jog_delta_label) + layout.addWidget(self.home_btn) + layout.addStretch(1) + layout.addWidget(self.jog_backward_btn, alignment=Qt.AlignmentFlag.AlignBottom) + layout.addWidget(self.limit_bwd_btn, alignment=Qt.AlignmentFlag.AlignBottom) + layout.addWidget(self.zero_btn, alignment=Qt.AlignmentFlag.AlignBottom) + layout.addStretch(1) + + return layout + + def _define_readonly_layout(self, layout: QVBoxLayout = None): + if layout is None: + layout = QVBoxLayout() + + self.target_position_label.setEnabled(False) + self.target_position_label.setVisible(False) + + self.jog_forward_btn.setEnabled(False) + self.jog_forward_btn.setVisible(False) + + self.jog_backward_btn.setEnabled(False) + self.jog_backward_btn.setVisible(False) + + self.home_btn.setEnabled(False) + self.home_btn.setVisible(False) + + self.zero_btn.setEnabled(False) + self.zero_btn.setVisible(False) + + self.limit_fwd_btn.setFixedHeight(24) + self.limit_bwd_btn.setFixedHeight(24) + + self.jog_delta_label.setText("0.1") + + _fine_step_label = QLabel("Fine Step", parent=self) + _font = _fine_step_label.font() + _font.setPointSize(12) + _fine_step_label.setFont(_font) + + layout.addLayout(self._define_title_and_enable_btn_layout()) + layout.addSpacing(4) + layout.addWidget(self.limit_fwd_btn, alignment=Qt.AlignmentFlag.AlignTop) + layout.addSpacing(8) + layout.addWidget( + self.position_label, + alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, + ) + layout.addLayout(self._define_encoder_label_layout()) + layout.addSpacing(8) + layout.addWidget(self.limit_bwd_btn, alignment=Qt.AlignmentFlag.AlignBottom) + layout.addSpacing(24) + layout.addWidget( + _fine_step_label, + alignment=Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignBaseline, + ) + layout.addWidget(self.jog_delta_label) + layout.addStretch(1) + + return layout + + def _define_title_and_enable_btn_layout(self): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addStretch(1) + layout.addWidget(self.axis_name_label) + layout.addSpacing(2) + layout.addWidget(self.enable_btn) + layout.addStretch(1) + + return layout + + def _define_encoder_label_layout(self): + layout = QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget( + self.encoder_label, + 0, + 0, + 5, + 8, + alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, + ) + layout.addWidget( + self.encoder_label_icon, + 4, + 7, + 1, + 1, + alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, + ) + + return layout + + @property + def logger(self) -> logging.Logger: + return self._logger + + @property + def mg(self) -> MotionGroup | None: + return self._mg + + @property + def axis_index(self) -> int: + return self._axis_index + + @property + def axis(self) -> Axis | None: + if self.mg is None or self.axis_index is None: + return None + + return self.mg.drive.axes[self.axis_index] + + @property + def encoder(self) -> u.Quantity: + encoder = self.mg.encoder + val = encoder.value[self.axis_index] + unit = encoder.unit + return val * unit + + @property + def position(self) -> u.Quantity: + position = self.mg.position + val = position.value[self.axis_index] + unit = position.unit + return val * unit + + @property + def target_position(self) -> float | int | None: + try: + pos = float(self.target_position_label.text()) + except ValueError: + pos = None + return pos + + @property + def interactive_display_mode(self): + return self._interactive_display_mode + + def _get_jog_delta(self): + delta_str = self.jog_delta_label.text() + return float(delta_str) + + @Slot() + def jog_forward(self): + pos = self.position.value + self._get_jog_delta() + self._move_to(pos) + + @Slot() + def jog_backward(self): + pos = self.position.value - self._get_jog_delta() + self._move_to(pos) + + def update_encoder_display(self, position: u.Quantity | float | int): + if not isinstance(position, (u.Quantity, float)): + return + elif isinstance(position, u.Quantity): + _txt = f"{position.value:.2f} {position.unit}" + else: + _txt = f"{position:.2f}" + + self.encoder_label.setText(_txt) + + def update_position_display(self, position: u.Quantity | float | int): + if not isinstance(position, (u.Quantity, float)): + return + elif isinstance(position, u.Quantity): + _txt = f"{position.value:.2f} {position.unit}" + else: + _txt = f"{position:.2f}" + + self.position_label.setText(_txt) + + def update_target_position_display(self, position): + if not isinstance(position, (u.Quantity, float)): + return + elif isinstance(position, u.Quantity): + _txt = f"{position.value:.2f}" + else: + _txt = f"{position:.2f}" + + self.target_position_label.setText(_txt) + + @Slot() + def _disable_motor(self): + self.axis.send_command("disable") + + @Slot() + def _move_off_limit(self): + axis = self.axis + if axis is None: + return + + axis.motor.move_off_limit() + + def _move_to(self, target_ax_pos): + target_pos = self.mg.position.value + target_pos[self.axis_index] = target_ax_pos + + if self.mg.drive.is_moving: + self.logger.info( + "Probe drive is currently moving. Did NOT perform move " + f"to {target_pos}." + ) + return + + try: + proceed = self.mspace_warning_dialog.exec() + except AttributeError: + proceed = False + + if proceed: + self.mg.move_to(target_pos) + + @Slot() + def _set_motor_enabled_state(self): + current_enabled_state = self.axis.motor.status["enabled"] + cmd_string = "disable" if current_enabled_state else "enable" + self.axis.send_command(cmd_string) + + @Slot() + def update_display_of_axis_status(self): + timer_active = self._update_display_timer.isActive() + if timer_active: + self._display_timer_issue_new_single_shot = True + else: + self._update_display_of_axis_status() + + # start a timed update to start update frequency control + self._update_display_timer.start(self._update_display_interval) + self._display_timer_issue_new_single_shot = False + + @Slot() + def _update_display_of_axis_status(self): + if self._mg.terminated: + self.setEnabled(False) + return + + self.setEnabled(self.axis.connected) + if not self.isEnabled(): + return + + pos = self.position + self.update_position_display(pos) + if self.target_position_label.text() == "": + self.update_target_position_display(pos) + + encoder = self.encoder + self.update_encoder_display(encoder) + + if np.isclose(pos.value, encoder.value, rtol=0.0, atol=0.02): + # encoder and absolute readingss are conssistent + self.position_label.setStyleSheet("color: black;") + self.encoder_label.setStyleSheet("color: black;") + else: + self.position_label.setStyleSheet("color: red;") + self.encoder_label.setStyleSheet("color: red;") + + _motor_status = self.axis.motor.status + + limits = _motor_status["limits"] + self.limit_fwd_btn.set_valid(state=limits["CW"]) + self.limit_bwd_btn.set_valid(state=limits["CCW"]) + + enabled_state = _motor_status["enabled"] + self.enable_btn.setChecked(enabled_state) + + if self._display_timer_issue_new_single_shot: + # start another single shot if update_display_of_axis_status() + # was triggered during the wait for the last single shot + self._update_display_timer.start(self._update_display_interval) + self._display_timer_issue_new_single_shot = False + + @Slot() + def _validate_jog_value(self): + _txt = self.jog_delta_label.text() + val = 0.0 if _txt == "" else float(_txt) + val = abs(val) + self.jog_delta_label.setText(f"{val:.2f}") + + @Slot() + def _validate_target_position_value(self): + self.targetPositionChanged.emit(self.target_position) + + @Slot() + def _zero_axis(self): + self.logger.info(f"Setting zero of axis {self.axis_index}") + self.mg.set_zero(axis=self.axis_index) + + def link_axis(self, mg: MotionGroup, ax_index: int): + if ( + not isinstance(ax_index, int) + or ax_index < 0 + or ax_index >= len(mg.drive.axes) + ): + self.unlink_axis() + return + + axis = mg.drive.axes[ax_index] + if self.axis is not None and self.axis is axis: + pass + else: + self.unlink_axis() + + self._mg = mg + self._axis_index = ax_index + + self.axis_name_label.setText(self.axis.name) + + # connect motor SimpleSignals + self.axis.motor.signals.connection_established.connect( + self._emit_connection_established + ) + self.axis.motor.signals.connection_lost.connect(self._emit_connection_lost) + self.axis.motor.signals.status_changed.connect(self.update_display_of_axis_status) + self.axis.motor.signals.status_changed.connect(self.axisStatusChanged.emit) + self.axis.motor.signals.movement_started.connect(self._emit_movement_started) + self.axis.motor.signals.movement_finished.connect(self._emit_movement_finished) + self.axis.motor.signals.movement_finished.connect( + self.update_display_of_axis_status + ) + + self.update_display_of_axis_status() + self.axisLinked.emit() + + def unlink_axis(self): + if self.axis is not None: + # disconnect all motor SimpleSignals + self.axis.motor.signals.connection_established.disconnect( + self._emit_connection_established + ) + self.axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) + self.axis.motor.signals.status_changed.disconnect( + self.update_display_of_axis_status + ) + self.axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) + self.axis.motor.signals.movement_started.disconnect( + self._emit_movement_started + ) + self.axis.motor.signals.movement_finished.disconnect( + self._emit_movement_finished + ) + self.axis.motor.signals.movement_finished.disconnect( + self.update_display_of_axis_status + ) + + self._mg = None + self._axis_index = None + self.axisUnlinked.emit() + + @Slot() + def _emit_connection_established(self): + self.establishedConnection.emit() + + @Slot() + def _emit_connection_lost(self): + self.lostConnection.emit() + + @Slot() + def _handle_connection_lost(self): + # Note: This slot needs to be trigger from a PySide6 signal and + # not from any of the SimpleSignals attached to Motor. + # Having the SimpleSignal execute this code risks the + # execution of an unsafe thread operation. The Motor + # event-loop is executing in a different thread that is + # unmanaged by PySide6. + if self.lost_connection_dialog is None: + return None + + self.lost_connection_dialog.register_lost_motor( + self.axis.name, + self.axis.motor.ip, + ) + self.setEnabled(False) + + @Slot() + def _handle_connection_established(self): + # Note: This slot needs to be trigger from a PySide6 signal and + # not from any of the SimpleSignals attached to Motor. + # Having the SimpleSignal execute this code risks the + # execution of an unsafe thread operation. The Motor + # event-loop is executing in a different thread that is + # unmanaged by PySide6. + if self.lost_connection_dialog is None: + return None + + self.lost_connection_dialog.register_resolved_motor(self.axis.name) + + self.setEnabled(True) + self.update_display_of_axis_status() + self.axisStatusChanged.emit() + + @Slot() + def _emit_movement_started(self): + self.movementStarted.emit(self.axis_index) + + @Slot() + def _emit_movement_finished(self): + self.movementStopped.emit(self.axis_index) + + def enable_motion_buttons(self): + self.zero_btn.setEnabled(True) + self.jog_forward_btn.setEnabled(True) + self.jog_backward_btn.setEnabled(True) + self.enable_btn.setEnabled(True) + + def disable_motion_buttons(self): + self.zero_btn.setEnabled(False) + self.jog_forward_btn.setEnabled(False) + self.jog_backward_btn.setEnabled(False) + self.enable_btn.setEnabled(False) + + def closeEvent(self, event): + self.logger.info("Closing AxisControlWidget") + + if isinstance(self.axis, Axis): + self.axis.motor.signals.connection_established.disconnect( + self._emit_connection_established + ) + self.axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) + self.axis.motor.signals.status_changed.disconnect( + self.update_display_of_axis_status + ) + self.axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) + self.axis.motor.signals.movement_started.disconnect( + self._emit_movement_started + ) + self.axis.motor.signals.movement_finished.disconnect( + self._emit_movement_finished + ) + self.axis.motor.signals.movement_finished.disconnect( + self.update_display_of_axis_status + ) + + event.accept() diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index bbc31b00..df29947d 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -48,6 +48,7 @@ from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure import configure_ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget +from bapsf_motion.gui.configure.controllers import AxisControlWidget from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger from bapsf_motion.gui.configure.message_boxes import ( @@ -84,649 +85,6 @@ import qtawesome as qta # noqa -class AxisControlWidget(QWidget): - axisLinked = Signal() - axisUnlinked = Signal() - movementStarted = Signal(int) - movementStopped = Signal(int) - axisStatusChanged = Signal() - targetPositionChanged = Signal(float) - lostConnection = Signal() - establishedConnection = Signal() - - def __init__( - self, - axis_display_mode="interactive", - parent=None, - ): - super().__init__(parent) - - self._logger = gui_logger - - self._mg = None - self._axis_index = None - - self._update_display_interval = 250 # in msec - self._update_display_timer = QTimer() - self._update_display_timer.setSingleShot(True) - self._display_timer_issue_new_single_shot = False - - if axis_display_mode not in ("interactive", "readonly"): - self._logger.info( - f"Forcing display mode of {self.__class__.__name__} to be" - f" interactive." - ) - axis_display_mode = "interactive" - self._interactive_display_mode = ( - True if axis_display_mode == "interactive" else False - ) - - self.setFixedWidth(120) - - # Define BUTTONS - _btn = IconButton(icon_name_dict["arrow-up"], parent=self) - _btn.setIconSize(42) - self.jog_forward_btn = _btn - - _btn = IconButton(icon_name_dict["arrow-down"], parent=self) - _btn.setIconSize(42) - self.jog_backward_btn = _btn - - _btn = ValidButton("FWD LIMIT", parent=self) - _btn.update_style_sheet( - {"background-color": "rgb(255, 95, 95)"}, - action="checked", - ) - self.limit_fwd_btn = _btn - - _btn = ValidButton("BWD LIMIT", parent=self) - _btn.update_style_sheet( - {"background-color": "rgb(255, 95, 95)"}, - action="checked", - ) - self.limit_bwd_btn = _btn - - _btn = StyleButton("HOME", parent=self) - _btn.setEnabled(False) - self.home_btn = _btn - self.home_btn.setHidden(True) - - _btn = ZeroButton("ZERO", parent=self) - self.zero_btn = _btn - - _btn = EnableIndicator(parent=self) - font = self.font() - font.setPointSize(8) - font.setBold(True) - _btn.setFont(font) - _btn.setFixedHeight(24) - _btn.setFixedWidth(70) - self.enable_btn = _btn - - # Define TEXT WIDGETS - _txt = QLabel("Name", parent=self) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setFixedHeight(18) - self.axis_name_label = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setReadOnly(True) - _txt.setToolTip("Motor Position") - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - self.position_label = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setReadOnly(True) - _txt.setToolTip( - "Encoder read position.\n\n If different than motor position, " - "then the motor is likely slipping / stalling." - ) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - self.encoder_label = _txt - - _txt = QLabel("E", parent=self) - _txt.setObjectName("encoder_icon") - _txt.setStyleSheet(""" - QLabel#encoder_icon { - color: grey; - padding: 2px; - } - """) - font = _txt.font() - font.setPointSize(8) - font.setBold(True) - _txt.setFont(font) - _txt.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - self.encoder_label_icon = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setValidator(QDoubleValidator(decimals=2)) - self.target_position_label = _txt - - _txt = QLineEdit("0", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setValidator(QDoubleValidator(decimals=2)) - self.jog_delta_label = _txt - - # Define ADVANCED WIDGETS - - self.mspace_warning_dialog = None - if hasattr(parent, "mspace_warning_dialog"): - self.mspace_warning_dialog = parent.mspace_warning_dialog - - self.lost_connection_dialog = None # type: Union[LostConnectionMessageBox, None] - if hasattr(parent, "lost_connection_dialog"): - self.lost_connection_dialog = parent.lost_connection_dialog - - self.setLayout(self._define_layout()) - self._connect_signals() - - def _connect_signals(self): - # Note: Connecting/disconnecting of SimpleSignals happens in - # the link_axis and unlink_axis methods respectively - # - self._update_display_timer.timeout.connect(self._update_display_of_axis_status) - - self.limit_fwd_btn.clicked.connect(self._move_off_limit) - self.limit_bwd_btn.clicked.connect(self._move_off_limit) - - self.jog_forward_btn.clicked.connect(self.jog_forward) - self.jog_backward_btn.clicked.connect(self.jog_backward) - self.zero_btn.clicked.connect(self._zero_axis) - self.jog_delta_label.editingFinished.connect(self._validate_jog_value) - self.target_position_label.editingFinished.connect( - self._validate_target_position_value - ) - self.enable_btn.clicked.connect(self._set_motor_enabled_state) - self.movementStopped.connect(self._disable_motor) - self.movementStopped.connect(self._update_display_of_axis_status) - - self.establishedConnection.connect(self._handle_connection_established) - self.lostConnection.connect(self._handle_connection_lost) - - def _define_layout(self): - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - if self.interactive_display_mode: - layout = self._define_interactive_layout(layout) - else: - layout = self._define_readonly_layout() - - return layout - - def _define_interactive_layout(self, layout: QVBoxLayout = None): - if layout is None: - layout = QVBoxLayout() - - layout.addLayout(self._define_title_and_enable_btn_layout()) - layout.addWidget( - self.position_label, - alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, - ) - layout.addLayout(self._define_encoder_label_layout()) - layout.addWidget(HLinePlain(parent=self)) - layout.addWidget( - self.target_position_label, - alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, - ) - layout.addWidget(self.limit_fwd_btn, alignment=Qt.AlignmentFlag.AlignTop) - layout.addWidget(self.jog_forward_btn) - layout.addStretch(1) - layout.addWidget(self.jog_delta_label) - layout.addWidget(self.home_btn) - layout.addStretch(1) - layout.addWidget(self.jog_backward_btn, alignment=Qt.AlignmentFlag.AlignBottom) - layout.addWidget(self.limit_bwd_btn, alignment=Qt.AlignmentFlag.AlignBottom) - layout.addWidget(self.zero_btn, alignment=Qt.AlignmentFlag.AlignBottom) - layout.addStretch(1) - - return layout - - def _define_readonly_layout(self, layout: QVBoxLayout = None): - if layout is None: - layout = QVBoxLayout() - - self.target_position_label.setEnabled(False) - self.target_position_label.setVisible(False) - - self.jog_forward_btn.setEnabled(False) - self.jog_forward_btn.setVisible(False) - - self.jog_backward_btn.setEnabled(False) - self.jog_backward_btn.setVisible(False) - - self.home_btn.setEnabled(False) - self.home_btn.setVisible(False) - - self.zero_btn.setEnabled(False) - self.zero_btn.setVisible(False) - - self.limit_fwd_btn.setFixedHeight(24) - self.limit_bwd_btn.setFixedHeight(24) - - self.jog_delta_label.setText("0.1") - - _fine_step_label = QLabel("Fine Step", parent=self) - _font = _fine_step_label.font() - _font.setPointSize(12) - _fine_step_label.setFont(_font) - - layout.addLayout(self._define_title_and_enable_btn_layout()) - layout.addSpacing(4) - layout.addWidget(self.limit_fwd_btn, alignment=Qt.AlignmentFlag.AlignTop) - layout.addSpacing(8) - layout.addWidget( - self.position_label, - alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, - ) - layout.addLayout(self._define_encoder_label_layout()) - layout.addSpacing(8) - layout.addWidget(self.limit_bwd_btn, alignment=Qt.AlignmentFlag.AlignBottom) - layout.addSpacing(24) - layout.addWidget( - _fine_step_label, - alignment=Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignBaseline, - ) - layout.addWidget(self.jog_delta_label) - layout.addStretch(1) - - return layout - - def _define_title_and_enable_btn_layout(self): - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addStretch(1) - layout.addWidget(self.axis_name_label) - layout.addSpacing(2) - layout.addWidget(self.enable_btn) - layout.addStretch(1) - - return layout - - def _define_encoder_label_layout(self): - layout = QGridLayout() - layout.setContentsMargins(0, 0, 0, 0) - - layout.addWidget( - self.encoder_label, - 0, - 0, - 5, - 8, - alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter, - ) - layout.addWidget( - self.encoder_label_icon, - 4, - 7, - 1, - 1, - alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, - ) - - return layout - - @property - def logger(self) -> logging.Logger: - return self._logger - - @property - def mg(self) -> Union[MotionGroup, None]: - return self._mg - - @property - def axis_index(self) -> int: - return self._axis_index - - @property - def axis(self) -> Union[Axis, None]: - if self.mg is None or self.axis_index is None: - return None - - return self.mg.drive.axes[self.axis_index] - - @property - def encoder(self) -> u.Quantity: - encoder = self.mg.encoder - val = encoder.value[self.axis_index] - unit = encoder.unit - return val * unit - - @property - def position(self) -> u.Quantity: - position = self.mg.position - val = position.value[self.axis_index] - unit = position.unit - return val * unit - - @property - def target_position(self) -> Union[float, None]: - try: - pos = float(self.target_position_label.text()) - except ValueError: - pos = None - return pos - - @property - def interactive_display_mode(self): - return self._interactive_display_mode - - def _get_jog_delta(self): - delta_str = self.jog_delta_label.text() - return float(delta_str) - - @Slot() - def jog_forward(self): - pos = self.position.value + self._get_jog_delta() - self._move_to(pos) - - @Slot() - def jog_backward(self): - pos = self.position.value - self._get_jog_delta() - self._move_to(pos) - - def update_encoder_display(self, position: Union[u.Quantity, float]): - if not isinstance(position, (u.Quantity, float)): - return - elif isinstance(position, u.Quantity): - _txt = f"{position.value:.2f} {position.unit}" - else: - _txt = f"{position:.2f}" - - self.encoder_label.setText(_txt) - - def update_position_display(self, position: Union[u.Quantity, float]): - if not isinstance(position, (u.Quantity, float)): - return - elif isinstance(position, u.Quantity): - _txt = f"{position.value:.2f} {position.unit}" - else: - _txt = f"{position:.2f}" - - self.position_label.setText(_txt) - - def update_target_position_display(self, position): - if not isinstance(position, (u.Quantity, float)): - return - elif isinstance(position, u.Quantity): - _txt = f"{position.value:.2f}" - else: - _txt = f"{position:.2f}" - - self.target_position_label.setText(_txt) - - @Slot() - def _disable_motor(self): - self.axis.send_command("disable") - - @Slot() - def _move_off_limit(self): - axis = self.axis - if axis is None: - return - - axis.motor.move_off_limit() - - def _move_to(self, target_ax_pos): - target_pos = self.mg.position.value - target_pos[self.axis_index] = target_ax_pos - - if self.mg.drive.is_moving: - self.logger.info( - "Probe drive is currently moving. Did NOT perform move " - f"to {target_pos}." - ) - return - - try: - proceed = self.mspace_warning_dialog.exec() - except AttributeError: - proceed = False - - if proceed: - self.mg.move_to(target_pos) - - @Slot() - def _set_motor_enabled_state(self): - current_enabled_state = self.axis.motor.status["enabled"] - cmd_string = "disable" if current_enabled_state else "enable" - self.axis.send_command(cmd_string) - - @Slot() - def update_display_of_axis_status(self): - timer_active = self._update_display_timer.isActive() - if timer_active: - self._display_timer_issue_new_single_shot = True - else: - self._update_display_of_axis_status() - - # start a timed update to start update frequency control - self._update_display_timer.start(self._update_display_interval) - self._display_timer_issue_new_single_shot = False - - @Slot() - def _update_display_of_axis_status(self): - if self._mg.terminated: - self.setEnabled(False) - return - - self.setEnabled(self.axis.connected) - if not self.isEnabled(): - return - - pos = self.position - self.update_position_display(pos) - if self.target_position_label.text() == "": - self.update_target_position_display(pos) - - encoder = self.encoder - self.update_encoder_display(encoder) - - if np.isclose(pos.value, encoder.value, rtol=0.0, atol=0.02): - # encoder and absolute readingss are conssistent - self.position_label.setStyleSheet("color: black;") - self.encoder_label.setStyleSheet("color: black;") - else: - self.position_label.setStyleSheet("color: red;") - self.encoder_label.setStyleSheet("color: red;") - - _motor_status = self.axis.motor.status - - limits = _motor_status["limits"] - self.limit_fwd_btn.set_valid(state=limits["CW"]) - self.limit_bwd_btn.set_valid(state=limits["CCW"]) - - enabled_state = _motor_status["enabled"] - self.enable_btn.setChecked(enabled_state) - - if self._display_timer_issue_new_single_shot: - # start another single shot if update_display_of_axis_status() - # was triggered during the wait for the last single shot - self._update_display_timer.start(self._update_display_interval) - self._display_timer_issue_new_single_shot = False - - @Slot() - def _validate_jog_value(self): - _txt = self.jog_delta_label.text() - val = 0.0 if _txt == "" else float(_txt) - val = abs(val) - self.jog_delta_label.setText(f"{val:.2f}") - - @Slot() - def _validate_target_position_value(self): - self.targetPositionChanged.emit(self.target_position) - - @Slot() - def _zero_axis(self): - self.logger.info(f"Setting zero of axis {self.axis_index}") - self.mg.set_zero(axis=self.axis_index) - - def link_axis(self, mg: MotionGroup, ax_index: int): - if ( - not isinstance(ax_index, int) - or ax_index < 0 - or ax_index >= len(mg.drive.axes) - ): - self.unlink_axis() - return - - axis = mg.drive.axes[ax_index] - if self.axis is not None and self.axis is axis: - pass - else: - self.unlink_axis() - - self._mg = mg - self._axis_index = ax_index - - self.axis_name_label.setText(self.axis.name) - - # connect motor SimpleSignals - self.axis.motor.signals.connection_established.connect( - self._emit_connection_established - ) - self.axis.motor.signals.connection_lost.connect(self._emit_connection_lost) - self.axis.motor.signals.status_changed.connect(self.update_display_of_axis_status) - self.axis.motor.signals.status_changed.connect(self.axisStatusChanged.emit) - self.axis.motor.signals.movement_started.connect(self._emit_movement_started) - self.axis.motor.signals.movement_finished.connect(self._emit_movement_finished) - self.axis.motor.signals.movement_finished.connect( - self.update_display_of_axis_status - ) - - self.update_display_of_axis_status() - self.axisLinked.emit() - - def unlink_axis(self): - if self.axis is not None: - # disconnect all motor SimpleSignals - self.axis.motor.signals.connection_established.disconnect( - self._emit_connection_established - ) - self.axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) - self.axis.motor.signals.status_changed.disconnect( - self.update_display_of_axis_status - ) - self.axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) - self.axis.motor.signals.movement_started.disconnect( - self._emit_movement_started - ) - self.axis.motor.signals.movement_finished.disconnect( - self._emit_movement_finished - ) - self.axis.motor.signals.movement_finished.disconnect( - self.update_display_of_axis_status - ) - - self._mg = None - self._axis_index = None - self.axisUnlinked.emit() - - @Slot() - def _emit_connection_established(self): - self.establishedConnection.emit() - - @Slot() - def _emit_connection_lost(self): - self.lostConnection.emit() - - @Slot() - def _handle_connection_lost(self): - # Note: This slot needs to be trigger from a PySide6 signal and - # not from any of the SimpleSignals attached to Motor. - # Having the SimpleSignal execute this code risks the - # execution of an unsafe thread operation. The Motor - # event-loop is executing in a different thread that is - # unmanaged by PySide6. - if self.lost_connection_dialog is None: - return None - - self.lost_connection_dialog.register_lost_motor( - self.axis.name, - self.axis.motor.ip, - ) - self.setEnabled(False) - - @Slot() - def _handle_connection_established(self): - # Note: This slot needs to be trigger from a PySide6 signal and - # not from any of the SimpleSignals attached to Motor. - # Having the SimpleSignal execute this code risks the - # execution of an unsafe thread operation. The Motor - # event-loop is executing in a different thread that is - # unmanaged by PySide6. - if self.lost_connection_dialog is None: - return None - - self.lost_connection_dialog.register_resolved_motor(self.axis.name) - - self.setEnabled(True) - self.update_display_of_axis_status() - self.axisStatusChanged.emit() - - @Slot() - def _emit_movement_started(self): - self.movementStarted.emit(self.axis_index) - - @Slot() - def _emit_movement_finished(self): - self.movementStopped.emit(self.axis_index) - - def enable_motion_buttons(self): - self.zero_btn.setEnabled(True) - self.jog_forward_btn.setEnabled(True) - self.jog_backward_btn.setEnabled(True) - self.enable_btn.setEnabled(True) - - def disable_motion_buttons(self): - self.zero_btn.setEnabled(False) - self.jog_forward_btn.setEnabled(False) - self.jog_backward_btn.setEnabled(False) - self.enable_btn.setEnabled(False) - - def closeEvent(self, event): - self.logger.info("Closing AxisControlWidget") - - if isinstance(self.axis, Axis): - self.axis.motor.signals.connection_established.disconnect( - self._emit_connection_established - ) - self.axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) - self.axis.motor.signals.status_changed.disconnect( - self.update_display_of_axis_status - ) - self.axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) - self.axis.motor.signals.movement_started.disconnect( - self._emit_movement_started - ) - self.axis.motor.signals.movement_finished.disconnect( - self._emit_movement_finished - ) - self.axis.motor.signals.movement_finished.disconnect( - self.update_display_of_axis_status - ) - - event.accept() - - class DriveBaseController(QWidget): driveStatusChanged = Signal() movementStarted = Signal() From 60cd7d324875f7864fa8e3e50ca38a41ebf31444 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:32:44 -0700 Subject: [PATCH 07/30] move DriveBaseController to bapsf_motion.gui.configure.controllers --- bapsf_motion/gui/configure/controllers.py | 248 ++++++++++++++++++ .../gui/configure/motion_group_widget.py | 246 +---------------- 2 files changed, 249 insertions(+), 245 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 4fcaf0ad..e42c0fa8 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -2,7 +2,9 @@ import logging import numpy as np +import warnings +from abc import abstractmethod from PySide6.QtCore import Signal, QTimer, Qt, Slot from PySide6.QtGui import QDoubleValidator from PySide6.QtWidgets import ( @@ -12,7 +14,9 @@ QVBoxLayout, QHBoxLayout, QGridLayout, + QLayout, ) +from typing import List from bapsf_motion.actors import MotionGroup, Drive, Axis, Motor from bapsf_motion.gui.configure.helpers import gui_logger @@ -670,3 +674,247 @@ def closeEvent(self, event): ) event.accept() + + +class DriveBaseController(QWidget): + driveStatusChanged = Signal() + movementStarted = Signal() + movementStopped = Signal() + moveTo = Signal(list) + zeroDrive = Signal() + targetPositionChanged = Signal(list) + + def __init__(self, axis_display_mode="interactive", parent=None): + # axis_display_mode == "interactive" or "readonly" + super().__init__(parent=parent) + + self._logger = gui_logger + + self._axis_display_mode = axis_display_mode + self.mspace_warning_dialog = None + if hasattr(parent, "mspace_warning_dialog"): + self.mspace_warning_dialog = parent.mspace_warning_dialog + + self.lost_connection_dialog = None + if hasattr(parent, "lost_connection_dialog"): + self.lost_connection_dialog = parent.lost_connection_dialog + + self._mg = None + self._mspace_drive_polarity = None + + self._axis_control_widgets = [] # type: List[AxisControlWidget] + self._initialize_axis_control_widgets() + + self._initialize_widgets() + + self.setLayout(self._define_layout()) + self._connect_signals() + + @abstractmethod + def _initialize_widgets(self): ... + + def _initialize_axis_control_widgets(self): + for ii in range(4): + acw = AxisControlWidget( + axis_display_mode=self._axis_display_mode, + parent=self, + ) + visible = True if ii == 0 else False + acw.setVisible(visible) + self._axis_control_widgets.append(acw) + + def _connect_signals(self): + self.movementStarted.connect(self.disable_motion_buttons) + self.movementStopped.connect(self.enable_motion_buttons) + + for acw in self._axis_control_widgets: + acw.targetPositionChanged.connect(self._target_position_changed) + + @abstractmethod + def _define_layout(self) -> QLayout: ... + + @property + def logger(self): + return self._logger + + @property + def mg(self) -> MotionGroup | None: + return self._mg + + @property + def mspace_drive_polarity(self): + return self._mspace_drive_polarity + + @property + def position(self) -> List[float]: + position = [] + for acw in self._axis_control_widgets: + if acw.isHidden(): + continue + + position.append(acw.position.value) + + return position + + @property + def target_position(self) -> List[float] | None: + target_position = [] + for acw in self._axis_control_widgets: + if acw.isHidden(): + continue + + target_position.append(acw.target_position) + + if not bool(target_position): + # no values in target position + return None + + if any(pos is None for pos in target_position): + # some target positions are not valid + return None + + return target_position + + @Slot() + def _target_position_changed(self, position): + self.logger.info(f"DBC target position changed {self.target_position}") + tpos = self.target_position + if tpos is None: + tpos = [] + self.targetPositionChanged.emit(tpos) + + def link_motion_group(self, mg: MotionGroup): + if not isinstance(mg, MotionGroup): + self.logger.warning( + f"Expected type {MotionGroup} for motion group, but got type" + f" {type(mg)}." + ) + + if not isinstance(mg.drive, Drive): + # drive has not been set yet + self.unlink_motion_group() + return + + if ( + isinstance(self.mg, MotionGroup) + and isinstance(self.mg.drive, Drive) + and mg.drive is self.mg.drive + ): + pass + else: + self.unlink_motion_group() + self._mg = mg + + for ii, ax in enumerate(self.mg.drive.axes): + acw = self._axis_control_widgets[ii] + acw.link_axis(self.mg, ii) + acw.establishedConnection.connect(self._drive_connection_established) + acw.lostConnection.connect(self._drive_connection_lost) + acw.movementStarted.connect(self._drive_movement_started) + acw.movementStopped.connect(self._drive_movement_finished) + acw.axisStatusChanged.connect(self.update_all_axis_displays) + acw.axisStatusChanged.connect(self.driveStatusChanged.emit) + acw.show() + + self.setEnabled(not (self._mg.terminated or not self._mg.connected)) + self._determine_mspace_drive_polarity() + + def unlink_motion_group(self): + for ii, acw in enumerate(self._axis_control_widgets): + visible = True if ii == 0 else False + + acw.unlink_axis() + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + acw.establishedConnection.disconnect(self._drive_connection_established) + acw.lostConnection.disconnect(self._drive_connection_lost) + acw.movementStarted.disconnect(self._drive_movement_started) + acw.movementStopped.disconnect(self._drive_movement_finished) + acw.axisStatusChanged.disconnect(self.update_all_axis_displays) + acw.axisStatusChanged.disconnect(self.driveStatusChanged.emit) + + acw.setVisible(visible) + + # self.mg.terminate(delay_loop_stop=True) + self._mg = None + self._mspace_drive_polarity = None + self.setEnabled(False) + + @Slot() + def update_all_axis_displays(self): + for acw in self._axis_control_widgets: + if acw.isHidden(): + continue + # elif acw.axis.is_moving: + # continue + + acw.update_display_of_axis_status() + + @Slot() + def disable_motion_buttons(self): + for acw in self._axis_control_widgets: + if acw.isHidden(): + continue + + acw.disable_motion_buttons() + + @Slot() + def enable_motion_buttons(self): + for acw in self._axis_control_widgets: + if acw.isHidden(): + continue + + acw.enable_motion_buttons() + + @Slot() + def _drive_connection_lost(self): + self.mg.drive.stop() + self.setEnabled(False) + + @Slot() + def _drive_connection_established(self): + if not isinstance(self.mg, MotionGroup) or not isinstance(self.mg.drive, Drive): + return + + if self.mg.drive.connected: + self.setEnabled(True) + + @Slot(int) + def _drive_movement_started(self, axis_index): + self.movementStarted.emit() + + @Slot(int) + def _drive_movement_finished(self, axis_index): + if not isinstance(self.mg, MotionGroup) or not isinstance(self.mg.drive, Drive): + return + + is_moving = [ax.is_moving for ax in self.mg.drive.axes] + is_moving[axis_index] = False + if not any(is_moving): + self.movementStopped.emit() + + def _determine_mspace_drive_polarity(self): + naxes = self.mg.drive.naxes + polarity = [1] * naxes + mspace_zero = [0] * naxes + drive_zero = self.mg.transform(mspace_zero, to_coords="drive") + + for ii in range(naxes): + test_pt = [0] * naxes + test_pt[ii] = 10 + drive_pt = self.mg.transform(test_pt, to_coords="drive") + delta = drive_pt[0][ii] - drive_zero[0][ii] + + pt_polarity = 1 if delta > 0 else -1 + polarity[ii] = pt_polarity + + self._mspace_drive_polarity = polarity + + def closeEvent(self, event): + self.logger.info(f"Closing {self.__class__.__name__}.") + + for acw in self._axis_control_widgets: + acw.close() + + event.accept() diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index df29947d..9f954772 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -48,7 +48,7 @@ from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure import configure_ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget -from bapsf_motion.gui.configure.controllers import AxisControlWidget +from bapsf_motion.gui.configure.controllers import DriveBaseController from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger from bapsf_motion.gui.configure.message_boxes import ( @@ -85,250 +85,6 @@ import qtawesome as qta # noqa -class DriveBaseController(QWidget): - driveStatusChanged = Signal() - movementStarted = Signal() - movementStopped = Signal() - moveTo = Signal(list) - zeroDrive = Signal() - targetPositionChanged = Signal(list) - - def __init__(self, axis_display_mode="interactive", parent=None): - # axis_display_mode == "interactive" or "readonly" - super().__init__(parent=parent) - - self._logger = gui_logger - - self._axis_display_mode = axis_display_mode - self.mspace_warning_dialog = None - if hasattr(parent, "mspace_warning_dialog"): - self.mspace_warning_dialog = parent.mspace_warning_dialog - - self.lost_connection_dialog = None - if hasattr(parent, "lost_connection_dialog"): - self.lost_connection_dialog = parent.lost_connection_dialog - - self._mg = None - self._mspace_drive_polarity = None - - self._axis_control_widgets = [] # type: List[AxisControlWidget] - self._initialize_axis_control_widgets() - - self._initialize_widgets() - - self.setLayout(self._define_layout()) - self._connect_signals() - - @abstractmethod - def _initialize_widgets(self): ... - - def _initialize_axis_control_widgets(self): - for ii in range(4): - acw = AxisControlWidget( - axis_display_mode=self._axis_display_mode, - parent=self, - ) - visible = True if ii == 0 else False - acw.setVisible(visible) - self._axis_control_widgets.append(acw) - - def _connect_signals(self): - self.movementStarted.connect(self.disable_motion_buttons) - self.movementStopped.connect(self.enable_motion_buttons) - - for acw in self._axis_control_widgets: - acw.targetPositionChanged.connect(self._target_position_changed) - - @abstractmethod - def _define_layout(self) -> QLayout: ... - - @property - def logger(self): - return self._logger - - @property - def mg(self) -> Union[MotionGroup, None]: - return self._mg - - @property - def mspace_drive_polarity(self): - return self._mspace_drive_polarity - - @property - def position(self) -> List[float]: - position = [] - for acw in self._axis_control_widgets: - if acw.isHidden(): - continue - - position.append(acw.position.value) - - return position - - @property - def target_position(self) -> Union[List[float], None]: - target_position = [] - for acw in self._axis_control_widgets: - if acw.isHidden(): - continue - - target_position.append(acw.target_position) - - if not bool(target_position): - # no values in target position - return None - - if any(pos is None for pos in target_position): - # some target positions are not valid - return None - - return target_position - - @Slot() - def _target_position_changed(self, position): - self.logger.info(f"DBC target position changed {self.target_position}") - tpos = self.target_position - if tpos is None: - tpos = [] - self.targetPositionChanged.emit(tpos) - - def link_motion_group(self, mg: MotionGroup): - if not isinstance(mg, MotionGroup): - self.logger.warning( - f"Expected type {MotionGroup} for motion group, but got type" - f" {type(mg)}." - ) - - if not isinstance(mg.drive, Drive): - # drive has not been set yet - self.unlink_motion_group() - return - - if ( - isinstance(self.mg, MotionGroup) - and isinstance(self.mg.drive, Drive) - and mg.drive is self.mg.drive - ): - pass - else: - self.unlink_motion_group() - self._mg = mg - - for ii, ax in enumerate(self.mg.drive.axes): - acw = self._axis_control_widgets[ii] - acw.link_axis(self.mg, ii) - acw.establishedConnection.connect(self._drive_connection_established) - acw.lostConnection.connect(self._drive_connection_lost) - acw.movementStarted.connect(self._drive_movement_started) - acw.movementStopped.connect(self._drive_movement_finished) - acw.axisStatusChanged.connect(self.update_all_axis_displays) - acw.axisStatusChanged.connect(self.driveStatusChanged.emit) - acw.show() - - self.setEnabled(not (self._mg.terminated or not self._mg.connected)) - self._determine_mspace_drive_polarity() - - def unlink_motion_group(self): - for ii, acw in enumerate(self._axis_control_widgets): - visible = True if ii == 0 else False - - acw.unlink_axis() - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=RuntimeWarning) - acw.establishedConnection.disconnect(self._drive_connection_established) - acw.lostConnection.disconnect(self._drive_connection_lost) - acw.movementStarted.disconnect(self._drive_movement_started) - acw.movementStopped.disconnect(self._drive_movement_finished) - acw.axisStatusChanged.disconnect(self.update_all_axis_displays) - acw.axisStatusChanged.disconnect(self.driveStatusChanged.emit) - - acw.setVisible(visible) - - # self.mg.terminate(delay_loop_stop=True) - self._mg = None - self._mspace_drive_polarity = None - self.setEnabled(False) - - @Slot() - def update_all_axis_displays(self): - for acw in self._axis_control_widgets: - if acw.isHidden(): - continue - # elif acw.axis.is_moving: - # continue - - acw.update_display_of_axis_status() - - @Slot() - def disable_motion_buttons(self): - for acw in self._axis_control_widgets: - if acw.isHidden(): - continue - - acw.disable_motion_buttons() - - @Slot() - def enable_motion_buttons(self): - for acw in self._axis_control_widgets: - if acw.isHidden(): - continue - - acw.enable_motion_buttons() - - @Slot() - def _drive_connection_lost(self): - self.mg.drive.stop() - self.setEnabled(False) - - @Slot() - def _drive_connection_established(self): - if not isinstance(self.mg, MotionGroup) or not isinstance(self.mg.drive, Drive): - return - - if self.mg.drive.connected: - self.setEnabled(True) - - @Slot(int) - def _drive_movement_started(self, axis_index): - self.movementStarted.emit() - - @Slot(int) - def _drive_movement_finished(self, axis_index): - if not isinstance(self.mg, MotionGroup) or not isinstance(self.mg.drive, Drive): - return - - is_moving = [ax.is_moving for ax in self.mg.drive.axes] - is_moving[axis_index] = False - if not any(is_moving): - self.movementStopped.emit() - - def _determine_mspace_drive_polarity(self): - naxes = self.mg.drive.naxes - polarity = [1] * naxes - mspace_zero = [0] * naxes - drive_zero = self.mg.transform(mspace_zero, to_coords="drive") - - for ii in range(naxes): - test_pt = [0] * naxes - test_pt[ii] = 10 - drive_pt = self.mg.transform(test_pt, to_coords="drive") - delta = drive_pt[0][ii] - drive_zero[0][ii] - - pt_polarity = 1 if delta > 0 else -1 - polarity[ii] = pt_polarity - - self._mspace_drive_polarity = polarity - - def closeEvent(self, event): - self.logger.info(f"Closing {self.__class__.__name__}.") - - for acw in self._axis_control_widgets: - acw.close() - - event.accept() - - class DriveDesktopController(DriveBaseController): def __init__(self, parent=None): super().__init__(axis_display_mode="interactive", parent=parent) From 3659ca3dced588720ae6b10043d51a027e4002d3 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:37:36 -0700 Subject: [PATCH 08/30] move DriveDesktopController to bapsf_motion.gui.configure.controllers --- bapsf_motion/gui/configure/controllers.py | 236 ++++++++++++++++++ .../gui/configure/motion_group_widget.py | 236 +----------------- 2 files changed, 237 insertions(+), 235 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index e42c0fa8..6f771d95 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -2,6 +2,7 @@ import logging import numpy as np +import re import warnings from abc import abstractmethod @@ -15,6 +16,7 @@ QHBoxLayout, QGridLayout, QLayout, + QSizePolicy, ) from typing import List @@ -918,3 +920,237 @@ def closeEvent(self, event): acw.close() event.accept() + + +class DriveDesktopController(DriveBaseController): + def __init__(self, parent: QWidget | None = None): + super().__init__(axis_display_mode="interactive", parent=parent) + + def _initialize_widgets(self): + # BUTTON WIDGETS + _btn = StyleButton("Move \n To", parent=self) + _btn.setMinimumHeight(100) + font = _btn.font() + font.setPointSize(20) + _btn.setFont(font) + self.move_to_btn = _btn + + _btn = StyleButton("Home \n All", parent=self) + _btn.setMinimumHeight(100) + _btn.setFont(font) + _btn.setEnabled(False) + self.home_btn = _btn + self.home_btn.setVisible(False) + + _btn = ZeroButton("Zero \n All", parent=self) + _btn.setMinimumHeight(100) + _btn.setFont(font) + self.zero_all_btn = _btn + + _btn = StyleButton("Holding\nCurrent", parent=self) + _btn.setFixedHeight(44) + font = _btn.font() + font.setPointSize(10) + _btn.setFont(font) + _btn.update_style_sheet( + styles={ + "background-color": re.sub( + " +", + " ", + """qlineargradient( + x1:0, + y1:0, + x2:1, + y2:0, + stop: 0 rgb(52, 161, 219), + stop: 0.1 rgb(52, 161, 219), + stop: 0.4 rgb(163, 163, 163), + stop: 1 rgb(163, 163, 163) + )""".replace("\n", ""), + ), + }, + action="base", + ) + _btn.update_style_sheet( + styles={ + "background-color": re.sub( + " +", + " ", + """qlineargradient( + x1:0, + y1:0, + x2:1, + y2:0, + stop: 0 rgb(163, 163, 163), + stop: 0.6 rgb(163, 163, 163), + stop: 0.9 rgb(250, 66, 45) + stop: 1 rgb(250, 66, 45) + )""".replace("\n", ""), + ), + }, + action="checked", + ) + _btn.setCheckable(True) + _btn.setChecked(False) + self.hold_current_btn = _btn + + def _connect_signals(self): + super()._connect_signals() + + self.zero_all_btn.clicked.connect(self.zeroDrive.emit) + self.move_to_btn.clicked.connect(self._move_to) + self.hold_current_btn.clicked.connect(self._toggle_holding_current) + + def _define_layout(self) -> QLayout: + _on = QLabel("O\nN", parent=self) + font = _on.font() + font.setBold(True) + _on.setFont(font) + _on.setAlignment(Qt.AlignmentFlag.AlignCenter) + _on.setFixedWidth(10) + _on.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + _off = QLabel("O\nF\nF", parent=self) + _off.setFont(font) + _off.setAlignment(Qt.AlignmentFlag.AlignCenter) + _off.setFixedWidth(10) + _off.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + holding_current_layout = QHBoxLayout() + holding_current_layout.setContentsMargins(0, 0, 0, 0) + holding_current_layout.addWidget( + _on, + alignment=Qt.AlignmentFlag.AlignVCenter, + ) + holding_current_layout.addWidget(self.hold_current_btn) + holding_current_layout.addWidget( + _off, + alignment=Qt.AlignmentFlag.AlignVCenter, + ) + _on.setVisible(False) + _off.setVisible(False) + self.hold_current_btn.setVisible(False) + + # Sub-Layout #1 + sub_layout = QVBoxLayout() + sub_layout.setContentsMargins(0, 0, 0, 0) + sub_layout.addWidget(self.move_to_btn) + sub_layout.addStretch() + # sub_layout.addWidget(self.home_btn) + sub_layout.addLayout(holding_current_layout) + sub_layout.addStretch() + sub_layout.addWidget(self.zero_all_btn) + sub_widget = QWidget(parent=self) + sub_widget.setLayout(sub_layout) + sub_widget.setFixedWidth(140) + + # Sub-Layout #2 + _text = QLabel("Position", parent=self) + font = _text.font() + font.setPointSize(14) + _text.setFont(font) + _pos_label = _text + + _text = QLabel("Target", parent=self) + font = _text.font() + font.setPointSize(14) + _text.setFont(font) + _target_label = _text + + _text = QLabel("Jog Δ", parent=self) + font = _text.font() + font.setPointSize(14) + _text.setFont(font) + _jog_delta_label = _text + + sub_layout2 = QVBoxLayout() + sub_layout2.setSpacing(8) + sub_layout2.addSpacing(54) + sub_layout2.addWidget( + _pos_label, + alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, + ) + sub_layout2.addSpacing(42) + sub_layout2.addWidget( + _target_label, + alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, + ) + sub_layout2.addSpacing(86) + sub_layout2.addWidget( + _jog_delta_label, + alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, + ) + sub_layout2.addStretch(1) + + layout = QHBoxLayout() + layout.addWidget(sub_widget) + layout.addLayout(sub_layout2) + for acw in self._axis_control_widgets: + layout.addWidget(acw) + layout.addSpacing(2) + layout.addStretch() + + return layout + + @Slot() + def _move_to(self): + target_pos = [ + acw.target_position + for acw in self._axis_control_widgets + if not acw.isHidden() + ] + + if self.mg.drive.is_moving: + self.logger.info( + "Probe drive is currently moving. Did NOT perform move " + f"to {target_pos}." + ) + target_pos = [] + + if any(p is None for p in target_pos): + self.logger.warning( + f"Requested target position ({target_pos}) is not valid," + f" NOT performing move to." + ) + return + + self.moveTo.emit(target_pos) + + @Slot() + def _toggle_holding_current(self): + hold_current = not self.hold_current_btn.isChecked() + if hold_current: + self.mg.drive.send_command("reset_currents") + else: + idle_currents = [0] * self.mg.drive.naxes + self.mg.drive.send_command("set_idle_current", *idle_currents) + + def set_target_position(self, target_position: List[float]): + npos = len(target_position) + naxes = self.mg.drive.naxes + + if npos != naxes: + self.logger.warning( + f"Received target position {target_position} does NOT " + f"have the same dimensionality as the drive " + f"({naxes})." + ) + return + + for ii, pos in enumerate(target_position): + acw = self._axis_control_widgets[ii] + acw.update_target_position_display(pos) + + def disable_motion_buttons(self): + self.move_to_btn.setEnabled(False) + self.zero_all_btn.setEnabled(False) + self.hold_current_btn.setEnabled(False) + + super().disable_motion_buttons() + + def enable_motion_buttons(self): + self.move_to_btn.setEnabled(True) + self.zero_all_btn.setEnabled(True) + self.hold_current_btn.setEnabled(True) + + super().enable_motion_buttons() diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 9f954772..42281225 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -48,7 +48,7 @@ from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure import configure_ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget -from bapsf_motion.gui.configure.controllers import DriveBaseController +from bapsf_motion.gui.configure.controllers import DriveBaseController, DriveDesktopController from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger from bapsf_motion.gui.configure.message_boxes import ( @@ -85,240 +85,6 @@ import qtawesome as qta # noqa -class DriveDesktopController(DriveBaseController): - def __init__(self, parent=None): - super().__init__(axis_display_mode="interactive", parent=parent) - - def _initialize_widgets(self): - # BUTTON WIDGETS - _btn = StyleButton("Move \n To", parent=self) - _btn.setMinimumHeight(100) - font = _btn.font() - font.setPointSize(20) - _btn.setFont(font) - self.move_to_btn = _btn - - _btn = StyleButton("Home \n All", parent=self) - _btn.setMinimumHeight(100) - _btn.setFont(font) - _btn.setEnabled(False) - self.home_btn = _btn - self.home_btn.setVisible(False) - - _btn = ZeroButton("Zero \n All", parent=self) - _btn.setMinimumHeight(100) - _btn.setFont(font) - self.zero_all_btn = _btn - - _btn = StyleButton("Holding\nCurrent", parent=self) - _btn.setFixedHeight(44) - font = _btn.font() - font.setPointSize(10) - _btn.setFont(font) - _btn.update_style_sheet( - styles={ - "background-color": re.sub( - " +", - " ", - """qlineargradient( - x1:0, - y1:0, - x2:1, - y2:0, - stop: 0 rgb(52, 161, 219), - stop: 0.1 rgb(52, 161, 219), - stop: 0.4 rgb(163, 163, 163), - stop: 1 rgb(163, 163, 163) - )""".replace("\n", ""), - ), - }, - action="base", - ) - _btn.update_style_sheet( - styles={ - "background-color": re.sub( - " +", - " ", - """qlineargradient( - x1:0, - y1:0, - x2:1, - y2:0, - stop: 0 rgb(163, 163, 163), - stop: 0.6 rgb(163, 163, 163), - stop: 0.9 rgb(250, 66, 45) - stop: 1 rgb(250, 66, 45) - )""".replace("\n", ""), - ), - }, - action="checked", - ) - _btn.setCheckable(True) - _btn.setChecked(False) - self.hold_current_btn = _btn - - def _connect_signals(self): - super()._connect_signals() - - self.zero_all_btn.clicked.connect(self.zeroDrive.emit) - self.move_to_btn.clicked.connect(self._move_to) - self.hold_current_btn.clicked.connect(self._toggle_holding_current) - - def _define_layout(self) -> QLayout: - _on = QLabel("O\nN", parent=self) - font = _on.font() - font.setBold(True) - _on.setFont(font) - _on.setAlignment(Qt.AlignmentFlag.AlignCenter) - _on.setFixedWidth(10) - _on.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - - _off = QLabel("O\nF\nF", parent=self) - _off.setFont(font) - _off.setAlignment(Qt.AlignmentFlag.AlignCenter) - _off.setFixedWidth(10) - _off.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - - holding_current_layout = QHBoxLayout() - holding_current_layout.setContentsMargins(0, 0, 0, 0) - holding_current_layout.addWidget( - _on, - alignment=Qt.AlignmentFlag.AlignVCenter, - ) - holding_current_layout.addWidget(self.hold_current_btn) - holding_current_layout.addWidget( - _off, - alignment=Qt.AlignmentFlag.AlignVCenter, - ) - _on.setVisible(False) - _off.setVisible(False) - self.hold_current_btn.setVisible(False) - - # Sub-Layout #1 - sub_layout = QVBoxLayout() - sub_layout.setContentsMargins(0, 0, 0, 0) - sub_layout.addWidget(self.move_to_btn) - sub_layout.addStretch() - # sub_layout.addWidget(self.home_btn) - sub_layout.addLayout(holding_current_layout) - sub_layout.addStretch() - sub_layout.addWidget(self.zero_all_btn) - sub_widget = QWidget(parent=self) - sub_widget.setLayout(sub_layout) - sub_widget.setFixedWidth(140) - - # Sub-Layout #2 - _text = QLabel("Position", parent=self) - font = _text.font() - font.setPointSize(14) - _text.setFont(font) - _pos_label = _text - - _text = QLabel("Target", parent=self) - font = _text.font() - font.setPointSize(14) - _text.setFont(font) - _target_label = _text - - _text = QLabel("Jog Δ", parent=self) - font = _text.font() - font.setPointSize(14) - _text.setFont(font) - _jog_delta_label = _text - - sub_layout2 = QVBoxLayout() - sub_layout2.setSpacing(8) - sub_layout2.addSpacing(54) - sub_layout2.addWidget( - _pos_label, - alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, - ) - sub_layout2.addSpacing(42) - sub_layout2.addWidget( - _target_label, - alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, - ) - sub_layout2.addSpacing(86) - sub_layout2.addWidget( - _jog_delta_label, - alignment=Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, - ) - sub_layout2.addStretch(1) - - layout = QHBoxLayout() - layout.addWidget(sub_widget) - layout.addLayout(sub_layout2) - for acw in self._axis_control_widgets: - layout.addWidget(acw) - layout.addSpacing(2) - layout.addStretch() - - return layout - - @Slot() - def _move_to(self): - target_pos = [ - acw.target_position - for acw in self._axis_control_widgets - if not acw.isHidden() - ] - - if self.mg.drive.is_moving: - self.logger.info( - "Probe drive is currently moving. Did NOT perform move " - f"to {target_pos}." - ) - target_pos = [] - - if any(p is None for p in target_pos): - self.logger.warning( - f"Requested target position ({target_pos}) is not valid," - f" NOT performing move to." - ) - return - - self.moveTo.emit(target_pos) - - @Slot() - def _toggle_holding_current(self): - hold_current = not self.hold_current_btn.isChecked() - if hold_current: - self.mg.drive.send_command("reset_currents") - else: - idle_currents = [0] * self.mg.drive.naxes - self.mg.drive.send_command("set_idle_current", *idle_currents) - - def set_target_position(self, target_position: List[float]): - npos = len(target_position) - naxes = self.mg.drive.naxes - - if npos != naxes: - self.logger.warning( - f"Received target position {target_position} does NOT " - f"have the same dimensionality as the drive " - f"({naxes})." - ) - return - - for ii, pos in enumerate(target_position): - acw = self._axis_control_widgets[ii] - acw.update_target_position_display(pos) - - def disable_motion_buttons(self): - self.move_to_btn.setEnabled(False) - self.zero_all_btn.setEnabled(False) - self.hold_current_btn.setEnabled(False) - - super().disable_motion_buttons() - - def enable_motion_buttons(self): - self.move_to_btn.setEnabled(True) - self.zero_all_btn.setEnabled(True) - self.hold_current_btn.setEnabled(True) - - super().enable_motion_buttons() - - class DriveGameController(DriveBaseController): def __init__(self, parent=None): super().__init__(axis_display_mode="readonly", parent=parent) From 2ac46b5a49cbfd6a0f6f5ac9c9a0de8e29124f36 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:43:04 -0700 Subject: [PATCH 09/30] move DriveGameController to bapsf_motion.gui.configure.controllers --- bapsf_motion/gui/configure/controllers.py | 320 +++++++++++++++++- .../gui/configure/motion_group_widget.py | 313 +---------------- 2 files changed, 323 insertions(+), 310 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 6f771d95..ee0abcf8 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -2,12 +2,19 @@ import logging import numpy as np +import os import re import warnings +# ensure joystick events are monitored when the pygame window +# is not in focus ... this needs to be done before importing pygame +os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" + +import pygame # noqa + from abc import abstractmethod -from PySide6.QtCore import Signal, QTimer, Qt, Slot -from PySide6.QtGui import QDoubleValidator +from PySide6.QtCore import Signal, QTimer, Qt, Slot, QThreadPool +from PySide6.QtGui import QDoubleValidator, QFont from PySide6.QtWidgets import ( QWidget, QLabel, @@ -17,12 +24,14 @@ QGridLayout, QLayout, QSizePolicy, + QComboBox, ) from typing import List from bapsf_motion.actors import MotionGroup, Drive, Axis, Motor from bapsf_motion.gui.configure.helpers import gui_logger from bapsf_motion.gui.configure.message_boxes import LostConnectionMessageBox +from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner from bapsf_motion.gui.icons import icon_name_dict from bapsf_motion.gui.widgets import ( EnableIndicator, @@ -31,6 +40,7 @@ StyleButton, ZeroButton, HLinePlain, + LED, ) from bapsf_motion.utils import units as u @@ -1154,3 +1164,309 @@ def enable_motion_buttons(self): self.hold_current_btn.setEnabled(True) super().enable_motion_buttons() + + +class DriveGameController(DriveBaseController): + def __init__(self, parent=None): + super().__init__(axis_display_mode="readonly", parent=parent) + + def _connect_signals(self): + super()._connect_signals() + + self.refresh_controller_list_btn.clicked.connect(self.refresh_controller_combo) + self.connect_btn.clicked.connect(self.connect_controller) + self.controller_combo_widget.currentIndexChanged.connect( + self.disconnect_controller + ) + + def _initialize_widgets(self): + self._pygame_joystick_runner = None # type: PyGameJoystickRunner | None + self._thread_pool = QThreadPool(parent=self) + + # BUTTON WIDGETS + _btn = StyleButton("Refresh List", parent=self) + _btn.setFixedHeight(32) + _font = _btn.font() + _font.setPointSize(12) + _btn.setFont(_font) + self.refresh_controller_list_btn = _btn + + _btn = StyleButton("Connect", parent=self) + _btn.setFixedHeight(32) + _btn.setFont(_font) + _btn.setFixedWidth(100) + self.connect_btn = _btn + + # TEXT/ICON WIDGETS + _led = LED(parent=self) + _led.set_fixed_height(24) + self.connected_led = _led + + # ADVANCED WIDGETS + _combo = QComboBox(parent=self) + _combo.setEditable(True) + _combo.lineEdit().setReadOnly(True) + _combo.lineEdit().setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + _combo.setFixedHeight(32) + _combo.setFont(_font) + self.controller_combo_widget = _combo + + def _define_layout(self) -> QLayout: + self.refresh_controller_combo() + + connect_layout = QHBoxLayout() + connect_layout.setContentsMargins(0, 0, 0, 0) + connect_layout.addStretch(1) + connect_layout.addWidget(self.connect_btn) + connect_layout.addWidget(self.connected_led) + connect_layout.addStretch(1) + + _label_font = QFont() + _label_font.setPointSize(12) + _left_stick = QLabel("Left Stick :", parent=self) + _left_stick.setFont(_label_font) + _right_stick = QLabel("Right Stick :", parent=self) + _right_stick.setFont(_label_font) + _dpad_vert_stick = QLabel("DPad Up/Down :", parent=self) + _dpad_vert_stick.setFont(_label_font) + _dpad_horz_stick = QLabel("DPad Left/Right :", parent=self) + _dpad_horz_stick.setFont(_label_font) + _ab = QLabel("A / B :", parent=self) + _ab.setFont(_label_font) + _y = QLabel("Y :", parent=self) + _y.setFont(_label_font) + _move_y = QLabel("Move Y", parent=self) + _move_y.setFont(_label_font) + _move_x = QLabel("Move X", parent=self) + _move_x.setFont(_label_font) + _fine_y = QLabel("Fine Y", parent=self) + _fine_y.setFont(_label_font) + _fine_x = QLabel("Fine X", parent=self) + _fine_x.setFont(_label_font) + _stop = QLabel("STOP", parent=self) + _stop.setFont(_label_font) + _zero = QLabel("Zero", parent=self) + _zero.setFont(_label_font) + + btn_label_layout = QGridLayout() + btn_label_layout.setContentsMargins(0, 0, 0, 0) + btn_label_layout.setColumnMinimumWidth(1, 8) + btn_label_layout.addWidget( + _left_stick, 0, 0, alignment=Qt.AlignmentFlag.AlignRight + ) + btn_label_layout.addWidget( + _right_stick, 1, 0, alignment=Qt.AlignmentFlag.AlignRight + ) + btn_label_layout.addWidget( + _dpad_vert_stick, 2, 0, alignment=Qt.AlignmentFlag.AlignRight + ) + btn_label_layout.addWidget( + _dpad_horz_stick, 3, 0, alignment=Qt.AlignmentFlag.AlignRight + ) + btn_label_layout.addWidget(_ab, 4, 0, alignment=Qt.AlignmentFlag.AlignRight) + btn_label_layout.addWidget(_y, 5, 0, alignment=Qt.AlignmentFlag.AlignRight) + + btn_label_layout.addWidget(_move_y, 0, 2, alignment=Qt.AlignmentFlag.AlignLeft) + btn_label_layout.addWidget(_move_x, 1, 2, alignment=Qt.AlignmentFlag.AlignLeft) + btn_label_layout.addWidget(_fine_y, 2, 2, alignment=Qt.AlignmentFlag.AlignLeft) + btn_label_layout.addWidget(_fine_x, 3, 2, alignment=Qt.AlignmentFlag.AlignLeft) + btn_label_layout.addWidget(_stop, 4, 2, alignment=Qt.AlignmentFlag.AlignLeft) + btn_label_layout.addWidget(_zero, 5, 2, alignment=Qt.AlignmentFlag.AlignLeft) + + sub_layout_1 = QVBoxLayout() + sub_layout_1.setContentsMargins(0, 0, 0, 0) + sub_layout_1.addSpacing(16) + sub_layout_1.addWidget(self.refresh_controller_list_btn) + sub_layout_1.addWidget(self.controller_combo_widget) + sub_layout_1.addLayout(connect_layout) + sub_layout_1.addSpacing(24) + sub_layout_1.addLayout(btn_label_layout) + sub_layout_1.addStretch(1) + + sub_widget_1 = QWidget(parent=self) + sub_widget_1.setLayout(sub_layout_1) + sub_widget_1.setMaximumWidth(200) + sub_widget_1.setMinimumWidth(100) + sub_widget_1.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(sub_widget_1) + layout.addSpacing(2) + for acw in self._axis_control_widgets: + layout.addWidget(acw) + layout.addSpacing(2) + layout.addStretch() + + return layout + + @property + def available_controllers(self) -> List[pygame.joystick.JoystickType]: + _joystick = pygame.joystick + + if not _joystick.get_init(): + _joystick.init() + + return [_joystick.Joystick(_id) for _id in range(_joystick.get_count())] + + @property + def joystick(self) -> pygame.joystick.JoystickType | None: + js_name = self.controller_combo_widget.currentText() + self.logger.info(f"Selected joystick: {js_name} - {self.available_controllers}") + js = None + for _js in self.available_controllers: + if _js.get_name() == js_name: + js = _js + break + + return js + + @Slot() + def refresh_controller_combo(self): + self.disconnect_controller() + + current_controller_name = self.controller_combo_widget.currentText() + + self.controller_combo_widget.clear() + + controller_names = [ + controller.get_name() for controller in self.available_controllers + ] + controller_names.append("") + self.controller_combo_widget.addItems(controller_names) + + if current_controller_name != "" and current_controller_name in controller_names: + self.controller_combo_widget.setCurrentText(current_controller_name) + self.connect_controller() + else: + self.controller_combo_widget.setCurrentText("") + + @Slot() + def connect_controller(self): + self.logger.info("Connecting controller.") + + if isinstance(self._pygame_joystick_runner, PyGameJoystickRunner): + self.disconnect_controller() + + self._pygame_joystick_runner = PyGameJoystickRunner(self.joystick) + + self._pygame_joystick_runner.signals.joystickConnected.connect( + self._update_connect_led + ) + self._pygame_joystick_runner.signals.axisMoved.connect(self._handle_axis_move) + self._pygame_joystick_runner.signals.buttonPressed.connect( + self._handle_button_press + ) + self._pygame_joystick_runner.signals.hatPressed.connect(self._handle_hat_press) + self._pygame_joystick_runner.signals.stopMovement.connect(self.stop_move) + + self._thread_pool.start(self._pygame_joystick_runner) + + @Slot() + def disconnect_controller(self): + if isinstance(self.mg, MotionGroup) and self.mg.is_moving: + self.stop_move() + + if self._pygame_joystick_runner is None: + return + + self._pygame_joystick_runner.quit() + self._pygame_joystick_runner = None + self._thread_pool.waitForDone(200) + self._thread_pool.clear() + + if isinstance(self.mg, MotionGroup) and self.mg.is_moving: + self.stop_move() + + def stop_move(self, axis=None, soft=False): + self.logger.debug("Stopping move.") + + if axis is None: + self.mg.stop(soft=soft) + return + + try: + self.mg.drive.send_command("stop", soft, axis=axis) + except Exception: # noqa + self.mg.stop() + + def zero_drive(self): + self.mg.set_zero() + + @Slot() + def _drive_connection_lost(self): + super()._drive_connection_lost() + self.disconnect_controller() + + @Slot(bool) + def _update_connect_led(self, value): + self.connected_led.setChecked(value) + + @Slot(int, float) + def _handle_axis_move(self, jaxis, value): + if jaxis not in (1, 3): + # moved joystick axis is not utilized + return + elif jaxis == 1: + axis_id = 1 + else: # jaxis == 3: + axis_id = 0 + + ax = self.mg.drive.axes[axis_id] + + if np.absolute(value) < 0.5: + self.stop_move(axis=axis_id, soft=True) + elif ax.is_moving: + pass + else: + try: + proceed = self.mspace_warning_dialog.exec() + except AttributeError: + proceed = False + + if not proceed: + return + + # pygame up-down axes are inverted + sign = 1 if value <= 0 else -1 + sign = self.mspace_drive_polarity[axis_id] * sign + direction = "forward" if sign > 0 else "backward" + + self.mg.drive.send_command("continuous_jog", direction, axis=axis_id) + + @Slot(int) + def _handle_button_press(self, button): + if button in (0, 1): + self.stop_move() + elif button == 3: + self.zero_drive() + + @Slot(int, int) + def _handle_hat_press(self, hat_id, direction): + if direction == 0: + # hat (dpad) button returned to unpressed state + # do nothing + return + + try: + proceed = self.mspace_warning_dialog.exec() + except AttributeError: + proceed = False + + if not proceed: + return + + acw = self._axis_control_widgets[hat_id] + if direction > 0: + acw.jog_forward() + else: + acw.jog_backward() + + def closeEvent(self, event): + self.disconnect_controller() + self._thread_pool.deleteLater() + super().closeEvent(event) diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 42281225..8871bf03 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -48,7 +48,10 @@ from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure import configure_ from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget -from bapsf_motion.gui.configure.controllers import DriveBaseController, DriveDesktopController +from bapsf_motion.gui.configure.controllers import ( + DriveDesktopController, + DriveGameController, +) from bapsf_motion.gui.configure.drive_overlay import DriveConfigOverlay from bapsf_motion.gui.configure.helpers import gui_logger from bapsf_motion.gui.configure.message_boxes import ( @@ -85,312 +88,6 @@ import qtawesome as qta # noqa -class DriveGameController(DriveBaseController): - def __init__(self, parent=None): - super().__init__(axis_display_mode="readonly", parent=parent) - - def _connect_signals(self): - super()._connect_signals() - - self.refresh_controller_list_btn.clicked.connect(self.refresh_controller_combo) - self.connect_btn.clicked.connect(self.connect_controller) - self.controller_combo_widget.currentIndexChanged.connect( - self.disconnect_controller - ) - - def _initialize_widgets(self): - self._pygame_joystick_runner = None # type: Union[PyGameJoystickRunner, None] - self._thread_pool = QThreadPool(parent=self) - - # BUTTON WIDGETS - _btn = StyleButton("Refresh List", parent=self) - _btn.setFixedHeight(32) - _font = _btn.font() - _font.setPointSize(12) - _btn.setFont(_font) - self.refresh_controller_list_btn = _btn - - _btn = StyleButton("Connect", parent=self) - _btn.setFixedHeight(32) - _btn.setFont(_font) - _btn.setFixedWidth(100) - self.connect_btn = _btn - - # TEXT/ICON WIDGETS - _led = LED(parent=self) - _led.set_fixed_height(24) - self.connected_led = _led - - # ADVANCED WIDGETS - _combo = QComboBox(parent=self) - _combo.setEditable(True) - _combo.lineEdit().setReadOnly(True) - _combo.lineEdit().setAlignment( - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter - ) - _combo.setFixedHeight(32) - _combo.setFont(_font) - self.controller_combo_widget = _combo - - def _define_layout(self) -> QLayout: - self.refresh_controller_combo() - - connect_layout = QHBoxLayout() - connect_layout.setContentsMargins(0, 0, 0, 0) - connect_layout.addStretch(1) - connect_layout.addWidget(self.connect_btn) - connect_layout.addWidget(self.connected_led) - connect_layout.addStretch(1) - - _label_font = QFont() - _label_font.setPointSize(12) - _left_stick = QLabel("Left Stick :", parent=self) - _left_stick.setFont(_label_font) - _right_stick = QLabel("Right Stick :", parent=self) - _right_stick.setFont(_label_font) - _dpad_vert_stick = QLabel("DPad Up/Down :", parent=self) - _dpad_vert_stick.setFont(_label_font) - _dpad_horz_stick = QLabel("DPad Left/Right :", parent=self) - _dpad_horz_stick.setFont(_label_font) - _ab = QLabel("A / B :", parent=self) - _ab.setFont(_label_font) - _y = QLabel("Y :", parent=self) - _y.setFont(_label_font) - _move_y = QLabel("Move Y", parent=self) - _move_y.setFont(_label_font) - _move_x = QLabel("Move X", parent=self) - _move_x.setFont(_label_font) - _fine_y = QLabel("Fine Y", parent=self) - _fine_y.setFont(_label_font) - _fine_x = QLabel("Fine X", parent=self) - _fine_x.setFont(_label_font) - _stop = QLabel("STOP", parent=self) - _stop.setFont(_label_font) - _zero = QLabel("Zero", parent=self) - _zero.setFont(_label_font) - - btn_label_layout = QGridLayout() - btn_label_layout.setContentsMargins(0, 0, 0, 0) - btn_label_layout.setColumnMinimumWidth(1, 8) - btn_label_layout.addWidget( - _left_stick, 0, 0, alignment=Qt.AlignmentFlag.AlignRight - ) - btn_label_layout.addWidget( - _right_stick, 1, 0, alignment=Qt.AlignmentFlag.AlignRight - ) - btn_label_layout.addWidget( - _dpad_vert_stick, 2, 0, alignment=Qt.AlignmentFlag.AlignRight - ) - btn_label_layout.addWidget( - _dpad_horz_stick, 3, 0, alignment=Qt.AlignmentFlag.AlignRight - ) - btn_label_layout.addWidget(_ab, 4, 0, alignment=Qt.AlignmentFlag.AlignRight) - btn_label_layout.addWidget(_y, 5, 0, alignment=Qt.AlignmentFlag.AlignRight) - - btn_label_layout.addWidget(_move_y, 0, 2, alignment=Qt.AlignmentFlag.AlignLeft) - btn_label_layout.addWidget(_move_x, 1, 2, alignment=Qt.AlignmentFlag.AlignLeft) - btn_label_layout.addWidget(_fine_y, 2, 2, alignment=Qt.AlignmentFlag.AlignLeft) - btn_label_layout.addWidget(_fine_x, 3, 2, alignment=Qt.AlignmentFlag.AlignLeft) - btn_label_layout.addWidget(_stop, 4, 2, alignment=Qt.AlignmentFlag.AlignLeft) - btn_label_layout.addWidget(_zero, 5, 2, alignment=Qt.AlignmentFlag.AlignLeft) - - sub_layout_1 = QVBoxLayout() - sub_layout_1.setContentsMargins(0, 0, 0, 0) - sub_layout_1.addSpacing(16) - sub_layout_1.addWidget(self.refresh_controller_list_btn) - sub_layout_1.addWidget(self.controller_combo_widget) - sub_layout_1.addLayout(connect_layout) - sub_layout_1.addSpacing(24) - sub_layout_1.addLayout(btn_label_layout) - sub_layout_1.addStretch(1) - - sub_widget_1 = QWidget(parent=self) - sub_widget_1.setLayout(sub_layout_1) - sub_widget_1.setMaximumWidth(200) - sub_widget_1.setMinimumWidth(100) - sub_widget_1.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred - ) - - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(sub_widget_1) - layout.addSpacing(2) - for acw in self._axis_control_widgets: - layout.addWidget(acw) - layout.addSpacing(2) - layout.addStretch() - - return layout - - @property - def available_controllers(self) -> List[pygame.joystick.JoystickType]: - _joystick = pygame.joystick - - if not _joystick.get_init(): - _joystick.init() - - return [_joystick.Joystick(_id) for _id in range(_joystick.get_count())] - - @property - def joystick(self) -> Union[pygame.joystick.JoystickType, None]: - js_name = self.controller_combo_widget.currentText() - self.logger.info(f"Selected joystick: {js_name} - {self.available_controllers}") - js = None - for _js in self.available_controllers: - if _js.get_name() == js_name: - js = _js - break - - return js - - @Slot() - def refresh_controller_combo(self): - self.disconnect_controller() - - current_controller_name = self.controller_combo_widget.currentText() - - self.controller_combo_widget.clear() - - controller_names = [ - controller.get_name() for controller in self.available_controllers - ] - controller_names.append("") - self.controller_combo_widget.addItems(controller_names) - - if current_controller_name != "" and current_controller_name in controller_names: - self.controller_combo_widget.setCurrentText(current_controller_name) - self.connect_controller() - else: - self.controller_combo_widget.setCurrentText("") - - @Slot() - def connect_controller(self): - self.logger.info("Connecting controller.") - - if isinstance(self._pygame_joystick_runner, PyGameJoystickRunner): - self.disconnect_controller() - - self._pygame_joystick_runner = PyGameJoystickRunner(self.joystick) - - self._pygame_joystick_runner.signals.joystickConnected.connect( - self._update_connect_led - ) - self._pygame_joystick_runner.signals.axisMoved.connect(self._handle_axis_move) - self._pygame_joystick_runner.signals.buttonPressed.connect( - self._handle_button_press - ) - self._pygame_joystick_runner.signals.hatPressed.connect(self._handle_hat_press) - self._pygame_joystick_runner.signals.stopMovement.connect(self.stop_move) - - self._thread_pool.start(self._pygame_joystick_runner) - - @Slot() - def disconnect_controller(self): - if isinstance(self.mg, MotionGroup) and self.mg.is_moving: - self.stop_move() - - if self._pygame_joystick_runner is None: - return - - self._pygame_joystick_runner.quit() - self._pygame_joystick_runner = None - self._thread_pool.waitForDone(200) - self._thread_pool.clear() - - if isinstance(self.mg, MotionGroup) and self.mg.is_moving: - self.stop_move() - - def stop_move(self, axis=None, soft=False): - self.logger.debug("Stopping move.") - - if axis is None: - self.mg.stop(soft=soft) - return - - try: - self.mg.drive.send_command("stop", soft, axis=axis) - except Exception: # noqa - self.mg.stop() - - def zero_drive(self): - self.mg.set_zero() - - @Slot() - def _drive_connection_lost(self): - super()._drive_connection_lost() - self.disconnect_controller() - - @Slot(bool) - def _update_connect_led(self, value): - self.connected_led.setChecked(value) - - @Slot(int, float) - def _handle_axis_move(self, jaxis, value): - if jaxis not in (1, 3): - # moved joystick axis is not utilized - return - elif jaxis == 1: - axis_id = 1 - else: # jaxis == 3: - axis_id = 0 - - ax = self.mg.drive.axes[axis_id] - - if np.absolute(value) < 0.5: - self.stop_move(axis=axis_id, soft=True) - elif ax.is_moving: - pass - else: - try: - proceed = self.mspace_warning_dialog.exec() - except AttributeError: - proceed = False - - if not proceed: - return - - # pygame up-down axes are inverted - sign = 1 if value <= 0 else -1 - sign = self.mspace_drive_polarity[axis_id] * sign - direction = "forward" if sign > 0 else "backward" - - self.mg.drive.send_command("continuous_jog", direction, axis=axis_id) - - @Slot(int) - def _handle_button_press(self, button): - if button in (0, 1): - self.stop_move() - elif button == 3: - self.zero_drive() - - @Slot(int, int) - def _handle_hat_press(self, hat_id, direction): - if direction == 0: - # hat (dpad) button returned to unpressed state - # do nothing - return - - try: - proceed = self.mspace_warning_dialog.exec() - except AttributeError: - proceed = False - - if not proceed: - return - - acw = self._axis_control_widgets[hat_id] - if direction > 0: - acw.jog_forward() - else: - acw.jog_backward() - - def closeEvent(self, event): - self.disconnect_controller() - self._thread_pool.deleteLater() - super().closeEvent(event) - - class DriveControlWidget(QWidget): movementStarted = Signal() movementStopped = Signal() @@ -434,7 +131,7 @@ def __init__(self, parent=None): self.lost_connection_dialog = LostConnectionMessageBox(parent=self) self.desktop_controller_widget = DriveDesktopController(parent=self) - self.game_controller_widget = None # type: Union[DriveBaseController, None] + self.game_controller_widget = None # type: Union[DriveGameController, None] self.stacked_controller_widget = QStackedWidget(parent=self) self.stacked_controller_widget.addWidget(self.desktop_controller_widget) From 14a9ad6f789250bf538201e8d8c3f1b8230fd62a Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:52:57 -0700 Subject: [PATCH 10/30] replace uses of Union[] with pipe | --- .../gui/configure/motion_group_widget.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 8871bf03..98fa0931 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -43,7 +43,7 @@ QVBoxLayout, QWidget, ) -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure import configure_ @@ -131,7 +131,7 @@ def __init__(self, parent=None): self.lost_connection_dialog = LostConnectionMessageBox(parent=self) self.desktop_controller_widget = DriveDesktopController(parent=self) - self.game_controller_widget = None # type: Union[DriveGameController, None] + self.game_controller_widget = None # type: DriveGameController | None self.stacked_controller_widget = QStackedWidget(parent=self) self.stacked_controller_widget.addWidget(self.desktop_controller_widget) @@ -218,7 +218,7 @@ def logger(self): return self._logger @property - def mg(self) -> Union[MotionGroup, None]: + def mg(self) -> MotionGroup | None: return self._mg @property @@ -547,7 +547,7 @@ def __init__( self.transform_btn = _btn # Define ADVANCED WIDGETS - self._overlay_widget = None # type: Union[_ConfigOverlay, None] + self._overlay_widget = None # type: _ConfigOverlay | None self._overlay_shown = False self.drive_control_widget = DriveControlWidget(parent=self) @@ -1311,7 +1311,7 @@ def mg(self) -> "MotionGroup": """Current working Motion Group""" return self._mg - def _set_mg(self, mg: Union[MotionGroup, None]): + def _set_mg(self, mg: MotionGroup| None): if not (isinstance(mg, MotionGroup) or mg is None): return @@ -1319,7 +1319,7 @@ def _set_mg(self, mg: Union[MotionGroup, None]): self.configChanged.emit() @property - def mg_config(self) -> Union[Dict[str, Any], "MotionGroupConfig"]: + def mg_config(self) -> "Dict[str, Any] | MotionGroupConfig": if isinstance(self.mg, MotionGroup): self._mg_config = _deepcopy_dict(self.mg.config) elif self._mg_config is None: @@ -1551,7 +1551,7 @@ def split_motion_group_name(mg_name): return drive_name, ml_name @staticmethod - def _spawn_motion_builder(config: Dict[str, Any]) -> Union[MotionBuilder, None]: + def _spawn_motion_builder(config: Dict[str, Any]) -> MotionBuilder | None: """Return an instance of |MotionBuilder|.""" if config is None or not config: return None @@ -1806,7 +1806,7 @@ def _transform_dropdown_new_selection(self, index): if index == -1: return - tr_default_config = None # type: Union[Dict[str, Any], None] + tr_default_config = None # type: Dict[str, Any] | None for _name, _config in self.transform_defaults: if tr_name != _name: continue From a8ea5a7fbcbac4c73160feab4be626027d863aa9 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:53:16 -0700 Subject: [PATCH 11/30] motion_group_widget.py : cleanup imports --- .../gui/configure/motion_group_widget.py | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 98fa0931..7296bb34 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -7,36 +7,21 @@ import asyncio import logging -import numpy as np import os import re -import warnings -# ensure joystick events are monitored when the pygame window -# is not in focus ... this needs to be done before importing pygame -os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" - -import pygame # noqa - -from abc import abstractmethod from PySide6.QtCore import ( QSize, Qt, - QThreadPool, QTimer, Signal, Slot, ) -from PySide6.QtGui import QDoubleValidator, QFont from PySide6.QtWidgets import ( - QCheckBox, QComboBox, - QGridLayout, QHBoxLayout, QLabel, - QLayout, QLineEdit, - QMessageBox, QPlainTextEdit, QSizePolicy, QStackedWidget, @@ -61,28 +46,20 @@ ) from bapsf_motion.gui.configure.motion_builder_overlay import MotionBuilderConfigOverlay from bapsf_motion.gui.configure.motion_space_display import MotionSpaceDisplay -from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner from bapsf_motion.gui.configure.transform_overlay import TransformConfigOverlay from bapsf_motion.gui.icons import icon_name_dict from bapsf_motion.gui.widgets import ( DiscardButton, DoneButton, - EnableIndicator, GearValidButton, HLinePlain, - IconButton, - LED, QTAIconLabel, StopButton, - StyleButton, - ValidButton, - ZeroButton, ) from bapsf_motion.motion_builder import MotionBuilder from bapsf_motion.transform import BaseTransform from bapsf_motion.transform.helpers import transform_registry from bapsf_motion.utils import _deepcopy_dict, dict_equal, loop_safe_stop, toml -from bapsf_motion.utils import units as u # import of qtawesome must happen after the PySide6 imports import qtawesome as qta # noqa From 5a6e253fde42292dc74cf349eece4b609eaec8b0 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:54:36 -0700 Subject: [PATCH 12/30] motion_group_widget.py : appease black and isort --- bapsf_motion/gui/configure/motion_group_widget.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 7296bb34..2d362c72 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -10,13 +10,7 @@ import os import re -from PySide6.QtCore import ( - QSize, - Qt, - QTimer, - Signal, - Slot, -) +from PySide6.QtCore import QSize, Qt, QTimer, Signal, Slot from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, @@ -1288,7 +1282,7 @@ def mg(self) -> "MotionGroup": """Current working Motion Group""" return self._mg - def _set_mg(self, mg: MotionGroup| None): + def _set_mg(self, mg: MotionGroup | None): if not (isinstance(mg, MotionGroup) or mg is None): return From 16b1cbaa4b7231281da2c5f498cb6a1c97176a88 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 16:59:40 -0700 Subject: [PATCH 13/30] motion_group_widget.py : handle type checking imports --- bapsf_motion/gui/configure/motion_group_widget.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 2d362c72..84044a02 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -3,6 +3,8 @@ configuration portion of the configuration GUI. """ +from __future__ import annotations + __all__ = ["MGWidget"] import asyncio @@ -22,11 +24,9 @@ QVBoxLayout, QWidget, ) -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING -from bapsf_motion.actors import Axis, Drive, MotionGroup, MotionGroupConfig, RunManager -from bapsf_motion.gui.configure import configure_ -from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget +from bapsf_motion.actors import Drive, MotionGroup, MotionGroupConfig, RunManager from bapsf_motion.gui.configure.controllers import ( DriveDesktopController, DriveGameController, @@ -55,6 +55,10 @@ from bapsf_motion.transform.helpers import transform_registry from bapsf_motion.utils import _deepcopy_dict, dict_equal, loop_safe_stop, toml +if TYPE_CHECKING: + from bapsf_motion.gui.configure import configure_ + from bapsf_motion.gui.configure.bases import _ConfigOverlay, _OverlayWidget + # import of qtawesome must happen after the PySide6 imports import qtawesome as qta # noqa From 25dcd8f60b023e7beb792ebacbb6c6d2c34534c6 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 17:06:50 -0700 Subject: [PATCH 14/30] controllers.py : cleanup imports --- bapsf_motion/gui/configure/controllers.py | 35 ++++++++++++++--------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index ee0abcf8..2291b6f9 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -1,4 +1,9 @@ +""" +Module containing "controller" classes that control the actual movement +of the probe dirves (i.e. motion groups). +""" +__all__ = ["DriveDesktopController", "DriveGameController"] import logging import numpy as np @@ -8,42 +13,44 @@ # ensure joystick events are monitored when the pygame window # is not in focus ... this needs to be done before importing pygame -os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" +os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" # noqa import pygame # noqa from abc import abstractmethod -from PySide6.QtCore import Signal, QTimer, Qt, Slot, QThreadPool +from PySide6.QtCore import Qt, QThreadPool, QTimer, Signal, Slot from PySide6.QtGui import QDoubleValidator, QFont from PySide6.QtWidgets import ( - QWidget, - QLabel, - QLineEdit, - QVBoxLayout, - QHBoxLayout, + QComboBox, QGridLayout, + QHBoxLayout, + QLabel, QLayout, + QLineEdit, QSizePolicy, - QComboBox, + QVBoxLayout, + QWidget, ) -from typing import List +from typing import List, TYPE_CHECKING -from bapsf_motion.actors import MotionGroup, Drive, Axis, Motor +from bapsf_motion.actors import Axis, Drive, MotionGroup from bapsf_motion.gui.configure.helpers import gui_logger -from bapsf_motion.gui.configure.message_boxes import LostConnectionMessageBox from bapsf_motion.gui.configure.pygame_ import PyGameJoystickRunner from bapsf_motion.gui.icons import icon_name_dict from bapsf_motion.gui.widgets import ( EnableIndicator, + HLinePlain, IconButton, - ValidButton, + LED, StyleButton, + ValidButton, ZeroButton, - HLinePlain, - LED, ) from bapsf_motion.utils import units as u +if TYPE_CHECKING: + from bapsf_motion.gui.configure.message_boxes import LostConnectionMessageBox + class AxisControlWidget(QWidget): axisLinked = Signal() From f00080d32b58e9b49cf21e8de9a7b6339be40c5d Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 17:08:35 -0700 Subject: [PATCH 15/30] replace use of Union[] with pipe | --- bapsf_motion/gui/configure/message_boxes.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index 7c405708..6753f66a 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -12,7 +12,6 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon from PySide6.QtWidgets import QDialog, QMessageBox, QWidget, QCheckBox -from typing import Union _HERE = Path(__file__).parent @@ -35,7 +34,7 @@ class WarningMessageBox(QMessageBox): and NO button is displayed and the user must choose how to proceed. - parent : Union[QWidget, None] + parent : QWidget | None The parent / owning widget. """ @@ -43,7 +42,7 @@ def __init__( self, message: str, button_layout: str = "acknowledge", - parent: Union[QWidget, None] = None, + parent: QWidget | None = None, ): super().__init__(parent) From 5c87b52adcec6ca43ea35b8fa7ee5c5c068e9ead Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 17:08:52 -0700 Subject: [PATCH 16/30] message_boxes.py : appease isort and black --- bapsf_motion/gui/configure/message_boxes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index 6753f66a..3a74843b 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -11,7 +11,7 @@ from pathlib import Path from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QDialog, QMessageBox, QWidget, QCheckBox +from PySide6.QtWidgets import QCheckBox, QDialog, QMessageBox, QWidget _HERE = Path(__file__).parent From 865eaf435af75d0c8c2eefdb76e35036c172dd6f Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 17:09:50 -0700 Subject: [PATCH 17/30] pygame_.py : appease isort and black --- bapsf_motion/gui/configure/pygame_.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bapsf_motion/gui/configure/pygame_.py b/bapsf_motion/gui/configure/pygame_.py index df16174b..3df05e6f 100644 --- a/bapsf_motion/gui/configure/pygame_.py +++ b/bapsf_motion/gui/configure/pygame_.py @@ -9,16 +9,11 @@ # ensure joystick events are monitored when the pygame window # is not in focus ... this needs to be done before importing pygame -os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" +os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1" # noqa import pygame # noqa -from PySide6.QtCore import ( - QObject, - QRunnable, - Signal, - Slot, -) +from PySide6.QtCore import QObject, QRunnable, Signal, Slot from bapsf_motion.gui.configure.helpers import gui_logger From 52708acf8616f8b055f7f913854529c8148cfcd9 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 18:30:16 -0700 Subject: [PATCH 18/30] AxisControlWidget: add annotations and clarifying comments --- bapsf_motion/gui/configure/controllers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 2291b6f9..9ad1e6c6 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -64,8 +64,8 @@ class AxisControlWidget(QWidget): def __init__( self, - axis_display_mode="interactive", - parent=None, + axis_display_mode: Literal["interactive", "readonly"] = "interactive", + parent: QWidget | None = None, ): super().__init__(parent) @@ -74,11 +74,14 @@ def __init__( self._mg = None self._axis_index = None + # Configure display update timer + # - to update widgets during a motor movement self._update_display_interval = 250 # in msec self._update_display_timer = QTimer() self._update_display_timer.setSingleShot(True) self._display_timer_issue_new_single_shot = False + # Configure display interactive-ness if axis_display_mode not in ("interactive", "readonly"): self._logger.info( f"Forcing display mode of {self.__class__.__name__} to be" From e341d46b4277965e176dab20026d1e5142cd452e Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 18:31:36 -0700 Subject: [PATCH 19/30] AxisControlWidget: move some widget instantiation into dedicated methods --- bapsf_motion/gui/configure/controllers.py | 94 +++++++++++++---------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 9ad1e6c6..e9ebf43e 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -31,7 +31,7 @@ QVBoxLayout, QWidget, ) -from typing import List, TYPE_CHECKING +from typing import List, Literal, TYPE_CHECKING from bapsf_motion.actors import Axis, Drive, MotionGroup from bapsf_motion.gui.configure.helpers import gui_logger @@ -94,45 +94,14 @@ def __init__( self.setFixedWidth(120) - # Define BUTTONS - _btn = IconButton(icon_name_dict["arrow-up"], parent=self) - _btn.setIconSize(42) - self.jog_forward_btn = _btn - - _btn = IconButton(icon_name_dict["arrow-down"], parent=self) - _btn.setIconSize(42) - self.jog_backward_btn = _btn - - _btn = ValidButton("FWD LIMIT", parent=self) - _btn.update_style_sheet( - {"background-color": "rgb(255, 95, 95)"}, - action="checked", - ) - self.limit_fwd_btn = _btn - - _btn = ValidButton("BWD LIMIT", parent=self) - _btn.update_style_sheet( - {"background-color": "rgb(255, 95, 95)"}, - action="checked", - ) - self.limit_bwd_btn = _btn - - _btn = StyleButton("HOME", parent=self) - _btn.setEnabled(False) - self.home_btn = _btn - self.home_btn.setHidden(True) - - _btn = ZeroButton("ZERO", parent=self) - self.zero_btn = _btn - - _btn = EnableIndicator(parent=self) - font = self.font() - font.setPointSize(8) - font.setBold(True) - _btn.setFont(font) - _btn.setFixedHeight(24) - _btn.setFixedWidth(70) - self.enable_btn = _btn + # Define WIDGETS + self.enable_btn = self._init_enable_btn() + self.home_btn = self._init_home_btn() + self.jog_forward_btn = self._init_jog_forward_btn() + self.jog_backward_btn = self._init_jog_backward_btn() + self.limit_fwd_btn = self._init_limit_fwd_btn() + self.limit_bwd_btn = self._init_limit_bwd_btn() + self.zero_btn = self._init_zero_btn() # Define TEXT WIDGETS _txt = QLabel("Name", parent=self) @@ -355,6 +324,51 @@ def _define_encoder_label_layout(self): return layout + def _init_enable_btn(self): + _btn = EnableIndicator(parent=self) + font = self.font() + font.setPointSize(8) + font.setBold(True) + _btn.setFont(font) + _btn.setFixedHeight(24) + _btn.setFixedWidth(70) + return _btn + + def _init_home_btn(self): + _btn = StyleButton("HOME", parent=self) + _btn.setEnabled(False) + _btn.setVisible(False) + return _btn + + def _init_jog_forward_btn(self): + _btn = IconButton(icon_name_dict["arrow-up"], parent=self) + _btn.setIconSize(42) + return _btn + + def _init_jog_backward_btn(self): + _btn = IconButton(icon_name_dict["arrow-down"], parent=self) + _btn.setIconSize(42) + return _btn + + def _init_limit_fwd_btn(self): + _btn = ValidButton("FWD LIMIT", parent=self) + _btn.update_style_sheet( + {"background-color": "rgb(255, 95, 95)"}, + action="checked", + ) + return _btn + + def _init_limit_bwd_btn(self): + _btn = ValidButton("BWD LIMIT", parent=self) + _btn.update_style_sheet( + {"background-color": "rgb(255, 95, 95)"}, + action="checked", + ) + return _btn + + def _init_zero_btn(self): + return ZeroButton("ZERO", parent=self) + @property def logger(self) -> logging.Logger: return self._logger From e86008717693862f50113ee6183700501f208ece Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 18:43:38 -0700 Subject: [PATCH 20/30] AxisControlWidget: move remaining widget instantiation into dedicated methods --- bapsf_motion/gui/configure/controllers.py | 156 ++++++++++++---------- 1 file changed, 83 insertions(+), 73 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index e9ebf43e..923030e9 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -95,78 +95,22 @@ def __init__( self.setFixedWidth(120) # Define WIDGETS + self.axis_name_label = self._init_axis_name_label() self.enable_btn = self._init_enable_btn() + self.encoder_label = self._init_encoder_label() + self.encoder_label_icon = self._init_encoder_label_icon() self.home_btn = self._init_home_btn() - self.jog_forward_btn = self._init_jog_forward_btn() self.jog_backward_btn = self._init_jog_backward_btn() - self.limit_fwd_btn = self._init_limit_fwd_btn() + self.jog_delta_label = self._init_jog_delta_label() + self.jog_forward_btn = self._init_jog_forward_btn() self.limit_bwd_btn = self._init_limit_bwd_btn() + self.limit_fwd_btn = self._init_limit_fwd_btn() + self.position_label = self._init_position_label() + self.target_position_label = self._init_target_position_label() self.zero_btn = self._init_zero_btn() - # Define TEXT WIDGETS - _txt = QLabel("Name", parent=self) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setFixedHeight(18) - self.axis_name_label = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setReadOnly(True) - _txt.setToolTip("Motor Position") - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - self.position_label = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - _txt.setReadOnly(True) - _txt.setToolTip( - "Encoder read position.\n\n If different than motor position, " - "then the motor is likely slipping / stalling." - ) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - self.encoder_label = _txt - - _txt = QLabel("E", parent=self) - _txt.setObjectName("encoder_icon") - _txt.setStyleSheet(""" - QLabel#encoder_icon { - color: grey; - padding: 2px; - } - """) - font = _txt.font() - font.setPointSize(8) - font.setBold(True) - _txt.setFont(font) - _txt.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - self.encoder_label_icon = _txt - - _txt = QLineEdit("", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setValidator(QDoubleValidator(decimals=2)) - self.target_position_label = _txt - - _txt = QLineEdit("0", parent=self) - _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = _txt.font() - font.setPointSize(14) - _txt.setFont(font) - _txt.setValidator(QDoubleValidator(decimals=2)) - self.jog_delta_label = _txt - - # Define ADVANCED WIDGETS - self.mspace_warning_dialog = None + # Retrieve Warning Dialogs from Parent if hasattr(parent, "mspace_warning_dialog"): self.mspace_warning_dialog = parent.mspace_warning_dialog @@ -324,6 +268,15 @@ def _define_encoder_label_layout(self): return layout + def _init_axis_name_label(self): + _txt = QLabel("Name", parent=self) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setFixedHeight(18) + return _txt + def _init_enable_btn(self): _btn = EnableIndicator(parent=self) font = self.font() @@ -334,38 +287,95 @@ def _init_enable_btn(self): _btn.setFixedWidth(70) return _btn + def _init_encoder_label(self): + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setReadOnly(True) + _txt.setToolTip( + "Encoder read position.\n\n If different than motor position, " + "then the motor is likely slipping / stalling." + ) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + return _txt + + def _init_encoder_label_icon(self): + _txt = QLabel("E", parent=self) + _txt.setObjectName("encoder_icon") + _txt.setStyleSheet(""" + QLabel#encoder_icon { + color: grey; + padding: 2px; + } + """) + font = _txt.font() + font.setPointSize(8) + font.setBold(True) + _txt.setFont(font) + _txt.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + return _txt + def _init_home_btn(self): _btn = StyleButton("HOME", parent=self) _btn.setEnabled(False) _btn.setVisible(False) return _btn - def _init_jog_forward_btn(self): - _btn = IconButton(icon_name_dict["arrow-up"], parent=self) + def _init_jog_backward_btn(self): + _btn = IconButton(icon_name_dict["arrow-down"], parent=self) _btn.setIconSize(42) return _btn - def _init_jog_backward_btn(self): - _btn = IconButton(icon_name_dict["arrow-down"], parent=self) + def _init_jog_delta_label(self): + _txt = QLineEdit("0", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setValidator(QDoubleValidator(decimals=2)) + return _txt + + def _init_jog_forward_btn(self): + _btn = IconButton(icon_name_dict["arrow-up"], parent=self) _btn.setIconSize(42) return _btn - def _init_limit_fwd_btn(self): - _btn = ValidButton("FWD LIMIT", parent=self) + def _init_limit_bwd_btn(self): + _btn = ValidButton("BWD LIMIT", parent=self) _btn.update_style_sheet( {"background-color": "rgb(255, 95, 95)"}, action="checked", ) return _btn - def _init_limit_bwd_btn(self): - _btn = ValidButton("BWD LIMIT", parent=self) + def _init_limit_fwd_btn(self): + _btn = ValidButton("FWD LIMIT", parent=self) _btn.update_style_sheet( {"background-color": "rgb(255, 95, 95)"}, action="checked", ) return _btn + def _init_position_label(self): + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + _txt.setReadOnly(True) + _txt.setToolTip("Motor Position") + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + return _txt + + def _init_target_position_label(self): + _txt = QLineEdit("", parent=self) + _txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = _txt.font() + font.setPointSize(14) + _txt.setFont(font) + _txt.setValidator(QDoubleValidator(decimals=2)) + return _txt + def _init_zero_btn(self): return ZeroButton("ZERO", parent=self) From 7eb01b07ef406339ff2be0a66002a0909a7db7c5 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 18:44:13 -0700 Subject: [PATCH 21/30] AxisControlWidget: message box annotations --- bapsf_motion/gui/configure/controllers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 923030e9..476ed5b4 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -49,7 +49,10 @@ from bapsf_motion.utils import units as u if TYPE_CHECKING: - from bapsf_motion.gui.configure.message_boxes import LostConnectionMessageBox + from bapsf_motion.gui.configure.message_boxes import ( + LostConnectionMessageBox, + MSpaceMessageBox, + ) class AxisControlWidget(QWidget): @@ -109,8 +112,8 @@ def __init__( self.target_position_label = self._init_target_position_label() self.zero_btn = self._init_zero_btn() - self.mspace_warning_dialog = None # Retrieve Warning Dialogs from Parent + self.mspace_warning_dialog = None # type: MSpaceMessageBox | None if hasattr(parent, "mspace_warning_dialog"): self.mspace_warning_dialog = parent.mspace_warning_dialog From a4ec4e7303d210ec7c54dd889bd2a151a90d9c08 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 18:44:46 -0700 Subject: [PATCH 22/30] AxisControlWidget: collect widget layout definition code-blocks --- bapsf_motion/gui/configure/controllers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 476ed5b4..b59a5199 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -95,8 +95,6 @@ def __init__( True if axis_display_mode == "interactive" else False ) - self.setFixedWidth(120) - # Define WIDGETS self.axis_name_label = self._init_axis_name_label() self.enable_btn = self._init_enable_btn() @@ -121,6 +119,7 @@ def __init__( if hasattr(parent, "lost_connection_dialog"): self.lost_connection_dialog = parent.lost_connection_dialog + self.setFixedWidth(120) self.setLayout(self._define_layout()) self._connect_signals() From 42d559b3da4687a6b33d3206ff7ae52bd7309445 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:06:43 -0700 Subject: [PATCH 23/30] add some annotations --- bapsf_motion/gui/configure/controllers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index b59a5199..b7b8c49f 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -158,7 +158,7 @@ def _define_layout(self): return layout - def _define_interactive_layout(self, layout: QVBoxLayout = None): + def _define_interactive_layout(self, layout: QVBoxLayout | None = None): if layout is None: layout = QVBoxLayout() @@ -186,7 +186,7 @@ def _define_interactive_layout(self, layout: QVBoxLayout = None): return layout - def _define_readonly_layout(self, layout: QVBoxLayout = None): + def _define_readonly_layout(self, layout: QVBoxLayout | None = None): if layout is None: layout = QVBoxLayout() @@ -732,7 +732,11 @@ class DriveBaseController(QWidget): zeroDrive = Signal() targetPositionChanged = Signal(list) - def __init__(self, axis_display_mode="interactive", parent=None): + def __init__( + self, + axis_display_mode: Literal["interactive", "readonly"] = "interactive", + parent: QWidget | None = None, + ): # axis_display_mode == "interactive" or "readonly" super().__init__(parent=parent) @@ -1204,6 +1208,8 @@ def enable_motion_buttons(self): class DriveGameController(DriveBaseController): def __init__(self, parent=None): + self._pygame_joystick_runner = None # type: PyGameJoystickRunner | None + super().__init__(axis_display_mode="readonly", parent=parent) def _connect_signals(self): @@ -1216,7 +1222,6 @@ def _connect_signals(self): ) def _initialize_widgets(self): - self._pygame_joystick_runner = None # type: PyGameJoystickRunner | None self._thread_pool = QThreadPool(parent=self) # BUTTON WIDGETS From 6be887e79d70ff6e528cc2cd2269964b8fbd3c21 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:10:23 -0700 Subject: [PATCH 24/30] use direct isinstance() of an actor class instead of checking for None --- bapsf_motion/gui/configure/controllers.py | 64 +++++++++++++---------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index b7b8c49f..d5b5f79e 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -395,10 +395,18 @@ def axis_index(self) -> int: @property def axis(self) -> Axis | None: - if self.mg is None or self.axis_index is None: + if ( + not isinstance(self.mg, MotionGroup) + or not isinstance(self.mg.drive, Drive) + or self.axis_index is None + ): return None - return self.mg.drive.axes[self.axis_index] + axis = self.mg.drive.axes[self.axis_index] + if not isinstance(axis, Axis): + return None + + return axis @property def encoder(self) -> u.Quantity: @@ -576,9 +584,11 @@ def _zero_axis(self): self.logger.info(f"Setting zero of axis {self.axis_index}") self.mg.set_zero(axis=self.axis_index) - def link_axis(self, mg: MotionGroup, ax_index: int): + def link_axis(self, mg: MotionGroup | None, ax_index: int): if ( - not isinstance(ax_index, int) + not isinstance(mg, MotionGroup) + or not isinstance(mg.drive, Drive) + or not isinstance(ax_index, int) or ax_index < 0 or ax_index >= len(mg.drive.axes) ): @@ -586,7 +596,7 @@ def link_axis(self, mg: MotionGroup, ax_index: int): return axis = mg.drive.axes[ax_index] - if self.axis is not None and self.axis is axis: + if isinstance(self.axis, Axis) and self.axis is axis: pass else: self.unlink_axis() @@ -594,42 +604,42 @@ def link_axis(self, mg: MotionGroup, ax_index: int): self._mg = mg self._axis_index = ax_index - self.axis_name_label.setText(self.axis.name) + axis = self.axis + if not isinstance(axis, Axis): + self.logger.error("Linking axis failed.") + return + + self.axis_name_label.setText(axis.name) # connect motor SimpleSignals - self.axis.motor.signals.connection_established.connect( + axis.motor.signals.connection_established.connect( self._emit_connection_established ) - self.axis.motor.signals.connection_lost.connect(self._emit_connection_lost) - self.axis.motor.signals.status_changed.connect(self.update_display_of_axis_status) - self.axis.motor.signals.status_changed.connect(self.axisStatusChanged.emit) - self.axis.motor.signals.movement_started.connect(self._emit_movement_started) - self.axis.motor.signals.movement_finished.connect(self._emit_movement_finished) - self.axis.motor.signals.movement_finished.connect( - self.update_display_of_axis_status - ) + axis.motor.signals.connection_lost.connect(self._emit_connection_lost) + axis.motor.signals.status_changed.connect(self.update_display_of_axis_status) + axis.motor.signals.status_changed.connect(self.axisStatusChanged.emit) + axis.motor.signals.movement_started.connect(self._emit_movement_started) + axis.motor.signals.movement_finished.connect(self._emit_movement_finished) + axis.motor.signals.movement_finished.connect(self.update_display_of_axis_status) self.update_display_of_axis_status() self.axisLinked.emit() def unlink_axis(self): - if self.axis is not None: + axis = self.axis + if isinstance(axis, Axis): # disconnect all motor SimpleSignals - self.axis.motor.signals.connection_established.disconnect( + axis.motor.signals.connection_established.disconnect( self._emit_connection_established ) - self.axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) - self.axis.motor.signals.status_changed.disconnect( + axis.motor.signals.connection_lost.disconnect(self._emit_connection_lost) + axis.motor.signals.status_changed.disconnect( self.update_display_of_axis_status ) - self.axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) - self.axis.motor.signals.movement_started.disconnect( - self._emit_movement_started - ) - self.axis.motor.signals.movement_finished.disconnect( - self._emit_movement_finished - ) - self.axis.motor.signals.movement_finished.disconnect( + axis.motor.signals.status_changed.disconnect(self.axisStatusChanged.emit) + axis.motor.signals.movement_started.disconnect(self._emit_movement_started) + axis.motor.signals.movement_finished.disconnect(self._emit_movement_finished) + axis.motor.signals.movement_finished.disconnect( self.update_display_of_axis_status ) From 027460d1ff57a9fcc8732ddabce1505e559e8fe2 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:10:38 -0700 Subject: [PATCH 25/30] cleanup vestige code --- bapsf_motion/gui/configure/controllers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index d5b5f79e..7d99a246 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -908,8 +908,6 @@ def update_all_axis_displays(self): for acw in self._axis_control_widgets: if acw.isHidden(): continue - # elif acw.axis.is_moving: - # continue acw.update_display_of_axis_status() From df0a22faa8858c2dd43bd484c8291575738ccedd Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:10:54 -0700 Subject: [PATCH 26/30] better handle joystick selection --- bapsf_motion/gui/configure/controllers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 7d99a246..133dbf1a 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -1401,7 +1401,12 @@ def connect_controller(self): if isinstance(self._pygame_joystick_runner, PyGameJoystickRunner): self.disconnect_controller() - self._pygame_joystick_runner = PyGameJoystickRunner(self.joystick) + selected_joystick = self.joystick + if not isinstance(selected_joystick, pygame.joystick.JoystickType): + self.logger.warning("Selected joystick not found.") + return + + self._pygame_joystick_runner = PyGameJoystickRunner(selected_joystick) self._pygame_joystick_runner.signals.joystickConnected.connect( self._update_connect_led From e4408111c3ca278814a433b9e338bae22dfc50bb Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:19:08 -0700 Subject: [PATCH 27/30] Motor.config handle if speed is an AckFlags from the motor not being connected --- bapsf_motion/actors/motor_.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bapsf_motion/actors/motor_.py b/bapsf_motion/actors/motor_.py index a09edc31..bc6735c8 100644 --- a/bapsf_motion/actors/motor_.py +++ b/bapsf_motion/actors/motor_.py @@ -1009,7 +1009,11 @@ def config(self) -> Dict[str, Any]: speed = self.motor["speed"] if isinstance(speed, u.Quantity): speed = speed.value - speed = float(speed) + try: + speed = float(speed) + except TypeError: + # Motor is likely connected and the stored value is an AckFlags + speed = 0.0 return { "name": self.name, From e4156c3d3026dc69ed7c3e73c6e5c37f42d65d47 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Thu, 11 Jun 2026 19:19:25 -0700 Subject: [PATCH 28/30] add QCloseEvent annotation --- bapsf_motion/gui/configure/controllers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index 133dbf1a..ae40cd21 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -19,7 +19,7 @@ from abc import abstractmethod from PySide6.QtCore import Qt, QThreadPool, QTimer, Signal, Slot -from PySide6.QtGui import QDoubleValidator, QFont +from PySide6.QtGui import QDoubleValidator, QFont, QCloseEvent from PySide6.QtWidgets import ( QComboBox, QGridLayout, @@ -709,7 +709,7 @@ def disable_motion_buttons(self): self.jog_backward_btn.setEnabled(False) self.enable_btn.setEnabled(False) - def closeEvent(self, event): + def closeEvent(self, event: QCloseEvent): self.logger.info("Closing AxisControlWidget") if isinstance(self.axis, Axis): From 1891b96cfb6efb695336256ea5e4fb72bee971ea Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Sat, 13 Jun 2026 17:05:04 -0700 Subject: [PATCH 29/30] fix spelling error --- bapsf_motion/gui/configure/message_boxes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bapsf_motion/gui/configure/message_boxes.py b/bapsf_motion/gui/configure/message_boxes.py index 3a74843b..0b0ac2ef 100644 --- a/bapsf_motion/gui/configure/message_boxes.py +++ b/bapsf_motion/gui/configure/message_boxes.py @@ -1,5 +1,5 @@ """ -Module containg custom `QDialog` and `QMessageBox` classes. +Module containingg custom `QDialog` and `QMessageBox` classes. """ __all__ = [ From 78b20fce7b052d5cd5f9cdd5b2b7ab6beaebb42b Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Sat, 13 Jun 2026 17:05:59 -0700 Subject: [PATCH 30/30] appease grumpy isort --- bapsf_motion/gui/configure/controllers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bapsf_motion/gui/configure/controllers.py b/bapsf_motion/gui/configure/controllers.py index ae40cd21..c8d05ea9 100644 --- a/bapsf_motion/gui/configure/controllers.py +++ b/bapsf_motion/gui/configure/controllers.py @@ -19,7 +19,7 @@ from abc import abstractmethod from PySide6.QtCore import Qt, QThreadPool, QTimer, Signal, Slot -from PySide6.QtGui import QDoubleValidator, QFont, QCloseEvent +from PySide6.QtGui import QCloseEvent, QDoubleValidator, QFont from PySide6.QtWidgets import ( QComboBox, QGridLayout,