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