From 1209520f2bd80b24ea0b678932c2061c5e2b3c9d Mon Sep 17 00:00:00 2001 From: raphaelhunziker1202-stack <250872901+raphaelhunziker1202-stack@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:28:58 +0200 Subject: [PATCH 1/5] MAVLink: allow mission upload and clear in flight when the mission is not being executed Aligns the MAVLink mission path with the MSP policy introduced in #10273, where setWaypoint() accepts mission uploads while armed as long as the WP mission is not actively being flown. The MAVLink handlers denied every mission transfer and clear outright while armed. Changes: - New mavlinkMissionEditBlocked() gate used by MISSION_COUNT, MISSION_CLEAR_ALL, MISSION_ITEM and MISSION_ITEM_INT: edits are refused while armed AND (WP mode active OR the mission's own RTH leg is running OR the on-the-fly mission planner is active). The RTH-leg term protects the land/loiter decision at home, which reads the live list; the planner term prevents two writers on the same list. The ARMED term keeps the disarmed path provably unchanged. - When the refused sender owns the receiving transfer, the transfer is aborted via mavlinkAbortMissionUpload(MAV_MISSION_DENIED) so the retry engine stops soliciting items from a partner that was just denied. - Guided fly-to-here / altitude-target items are now dispatched on the item itself (NAV_WAYPOINT with current == 2 or 3) instead of on transfer state, so a guided click can never be absorbed into a running upload. - mavlinkCommitMissionUpload() / mavlinkClearPersistedMission(): while armed the commit or clear applies to RAM only and skips persistence - saveNonVolatileWaypointList() refuses to run while armed, and a flash write mid-flight would stall the main loop. The uploaded mission survives disarm but not a reboot; persisting after landing remains the GCS's responsibility. An in-flight upload also collapses a loaded multi-mission set to the uploaded mission for the rest of the session. - mavlinkResolveUploadedMissionJumps(): armed commits now enforce the same JUMP rules as the arm-time validation they bypass (no JUMP as first item, no self/adjacent targets, sane repeat count, geo-referenced target). Ground uploads are left to the arm-time check. - navigation.c: setWaypoint()'s post-upload clamp of activeWaypointIndex uses >= instead of > (the index is 0-based, so index == waypointCount is already out of range); new public isWpMissionPlannerActive() accessor. - Unit tests: the old MissionCountWhileArmedIsRejected asserts the new policy as MissionCountWhileArmedStartsTransfer; new tests cover the WP-mode and mission-RTH rejections, RAM-only armed commit and clear (persist not called), and the preserved clear rejection during WP mode. The staged upload buffer with atomic commit and snapshot rollback means an in-flight upload never exposes a partially written list to the navigation state machine within a main-loop tick. Note the deliberate MSP-parity semantics carried over from #10273: with nav_wp_mission_restart = RESUME, replacing the mission mid-flight keeps the waypoint index when the new mission is at least as long as the index. --- src/main/mavlink/mavlink_mission.c | 108 ++++++++++++++++-- src/main/navigation/navigation.c | 10 +- src/main/navigation/navigation.h | 1 + src/test/unit/mavlink_unittest.cc | 171 +++++++++++++++++++++++++---- 4 files changed, 255 insertions(+), 35 deletions(-) diff --git a/src/main/mavlink/mavlink_mission.c b/src/main/mavlink/mavlink_mission.c index 08141b4f086..372a57e3e15 100644 --- a/src/main/mavlink/mavlink_mission.c +++ b/src/main/mavlink/mavlink_mission.c @@ -89,6 +89,19 @@ static bool mavlinkMissionTargetIsLocal(uint8_t targetSystem, uint8_t targetComp (targetComponent == 0 || targetComponent == mavComponentId); } +// A mission edit (upload or clear) is refused while the WP mission is being +// executed: WP mode active, the mission's own RTH leg running (the land/loiter +// decision at home reads the live list), or the on-the-fly mission planner +// writing the same list. This matches the MSP policy from #10273 combined +// with the updateWpMissionPlanner() guard. The ARMED term makes the disarmed +// path provably unchanged, including the short window right after disarming +// before the nav FSM drops out of WP mode. +static bool mavlinkMissionEditBlocked(void) +{ + return ARMING_FLAG(ARMED) && + (FLIGHT_MODE(NAV_WP_MODE) || isWaypointMissionRTHActive() || isWpMissionPlannerActive()); +} + static bool mavlinkMissionSenderOwnsTransfer(void) { return mavlinkContext.recvMsg.sysid == mavMissionTransfer.partnerSystem && @@ -194,7 +207,11 @@ static bool mavlinkClearPersistedMission(void) resetWaypointList(); mavlinkContext.missionCompleted = false; - if (mavlinkPersistMission()) { + // While armed the clear applies to RAM only, mirroring the MSP policy: + // saveNonVolatileWaypointList() refuses to run while armed, and a flash + // write mid-flight would stall the main loop anyway. The persisted + // mission is left untouched until the next clear or upload on the ground. + if (ARMING_FLAG(ARMED) || mavlinkPersistMission()) { return true; } @@ -400,6 +417,26 @@ static bool mavlinkResolveUploadedMissionJumps(void) return false; } + // For a mission committed in flight, enforce the same JUMP rules as + // the arm-time validation in navigationIsBlockingArming(), which an + // in-flight upload bypasses entirely: a JUMP cannot be the first + // mission item, cannot target itself or an immediately adjacent + // item, must have a sane repeat count and must target a + // geo-referenced item. Ground uploads are left to the arm-time + // check, keeping disarmed behaviour unchanged. + if (ARMING_FLAG(ARMED)) { + const int targetIndex = targetWaypointNumber - 1; + const navWaypoint_t *target = &mavlinkMissionUploadWaypoints[targetIndex]; + if (i == 0 || + wp->p2 < -1 || + (targetIndex >= (int)i - 1 && targetIndex <= (int)i + 1) || + !(target->action == NAV_WP_ACTION_WAYPOINT || + target->action == NAV_WP_ACTION_HOLD_TIME || + target->action == NAV_WP_ACTION_LAND)) { + return false; + } + } + wp->p1 = targetWaypointNumber; } @@ -427,7 +464,11 @@ static bool mavlinkCommitMissionUpload(void) setWaypoint(i + 1, &mavlinkMissionUploadWaypoints[i]); } - if (!isWaypointListValid() || !mavlinkPersistMission()) { + // While armed the uploaded mission lives in RAM only, mirroring the MSP + // in-flight upload policy (see #10273): saveNonVolatileWaypointList() + // refuses to run while armed, and a flash write mid-flight would stall + // the main loop anyway. On the ground the mission is persisted as before. + if (!isWaypointListValid() || (!ARMING_FLAG(ARMED) && !mavlinkPersistMission())) { mavlinkRestoreMission(&previousMission); return false; } @@ -950,8 +991,16 @@ bool mavlinkHandleIncomingMissionClearAll(void) mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_UNSUPPORTED); return true; } - if (ARMING_FLAG(ARMED)) { - mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + // Clearing is allowed while merely armed (RAM only, see + // mavlinkClearPersistedMission), but not while the WP mission is being + // executed. If the refused sender owns a receiving transfer, abort it so + // the retry engine stops soliciting items from a partner we just denied. + if (mavlinkMissionEditBlocked()) { + if (mavMissionTransfer.state == MAVLINK_MISSION_TRANSFER_RECEIVING && mavlinkMissionSenderOwnsTransfer()) { + mavlinkAbortMissionUpload(MAV_MISSION_DENIED); + } else { + mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + } return true; } if (mavMissionTransfer.state != MAVLINK_MISSION_TRANSFER_IDLE && !mavlinkMissionSenderOwnsTransfer()) { @@ -983,8 +1032,17 @@ bool mavlinkHandleIncomingMissionCount(void) } return true; } - if (ARMING_FLAG(ARMED)) { - mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + // Starting an upload is allowed while merely armed (the commit stays in + // RAM, see mavlinkCommitMissionUpload), but not while the WP mission is + // being executed. If the refused sender owns a receiving transfer (e.g. + // a retry after WP mode engaged mid-upload), abort it so the retry + // engine stops soliciting items from a partner we just denied. + if (mavlinkMissionEditBlocked()) { + if (mavMissionTransfer.state == MAVLINK_MISSION_TRANSFER_RECEIVING && mavlinkMissionSenderOwnsTransfer()) { + mavlinkAbortMissionUpload(MAV_MISSION_DENIED); + } else { + mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + } return true; } if (mavMissionTransfer.state != MAVLINK_MISSION_TRANSFER_IDLE && !mavlinkMissionSenderOwnsTransfer()) { @@ -1049,14 +1107,27 @@ bool mavlinkHandleIncomingMissionItem(void) } if (ARMING_FLAG(ARMED)) { - if (msg.command == MAV_CMD_NAV_WAYPOINT) { + // Guided fly-to-here (current == 2) and altitude-target (current == 3) + // items are identified by the item itself - no legitimate upload item + // carries these current values - so a guided click is never absorbed + // into a running upload transfer. + if (msg.command == MAV_CMD_NAV_WAYPOINT && (msg.current == 2 || msg.current == 3)) { return mavlinkHandleArmedGuidedMissionItem(msg.current, msg.frame, MAV_FRAME_SUPPORTED_GLOBAL | MAV_FRAME_SUPPORTED_GLOBAL_RELATIVE_ALT, (int32_t)lrintf(msg.x * 1e7f), (int32_t)lrintf(msg.y * 1e7f), msg.z); } - mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_ERROR); - return true; + // Upload items are accepted while merely armed (in-flight upload, + // matching the MSP policy from #10273), but not while the WP mission + // is being executed. + if (mavlinkMissionEditBlocked()) { + if (mavMissionTransfer.state == MAVLINK_MISSION_TRANSFER_RECEIVING && mavlinkMissionSenderOwnsTransfer()) { + mavlinkAbortMissionUpload(MAV_MISSION_DENIED); + } else { + mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + } + return true; + } } return mavlinkHandleMissionItemCommon(false, msg.frame, msg.command, msg.current, msg.autocontinue, msg.seq, @@ -1252,14 +1323,27 @@ bool mavlinkHandleIncomingMissionItemInt(void) } if (ARMING_FLAG(ARMED)) { - if (msg.command == MAV_CMD_NAV_WAYPOINT) { + // Guided fly-to-here (current == 2) and altitude-target (current == 3) + // items are identified by the item itself - no legitimate upload item + // carries these current values - so a guided click is never absorbed + // into a running upload transfer. + if (msg.command == MAV_CMD_NAV_WAYPOINT && (msg.current == 2 || msg.current == 3)) { return mavlinkHandleArmedGuidedMissionItem(msg.current, msg.frame, MAV_FRAME_SUPPORTED_GLOBAL_INT | MAV_FRAME_SUPPORTED_GLOBAL_RELATIVE_ALT_INT, msg.x, msg.y, msg.z); } - mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_ERROR); - return true; + // Upload items are accepted while merely armed (in-flight upload, + // matching the MSP policy from #10273), but not while the WP mission + // is being executed. + if (mavlinkMissionEditBlocked()) { + if (mavMissionTransfer.state == MAVLINK_MISSION_TRANSFER_RECEIVING && mavlinkMissionSenderOwnsTransfer()) { + mavlinkAbortMissionUpload(MAV_MISSION_DENIED); + } else { + mavlinkSendMissionAckTo(mavlinkContext.recvMsg.sysid, mavlinkContext.recvMsg.compid, MAV_MISSION_DENIED); + } + return true; + } } return mavlinkHandleMissionItemCommon(true, msg.frame, msg.command, msg.current, msg.autocontinue, msg.seq, diff --git a/src/main/navigation/navigation.c b/src/main/navigation/navigation.c index 7d7cf1629fb..01240ce821a 100644 --- a/src/main/navigation/navigation.c +++ b/src/main/navigation/navigation.c @@ -5488,8 +5488,9 @@ void setWaypoint(uint8_t wpNumber, const navWaypoint_t * wpData) posControl.geoWaypointCount = posControl.waypointCount - nonGeoWaypointCount; if (posControl.waypointListValid) { nonGeoWaypointCount = 0; - // If active WP index is bigger than total mission WP number, reset active WP index (Mission Upload mid flight with interrupted mission) if RESUME is enabled - if (posControl.activeWaypointIndex > posControl.waypointCount) { + // If active WP index is beyond the new mission, reset active WP index (Mission Upload mid flight with interrupted mission) if RESUME is enabled. + // activeWaypointIndex is 0-based, so an index equal to waypointCount is already out of range. + if (posControl.activeWaypointIndex >= posControl.waypointCount) { posControl.activeWaypointIndex = 0; } } @@ -5517,6 +5518,11 @@ bool isWaypointListValid(void) return posControl.waypointListValid; } +bool isWpMissionPlannerActive(void) +{ + return posControl.flags.wpMissionPlannerActive; +} + int getWaypointCount(void) { uint8_t waypointCount = posControl.waypointCount; diff --git a/src/main/navigation/navigation.h b/src/main/navigation/navigation.h index d0713d401c5..29c660351ab 100644 --- a/src/main/navigation/navigation.h +++ b/src/main/navigation/navigation.h @@ -848,6 +848,7 @@ bool navCanSetHome(void); */ bool navigationRTHAllowsLanding(void); bool isWaypointMissionRTHActive(void); +bool isWpMissionPlannerActive(void); #ifdef USE_AUTO_TRANSITION navVtolTransitionOsdState_e navigationVtolTransitionOsdState(void); #endif diff --git a/src/test/unit/mavlink_unittest.cc b/src/test/unit/mavlink_unittest.cc index 537b17f45c4..e7530f9caf4 100644 --- a/src/test/unit/mavlink_unittest.cc +++ b/src/test/unit/mavlink_unittest.cc @@ -131,6 +131,8 @@ static int setWaypointCalls; static int resetWaypointCalls; static int saveWaypointCalls; static bool saveWaypointResult; +static bool testWaypointMissionRTHActive; +static bool testWpMissionPlannerActive; static int mavlinkRxHandleCalls; static bool gcsValid; static int waypointCount; @@ -330,6 +332,8 @@ static void initMavlinkTestState(void) resetWaypointCalls = 0; saveWaypointCalls = 0; saveWaypointResult = true; + testWaypointMissionRTHActive = false; + testWpMissionPlannerActive = false; mavlinkRxHandleCalls = 0; mspCommandCallCount = 0; testReplyPayloadLength = 300; @@ -1548,7 +1552,9 @@ TEST(MavlinkTelemetryTest, MissionCountZeroRestoresPreviousMissionOnPersistFailu EXPECT_EQ(waypointStore[0].flag, NAV_WP_FLAG_LAST); } -TEST(MavlinkTelemetryTest, MissionCountWhileArmedIsRejected) +// In-flight upload: while merely armed (WP mode not active) an upload +// transfer starts normally, matching the MSP policy from #10273. +TEST(MavlinkTelemetryTest, MissionCountWhileArmedStartsTransfer) { initMavlinkTestState(); ENABLE_ARMING_FLAG(ARMED); @@ -1561,28 +1567,141 @@ TEST(MavlinkTelemetryTest, MissionCountWhileArmedIsRejected) pushRxMessage(&msg); handleMAVLinkTelemetry(1000); - mavlink_status_t status; - memset(&status, 0, sizeof(status)); - mavlink_message_t outMsg; - bool sawAck = false; - bool sawRequest = false; + mavlink_message_t requestMsg; + EXPECT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_REQUEST_INT, &requestMsg)); + mavlink_message_t ackMsg; + EXPECT_FALSE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); +} - for (size_t i = 0; i < serialTxLen; i++) { - if (mavlink_parse_char(0, serialTxBuffer[i], &outMsg, &status) == MAVLINK_FRAMING_OK) { - if (outMsg.msgid == MAVLINK_MSG_ID_MISSION_ACK) { - mavlink_mission_ack_t ack; - mavlink_msg_mission_ack_decode(&outMsg, &ack); - EXPECT_EQ(ack.type, MAV_MISSION_DENIED); - sawAck = true; - } - if (outMsg.msgid == MAVLINK_MSG_ID_MISSION_REQUEST_INT) { - sawRequest = true; - } - } - } +// While the WP mission is actively being flown, an upload is rejected. +TEST(MavlinkTelemetryTest, MissionCountWhileWpModeActiveIsRejected) +{ + initMavlinkTestState(); + ENABLE_ARMING_FLAG(ARMED); + flightModeFlags = NAV_WP_MODE; - EXPECT_TRUE(sawAck); - EXPECT_FALSE(sawRequest); + mavlink_message_t msg; + mavlink_msg_mission_count_pack( + 42, 200, &msg, + 1, testTargetComponent, 1, MAV_MISSION_TYPE_MISSION, 0); + + pushRxMessage(&msg); + handleMAVLinkTelemetry(1000); + + mavlink_message_t ackMsg; + ASSERT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); + mavlink_mission_ack_t ack; + mavlink_msg_mission_ack_decode(&ackMsg, &ack); + EXPECT_EQ(ack.type, MAV_MISSION_DENIED); + mavlink_message_t requestMsg; + EXPECT_FALSE(findTxMessageById(MAVLINK_MSG_ID_MISSION_REQUEST_INT, &requestMsg)); +} + +// An in-flight upload commits to RAM but must not touch the EEPROM: +// saveNonVolatileWaypointList() refuses while armed, and a flash write +// mid-flight would stall the main loop. +TEST(MavlinkTelemetryTest, MissionUploadWhileArmedCommitsToRamWithoutPersist) +{ + initMavlinkTestState(); + ENABLE_ARMING_FLAG(ARMED); + + mavlink_message_t countMsg; + mavlink_msg_mission_count_pack( + 42, 200, &countMsg, + 1, testTargetComponent, 1, MAV_MISSION_TYPE_MISSION, 0); + pushRxMessage(&countMsg); + handleMAVLinkTelemetry(1000); + resetSerialBuffers(); + + mavlink_message_t itemMsg; + mavlink_msg_mission_item_int_pack( + 42, 200, &itemMsg, + 1, testTargetComponent, 0, + MAV_FRAME_GLOBAL_RELATIVE_ALT_INT, + MAV_CMD_NAV_WAYPOINT, 1, 1, + 0, 0, 0, 0, + 375000000, -1222500000, 12.3f, + MAV_MISSION_TYPE_MISSION); + pushRxMessage(&itemMsg); + handleMAVLinkTelemetry(1000); + + mavlink_message_t ackMsg; + ASSERT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); + mavlink_mission_ack_t ack; + mavlink_msg_mission_ack_decode(&ackMsg, &ack); + EXPECT_EQ(ack.type, MAV_MISSION_ACCEPTED); + EXPECT_EQ(waypointCount, 1); + EXPECT_EQ(waypointStore[0].lat, 375000000); + EXPECT_EQ(waypointStore[0].lon, -1222500000); + EXPECT_EQ(waypointStore[0].flag, NAV_WP_FLAG_LAST); + EXPECT_EQ(saveWaypointCalls, 0); +} + +// An in-flight clear applies to RAM only, without persisting. +TEST(MavlinkTelemetryTest, MissionClearAllWhileArmedClearsRamWithoutPersist) +{ + initMavlinkTestState(); + ENABLE_ARMING_FLAG(ARMED); + + mavlink_message_t msg; + mavlink_msg_mission_clear_all_pack( + 42, 200, &msg, + 1, testTargetComponent, MAV_MISSION_TYPE_MISSION); + pushRxMessage(&msg); + handleMAVLinkTelemetry(1000); + + mavlink_message_t ackMsg; + ASSERT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); + mavlink_mission_ack_t ack; + mavlink_msg_mission_ack_decode(&ackMsg, &ack); + EXPECT_EQ(ack.type, MAV_MISSION_ACCEPTED); + EXPECT_EQ(resetWaypointCalls, 1); + EXPECT_EQ(saveWaypointCalls, 0); +} + +// The mission's own RTH leg still reads the live list (land/loiter decision +// at home), so editing stays forbidden during it even though NAV_WP_MODE is +// no longer asserted. +TEST(MavlinkTelemetryTest, MissionCountDuringMissionRthLegIsRejected) +{ + initMavlinkTestState(); + ENABLE_ARMING_FLAG(ARMED); + testWaypointMissionRTHActive = true; + + mavlink_message_t msg; + mavlink_msg_mission_count_pack( + 42, 200, &msg, + 1, testTargetComponent, 1, MAV_MISSION_TYPE_MISSION, 0); + pushRxMessage(&msg); + handleMAVLinkTelemetry(1000); + + mavlink_message_t ackMsg; + ASSERT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); + mavlink_mission_ack_t ack; + mavlink_msg_mission_ack_decode(&ackMsg, &ack); + EXPECT_EQ(ack.type, MAV_MISSION_DENIED); +} + +// Clearing the mission that is actively being flown stays forbidden. +TEST(MavlinkTelemetryTest, MissionClearAllWhileWpModeActiveIsRejected) +{ + initMavlinkTestState(); + ENABLE_ARMING_FLAG(ARMED); + flightModeFlags = NAV_WP_MODE; + + mavlink_message_t msg; + mavlink_msg_mission_clear_all_pack( + 42, 200, &msg, + 1, testTargetComponent, MAV_MISSION_TYPE_MISSION); + pushRxMessage(&msg); + handleMAVLinkTelemetry(1000); + + mavlink_message_t ackMsg; + ASSERT_TRUE(findTxMessageById(MAVLINK_MSG_ID_MISSION_ACK, &ackMsg)); + mavlink_mission_ack_t ack; + mavlink_msg_mission_ack_decode(&ackMsg, &ack); + EXPECT_EQ(ack.type, MAV_MISSION_DENIED); + EXPECT_EQ(resetWaypointCalls, 0); } TEST(MavlinkTelemetryTest, MissionItemIntSingleItemAcksAccepted) @@ -3896,6 +4015,16 @@ bool saveNonVolatileWaypointList(void) return saveWaypointResult; } +bool isWaypointMissionRTHActive(void) +{ + return testWaypointMissionRTHActive; +} + +bool isWpMissionPlannerActive(void) +{ + return testWpMissionPlannerActive; +} + void resetWaypointList(void) { resetWaypointCalls++; From ea0577cbb030bedad720e33609f2cef1a1734d8b Mon Sep 17 00:00:00 2001 From: Raphael Hunziker Date: Wed, 9 Sep 2026 10:11:02 +0200 Subject: [PATCH 2/5] Reset the mission planner state when the waypoint list is replaced An upload or clear (MSP, MAVLink, EEPROM load) goes through resetWaypointList(), but the on-the-fly planner kept its write index and status across it. Re-enabling the planner afterwards wrote at the stale index inside the new mission and derived the waypoint count from it, or refused new waypoints if the old status was still FULL. --- src/main/navigation/navigation.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/navigation/navigation.c b/src/main/navigation/navigation.c index 01240ce821a..3010dad6ef2 100644 --- a/src/main/navigation/navigation.c +++ b/src/main/navigation/navigation.c @@ -5506,6 +5506,11 @@ void resetWaypointList(void) posControl.geoWaypointCount = 0; posControl.startWpIndex = 0; posControl.wpReachedNotificationPending = false; + // The list no longer holds the mission planner's waypoints: an upload or + // clear (MSP, MAVLink, EEPROM) replaced it. Otherwise the planner would + // resume at its old index inside the new mission when re-enabled. + posControl.wpPlannerActiveWPIndex = 0; + posControl.wpMissionPlannerStatus = WP_PLAN_WAIT; #ifdef USE_MULTI_MISSION posControl.totalMultiMissionWpCount = 0; posControl.loadedMultiMissionIndex = 0; From 75bed837e117145736c30af54071d849c5b5281e Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Wed, 9 Sep 2026 18:01:58 +0200 Subject: [PATCH 3/5] Fix portable PG version validation --- .github/scripts/check-pg-versions.sh | 7 ++++--- .github/workflows/pg-version-check.yml | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check-pg-versions.sh b/.github/scripts/check-pg-versions.sh index e07f7538fda..aedc895b77f 100755 --- a/.github/scripts/check-pg-versions.sh +++ b/.github/scripts/check-pg-versions.sh @@ -123,8 +123,8 @@ check_file_for_pg_changes() { echo " ⚠️ Struct definition modified in $struct_found_in" # Check if version was incremented in PG_REGISTER - local old_version=$(echo "$diff_output" | grep "^-.*PG_REGISTER.*$struct_type" | grep -oP ',\s*\K\d+(?=\s*\))' || echo "") - local new_version=$(echo "$diff_output" | grep "^+.*PG_REGISTER.*$struct_type" | grep -oP ',\s*\K\d+(?=\s*\))' || echo "") + local old_version=$(echo "$diff_output" | grep "^-.*PG_REGISTER.*$struct_type" | sed -nE 's/.*,[[:space:]]*([0-9]+)[[:space:]]*\).*/\1/p' || echo "") + local new_version=$(echo "$diff_output" | grep "^+.*PG_REGISTER.*$struct_type" | sed -nE 's/.*,[[:space:]]*([0-9]+)[[:space:]]*\).*/\1/p' || echo "") # Find line number of PG_REGISTER for error reporting local line_num=$(git show $HEAD_COMMIT:"$file" | grep -n "PG_REGISTER.*$struct_type" | cut -d: -f1 | head -1) @@ -187,7 +187,8 @@ while IFS= read -r file; do fi # Determine companion file (.c <-> .h) - local companion="" + # (this loop runs at top level, so no "local" here: bash would abort the script) + companion="" if [[ "$file" == *.c ]]; then companion="${file%.c}.h" elif [[ "$file" == *.h ]]; then diff --git a/.github/workflows/pg-version-check.yml b/.github/workflows/pg-version-check.yml index d9d8c289930..89c71c82224 100644 --- a/.github/workflows/pg-version-check.yml +++ b/.github/workflows/pg-version-check.yml @@ -46,10 +46,14 @@ jobs: - name: Post comment if issues found if: steps.pg_check.outputs.exit_code == '1' uses: actions/github-script@v7 + env: + # Passed through the environment: inlining the multi-line script output + # into the JavaScript source breaks the string literal (SyntaxError). + PG_CHECK_OUTPUT: ${{ steps.pg_check.outputs.output }} with: script: | // Use the captured output from the previous step - const output = '${{ steps.pg_check.outputs.output }}'; + const output = process.env.PG_CHECK_OUTPUT || ''; let issuesContent = ''; try { From d94d72ebf9420c0880d1ae514f587ea989b6f5ac Mon Sep 17 00:00:00 2001 From: Raphael Hunziker Date: Sun, 13 Sep 2026 20:59:57 +0200 Subject: [PATCH 4/5] Drop the parameter-group check changes from this PR Those two files belong to #11885, which replaces check-pg-versions.sh with a Python checker. Carrying a second, older edit of the same file here only produces a conflict once either lands, and it is unrelated to the MAVLink mission change. --- .github/scripts/check-pg-versions.sh | 7 +++---- .github/workflows/pg-version-check.yml | 6 +----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/scripts/check-pg-versions.sh b/.github/scripts/check-pg-versions.sh index aedc895b77f..e07f7538fda 100755 --- a/.github/scripts/check-pg-versions.sh +++ b/.github/scripts/check-pg-versions.sh @@ -123,8 +123,8 @@ check_file_for_pg_changes() { echo " ⚠️ Struct definition modified in $struct_found_in" # Check if version was incremented in PG_REGISTER - local old_version=$(echo "$diff_output" | grep "^-.*PG_REGISTER.*$struct_type" | sed -nE 's/.*,[[:space:]]*([0-9]+)[[:space:]]*\).*/\1/p' || echo "") - local new_version=$(echo "$diff_output" | grep "^+.*PG_REGISTER.*$struct_type" | sed -nE 's/.*,[[:space:]]*([0-9]+)[[:space:]]*\).*/\1/p' || echo "") + local old_version=$(echo "$diff_output" | grep "^-.*PG_REGISTER.*$struct_type" | grep -oP ',\s*\K\d+(?=\s*\))' || echo "") + local new_version=$(echo "$diff_output" | grep "^+.*PG_REGISTER.*$struct_type" | grep -oP ',\s*\K\d+(?=\s*\))' || echo "") # Find line number of PG_REGISTER for error reporting local line_num=$(git show $HEAD_COMMIT:"$file" | grep -n "PG_REGISTER.*$struct_type" | cut -d: -f1 | head -1) @@ -187,8 +187,7 @@ while IFS= read -r file; do fi # Determine companion file (.c <-> .h) - # (this loop runs at top level, so no "local" here: bash would abort the script) - companion="" + local companion="" if [[ "$file" == *.c ]]; then companion="${file%.c}.h" elif [[ "$file" == *.h ]]; then diff --git a/.github/workflows/pg-version-check.yml b/.github/workflows/pg-version-check.yml index 89c71c82224..d9d8c289930 100644 --- a/.github/workflows/pg-version-check.yml +++ b/.github/workflows/pg-version-check.yml @@ -46,14 +46,10 @@ jobs: - name: Post comment if issues found if: steps.pg_check.outputs.exit_code == '1' uses: actions/github-script@v7 - env: - # Passed through the environment: inlining the multi-line script output - # into the JavaScript source breaks the string literal (SyntaxError). - PG_CHECK_OUTPUT: ${{ steps.pg_check.outputs.output }} with: script: | // Use the captured output from the previous step - const output = process.env.PG_CHECK_OUTPUT || ''; + const output = '${{ steps.pg_check.outputs.output }}'; let issuesContent = ''; try { From 5ebd67e962f2ae66727a8006b3ab5dbe1c7ffc10 Mon Sep 17 00:00:00 2001 From: Raphael Hunziker Date: Sun, 13 Sep 2026 21:17:33 +0200 Subject: [PATCH 5/5] docs: describe in-flight MAVLink mission edits Four statements no longer held: the blanket armed rejection, the clear that always removed the saved mission, and the claim that every completed upload reaches nonvolatile storage. Adds what RAM-only means for the pilot - the mission survives disarming but not a reboot - and that an upload while armed collapses a loaded multi-mission set. --- docs/Mavlink.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Mavlink.md b/docs/Mavlink.md index b201daf255c..d29f29cb12a 100644 --- a/docs/Mavlink.md +++ b/docs/Mavlink.md @@ -22,7 +22,7 @@ INAV builds against the checked-in generated `storm32` MAVLink headers/dialect b - **No MAVLink parameter API**: INAV sends a single stub parameter and otherwise ignores parameter traffic. Configure the aircraft through the INAV Configurator or CLI instead. - **Selective command support**: INAV implements a useful subset of MAVLink commands and ACKs unsupported commands as `UNSUPPORTED`. -- **Mission handling is partial**: uploads are rejected while armed except for legacy guided waypoint writes, mission frames are validated per command, and MSP mission parity gaps remain. +- **Mission handling is partial**: uploads and clears are rejected while armed only when the mission is in use (WP mode active, the mission's own RTH leg running, or the on-the-fly mission planner active), mission frames are validated per command, and MSP mission parity gaps remain. - **Mode reporting is approximate**: `custom_mode` is mapped to ArduPilot-style modes for compatibility and does not represent every INAV state exactly. - **Single local component identity**: INAV always originates as `MAV_COMP_ID_AUTOPILOT1`; attached radios, GCSes, and companions are always remote components, never local per-port FC identities. - **Flow control is per-port and opportunistic**: INAV uses remote TX buffer information from `RADIO_STATUS.txbuf`, or from `MLRS_RADIO_LINK_FLOW_CONTROL.txbuf` on MLRS links. Without flow-control input it falls back to blind 20 ms pacing. @@ -159,10 +159,10 @@ Messages are organized into MAVLink datastream groups. Each group sends one mess - `PING`: broadcast requests are echoed to the requesting system/component. - `TIMESYNC`: broadcast or locally targeted requests receive the local boot time in nanoseconds. - `MISSION_COUNT`: starts an upload transaction. Stored INAV waypoints remain capped at `NAV_MAX_WAYPOINTS`; the upload transaction also allows QGC planned-home and non-storage command items. The owning system/component and ingress port are retained for the transaction. -- `MISSION_ITEM` / `MISSION_ITEM_INT`: stores mission waypoints; rejects unsupported frames / sequence errors. Upload while armed is rejected except legacy guided waypoint writes. +- `MISSION_ITEM` / `MISSION_ITEM_INT`: stores mission waypoints; rejects unsupported frames / sequence errors. While armed, an upload is rejected only when the mission is in use; guided waypoint writes (`current` 2 or 3) are always accepted. - `MISSION_REQUEST_LIST`, `MISSION_REQUEST`, `MISSION_REQUEST_INT`: stateful mission download with partner checks and one-item retransmission support. - `MISSION_ACK`: completes an active mission download. -- `MISSION_CLEAR_ALL`: clears the runtime and saved mission. +- `MISSION_CLEAR_ALL`: clears the runtime mission, and the saved mission when disarmed. While armed it clears the runtime mission only. - `COMMAND_LONG` / `COMMAND_INT`: command transport for supported `MAV_CMD_*` handlers. - `REQUEST_DATA_STREAM`: legacy stream-rate control per stream group. - `SET_POSITION_TARGET_GLOBAL_INT`: writes the GCS-guided waypoint when the frame is supported; altitude-only requests are also accepted when X/Y are masked out and GCS navigation is valid. @@ -228,7 +228,7 @@ The default ArduPilot-compatible path reports modes through `HEARTBEAT.custom_mo ## MAVLink missions -INAV supports MAVLink mission upload, download, clear, live mission-state reporting, and waypoint-reached notifications. Uploads retry the outstanding request every 1.5 seconds and abort after five unsuccessful retries. Downloads time out after five seconds of inactivity. Only the system/component and ingress port that started a transfer may continue it. Completed uploads and clears update nonvolatile waypoint storage on targets that provide it. Mission downloads always reply with `MISSION_ITEM_INT`, including in response to a legacy float `MISSION_REQUEST`. +INAV supports MAVLink mission upload, download, clear, live mission-state reporting, and waypoint-reached notifications. Uploads retry the outstanding request every 1.5 seconds and abort after five unsuccessful retries. Downloads time out after five seconds of inactivity. Only the system/component and ingress port that started a transfer may continue it. Completed uploads and clears update nonvolatile waypoint storage on targets that provide it when the aircraft is disarmed. While armed they apply to the runtime mission only: the mission survives disarming but is lost on reboot, and writing it to storage afterwards is the GCS's job. An upload while armed also collapses a loaded multi-mission set to the uploaded mission for the rest of the session. Mission downloads always reply with `MISSION_ITEM_INT`, including in response to a legacy float `MISSION_REQUEST`. Mission upload is staged before it touches the live INAV waypoint list. The MAVLink stream is translated into a temporary INAV mission, validated, and committed only after the full upload succeeds, so rejected uploads do not leave a half-written mission in the FC. QGC planned home item `0` is skipped because INAV stores home separately; MAVLink sequence `1` becomes INAV waypoint `1`.