diff --git a/src/cfclient/ui/dialogs/inputconfigdialogue.py b/src/cfclient/ui/dialogs/inputconfigdialogue.py index 829144029..42eccc9a6 100644 --- a/src/cfclient/ui/dialogs/inputconfigdialogue.py +++ b/src/cfclient/ui/dialogs/inputconfigdialogue.py @@ -35,6 +35,7 @@ from PyQt6.QtCore import QTimer from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QMessageBox +from cfclient.utils.config import Config from cfclient.utils.config_manager import ConfigManager from PyQt6 import QtWidgets from PyQt6 import uic @@ -51,6 +52,8 @@ class InputConfigDialogue(QtWidgets.QWidget, inputconfig_widget_class): + closed = pyqtSignal() + def __init__(self, joystickReader, *args): super(InputConfigDialogue, self).__init__(*args) self.setupUi(self) @@ -171,6 +174,9 @@ def __init__(self, joystickReader, *args): self._map = {} self._saved_open_device = None + self._original_input_map = None + self._original_input_map_name = None + self._config_was_saved = False @staticmethod def _scale(max_value, value): @@ -224,8 +230,13 @@ def _show_config_popup(self, caption, message, directions=[]): self._popup.show() def _start_configuration(self): - self._input.enableRawReading( - str(self.inputDeviceSelector.currentText())) + device_name = str(self.inputDeviceSelector.currentText()) + dev = self._input._get_device_from_name(device_name) + if dev: + self._original_input_map = dev.input_map + self._original_input_map_name = getattr( + dev, 'input_map_name', None) + self._input.enableRawReading(device_name) self._input_device_reader.start_reading() self._populate_config_dropdown() self.profileCombo.setEnabled(True) @@ -391,6 +402,14 @@ def _save_config(self): if config_name is None: config_name = str(self.profileCombo.currentText()) ConfigManager().save_config(self._map, config_name) + # Update the name on the raw device so the Flight tab shows it. + # The actual mapping data is already applied via set_raw_input_map. + if self._input._input_device: + self._input._input_device.input_map_name = config_name + device_name = str(self.inputDeviceSelector.currentText()) + Config().get("device_config_mapping")[ + device_name] = config_name + self._config_was_saved = True self.close() def showEvent(self, event): @@ -403,8 +422,15 @@ def closeEvent(self, event): """Called when dialog is closed""" self._input.stop_raw_reading() self._input_device_reader.stop_reading() + if not self._config_was_saved and self._original_input_map is not None: + device_name = str(self.inputDeviceSelector.currentText()) + dev = self._input._get_device_from_name(device_name) + if dev: + dev.input_map = self._original_input_map + dev.input_map_name = self._original_input_map_name # self._input.start_input(self._saved_open_device) self._input.resume_input() + self.closed.emit() class DeviceReader(QThread): diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index 0990cb097..491026ec6 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -61,7 +61,6 @@ from PyQt6.QtGui import QShortcut from PyQt6.QtGui import QDesktopServices from PyQt6.QtGui import QPalette -from PyQt6.QtWidgets import QLabel from PyQt6.QtWidgets import QMenu from PyQt6.QtWidgets import QMessageBox @@ -100,6 +99,7 @@ class MainUI(QtWidgets.QMainWindow, main_window_class): disconnectedSignal = pyqtSignal(str) linkQualitySignal = pyqtSignal(float) + _gamepad_device_updated = pyqtSignal(str, str, str) _input_device_error_signal = pyqtSignal(str) _input_discovery_signal = pyqtSignal(object) _log_error_signal = pyqtSignal(object, str) @@ -130,18 +130,8 @@ def __init__(self, *args): self.scanner.interfaceFoundSignal.connect(self.foundInterfaces) self.scanner.start() - # Create and start the Input Reader - self._statusbar_label = QLabel("No input-device found, insert one to" - " fly.") - self.statusBar().addWidget(self._statusbar_label) - - # - # We use this hacky-trick to find out if we are in dark-mode and - # figure out what bgcolor to set from that. We always use the current - # palette forgreound. - # - self.textColor = self._statusbar_label.palette().color(QPalette.ColorRole.WindowText) - self.bgColor = self._statusbar_label.palette().color(QPalette.ColorRole.Window) + self.textColor = self.palette().color(QPalette.ColorRole.WindowText) + self.bgColor = self.palette().color(QPalette.ColorRole.Window) self.isDark = self.textColor.value() > self.bgColor.value() self.joystickReader = JoystickReader() @@ -552,10 +542,27 @@ def set_preferred_dock_area(self, area): tab_toolbox = dock_widget.tab_toolbox tab_toolbox.set_preferred_dock_area(area) + def _rescan_devices(self): + self._gamepad_device_updated.emit("No input device connected", "—", "—") + self._menu_devices.clear() + self._active_device = "" + self.joystickReader.stop_input() + + # for c in self._menu_mappings.actions(): + # c.setEnabled(False) + # devs = self.joystickReader.available_devices() + # if (len(devs) > 0): + # self.device_discovery(devs) + def _show_input_device_config_dialog(self): self.inputConfig = InputConfigDialogue(self.joystickReader) + self.inputConfig.closed.connect(self._on_input_config_closed) self.inputConfig.show() + def _on_input_config_closed(self): + self._sync_input_map_menus() + self._update_input_device_status() + def _show_connect_dialog(self): self.logConfigDialogue.show() @@ -583,6 +590,7 @@ def _update_battery(self, timestamp, data, logconf): def _connected(self): self.uiState = UIState.CONNECTED self._update_ui_state() + self.joystickReader.require_thrust_zero() Config().set("link_uri", str(self._connectivity_manager.get_interface())) @@ -709,41 +717,87 @@ def _mux_selected(self, checked): if type(dev_node) is QAction and dev_node.isChecked(): dev_node.toggled.emit(True) - self._update_input_device_footer() - - def _get_dev_status(self, device): - msg = "{}".format(device.name) - if device.supports_mapping: - map_name = "No input mapping" - if device.input_map: - # Display the friendly name instead of the config file name - map_name = ConfigManager().get_display_name(device.input_map_name) - msg += " ({})".format(map_name) - return msg + self._update_input_device_status() - def _update_input_device_footer(self): - """Update the footer in the bottom of the UI with status for the - input device and its mapping""" + def _sync_input_map_menus(self): + """Sync the Input device menu to reflect the current mapping. - msg = "" + After the config dialog saves a new mapping, the menu may be + missing the new entry and still have the old one checked. Walk + every role menu, find the checked device, and make sure its map + sub-menu contains and selects the active mapping name. + """ + for menu in self._all_role_menus: + role_menu = menu["rolemenu"] + for dev_node in role_menu.actions(): + if not dev_node.isChecked(): + continue + data = dev_node.data() + if data is None or not isinstance(data, tuple): + continue + if len(data) < 3: + continue + (map_menu, device, _mux_menu) = data + if map_menu is None or not device.supports_mapping: + continue + + active_name = getattr(device, 'input_map_name', None) + if not active_name: + continue + + # Get the QActionGroup from an existing map action + map_actions = map_menu.actions() + map_group = None + if map_actions: + map_group = map_actions[0].actionGroup() + + # Add a menu entry if the config is new + existing = {a.text() for a in map_actions} + if active_name not in existing and map_group: + node = QAction(active_name, map_menu, + checkable=True, enabled=True) + node.toggled.connect(self._inputconfig_selected) + node.setData(dev_node) + map_menu.addAction(node) + map_group.addAction(node) + + # Check the active mapping without triggering + # _inputconfig_selected (which would reload the config + # from disk and overwrite the live mapping). + # Uncheck all first since blockSignals prevents the + # exclusive QActionGroup from doing it automatically. + for action in map_menu.actions(): + action.blockSignals(True) + action.setChecked(action.text() == active_name) + action.blockSignals(False) + + def _update_input_device_status(self): + """Update the gamepad device info in the Flight tab.""" if len(self.joystickReader.available_devices()) > 0: mux = self.joystickReader._selected_mux - msg = "Using {} mux with ".format(mux.name) - for key in list(mux._devs.keys())[:-1]: - if mux._devs[key]: - msg += "{}, ".format(self._get_dev_status(mux._devs[key])) + mux_name = mux.name + device_names = [] + mapping_names = [] + for dev in mux._devs.values(): + if dev: + device_names.append(dev.name) + if dev.supports_mapping: + mapping_names.append( + ConfigManager().get_display_name(dev.input_map_name) + if dev.input_map else "No mapping") + else: + mapping_names.append("N/A") else: - msg += "N/A, " - # Last item - key = list(mux._devs.keys())[-1] - if mux._devs[key]: - msg += "{}".format(self._get_dev_status(mux._devs[key])) - else: - msg += "N/A" + device_names.append("N/A") + mapping_names.append("N/A") + device_str = ", ".join(device_names) + mapping_str = ", ".join(mapping_names) else: - msg = "No input device found" - self._statusbar_label.setText(msg) + device_str = "No input device found" + mapping_str = "—" + mux_name = "—" + self._gamepad_device_updated.emit(device_str, mapping_str, mux_name) def _inputdevice_selected(self, checked): """Called when a new input device has been selected from the menu. The @@ -777,7 +831,7 @@ def _inputdevice_selected(self, checked): self._mapping_support = self.joystickReader.start_input( device.name, role_in_mux) - self._update_input_device_footer() + self._update_input_device_status() def _inputconfig_selected(self, checked): """Called when a new configuration has been selected from the menu. The @@ -790,7 +844,7 @@ def _inputconfig_selected(self, checked): selected_mapping = self.sender().config_name device = self.sender().data().data()[1] self.joystickReader.set_input_map(device.name, selected_mapping) - self._update_input_device_footer() + self._update_input_device_status() def device_discovery(self, devs): """Called when new devices have been added""" @@ -870,7 +924,7 @@ def device_discovery(self, devs): self._all_role_menus[0]["rolemenu"].actions()[0].setChecked(True) logger.info("Select first device") - self._update_input_device_footer() + self._update_input_device_status() def _open_config_folder(self): QDesktopServices.openUrl( diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index 3706d104b..4fec3269a 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -92,7 +92,8 @@ class FlightTab(TabToolbox, flight_tab_class): _log_data_signal = pyqtSignal(int, object, object) _pose_data_signal = pyqtSignal(object, object) - + _thrust_lock_signal = pyqtSignal(bool) + _gamepad_device_signal = pyqtSignal(str, str, str) _input_updated_signal = pyqtSignal(float, float, float, float) _rp_trim_updated_signal = pyqtSignal(float, float) _emergency_stop_updated_signal = pyqtSignal(bool) @@ -100,7 +101,6 @@ class FlightTab(TabToolbox, flight_tab_class): _assisted_control_updated_signal = pyqtSignal(bool) _heighthold_input_updated_signal = pyqtSignal(float, float, float, float) _hover_input_updated_signal = pyqtSignal(float, float, float, float) - _log_error_signal = pyqtSignal(object, str) # UI_DATA_UPDATE_FPS = 10 @@ -122,6 +122,14 @@ def __init__(self, helper): super(FlightTab, self).__init__(helper, 'Flight Control') self.setupUi(self) + self._thrust_lock_signal.connect(self._thrust_lock_updated) + self._helper.inputDeviceReader.thrust_lock_active.add_callback( + self._thrust_lock_signal.emit) + + self._gamepad_device_signal.connect(self._gamepad_device_updated) + self._helper.mainUI._gamepad_device_updated.connect( + self._gamepad_device_signal.emit) + self.disconnectedSignal.connect(self.disconnected) self.connectionFinishedSignal.connect(self.connected) # Incomming signals @@ -162,6 +170,8 @@ def __init__(self, helper): self._log_error_signal.connect(self._logging_error) self._isConnected = False + self._has_device = False + self._has_mapping = False # Connect UI signals that are in this tab self.flightModeCombo.currentIndexChanged.connect(self.flightmodeChange) @@ -216,6 +226,42 @@ def __init__(self, helper): self._helper.pose_logger.data_received_cb.add_callback(self._pose_data_signal.emit) + def _thrust_lock_updated(self, active): + if active: + self.gamepadStatusLabel.setText("Lower throttle to arm") + self.gamepadStatusLabel.setStyleSheet("color: red;") + else: + if self._has_device and self._has_mapping: + self.gamepadStatusLabel.setText("Ready") + self.gamepadStatusLabel.setStyleSheet("") + elif self._has_device: + self.gamepadStatusLabel.setText( + "No mapping selected") + self.gamepadStatusLabel.setStyleSheet( + "color: orange;") + else: + self.gamepadStatusLabel.setText("\u2014") + self.gamepadStatusLabel.setStyleSheet("") + + def _gamepad_device_updated(self, device, mapping, mux): + self.gamepadNameLabel.setText(device) + self.gamepadMappingLabel.setText(mapping) + self.gamepadMuxLabel.setText(mux) + no_device_strings = ("No input device connected", + "No input device found") + self._has_device = device not in no_device_strings + if self._has_device: + self._has_mapping = "No mapping" not in mapping + if not self._has_mapping: + self.gamepadStatusLabel.setText( + "No mapping selected") + self.gamepadStatusLabel.setStyleSheet( + "color: orange;") + else: + self._has_mapping = False + self.gamepadStatusLabel.setText("\u2014") + self.gamepadStatusLabel.setStyleSheet("") + def _set_limiting_enabled(self, rp_limiting_enabled, yaw_limiting_enabled, thrust_limiting_enabled): self.targetCalRoll.setEnabled(rp_limiting_enabled) diff --git a/src/cfclient/ui/tabs/flightTab.ui b/src/cfclient/ui/tabs/flightTab.ui index 8634d0e22..afb4e04be 100644 --- a/src/cfclient/ui/tabs/flightTab.ui +++ b/src/cfclient/ui/tabs/flightTab.ui @@ -360,6 +360,71 @@ + + + + Gamepad + + + + + + Status + + + + + + + + + + + + + + Gamepad + + + + + + + No input device found + + + + + + + Mapping + + + + + + + + + + + + + + Mux + + + + + + + + + + + + + @@ -649,7 +714,7 @@ - Gamepad Input + Gamepad Setpoint Qt::AlignmentFlag::AlignCenter diff --git a/src/cfclient/utils/input/__init__.py b/src/cfclient/utils/input/__init__.py index 09628b9d3..c217b7837 100644 --- a/src/cfclient/utils/input/__init__.py +++ b/src/cfclient/utils/input/__init__.py @@ -166,6 +166,8 @@ def __init__(self, do_device_discovery=True): ConfigManager().get_list_of_configs() + self._thrust_interlock = False + self.input_updated = Caller() self.assisted_input_updated = Caller() self.heighthold_input_updated = Caller() @@ -182,6 +184,9 @@ def __init__(self, do_device_discovery=True): # Call with 3 bools (rp_limiting, yaw_limiting, thrust_limiting) self.limiting_updated = Caller() + # Called with True when interlock is engaged, False when released + self.thrust_lock_active = Caller() + def _get_device_from_name(self, device_name): """Get the raw device from a name""" for d in readers.devices(): @@ -189,6 +194,30 @@ def _get_device_from_name(self, device_name): return d return None + def require_thrust_zero(self): + """Engage the thrust interlock. + + While active, thrust output is forced to zero until the physical + throttle is brought to zero by the user. This prevents unexpected + take-off when connecting with a bad input mapping or after switching + mappings. + + If thrust is already at zero, the interlock is not engaged to avoid + a brief red flash in the UI. + """ + if self._thrust_interlock: + return + + try: + data = self._selected_mux.read() + if data and data.thrust < 1: + return + except Exception: + pass + + self._thrust_interlock = True + self.thrust_lock_active.call(True) + def set_hover_max_height(self, height): self._hover_max_height = height @@ -315,6 +344,7 @@ def set_input_map(self, device_name, input_map_name): dev.input_map_name = input_map_name Config().get("device_config_mapping")[device_name] = input_map_name dev.set_dead_band(self._rp_dead_band) + self.require_thrust_zero() def start_input(self, device_name, role="Device", config_name=None): """ @@ -330,6 +360,7 @@ def start_input(self, device_name, role="Device", config_name=None): self.limiting_updated.call(device.limit_rp, device.limit_yaw, device.limit_thrust) + self.require_thrust_zero() self._read_timer.start() return device.supports_mapping except Exception: @@ -515,6 +546,12 @@ def read_input(self): else: # Using alt hold the data is not in a percentage if not data.assistedControl: + if self._thrust_interlock: + if data.thrust < 1: + self._thrust_interlock = False + self.thrust_lock_active.call(False) + else: + data.thrust = 0 data.thrust = JoystickReader.p2t(data.thrust) # Thrust might be <0 here, make sure it's not otherwise