diff --git a/src/surface/gui/gui/pilot_app.py b/src/surface/gui/gui/pilot_app.py index b20836cb..71cfddb1 100644 --- a/src/surface/gui/gui/pilot_app.py +++ b/src/surface/gui/gui/pilot_app.py @@ -15,7 +15,7 @@ def __init__(self) -> None: layout = QHBoxLayout() self.setLayout(layout) - # Look into QStackedLayout for possibly switching between + # TODO Look into QStackedLayout for possibly switching between # 1 big camera feed and 2 smaller ones video_area = SwitchableVideoWidget(["front_cam/image_raw", "bottom_cam/image_raw", diff --git a/src/surface/gui/gui/styles/custom_styles.py b/src/surface/gui/gui/styles/custom_styles.py index a47cf033..f914a1fe 100644 --- a/src/surface/gui/gui/styles/custom_styles.py +++ b/src/surface/gui/gui/styles/custom_styles.py @@ -1,21 +1,45 @@ -class Style(): - """Represents a single class that can be applied to gui objects to change their appearance.""" +from PyQt6.QtWidgets import QWidget, QPushButton - PROPERTY_NAME: str +class IndicatorMixin(QWidget): -class WidgetState(Style): - """Represents the state of a widget that can be alternately active or inactive.""" - - PROPERTY_NAME = "widgetState" + _PROPERTY_NAME = "widgetState" # A component is running, enabled, or armed - ON = "on" + _ON = "on" # A component is disabled, not running, or disarmed, but could be enabled through this widget - OFF = "off" + _OFF = "off" # A component is disabled, not expected to have any effect or perform its function because of # some external factor, either another widget or something external to the gui # For example, a the arm button when the pi is not connected - INACTIVE = "inactive" + _INACTIVE = "inactive" + + # Removes any state + _NO_STATE = "" + + def set_on(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._ON) + self._update_style() + + def set_off(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._OFF) + self._update_style() + + def set_inactive(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._INACTIVE) + self._update_style() + + def remove_state(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._NO_STATE) + self._update_style() + + def _update_style(self) -> None: + style = self.style() + if style is not None: + style.polish(self) + + +class ButtonIndicator(QPushButton, IndicatorMixin): + pass diff --git a/src/surface/gui/gui/widgets/arm.py b/src/surface/gui/gui/widgets/arm.py index 34ccb867..c1e22031 100644 --- a/src/surface/gui/gui/widgets/arm.py +++ b/src/surface/gui/gui/widgets/arm.py @@ -1,9 +1,10 @@ from gui.event_nodes.client import GUIEventClient from gui.event_nodes.subscriber import GUIEventSubscriber -from gui.styles.custom_styles import WidgetState +from gui.styles.custom_styles import ButtonIndicator from mavros_msgs.srv import CommandBool from PyQt6.QtCore import pyqtSignal, pyqtSlot -from PyQt6.QtWidgets import QHBoxLayout, QPushButton, QWidget +from PyQt6.QtWidgets import QHBoxLayout, QWidget + from rov_msgs.msg import VehicleState @@ -16,7 +17,7 @@ class Arm(QWidget): BUTTON_HEIGHT = 60 BUTTON_STYLESHEET = 'QPushButton { font-size: 20px; }' - command_response_signal: pyqtSignal = pyqtSignal(CommandBool.Response) + command_response_signal = pyqtSignal(CommandBool.Response) vehicle_state_signal = pyqtSignal(VehicleState) def __init__(self) -> None: @@ -26,8 +27,8 @@ def __init__(self) -> None: layout = QHBoxLayout() self.setLayout(layout) - self.arm_button = QPushButton() - self.disarm_button = QPushButton() + self.arm_button = ButtonIndicator() + self.disarm_button = ButtonIndicator() self.arm_button.setText("Arm") self.disarm_button.setText("Disarm") @@ -40,8 +41,8 @@ def __init__(self) -> None: self.arm_button.setStyleSheet(self.BUTTON_STYLESHEET) self.disarm_button.setStyleSheet(self.BUTTON_STYLESHEET) - self.arm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.INACTIVE) - self.disarm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.INACTIVE) + self.arm_button.set_inactive() + self.disarm_button.set_inactive() self.arm_button.clicked.connect(self.arm_clicked) self.disarm_button.clicked.connect(self.disarm_clicked) @@ -81,18 +82,11 @@ def arm_status(self, res: CommandBool.Response) -> None: def vehicle_state_callback(self, msg: VehicleState) -> None: if msg.pixhawk_connected: if msg.armed: - self.arm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.ON) - self.disarm_button.setProperty(WidgetState.PROPERTY_NAME, "") + self.arm_button.set_on() + self.disarm_button.remove_state() else: - self.arm_button.setProperty(WidgetState.PROPERTY_NAME, "") - self.disarm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.OFF) + self.arm_button.remove_state() + self.disarm_button.set_off() else: - self.arm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.INACTIVE) - self.disarm_button.setProperty(WidgetState.PROPERTY_NAME, WidgetState.INACTIVE) - - for button in (self.arm_button, self.disarm_button): - style = button.style() - if style is not None: - style.polish(button) - else: - self.arm_client.get_logger().error("Gui button missing a style") + self.arm_button.set_inactive() + self.disarm_button.set_inactive() diff --git a/src/surface/gui/gui/widgets/circle.py b/src/surface/gui/gui/widgets/circle.py new file mode 100644 index 00000000..23e6d873 --- /dev/null +++ b/src/surface/gui/gui/widgets/circle.py @@ -0,0 +1,31 @@ +from typing import Optional +from gui.styles.custom_styles import IndicatorMixin +from PyQt6.QtCore import QSize, Qt +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QWidget, QLabel + + +class Circle(QLabel): + def __init__(self, parent: Optional[QWidget] = None, + radius: int = 50, + color: Optional[QColor | Qt.GlobalColor] = None) -> None: + super().__init__(parent) + self.setFixedSize(QSize(2 * radius, 2 * radius)) + stylesheet = self.styleSheet() + self.setStyleSheet(f"{stylesheet}border-radius: {radius}px;") + + if color: + self.set_color(color) + + def set_color(self, color: QColor | Qt.GlobalColor) -> None: + if isinstance(color, Qt.GlobalColor): + color = QColor(color) + style = f"background-color: rgb({color.red()}, {color.green()}, {color.blue()});" + self.setStyleSheet(f"{self.styleSheet()}{style}") + + +class CircleIndicator(Circle, IndicatorMixin): + def __init__(self, parent: Optional[QWidget] = None, + radius: int = 50) -> None: + super().__init__(parent, radius) + self.set_inactive() diff --git a/src/surface/gui/gui/widgets/debug_tab.py b/src/surface/gui/gui/widgets/debug_tab.py index 5f5d3a22..9b837ad8 100644 --- a/src/surface/gui/gui/widgets/debug_tab.py +++ b/src/surface/gui/gui/widgets/debug_tab.py @@ -1,9 +1,10 @@ from gui.widgets.arm import Arm +from gui.widgets.heartbeat import HeartbeatWidget from gui.widgets.ip_widget import IPWidget from gui.widgets.logger import Logger from gui.widgets.thruster_tester import ThrusterTester -from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget class DebugWidget(QWidget): @@ -14,6 +15,9 @@ def __init__(self) -> None: top_bar.addWidget(IPWidget(), alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + top_bar.addWidget(HeartbeatWidget(), alignment=Qt.AlignmentFlag.AlignTop | + Qt.AlignmentFlag.AlignLeft) + right_bar = QVBoxLayout() right_bar.addWidget(ThrusterTester()) right_bar.addWidget(Arm()) diff --git a/src/surface/gui/gui/widgets/flood_warning.py b/src/surface/gui/gui/widgets/flood_warning.py index 29a9aed7..436f52bb 100644 --- a/src/surface/gui/gui/widgets/flood_warning.py +++ b/src/surface/gui/gui/widgets/flood_warning.py @@ -1,14 +1,15 @@ from gui.event_nodes.subscriber import GUIEventSubscriber +from gui.widgets.circle import CircleIndicator from PyQt6.QtCore import pyqtSignal, pyqtSlot from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from rov_msgs.msg import Flooding class FloodWarning(QWidget): - signal: pyqtSignal = pyqtSignal(Flooding) + signal = pyqtSignal(Flooding) def __init__(self) -> None: super().__init__() @@ -18,17 +19,23 @@ def __init__(self) -> None: # Create a latch variable self.warning_msg_latch: bool = False # Create basic 2 vertical stacked boxes layout - self.flood_layout = QVBoxLayout() + flood_layout = QVBoxLayout() # Create the label that tells us what this is - self.label = QLabel('Flooding Indicator') + + header_layout = QHBoxLayout() + label = QLabel('Flooding Status') font = QFont("Arial", 14) - self.label.setFont(font) - self.flood_layout.addWidget(self.label) + label.setFont(font) + header_layout.addWidget(label) + self.indicator_circle = CircleIndicator(radius=10) + header_layout.addWidget(self.indicator_circle) + + flood_layout.addLayout(header_layout) self.indicator = QLabel('No Water present') self.indicator.setFont(font) - self.flood_layout.addWidget(self.indicator) - self.setLayout(self.flood_layout) + flood_layout.addWidget(self.indicator) + self.setLayout(flood_layout) @pyqtSlot(Flooding) def refresh(self, msg: Flooding) -> None: @@ -36,8 +43,10 @@ def refresh(self, msg: Flooding) -> None: self.indicator.setText('FLOODING') self.subscription.get_logger().error("Robot is actively flooding, do something!") self.warning_msg_latch = True + self.indicator_circle.set_off() else: self.indicator.setText('No Water present') + self.indicator_circle.set_on() if self.warning_msg_latch: self.subscription.get_logger().warning("Robot flooding has reset itself.") self.warning_msg_latch = False diff --git a/src/surface/gui/gui/widgets/heartbeat.py b/src/surface/gui/gui/widgets/heartbeat.py new file mode 100644 index 00000000..c9f2392f --- /dev/null +++ b/src/surface/gui/gui/widgets/heartbeat.py @@ -0,0 +1,58 @@ +from gui.event_nodes.subscriber import GUIEventSubscriber +from gui.widgets.circle import CircleIndicator +from PyQt6.QtCore import pyqtSignal, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from rov_msgs.msg import VehicleState + + +class HeartbeatWidget(QWidget): + + signal = pyqtSignal(VehicleState) + + def __init__(self) -> None: + super().__init__() + + self.signal.connect(self.refresh) + self.subscription = GUIEventSubscriber(VehicleState, 'vehicle_state_event', self.signal) + # Create a latch variable + self.warning_msg_latch: bool = False + + heartbeat_layout = QVBoxLayout() + + font = QFont("Arial", 14) + + pi_status_layout = QHBoxLayout() + self.pi_indicator = QLabel('No Pi Status') + self.pi_indicator.setFont(font) + pi_status_layout.addWidget(self.pi_indicator) + self.pi_indicator_circle = CircleIndicator(radius=10) + pi_status_layout.addWidget(self.pi_indicator_circle) + heartbeat_layout.addLayout(pi_status_layout) + + pixhawk_status_layout = QHBoxLayout() + self.pixhawk_indicator = QLabel('No Pixhawk Status') + self.pixhawk_indicator.setFont(font) + pixhawk_status_layout.addWidget(self.pixhawk_indicator) + self.pixhawk_indicator_circle = CircleIndicator(radius=10) + pixhawk_status_layout.addWidget(self.pixhawk_indicator_circle) + heartbeat_layout.addLayout(pixhawk_status_layout) + + self.setLayout(heartbeat_layout) + + @pyqtSlot(VehicleState) + def refresh(self, msg: VehicleState) -> None: + if msg.pi_connected: + self.pi_indicator.setText('Pi Connected') + self.pi_indicator_circle.set_on() + else: + self.pi_indicator.setText('Pi Disconnected') + self.pi_indicator_circle.set_off() + + if msg.pixhawk_connected: + self.pixhawk_indicator.setText('Pixhawk Connected') + self.pixhawk_indicator_circle.set_on() + else: + self.pixhawk_indicator.setText('Pixhawk Disconnected') + self.pixhawk_indicator_circle.set_off()