From 9391baf5d4422a283ffa996515ce758dbac78bea Mon Sep 17 00:00:00 2001 From: alireza787b Date: Sun, 30 Aug 2026 02:45:20 +0000 Subject: [PATCH] feat(PlanView): choose PX4 VTOL takeoff mode Offer VTOL takeoff and multicopter takeoff choices for PX4 VTOL missions while keeping VTOL takeoff as the default. Use the existing MAVLink commands and preserve the current behavior for ArduPilot and non-VTOL vehicles. Update mission-state calculations, PX4 command metadata, the Plan View guide, and focused tests for insertion, persistence, path state, compatibility, and the offline planning UI. --- docs/en/qgc-user-guide/plan_view/plan_view.md | 3 + src/FirmwarePlugin/FirmwarePlugin.h | 1 + .../PX4/PX4-MavCmdInfoVTOL.json | 5 + src/FirmwarePlugin/PX4/PX4FirmwarePlugin.cc | 3 + src/MissionManager/MissionController.cc | 54 ++++-- src/MissionManager/MissionController.h | 11 +- .../MissionFlightStatusCalculator.cc | 18 +- .../MissionFlightStatusCalculator.h | 4 +- src/MissionManager/TakeoffMissionItem.cc | 17 +- src/PlanView/PlanView.qml | 41 +++++ src/Vehicle/VehicleSupports.cc | 5 + src/Vehicle/VehicleSupports.h | 2 + test/MissionManager/MissionCommandTreeTest.cc | 10 ++ test/MissionManager/MissionCommandTreeTest.h | 1 + test/MissionManager/MissionControllerTest.cc | 159 ++++++++++++++++++ test/MissionManager/MissionControllerTest.h | 8 + .../PlanMasterControllerTest.cc | 32 +++- .../MissionManager/PlanMasterControllerTest.h | 1 + test/QmlUITests/PlanViewUITest.cc | 62 +++++++ test/QmlUITests/PlanViewUITest.h | 5 + 20 files changed, 412 insertions(+), 30 deletions(-) diff --git a/docs/en/qgc-user-guide/plan_view/plan_view.md b/docs/en/qgc-user-guide/plan_view/plan_view.md index a41dc37f197c..b782c75ba3b1 100644 --- a/docs/en/qgc-user-guide/plan_view/plan_view.md +++ b/docs/en/qgc-user-guide/plan_view/plan_view.md @@ -54,6 +54,9 @@ The plan tools are a vertical tool strip on the left side of the map, used for a Inserts a takeoff command into the mission. This tool is available for all vehicle types except rovers. +For PX4 VTOL vehicles, choose **VTOL takeoff** to climb and transition to fixed-wing flight, or +**Multicopter takeoff** to remain in multicopter mode. The VTOL takeoff remains the default choice. + ### Pattern The [Pattern](pattern.md) tool simplifies the creation of missions for flying complex geometries, including [surveys](../plan_view/pattern_survey.md) and [structure scans](../plan_view/pattern_structure_scan_v2.md). diff --git a/src/FirmwarePlugin/FirmwarePlugin.h b/src/FirmwarePlugin/FirmwarePlugin.h index 3de580e6631a..f05307fe206f 100644 --- a/src/FirmwarePlugin/FirmwarePlugin.h +++ b/src/FirmwarePlugin/FirmwarePlugin.h @@ -87,6 +87,7 @@ class FirmwarePlugin : public QObject ROIModeCapability = 1 << 5, ///< Vehicle supports ROI (both in Fly guided mode and from Plan creation) ChangeHeadingCapability = 1 << 6, ///< Vehicle supports changing heading at current location GuidedTakeoffCapability = 1 << 7, ///< Vehicle supports guided takeoff + VTOLMulticopterTakeoffCapability = 1 << 8, ///< VTOL supports MAV_CMD_NAV_TAKEOFF while remaining in multicopter mode }; /// Parameter name remapping support: diff --git a/src/FirmwarePlugin/PX4/PX4-MavCmdInfoVTOL.json b/src/FirmwarePlugin/PX4/PX4-MavCmdInfoVTOL.json index e03df84d9b8e..163a3f3fcdfa 100644 --- a/src/FirmwarePlugin/PX4/PX4-MavCmdInfoVTOL.json +++ b/src/FirmwarePlugin/PX4/PX4-MavCmdInfoVTOL.json @@ -4,5 +4,10 @@ "fileType": "MavCmdInfo", "mavCmdInfo": [ + { + "id": 22, + "comment": "MAV_CMD_NAV_TAKEOFF", + "description": "Take off vertically and continue the mission in multicopter mode." + } ] } diff --git a/src/FirmwarePlugin/PX4/PX4FirmwarePlugin.cc b/src/FirmwarePlugin/PX4/PX4FirmwarePlugin.cc index ad9f0bbd9e16..8e458815988d 100644 --- a/src/FirmwarePlugin/PX4/PX4FirmwarePlugin.cc +++ b/src/FirmwarePlugin/PX4/PX4FirmwarePlugin.cc @@ -174,6 +174,9 @@ bool PX4FirmwarePlugin::isCapable(const Vehicle *vehicle, FirmwareCapabilities c if (vehicle->multiRotor() || vehicle->vtol()) { available |= TakeoffVehicleCapability | GuidedTakeoffCapability | OrbitModeCapability; } + if (vehicle->vtol()) { + available |= VTOLMulticopterTakeoffCapability; + } if (vehicle->fixedWing()) { available |= TakeoffVehicleCapability | OrbitModeCapability; } diff --git a/src/MissionManager/MissionController.cc b/src/MissionManager/MissionController.cc index 9326262fca21..fbdd39b08b16 100644 --- a/src/MissionManager/MissionController.cc +++ b/src/MissionManager/MissionController.cc @@ -24,6 +24,7 @@ #include "TakeoffMissionItem.h" #include "PlanViewSettings.h" #include "MissionCommandTree.h" +#include "AppMessages.h" #include "QGCMath.h" #include "QGCLoggingCategory.h" @@ -71,7 +72,7 @@ MissionController::~MissionController() void MissionController::_resetMissionFlightStatus(void) { - _flightStatusCalc.reset(_controllerVehicle, _managerVehicle, _missionContainsVTOLTakeoff); + _flightStatusCalc.reset(_controllerVehicle, _managerVehicle, _missionStartsInVTOLMulticopterMode); _missionFlightStatus = _flightStatusCalc.status(); emit missionPlannedDistanceChanged(_missionFlightStatus.plannedDistance); @@ -313,9 +314,33 @@ VisualMissionItem* MissionController::insertSimpleMissionItem(QGeoCoordinate coo } VisualMissionItem* MissionController::insertTakeoffItem(QGeoCoordinate /*coordinate*/, int visualItemIndex, bool makeCurrentItem) +{ + return _insertTakeoffItemWorker( + _controllerVehicle->vtol() ? MAV_CMD_NAV_VTOL_TAKEOFF : MAV_CMD_NAV_TAKEOFF, + visualItemIndex, + makeCurrentItem); +} + +VisualMissionItem* MissionController::insertVTOLMulticopterTakeoffItem( + QGeoCoordinate /*coordinate*/, int visualItemIndex, bool makeCurrentItem) +{ + const FirmwarePlugin* firmwarePlugin = _controllerVehicle->firmwarePlugin(); + const QList supportedCommands = firmwarePlugin->supportedMissionCommands(_controllerVehicle->vehicleClass()); + const bool commandSupported = supportedCommands.isEmpty() || supportedCommands.contains(MAV_CMD_NAV_TAKEOFF); + if (!_controllerVehicle->vtol() + || !firmwarePlugin->isCapable(_controllerVehicle, FirmwarePlugin::VTOLMulticopterTakeoffCapability) + || !commandSupported) { + QGC::showAppMessage(tr("Multicopter takeoff is not supported for this vehicle and firmware.")); + return nullptr; + } + + return _insertTakeoffItemWorker(MAV_CMD_NAV_TAKEOFF, visualItemIndex, makeCurrentItem); +} + +VisualMissionItem* MissionController::_insertTakeoffItemWorker(MAV_CMD command, int visualItemIndex, bool makeCurrentItem) { int sequenceNumber = _nextSequenceNumber(); - _takeoffMissionItem = new TakeoffMissionItem(_controllerVehicle->vtol() ? MAV_CMD_NAV_VTOL_TAKEOFF : MAV_CMD_NAV_TAKEOFF, _masterController, _flyView, _settingsItem, false /* forLoad */); + _takeoffMissionItem = new TakeoffMissionItem(command, _masterController, _flyView, _settingsItem, false /* forLoad */); _takeoffMissionItem->setSequenceNumber(sequenceNumber); _initVisualItem(_takeoffMissionItem); @@ -996,7 +1021,7 @@ void MissionController::_recalcFlightPathSegments(void) FlightPathSegmentHashTable oldSegmentTable = _flightPathSegmentHashTable; - _missionContainsVTOLTakeoff = false; + _missionStartsInVTOLMulticopterMode = false; _flightPathSegmentHashTable.clear(); _simpleFlightPathSegments.beginResetModel(); @@ -1036,14 +1061,19 @@ void MissionController::_recalcFlightPathSegments(void) MAV_CMD command = simpleItem->mavCommand(); switch (command) { case MAV_CMD_NAV_TAKEOFF: + if (firstCoordinateNotFound + && _controllerVehicle->firmwarePlugin()->isCapable( + _controllerVehicle, FirmwarePlugin::VTOLMulticopterTakeoffCapability)) { + _missionStartsInVTOLMulticopterMode = true; + } + if (!linkEndToHome && firstCoordinateNotFound) { + linkStartToHome = true; + } + break; case MAV_CMD_NAV_VTOL_TAKEOFF: - _missionContainsVTOLTakeoff = command == MAV_CMD_NAV_VTOL_TAKEOFF; - if (!linkEndToHome) { - // If we still haven't found the first coordinate item and we hit a takeoff command this means the mission starts from the ground. - // Link the first item back to home to show that. - if (firstCoordinateNotFound) { - linkStartToHome = true; - } + if (!linkEndToHome && firstCoordinateNotFound) { + _missionStartsInVTOLMulticopterMode = true; + linkStartToHome = true; } break; case MAV_CMD_NAV_RETURN_TO_LAUNCH: @@ -1173,7 +1203,9 @@ void MissionController::_recalcMissionFlightStatus() qCDebug(MissionControllerLog) << "_recalcMissionFlightStatus"; - _flightStatusCalc.recalc(_visualItems, _settingsItem, _controllerVehicle, _managerVehicle, _appSettings, _planViewSettings, _missionContainsVTOLTakeoff); + _flightStatusCalc.recalc( + _visualItems, _settingsItem, _controllerVehicle, _managerVehicle, _appSettings, _planViewSettings, + _missionStartsInVTOLMulticopterMode); _missionFlightStatus = _flightStatusCalc.status(); _minAMSLAltitude = _flightStatusCalc.minAMSLAltitude(); _maxAMSLAltitude = _flightStatusCalc.maxAMSLAltitude(); diff --git a/src/MissionManager/MissionController.h b/src/MissionManager/MissionController.h index 61ad9a658253..84a27bde99d1 100644 --- a/src/MissionManager/MissionController.h +++ b/src/MissionManager/MissionController.h @@ -118,6 +118,14 @@ class MissionController : public PlanElementController /// @return Newly created item Q_INVOKABLE VisualMissionItem* insertTakeoffItem(QGeoCoordinate coordinate, int visualItemIndex, bool makeCurrentItem = false); + /// Add a multicopter takeoff item for a VTOL which supports remaining in multicopter mode + /// @param coordinate: Coordinate for item + /// @param visualItemIndex: index to insert at, -1 for end of list + /// @param makeCurrentItem: true: Make this item the current item + /// @return Newly created item, or nullptr if the vehicle does not support this command behavior + Q_INVOKABLE VisualMissionItem* insertVTOLMulticopterTakeoffItem( + QGeoCoordinate coordinate, int visualItemIndex, bool makeCurrentItem = false); + /// Add a new land item to the list /// @param coordinate: Coordinate for item /// @param visualItemIndex: index to insert at, -1 for end of list @@ -385,6 +393,7 @@ private slots: void _initLoadedVisualItems (QmlObjectListModel* loadedVisualItems); FlightPathSegment* _addFlightPathSegment (FlightPathSegmentHashTable& prevItemPairHashTable, VisualItemPair& pair, bool mavlinkTerrainFrame); VisualMissionItem* _insertSimpleMissionItemWorker (QGeoCoordinate coordinate, MAV_CMD command, int visualItemIndex, bool makeCurrentItem); + VisualMissionItem* _insertTakeoffItemWorker (MAV_CMD command, int visualItemIndex, bool makeCurrentItem); void _insertComplexMissionItemWorker (const QGeoCoordinate& mapCenterCoordinate, ComplexMissionItem* complexItem, int visualItemIndex, bool makeCurrentItem); bool _isROIBeginItem (SimpleMissionItem* simpleItem); bool _isROICancelItem (SimpleMissionItem* simpleItem); @@ -450,7 +459,7 @@ private slots: bool _isROIBeginCurrentItem = false; double _minAMSLAltitude = 0; double _maxAMSLAltitude = 0; - bool _missionContainsVTOLTakeoff = false; + bool _missionStartsInVTOLMulticopterMode = false; QGroundControlQmlGlobal::AltitudeFrame _globalAltFrame = QGroundControlQmlGlobal::AltitudeFrameRelative; diff --git a/src/MissionManager/MissionFlightStatusCalculator.cc b/src/MissionManager/MissionFlightStatusCalculator.cc index 99ebedafff4c..e45f1ec1d5a8 100644 --- a/src/MissionManager/MissionFlightStatusCalculator.cc +++ b/src/MissionManager/MissionFlightStatusCalculator.cc @@ -12,7 +12,8 @@ #include -void MissionFlightStatusCalculator::reset(Vehicle* controllerVehicle, Vehicle* managerVehicle, bool missionContainsVTOLTakeoff) +void MissionFlightStatusCalculator::reset( + Vehicle* controllerVehicle, Vehicle* managerVehicle, bool missionStartsInVTOLMulticopterMode) { _status.totalDistance = 0.0; _status.plannedDistance = 0.0; @@ -36,7 +37,9 @@ void MissionFlightStatusCalculator::reset(Vehicle* controllerVehicle, Vehicle* m _status.cruiseAmpsTotal = 0; _status.batteryChangePoint = -1; _status.batteriesRequired = -1; - _status.vtolMode = missionContainsVTOLTakeoff ? QGCMAVLink::VehicleClassMultiRotor : QGCMAVLink::VehicleClassFixedWing; + _status.vtolMode = missionStartsInVTOLMulticopterMode + ? QGCMAVLink::VehicleClassMultiRotor + : QGCMAVLink::VehicleClassFixedWing; controllerVehicle->firmwarePlugin()->batteryConsumptionData(controllerVehicle, _status.mAhBattery, _status.hoverAmps, _status.cruiseAmps); if (_status.mAhBattery != 0) { @@ -51,7 +54,7 @@ void MissionFlightStatusCalculator::recalc(QmlObjectListModel* visualItems, Vehicle* managerVehicle, AppSettings* appSettings, PlanViewSettings* planViewSettings, - bool missionContainsVTOLTakeoff) + bool missionStartsInVTOLMulticopterMode) { bool firstCoordinateItem = true; VisualMissionItem* lastFlyThroughVI = qobject_cast(visualItems->get(0)); @@ -70,7 +73,7 @@ void MissionFlightStatusCalculator::recalc(QmlObjectListModel* visualItems, _minAMSLAltitude = _maxAMSLAltitude = qQNaN(); - reset(controllerVehicle, managerVehicle, missionContainsVTOLTakeoff); + reset(controllerVehicle, managerVehicle, missionStartsInVTOLMulticopterMode); bool linkStartToHome = false; bool foundRTL = false; @@ -242,7 +245,12 @@ void MissionFlightStatusCalculator::recalc(QmlObjectListModel* visualItems, // Update VTOL state if (simpleItem && controllerVehicle->vtol()) { switch (simpleItem->command()) { - case MAV_CMD_NAV_TAKEOFF: // This will do a fixed wing style takeoff + case MAV_CMD_NAV_TAKEOFF: + if (!controllerVehicle->firmwarePlugin()->isCapable( + controllerVehicle, FirmwarePlugin::VTOLMulticopterTakeoffCapability)) { + _status.vtolMode = QGCMAVLink::VehicleClassFixedWing; + } + break; case MAV_CMD_NAV_VTOL_TAKEOFF: // Vehicle goes straight up and then transitions to FW case MAV_CMD_NAV_LAND: _status.vtolMode = QGCMAVLink::VehicleClassFixedWing; diff --git a/src/MissionManager/MissionFlightStatusCalculator.h b/src/MissionManager/MissionFlightStatusCalculator.h index 38fb6f590fb8..723181e1fbbc 100644 --- a/src/MissionManager/MissionFlightStatusCalculator.h +++ b/src/MissionManager/MissionFlightStatusCalculator.h @@ -20,7 +20,7 @@ class MissionFlightStatusCalculator { public: /// Resets the flight status fields to defaults based on vehicle properties. - void reset(Vehicle* controllerVehicle, Vehicle* managerVehicle, bool missionContainsVTOLTakeoff); + void reset(Vehicle* controllerVehicle, Vehicle* managerVehicle, bool missionStartsInVTOLMulticopterMode); /// Runs the full recalculation over all visual items, updating per-item /// display properties and computing aggregate flight statistics. @@ -30,7 +30,7 @@ class MissionFlightStatusCalculator Vehicle* managerVehicle, AppSettings* appSettings, PlanViewSettings* planViewSettings, - bool missionContainsVTOLTakeoff); + bool missionStartsInVTOLMulticopterMode); const MissionFlightStatus_t& status() const { return _status; } double minAMSLAltitude() const { return _minAMSLAltitude; } diff --git a/src/MissionManager/TakeoffMissionItem.cc b/src/MissionManager/TakeoffMissionItem.cc index 1536a6fa6b52..327260d0771d 100644 --- a/src/MissionManager/TakeoffMissionItem.cc +++ b/src/MissionManager/TakeoffMissionItem.cc @@ -1,11 +1,13 @@ #include "TakeoffMissionItem.h" + +#include "FirmwarePlugin.h" #include "MissionCommandTree.h" -#include "QGroundControlQmlGlobal.h" -#include "SettingsManager.h" -#include "PlanViewSettings.h" -#include "PlanMasterController.h" #include "MissionSettingsItem.h" #include "MultiVehicleManager.h" +#include "PlanMasterController.h" +#include "PlanViewSettings.h" +#include "QGroundControlQmlGlobal.h" +#include "SettingsManager.h" #include "Vehicle.h" TakeoffMissionItem::TakeoffMissionItem(PlanMasterController* masterController, bool flyView, MissionSettingsItem* settingsItem, bool forLoad) @@ -112,7 +114,12 @@ bool TakeoffMissionItem::isTakeoffCommand(MAV_CMD command) void TakeoffMissionItem::_initLaunchTakeoffAtSameLocation(void) { if (specifiesCoordinate()) { - if (_controllerVehicle->fixedWing() || _controllerVehicle->vtol()) { + const bool isVTOLMulticopterTakeoff = + _controllerVehicle->vtol() + && mavCommand() == MAV_CMD_NAV_TAKEOFF + && _controllerVehicle->firmwarePlugin()->isCapable( + _controllerVehicle, FirmwarePlugin::VTOLMulticopterTakeoffCapability); + if (_controllerVehicle->fixedWing() || (_controllerVehicle->vtol() && !isVTOLMulticopterTakeoff)) { _setLaunchTakeoffAtSameLocation(false); } else { // PX4 specifies a coordinate for takeoff even for multi-rotor. But it makes more sense to not have a coordinate diff --git a/src/PlanView/PlanView.qml b/src/PlanView/PlanView.qml index 257a99cf3027..c6e26a819138 100644 --- a/src/PlanView/PlanView.qml +++ b/src/PlanView/PlanView.qml @@ -194,6 +194,11 @@ Item { _missionController.insertTakeoffItem(mapCenter(), nextIndex, true /* makeCurrentItem */) } + function insertVTOLMulticopterTakeoffItemAfterCurrent() { + var nextIndex = _missionController.currentPlanViewVIIndex + 1 + _missionController.insertVTOLMulticopterTakeoffItem(mapCenter(), nextIndex, true /* makeCurrentItem */) + } + function insertLandItemAfterCurrent() { var nextIndex = _missionController.currentPlanViewVIIndex + 1 _missionController.insertLandItem(mapCenter(), nextIndex, true /* makeCurrentItem */) @@ -454,6 +459,9 @@ Item { iconSource: "/res/takeoff.svg" enabled: _missionController.isInsertTakeoffValid visible: toolStrip._isMissionLayer && !_planMasterController.controllerVehicle.rover + dropPanelComponent: _planMasterController.controllerVehicle.supports.vtolMulticopterTakeoff + ? vtolTakeoffDropPanel + : null onTriggered: { insertTakeoffItemAfterCurrent() } @@ -808,6 +816,39 @@ Item { } // Column } + Component { + id: vtolTakeoffDropPanel + + ColumnLayout { + spacing: ScreenTools.defaultFontPixelWidth * 0.5 + + QGCLabel { text: qsTr("Choose takeoff mode:") } + + QGCButton { + objectName: "planTakeoff_vtolButton" + text: qsTr("VTOL takeoff") + Layout.fillWidth: true + primary: true + + onClicked: { + insertTakeoffItemAfterCurrent() + dropPanel.hide() + } + } + + QGCButton { + objectName: "planTakeoff_mcButton" + text: qsTr("Multicopter takeoff") + Layout.fillWidth: true + + onClicked: { + insertVTOLMulticopterTakeoffItemAfterCurrent() + dropPanel.hide() + } + } + } + } + QGCPopupDialogFactory { id: promptForPlanUsageOnVehicleChangePopupFactory diff --git a/src/Vehicle/VehicleSupports.cc b/src/Vehicle/VehicleSupports.cc index 7529ee262185..a6e96744866f 100644 --- a/src/Vehicle/VehicleSupports.cc +++ b/src/Vehicle/VehicleSupports.cc @@ -86,3 +86,8 @@ bool VehicleSupports::changeHeading() const { return _vehicle->firmwarePlugin()->isCapable(_vehicle, FirmwarePlugin::ChangeHeadingCapability); } + +bool VehicleSupports::vtolMulticopterTakeoff() const +{ + return _vehicle->firmwarePlugin()->isCapable(_vehicle, FirmwarePlugin::VTOLMulticopterTakeoffCapability); +} diff --git a/src/Vehicle/VehicleSupports.h b/src/Vehicle/VehicleSupports.h index dc7c45b4e687..bc17b1765540 100644 --- a/src/Vehicle/VehicleSupports.h +++ b/src/Vehicle/VehicleSupports.h @@ -30,6 +30,7 @@ class VehicleSupports : public QObject Q_PROPERTY(bool guidedTakeoffWithAltitude READ guidedTakeoffWithAltitude CONSTANT) Q_PROPERTY(bool guidedTakeoffWithoutAltitude READ guidedTakeoffWithoutAltitude CONSTANT) Q_PROPERTY(bool changeHeading READ changeHeading CONSTANT) + Q_PROPERTY(bool vtolMulticopterTakeoff READ vtolMulticopterTakeoff CONSTANT) bool throttleModeCenterZero() const; bool negativeThrust() const; @@ -46,6 +47,7 @@ class VehicleSupports : public QObject bool guidedTakeoffWithAltitude() const; bool guidedTakeoffWithoutAltitude() const; bool changeHeading() const; + bool vtolMulticopterTakeoff() const; signals: void terrainFrameChanged(); diff --git a/test/MissionManager/MissionCommandTreeTest.cc b/test/MissionManager/MissionCommandTreeTest.cc index e749e98b71ba..bdb9c8cf6651 100644 --- a/test/MissionManager/MissionCommandTreeTest.cc +++ b/test/MissionManager/MissionCommandTreeTest.cc @@ -176,6 +176,16 @@ void MissionCommandTreeTest::testOverride() delete vehicle; } +void MissionCommandTreeTest::testPX4VTOLTakeoffOverride() +{ + MissionCommandList commandList(QStringLiteral(":/json/PX4-MavCmdInfoVTOL.json"), false /* baseCommandList */); + const MissionCommandUIInfo* const takeoffInfo = commandList.getUIInfo(MAV_CMD_NAV_TAKEOFF); + + QVERIFY(takeoffInfo); + QCOMPARE(takeoffInfo->description(), + QStringLiteral("Take off vertically and continue the mission in multicopter mode.")); +} + void MissionCommandTreeTest::testAllTrees() { ignoreLogMessage("FirmwarePlugin.ParameterMetaData", QtWarningMsg, QRegularExpression("Skipping invalid enum value")); diff --git a/test/MissionManager/MissionCommandTreeTest.h b/test/MissionManager/MissionCommandTreeTest.h index bd607b1cc0b3..06be0696da28 100644 --- a/test/MissionManager/MissionCommandTreeTest.h +++ b/test/MissionManager/MissionCommandTreeTest.h @@ -18,6 +18,7 @@ private slots: void testJsonLoad(); void testOverride(); + void testPX4VTOLTakeoffOverride(); void testAllTrees(); void testUnknownCommandFallbacks(); diff --git a/test/MissionManager/MissionControllerTest.cc b/test/MissionManager/MissionControllerTest.cc index f33bc3bb523d..5e4bbda3a691 100644 --- a/test/MissionManager/MissionControllerTest.cc +++ b/test/MissionManager/MissionControllerTest.cc @@ -14,8 +14,11 @@ #include "PlanViewSettings.h" #include "SettingsManager.h" #include "SimpleMissionItem.h" +#include "TakeoffMissionItem.h" #include "TestFixtures.h" #include "MultiSignalSpy.h" +#include "Vehicle.h" +#include "VehicleSupports.h" #include #include @@ -45,6 +48,17 @@ void MissionControllerTest::_initForFirmwareType(MAV_AUTOPILOT firmwareType) SettingsManager::instance()->appSettings()->offlineEditingFirmwareClass()->setRawValue( QGCMAVLink::firmwareClass(firmwareType)); _masterController = std::make_unique(); + _startMasterController(); +} + +void MissionControllerTest::_initForVehicleType(MAV_AUTOPILOT firmwareType, MAV_TYPE vehicleType) +{ + _masterController = std::make_unique(firmwareType, vehicleType); + _startMasterController(); +} + +void MissionControllerTest::_startMasterController() +{ _masterController->setFlyView(false); _missionController = _masterController->missionController(); MultiSignalSpy missionControllerSpy; @@ -69,6 +83,151 @@ void MissionControllerTest::_initForFirmwareType(MAV_AUTOPILOT firmwareType) QCOMPARE(simpleFlightPathSegments->count(), 0); } +void MissionControllerTest::_testVTOLTakeoffModes_data() +{ + QTest::addColumn("firmwareType"); + QTest::addColumn("vehicleType"); + QTest::addColumn("useMulticopterTakeoff"); + QTest::addColumn("supportsMulticopterTakeoff"); + QTest::addColumn("expectedCommand"); + QTest::addColumn("expectedSameLocation"); + QTest::addColumn("expectedWaypointVTOLMode"); + + QTest::newRow("PX4 VTOL default") + << int(MAV_AUTOPILOT_PX4) << int(MAV_TYPE_VTOL_TAILSITTER_QUADROTOR) + << false << true << int(MAV_CMD_NAV_VTOL_TAKEOFF) << false << int(QGCMAVLink::VehicleClassFixedWing); + QTest::newRow("PX4 VTOL multicopter") + << int(MAV_AUTOPILOT_PX4) << int(MAV_TYPE_VTOL_TAILSITTER_QUADROTOR) + << true << true << int(MAV_CMD_NAV_TAKEOFF) << true << int(QGCMAVLink::VehicleClassMultiRotor); + QTest::newRow("PX4 multicopter unchanged") + << int(MAV_AUTOPILOT_PX4) << int(MAV_TYPE_QUADROTOR) + << false << false << int(MAV_CMD_NAV_TAKEOFF) << true << int(QGCMAVLink::VehicleClassGeneric); + QTest::newRow("PX4 fixed wing unchanged") + << int(MAV_AUTOPILOT_PX4) << int(MAV_TYPE_FIXED_WING) + << false << false << int(MAV_CMD_NAV_TAKEOFF) << false << int(QGCMAVLink::VehicleClassGeneric); +} + +void MissionControllerTest::_testVTOLTakeoffModes() +{ + QFETCH(int, firmwareType); + QFETCH(int, vehicleType); + QFETCH(bool, useMulticopterTakeoff); + QFETCH(bool, supportsMulticopterTakeoff); + QFETCH(int, expectedCommand); + QFETCH(bool, expectedSameLocation); + QFETCH(int, expectedWaypointVTOLMode); + + _initForVehicleType(static_cast(firmwareType), static_cast(vehicleType)); + + Vehicle* controllerVehicle = _masterController->controllerVehicle(); + QVERIFY(controllerVehicle); + QCOMPARE(controllerVehicle->supports()->vtolMulticopterTakeoff(), supportsMulticopterTakeoff); + + const QGeoCoordinate home = Coord::zurich(); + _missionController->setHomePosition(home); + VisualMissionItem* visualTakeoff = useMulticopterTakeoff + ? _missionController->insertVTOLMulticopterTakeoffItem(home, 1) + : _missionController->insertTakeoffItem(home, 1); + QVERIFY(visualTakeoff); + + TakeoffMissionItem* takeoffItem = qobject_cast(visualTakeoff); + QVERIFY(takeoffItem); + QCOMPARE(takeoffItem->mavCommand(), static_cast(expectedCommand)); + QCOMPARE(takeoffItem->launchTakeoffAtSameLocation(), expectedSameLocation); + + SimpleMissionItem* waypoint = qobject_cast( + _missionController->insertSimpleMissionItem(home.atDistanceAndAzimuth(100.0, 0.0), 2)); + QVERIFY(waypoint); + + if (controllerVehicle->vtol()) { + QCOMPARE_TRUE_WAIT(waypoint->property("previousVTOLMode").toInt(), expectedWaypointVTOLMode, TestTimeout::mediumMs()); + } +} + +void MissionControllerTest::_testArduPilotVTOLOrdinaryTakeoffCompatibility() +{ + _initForVehicleType(MAV_AUTOPILOT_ARDUPILOTMEGA, MAV_TYPE_VTOL_TAILSITTER_QUADROTOR); + + Vehicle* controllerVehicle = _masterController->controllerVehicle(); + QVERIFY(controllerVehicle); + QCOMPARE(controllerVehicle->supports()->vtolMulticopterTakeoff(), false); + + const QGeoCoordinate home = Coord::zurich(); + _missionController->setHomePosition(home); + MissionSettingsItem* settingsItem = _missionController->visualItems()->value(0); + QVERIFY(settingsItem); + + TakeoffMissionItem* defaultTakeoffItem = qobject_cast( + _missionController->insertTakeoffItem(home, 1)); + QVERIFY(defaultTakeoffItem); + QCOMPARE(defaultTakeoffItem->mavCommand(), MAV_CMD_NAV_VTOL_TAKEOFF); + + SimpleMissionItem* waypoint = qobject_cast( + _missionController->insertSimpleMissionItem(home.atDistanceAndAzimuth(100.0, 0.0), 2)); + QVERIFY(waypoint); + QCOMPARE_TRUE_WAIT( + waypoint->property("previousVTOLMode").toInt(), + int(QGCMAVLink::VehicleClassFixedWing), + TestTimeout::mediumMs()); + + TakeoffMissionItem ordinaryTakeoffItem( + MAV_CMD_NAV_TAKEOFF, _masterController.get(), false /* flyView */, settingsItem, false /* forLoad */); + QCOMPARE(ordinaryTakeoffItem.launchTakeoffAtSameLocation(), !ordinaryTakeoffItem.specifiesCoordinate()); +} + +void MissionControllerTest::_testUnsupportedVTOLMulticopterTakeoff() +{ + _initForVehicleType(MAV_AUTOPILOT_PX4, MAV_TYPE_QUADROTOR); + _missionController->setHomePosition(Coord::zurich()); + const int initialCount = _missionController->visualItems()->count(); + + expectAppMessage(QRegularExpression(QStringLiteral("Multicopter takeoff is not supported"))); + QVERIFY(!_missionController->insertVTOLMulticopterTakeoffItem(Coord::zurich(), 1)); + verifyExpectedLogMessage(); + QCOMPARE(_missionController->visualItems()->count(), initialCount); +} + +void MissionControllerTest::_testVTOLTakeoffJsonRoundTrip_data() +{ + QTest::addColumn("useMulticopterTakeoff"); + QTest::addColumn("expectedCommand"); + + QTest::newRow("VTOL takeoff") << false << int(MAV_CMD_NAV_VTOL_TAKEOFF); + QTest::newRow("multicopter takeoff") << true << int(MAV_CMD_NAV_TAKEOFF); +} + +void MissionControllerTest::_testVTOLTakeoffJsonRoundTrip() +{ + QFETCH(bool, useMulticopterTakeoff); + QFETCH(int, expectedCommand); + + _initForVehicleType(MAV_AUTOPILOT_PX4, MAV_TYPE_VTOL_TAILSITTER_QUADROTOR); + + const QGeoCoordinate home = Coord::zurich(); + _missionController->setHomePosition(home); + VisualMissionItem* visualTakeoff = useMulticopterTakeoff + ? _missionController->insertVTOLMulticopterTakeoffItem(home, 1) + : _missionController->insertTakeoffItem(home, 1); + TakeoffMissionItem* takeoffItem = qobject_cast(visualTakeoff); + QVERIFY(takeoffItem); + takeoffItem->setCoordinate(home.atDistanceAndAzimuth(50.0, 0.0)); + QVERIFY(_missionController->insertSimpleMissionItem(home.atDistanceAndAzimuth(100.0, 0.0), 2)); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString filename = tempDir.filePath(QStringLiteral("VTOLTakeoff.plan")); + QVERIFY(_masterController->saveToFile(filename)); + + _masterController->removeAll(); + _masterController->loadFromFile(filename); + + QmlObjectListModel* visualItems = _missionController->visualItems(); + QCOMPARE(visualItems->count(), 3); + TakeoffMissionItem* loadedTakeoff = visualItems->value(1); + QVERIFY(loadedTakeoff); + QCOMPARE(loadedTakeoff->mavCommand(), static_cast(expectedCommand)); +} + void MissionControllerTest::_testEmptyVehicle_data() { TestData::addAutopilotRows(); diff --git a/test/MissionManager/MissionControllerTest.h b/test/MissionManager/MissionControllerTest.h index 24acab4bb3ee..b4fb4a757827 100644 --- a/test/MissionManager/MissionControllerTest.h +++ b/test/MissionManager/MissionControllerTest.h @@ -35,12 +35,20 @@ private slots: void _testInsertValidityHomePositionGating(); void _testLandToolInsertsSingleRtl_data(); void _testLandToolInsertsSingleRtl(); + void _testVTOLTakeoffModes_data(); + void _testVTOLTakeoffModes(); + void _testArduPilotVTOLOrdinaryTakeoffCompatibility(); + void _testUnsupportedVTOLMulticopterTakeoff(); + void _testVTOLTakeoffJsonRoundTrip_data(); + void _testVTOLTakeoffJsonRoundTrip(); // Parameterized tests - runs once per autopilot type UT_PARAMETERIZED_TEST(_testEmptyVehicle); private: void _initForFirmwareType(MAV_AUTOPILOT firmwareType); + void _initForVehicleType(MAV_AUTOPILOT firmwareType, MAV_TYPE vehicleType); + void _startMasterController(); void _setupVisualItemSignals(VisualMissionItem* visualItem); std::unique_ptr _masterController; diff --git a/test/MissionManager/PlanMasterControllerTest.cc b/test/MissionManager/PlanMasterControllerTest.cc index c96c808f6c69..5796db4230d0 100644 --- a/test/MissionManager/PlanMasterControllerTest.cc +++ b/test/MissionManager/PlanMasterControllerTest.cc @@ -41,14 +41,33 @@ void PlanMasterControllerTest::_testMissionPlannerFileLoad() QCOMPARE(_masterController->missionController()->visualItems()->count(), 6); } +void PlanMasterControllerTest::_testTakeoffTextFileLoad_data() +{ + QTest::addColumn("firmwareClass"); + QTest::addColumn("vehicleClass"); + QTest::addColumn("takeoffCommand"); + + QTest::newRow("ArduPilot takeoff") + << int(QGCMAVLink::FirmwareClassArduPilot) << int(QGCMAVLink::VehicleClassMultiRotor) << int(MAV_CMD_NAV_TAKEOFF); + QTest::newRow("PX4 VTOL multicopter takeoff") + << int(QGCMAVLink::FirmwareClassPX4) << int(QGCMAVLink::VehicleClassVTOL) << int(MAV_CMD_NAV_TAKEOFF); + QTest::newRow("PX4 VTOL takeoff") + << int(QGCMAVLink::FirmwareClassPX4) << int(QGCMAVLink::VehicleClassVTOL) << int(MAV_CMD_NAV_VTOL_TAKEOFF); +} + void PlanMasterControllerTest::_testTakeoffTextFileLoad() { + QFETCH(int, firmwareClass); + QFETCH(int, vehicleClass); + QFETCH(int, takeoffCommand); + // Plain-text mission file with home position, takeoff and one waypoint (#13167) - static const char* kTakeoffMission = + const QByteArray takeoffMission = QByteArray( "QGC WPL 110\r\n" "0\t1\t0\t16\t0\t0\t0\t0\t34.577822\t-112.469101\t584.380005\t1\r\n" - "1\t0\t3\t22\t20.000000\t0.000000\t0.000000\t0.000000\t0.000000\t0.000000\t30.000000\t1\r\n" - "2\t0\t3\t16\t0.000000\t0.000000\t0.000000\t0.000000\t34.469587\t-112.534801\t90.000000\t1\r\n"; + "1\t0\t3\t") + QByteArray::number(takeoffCommand) + QByteArray( + "\t20.000000\t0.000000\t0.000000\t0.000000\t0.000000\t0.000000\t30.000000\t1\r\n" + "2\t0\t3\t16\t0.000000\t0.000000\t0.000000\t0.000000\t34.469587\t-112.534801\t90.000000\t1\r\n"); QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -57,10 +76,11 @@ void PlanMasterControllerTest::_testTakeoffTextFileLoad() // No QIODevice::Text: write the CRLF line endings verbatim on all platforms to match // the original repro file from the issue. QVERIFY(file.open(QIODevice::WriteOnly)); - QVERIFY(file.write(kTakeoffMission) != -1); + QVERIFY(file.write(takeoffMission) != -1); file.close(); - SettingsManager::instance()->appSettings()->offlineEditingFirmwareClass()->setRawValue(QGCMAVLink::FirmwareClassArduPilot); + SettingsManager::instance()->appSettings()->offlineEditingFirmwareClass()->setRawValue(firmwareClass); + SettingsManager::instance()->appSettings()->offlineEditingVehicleClass()->setRawValue(vehicleClass); _masterController->loadFromFile(filename); @@ -71,7 +91,7 @@ void PlanMasterControllerTest::_testTakeoffTextFileLoad() // resulting in a single takeoff item carrying the waypoint's values. TakeoffMissionItem* takeoffItem = visualItems->value(1); QVERIFY(takeoffItem); - QCOMPARE(static_cast(takeoffItem->command()), MAV_CMD_NAV_TAKEOFF); + QCOMPARE(static_cast(takeoffItem->command()), static_cast(takeoffCommand)); QCOMPARE(takeoffItem->missionItem().param7(), 30.0); SimpleMissionItem* waypointItem = visualItems->value(2); diff --git a/test/MissionManager/PlanMasterControllerTest.h b/test/MissionManager/PlanMasterControllerTest.h index e670993701b9..2c370b113b6a 100644 --- a/test/MissionManager/PlanMasterControllerTest.h +++ b/test/MissionManager/PlanMasterControllerTest.h @@ -13,6 +13,7 @@ private slots: void cleanup() final; void _testMissionPlannerFileLoad(); + void _testTakeoffTextFileLoad_data(); void _testTakeoffTextFileLoad(); void _testActiveVehicleChanged(); void _testDirtyFlagsMatrix_data(); diff --git a/test/QmlUITests/PlanViewUITest.cc b/test/QmlUITests/PlanViewUITest.cc index 976effbd6298..ec592af8ffc5 100644 --- a/test/QmlUITests/PlanViewUITest.cc +++ b/test/QmlUITests/PlanViewUITest.cc @@ -10,10 +10,13 @@ #include "AppSettings.h" #include "Fact.h" +#include "MissionController.h" #include "PlanViewSettings.h" #include "QGCFileDialogController.h" #include "QGCMAVLink.h" +#include "QmlObjectListModel.h" #include "SettingsManager.h" +#include "SimpleMissionItem.h" UT_REGISTER_TEST(PlanViewUITest, TestLabel::Integration, TestLabel::MissionManager) @@ -38,6 +41,21 @@ int PlanViewUITest::_missionItemCount() return visualItems->property("count").toInt(); } +int PlanViewUITest::_missionItemCommand(int index) +{ + QQuickItem* planView = findVisibleItem(_rootItem, QStringLiteral("mainView_plan")); + if (!planView) { + return -1; + } + MissionController* missionController = qobject_cast( + planView->property("_missionController").value()); + if (!missionController || index < 0 || index >= missionController->visualItems()->count()) { + return -1; + } + SimpleMissionItem* item = missionController->visualItems()->value(index); + return item ? item->command() : -1; +} + QGeoCoordinate PlanViewUITest::_plannedHomePosition() { QQuickItem *planView = findVisibleItem(_rootItem, QStringLiteral("mainView_plan")); @@ -150,6 +168,50 @@ void PlanViewUITest::_testTakeoffNotRequiredWaypointOnEmptyPlan() _verifyWaypointToolAddsWaypointOnEmptyPlan(true /* expectTakeoffButtonVisible */); } +void PlanViewUITest::_testVTOLTakeoffChoices_data() +{ + QTest::addColumn("choiceButton"); + QTest::addColumn("expectedCommand"); + + QTest::newRow("VTOL takeoff") << QStringLiteral("planTakeoff_vtolButton") << int(MAV_CMD_NAV_VTOL_TAKEOFF); + QTest::newRow("multicopter takeoff") << QStringLiteral("planTakeoff_mcButton") << int(MAV_CMD_NAV_TAKEOFF); +} + +void PlanViewUITest::_testVTOLTakeoffChoices() +{ + QFETCH(QString, choiceButton); + QFETCH(int, expectedCommand); + + AppSettings* appSettings = SettingsManager::instance()->appSettings(); + appSettings->offlineEditingFirmwareClass()->setRawValue(QGCMAVLink::FirmwareClassPX4); + appSettings->offlineEditingVehicleClass()->setRawValue(QGCMAVLink::VehicleClassVTOL); + + startUI(); + if (QTest::currentTestFailed()) return; + + _navigateToPlanAndCenterMap(); + if (QTest::currentTestFailed()) return; + + _clickMap(0.5, 0.5); + if (QTest::currentTestFailed()) return; + QVERIFY2(waitForCondition([&] { return _plannedHomePosition().isValid(); }, 2000, + QStringLiteral("VTOL home position set")), + "Map click did not set the VTOL home position"); + + QVERIFY2(clickButton(QStringLiteral("planToolStrip_takeoffButton")), "Failed to open the VTOL takeoff choices"); + QQuickItem* vtolButton = findVisibleItem(_rootItem, QStringLiteral("planTakeoff_vtolButton"), 2000); + QQuickItem* multicopterButton = findVisibleItem(_rootItem, QStringLiteral("planTakeoff_mcButton"), 2000); + QVERIFY2(vtolButton, "VTOL takeoff choice was not shown"); + QVERIFY2(multicopterButton, "Multicopter takeoff choice was not shown"); + QCOMPARE(vtolButton->property("primary").toBool(), true); + + QVERIFY2(clickButton(choiceButton), "Failed to select the VTOL takeoff mode"); + QVERIFY2(waitForCondition([&] { return _missionItemCount() == 2; }, 2000, + QStringLiteral("VTOL takeoff inserted")), + "The selected VTOL takeoff command was not inserted"); + QCOMPARE(_missionItemCommand(1), expectedCommand); +} + void PlanViewUITest::_verifyFullState(const PlanUIState &state, const QString &context) { const QString takeoffBtn = QStringLiteral("planToolStrip_takeoffButton"); diff --git a/test/QmlUITests/PlanViewUITest.h b/test/QmlUITests/PlanViewUITest.h index 77e2270511ec..b294ecacf080 100644 --- a/test/QmlUITests/PlanViewUITest.h +++ b/test/QmlUITests/PlanViewUITest.h @@ -23,6 +23,8 @@ private slots: void _testSaveAsMenu(); void _testRoverWaypointOnEmptyPlan(); void _testTakeoffNotRequiredWaypointOnEmptyPlan(); + void _testVTOLTakeoffChoices_data(); + void _testVTOLTakeoffChoices(); private: /// Complete expected state of all Plan view UI under test @@ -64,6 +66,9 @@ private slots: /// Current MissionController visualItems count, or -1 if unavailable. int _missionItemCount(); + /// MAVLink command for a visual mission item, or -1 if unavailable. + int _missionItemCommand(int index); + /// Current MissionController plannedHomePosition, or invalid if unavailable. QGeoCoordinate _plannedHomePosition(); };