From 18fe464ecc2d9ad61c02be0a8dce88822a6e52f8 Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Wed, 1 Jul 2026 19:14:57 +0200 Subject: [PATCH 1/9] Add Failure Injection feature to PX4 AutoPilot Plugin - Introduced FailureInjection and FailureInjectionComponent classes to manage simulated sensor and system failures. - Updated CMakeLists.txt to include new source files and tests for Failure Injection. - Implemented QML interface for Failure Injection, allowing users to inject failures and view activity logs. - Enhanced MAVLink enums to support new FAILURE_UNIT and FAILURE_TYPE enums for failure injection. - Added unit tests for FailureInjection functionality to ensure proper behavior and integration. --- cmake/CustomOptions.cmake | 2 +- src/AutoPilotPlugins/PX4/CMakeLists.txt | 5 + src/AutoPilotPlugins/PX4/FailureInjection.cc | 127 ++++++ src/AutoPilotPlugins/PX4/FailureInjection.h | 56 +++ .../PX4/FailureInjectionComponent.cc | 33 ++ .../PX4/FailureInjectionComponent.h | 29 ++ .../PX4/FailureInjectionComponent.qml | 415 ++++++++++++++++++ .../PX4/PX4AutoPilotPlugin.cc | 9 + src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.h | 2 + test/AutoPilotPlugins/CMakeLists.txt | 3 + .../PX4/FailureInjectionTest.cc | 115 +++++ .../PX4/FailureInjectionTest.h | 19 + tools/generators/mavlink_enums.py | 35 +- 13 files changed, 845 insertions(+), 5 deletions(-) create mode 100644 src/AutoPilotPlugins/PX4/FailureInjection.cc create mode 100644 src/AutoPilotPlugins/PX4/FailureInjection.h create mode 100644 src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc create mode 100644 src/AutoPilotPlugins/PX4/FailureInjectionComponent.h create mode 100644 src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml create mode 100644 test/AutoPilotPlugins/PX4/FailureInjectionTest.cc create mode 100644 test/AutoPilotPlugins/PX4/FailureInjectionTest.h diff --git a/cmake/CustomOptions.cmake b/cmake/CustomOptions.cmake index ae54f007c4eb..947650aa1413 100644 --- a/cmake/CustomOptions.cmake +++ b/cmake/CustomOptions.cmake @@ -120,7 +120,7 @@ option(QGC_ENABLE_GST_VIDEOSTREAMING "Enable GStreamer video backend" ON) # ============================================================================ set(QGC_MAVLINK_GIT_REPO "https://github.com/mavlink/mavlink.git" CACHE STRING "MAVLink repository URL") -set(QGC_MAVLINK_GIT_TAG "c409cf690454db6d3e004bd14173bc6c7ff1e0ff" CACHE STRING "MAVLink repository commit/tag") +set(QGC_MAVLINK_GIT_TAG "35cd26af3036b54205b0c1d25ac7bf6f5bdbe15a" CACHE STRING "MAVLink repository commit/tag") set(QGC_MAVLINK_DIALECT "all" CACHE STRING "MAVLink dialect") set(QGC_MAVLINK_VERSION "2.0" CACHE STRING "MAVLink protocol version") diff --git a/src/AutoPilotPlugins/PX4/CMakeLists.txt b/src/AutoPilotPlugins/PX4/CMakeLists.txt index a8ff3de75cf1..7c1b1c0def5a 100644 --- a/src/AutoPilotPlugins/PX4/CMakeLists.txt +++ b/src/AutoPilotPlugins/PX4/CMakeLists.txt @@ -13,6 +13,10 @@ target_sources(${CMAKE_PROJECT_NAME} AirframeComponentAirframes.h AirframeComponentController.cc AirframeComponentController.h + FailureInjection.cc + FailureInjection.h + FailureInjectionComponent.cc + FailureInjectionComponent.h FlightModesComponent.cc FlightModesComponent.h PowerComponent.cc @@ -108,6 +112,7 @@ qt_add_qml_module(AutoPilotPluginsPX4Module CalcAmpsPerVoltDialog.qml CalcVoltageDividerDialog.qml ESCCalibrationDialog.qml + FailureInjectionComponent.qml FlightModesComponentSummary.qml PowerComponentSummary.qml PX4FlightBehaviorCopter.qml diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc new file mode 100644 index 000000000000..2619e87f6493 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -0,0 +1,127 @@ +/**************************************************************************** + * FailureInjection.cc + ****************************************************************************/ +#include "FailureInjection.h" +#include "MAVLinkEnumsQml.h" // MAVLinkEnums::FAILURE_UNIT / FAILURE_TYPE, Q_ENUM_NS-reflected from the MAVLink dialect +#include "QGCMAVLink.h" // QGCMAVLink::mavResultToString() fallback for resolveResult() + +#include "MAVLinkLib.h" // MAV_RESULT_* for resolveResult() + +#include +#include + +namespace { + +QVariantMap _makeUnit(const QString &name, int unit) +{ + return QVariantMap{ {"name", name}, {"unit", unit} }; +} + +QVariantMap _makeType(const QString &name, int type) +{ + return QVariantMap{ {"name", name}, {"type", type} }; +} + +/// Strip the longest matching MAVLink enum prefix, e.g. FAILURE_UNIT_SENSOR_GYRO -> GYRO. +QString _stripPrefix(const QString &key, const QStringList &prefixesLongestFirst) +{ + for (const QString &prefix : prefixesLongestFirst) { + if (key.startsWith(prefix)) { + return key.mid(prefix.length()); + } + } + return key; +} + +/// Builds a {name, value} catalog from the Q_ENUM_NS-exposed enum on MAVLinkEnums::staticMetaObject, +/// looked up by name. +QVariantList _buildCatalog(const char *enumName, const QStringList &prefixesLongestFirst, QVariantMap (*makeEntry)(const QString &, int)) +{ + QVariantList list; + const QMetaObject &enumsMetaObject = MAVLinkEnums::staticMetaObject; + const QMetaEnum me = enumsMetaObject.enumerator(enumsMetaObject.indexOfEnumerator(enumName)); + for (int i = 0; i < me.keyCount(); ++i) { + const QString key = QString::fromLatin1(me.key(i)); + if (key.endsWith(QStringLiteral("_ENUM_END"))) { + continue; // dialect sentinel, not a real value + } + list.append(makeEntry(_stripPrefix(key, prefixesLongestFirst), me.value(i))); + } + return list; +} + +} // namespace + +FailureInjection::FailureInjection(QObject *parent) + : QObject(parent) +{ + _units = _buildCatalog("FAILURE_UNIT", + { QStringLiteral("FAILURE_UNIT_SENSOR_"), QStringLiteral("FAILURE_UNIT_SYSTEM_"), QStringLiteral("FAILURE_UNIT_") }, _makeUnit); + _types = _buildCatalog("FAILURE_TYPE", { QStringLiteral("FAILURE_TYPE_") }, _makeType); +} + +void FailureInjection::logRow(const QString &unitName, const QString &typeName, const QString &instanceLabel, const QString &time) +{ + _activity.prepend(QVariantMap{ + {"time", time}, {"unitName", unitName}, {"typeName", typeName}, {"instance", instanceLabel}, {"result", QStringLiteral("pending")} + }); + emit activityChanged(); +} + +void FailureInjection::logInjection(const QString &unitName, const QString &typeName, int unitEnum, const QString &instanceLabel, const QString &time) +{ + logRow(unitName, typeName, instanceLabel, time); + if (!_injectedUnits.contains(unitEnum)) { + _injectedUnits.append(unitEnum); + } +} + +void FailureInjection::resolveResult(int ackResult) +{ + // ACKs for MAV_CMD_INJECT_FAILURE arrive in send order; resolve the oldest pending row (highest index, since newest is prepended). + int pendingIndex = -1; + for (int i = _activity.size() - 1; i >= 0; --i) { + if (_activity.at(i).toMap().value(QStringLiteral("result")).toString() == QStringLiteral("pending")) { + pendingIndex = i; + break; + } + } + if (pendingIndex < 0) { + return; + } + + if (ackResult == MAV_RESULT_IN_PROGRESS) { + return; // not a terminal result; leave pending + } + + QString reason; + switch (ackResult) { + // Not tr()'d: FailureInjectionComponent.qml matches this exact literal to style the row green. + case MAV_RESULT_ACCEPTED: reason = QStringLiteral("accepted"); break; + case MAV_RESULT_TEMPORARILY_REJECTED: reason = tr("Temporarily rejected"); break; + case MAV_RESULT_DENIED: reason = tr("Denied"); break; + case MAV_RESULT_UNSUPPORTED: reason = tr("Unsupported"); break; + case MAV_RESULT_FAILED: reason = tr("Failed"); break; + case MAV_RESULT_CANCELLED: reason = tr("Cancelled"); break; + default: reason = QGCMAVLink::mavResultToString(static_cast(ackResult)); break; + } + + QVariantMap row = _activity.at(pendingIndex).toMap(); + row[QStringLiteral("result")] = reason; + _activity[pendingIndex] = row; + emit activityChanged(); +} + +QVariantList FailureInjection::injectedUnits(void) const +{ + QVariantList list; + for (int unitEnum : _injectedUnits) { + list.append(unitEnum); + } + return list; +} + +void FailureInjection::clearInjectedUnits(void) +{ + _injectedUnits.clear(); +} diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.h b/src/AutoPilotPlugins/PX4/FailureInjection.h new file mode 100644 index 000000000000..2662555ceef5 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -0,0 +1,56 @@ +/**************************************************************************** + * FailureInjection.h + * + * QML singleton backing the Failure Injection page. It serves two roles: + * + * 1. Static catalog: the MAVLink FAILURE_UNIT / FAILURE_TYPE enums. Enum + * values come from the MAVLink dialect headers, tracking common.xml; + * display names and per-unit instance maxima are defined in the .cc. + * + * 2. Session state: the activity log and the set of units injected this + * session, persisted here so they survive navigating away from and back + * to the page, which destroys/recreates the page via its SetupPage Loader. + ****************************************************************************/ +#pragma once + +#include +#include +#include + +class FailureInjection : public QObject +{ + Q_OBJECT + QML_NAMED_ELEMENT(FailureInjection) + QML_SINGLETON + Q_PROPERTY(QVariantList units READ units CONSTANT) ///< [{ name, unit, max }] + Q_PROPERTY(QVariantList types READ types CONSTANT) ///< [{ name, type }] + Q_PROPERTY(QVariantList activity READ activity NOTIFY activityChanged) ///< newest first: [{ time, unitName, typeName, instance, result }] + +public: + explicit FailureInjection(QObject *parent = nullptr); + + QVariantList units(void) const { return _units; } + QVariantList types(void) const { return _types; } + QVariantList activity(void) const { return _activity; } + + /// Add a log row (prepended, newest first, result "pending") without tracking the unit for Reset. + /// instanceLabel is a display descriptor for the affected instance(s), e.g. "all", "2", "1, 3, 5". + Q_INVOKABLE void logRow(const QString &unitName, const QString &typeName, const QString &instanceLabel, const QString &time); + /// Record one injected failure: adds a log row and remembers the unit so Reset can restore it. + Q_INVOKABLE void logInjection(const QString &unitName, const QString &typeName, int unitEnum, const QString &instanceLabel, const QString &time); + /// Resolve the oldest still-pending injection with a MAV_RESULT ack code; sets the row result. + Q_INVOKABLE void resolveResult(int ackResult); + /// Distinct FAILURE_UNIT values injected this session, so Reset restores only those. + Q_INVOKABLE QVariantList injectedUnits(void) const; + /// Forget the injected-units set (after a Reset reverted them); the activity log is left intact. + Q_INVOKABLE void clearInjectedUnits(void); + +signals: + void activityChanged(void); + +private: + QVariantList _units; + QVariantList _types; + QVariantList _activity; + QList _injectedUnits; +}; diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc new file mode 100644 index 000000000000..d5816e365ea5 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc @@ -0,0 +1,33 @@ +/**************************************************************************** + * FailureInjectionComponent.cc + ****************************************************************************/ +#include "FailureInjectionComponent.h" +#include "AutoPilotPlugin.h" +#include "Vehicle.h" + +FailureInjectionComponent::FailureInjectionComponent(Vehicle* vehicle, AutoPilotPlugin* autopilot, QObject* parent) + : VehicleComponent(vehicle, autopilot, AutoPilotPlugin::UnknownVehicleComponent, parent) + , _name(tr("Failure Injection")) +{ +} + +QString FailureInjectionComponent::name(void) const +{ + return _name; +} + +QString FailureInjectionComponent::description(void) const +{ + return tr("Failure Injection is used to simulate sensor and system failures (MAV_CMD_INJECT_FAILURE) " + "to validate failsafes. Requires SYS_FAILURE_EN = 1 and a vehicle reboot."); +} + +QString FailureInjectionComponent::iconResource(void) const +{ + return QStringLiteral("/qmlimages/subMenuButtonImage.png"); +} + +QUrl FailureInjectionComponent::setupSource(void) const +{ + return QUrl::fromUserInput(QStringLiteral("qrc:/qml/QGroundControl/AutoPilotPlugins/PX4/FailureInjectionComponent.qml")); +} diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h new file mode 100644 index 000000000000..00dd35a9897b --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h @@ -0,0 +1,29 @@ +/**************************************************************************** + * FailureInjectionComponent.h + * Vehicle Setup component wrapper for the Failure Injection QML page. + ****************************************************************************/ +#pragma once + +#include "VehicleComponent.h" + +class FailureInjectionComponent : public VehicleComponent +{ + Q_OBJECT +public: + FailureInjectionComponent(Vehicle* vehicle, AutoPilotPlugin* autopilot, QObject* parent = nullptr); + + QString name (void) const override; + QString description (void) const override; + QString iconResource (void) const override; + bool requiresSetup (void) const override { return false; } + bool setupComplete (void) const override { return true; } + QStringList setupCompleteChangedTriggerList(void) const override { return QStringList(); } + QUrl setupSource (void) const override; + QUrl summaryQmlSource (void) const override { return QUrl(); } + // Failure injection is meant to be exercised in flight (validate failsafes/EKF), so keep the page usable while armed. + bool allowSetupWhileArmed (void) const override { return true; } + bool allowSetupWhileFlying (void) const override { return true; } + +private: + const QString _name; +}; diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml new file mode 100644 index 000000000000..69348d993c92 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -0,0 +1,415 @@ +/**************************************************************************** + * FailureInjectionComponent.qml + * + * Vehicle Setup page (PX4) that injects simulated sensor/system failures via + * MAV_CMD_INJECT_FAILURE: SYS_FAILURE_EN gate -> reboot -> armed, then a + * component/type/instance picker with an activity log and reset-all. + ****************************************************************************/ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import QGroundControl +import QGroundControl.Controls +import QGroundControl.FactControls + +SetupPage { + id: failureInjectionPage + pageComponent: pageComponent + + // ---- state / model ------------------------------------------------------ + + property var _activeVehicle: QGroundControl.multiVehicleManager.activeVehicle + property Fact _sysFailureEn: controller.getParameterFact(-1, "SYS_FAILURE_EN", true /* reportMissing */) + property bool _paramSet: _sysFailureEn && _sysFailureEn.value === 1 + property bool _pendingReboot: false // SYS_FAILURE_EN just toggled, reboot not yet triggered + property bool _armed: _paramSet // PX4 honors SYS_FAILURE_EN at boot; arm once the param reads 1 + + readonly property int _cmdInjectFailure: 420 // MAV_CMD_INJECT_FAILURE + + // FAILURE_UNIT / FAILURE_TYPE sourced from the MAVLink dialect via the C++ singleton + // (enum values + names track common.xml). + property var _units: FailureInjection.units // [{ name, unit }] + property var _types: FailureInjection.types // [{ name, type }] + + property int _unitIndex: 4 // GPS + property int _typeIndex: 1 // Off + property var _selectedInstances: [1] // 1-based instance numbers + readonly property int _instanceCount: 8 // fixed number of selectable instances (not tied to the unit) + + // Activity log column widths — shared by the header and every row so the fields line up. + readonly property real _colTimeWidth: ScreenTools.defaultFontPixelWidth * 9 + readonly property real _colUnitWidth: ScreenTools.defaultFontPixelWidth * 17 + readonly property real _colTypeWidth: ScreenTools.defaultFontPixelWidth * 15 + + FactPanelController { id: controller } + QGCPalette { id: qgcPal; colorGroupEnabled: true } + + // Activity log + injected-unit set live in the FailureInjection singleton so they + // persist across navigating away from / back to this page (which recreates the QML). + + // Holds MAV_CMD_INJECT_FAILURE sends not yet dispatched. Each entry is sent only once the + // previous one's ack has come back through onMavCommandResult/_onAck, so at most one is ever + // in flight at a time. + property var _sendQueue: [] // outstanding sends: [{ unit, type, instance, logArgs|null }] + + Connections { + target: _activeVehicle + function onMavCommandResult(vehicleId, targetComponent, command, ackResult, failureCode) { + if (command === _cmdInjectFailure) { + _onAck(ackResult) + } + } + } + + // ---- behaviour ---------------------------------------------------------- + + function _injectOne(unitEnum, typeEnum, param3, param4) { + if (!_activeVehicle) { + return + } + // sendCommand is the QML-callable form of sendMavCommand. Target the component + // that owns SYS_FAILURE_EN (the autopilot); fall back to component 1. + var compId = _sysFailureEn ? _sysFailureEn.componentId : 1 + // param3 = instance (0 = all, NaN = use bitmask), param4 = instance bitmask (bit 0 = instance 1). + // showError=false: the ack result is surfaced via the mavCommandResult handler (per-row status). + _activeVehicle.sendCommand(compId, + _cmdInjectFailure, + false, // showError + unitEnum, typeEnum, param3, param4, // param1..4 + 0, 0, 0) // param5..7 + } + + // Map the current instance selection to the command form: + // All -> {p3:0, p4:0}; one instance n -> {p3:n, p4:0}; many -> {p3:NaN, p4:bitmask}. + function _instanceSend() { + var sel = _selectedInstances.slice().sort(function(a, b){ return a - b }) + if (sel.indexOf(0) >= 0) { + return { param3: 0, param4: 0, label: "all" } + } + if (sel.length === 1) { + return { param3: sel[0], param4: 0, label: "" + sel[0] } + } + var mask = 0 + for (var i = 0; i < sel.length; ++i) { + mask |= (1 << (sel[i] - 1)) + } + return { param3: NaN, param4: mask, label: sel.join(", ") } + } + + // Queue one command; kick off sending if the queue was idle. + function _enqueueSend(item) { + var q = _sendQueue.slice() + q.push(item) + _sendQueue = q + if (q.length === 1) { + _sendCurrent() + } + } + + // Send the command at the head of the queue, logging it when it's an injection (not a reset). + function _sendCurrent() { + if (_sendQueue.length === 0 || !_activeVehicle) { + return + } + var s = _sendQueue[0] + _injectOne(s.unit, s.type, s.param3, s.param4) + if (s.logArgs) { + var stamp = Qt.formatDateTime(new Date(), "hh:mm:ss") + if (s.logArgs.track) { + // injection: log the row and remember the unit for Reset + FailureInjection.logInjection(s.logArgs.unitName, s.logArgs.typeName, s.unit, s.logArgs.instanceLabel, stamp) + } else { + // reset OK: log the row but don't re-track the unit + FailureInjection.logRow(s.logArgs.unitName, s.logArgs.typeName, s.logArgs.instanceLabel, stamp) + } + } + } + + // One ack arrived: resolve the matching log row (only one is pending at a time), then send the next. + function _onAck(ackResult) { + FailureInjection.resolveResult(ackResult) // no-op when the head was a reset (no pending row) + if (_sendQueue.length > 0) { + var q = _sendQueue.slice() + q.shift() + _sendQueue = q + } + _sendCurrent() + } + + function _apply() { + if (!_armed || _selectedInstances.length === 0) { + return + } + var u = _units[_unitIndex] + var t = _types[_typeIndex] + var send = _instanceSend() // one command covers all selected instances (bitmask when >1) + _enqueueSend({ unit: u.unit, type: t.type, param3: send.param3, param4: send.param4, + logArgs: { unitName: u.name, typeName: t.name, instanceLabel: send.label, track: true } }) + } + + function _toggleInstance(n) { + var arr = _selectedInstances.slice() + var zero = arr.indexOf(0) // picking a specific instance clears the "All" selection + if (zero >= 0) { arr.splice(zero, 1) } + var idx = arr.indexOf(n) + if (idx >= 0) { arr.splice(idx, 1) } else { arr.push(n) } + _selectedInstances = arr + } + + function _setEnabled(on) { + if (_sysFailureEn) { + _sysFailureEn.value = on ? 1 : 0 + } + _pendingReboot = true // PX4 evaluates SYS_FAILURE_EN at boot, so any change (on or off) needs a reboot + } + + function _unitName(unitEnum) { + for (var i = 0; i < _units.length; ++i) { + if (_units[i].unit === unitEnum) { return _units[i].name } + } + return "" + unitEnum + } + + function _typeName(typeEnum) { + for (var i = 0; i < _types.length; ++i) { + if (_types[i].type === typeEnum) { return _types[i].name } + } + return "" + typeEnum + } + + // Send FAILURE_TYPE_OK (all instances) to every unit injected this session and log each as a row. + // The activity list is kept; the injected-units set is cleared since those failures are now reverted. + function _resetAll() { + var injected = FailureInjection.injectedUnits() // Q_INVOKABLE method — needs the call parentheses + FailureInjection.clearInjectedUnits() + for (var i = 0; i < injected.length; ++i) { + _enqueueSend({ unit: injected[i], type: 0 /* FAILURE_TYPE_OK */, param3: 0 /* all instances */, param4: 0, + logArgs: { unitName: _unitName(injected[i]), typeName: _typeName(0), instanceLabel: "all", track: false } }) + } + } + + // ---- page --------------------------------------------------------------- + + Component { + id: pageComponent + + ColumnLayout { + width: availableWidth + spacing: ScreenTools.defaultFontPixelHeight + + QGCLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Injects simulated failures into flight components (sensors, GPS, motors, and more) to test failsafe behavior, " + + "Use with care — an injected failure affects the vehicle immediately.") + } + + // ---- SYS_FAILURE_EN gate + reboot ---------------------------------- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: gateCol.height + (ScreenTools.defaultFontPixelHeight * 1.2) + color: qgcPal.windowShade + radius: ScreenTools.defaultFontPixelWidth * 0.5 + + ColumnLayout { + id: gateCol + anchors.margins: ScreenTools.defaultFontPixelHeight * 0.6 + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: ScreenTools.defaultFontPixelHeight * 0.4 + + RowLayout { + Layout.fillWidth: true + spacing: ScreenTools.defaultFontPixelWidth * 2 + + QGCCheckBox { + text: qsTr("SYS_FAILURE_EN") + enabled: _sysFailureEn !== null + checked: _paramSet + onClicked: _setEnabled(checked) + } + QGCLabel { + Layout.fillWidth: true + color: qgcPal.colorOrange + visible: _pendingReboot + text: qsTr("Parameter written — reboot required before it takes effect.") + } + QGCLabel { + Layout.fillWidth: true + color: qgcPal.colorGreen + visible: _armed && !_pendingReboot + text: qsTr("Active — injection armed.") + } + QGCButton { + text: qsTr("Reboot Vehicle") + visible: _pendingReboot + onClicked: { + if (_activeVehicle) { _activeVehicle.rebootVehicle() } + _pendingReboot = false // link drops & reconnects; param re-reads on return + } + } + } + } + } + + // ---- builder ------------------------------------------------------- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: builderCol.height + (ScreenTools.defaultFontPixelHeight * 1.2) + color: qgcPal.windowShade + radius: ScreenTools.defaultFontPixelWidth * 0.5 + enabled: _armed + opacity: _armed ? 1.0 : 0.4 + + ColumnLayout { + id: builderCol + anchors.margins: ScreenTools.defaultFontPixelHeight * 0.6 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: ScreenTools.defaultFontPixelHeight * 0.6 + + RowLayout { + spacing: ScreenTools.defaultFontPixelWidth * 2 + + ColumnLayout { + QGCLabel { text: qsTr("Component") } + QGCComboBox { + id: unitCombo + Layout.preferredWidth: ScreenTools.defaultFontPixelWidth * 24 + model: _units.map(function(u){ return u.name }) + currentIndex: _unitIndex + onActivated: function(index) { + _unitIndex = index + _selectedInstances = [1] // reset selection on unit change + } + } + } + ColumnLayout { + QGCLabel { text: qsTr("Failure type") } + QGCComboBox { + Layout.preferredWidth: ScreenTools.defaultFontPixelWidth * 18 + model: _types.map(function(t){ return t.name }) + currentIndex: _typeIndex + onActivated: function(index) { _typeIndex = index } + } + } + Item { Layout.fillWidth: true } + QGCButton { + text: qsTr("Inject failure") + primary: true + enabled: _armed && _selectedInstances.length > 0 + onClicked: _apply() + } + } + + // multi-select instances ("All" = instance 0 = every instance of the unit) + QGCLabel { text: qsTr("Instances — select one or more, or All") } + Flow { + Layout.fillWidth: true + spacing: ScreenTools.defaultFontPixelWidth * 2 + Repeater { + model: _instanceCount + QGCCheckBox { + text: "i = " + (index + 1) + checked: _selectedInstances.indexOf(index + 1) >= 0 + onClicked: _toggleInstance(index + 1) + } + } + // vertical divider, then the "All" toggle, left-aligned right after the instances + Rectangle { + width: 1 + height: ScreenTools.defaultFontPixelHeight * 1.5 + color: qgcPal.text + opacity: 0.3 + } + QGCCheckBox { + text: qsTr("All (i = 0)") + checked: _selectedInstances.indexOf(0) >= 0 + // "All" is exclusive: selecting it clears specific instances, and vice versa + onClicked: _selectedInstances = checked ? [0] : [] + } + } + + // live command preview + QGCLabel { + Layout.fillWidth: true + font.family: ScreenTools.fixedFontFamily + color: qgcPal.colorGreen + wrapMode: Text.WordWrap + text: { + if (_selectedInstances.length === 0) { + return "MAV_CMD_INJECT_FAILURE param1=" + _units[_unitIndex].unit + + " param2=" + _types[_typeIndex].type + " (no instance selected)" + } + var u = _units[_unitIndex] + var t = _types[_typeIndex] + var s = _instanceSend() + var p3 = isNaN(s.param3) ? "NaN" : s.param3 + return "MAV_CMD_INJECT_FAILURE param1=" + u.unit + + " param2=" + t.type + " param3=" + p3 + + " param4=" + s.param4 + " (i=" + s.label + ")" + } + } + } + } + + // ---- activity log (disabled until armed, same as the builder) ------ + RowLayout { + Layout.fillWidth: true + enabled: _armed + opacity: _armed ? 1.0 : 0.4 + QGCLabel { Layout.fillWidth: true; text: qsTr("Activity — newest first") } + QGCButton { text: qsTr("Reset all"); onClicked: _resetAll() } + } + // Column header, aligned with the rows below. + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: ScreenTools.defaultFontPixelWidth + Layout.rightMargin: ScreenTools.defaultFontPixelWidth + spacing: ScreenTools.defaultFontPixelWidth * 2 + enabled: _armed + opacity: _armed ? 1.0 : 0.4 + QGCLabel { Layout.preferredWidth: _colTimeWidth; font.family: ScreenTools.fixedFontFamily; color: qgcPal.text; text: qsTr("Time") } + QGCLabel { Layout.preferredWidth: _colUnitWidth; font.family: ScreenTools.fixedFontFamily; color: qgcPal.text; text: qsTr("Component") } + QGCLabel { Layout.preferredWidth: _colTypeWidth; font.family: ScreenTools.fixedFontFamily; color: qgcPal.text; text: qsTr("Failure") } + QGCLabel { Layout.fillWidth: true; font.family: ScreenTools.fixedFontFamily; color: qgcPal.text; text: qsTr("Instances") } + QGCLabel { font.family: ScreenTools.fixedFontFamily; color: qgcPal.text; text: qsTr("Result") } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 12 + color: qgcPal.windowShadeDark + radius: ScreenTools.defaultFontPixelWidth * 0.5 + enabled: _armed + opacity: _armed ? 1.0 : 0.4 + + QGCListView { + anchors.fill: parent + anchors.margins: ScreenTools.defaultFontPixelWidth + clip: true + model: FailureInjection.activity + delegate: RowLayout { + required property var modelData + readonly property bool _pending: modelData.result === "pending" + readonly property bool _accepted: modelData.result === "accepted" + width: ListView.view.width + spacing: ScreenTools.defaultFontPixelWidth * 2 + QGCLabel { Layout.preferredWidth: _colTimeWidth; font.family: ScreenTools.fixedFontFamily; color: qgcPal.colorOrange; text: modelData.time } + QGCLabel { Layout.preferredWidth: _colUnitWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.unitName } + QGCLabel { Layout.preferredWidth: _colTypeWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.typeName } + QGCLabel { Layout.fillWidth: true; font.family: ScreenTools.fixedFontFamily; elide: Text.ElideRight; text: modelData.instance } + QGCLabel { + font.family: ScreenTools.fixedFontFamily + color: _pending ? qgcPal.colorOrange : (_accepted ? qgcPal.colorGreen : qgcPal.colorRed) + text: _pending ? qsTr("…") : (_accepted ? qsTr("✓ Accepted") : ("✗ " + modelData.result)) + } + } + } + } + } + } +} diff --git a/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.cc b/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.cc index 0e23d1e22ab5..b53a96ac2729 100644 --- a/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.cc +++ b/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.cc @@ -7,6 +7,7 @@ #include "PowerComponent.h" #include "SafetyComponent.h" #include "SensorsComponent.h" +#include "FailureInjectionComponent.h" #include "ParameterManager.h" #include "Vehicle.h" #include "Actuators.h" @@ -23,6 +24,7 @@ PX4AutoPilotPlugin::PX4AutoPilotPlugin(Vehicle* vehicle, QObject* parent) , _airframeComponent(nullptr) , _radioComponent(nullptr) , _esp8266Component(nullptr) + , _failureInjectionComponent(nullptr) , _flightModesComponent(nullptr) , _sensorsComponent(nullptr) , _safetyComponent(nullptr) @@ -114,6 +116,13 @@ const QVariantList& PX4AutoPilotPlugin::vehicleComponents(void) _safetyComponent->setupTriggerSignals(); _components.append(QVariant::fromValue(static_cast(_safetyComponent))); + //-- Failure Injection, gated on SYS_FAILURE_EN existing + if (_vehicle->parameterManager()->parameterExists(ParameterManager::defaultComponentId, "SYS_FAILURE_EN")) { + _failureInjectionComponent = new FailureInjectionComponent(_vehicle, this, this); + _failureInjectionComponent->setupTriggerSignals(); + _components.append(QVariant::fromValue(static_cast(_failureInjectionComponent))); + } + _tuningComponent = new PX4TuningComponent(_vehicle, this, this); _tuningComponent->setupTriggerSignals(); _components.append(QVariant::fromValue(static_cast(_tuningComponent))); diff --git a/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.h b/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.h index 584e7edd19ce..b2e0eb5a9d68 100644 --- a/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.h +++ b/src/AutoPilotPlugins/PX4/PX4AutoPilotPlugin.h @@ -6,6 +6,7 @@ #include "AirframeComponent.h" #include "PX4RadioComponent.h" #include "ESP8266Component.h" +#include "FailureInjectionComponent.h" #include "FlightModesComponent.h" #include "SensorsComponent.h" #include "SafetyComponent.h" @@ -37,6 +38,7 @@ class PX4AutoPilotPlugin : public AutoPilotPlugin AirframeComponent* _airframeComponent; PX4RadioComponent* _radioComponent; ESP8266Component* _esp8266Component; + FailureInjectionComponent* _failureInjectionComponent; FlightModesComponent* _flightModesComponent; SensorsComponent* _sensorsComponent; SafetyComponent* _safetyComponent; diff --git a/test/AutoPilotPlugins/CMakeLists.txt b/test/AutoPilotPlugins/CMakeLists.txt index 427b4a14ad3a..088253517139 100644 --- a/test/AutoPilotPlugins/CMakeLists.txt +++ b/test/AutoPilotPlugins/CMakeLists.txt @@ -8,9 +8,12 @@ target_sources(${CMAKE_PROJECT_NAME} APM/APMFreshFlashParamsTest.h PX4/AirframeComponentAirframesTest.cc PX4/AirframeComponentAirframesTest.h + PX4/FailureInjectionTest.cc + PX4/FailureInjectionTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/PX4) add_qgc_test(AirframeComponentAirframesTest LABELS Unit) add_qgc_test(APMFreshFlashParamsTest LABELS Integration Vehicle TIMEOUT ${QGC_TEST_TIMEOUT_EXTENDED}) +add_qgc_test(FailureInjectionTest LABELS Unit) diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc new file mode 100644 index 000000000000..dedf6bb3bba7 --- /dev/null +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -0,0 +1,115 @@ +#include "FailureInjectionTest.h" + +#include + +#include "FailureInjection.h" +#include "MAVLinkLib.h" + +UT_REGISTER_TEST_LIGHTWEIGHT(FailureInjectionTest, TestLabel::Unit) + +void FailureInjectionTest::_catalogPopulatedFromMavlinkEnums() +{ + FailureInjection failureInjection; + + const QVariantList units = failureInjection.units(); + const QVariantList types = failureInjection.types(); + QVERIFY2(!units.isEmpty(), "FAILURE_UNIT catalog failed to build from the MAVLink dialect"); + QVERIFY2(!types.isEmpty(), "FAILURE_TYPE catalog failed to build from the MAVLink dialect"); + + bool foundGps = false; + for (const QVariant &entry : units) { + const QVariantMap map = entry.toMap(); + if (map.value(QStringLiteral("unit")).toInt() == static_cast(FAILURE_UNIT_SENSOR_GPS)) { + QCOMPARE(map.value(QStringLiteral("name")).toString(), QStringLiteral("GPS")); + foundGps = true; + } + } + QVERIFY2(foundGps, "FAILURE_UNIT_SENSOR_GPS missing from the units catalog, or its prefix was not stripped"); + + bool foundOk = false; + for (const QVariant &entry : types) { + const QVariantMap map = entry.toMap(); + if (map.value(QStringLiteral("type")).toInt() == static_cast(FAILURE_TYPE_OK)) { + QCOMPARE(map.value(QStringLiteral("name")).toString(), QStringLiteral("OK")); + foundOk = true; + } + } + QVERIFY2(foundOk, "FAILURE_TYPE_OK missing from the types catalog, or its prefix was not stripped"); +} + +void FailureInjectionTest::_logRowAddsPendingEntryWithoutTracking() +{ + FailureInjection failureInjection; + QSignalSpy activityChangedSpy(&failureInjection, &FailureInjection::activityChanged); + + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + + QCOMPARE(activityChangedSpy.count(), 1); + QCOMPARE(failureInjection.activity().count(), 1); + QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); + QVERIFY2(failureInjection.injectedUnits().isEmpty(), "logRow() must not track the unit for Reset"); +} + +void FailureInjectionTest::_logInjectionTracksUnitOnce() +{ + FailureInjection failureInjection; + + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("1"), QStringLiteral("12:00:00")); + QCOMPARE(failureInjection.injectedUnits().count(), 1); + QCOMPARE(failureInjection.injectedUnits().first().toInt(), static_cast(FAILURE_UNIT_SENSOR_GPS)); + + // Injecting the same unit again must not duplicate the tracked entry. + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Stuck"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("2"), QStringLiteral("12:00:01")); + QCOMPARE(failureInjection.injectedUnits().count(), 1); + QCOMPARE(failureInjection.activity().count(), 2); +} + +void FailureInjectionTest::_resolveResultResolvesOldestPendingRow() +{ + FailureInjection failureInjection; + + // Newest is prepended: index 0 holds the second row logged, index 1 the first. + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GYRO"), QStringLiteral("Stuck"), QStringLiteral("2"), QStringLiteral("12:00:01")); + + failureInjection.resolveResult(MAV_RESULT_ACCEPTED); + + const QVariantList activity = failureInjection.activity(); + QCOMPARE(activity.at(1).toMap().value(QStringLiteral("result")).toString(), QStringLiteral("accepted")); // oldest (GPS) resolved first + QCOMPARE(activity.at(0).toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); // newest (GYRO) still pending +} + +void FailureInjectionTest::_resolveResultIgnoresInProgress() +{ + FailureInjection failureInjection; + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + + failureInjection.resolveResult(MAV_RESULT_IN_PROGRESS); + + QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); +} + +void FailureInjectionTest::_resolveResultUnknownCodeFallsBackToMavResultString() +{ + FailureInjection failureInjection; + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + + // Not a real MAV_RESULT value on the wire today, but resolveResult() must still produce + // *some* readable text for a future/unrecognized code instead of silently leaving it pending. + failureInjection.resolveResult(99); + + QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), + QStringLiteral("MAV_RESULT unknown 99")); +} + +void FailureInjectionTest::_clearInjectedUnitsForgetsTrackedUnits() +{ + FailureInjection failureInjection; + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("1"), QStringLiteral("12:00:00")); + QVERIFY(!failureInjection.injectedUnits().isEmpty()); + + failureInjection.clearInjectedUnits(); + + QVERIFY(failureInjection.injectedUnits().isEmpty()); + QCOMPARE(failureInjection.activity().count(), 1); // the activity log itself is left intact +} diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h new file mode 100644 index 000000000000..4d0eb391f39b --- /dev/null +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h @@ -0,0 +1,19 @@ +#pragma once + +#include "UnitTest.h" + +/// Tests for the FailureInjection QML singleton's data model: the MAVLink-derived +/// FAILURE_UNIT/FAILURE_TYPE catalog, the activity log, and injected-unit tracking. +class FailureInjectionTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _catalogPopulatedFromMavlinkEnums(); + void _logRowAddsPendingEntryWithoutTracking(); + void _logInjectionTracksUnitOnce(); + void _resolveResultResolvesOldestPendingRow(); + void _resolveResultIgnoresInProgress(); + void _resolveResultUnknownCodeFallsBackToMavResultString(); + void _clearInjectedUnitsForgetsTrackedUnits(); +}; diff --git a/tools/generators/mavlink_enums.py b/tools/generators/mavlink_enums.py index c2193e600683..a27586fb5a58 100644 --- a/tools/generators/mavlink_enums.py +++ b/tools/generators/mavlink_enums.py @@ -127,8 +127,35 @@ def build_enums_header(dialects): return ''.join(parts), enum_names -def build_qml_header(enum_names): - using_lines = '\n'.join(f' using ::{n};' for n in enum_names) +# Enums listed here get a full local redeclaration inside the namespace, giving Q_ENUM_NS real +# enumerator metadata for them. Everything else gets a `using`-alias to the global mavlink typedef. +_QML_REFLECTED_ENUMS = ("FAILURE_UNIT", "FAILURE_TYPE") +_RAW_ENUM_ENTRY_RE = re.compile(r'([A-Z][A-Z0-9_]*)\s*=\s*(\d+)') + + +def _raw_enum_entries(enums_text, enum_name): + """Parse `NAME=VALUE` entries of a single enum out of the generated enum text, names unmodified.""" + m = re.search(r'typedef\s+enum\s+' + re.escape(enum_name) + r'\b', enums_text) + if not m: + return [] + start = enums_text.find('{', m.end()) + close = re.search(r'\}\s*' + re.escape(enum_name) + r'\s*;', enums_text) + if start < 0 or not close: + return [] + block = enums_text[start + 1:close.start()] + return _RAW_ENUM_ENTRY_RE.findall(block) + + +def build_qml_header(enum_names, enums_text): + decl_lines = [] + for n in enum_names: + if n in _QML_REFLECTED_ENUMS: + entries = _raw_enum_entries(enums_text, n) + body = ",\n".join(f" {name} = {value}" for name, value in entries) + decl_lines.append(f" enum {n} {{\n{body}\n }};") + else: + decl_lines.append(f" using ::{n};") + using_or_decl_lines = "\n".join(decl_lines) q_enum_lines = '\n'.join(f' Q_ENUM_NS({n})' for n in enum_names) return f"""\ #pragma once @@ -146,7 +173,7 @@ def build_qml_header(enum_names): Q_NAMESPACE QML_NAMED_ELEMENT(MAVLinkEnums) -{using_lines} +{using_or_decl_lines} {q_enum_lines} }} @@ -194,7 +221,7 @@ def main(): written: list[Path] = [] if write_if_changed(enums_h_path, enums_h): written.append(enums_h_path) - if write_if_changed(qml_h_path, build_qml_header(enum_names)): + if write_if_changed(qml_h_path, build_qml_header(enum_names, enums_h)): written.append(qml_h_path) if write_if_changed(qml_cc_path, build_qml_anchor_cc()): written.append(qml_cc_path) From 9e70ba029720bd8bdc5b5a8324b65c1c6447ad71 Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Thu, 2 Jul 2026 09:23:40 +0200 Subject: [PATCH 2/9] slight refactor to improve test coverage --- src/AutoPilotPlugins/PX4/CMakeLists.txt | 1 + .../PX4/FailureInjectionComponent.qml | 51 ++++++-------- .../PX4/FailureInjectionInstances.js | 47 +++++++++++++ test/QmlUITests/CMakeLists.txt | 3 + test/QmlUITests/FailureInjectionUITest.cc | 58 ++++++++++++++++ test/QmlUITests/FailureInjectionUITest.h | 19 ++++++ .../tests/tst_FailureInjectionInstances.qml | 68 +++++++++++++++++++ 7 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 src/AutoPilotPlugins/PX4/FailureInjectionInstances.js create mode 100644 test/QmlUITests/FailureInjectionUITest.cc create mode 100644 test/QmlUITests/FailureInjectionUITest.h create mode 100644 test/UnitTestFramework/QmlTesting/tests/tst_FailureInjectionInstances.qml diff --git a/src/AutoPilotPlugins/PX4/CMakeLists.txt b/src/AutoPilotPlugins/PX4/CMakeLists.txt index 7c1b1c0def5a..f6573c97edd4 100644 --- a/src/AutoPilotPlugins/PX4/CMakeLists.txt +++ b/src/AutoPilotPlugins/PX4/CMakeLists.txt @@ -113,6 +113,7 @@ qt_add_qml_module(AutoPilotPluginsPX4Module CalcVoltageDividerDialog.qml ESCCalibrationDialog.qml FailureInjectionComponent.qml + FailureInjectionInstances.js FlightModesComponentSummary.qml PowerComponentSummary.qml PX4FlightBehaviorCopter.qml diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index 69348d993c92..d46559518f59 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -14,6 +14,8 @@ import QGroundControl import QGroundControl.Controls import QGroundControl.FactControls +import "FailureInjectionInstances.js" as Instances + SetupPage { id: failureInjectionPage pageComponent: pageComponent @@ -81,21 +83,8 @@ SetupPage { 0, 0, 0) // param5..7 } - // Map the current instance selection to the command form: - // All -> {p3:0, p4:0}; one instance n -> {p3:n, p4:0}; many -> {p3:NaN, p4:bitmask}. function _instanceSend() { - var sel = _selectedInstances.slice().sort(function(a, b){ return a - b }) - if (sel.indexOf(0) >= 0) { - return { param3: 0, param4: 0, label: "all" } - } - if (sel.length === 1) { - return { param3: sel[0], param4: 0, label: "" + sel[0] } - } - var mask = 0 - for (var i = 0; i < sel.length; ++i) { - mask |= (1 << (sel[i] - 1)) - } - return { param3: NaN, param4: mask, label: sel.join(", ") } + return Instances.instanceSend(_selectedInstances) } // Queue one command; kick off sending if the queue was idle. @@ -150,12 +139,7 @@ SetupPage { } function _toggleInstance(n) { - var arr = _selectedInstances.slice() - var zero = arr.indexOf(0) // picking a specific instance clears the "All" selection - if (zero >= 0) { arr.splice(zero, 1) } - var idx = arr.indexOf(n) - if (idx >= 0) { arr.splice(idx, 1) } else { arr.push(n) } - _selectedInstances = arr + _selectedInstances = Instances.toggleInstance(_selectedInstances, n) } function _setEnabled(on) { @@ -166,17 +150,11 @@ SetupPage { } function _unitName(unitEnum) { - for (var i = 0; i < _units.length; ++i) { - if (_units[i].unit === unitEnum) { return _units[i].name } - } - return "" + unitEnum + return Instances.unitName(_units, unitEnum) } function _typeName(typeEnum) { - for (var i = 0; i < _types.length; ++i) { - if (_types[i].type === typeEnum) { return _types[i].name } - } - return "" + typeEnum + return Instances.typeName(_types, typeEnum) } // Send FAILURE_TYPE_OK (all instances) to every unit injected this session and log each as a row. @@ -226,24 +204,28 @@ SetupPage { spacing: ScreenTools.defaultFontPixelWidth * 2 QGCCheckBox { + objectName: "failureInjection_enableCheckbox" text: qsTr("SYS_FAILURE_EN") enabled: _sysFailureEn !== null checked: _paramSet onClicked: _setEnabled(checked) } QGCLabel { + objectName: "failureInjection_pendingRebootLabel" Layout.fillWidth: true color: qgcPal.colorOrange visible: _pendingReboot text: qsTr("Parameter written — reboot required before it takes effect.") } QGCLabel { + objectName: "failureInjection_armedLabel" Layout.fillWidth: true color: qgcPal.colorGreen visible: _armed && !_pendingReboot text: qsTr("Active — injection armed.") } QGCButton { + objectName: "failureInjection_rebootButton" text: qsTr("Reboot Vehicle") visible: _pendingReboot onClicked: { @@ -299,6 +281,7 @@ SetupPage { } Item { Layout.fillWidth: true } QGCButton { + objectName: "failureInjection_injectButton" text: qsTr("Inject failure") primary: true enabled: _armed && _selectedInstances.length > 0 @@ -363,7 +346,7 @@ SetupPage { enabled: _armed opacity: _armed ? 1.0 : 0.4 QGCLabel { Layout.fillWidth: true; text: qsTr("Activity — newest first") } - QGCButton { text: qsTr("Reset all"); onClicked: _resetAll() } + QGCButton { objectName: "failureInjection_resetAllButton"; text: qsTr("Reset all"); onClicked: _resetAll() } } // Column header, aligned with the rows below. RowLayout { @@ -388,21 +371,25 @@ SetupPage { opacity: _armed ? 1.0 : 0.4 QGCListView { + objectName: "failureInjection_activityList" anchors.fill: parent anchors.margins: ScreenTools.defaultFontPixelWidth clip: true model: FailureInjection.activity delegate: RowLayout { required property var modelData + required property int index readonly property bool _pending: modelData.result === "pending" readonly property bool _accepted: modelData.result === "accepted" + objectName: "failureInjection_activityRow_" + index width: ListView.view.width spacing: ScreenTools.defaultFontPixelWidth * 2 QGCLabel { Layout.preferredWidth: _colTimeWidth; font.family: ScreenTools.fixedFontFamily; color: qgcPal.colorOrange; text: modelData.time } - QGCLabel { Layout.preferredWidth: _colUnitWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.unitName } - QGCLabel { Layout.preferredWidth: _colTypeWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.typeName } - QGCLabel { Layout.fillWidth: true; font.family: ScreenTools.fixedFontFamily; elide: Text.ElideRight; text: modelData.instance } + QGCLabel { objectName: "failureInjection_unitName_" + index; Layout.preferredWidth: _colUnitWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.unitName } + QGCLabel { objectName: "failureInjection_typeName_" + index; Layout.preferredWidth: _colTypeWidth; font.family: ScreenTools.fixedFontFamily; text: modelData.typeName } + QGCLabel { objectName: "failureInjection_instance_" + index; Layout.fillWidth: true; font.family: ScreenTools.fixedFontFamily; elide: Text.ElideRight; text: modelData.instance } QGCLabel { + objectName: "failureInjection_result_" + index font.family: ScreenTools.fixedFontFamily color: _pending ? qgcPal.colorOrange : (_accepted ? qgcPal.colorGreen : qgcPal.colorRed) text: _pending ? qsTr("…") : (_accepted ? qsTr("✓ Accepted") : ("✗ " + modelData.result)) diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js b/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js new file mode 100644 index 000000000000..50e1b7872f0f --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js @@ -0,0 +1,47 @@ +.pragma library + +// Map a selected-instances array to the MAV_CMD_INJECT_FAILURE param3/param4 form: +// All -> {p3:0, p4:0}; one instance n -> {p3:n, p4:0}; many -> {p3:NaN, p4:bitmask}. +function instanceSend(selectedInstances) { + var sel = selectedInstances.slice().sort(function(a, b){ return a - b }) + if (sel.indexOf(0) >= 0) { + return { param3: 0, param4: 0, label: "all" } + } + if (sel.length === 1) { + return { param3: sel[0], param4: 0, label: "" + sel[0] } + } + var mask = 0 + for (var i = 0; i < sel.length; ++i) { + mask |= (1 << (sel[i] - 1)) + } + return { param3: NaN, param4: mask, label: sel.join(", ") } +} + +// Toggle instance n in the selection. Picking a specific instance clears "All" (0); picking "All" +// is handled by the caller directly, this only covers the specific-instance toggle. +function toggleInstance(selectedInstances, n) { + var arr = selectedInstances.slice() + var zero = arr.indexOf(0) + if (zero >= 0) { arr.splice(zero, 1) } + var idx = arr.indexOf(n) + if (idx >= 0) { arr.splice(idx, 1) } else { arr.push(n) } + return arr +} + +// Look up the display name for a FAILURE_UNIT value in a [{ name, unit }] catalog list. +// Falls back to the numeric value as a string if not found. +function unitName(units, unitEnum) { + for (var i = 0; i < units.length; ++i) { + if (units[i].unit === unitEnum) { return units[i].name } + } + return "" + unitEnum +} + +// Look up the display name for a FAILURE_TYPE value in a [{ name, type }] catalog list. +// Falls back to the numeric value as a string if not found. +function typeName(types, typeEnum) { + for (var i = 0; i < types.length; ++i) { + if (types[i].type === typeEnum) { return types[i].name } + } + return "" + typeEnum +} diff --git a/test/QmlUITests/CMakeLists.txt b/test/QmlUITests/CMakeLists.txt index 101e967a091e..e4c3cdd1bc83 100644 --- a/test/QmlUITests/CMakeLists.txt +++ b/test/QmlUITests/CMakeLists.txt @@ -28,6 +28,8 @@ target_sources(${CMAKE_PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/APMFreshFlashUITest.h ${CMAKE_CURRENT_SOURCE_DIR}/PX4AirframeSetupUITest.cc ${CMAKE_CURRENT_SOURCE_DIR}/PX4AirframeSetupUITest.h + ${CMAKE_CURRENT_SOURCE_DIR}/FailureInjectionUITest.cc + ${CMAKE_CURRENT_SOURCE_DIR}/FailureInjectionUITest.h ${CMAKE_CURRENT_SOURCE_DIR}/AppCloseWarningUITest.cc ${CMAKE_CURRENT_SOURCE_DIR}/AppCloseWarningUITest.h ${CMAKE_CURRENT_SOURCE_DIR}/FlyViewProximityRadarUITest.cc @@ -52,6 +54,7 @@ add_qgc_test(PX4SensorsCalibrationUITest LABELS Integration NoSanitizer TIMEOUT add_qgc_test(APMSensorsCalibrationUITest LABELS Integration NoSanitizer TIMEOUT 240) add_qgc_test(APMFreshFlashUITest LABELS Integration NoSanitizer TIMEOUT 180) add_qgc_test(PX4AirframeSetupUITest LABELS Integration NoSanitizer TIMEOUT 120) +add_qgc_test(FailureInjectionUITest LABELS Integration NoSanitizer TIMEOUT 120) add_qgc_test(AppCloseWarningUITest LABELS Integration NoSanitizer TIMEOUT 120) add_qgc_test(FlyViewProximityRadarUITest LABELS Integration NoSanitizer TIMEOUT 120) add_qgc_test(NTRIPSettingsUITest LABELS Integration NoSanitizer TIMEOUT 120) diff --git a/test/QmlUITests/FailureInjectionUITest.cc b/test/QmlUITests/FailureInjectionUITest.cc new file mode 100644 index 000000000000..2f6191213c5c --- /dev/null +++ b/test/QmlUITests/FailureInjectionUITest.cc @@ -0,0 +1,58 @@ +#include "FailureInjectionUITest.h" + +#include +#include + +#include "MockLink.h" + +UT_REGISTER_TEST(FailureInjectionUITest, TestLabel::Integration) + +void FailureInjectionUITest::_testInjectAndReset() +{ + runWithMockLink( + [] { return MockLink::startPX4MockLink(false, false, false); }, + [&](QPointer /*mockLink*/, Vehicle * /*vehicle*/) { + + navigateToConfigureView(); + if (QTest::currentTestFailed()) return; + + clickSidebarButton(QStringLiteral("vehicleConfig_comp_FailureInjection")); + if (QTest::currentTestFailed()) return; + + // PX4MockLink.params ships SYS_FAILURE_EN=1, so the page starts already armed. + QVERIFY2(verifyChecked(QStringLiteral("failureInjection_enableCheckbox"), true, "on page open"), + "SYS_FAILURE_EN checkbox not checked on page open"); + QQuickItem *armedLabel = findVisibleItem(_rootItem, QStringLiteral("failureInjection_armedLabel"), 2000); + QVERIFY2(armedLabel, "Armed label not visible on page open"); + + // Default selection (unit index 4 = GPS, type index 1 = OFF, instance 1) is injected as-is. + QVERIFY2(clickButton(QStringLiteral("failureInjection_injectButton")), "Failed to click Inject failure"); + + QQuickItem *activityList = findVisibleItem(_rootItem, QStringLiteral("failureInjection_activityList"), 2000); + QVERIFY2(activityList, "Activity list not visible after injecting"); + QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 1; }, 3000), + "Activity row not added after injecting"); + + QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after inject"), + "Injected row does not show unit GPS"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OFF"), "after inject"), + "Injected row does not show type OFF"); + + // MockLink doesn't handle MAV_CMD_INJECT_FAILURE, so it acks MAV_RESULT_UNSUPPORTED. + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("✗ Unsupported"), "after inject ack"), + "Injected row result never resolved to Unsupported"); + + // Reset all reverts the unit tracked by the injection above: a second row appears (GPS / OK). + QVERIFY2(clickButton(QStringLiteral("failureInjection_resetAllButton")), "Failed to click Reset all"); + + QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 2; }, 3000), + "Activity row not added after reset"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after reset"), + "Reset row does not show unit GPS"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OK"), "after reset"), + "Reset row does not show type OK"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("✗ Unsupported"), "after reset ack"), + "Reset row result never resolved to Unsupported"); + + }); +} diff --git a/test/QmlUITests/FailureInjectionUITest.h b/test/QmlUITests/FailureInjectionUITest.h new file mode 100644 index 000000000000..6fc2a254e32e --- /dev/null +++ b/test/QmlUITests/FailureInjectionUITest.h @@ -0,0 +1,19 @@ +#pragma once + +#include "VehicleConfigUITestBase.h" + +/// UI test that boots the full QML UI with a PX4 MockLink vehicle connected, +/// navigates to the Failure Injection setup page and drives an injection and a +/// reset through the real UI controls. MockLink doesn't special-case +/// MAV_CMD_INJECT_FAILURE, so it acks MAV_RESULT_UNSUPPORTED for every send — +/// this test exercises the send/ack/log round trip, not PX4 behavior. +class FailureInjectionUITest : public VehicleConfigUITestBase +{ + Q_OBJECT + +public: + FailureInjectionUITest() = default; + +private slots: + void _testInjectAndReset(); +}; diff --git a/test/UnitTestFramework/QmlTesting/tests/tst_FailureInjectionInstances.qml b/test/UnitTestFramework/QmlTesting/tests/tst_FailureInjectionInstances.qml new file mode 100644 index 000000000000..ea5eb45dfc23 --- /dev/null +++ b/test/UnitTestFramework/QmlTesting/tests/tst_FailureInjectionInstances.qml @@ -0,0 +1,68 @@ +import QtQuick +import QtTest + +import "../../../../src/AutoPilotPlugins/PX4/FailureInjectionInstances.js" as Instances + +/// Tests the pure instance-selection logic shared with FailureInjectionComponent.qml. +TestCase { + id: testCase + name: "FailureInjectionInstancesTest" + + function test_instanceSend_data() { + return [ + { tag: "all", selected: [0], param3: 0, param4: 0, label: "all" }, + { tag: "single", selected: [3], param3: 3, param4: 0, label: "3" }, + { tag: "unsorted-single", selected: [3], param3: 3, param4: 0, label: "3" }, + ] + } + + function test_instanceSend(data) { + var send = Instances.instanceSend(data.selected) + compare(send.param3, data.param3) + compare(send.param4, data.param4) + compare(send.label, data.label) + } + + function test_instanceSend_multiIsBitmask() { + var send = Instances.instanceSend([1, 3, 5]) + verify(isNaN(send.param3), "param3 is NaN when using the bitmask form") + compare(send.param4, (1 << 0) | (1 << 2) | (1 << 4)) + compare(send.label, "1, 3, 5") + } + + function test_instanceSend_sortsBeforeLabeling() { + var send = Instances.instanceSend([5, 1, 3]) + compare(send.label, "1, 3, 5") + } + + function test_toggleInstance_addsAndRemoves() { + var sel = Instances.toggleInstance([1], 2) + compare(sel, [1, 2]) + + sel = Instances.toggleInstance(sel, 1) + compare(sel, [2]) + } + + function test_toggleInstance_clearsAllSelection() { + var sel = Instances.toggleInstance([0], 3) + compare(sel, [3]) + } + + function test_unitName_found() { + var units = [{ name: "GYRO", unit: 0 }, { name: "GPS", unit: 4 }] + compare(Instances.unitName(units, 4), "GPS") + } + + function test_unitName_fallsBackToNumber() { + compare(Instances.unitName([], 99), "99") + } + + function test_typeName_found() { + var types = [{ name: "OK", type: 0 }, { name: "OFF", type: 1 }] + compare(Instances.typeName(types, 1), "OFF") + } + + function test_typeName_fallsBackToNumber() { + compare(Instances.typeName([], 7), "7") + } +} From 5beeecfad98efd2d2343dccf0437c91f910a0a16 Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Fri, 3 Jul 2026 13:16:19 +0200 Subject: [PATCH 3/9] fix(ui): fix android related rendering issues --- src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml | 5 +++-- test/QmlUITests/FailureInjectionUITest.cc | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index d46559518f59..dcd35b364d8b 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -213,9 +213,10 @@ SetupPage { QGCLabel { objectName: "failureInjection_pendingRebootLabel" Layout.fillWidth: true + elide: Text.ElideRight color: qgcPal.colorOrange visible: _pendingReboot - text: qsTr("Parameter written — reboot required before it takes effect.") + text: qsTr("Reboot required to apply.") } QGCLabel { objectName: "failureInjection_armedLabel" @@ -392,7 +393,7 @@ SetupPage { objectName: "failureInjection_result_" + index font.family: ScreenTools.fixedFontFamily color: _pending ? qgcPal.colorOrange : (_accepted ? qgcPal.colorGreen : qgcPal.colorRed) - text: _pending ? qsTr("…") : (_accepted ? qsTr("✓ Accepted") : ("✗ " + modelData.result)) + text: _pending ? qsTr("…") : (_accepted ? qsTr("✓ Accepted") : ("× " + modelData.result)) } } } diff --git a/test/QmlUITests/FailureInjectionUITest.cc b/test/QmlUITests/FailureInjectionUITest.cc index 2f6191213c5c..ef6529a94f7e 100644 --- a/test/QmlUITests/FailureInjectionUITest.cc +++ b/test/QmlUITests/FailureInjectionUITest.cc @@ -39,7 +39,7 @@ void FailureInjectionUITest::_testInjectAndReset() "Injected row does not show type OFF"); // MockLink doesn't handle MAV_CMD_INJECT_FAILURE, so it acks MAV_RESULT_UNSUPPORTED. - QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("✗ Unsupported"), "after inject ack"), + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), "after inject ack"), "Injected row result never resolved to Unsupported"); // Reset all reverts the unit tracked by the injection above: a second row appears (GPS / OK). @@ -51,7 +51,7 @@ void FailureInjectionUITest::_testInjectAndReset() "Reset row does not show unit GPS"); QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OK"), "after reset"), "Reset row does not show type OK"); - QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("✗ Unsupported"), "after reset ack"), + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), "after reset ack"), "Reset row result never resolved to Unsupported"); }); From 1f2f461431716f2776c9ace4673ea32835206082 Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Tue, 7 Jul 2026 07:32:04 +0200 Subject: [PATCH 4/9] fix(FailureInjection): reorder injection call for improved logging --- src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index dcd35b364d8b..7134f3b2f38c 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -103,7 +103,6 @@ SetupPage { return } var s = _sendQueue[0] - _injectOne(s.unit, s.type, s.param3, s.param4) if (s.logArgs) { var stamp = Qt.formatDateTime(new Date(), "hh:mm:ss") if (s.logArgs.track) { @@ -114,6 +113,7 @@ SetupPage { FailureInjection.logRow(s.logArgs.unitName, s.logArgs.typeName, s.logArgs.instanceLabel, stamp) } } + _injectOne(s.unit, s.type, s.param3, s.param4) } // One ack arrived: resolve the matching log row (only one is pending at a time), then send the next. From 9a51f7bd07f7e32a5fc0da69b8ec7cfc26a74e8f Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Thu, 23 Jul 2026 14:17:17 -0700 Subject: [PATCH 5/9] feat(FailureInjection): add detail parameters for failure types and update UI components --- cmake/CustomOptions.cmake | 2 +- src/AutoPilotPlugins/PX4/CMakeLists.txt | 1 + src/AutoPilotPlugins/PX4/FailureInjection.cc | 25 ++++++++++++++++ src/AutoPilotPlugins/PX4/FailureInjection.h | 3 ++ .../PX4/FailureInjectionComponent.cc | 2 +- .../PX4/FailureInjectionComponent.qml | 30 +++++++++++++++++++ .../PX4/Images/WarningEmergency.svg | 5 ++++ .../PX4/FailureInjectionTest.cc | 15 ++++++++++ .../PX4/FailureInjectionTest.h | 1 + test/QmlUITests/FailureInjectionUITest.cc | 2 +- 10 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/AutoPilotPlugins/PX4/Images/WarningEmergency.svg diff --git a/cmake/CustomOptions.cmake b/cmake/CustomOptions.cmake index 947650aa1413..1bd4f8bf0df0 100644 --- a/cmake/CustomOptions.cmake +++ b/cmake/CustomOptions.cmake @@ -120,7 +120,7 @@ option(QGC_ENABLE_GST_VIDEOSTREAMING "Enable GStreamer video backend" ON) # ============================================================================ set(QGC_MAVLINK_GIT_REPO "https://github.com/mavlink/mavlink.git" CACHE STRING "MAVLink repository URL") -set(QGC_MAVLINK_GIT_TAG "35cd26af3036b54205b0c1d25ac7bf6f5bdbe15a" CACHE STRING "MAVLink repository commit/tag") +set(QGC_MAVLINK_GIT_TAG "1fe1417edba14c178a20303c6914d3b301097a02" CACHE STRING "MAVLink repository commit/tag") set(QGC_MAVLINK_DIALECT "all" CACHE STRING "MAVLink dialect") set(QGC_MAVLINK_VERSION "2.0" CACHE STRING "MAVLink protocol version") diff --git a/src/AutoPilotPlugins/PX4/CMakeLists.txt b/src/AutoPilotPlugins/PX4/CMakeLists.txt index f6573c97edd4..0e9a72b559e9 100644 --- a/src/AutoPilotPlugins/PX4/CMakeLists.txt +++ b/src/AutoPilotPlugins/PX4/CMakeLists.txt @@ -188,4 +188,5 @@ qt_add_resources(${CMAKE_PROJECT_NAME} autopilot_plugin_px4_qmlimages "${CMAKE_SOURCE_DIR}/src/AutoPilotPlugins/PX4/Images/VehicleTailDownRotate.png" "${CMAKE_SOURCE_DIR}/src/AutoPilotPlugins/PX4/Images/VehicleUpsideDown.png" "${CMAKE_SOURCE_DIR}/src/AutoPilotPlugins/PX4/Images/VehicleUpsideDownRotate.png" + "${CMAKE_SOURCE_DIR}/src/AutoPilotPlugins/PX4/Images/WarningEmergency.svg" ) diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc index 2619e87f6493..bf5fdcd09dce 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -125,3 +125,28 @@ void FailureInjection::clearInjectedUnits(void) { _injectedUnits.clear(); } + +QVariantList FailureInjection::detailParams(int unitEnum, int typeEnum) const +{ + // Vehicle parameters that refine how a failure manifests, keyed by (unit, type). The page shows + // an editor per entry, but only when the connected vehicle actually exposes the parameter. + // Adding a new combo is one more table line. + struct DetailParam { + int unitEnum; + int typeEnum; + QString param; + QString label; + }; + static const QList table = { + { FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, + QStringLiteral("SYS_FAIL_BAT_LVL"), tr("Battery level") }, + }; + + QVariantList list; + for (const DetailParam &entry : table) { + if (entry.unitEnum == unitEnum && entry.typeEnum == typeEnum) { + list.append(QVariantMap{ {"param", entry.param}, {"label", entry.label} }); + } + } + return list; +} diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.h b/src/AutoPilotPlugins/PX4/FailureInjection.h index 2662555ceef5..ba2d85718151 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.h +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -44,6 +44,9 @@ class FailureInjection : public QObject Q_INVOKABLE QVariantList injectedUnits(void) const; /// Forget the injected-units set (after a Reset reverted them); the activity log is left intact. Q_INVOKABLE void clearInjectedUnits(void); + /// Vehicle parameters that refine how a (unit, type) failure manifests, e.g. BATTERY+WRONG -> + /// SYS_FAIL_BAT_LVL. Returns [{ param, label }]; empty when the combo has no detail parameters. + Q_INVOKABLE QVariantList detailParams(int unitEnum, int typeEnum) const; signals: void activityChanged(void); diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc index d5816e365ea5..c03e0ab0e836 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc @@ -24,7 +24,7 @@ QString FailureInjectionComponent::description(void) const QString FailureInjectionComponent::iconResource(void) const { - return QStringLiteral("/qmlimages/subMenuButtonImage.png"); + return QStringLiteral("/qmlimages/WarningEmergency.svg"); } QUrl FailureInjectionComponent::setupSource(void) const diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index 7134f3b2f38c..203f7cbc779b 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -40,6 +40,10 @@ SetupPage { property var _selectedInstances: [1] // 1-based instance numbers readonly property int _instanceCount: 8 // fixed number of selectable instances (not tied to the unit) + // Detail parameters for the selected unit/type combo (e.g. BATTERY + WRONG -> SYS_FAIL_BAT_LVL). + // Re-evaluates when either picker updates _unitIndex/_typeIndex. + property var _detailParams: FailureInjection.detailParams(_units[_unitIndex].unit, _types[_typeIndex].type) + // Activity log column widths — shared by the header and every row so the fields line up. readonly property real _colTimeWidth: ScreenTools.defaultFontPixelWidth * 9 readonly property real _colUnitWidth: ScreenTools.defaultFontPixelWidth * 17 @@ -280,6 +284,32 @@ SetupPage { onActivated: function(index) { _typeIndex = index } } } + // Detail parameters for the selected combo, styled like the pickers to their + // left. Each editor appears only when the vehicle exposes the parameter: + // enum metadata -> dropdown, otherwise numeric field. + Repeater { + model: _detailParams + ColumnLayout { + id: detailCol + property Fact _detailFact: controller.parameterExists(-1, modelData.param) + ? controller.getParameterFact(-1, modelData.param, false /* reportMissing */) + : null + visible: detailCol._detailFact !== null + + QGCLabel { text: modelData.label } + FactComboBox { + Layout.preferredWidth: ScreenTools.defaultFontPixelWidth * 18 + fact: detailCol._detailFact + indexModel: false + visible: detailCol._detailFact !== null && detailCol._detailFact.enumStrings.length > 0 + } + FactTextField { + Layout.preferredWidth: ScreenTools.defaultFontPixelWidth * 18 + fact: detailCol._detailFact + visible: detailCol._detailFact !== null && detailCol._detailFact.enumStrings.length === 0 + } + } + } Item { Layout.fillWidth: true } QGCButton { objectName: "failureInjection_injectButton" diff --git a/src/AutoPilotPlugins/PX4/Images/WarningEmergency.svg b/src/AutoPilotPlugins/PX4/Images/WarningEmergency.svg new file mode 100644 index 000000000000..d42ed291dac4 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/Images/WarningEmergency.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc index dedf6bb3bba7..d945b7ced311 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -113,3 +113,18 @@ void FailureInjectionTest::_clearInjectedUnitsForgetsTrackedUnits() QVERIFY(failureInjection.injectedUnits().isEmpty()); QCOMPARE(failureInjection.activity().count(), 1); // the activity log itself is left intact } + +void FailureInjectionTest::_detailParamsMapCombos() +{ + FailureInjection failureInjection; + + // BATTERY + WRONG exposes the SYS_FAIL_BAT_LVL detail parameter. + const QVariantList batteryWrong = failureInjection.detailParams(FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG); + QCOMPARE(batteryWrong.count(), 1); + QCOMPARE(batteryWrong.first().toMap().value(QStringLiteral("param")).toString(), QStringLiteral("SYS_FAIL_BAT_LVL")); + QVERIFY2(!batteryWrong.first().toMap().value(QStringLiteral("label")).toString().isEmpty(), "detail param must have a display label"); + + // Combos without detail parameters return an empty list. + QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_OFF).isEmpty()); + QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SENSOR_GPS, FAILURE_TYPE_WRONG).isEmpty()); +} diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h index 4d0eb391f39b..ff3394a83fe0 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h @@ -16,4 +16,5 @@ private slots: void _resolveResultIgnoresInProgress(); void _resolveResultUnknownCodeFallsBackToMavResultString(); void _clearInjectedUnitsForgetsTrackedUnits(); + void _detailParamsMapCombos(); }; diff --git a/test/QmlUITests/FailureInjectionUITest.cc b/test/QmlUITests/FailureInjectionUITest.cc index ef6529a94f7e..24dbfafbae62 100644 --- a/test/QmlUITests/FailureInjectionUITest.cc +++ b/test/QmlUITests/FailureInjectionUITest.cc @@ -10,7 +10,7 @@ UT_REGISTER_TEST(FailureInjectionUITest, TestLabel::Integration) void FailureInjectionUITest::_testInjectAndReset() { runWithMockLink( - [] { return MockLink::startPX4MockLink(false, false, false); }, + [] { return MockLink::startPX4MockLink(); }, [&](QPointer /*mockLink*/, Vehicle * /*vehicle*/) { navigateToConfigureView(); From 385636cbad04ee7a7977b536cb0fdff235aa927d Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Thu, 23 Jul 2026 14:38:47 -0700 Subject: [PATCH 6/9] feat(FailureInjection): improve detail parameter handling and add utility functions for unit/type indexing --- src/AutoPilotPlugins/PX4/FailureInjection.cc | 15 +++++++-------- src/AutoPilotPlugins/PX4/FailureInjection.h | 8 ++++---- .../PX4/FailureInjectionComponent.qml | 13 ++++++++----- .../PX4/FailureInjectionInstances.js | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc index bf5fdcd09dce..c2afe13219e6 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -132,20 +132,19 @@ QVariantList FailureInjection::detailParams(int unitEnum, int typeEnum) const // an editor per entry, but only when the connected vehicle actually exposes the parameter. // Adding a new combo is one more table line. struct DetailParam { - int unitEnum; - int typeEnum; - QString param; - QString label; + int unitEnum; + int typeEnum; + const char *param; + const char *label; }; - static const QList table = { - { FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, - QStringLiteral("SYS_FAIL_BAT_LVL"), tr("Battery level") }, + static const DetailParam table[] = { + { FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, "SYS_FAIL_BAT_LVL", QT_TR_NOOP("Battery level") }, }; QVariantList list; for (const DetailParam &entry : table) { if (entry.unitEnum == unitEnum && entry.typeEnum == typeEnum) { - list.append(QVariantMap{ {"param", entry.param}, {"label", entry.label} }); + list.append(QVariantMap{ {"param", QString::fromLatin1(entry.param)}, {"label", tr(entry.label)} }); } } return list; diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.h b/src/AutoPilotPlugins/PX4/FailureInjection.h index ba2d85718151..41117e84c242 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.h +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -3,9 +3,9 @@ * * QML singleton backing the Failure Injection page. It serves two roles: * - * 1. Static catalog: the MAVLink FAILURE_UNIT / FAILURE_TYPE enums. Enum - * values come from the MAVLink dialect headers, tracking common.xml; - * display names and per-unit instance maxima are defined in the .cc. + * 1. Static catalog: the MAVLink FAILURE_UNIT / FAILURE_TYPE enums. Values and + * names come from the MAVLink dialect headers (tracking common.xml); the .cc + * derives each display name by stripping the enum-name prefix. * * 2. Session state: the activity log and the set of units injected this * session, persisted here so they survive navigating away from and back @@ -22,7 +22,7 @@ class FailureInjection : public QObject Q_OBJECT QML_NAMED_ELEMENT(FailureInjection) QML_SINGLETON - Q_PROPERTY(QVariantList units READ units CONSTANT) ///< [{ name, unit, max }] + Q_PROPERTY(QVariantList units READ units CONSTANT) ///< [{ name, unit }] Q_PROPERTY(QVariantList types READ types CONSTANT) ///< [{ name, type }] Q_PROPERTY(QVariantList activity READ activity NOTIFY activityChanged) ///< newest first: [{ time, unitName, typeName, instance, result }] diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index 203f7cbc779b..61acddc52cb5 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -26,7 +26,7 @@ SetupPage { property Fact _sysFailureEn: controller.getParameterFact(-1, "SYS_FAILURE_EN", true /* reportMissing */) property bool _paramSet: _sysFailureEn && _sysFailureEn.value === 1 property bool _pendingReboot: false // SYS_FAILURE_EN just toggled, reboot not yet triggered - property bool _armed: _paramSet // PX4 honors SYS_FAILURE_EN at boot; arm once the param reads 1 + property bool _armed: _paramSet && !_pendingReboot readonly property int _cmdInjectFailure: 420 // MAV_CMD_INJECT_FAILURE @@ -35,14 +35,17 @@ SetupPage { property var _units: FailureInjection.units // [{ name, unit }] property var _types: FailureInjection.types // [{ name, type }] - property int _unitIndex: 4 // GPS - property int _typeIndex: 1 // Off + property int _unitIndex: Instances.indexOfUnit(_units, 4) // FAILURE_UNIT_SENSOR_GPS + property int _typeIndex: Instances.indexOfType(_types, 1) // FAILURE_TYPE_OFF property var _selectedInstances: [1] // 1-based instance numbers readonly property int _instanceCount: 8 // fixed number of selectable instances (not tied to the unit) // Detail parameters for the selected unit/type combo (e.g. BATTERY + WRONG -> SYS_FAIL_BAT_LVL). // Re-evaluates when either picker updates _unitIndex/_typeIndex. - property var _detailParams: FailureInjection.detailParams(_units[_unitIndex].unit, _types[_typeIndex].type) + property var _detailParams: (_unitIndex >= 0 && _unitIndex < _units.length && + _typeIndex >= 0 && _typeIndex < _types.length) + ? FailureInjection.detailParams(_units[_unitIndex].unit, _types[_typeIndex].type) + : [] // Activity log column widths — shared by the header and every row so the fields line up. readonly property real _colTimeWidth: ScreenTools.defaultFontPixelWidth * 9 @@ -122,7 +125,7 @@ SetupPage { // One ack arrived: resolve the matching log row (only one is pending at a time), then send the next. function _onAck(ackResult) { - FailureInjection.resolveResult(ackResult) // no-op when the head was a reset (no pending row) + FailureInjection.resolveResult(ackResult) // resolves the oldest pending row (both injections and resets log one) if (_sendQueue.length > 0) { var q = _sendQueue.slice() q.shift() diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js b/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js index 50e1b7872f0f..d270ff57ba19 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js +++ b/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js @@ -45,3 +45,21 @@ function typeName(types, typeEnum) { } return "" + typeEnum } + +// Catalog index of a FAILURE_UNIT value in a [{ name, unit }] list, or 0 if absent. Used to pick +// defaults by (stable) enum value rather than by list position, which shifts as the MAVLink dialect +// gains/reorders entries. +function indexOfUnit(units, unitEnum) { + for (var i = 0; i < units.length; ++i) { + if (units[i].unit === unitEnum) { return i } + } + return 0 +} + +// Catalog index of a FAILURE_TYPE value in a [{ name, type }] list, or 0 if absent. +function indexOfType(types, typeEnum) { + for (var i = 0; i < types.length; ++i) { + if (types[i].type === typeEnum) { return i } + } + return 0 +} From 10aaec01775353c35709e79ac3be770565b08229 Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Thu, 23 Jul 2026 14:56:14 -0700 Subject: [PATCH 7/9] refactor(FailureInjection): improve code readability and consistency across files --- src/AutoPilotPlugins/PX4/FailureInjection.cc | 105 +++++++++++------- src/AutoPilotPlugins/PX4/FailureInjection.h | 19 ++-- .../PX4/FailureInjectionComponent.cc | 16 +-- .../PX4/FailureInjectionComponent.h | 28 +++-- .../PX4/FailureInjectionTest.cc | 48 +++++--- test/QmlUITests/FailureInjectionUITest.cc | 91 +++++++-------- 6 files changed, 181 insertions(+), 126 deletions(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc index c2afe13219e6..cae4061211ef 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -2,30 +2,30 @@ * FailureInjection.cc ****************************************************************************/ #include "FailureInjection.h" -#include "MAVLinkEnumsQml.h" // MAVLinkEnums::FAILURE_UNIT / FAILURE_TYPE, Q_ENUM_NS-reflected from the MAVLink dialect -#include "QGCMAVLink.h" // QGCMAVLink::mavResultToString() fallback for resolveResult() - -#include "MAVLinkLib.h" // MAV_RESULT_* for resolveResult() #include #include +#include "MAVLinkEnumsQml.h" // MAVLinkEnums::FAILURE_UNIT / FAILURE_TYPE, Q_ENUM_NS-reflected from the MAVLink dialect +#include "MAVLinkLib.h" // MAV_RESULT_* for resolveResult() +#include "QGCMAVLink.h" // QGCMAVLink::mavResultToString() fallback for resolveResult() + namespace { -QVariantMap _makeUnit(const QString &name, int unit) +QVariantMap _makeUnit(const QString& name, int unit) { - return QVariantMap{ {"name", name}, {"unit", unit} }; + return QVariantMap{{"name", name}, {"unit", unit}}; } -QVariantMap _makeType(const QString &name, int type) +QVariantMap _makeType(const QString& name, int type) { - return QVariantMap{ {"name", name}, {"type", type} }; + return QVariantMap{{"name", name}, {"type", type}}; } /// Strip the longest matching MAVLink enum prefix, e.g. FAILURE_UNIT_SENSOR_GYRO -> GYRO. -QString _stripPrefix(const QString &key, const QStringList &prefixesLongestFirst) +QString _stripPrefix(const QString& key, const QStringList& prefixesLongestFirst) { - for (const QString &prefix : prefixesLongestFirst) { + for (const QString& prefix : prefixesLongestFirst) { if (key.startsWith(prefix)) { return key.mid(prefix.length()); } @@ -35,40 +35,46 @@ QString _stripPrefix(const QString &key, const QStringList &prefixesLongestFirst /// Builds a {name, value} catalog from the Q_ENUM_NS-exposed enum on MAVLinkEnums::staticMetaObject, /// looked up by name. -QVariantList _buildCatalog(const char *enumName, const QStringList &prefixesLongestFirst, QVariantMap (*makeEntry)(const QString &, int)) +QVariantList _buildCatalog(const char* enumName, const QStringList& prefixesLongestFirst, + QVariantMap (*makeEntry)(const QString&, int)) { QVariantList list; - const QMetaObject &enumsMetaObject = MAVLinkEnums::staticMetaObject; + const QMetaObject& enumsMetaObject = MAVLinkEnums::staticMetaObject; const QMetaEnum me = enumsMetaObject.enumerator(enumsMetaObject.indexOfEnumerator(enumName)); for (int i = 0; i < me.keyCount(); ++i) { const QString key = QString::fromLatin1(me.key(i)); if (key.endsWith(QStringLiteral("_ENUM_END"))) { - continue; // dialect sentinel, not a real value + continue; // dialect sentinel, not a real value } list.append(makeEntry(_stripPrefix(key, prefixesLongestFirst), me.value(i))); } return list; } -} // namespace +} // namespace -FailureInjection::FailureInjection(QObject *parent) - : QObject(parent) +FailureInjection::FailureInjection(QObject* parent) : QObject(parent) { _units = _buildCatalog("FAILURE_UNIT", - { QStringLiteral("FAILURE_UNIT_SENSOR_"), QStringLiteral("FAILURE_UNIT_SYSTEM_"), QStringLiteral("FAILURE_UNIT_") }, _makeUnit); - _types = _buildCatalog("FAILURE_TYPE", { QStringLiteral("FAILURE_TYPE_") }, _makeType); + {QStringLiteral("FAILURE_UNIT_SENSOR_"), QStringLiteral("FAILURE_UNIT_SYSTEM_"), + QStringLiteral("FAILURE_UNIT_")}, + _makeUnit); + _types = _buildCatalog("FAILURE_TYPE", {QStringLiteral("FAILURE_TYPE_")}, _makeType); } -void FailureInjection::logRow(const QString &unitName, const QString &typeName, const QString &instanceLabel, const QString &time) +void FailureInjection::logRow(const QString& unitName, const QString& typeName, const QString& instanceLabel, + const QString& time) { - _activity.prepend(QVariantMap{ - {"time", time}, {"unitName", unitName}, {"typeName", typeName}, {"instance", instanceLabel}, {"result", QStringLiteral("pending")} - }); + _activity.prepend(QVariantMap{{"time", time}, + {"unitName", unitName}, + {"typeName", typeName}, + {"instance", instanceLabel}, + {"result", QStringLiteral("pending")}}); emit activityChanged(); } -void FailureInjection::logInjection(const QString &unitName, const QString &typeName, int unitEnum, const QString &instanceLabel, const QString &time) +void FailureInjection::logInjection(const QString& unitName, const QString& typeName, int unitEnum, + const QString& instanceLabel, const QString& time) { logRow(unitName, typeName, instanceLabel, time); if (!_injectedUnits.contains(unitEnum)) { @@ -78,7 +84,8 @@ void FailureInjection::logInjection(const QString &unitName, const QString &type void FailureInjection::resolveResult(int ackResult) { - // ACKs for MAV_CMD_INJECT_FAILURE arrive in send order; resolve the oldest pending row (highest index, since newest is prepended). + // ACKs for MAV_CMD_INJECT_FAILURE arrive in send order; resolve the oldest pending row (highest index, since newest + // is prepended). int pendingIndex = -1; for (int i = _activity.size() - 1; i >= 0; --i) { if (_activity.at(i).toMap().value(QStringLiteral("result")).toString() == QStringLiteral("pending")) { @@ -91,19 +98,33 @@ void FailureInjection::resolveResult(int ackResult) } if (ackResult == MAV_RESULT_IN_PROGRESS) { - return; // not a terminal result; leave pending + return; // not a terminal result; leave pending } QString reason; switch (ackResult) { - // Not tr()'d: FailureInjectionComponent.qml matches this exact literal to style the row green. - case MAV_RESULT_ACCEPTED: reason = QStringLiteral("accepted"); break; - case MAV_RESULT_TEMPORARILY_REJECTED: reason = tr("Temporarily rejected"); break; - case MAV_RESULT_DENIED: reason = tr("Denied"); break; - case MAV_RESULT_UNSUPPORTED: reason = tr("Unsupported"); break; - case MAV_RESULT_FAILED: reason = tr("Failed"); break; - case MAV_RESULT_CANCELLED: reason = tr("Cancelled"); break; - default: reason = QGCMAVLink::mavResultToString(static_cast(ackResult)); break; + // Not tr()'d: FailureInjectionComponent.qml matches this exact literal to style the row green. + case MAV_RESULT_ACCEPTED: + reason = QStringLiteral("accepted"); + break; + case MAV_RESULT_TEMPORARILY_REJECTED: + reason = tr("Temporarily rejected"); + break; + case MAV_RESULT_DENIED: + reason = tr("Denied"); + break; + case MAV_RESULT_UNSUPPORTED: + reason = tr("Unsupported"); + break; + case MAV_RESULT_FAILED: + reason = tr("Failed"); + break; + case MAV_RESULT_CANCELLED: + reason = tr("Cancelled"); + break; + default: + reason = QGCMAVLink::mavResultToString(static_cast(ackResult)); + break; } QVariantMap row = _activity.at(pendingIndex).toMap(); @@ -131,20 +152,22 @@ QVariantList FailureInjection::detailParams(int unitEnum, int typeEnum) const // Vehicle parameters that refine how a failure manifests, keyed by (unit, type). The page shows // an editor per entry, but only when the connected vehicle actually exposes the parameter. // Adding a new combo is one more table line. - struct DetailParam { - int unitEnum; - int typeEnum; - const char *param; - const char *label; + struct DetailParam + { + int unitEnum; + int typeEnum; + const char* param; + const char* label; }; + static const DetailParam table[] = { - { FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, "SYS_FAIL_BAT_LVL", QT_TR_NOOP("Battery level") }, + {FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, "SYS_FAIL_BAT_LVL", QT_TR_NOOP("Battery level")}, }; QVariantList list; - for (const DetailParam &entry : table) { + for (const DetailParam& entry : table) { if (entry.unitEnum == unitEnum && entry.typeEnum == typeEnum) { - list.append(QVariantMap{ {"param", QString::fromLatin1(entry.param)}, {"label", tr(entry.label)} }); + list.append(QVariantMap{{"param", QString::fromLatin1(entry.param)}, {"label", tr(entry.label)}}); } } return list; diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.h b/src/AutoPilotPlugins/PX4/FailureInjection.h index 41117e84c242..dfb82b327023 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.h +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -22,22 +22,27 @@ class FailureInjection : public QObject Q_OBJECT QML_NAMED_ELEMENT(FailureInjection) QML_SINGLETON - Q_PROPERTY(QVariantList units READ units CONSTANT) ///< [{ name, unit }] - Q_PROPERTY(QVariantList types READ types CONSTANT) ///< [{ name, type }] - Q_PROPERTY(QVariantList activity READ activity NOTIFY activityChanged) ///< newest first: [{ time, unitName, typeName, instance, result }] + Q_PROPERTY(QVariantList units READ units CONSTANT) ///< [{ name, unit }] + Q_PROPERTY(QVariantList types READ types CONSTANT) ///< [{ name, type }] + Q_PROPERTY(QVariantList activity READ activity NOTIFY + activityChanged) ///< newest first: [{ time, unitName, typeName, instance, result }] public: - explicit FailureInjection(QObject *parent = nullptr); + explicit FailureInjection(QObject* parent = nullptr); QVariantList units(void) const { return _units; } + QVariantList types(void) const { return _types; } + QVariantList activity(void) const { return _activity; } /// Add a log row (prepended, newest first, result "pending") without tracking the unit for Reset. /// instanceLabel is a display descriptor for the affected instance(s), e.g. "all", "2", "1, 3, 5". - Q_INVOKABLE void logRow(const QString &unitName, const QString &typeName, const QString &instanceLabel, const QString &time); + Q_INVOKABLE void logRow(const QString& unitName, const QString& typeName, const QString& instanceLabel, + const QString& time); /// Record one injected failure: adds a log row and remembers the unit so Reset can restore it. - Q_INVOKABLE void logInjection(const QString &unitName, const QString &typeName, int unitEnum, const QString &instanceLabel, const QString &time); + Q_INVOKABLE void logInjection(const QString& unitName, const QString& typeName, int unitEnum, + const QString& instanceLabel, const QString& time); /// Resolve the oldest still-pending injection with a MAV_RESULT ack code; sets the row result. Q_INVOKABLE void resolveResult(int ackResult); /// Distinct FAILURE_UNIT values injected this session, so Reset restores only those. @@ -55,5 +60,5 @@ class FailureInjection : public QObject QVariantList _units; QVariantList _types; QVariantList _activity; - QList _injectedUnits; + QList _injectedUnits; }; diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc index c03e0ab0e836..bc7b456ad3a0 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc @@ -2,14 +2,14 @@ * FailureInjectionComponent.cc ****************************************************************************/ #include "FailureInjectionComponent.h" + #include "AutoPilotPlugin.h" #include "Vehicle.h" FailureInjectionComponent::FailureInjectionComponent(Vehicle* vehicle, AutoPilotPlugin* autopilot, QObject* parent) - : VehicleComponent(vehicle, autopilot, AutoPilotPlugin::UnknownVehicleComponent, parent) - , _name(tr("Failure Injection")) -{ -} + : VehicleComponent(vehicle, autopilot, AutoPilotPlugin::UnknownVehicleComponent, parent), + _name(tr("Failure Injection")) +{} QString FailureInjectionComponent::name(void) const { @@ -18,8 +18,9 @@ QString FailureInjectionComponent::name(void) const QString FailureInjectionComponent::description(void) const { - return tr("Failure Injection is used to simulate sensor and system failures (MAV_CMD_INJECT_FAILURE) " - "to validate failsafes. Requires SYS_FAILURE_EN = 1 and a vehicle reboot."); + return tr( + "Failure Injection is used to simulate sensor and system failures (MAV_CMD_INJECT_FAILURE) " + "to validate failsafes. Requires SYS_FAILURE_EN = 1 and a vehicle reboot."); } QString FailureInjectionComponent::iconResource(void) const @@ -29,5 +30,6 @@ QString FailureInjectionComponent::iconResource(void) const QUrl FailureInjectionComponent::setupSource(void) const { - return QUrl::fromUserInput(QStringLiteral("qrc:/qml/QGroundControl/AutoPilotPlugins/PX4/FailureInjectionComponent.qml")); + return QUrl::fromUserInput( + QStringLiteral("qrc:/qml/QGroundControl/AutoPilotPlugins/PX4/FailureInjectionComponent.qml")); } diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h index 00dd35a9897b..f9d1dafab7e7 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h @@ -12,17 +12,25 @@ class FailureInjectionComponent : public VehicleComponent public: FailureInjectionComponent(Vehicle* vehicle, AutoPilotPlugin* autopilot, QObject* parent = nullptr); - QString name (void) const override; - QString description (void) const override; - QString iconResource (void) const override; - bool requiresSetup (void) const override { return false; } - bool setupComplete (void) const override { return true; } + QString name(void) const override; + QString description(void) const override; + QString iconResource(void) const override; + + bool requiresSetup(void) const override { return false; } + + bool setupComplete(void) const override { return true; } + QStringList setupCompleteChangedTriggerList(void) const override { return QStringList(); } - QUrl setupSource (void) const override; - QUrl summaryQmlSource (void) const override { return QUrl(); } - // Failure injection is meant to be exercised in flight (validate failsafes/EKF), so keep the page usable while armed. - bool allowSetupWhileArmed (void) const override { return true; } - bool allowSetupWhileFlying (void) const override { return true; } + + QUrl setupSource(void) const override; + + QUrl summaryQmlSource(void) const override { return QUrl(); } + + // Failure injection is meant to be exercised in flight (validate failsafes/EKF), so keep the page usable while + // armed. + bool allowSetupWhileArmed(void) const override { return true; } + + bool allowSetupWhileFlying(void) const override { return true; } private: const QString _name; diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc index d945b7ced311..c3500a355c18 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -17,7 +17,7 @@ void FailureInjectionTest::_catalogPopulatedFromMavlinkEnums() QVERIFY2(!types.isEmpty(), "FAILURE_TYPE catalog failed to build from the MAVLink dialect"); bool foundGps = false; - for (const QVariant &entry : units) { + for (const QVariant& entry : units) { const QVariantMap map = entry.toMap(); if (map.value(QStringLiteral("unit")).toInt() == static_cast(FAILURE_UNIT_SENSOR_GPS)) { QCOMPARE(map.value(QStringLiteral("name")).toString(), QStringLiteral("GPS")); @@ -27,7 +27,7 @@ void FailureInjectionTest::_catalogPopulatedFromMavlinkEnums() QVERIFY2(foundGps, "FAILURE_UNIT_SENSOR_GPS missing from the units catalog, or its prefix was not stripped"); bool foundOk = false; - for (const QVariant &entry : types) { + for (const QVariant& entry : types) { const QVariantMap map = entry.toMap(); if (map.value(QStringLiteral("type")).toInt() == static_cast(FAILURE_TYPE_OK)) { QCOMPARE(map.value(QStringLiteral("name")).toString(), QStringLiteral("OK")); @@ -42,11 +42,13 @@ void FailureInjectionTest::_logRowAddsPendingEntryWithoutTracking() FailureInjection failureInjection; QSignalSpy activityChangedSpy(&failureInjection, &FailureInjection::activityChanged); - failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:00")); QCOMPARE(activityChangedSpy.count(), 1); QCOMPARE(failureInjection.activity().count(), 1); - QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); + QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), + QStringLiteral("pending")); QVERIFY2(failureInjection.injectedUnits().isEmpty(), "logRow() must not track the unit for Reset"); } @@ -54,12 +56,14 @@ void FailureInjectionTest::_logInjectionTracksUnitOnce() { FailureInjection failureInjection; - failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, + QStringLiteral("1"), QStringLiteral("12:00:00")); QCOMPARE(failureInjection.injectedUnits().count(), 1); QCOMPARE(failureInjection.injectedUnits().first().toInt(), static_cast(FAILURE_UNIT_SENSOR_GPS)); // Injecting the same unit again must not duplicate the tracked entry. - failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Stuck"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("2"), QStringLiteral("12:00:01")); + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Stuck"), FAILURE_UNIT_SENSOR_GPS, + QStringLiteral("2"), QStringLiteral("12:00:01")); QCOMPARE(failureInjection.injectedUnits().count(), 1); QCOMPARE(failureInjection.activity().count(), 2); } @@ -69,30 +73,37 @@ void FailureInjectionTest::_resolveResultResolvesOldestPendingRow() FailureInjection failureInjection; // Newest is prepended: index 0 holds the second row logged, index 1 the first. - failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); - failureInjection.logRow(QStringLiteral("GYRO"), QStringLiteral("Stuck"), QStringLiteral("2"), QStringLiteral("12:00:01")); + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GYRO"), QStringLiteral("Stuck"), QStringLiteral("2"), + QStringLiteral("12:00:01")); failureInjection.resolveResult(MAV_RESULT_ACCEPTED); const QVariantList activity = failureInjection.activity(); - QCOMPARE(activity.at(1).toMap().value(QStringLiteral("result")).toString(), QStringLiteral("accepted")); // oldest (GPS) resolved first - QCOMPARE(activity.at(0).toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); // newest (GYRO) still pending + QCOMPARE(activity.at(1).toMap().value(QStringLiteral("result")).toString(), + QStringLiteral("accepted")); // oldest (GPS) resolved first + QCOMPARE(activity.at(0).toMap().value(QStringLiteral("result")).toString(), + QStringLiteral("pending")); // newest (GYRO) still pending } void FailureInjectionTest::_resolveResultIgnoresInProgress() { FailureInjection failureInjection; - failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:00")); failureInjection.resolveResult(MAV_RESULT_IN_PROGRESS); - QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), QStringLiteral("pending")); + QCOMPARE(failureInjection.activity().first().toMap().value(QStringLiteral("result")).toString(), + QStringLiteral("pending")); } void FailureInjectionTest::_resolveResultUnknownCodeFallsBackToMavResultString() { FailureInjection failureInjection; - failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:00")); // Not a real MAV_RESULT value on the wire today, but resolveResult() must still produce // *some* readable text for a future/unrecognized code instead of silently leaving it pending. @@ -105,13 +116,14 @@ void FailureInjectionTest::_resolveResultUnknownCodeFallsBackToMavResultString() void FailureInjectionTest::_clearInjectedUnitsForgetsTrackedUnits() { FailureInjection failureInjection; - failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, + QStringLiteral("1"), QStringLiteral("12:00:00")); QVERIFY(!failureInjection.injectedUnits().isEmpty()); failureInjection.clearInjectedUnits(); QVERIFY(failureInjection.injectedUnits().isEmpty()); - QCOMPARE(failureInjection.activity().count(), 1); // the activity log itself is left intact + QCOMPARE(failureInjection.activity().count(), 1); // the activity log itself is left intact } void FailureInjectionTest::_detailParamsMapCombos() @@ -121,8 +133,10 @@ void FailureInjectionTest::_detailParamsMapCombos() // BATTERY + WRONG exposes the SYS_FAIL_BAT_LVL detail parameter. const QVariantList batteryWrong = failureInjection.detailParams(FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG); QCOMPARE(batteryWrong.count(), 1); - QCOMPARE(batteryWrong.first().toMap().value(QStringLiteral("param")).toString(), QStringLiteral("SYS_FAIL_BAT_LVL")); - QVERIFY2(!batteryWrong.first().toMap().value(QStringLiteral("label")).toString().isEmpty(), "detail param must have a display label"); + QCOMPARE(batteryWrong.first().toMap().value(QStringLiteral("param")).toString(), + QStringLiteral("SYS_FAIL_BAT_LVL")); + QVERIFY2(!batteryWrong.first().toMap().value(QStringLiteral("label")).toString().isEmpty(), + "detail param must have a display label"); // Combos without detail parameters return an empty list. QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_OFF).isEmpty()); diff --git a/test/QmlUITests/FailureInjectionUITest.cc b/test/QmlUITests/FailureInjectionUITest.cc index 24dbfafbae62..d8a17e02d637 100644 --- a/test/QmlUITests/FailureInjectionUITest.cc +++ b/test/QmlUITests/FailureInjectionUITest.cc @@ -11,48 +11,51 @@ void FailureInjectionUITest::_testInjectAndReset() { runWithMockLink( [] { return MockLink::startPX4MockLink(); }, - [&](QPointer /*mockLink*/, Vehicle * /*vehicle*/) { - - navigateToConfigureView(); - if (QTest::currentTestFailed()) return; - - clickSidebarButton(QStringLiteral("vehicleConfig_comp_FailureInjection")); - if (QTest::currentTestFailed()) return; - - // PX4MockLink.params ships SYS_FAILURE_EN=1, so the page starts already armed. - QVERIFY2(verifyChecked(QStringLiteral("failureInjection_enableCheckbox"), true, "on page open"), - "SYS_FAILURE_EN checkbox not checked on page open"); - QQuickItem *armedLabel = findVisibleItem(_rootItem, QStringLiteral("failureInjection_armedLabel"), 2000); - QVERIFY2(armedLabel, "Armed label not visible on page open"); - - // Default selection (unit index 4 = GPS, type index 1 = OFF, instance 1) is injected as-is. - QVERIFY2(clickButton(QStringLiteral("failureInjection_injectButton")), "Failed to click Inject failure"); - - QQuickItem *activityList = findVisibleItem(_rootItem, QStringLiteral("failureInjection_activityList"), 2000); - QVERIFY2(activityList, "Activity list not visible after injecting"); - QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 1; }, 3000), - "Activity row not added after injecting"); - - QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after inject"), - "Injected row does not show unit GPS"); - QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OFF"), "after inject"), - "Injected row does not show type OFF"); - - // MockLink doesn't handle MAV_CMD_INJECT_FAILURE, so it acks MAV_RESULT_UNSUPPORTED. - QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), "after inject ack"), - "Injected row result never resolved to Unsupported"); - - // Reset all reverts the unit tracked by the injection above: a second row appears (GPS / OK). - QVERIFY2(clickButton(QStringLiteral("failureInjection_resetAllButton")), "Failed to click Reset all"); - - QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 2; }, 3000), - "Activity row not added after reset"); - QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after reset"), - "Reset row does not show unit GPS"); - QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OK"), "after reset"), - "Reset row does not show type OK"); - QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), "after reset ack"), - "Reset row result never resolved to Unsupported"); - - }); + [&](QPointer /*mockLink*/, Vehicle* /*vehicle*/) { + navigateToConfigureView(); + if (QTest::currentTestFailed()) + return; + + clickSidebarButton(QStringLiteral("vehicleConfig_comp_FailureInjection")); + if (QTest::currentTestFailed()) + return; + + // PX4MockLink.params ships SYS_FAILURE_EN=1, so the page starts already armed. + QVERIFY2(verifyChecked(QStringLiteral("failureInjection_enableCheckbox"), true, "on page open"), + "SYS_FAILURE_EN checkbox not checked on page open"); + QQuickItem* armedLabel = findVisibleItem(_rootItem, QStringLiteral("failureInjection_armedLabel"), 2000); + QVERIFY2(armedLabel, "Armed label not visible on page open"); + + // Default selection (unit index 4 = GPS, type index 1 = OFF, instance 1) is injected as-is. + QVERIFY2(clickButton(QStringLiteral("failureInjection_injectButton")), "Failed to click Inject failure"); + + QQuickItem* activityList = + findVisibleItem(_rootItem, QStringLiteral("failureInjection_activityList"), 2000); + QVERIFY2(activityList, "Activity list not visible after injecting"); + QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 1; }, 3000), + "Activity row not added after injecting"); + + QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after inject"), + "Injected row does not show unit GPS"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OFF"), "after inject"), + "Injected row does not show type OFF"); + + // MockLink doesn't handle MAV_CMD_INJECT_FAILURE, so it acks MAV_RESULT_UNSUPPORTED. + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), + "after inject ack"), + "Injected row result never resolved to Unsupported"); + + // Reset all reverts the unit tracked by the injection above: a second row appears (GPS / OK). + QVERIFY2(clickButton(QStringLiteral("failureInjection_resetAllButton")), "Failed to click Reset all"); + + QVERIFY2(QTest::qWaitFor([&] { return activityList->property("count").toInt() == 2; }, 3000), + "Activity row not added after reset"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_unitName_0"), QStringLiteral("GPS"), "after reset"), + "Reset row does not show unit GPS"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_typeName_0"), QStringLiteral("OK"), "after reset"), + "Reset row does not show type OK"); + QVERIFY2(verifyText(QStringLiteral("failureInjection_result_0"), QStringLiteral("× Unsupported"), + "after reset ack"), + "Reset row result never resolved to Unsupported"); + }); } From b1ef56258e3bcb2ed0c0cc508649ea091ca3fccb Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Fri, 24 Jul 2026 14:00:00 -0700 Subject: [PATCH 8/9] feat(FailureInjection): incoperate copilot feedback --- src/AutoPilotPlugins/PX4/FailureInjection.cc | 30 +++++++++- src/AutoPilotPlugins/PX4/FailureInjection.h | 12 +++- .../PX4/FailureInjectionComponent.cc | 1 - .../PX4/FailureInjectionComponent.qml | 30 ++++++++-- .../PX4/FailureInjectionTest.cc | 59 +++++++++++++++++-- .../PX4/FailureInjectionTest.h | 4 +- tools/generators/mavlink_enums.py | 5 ++ 7 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc index cae4061211ef..61fa83c8e600 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -142,9 +142,37 @@ QVariantList FailureInjection::injectedUnits(void) const return list; } -void FailureInjection::clearInjectedUnits(void) +void FailureInjection::markUnitReset(int unitEnum) { + _injectedUnits.removeAll(unitEnum); +} + +void FailureInjection::resolvePendingInterrupted(void) +{ + bool changed = false; + for (int i = 0; i < _activity.size(); ++i) { + QVariantMap row = _activity.at(i).toMap(); + if (row.value(QStringLiteral("result")).toString() == QStringLiteral("pending")) { + row[QStringLiteral("result")] = tr("Interrupted"); + _activity[i] = row; + changed = true; + } + } + if (changed) { + emit activityChanged(); + } +} + +void FailureInjection::notifyActiveVehicle(int vehicleId) +{ + if ((vehicleId < 0) || (vehicleId == _currentVehicleId)) { + // No vehicle / transient disconnect, or the same vehicle (e.g. after a reboot) — keep the session. + return; + } + _currentVehicleId = vehicleId; _injectedUnits.clear(); + _activity.clear(); + emit activityChanged(); } QVariantList FailureInjection::detailParams(int unitEnum, int typeEnum) const diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.h b/src/AutoPilotPlugins/PX4/FailureInjection.h index dfb82b327023..e47a7a4de9a9 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.h +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -10,6 +10,7 @@ * 2. Session state: the activity log and the set of units injected this * session, persisted here so they survive navigating away from and back * to the page, which destroys/recreates the page via its SetupPage Loader. + * notifyActiveVehicle() clears the session when the active vehicle changes. ****************************************************************************/ #pragma once @@ -47,8 +48,14 @@ class FailureInjection : public QObject Q_INVOKABLE void resolveResult(int ackResult); /// Distinct FAILURE_UNIT values injected this session, so Reset restores only those. Q_INVOKABLE QVariantList injectedUnits(void) const; - /// Forget the injected-units set (after a Reset reverted them); the activity log is left intact. - Q_INVOKABLE void clearInjectedUnits(void); + /// Untrack one FAILURE_UNIT once its reset is accepted, so an interrupted Reset all keeps the rest retryable. + Q_INVOKABLE void markUnitReset(int unitEnum); + /// Resolve any still-"pending" rows to "Interrupted"; called on page (re)load to clear stragglers whose + /// ack was lost when the previous page instance was destroyed mid-send. + Q_INVOKABLE void resolvePendingInterrupted(void); + /// Note the active vehicle's system id; a switch to a different vehicle clears the session so Reset all + /// can't target the wrong vehicle. A negative id (transient disconnect/reboot) is ignored. + Q_INVOKABLE void notifyActiveVehicle(int vehicleId); /// Vehicle parameters that refine how a (unit, type) failure manifests, e.g. BATTERY+WRONG -> /// SYS_FAIL_BAT_LVL. Returns [{ param, label }]; empty when the combo has no detail parameters. Q_INVOKABLE QVariantList detailParams(int unitEnum, int typeEnum) const; @@ -61,4 +68,5 @@ class FailureInjection : public QObject QVariantList _types; QVariantList _activity; QList _injectedUnits; + int _currentVehicleId = -1; ///< MAVLink system id the session belongs to; -1 until the first vehicle is known }; diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc index bc7b456ad3a0..a61bd18a22de 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc @@ -4,7 +4,6 @@ #include "FailureInjectionComponent.h" #include "AutoPilotPlugin.h" -#include "Vehicle.h" FailureInjectionComponent::FailureInjectionComponent(Vehicle* vehicle, AutoPilotPlugin* autopilot, QObject* parent) : VehicleComponent(vehicle, autopilot, AutoPilotPlugin::UnknownVehicleComponent, parent), diff --git a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml index 61acddc52cb5..c51e9c9c02e1 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -29,6 +29,7 @@ SetupPage { property bool _armed: _paramSet && !_pendingReboot readonly property int _cmdInjectFailure: 420 // MAV_CMD_INJECT_FAILURE + readonly property int _mavResultAccepted: 0 // MAV_RESULT_ACCEPTED // FAILURE_UNIT / FAILURE_TYPE sourced from the MAVLink dialect via the C++ singleton // (enum values + names track common.xml). @@ -72,6 +73,16 @@ SetupPage { } } + // Active-vehicle switch: clear the singleton's session and drop queued sends aimed at the old vehicle. + // Fires only while the page is loaded; switches made while unloaded are caught by Component.onCompleted. + Connections { + target: QGroundControl.multiVehicleManager + function onActiveVehicleChanged(vehicle) { + FailureInjection.notifyActiveVehicle(vehicle ? vehicle.id : -1) + _sendQueue = [] + } + } + // ---- behaviour ---------------------------------------------------------- function _injectOne(unitEnum, typeEnum, param3, param4) { @@ -125,7 +136,12 @@ SetupPage { // One ack arrived: resolve the matching log row (only one is pending at a time), then send the next. function _onAck(ackResult) { + var acked = _sendQueue.length > 0 ? _sendQueue[0] : null // head is the send being acked FailureInjection.resolveResult(ackResult) // resolves the oldest pending row (both injections and resets log one) + // An accepted reset (track:false) untracks its unit — on ack, not up front, so an interrupted Reset all keeps the rest retryable. + if (acked && acked.logArgs && !acked.logArgs.track && ackResult === _mavResultAccepted) { + FailureInjection.markUnitReset(acked.unit) + } if (_sendQueue.length > 0) { var q = _sendQueue.slice() q.shift() @@ -164,11 +180,10 @@ SetupPage { return Instances.typeName(_types, typeEnum) } - // Send FAILURE_TYPE_OK (all instances) to every unit injected this session and log each as a row. - // The activity list is kept; the injected-units set is cleared since those failures are now reverted. + // Send FAILURE_TYPE_OK (all instances) to every injected unit; each is untracked on its accepted ack + // (see _onAck), so an interrupted Reset all leaves the rest retryable. Activity list is kept. function _resetAll() { var injected = FailureInjection.injectedUnits() // Q_INVOKABLE method — needs the call parentheses - FailureInjection.clearInjectedUnits() for (var i = 0; i < injected.length; ++i) { _enqueueSend({ unit: injected[i], type: 0 /* FAILURE_TYPE_OK */, param3: 0 /* all instances */, param4: 0, logArgs: { unitName: _unitName(injected[i]), typeName: _typeName(0), instanceLabel: "all", track: false } }) @@ -184,10 +199,17 @@ SetupPage { width: availableWidth spacing: ScreenTools.defaultFontPixelHeight + // On (re)load: clear the session if the vehicle changed while the page was unloaded, then + // resolve any row left "pending" when a prior page instance was destroyed mid-send. + Component.onCompleted: { + FailureInjection.notifyActiveVehicle(_activeVehicle ? _activeVehicle.id : -1) + FailureInjection.resolvePendingInterrupted() + } + QGCLabel { Layout.fillWidth: true wrapMode: Text.WordWrap - text: qsTr("Injects simulated failures into flight components (sensors, GPS, motors, and more) to test failsafe behavior, " + + text: qsTr("Injects simulated failures into flight components (sensors, GPS, motors, and more) to test failsafe behavior. " + "Use with care — an injected failure affects the vehicle immediately.") } diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc index c3500a355c18..b0b2f2a2859c 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -113,17 +113,68 @@ void FailureInjectionTest::_resolveResultUnknownCodeFallsBackToMavResultString() QStringLiteral("MAV_RESULT unknown 99")); } -void FailureInjectionTest::_clearInjectedUnitsForgetsTrackedUnits() +void FailureInjectionTest::_markUnitResetRemovesTrackedUnit() { FailureInjection failureInjection; failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, QStringLiteral("1"), QStringLiteral("12:00:00")); - QVERIFY(!failureInjection.injectedUnits().isEmpty()); + failureInjection.logInjection(QStringLiteral("GYRO"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GYRO, + QStringLiteral("1"), QStringLiteral("12:00:01")); + QCOMPARE(failureInjection.injectedUnits().count(), 2); - failureInjection.clearInjectedUnits(); + failureInjection.markUnitReset(FAILURE_UNIT_SENSOR_GPS); + // Only the reset unit is forgotten; the other stays tracked so an interrupted Reset all can retry it. + const QVariantList remaining = failureInjection.injectedUnits(); + QCOMPARE(remaining.count(), 1); + QCOMPARE(remaining.first().toInt(), static_cast(FAILURE_UNIT_SENSOR_GYRO)); + QCOMPARE(failureInjection.activity().count(), 2); // activity log left intact +} + +void FailureInjectionTest::_resolvePendingInterruptedResolvesStragglers() +{ + FailureInjection failureInjection; + failureInjection.logRow(QStringLiteral("GPS"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:00")); + failureInjection.logRow(QStringLiteral("GYRO"), QStringLiteral("Off"), QStringLiteral("1"), + QStringLiteral("12:00:01")); + // Resolve the oldest normally; the newest stays pending, as if its ack was lost on navigation. + failureInjection.resolveResult(MAV_RESULT_ACCEPTED); + + QSignalSpy activityChangedSpy(&failureInjection, &FailureInjection::activityChanged); + failureInjection.resolvePendingInterrupted(); + + QCOMPARE(activityChangedSpy.count(), 1); + QCOMPARE(failureInjection.activity().count(), 2); // rows are resolved in place, not dropped + for (const QVariant& entry : failureInjection.activity()) { + QVERIFY2(entry.toMap().value(QStringLiteral("result")).toString() != QStringLiteral("pending"), + "no row should remain pending after resolvePendingInterrupted()"); + } +} + +void FailureInjectionTest::_activeVehicleSwitchClearsSession() +{ + FailureInjection failureInjection; + + // Establish vehicle 1 and inject a failure into it. + failureInjection.notifyActiveVehicle(1); + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, + QStringLiteral("1"), QStringLiteral("12:00:00")); + QCOMPARE(failureInjection.injectedUnits().count(), 1); + QCOMPARE(failureInjection.activity().count(), 1); + + // A transient disconnect (id < 0) and the same vehicle reconnecting (e.g. after a reboot) keep the session. + failureInjection.notifyActiveVehicle(-1); + failureInjection.notifyActiveVehicle(1); + QCOMPARE(failureInjection.injectedUnits().count(), 1); + QCOMPARE(failureInjection.activity().count(), 1); + + // A genuine switch to a different vehicle clears the session so Reset all can't target the wrong vehicle. + QSignalSpy activityChangedSpy(&failureInjection, &FailureInjection::activityChanged); + failureInjection.notifyActiveVehicle(2); + QCOMPARE(activityChangedSpy.count(), 1); QVERIFY(failureInjection.injectedUnits().isEmpty()); - QCOMPARE(failureInjection.activity().count(), 1); // the activity log itself is left intact + QVERIFY(failureInjection.activity().isEmpty()); } void FailureInjectionTest::_detailParamsMapCombos() diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h index ff3394a83fe0..ac7e1b4acb4c 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h @@ -15,6 +15,8 @@ private slots: void _resolveResultResolvesOldestPendingRow(); void _resolveResultIgnoresInProgress(); void _resolveResultUnknownCodeFallsBackToMavResultString(); - void _clearInjectedUnitsForgetsTrackedUnits(); + void _markUnitResetRemovesTrackedUnit(); + void _resolvePendingInterruptedResolvesStragglers(); + void _activeVehicleSwitchClearsSession(); void _detailParamsMapCombos(); }; diff --git a/tools/generators/mavlink_enums.py b/tools/generators/mavlink_enums.py index a27586fb5a58..3421046a043d 100644 --- a/tools/generators/mavlink_enums.py +++ b/tools/generators/mavlink_enums.py @@ -151,6 +151,11 @@ def build_qml_header(enum_names, enums_text): for n in enum_names: if n in _QML_REFLECTED_ENUMS: entries = _raw_enum_entries(enums_text, n) + if not entries: + sys.exit( + f"mavlink_enums.py: QML-reflected enum '{n}' has no parseable entries; " + "cannot generate MAVLinkEnumsQml.h (check the dialect headers / _QML_REFLECTED_ENUMS)" + ) body = ",\n".join(f" {name} = {value}" for name, value in entries) decl_lines.append(f" enum {n} {{\n{body}\n }};") else: From 5d0273c33ff0f95f421ff1d539c6aee411852d2b Mon Sep 17 00:00:00 2001 From: Claudio Chies Date: Tue, 4 Aug 2026 13:25:28 +0200 Subject: [PATCH 9/9] feat(FailureInjection): add GPS wrong detail parameter and update related tests --- src/AutoPilotPlugins/PX4/FailureInjection.cc | 1 + test/AutoPilotPlugins/PX4/FailureInjectionTest.cc | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/AutoPilotPlugins/PX4/FailureInjection.cc b/src/AutoPilotPlugins/PX4/FailureInjection.cc index 61fa83c8e600..d7756cf24cfb 100644 --- a/src/AutoPilotPlugins/PX4/FailureInjection.cc +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -189,6 +189,7 @@ QVariantList FailureInjection::detailParams(int unitEnum, int typeEnum) const }; static const DetailParam table[] = { + {FAILURE_UNIT_SENSOR_GPS, FAILURE_TYPE_WRONG, "SYS_FAIL_GPS_WRG", QT_TR_NOOP("GPS fix type")}, {FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_WRONG, "SYS_FAIL_BAT_LVL", QT_TR_NOOP("Battery level")}, }; diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc index b0b2f2a2859c..276c10f3b64e 100644 --- a/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -189,7 +189,15 @@ void FailureInjectionTest::_detailParamsMapCombos() QVERIFY2(!batteryWrong.first().toMap().value(QStringLiteral("label")).toString().isEmpty(), "detail param must have a display label"); - // Combos without detail parameters return an empty list. + // GPS + WRONG exposes the SYS_FAIL_GPS_WRG detail parameter. + const QVariantList gpsWrong = failureInjection.detailParams(FAILURE_UNIT_SENSOR_GPS, FAILURE_TYPE_WRONG); + QCOMPARE(gpsWrong.count(), 1); + QCOMPARE(gpsWrong.first().toMap().value(QStringLiteral("param")).toString(), QStringLiteral("SYS_FAIL_GPS_WRG")); + QVERIFY2(!gpsWrong.first().toMap().value(QStringLiteral("label")).toString().isEmpty(), + "detail param must have a display label"); + + // Combos without detail parameters return an empty list: both the unit and the type must match. QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SYSTEM_BATTERY, FAILURE_TYPE_OFF).isEmpty()); - QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SENSOR_GPS, FAILURE_TYPE_WRONG).isEmpty()); + QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SENSOR_GPS, FAILURE_TYPE_OFF).isEmpty()); + QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SENSOR_GYRO, FAILURE_TYPE_WRONG).isEmpty()); }