diff --git a/cmake/CustomOptions.cmake b/cmake/CustomOptions.cmake index ae54f007c4eb..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 "c409cf690454db6d3e004bd14173bc6c7ff1e0ff" 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 a8ff3de75cf1..0e9a72b559e9 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,8 @@ qt_add_qml_module(AutoPilotPluginsPX4Module CalcAmpsPerVoltDialog.qml CalcVoltageDividerDialog.qml ESCCalibrationDialog.qml + FailureInjectionComponent.qml + FailureInjectionInstances.js FlightModesComponentSummary.qml PowerComponentSummary.qml PX4FlightBehaviorCopter.qml @@ -182,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 new file mode 100644 index 000000000000..d7756cf24cfb --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjection.cc @@ -0,0 +1,203 @@ +/**************************************************************************** + * FailureInjection.cc + ****************************************************************************/ +#include "FailureInjection.h" + +#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) +{ + 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::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 +{ + // 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; + }; + + 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")}, + }; + + QVariantList list; + for (const DetailParam& entry : table) { + if (entry.unitEnum == unitEnum && entry.typeEnum == typeEnum) { + 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 new file mode 100644 index 000000000000..e47a7a4de9a9 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjection.h @@ -0,0 +1,72 @@ +/**************************************************************************** + * FailureInjection.h + * + * QML singleton backing the Failure Injection page. It serves two roles: + * + * 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 + * to the page, which destroys/recreates the page via its SetupPage Loader. + * notifyActiveVehicle() clears the session when the active vehicle changes. + ****************************************************************************/ +#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 }] + 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; + /// 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; + +signals: + void activityChanged(void); + +private: + QVariantList _units; + 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 new file mode 100644 index 000000000000..a61bd18a22de --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.cc @@ -0,0 +1,34 @@ +/**************************************************************************** + * FailureInjectionComponent.cc + ****************************************************************************/ +#include "FailureInjectionComponent.h" + +#include "AutoPilotPlugin.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/WarningEmergency.svg"); +} + +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..f9d1dafab7e7 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.h @@ -0,0 +1,37 @@ +/**************************************************************************** + * 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..c51e9c9c02e1 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionComponent.qml @@ -0,0 +1,458 @@ +/**************************************************************************** + * 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 + +import "FailureInjectionInstances.js" as Instances + +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 && !_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). + property var _units: FailureInjection.units // [{ name, unit }] + property var _types: FailureInjection.types // [{ name, type }] + + 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: (_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 + 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) + } + } + } + + // 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) { + 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 + } + + function _instanceSend() { + return Instances.instanceSend(_selectedInstances) + } + + // 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] + 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) + } + } + _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. + 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() + _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) { + _selectedInstances = Instances.toggleInstance(_selectedInstances, n) + } + + 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) { + return Instances.unitName(_units, unitEnum) + } + + function _typeName(typeEnum) { + return Instances.typeName(_types, typeEnum) + } + + // 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 + 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 + + // 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. " + + "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 { + objectName: "failureInjection_enableCheckbox" + text: qsTr("SYS_FAILURE_EN") + enabled: _sysFailureEn !== null + checked: _paramSet + onClicked: _setEnabled(checked) + } + QGCLabel { + objectName: "failureInjection_pendingRebootLabel" + Layout.fillWidth: true + elide: Text.ElideRight + color: qgcPal.colorOrange + visible: _pendingReboot + text: qsTr("Reboot required to apply.") + } + 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: { + 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 } + } + } + // 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" + 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 { objectName: "failureInjection_resetAllButton"; 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 { + 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 { 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..d270ff57ba19 --- /dev/null +++ b/src/AutoPilotPlugins/PX4/FailureInjectionInstances.js @@ -0,0 +1,65 @@ +.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 +} + +// 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 +} 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/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..276c10f3b64e --- /dev/null +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.cc @@ -0,0 +1,203 @@ +#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::_markUnitResetRemovesTrackedUnit() +{ + FailureInjection failureInjection; + failureInjection.logInjection(QStringLiteral("GPS"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GPS, + QStringLiteral("1"), QStringLiteral("12:00:00")); + failureInjection.logInjection(QStringLiteral("GYRO"), QStringLiteral("Off"), FAILURE_UNIT_SENSOR_GYRO, + QStringLiteral("1"), QStringLiteral("12:00:01")); + QCOMPARE(failureInjection.injectedUnits().count(), 2); + + 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()); + QVERIFY(failureInjection.activity().isEmpty()); +} + +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"); + + // 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_OFF).isEmpty()); + QVERIFY(failureInjection.detailParams(FAILURE_UNIT_SENSOR_GYRO, FAILURE_TYPE_WRONG).isEmpty()); +} diff --git a/test/AutoPilotPlugins/PX4/FailureInjectionTest.h b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h new file mode 100644 index 000000000000..ac7e1b4acb4c --- /dev/null +++ b/test/AutoPilotPlugins/PX4/FailureInjectionTest.h @@ -0,0 +1,22 @@ +#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 _markUnitResetRemovesTrackedUnit(); + void _resolvePendingInterruptedResolvesStragglers(); + void _activeVehicleSwitchClearsSession(); + void _detailParamsMapCombos(); +}; 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..d8a17e02d637 --- /dev/null +++ b/test/QmlUITests/FailureInjectionUITest.cc @@ -0,0 +1,61 @@ +#include "FailureInjectionUITest.h" + +#include +#include + +#include "MockLink.h" + +UT_REGISTER_TEST(FailureInjectionUITest, TestLabel::Integration) + +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"); + }); +} 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") + } +} diff --git a/tools/generators/mavlink_enums.py b/tools/generators/mavlink_enums.py index c2193e600683..3421046a043d 100644 --- a/tools/generators/mavlink_enums.py +++ b/tools/generators/mavlink_enums.py @@ -127,8 +127,40 @@ 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) + 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: + 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 +178,7 @@ def build_qml_header(enum_names): Q_NAMESPACE QML_NAMED_ELEMENT(MAVLinkEnums) -{using_lines} +{using_or_decl_lines} {q_enum_lines} }} @@ -194,7 +226,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)