diff --git a/CLAUDE.md b/CLAUDE.md index dd52413..cc57ac6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Bullet GCSS** (Ground Control Station System) is a web-based UAV ground control station that operates over cellular networks with no range limit. It is a PWA (no app installation required) that works cross-platform. +## Local Resources + +- **INAV firmware repository** is available at `~/dev/inav` — useful for cross-referencing MSP protocol definitions, box IDs, and flight controller behavior. + ## Two-Component Architecture ### 1. ESP32-Modem (Embedded Firmware — [ESP32-Modem/](ESP32-Modem/)) diff --git a/ESP32-Modem/ESP32-Modem.cpp b/ESP32-Modem/ESP32-Modem.cpp index 35dece5..da916aa 100644 --- a/ESP32-Modem/ESP32-Modem.cpp +++ b/ESP32-Modem/ESP32-Modem.cpp @@ -165,16 +165,15 @@ uint8_t boxIdFailsafe = 0; uint8_t boxIdManual = 0; uint8_t boxIdAngle = 0; uint8_t boxIdHorizon = 0; -uint8_t boxIdMspOverride = 0; -// Current RC channel values — updated every telemetry cycle from MSP_RC, used when building MSP_SET_RAW_RC -#define RC_CHANNEL_MAX 34 -uint16_t rcChannels[RC_CHANNEL_MAX] = {0}; -uint8_t rcChannelCount = 0; +// Dedicated BulletGCSS aux channel — index into INAV's aux channel array (0-based, CH5=0). +// Set by msp_setup_aux_channel() at startup; -1 until configured. +int8_t dedicatedAuxChannel = -1; +bool auxChannelReady = false; -// MSP RC Override configuration — populated once from MSP2_COMMON_SETTING -uint32_t mspOverrideChannelsMask = 0; -bool mspOverrideFetched = false; +// Neutral PWM sent on the dedicated channel when no mode is commanded. +// Range 0 in the layout: 1000-1100 µs, center 1050 µs. +#define BGCSS_NEUTRAL_PWM 1050 // ── Remotely commandable flight modes ──────────────────────────────────────── // Each FlightMode bundles what were previously four separate per-mode variables: @@ -188,20 +187,32 @@ FlightMode modeCruise = {}; FlightMode modePosHold = {}; // Table of all commandable flight modes. -// Drives mode-range parsing, override-channel setup, RC override sending, -// command dispatch, and conflict resolution — eliminating per-mode repetition. +// startStep/endStep: the fixed activation range written to INAV's mode conditions. +// centerPwm: the PWM value sent on the dedicated channel to activate this mode. +// Layout (10 ranges of 100 µs, 1000-2000 µs; step = 25 µs, step 0 = 900 µs): +// Range 0: 1000-1100 (steps 4- 8) neutral — no entry written +// Range 1: 1100-1200 (steps 8-12) RTH +// Range 2: 1200-1300 (steps 12-16) AltHold +// Range 3: 1300-1400 (steps 16-20) Cruise +// Range 4: 1400-1500 (steps 20-24) WP Mission +// Range 5: 1500-1600 (steps 24-28) Beeper +// Range 6: 1600-1700 (steps 28-32) PosHold +// Ranges 7-9: 1700-2000 — spare, reserved struct FlightModeEntry { - const char* cmdName; // MQTT command string (e.g. "rth") - uint8_t permId; // MSP_PERM_ID_* constant for MSP_BOXIDS / MSP_MODE_RANGES matching + const char* cmdName; // MQTT command string (e.g. "rth") + uint8_t permId; // MSP_PERM_ID_* constant FlightMode* mode; + uint8_t startStep; // range start written to INAV condition slot + uint8_t endStep; // range end written to INAV condition slot + uint16_t centerPwm; // PWM sent on dedicated channel to activate this mode }; static FlightModeEntry cmdModes[] = { - { "rth", MSP_PERM_ID_RTH, &modeRth }, - { "althold", MSP_PERM_ID_ALTHOLD, &modeAltHold }, - { "cruise", MSP_PERM_ID_CRUISE, &modeCruise }, - { "beeper", MSP_PERM_ID_BEEPER, &modeBeeper }, - { "wp", MSP_PERM_ID_WP, &modeWp }, - { "poshold", MSP_PERM_ID_POSHOLD, &modePosHold }, + { "rth", MSP_PERM_ID_RTH, &modeRth, 8, 12, 1150 }, + { "althold", MSP_PERM_ID_ALTHOLD, &modeAltHold, 12, 24, 1250 }, // wide range: also active when poshold/cruise are selected + { "poshold", MSP_PERM_ID_POSHOLD, &modePosHold, 16, 20, 1350 }, // inside althold range → co-activates althold + { "cruise", MSP_PERM_ID_CRUISE, &modeCruise, 20, 24, 1450 }, // inside althold range → co-activates althold + { "wp", MSP_PERM_ID_WP, &modeWp, 24, 28, 1550 }, + { "beeper", MSP_PERM_ID_BEEPER, &modeBeeper, 28, 32, 1650 }, }; static const int CMD_MODE_COUNT = sizeof(cmdModes) / sizeof(cmdModes[0]); // ───────────────────────────────────────────────────────────────────────────── @@ -212,7 +223,7 @@ uint32_t lastFcProbeTs = 0; // Flags that prevents some routines to run more than one time bool boxIdsFetched = 0; -bool modeRangesFetched = false; +bool auxChannelSetupDone = false; // true once msp_setup_aux_channel() has been called and succeeded bool fcVersionFetched = false; bool callsignFetched = 0; uint8_t waypointMessageCounter = 0; @@ -284,14 +295,9 @@ void msp2_get_misc2(); void msp_get_wp(uint8_t wp_no); void msp_get_fc_version(); void msp_get_boxids(); -void msp_get_mode_ranges(); -void msp_get_override_channels(); -bool msp_get_setting_u32(const char* name, uint32_t &value); -bool msp_set_setting_u32(const char* name, uint32_t value); -void msp_get_rc(); -void msp_send_rc_override(); +void msp_setup_aux_channel(); +void msp_send_aux_rc(); void clearAllCommandStates(); -void resolveChannelConflicts(uint8_t channelIndex); void msp_get_callsign(); void msp_get_activeboxes(); void sendMessageTask(); @@ -1018,27 +1024,24 @@ void mqttCommandCallback(char* topic, byte* payload, unsigned int length) { LOGLINE("RC command '%s': invalid state '%s'", cmd, stateStr); return; } - if (!entry->mode->available) { - LOGLINE("RC command '%s': unavailable (mode not found or channel not overrideable)", cmd); + if (!auxChannelReady) { + LOGLINE("RC command '%s': aux channel not yet configured", cmd); return; } xSemaphoreTake(cmdMutex, portMAX_DELAY); if (state == 1) { - // New command wins — clear any other active command on the same channel first. - resolveChannelConflicts(entry->mode->range.rcChannelIndex); + // All modes share one dedicated channel — deactivate all others first. + for (int i = 0; i < CMD_MODE_COUNT; i++) + cmdModes[i].mode->active = false; entry->mode->active = true; } else { entry->mode->active = false; } xSemaphoreGive(cmdMutex); - if (state == 1) - LOGLINE("%s ON -> RC_CH%d will be held at %d", cmd, - entry->mode->range.rcChannelIndex + 1, entry->mode->range.onValue); - else - LOGLINE("%s OFF -> RC_CH%d released to radio", cmd, - entry->mode->range.rcChannelIndex + 1); + LOGLINE("%s %s", cmd, state == 1 ? "ON" : "OFF"); + msp_send_aux_rc(); } } @@ -1142,14 +1145,22 @@ void getTelemetryData() // Startup-only — each has an internal flag and returns immediately once done. msp_get_fc_version(); msp_get_boxids(); - msp_get_mode_ranges(); - msp_get_override_channels(); + msp_setup_aux_channel(); - // Always: keep RC values fresh and send any active channel overrides. - // These two must run every cycle to stay within INAV's 200 ms freshness window. + // Update cmd telemetry fields every cycle so the publish loop can report changes. uint32_t _cycleStart = millis(); - MSP_TIME("msp_get_rc", msp_get_rc()); - MSP_TIME("msp_send_rc_override", msp_send_rc_override()); + { + bool snap[CMD_MODE_COUNT]; + xSemaphoreTake(cmdMutex, portMAX_DELAY); + for (int i = 0; i < CMD_MODE_COUNT; i++) snap[i] = cmdModes[i].mode->active; + xSemaphoreGive(cmdMutex); + uavstatus.cmdRth = snap[0] ? 1 : 0; + uavstatus.cmdAltHold = snap[1] ? 1 : 0; + uavstatus.cmdCruise = snap[2] ? 1 : 0; + uavstatus.cmdBeeper = snap[3] ? 1 : 0; + uavstatus.cmdWp = snap[4] ? 1 : 0; + uavstatus.cmdPosHold = snap[5] ? 1 : 0; + } // Round-robin: spread remaining telemetry across 6 cycles (one group per cycle) // so each cycle completes well within the 160 ms period. @@ -1217,15 +1228,13 @@ void getTelemetryData() { LOGLINE("MSP connection lost - resetting..."); msp.reset(); - fcReady = false; - boxIdsFetched = false; - modeRangesFetched = false; - fcVersionFetched = false; - mspOverrideFetched = false; - callsignFetched = false; - rcChannelCount = 0; - for (int i = 0; i < CMD_MODE_COUNT; i++) - cmdModes[i].mode->available = false; + fcReady = false; + boxIdsFetched = false; + auxChannelSetupDone = false; + auxChannelReady = false; + dedicatedAuxChannel = -1; + fcVersionFetched = false; + callsignFetched = false; // FC disconnect — pilot must regain manual control; clear all active overrides. clearAllCommandStates(); } @@ -1504,7 +1513,6 @@ void msp_get_boxids() { else if (permId == MSP_PERM_ID_MANUAL) boxIdManual = boxIndex; else if (permId == MSP_PERM_ID_ANGLE) boxIdAngle = boxIndex; else if (permId == MSP_PERM_ID_HORIZON) boxIdHorizon = boxIndex; - else if (permId == MSP_PERM_ID_MSPOVERRIDE) boxIdMspOverride = boxIndex; } } boxIdsFetched = 1; @@ -1514,188 +1522,114 @@ void msp_get_boxids() { } } -void msp_get_mode_ranges() { - // Only needs to run once at startup - if (modeRangesFetched) - return; +// Configure the dedicated BulletGCSS aux channel on the FC. +// Runs once at startup. Scans all 40 INAV mode condition slots, reuses the +// existing BulletGCSS channel if found (ESP32 reboot case), otherwise claims +// a free aux channel at CH25+ and writes 6 condition entries via MSP_SET_MODE_RANGE. +void msp_setup_aux_channel() { + if (auxChannelSetupDone) return; - // INAV supports up to 20 mode activation conditions; each entry is 4 bytes - const int MAX_ENTRIES = 20; - modeRangeEntry_t entries[MAX_ENTRIES]; + const int MAX_CONDITIONS = 40; + modeRangeEntry_t entries[MAX_CONDITIONS]; uint16_t dataLen = 0; - if (msp.request(MSP_MODE_RANGES, entries, sizeof(entries), &dataLen)) { - int entryCount = dataLen / sizeof(modeRangeEntry_t); - LOGLINE("MSP_MODE_RANGES: %d entries received", entryCount); + if (!msp.request(MSP_MODE_RANGES, entries, sizeof(entries), &dataLen)) { + LOGLINE("msp_setup_aux_channel: MSP_MODE_RANGES request failed"); + return; + } + int entryCount = dataLen / sizeof(modeRangeEntry_t); + LOGLINE("msp_setup_aux_channel: %d condition slots read", entryCount); + + // Step 1: scan for an existing BulletGCSS channel (all 6 modes on the same + // aux channel with exactly the expected step values). + for (int8_t auxCh = 20; auxCh <= 27; auxCh++) { + int matched = 0; for (int i = 0; i < entryCount; i++) { modeRangeEntry_t &e = entries[i]; - // Skip empty/invalid entries (no activation range, or garbage auxChannel value) - if (e.startStep >= e.endStep || e.auxChannelIndex > 17) - continue; - - uint8_t rcCh = e.auxChannelIndex + 4; // 0-based; CH1-CH4 are sticks - uint16_t onVal = 900 + ((e.startStep + e.endStep) / 2) * 25; - uint16_t pwmLo = 900 + e.startStep * 25; - uint16_t pwmHi = 900 + e.endStep * 25; - - LOGLINE(" id=%d auxCh=%d steps=%d-%d => RC_CH%d (%d-%d) onVal=%d", - e.permanentId, e.auxChannelIndex, - e.startStep, e.endStep, - rcCh + 1, pwmLo, pwmHi, onVal); - - modeRangeInfo_t info = {rcCh, onVal, pwmLo, pwmHi, true}; - + if (e.auxChannelIndex != auxCh) continue; for (int j = 0; j < CMD_MODE_COUNT; j++) { - if (e.permanentId == cmdModes[j].permId) { - cmdModes[j].mode->range = info; + if (e.permanentId == cmdModes[j].permId && + e.startStep == cmdModes[j].startStep && + e.endStep == cmdModes[j].endStep) { + matched++; break; } } } - - LOGLINE("Mode ranges summary:"); - for (int j = 0; j < CMD_MODE_COUNT; j++) { - FlightMode* m = cmdModes[j].mode; - if (m->range.found) - LOGLINE(" %-8s RC_CH%d onValue=%d", cmdModes[j].cmdName, - m->range.rcChannelIndex + 1, m->range.onValue); - else - LOGLINE(" %-8s not found", cmdModes[j].cmdName); + if (matched == CMD_MODE_COUNT) { + dedicatedAuxChannel = auxCh; + auxChannelSetupDone = true; + auxChannelReady = true; + lastMspCommunicationTs = millis(); + LOGLINE("msp_setup_aux_channel: reusing existing channel AUX%d (CH%d)", + auxCh + 1, auxCh + 5); + return; } - - modeRangesFetched = true; - lastMspCommunicationTs = millis(); - } else { - LOGLINE("MSP_MODE_RANGES request failed!"); - } -} - -// Read a uint32_t setting by name via MSP2_COMMON_SETTING (0x1003). -// Request payload: null-terminated setting name. -// Response payload: raw value bytes (4 bytes for uint32_t). -bool msp_get_setting_u32(const char* name, uint32_t &value) { - uint8_t reqBuf[64]; - uint8_t nameLen = strlen(name) + 1; // include null terminator - if (nameLen > sizeof(reqBuf)) - return false; - memcpy(reqBuf, name, nameLen); - - uint32_t resp = 0; - uint16_t recvSize = 0; - if (msp.requestWithResponse(MSP2_COMMON_SETTING, reqBuf, nameLen, &resp, sizeof(resp), &recvSize)) { - value = resp; - return true; } - return false; -} - -// Write a uint32_t setting by name via MSP2_COMMON_SET_SETTING (0x1004). -// Request payload: null-terminated setting name immediately followed by the raw value bytes. -bool msp_set_setting_u32(const char* name, uint32_t value) { - uint8_t buf[64 + sizeof(uint32_t)]; - uint8_t nameLen = strlen(name) + 1; - if (nameLen > 64) - return false; - memcpy(buf, name, nameLen); - memcpy(buf + nameLen, &value, sizeof(uint32_t)); - return msp.command(MSP2_COMMON_SET_SETTING, buf, nameLen + sizeof(uint32_t)); -} -// Helper: update cmdAvailable* flags from the current mspOverrideChannelsMask and print a summary. -static void updateCommandAvailability() { - for (int i = 0; i < CMD_MODE_COUNT; i++) { - FlightMode* m = cmdModes[i].mode; - if (!m->range.found) { - LOGLINE(" %-8s UNAVAILABLE (no mode range)", cmdModes[i].cmdName); - m->available = false; - } else { - bool enabled = (mspOverrideChannelsMask & (1UL << m->range.rcChannelIndex)) != 0; - m->available = enabled; - LOGLINE(" %-8s RC_CH%-2d %s", cmdModes[i].cmdName, - m->range.rcChannelIndex + 1, enabled ? "OK" : "BLOCKED"); + // Step 2: find a free aux channel at CH25+ (aux index 20+). + int8_t freeChannel = -1; + for (int8_t auxCh = 20; auxCh <= 27; auxCh++) { + bool inUse = false; + for (int i = 0; i < entryCount; i++) { + if (entries[i].auxChannelIndex == auxCh && + entries[i].startStep < entries[i].endStep) { + inUse = true; + break; + } } + if (!inUse) { freeChannel = auxCh; break; } } -} -void msp_get_override_channels() { - // Only needs to run once, and only after mode ranges are known - if (mspOverrideFetched || !modeRangesFetched) - return; - - // Step 1: read the current override mask from the FC - if (!msp_get_setting_u32("msp_override_channels", mspOverrideChannelsMask)) { - LOGLINE("msp_override_channels read failed!"); + if (freeChannel < 0) { + LOGLINE("msp_setup_aux_channel: no free aux channel available at CH25+"); return; } - LOGLINE("msp_override_channels (current): 0x%08X", mspOverrideChannelsMask); - // Step 2: build a mask of every channel we need to control - uint32_t neededMask = 0; - for (int i = 0; i < CMD_MODE_COUNT; i++) { - if (cmdModes[i].mode->range.found) - neededMask |= (1UL << cmdModes[i].mode->range.rcChannelIndex); + // Step 3: find CMD_MODE_COUNT free condition slots (startStep >= endStep = empty). + int freeSlots[CMD_MODE_COUNT]; + int slotsFound = 0; + for (int i = 0; i < entryCount && slotsFound < CMD_MODE_COUNT; i++) { + if (entries[i].startStep >= entries[i].endStep) + freeSlots[slotsFound++] = i; } - // Step 3: if any needed channels are missing, OR them in and write back - uint32_t missingMask = neededMask & ~mspOverrideChannelsMask; - if (missingMask != 0) { - uint32_t newMask = mspOverrideChannelsMask | missingMask; - LOGLINE("Adding channels to msp_override_channels: 0x%08X -> 0x%08X", - mspOverrideChannelsMask, newMask); - - if (!msp_set_setting_u32("msp_override_channels", newMask)) { - LOGLINE("msp_override_channels write failed!"); - return; - } + if (slotsFound < CMD_MODE_COUNT) { + LOGLINE("msp_setup_aux_channel: not enough free slots (%d/%d)", slotsFound, CMD_MODE_COUNT); + return; + } - // Step 4: read back to confirm the FC accepted the new value - uint32_t confirmedMask = 0; - if (!msp_get_setting_u32("msp_override_channels", confirmedMask)) { - LOGLINE("msp_override_channels confirm-read failed!"); + // Step 4: write one condition entry per commandable mode. + LOGLINE("msp_setup_aux_channel: claiming AUX%d (CH%d), writing %d conditions", + freeChannel + 1, freeChannel + 5, CMD_MODE_COUNT); + + for (int j = 0; j < CMD_MODE_COUNT; j++) { + uint8_t payload[5] = { + (uint8_t)freeSlots[j], + cmdModes[j].permId, + (uint8_t)freeChannel, + cmdModes[j].startStep, + cmdModes[j].endStep + }; + if (!msp.command(MSP_SET_MODE_RANGE, payload, sizeof(payload), true)) { + LOGLINE("msp_setup_aux_channel: MSP_SET_MODE_RANGE failed for %s", cmdModes[j].cmdName); return; } - mspOverrideChannelsMask = confirmedMask; - LOGLINE("msp_override_channels (confirmed): 0x%08X", mspOverrideChannelsMask); + LOGLINE(" slot %d: %-8s steps %d-%d -> %d µs", + freeSlots[j], cmdModes[j].cmdName, + cmdModes[j].startStep, cmdModes[j].endStep, cmdModes[j].centerPwm); } - // Step 5: update per-command availability from the final confirmed mask - LOGLINE("Command availability:"); - updateCommandAvailability(); - - mspOverrideFetched = true; + dedicatedAuxChannel = freeChannel; + auxChannelSetupDone = true; + auxChannelReady = true; lastMspCommunicationTs = millis(); + msp_send_aux_rc(); // initialise channel to neutral immediately } -void msp_get_rc() { - uint16_t buf[RC_CHANNEL_MAX]; - uint16_t dataLen = 0; - - if (msp.request(MSP_RC, buf, sizeof(buf), &dataLen)) { - uint8_t count = dataLen / sizeof(uint16_t); - if (count > RC_CHANNEL_MAX) - count = RC_CHANNEL_MAX; - - bool firstRead = (rcChannelCount == 0); - rcChannelCount = count; - for (uint8_t i = 0; i < count; i++) - rcChannels[i] = buf[i]; - - if (firstRead) { - LOGLINE("MSP_RC: %d channels", count); - for (uint8_t i = 0; i < count; i++) - LOGLINE(" CH%d: %d", i + 1, rcChannels[i]); - } - - lastMspCommunicationTs = millis(); - } else { - LOGLINE("MSP_RC request failed!"); - } -} - -// Clear all per-command active states. Called when MSP RC Override goes inactive -// mid-flight, or when the FC disconnects — prevents stale commands from firing -// when the pilot regains control or the FC reconnects. +// Clear all per-command active states. Called on FC disconnect — prevents stale +// commands from firing when the FC reconnects. void clearAllCommandStates() { xSemaphoreTake(cmdMutex, portMAX_DELAY); for (int i = 0; i < CMD_MODE_COUNT; i++) @@ -1705,150 +1639,30 @@ void clearAllCommandStates() { uavstatus.cmdBeeper = uavstatus.cmdWp = uavstatus.cmdPosHold = 0; } -// Clear any active commands that share the same RC channel as a new incoming -// command. Called before setting a new command state to ON so that the latest -// command always wins on a shared channel. -void resolveChannelConflicts(uint8_t channelIndex) { - for (int i = 0; i < CMD_MODE_COUNT; i++) { - FlightMode* m = cmdModes[i].mode; - if (m->active && m->range.found && m->range.rcChannelIndex == channelIndex) - m->active = false; - } -} +// Send MSP2_INAV_SET_AUX_RC to set the dedicated channel to the active mode's +// PWM value, or to the neutral value when no mode is commanded. +// Called on command state change and once at channel setup. Values persist on +// the FC until overwritten — no periodic refresh needed. +void msp_send_aux_rc() { + if (!auxChannelReady) return; -// Return a safe neutral PWM value for a channel that has known mode ranges but -// no active mode. Strategy: scan [900, 2100] for the largest contiguous gap not -// covered by any mode on this channel, then return the midpoint of that gap. -// Falls back to 1000 if no gap is found (shouldn't happen in practice). -static uint16_t findSafeOffValue(uint8_t ch) { - // Collect all [startPWM, endPWM] intervals for modes on this channel. - const uint16_t PWM_MIN = 900; - const uint16_t PWM_MAX = 2100; - - // Max entries: CMD_MODE_COUNT intervals. - uint16_t starts[CMD_MODE_COUNT]; - uint16_t ends[CMD_MODE_COUNT]; - int count = 0; - for (int i = 0; i < CMD_MODE_COUNT; i++) { - modeRangeInfo_t& r = cmdModes[i].mode->range; - if (!r.found || r.rcChannelIndex != ch) continue; - starts[count] = r.startPWM; - ends[count] = r.endPWM; - count++; - } - - if (count == 0) return 1000; // no ranges — shouldn't reach here - - // Insertion sort by startPWM. - for (int i = 1; i < count; i++) { - uint16_t ks = starts[i], ke = ends[i]; - int j = i - 1; - while (j >= 0 && starts[j] > ks) { - starts[j+1] = starts[j]; - ends[j+1] = ends[j]; - j--; - } - starts[j+1] = ks; - ends[j+1] = ke; - } - - // Scan for the largest gap: check [PWM_MIN, first start], each [end_i, start_{i+1}], [last end, PWM_MAX]. - uint16_t bestMid = 1000; - uint16_t bestGap = 0; - - auto checkGap = [&](uint16_t lo, uint16_t hi) { - if (hi <= lo) return; - uint16_t gap = hi - lo; - if (gap > bestGap) { - bestGap = gap; - bestMid = lo + gap / 2; - } - }; - - checkGap(PWM_MIN, starts[0]); - for (int i = 0; i < count - 1; i++) - checkGap(ends[i], starts[i+1]); - checkGap(ends[count-1], PWM_MAX); - - return bestMid; -} - -// Send MSP_SET_RAW_RC to the FC when any sustained RC channel command is active. -// Called every telemetry cycle (~200 ms) after msp_get_rc() refreshes rcChannels[]. -// Starts from the real radio values so untouched channels pass through unmodified. -void msp_send_rc_override() { - if (rcChannelCount == 0) return; - if (!uavstatus.mspRcOverride) return; - - // Snapshot active states under cmdMutex so we don't race with mqttCommandCallback. - bool activeSnapshot[CMD_MODE_COUNT]; - bool anyActive = false; + // Find the active mode's center PWM, or use the neutral value. + uint16_t pwm = BGCSS_NEUTRAL_PWM; xSemaphoreTake(cmdMutex, portMAX_DELAY); for (int i = 0; i < CMD_MODE_COUNT; i++) { - activeSnapshot[i] = cmdModes[i].mode->active; - if (activeSnapshot[i]) anyActive = true; + if (cmdModes[i].mode->active) { pwm = cmdModes[i].centerPwm; break; } } xSemaphoreGive(cmdMutex); - // Update telemetry command state fields from the snapshot (always, even when nothing active). - // cmdModes[] order: 0=rth, 1=althold, 2=cruise, 3=beeper, 4=wp, 5=poshold - uavstatus.cmdRth = activeSnapshot[0] ? 1 : 0; - uavstatus.cmdAltHold = activeSnapshot[1] ? 1 : 0; - uavstatus.cmdCruise = activeSnapshot[2] ? 1 : 0; - uavstatus.cmdBeeper = activeSnapshot[3] ? 1 : 0; - uavstatus.cmdWp = activeSnapshot[4] ? 1 : 0; - uavstatus.cmdPosHold = activeSnapshot[5] ? 1 : 0; - - if (!anyActive) return; - - // Start from the current radio values read by msp_get_rc(). - // NOTE: when MSP RC Override is active, MSP_RC returns our own previously - // sent values, not the real radio values. We must therefore explicitly - // neutralise any channel that has known mode ranges but no active mode, - // otherwise deactivated channels keep the last overridden value. - uint16_t channels[RC_CHANNEL_MAX]; - memcpy(channels, rcChannels, rcChannelCount * sizeof(uint16_t)); - - // For each RC channel that has at least one known range but no active mode, - // find the largest gap in [900, 2100] not covered by any mode on that channel - // and place the channel at the midpoint of that gap. - for (uint8_t ch = 0; ch < rcChannelCount; ch++) { - bool chHasMode = false; - bool chHasActiveMode = false; - - for (int i = 0; i < CMD_MODE_COUNT; i++) { - modeRangeInfo_t& r = cmdModes[i].mode->range; - if (!r.found || r.rcChannelIndex != ch) continue; - chHasMode = true; - if (activeSnapshot[i]) chHasActiveMode = true; - } - - if (chHasMode && !chHasActiveMode) - channels[ch] = findSafeOffValue(ch); - } - - // Apply active mode overrides (always wins over the neutral value above). - //static uint32_t lastRcOverrideTs = 0; - //uint32_t now = millis(); - //if (lastRcOverrideTs != 0) - // SerialMon.printf("MSP_SET_RAW_RC: %lu ms since last call\n", now - lastRcOverrideTs); - //lastRcOverrideTs = now; - //SerialMon.printf("MSP_SET_RAW_RC: sending %d channels\n", rcChannelCount); - for (int i = 0; i < CMD_MODE_COUNT; i++) { - if (activeSnapshot[i] && cmdModes[i].mode->range.found) { - uint8_t ch = cmdModes[i].mode->range.rcChannelIndex; - uint16_t val = cmdModes[i].mode->range.onValue; - //SerialMon.printf(" %s: CH%d %d -> %d\n", cmdModes[i].cmdName, ch + 1, channels[ch], val); - channels[ch] = val; - } - } - //SerialMon.printf(" Full channel dump:"); - //for (int i = 0; i < rcChannelCount; i++) - // SerialMon.printf(" CH%d=%d", i + 1, channels[i]); - //SerialMon.println(); - - msp.send(MSP_SET_RAW_RC, channels, rcChannelCount * sizeof(uint16_t)); - //SerialMon.println(" MSP_SET_RAW_RC sent."); + // MSP2_INAV_SET_AUX_RC payload: 16-bit mode, single channel. + // defByte bits 7-3: 0-based RC channel index (auxChannelIndex + 4). + // defByte bits 2-0: resolution = 3 (16-bit direct PWM). + uint8_t rcIndex = (uint8_t)dedicatedAuxChannel + 4; + uint8_t payload[3]; + payload[0] = (rcIndex << 3) | 3; + payload[1] = pwm & 0xFF; + payload[2] = pwm >> 8; + msp.send(MSP2_INAV_SET_AUX_RC, payload, sizeof(payload)); } void msp_get_callsign() { @@ -1924,7 +1738,6 @@ void msp_get_activeboxes() { bool fmAltHold = (boxes64 & (1LL << modeAltHold.boxId)) != 0; bool fmWaypoint = (boxes64 & (1LL << modeWp.boxId)) != 0; bool fmHorizon = (boxes64 & (1LL << boxIdHorizon)) != 0; - bool fmMspOverride = (boxes64 & (1LL << boxIdMspOverride)) != 0; /* MANU = 1 @@ -1970,13 +1783,6 @@ void msp_get_activeboxes() { uavstatus.fmWp = fmWaypoint; uavstatus.fmPosHold = fmPosHold; - // Detect MSP RC Override going inactive — clear all command states so - // stale commands don't fire when the pilot switches the mode back on. - if (uavstatus.mspRcOverride && !fmMspOverride) { - LOGLINE("MSP RC Override deactivated — clearing all command states"); - clearAllCommandStates(); - } - uavstatus.mspRcOverride = fmMspOverride; lastMspCommunicationTs = millis(); } @@ -2150,8 +1956,6 @@ void buildTelemetryMessage(char* message) { if(lastStatus.downlinkStatus != publishedStatus.downlinkStatus || msgGroup == 7) sprintf(message, "%sdls:%d,", message, publishedStatus.downlinkStatus); // downlinkStatus - if(lastStatus.mspRcOverride != publishedStatus.mspRcOverride || msgGroup == 7) - sprintf(message, "%smro:%d,", message, publishedStatus.mspRcOverride); // mspRcOverride if(lastStatus.cmdRth != publishedStatus.cmdRth || msgGroup == 7) sprintf(message, "%scmdrth:%d,", message, publishedStatus.cmdRth); diff --git a/ESP32-Modem/msp_library.h b/ESP32-Modem/msp_library.h index 7862953..96c4157 100644 --- a/ESP32-Modem/msp_library.h +++ b/ESP32-Modem/msp_library.h @@ -49,7 +49,8 @@ #define MSP_NAV_STATUS 121 #define MSP_SENSOR_STATUS 151 #define MSP_SET_WP 209 -#define MSP_SET_RAW_RC 200 +#define MSP_SET_MODE_RANGE 35 +#define MSP2_INAV_SET_AUX_RC 0x2230 #define MSP2_INAV_ANALOG 0x2002 #define MSP2_INAV_MISC2 0x203A #define MSP2_INAV_SET_WP_INDEX 0x2221 // in: jump to WP N during active mission (U8 index, 0-based) @@ -70,9 +71,8 @@ #define MSP_PERM_ID_FAILSAFE 27 #define MSP_PERM_ID_WP 28 #define MSP_PERM_ID_CRUISE 53 -#define MSP_PERM_ID_MSPOVERRIDE 50 -// One entry returned by MSP_MODE_RANGES (4 bytes each, up to 20 entries) +// One entry returned by MSP_MODE_RANGES (4 bytes each, up to 40 entries) struct modeRangeEntry_t { uint8_t permanentId; // mode identifier (see MSP_PERM_ID_* above) uint8_t auxChannelIndex; // AUX channel (0 = AUX1); RC channel index = auxChannelIndex + 4 @@ -80,25 +80,12 @@ struct modeRangeEntry_t { uint8_t endStep; // activation range end } __attribute__((packed)); -// Computed activation info for a single flight mode (populated from MSP_MODE_RANGES at startup) -struct modeRangeInfo_t { - uint8_t rcChannelIndex; // 0-based RC channel index (auxChannelIndex + 4) - uint16_t onValue; // PWM midpoint of the activation range - uint16_t startPWM; // lower bound of the activation range (900 + startStep * 25) - uint16_t endPWM; // upper bound of the activation range (900 + endStep * 25) - bool found; // true once a valid range was found for this mode -}; - -// All per-mode state bundled into one struct. -// range: RC channel assignment and activation PWM (from MSP_MODE_RANGES) -// available: channel is enabled in msp_override_channels (set at startup) -// active: a sustained RC override for this mode is currently commanded -// boxId: index in the MSP_ACTIVEBOXES bitmask (from MSP_BOXNAMES) +// Per-mode runtime state. +// active: a sustained RC override for this mode is currently commanded +// boxId: index in the MSP_ACTIVEBOXES bitmask (from MSP_BOXNAMES) struct FlightMode { - modeRangeInfo_t range; - bool available; - bool active; - uint8_t boxId; + bool active; + uint8_t boxId; }; // This enum is a copy from INAV one in "src/main/fc/rc_modes.h" diff --git a/ESP32-Modem/uav_status.h b/ESP32-Modem/uav_status.h index f45f772..d6b7189 100644 --- a/ESP32-Modem/uav_status.h +++ b/ESP32-Modem/uav_status.h @@ -66,7 +66,6 @@ struct uav_status uint8_t navState; uint8_t isWpMissionValid; uint8_t downlinkStatus; // 1 = subscribed to command topic ok, 0 = not subscribed - uint8_t mspRcOverride; // 1 = MSP RC Override flight mode is active, 0 = not active uint8_t cmdRth; // 1 = RTH channel actively overridden by firmware uint8_t cmdAltHold; // 1 = AltHold channel actively overridden uint8_t cmdCruise; // 1 = Cruise channel actively overridden diff --git a/UI/basicui.html b/UI/basicui.html index 593984d..39eb0ca 100644 --- a/UI/basicui.html +++ b/UI/basicui.html @@ -844,9 +844,6 @@ -
RTH diff --git a/UI/js/CommScripts.js b/UI/js/CommScripts.js index 43a16f5..0f87459 100644 --- a/UI/js/CommScripts.js +++ b/UI/js/CommScripts.js @@ -760,7 +760,6 @@ export function resetDataObject() mWhDraw: 0, protocolVersion: 1, // 1 = current/legacy; set from low-priority message (pv field) downlinkStatus: 0, // 0 = firmware not subscribed to command topic, 1 = subscribed ok - mspRcOverride: 0, // 0 = MSP RC Override flight mode not active, 1 = active (commands can be sent) cmdRth: 0, // 1 = firmware actively overriding this channel, 0 = not overriding cmdAltHold: 0, cmdCruise: 0, @@ -1134,11 +1133,6 @@ function parseStandardTelemetryMessage(payload) if(raw === 0 || raw === 1) data.downlinkStatus = raw; break; - case "mro": - raw = parseInt(arrData[1]); - if(raw === 0 || raw === 1) - data.mspRcOverride = raw; - break; case "cmdrth": raw = parseInt(arrData[1]); if(raw === 0 || raw === 1) data.cmdRth = raw; diff --git a/UI/js/InfoPanelScripts.js b/UI/js/InfoPanelScripts.js index 1c50814..b4bdcfb 100644 --- a/UI/js/InfoPanelScripts.js +++ b/UI/js/InfoPanelScripts.js @@ -207,8 +207,6 @@ function getCommandChannelIcon() { if(!mqttConnected || data.downlinkStatus !== 1) return "img/command_error.png"; - if(data.mspRcOverride !== 1) - return "img/command_warning.png"; return "img/command_ok.png"; } diff --git a/UI/js/MissionPlannerScripts.js b/UI/js/MissionPlannerScripts.js index bc590d5..137b7f7 100644 --- a/UI/js/MissionPlannerScripts.js +++ b/UI/js/MissionPlannerScripts.js @@ -472,10 +472,6 @@ async function uploadMission() { alert('Cannot upload: WP Mission mode is currently active on the aircraft.\nDeactivate it first.'); return; } - if (data.mspRcOverride === 1) { - alert('Cannot upload: MSP RC Override mode is currently active on the aircraft.\nDeactivate it first.'); - return; - } if (data.uavIsArmed === 1) { if (!confirm('The aircraft is currently armed.\nUploading a mission mid-flight is dangerous.\n\nAre you sure you want to continue?')) return; @@ -548,10 +544,6 @@ async function downloadMission() { alert('Cannot download: WP Mission mode is currently active on the aircraft.\nDeactivate it first.'); return; } - if (data.mspRcOverride === 1) { - alert('Cannot download: MSP RC Override mode is currently active on the aircraft.\nDeactivate it first.'); - return; - } if (data.uavIsArmed === 1) { if (!confirm('The aircraft is currently armed.\nDownloading a mission mid-flight may be dangerous.\n\nAre you sure you want to continue?')) return; diff --git a/UI/js/bsPageScripts.js b/UI/js/bsPageScripts.js index d3fcb04..cdaeaee 100644 --- a/UI/js/bsPageScripts.js +++ b/UI/js/bsPageScripts.js @@ -88,7 +88,7 @@ function openCommandsModal() { function updateCommandsPanel() { var downlinkOk = mqttConnected && data.downlinkStatus === 1; - var rcOk = downlinkOk && data.mspRcOverride === 1; + var rcOk = downlinkOk; // Version warning: shown when version is known and < 9.0.0 var versionKnown = data.fcVersion !== ""; @@ -115,7 +115,6 @@ function updateCommandsPanel() { } document.getElementById("commandsDownlinkWarning").style.display = downlinkOk ? "none" : "block"; - document.getElementById("commandsMroWarning").style.display = (downlinkOk && !rcOk) ? "block" : "none"; document.getElementById("btSendPing").disabled = !downlinkOk; var extOk = data.extCmdsSupported >= 1; @@ -964,11 +963,11 @@ function wireEventListeners() { for (var i = 0; i < rcCommands.length; i++) { (function(entry) { document.getElementById(entry.onId).addEventListener("click", function() { - if (!mqttConnected || data.downlinkStatus !== 1 || data.mspRcOverride !== 1) return; + if (!mqttConnected || data.downlinkStatus !== 1) return; publishCommand(entry.cmd, 1); }); document.getElementById(entry.offId).addEventListener("click", function() { - if (!mqttConnected || data.downlinkStatus !== 1 || data.mspRcOverride !== 1) return; + if (!mqttConnected || data.downlinkStatus !== 1) return; publishCommand(entry.cmd, 0); }); })(rcCommands[i]); diff --git a/docs/task_aux_rc_support.md b/docs/task_aux_rc_support.md new file mode 100644 index 0000000..8ba0806 --- /dev/null +++ b/docs/task_aux_rc_support.md @@ -0,0 +1,143 @@ +# Task: AUX RC Support via MSP2_INAV_SET_AUX_RC + +## Background + +INAV PR #11482 (by b14ckyy) introduces `MSP2_INAV_SET_AUX_RC` (0x2230), a new bandwidth-efficient MSP command for updating auxiliary RC channels (CH13–CH32) without affecting the primary stick channels. This document captures the current BulletGCSS implementation, what the new command enables, and the constraints that must be resolved before a migration plan can be finalized. + +## Current Implementation + +BulletGCSS uses `MSP_SET_RAW_RC` (code 200, MSPv1) to activate flight modes remotely. The mechanism works by replacing the entire RC channel array on every 160 ms telemetry cycle. + +### Flow + +1. **`msp_get_mode_ranges()`** — runs once at startup. Reads `MSP_MODE_RANGES` (34) from the FC to discover which RC channel and PWM range activates each commandable flight mode (RTH, AltHold, Cruise, WP, Beeper, PosHold). Stores results in `cmdModes[].mode->range`. + +2. **`msp_get_override_channels()`** — runs once after mode ranges are known. Reads the `msp_override_channels` FC setting, ORs in any channels needed by BulletGCSS, and writes the updated mask back to the FC. This is required by INAV to whitelist which channels `MSP_SET_RAW_RC` is allowed to override. + +3. **`msp_get_rc()`** — runs every 160 ms cycle. Reads current RC channel values from the FC via `MSP_RC` (105) into `rcChannels[]`. When MSP RC Override is active, the FC echoes back the previously sent override values rather than the real radio values. + +4. **`msp_send_rc_override()`** — runs every 160 ms cycle. Builds a modified copy of `rcChannels[]`, patches in the active mode values (or safe neutral values for inactive modes), and sends the full array via `MSP_SET_RAW_RC`. + +### Complexity Required by This Approach + +- **Full channel array every cycle** — `MSP_SET_RAW_RC` always starts at CH1 and covers all N channels. The firmware must send stick values (CH1–CH4) unchanged alongside the patched aux channels. +- **`msp_get_rc()` on every cycle** — needed solely to get current stick positions to pass through untouched. +- **Gap-finder (`findSafeOffValue()`)** — when deactivating a mode, the firmware cannot simply stop sending a channel. Because all channels are always sent, it must explicitly write a neutral value. If another mode shares the same channel, the neutral value must fall in a gap between all activation ranges on that channel to avoid inadvertently triggering another mode. +- **`MSP RC OVERRIDE` flight mode required** — INAV only applies `MSP_SET_RAW_RC` overrides when this flight mode is active on the FC. +- **`msp_override_channels` setting** — must be configured in INAV RAM to whitelist channels; BulletGCSS auto-configures this at startup. + +### Relevant Source Locations + +| Item | File | Lines | +|---|---|---| +| `rcChannels[]` / `rcChannelCount` | `ESP32-Modem.cpp` | 170–173 | +| `cmdModes[]` table | `ESP32-Modem.cpp` | 198–206 | +| `msp_get_mode_ranges()` | `ESP32-Modem.cpp` | 1517–1572 | +| `msp_get_override_channels()` | `ESP32-Modem.cpp` | 1621–1668 | +| `msp_get_rc()` | `ESP32-Modem.cpp` | 1670–1694 | +| `findSafeOffValue()` | `ESP32-Modem.cpp` | 1723–~ | +| `msp_send_rc_override()` | `ESP32-Modem.cpp` | 1779–1852 | +| `MSP_SET_RAW_RC` constant | `msp_library.h` | 52 | + +## What MSP2_INAV_SET_AUX_RC Enables + +The new command targets only CH13–CH32 as a post-RX overlay applied after MSP RC Override but before failsafe. Key differences: + +- **No `MSP RC OVERRIDE` flight mode required** — the overlay is always applied regardless of active flight modes. +- **No `msp_override_channels` configuration** — that setting is specific to `MSP_SET_RAW_RC` and does not apply here. +- **No need to read RC values first** — the command never touches CH1–CH12, so stick pass-through is not a concern. +- **No gap-finder needed for shared channels** — sending value `0` for a channel means "no update / skip", though a safe neutral value still needs to be sent when explicitly deactivating a mode. +- **Bandwidth reduction ~65%** — the payload covers only the target aux channels in configurable resolution (2/4/8/16-bit per channel). + +## Key Constraint + +The new command only covers **CH13–CH32** (aux index 8 and above). The most common INAV setup maps flight mode switches to **CH5–CH12**. If a user's mode ranges are on CH5–CH12, `MSP2_INAV_SET_AUX_RC` cannot reach them and `MSP_SET_RAW_RC` would still be required for those channels. + +This is the central question that must be resolved before implementation: +- **Hybrid approach**: use `MSP2_INAV_SET_AUX_RC` for CH13+ and keep `MSP_SET_RAW_RC` for CH5–CH12. +- **Full migration**: drop `MSP_SET_RAW_RC` entirely and require users to assign flight mode switches to CH13+. +- **Keep as-is**: continue using `MSP_SET_RAW_RC` until INAV extends the new command's channel range. + +## Proposed New Approach: Self-Configured Dedicated Channel + +### Problem with the Current Detection Approach + +The current implementation reads the user's existing mode range configuration and relies on those ranges being present. If the user has not configured a range for a given mode (e.g. no RTH switch set up in the INAV Configurator), BulletGCSS cannot trigger that mode at all. This creates a hard dependency on the user's AUX channel layout and means BulletGCSS capability varies unpredictably between aircraft. + +### Solution: BulletGCSS Owns Its Channel + +Instead of discovering and reusing the user's configuration, BulletGCSS firmware will: + +1. **Scan all existing mode ranges** — read all 40 condition slots via `MSP_MODE_RANGES` (34) and record which aux channels are already in use (have any valid activation range). +2. **Find a free aux channel** — starting from aux channel 20 (CH25, 0-based RC index 24), find the first channel that has no existing mode range mapped to it. This keeps BulletGCSS out of the range users typically use for manual switches (CH5–CH24). +3. **Claim 6 free condition slots** — identify 6 unused entries in the 40-slot condition table (entries where `startStep >= endStep` are considered empty). +4. **Write the BulletGCSS range map** — use `MSP_SET_MODE_RANGE` (35) to write one condition per commandable mode into the 6 claimed slots, all targeting the chosen dedicated channel. +5. **Control the channel via `MSP2_INAV_SET_AUX_RC`** — on every cycle, send the single channel's PWM value to activate the desired mode (or the neutral range to activate none). + +This approach makes BulletGCSS fully self-contained: no manual INAV Configurator setup is needed for remote mode control. + +### Dedicated Channel Layout + +The dedicated channel is divided into 10 equal ranges of 100 µs each, covering 1000–2000 µs. INAV steps are 25 µs each (step 0 = 900 µs, step 4 = 1000 µs). + +| Range index | PWM (µs) | Steps (start–end) | Mode | Condition slot usage | +|---|---|---|---|---| +| 0 | 1000–1100 | 4–8 | *(neutral — no mode active)* | — no entry written — | +| 1 | 1100–1200 | 8–12 | RTH | 1 slot | +| 2 | 1200–1300 | 12–16 | AltHold | 1 slot | +| 3 | 1300–1400 | 16–20 | Cruise | 1 slot | +| 4 | 1400–1500 | 20–24 | WP Mission | 1 slot | +| 5 | 1500–1600 | 24–28 | Beeper | 1 slot | +| 6 | 1600–1700 | 28–32 | PosHold | 1 slot | +| 7 | 1700–1800 | 32–36 | *(spare)* | — no entry written — | +| 8 | 1800–1900 | 36–40 | *(spare)* | — no entry written — | +| 9 | 1900–2000 | 40–44 | *(spare)* | — no entry written — | + +The neutral range (1000–1100 µs, center 1050 µs) has no condition entry. When no mode should be active, BulletGCSS sets the channel to 1050 µs; the FC sees no activation condition for that value and keeps all modes off. + +On each cycle, BulletGCSS sets the dedicated channel to the center PWM of the active mode's range, or to 1050 µs if no mode is active. Only one mode can be active at a time on this channel, which is acceptable because INAV navigation modes (RTH, Cruise, WP, AltHold) are mutually exclusive by design. + +### `MSP_SET_MODE_RANGE` Payload + +5 bytes per call: `[slotIndex (u8), permanentId (u8), auxChannelIndex (u8), startStep (u8), endStep (u8)]` + +- `slotIndex`: 0–39 (one of the 5 free slots identified in step 3 above) +- `permanentId`: mode permanent ID (RTH=10, AltHold=3, Cruise=53, WP=28, Beeper=13, PosHold=11; see `msp_library.h`) +- `auxChannelIndex`: 0-based aux index of the chosen dedicated channel (e.g. 20 for CH25) +- `startStep` / `endStep`: from the table above + +The 3 spare ranges (1700–2000 µs) are reserved for future use and **no condition entries are written for them**. Only 6 slots are needed — one per commandable mode. + +**EEPROM write policy**: these writes go to INAV RAM only — `MSP_EEPROM_WRITE` is intentionally not called. This avoids permanently modifying the user's saved FC configuration. Whether INAV applies in-RAM mode range changes immediately without a save is to be confirmed by testing. If it does not, an EEPROM write will be required and the reconnect detection logic below becomes even more important. + +**Note on concurrent modes**: only one mode can be active at a time on the dedicated channel, which is acceptable because INAV navigation modes (RTH, Cruise, WP, AltHold) are mutually exclusive by design. Beeper is independent but shares the channel; simultaneous Beeper + nav mode is not possible with this single-channel design and is accepted as a known limitation. + +### Startup Sequence: Detect Before Configure + +The ESP32 has a watchdog routine that can reboot the board mid-session. On reconnect to the FC, the mode ranges written in the previous session may still be present in INAV RAM (if no FC reboot occurred). Writing a second dedicated channel on every reconnect would exhaust the available condition slots over time and collide with the user's own configuration. + +Therefore, the startup sequence must **detect an existing BulletGCSS channel before writing a new one**: + +1. **Read all 40 mode condition slots** via `MSP_MODE_RANGES`. +2. **Scan for an existing BulletGCSS channel** — check whether all 6 commandable modes (RTH, AltHold, Cruise, WP, Beeper, PosHold) already have condition entries on the **same** aux channel with the **exact expected step values** from the layout table above. If all 6 are found on the same channel with correct steps, the channel is already configured; skip to step 6. +3. **Find a free aux channel** ≥ 20 (CH25+) — a channel with no existing condition entries. +4. **Find 6 free condition slots** — entries where `startStep >= endStep` (INAV's representation of an empty/disabled condition). +5. **Write 6 mode range entries** via `MSP_SET_MODE_RANGE`, one per commandable mode, all targeting the chosen channel. +6. **Store the dedicated `auxChannelIndex`** and per-mode center PWM values for use by the send loop. + +This detection ensures that an ESP32 reboot followed by FC reconnect reuses the existing channel rather than creating a duplicate. An INAV reboot is a catastrophic event outside BulletGCSS's responsibility; in that case the ranges are gone and BulletGCSS will simply write them fresh (the scan in step 2 will find nothing, so a new channel is claimed). + +### Send Loop Change + +`msp_send_rc_override()` is replaced by a simpler function: + +- Determine the active mode (if any) from `cmdModes[].mode->active` +- Look up the center PWM for that mode from the layout table (or 1050 µs for neutral) +- Send a single-channel `MSP2_INAV_SET_AUX_RC` packet targeting the dedicated channel index + +Because `MSP2_INAV_SET_AUX_RC` does not require `MSP RC OVERRIDE` flight mode, and only one channel value is ever sent, the following can all be removed: +- `msp_get_rc()` (no longer needed — stick values are not touched) +- `rcChannels[]` / `rcChannelCount` arrays +- `findSafeOffValue()` (no shared-channel conflict possible on a BulletGCSS-owned channel) +- `mspOverrideChannelsMask` / `msp_get_override_channels()` / `msp_set_setting_u32()` +- The `mspRcOverride` guard in the send loop (no longer a prerequisite)