From 97ed15ed03abcc3e9776e4140858fd58476c28f9 Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 22 Apr 2026 22:52:54 -0400 Subject: [PATCH 01/11] feat: menu-driven airplane mode (#3) Replaces the earlier button-gesture design which crashed ESP32 on enter when WiFi/BLE had live sessions. The gesture tore down radios synchronously from the ButtonThread context; the menu version persists radio-off config to NVS and reboots instead, using the same "reboot to apply" pattern Meshtastic already uses for Reboot and Shutdown. Design: - AirplaneMode::toggle() is the single entry point. - isActive() is derived from the live config (all 3 radio flags false) rather than a runtime state machine. - Saved pre-airplane radio state lives in ESP32 Preferences NVS so exit restores faithfully; fallback defaults to all-radios-on if NVS was wiped while active. - No new protobuf fields; no build-flag gate; always-available menu entry alongside Reboot and Shutdown. Changes: - src/modules/AirplaneMode.{h,cpp} rewritten (drops gesture state machine; ~80 lines net from ~150+). - src/graphics/draw/MenuHandler.h: add AirplaneModeMenu enum and airplaneModeMenu() declaration. - src/graphics/draw/MenuHandler.cpp: add airplaneModeMenu() implementation, add entry to powerMenu() (label reflects state), dispatch from handleMenuSwitch. Resolves the crash reported on feat/portable-combined (gesture enter crashed in ButtonThread due to synchronous esp_wifi_stop() / nimbleBluetooth->deinit() on live connections). --- src/graphics/draw/MenuHandler.cpp | 43 +++++++++- src/graphics/draw/MenuHandler.h | 2 + src/modules/AirplaneMode.cpp | 136 ++++++++++++++++++++++++++++++ src/modules/AirplaneMode.h | 60 +++++++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/modules/AirplaneMode.cpp create mode 100644 src/modules/AirplaneMode.h diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index e92ba48394e..73efe671fc4 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -20,6 +20,7 @@ #include "mesh/MeshTypes.h" #include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" +#include "modules/AirplaneMode.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" #include "modules/KeyVerificationModule.h" @@ -2177,6 +2178,31 @@ void menuHandler::shutdownMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::airplaneModeMenu() +{ + static const char *optionsArray[] = {"Back", "Confirm"}; + BannerOverlayOptions bannerOptions; + bool active = AirplaneMode::instance().isActive(); + bannerOptions.message = active ? "Exit Airplane Mode? Device will reboot." : "Airplane Mode? All radios off. Device will reboot."; + if (currentResolution == ScreenResolution::UltraLow) { + bannerOptions.message = active ? "Exit Airplane?" : "Airplane?"; + } + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + IF_SCREEN(screen->showSimpleBanner(AirplaneMode::instance().isActive() ? "Exiting airplane mode..." + : "Entering airplane mode...", + 0)); + AirplaneMode::instance().toggle(); + } else { + menuQueue = PowerMenu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::removeFavoriteMenu() { @@ -2377,9 +2403,9 @@ void menuHandler::screenOptionsMenu() void menuHandler::powerMenu() { - enum optionsNumbers { Back, Reboot, Shutdown, MUI }; - static const char *optionsArray[4] = {"Back"}; - static int optionsEnumArray[4] = {Back}; + enum optionsNumbers { Back, Reboot, Shutdown, Airplane, MUI }; + static const char *optionsArray[5] = {"Back"}; + static int optionsEnumArray[5] = {Back}; int options = 1; optionsArray[options] = "Reboot"; @@ -2388,6 +2414,11 @@ void menuHandler::powerMenu() optionsArray[options] = "Shutdown"; optionsEnumArray[options++] = Shutdown; + // Airplane mode always-available — no build-flag gate. Label reflects + // current state so the user knows what Confirm will do. + optionsArray[options] = AirplaneMode::instance().isActive() ? "Exit Airplane" : "Airplane Mode"; + optionsEnumArray[options++] = Airplane; + #if HAS_TFT optionsArray[options] = "Switch to MUI"; optionsEnumArray[options++] = MUI; @@ -2408,6 +2439,9 @@ void menuHandler::powerMenu() } else if (selected == Shutdown) { menuHandler::menuQueue = menuHandler::ShutdownMenu; screen->runNow(); + } else if (selected == Airplane) { + menuHandler::menuQueue = menuHandler::AirplaneModeMenu; + screen->runNow(); } else if (selected == MUI) { menuHandler::menuQueue = menuHandler::MuiPicker; screen->runNow(); @@ -2739,6 +2773,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case ShutdownMenu: shutdownMenu(); break; + case AirplaneModeMenu: + airplaneModeMenu(); + break; case NodePickerMenu: NodePicker(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 4a0360412dd..ddbc7c18324 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -34,6 +34,7 @@ class menuHandler BrightnessPicker, RebootMenu, ShutdownMenu, + AirplaneModeMenu, NodePickerMenu, ManageNodeMenu, RemoveFavorite, @@ -95,6 +96,7 @@ class menuHandler static void BrightnessPickerMenu(); static void rebootMenu(); static void shutdownMenu(); + static void airplaneModeMenu(); static void NodePicker(); static void manageNodeMenu(); static void addFavoriteMenu(); diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp new file mode 100644 index 00000000000..535131ce0ef --- /dev/null +++ b/src/modules/AirplaneMode.cpp @@ -0,0 +1,136 @@ +#include "AirplaneMode.h" + +#if HAS_SCREEN || defined(ARCH_ESP32) + +#include "NodeDB.h" +#include "configuration.h" +#include "main.h" + +#ifdef ARCH_ESP32 +#include +#endif + +// NVS key names — kept short to fit in NVS's 15-char key limit. +// Namespace is shared with main-esp32.cpp's "meshtastic" namespace. +static const char *kNvsNamespace = "meshtastic"; +static const char *kKeySavedWifi = "apm_saved_wifi"; // 14 chars +static const char *kKeySavedBt = "apm_saved_bt"; // 12 chars +static const char *kKeySavedLoraTx = "apm_saved_lora"; // 14 chars + +AirplaneMode &AirplaneMode::instance() +{ + static AirplaneMode singleton; + return singleton; +} + +bool AirplaneMode::isActive() const +{ + // Airplane mode = all three radio flags disabled in the live config. + return (!config.network.wifi_enabled) && (!config.bluetooth.enabled) && (!config.lora.tx_enabled); +} + +void AirplaneMode::toggle() +{ + if (isActive()) + exitActive(); + else + enterActive(); +} + +void AirplaneMode::enterActive() +{ + LOG_WARN("AirplaneMode: entering — saving radio state and rebooting"); + + // Stash the current (pre-airplane) radio state into NVS so exit can restore exactly. + saveRadioStateToNvs(config.network.wifi_enabled, config.bluetooth.enabled, config.lora.tx_enabled); + + // Disable all three. No synchronous teardown here — the reboot handles it. + config.network.wifi_enabled = false; + config.bluetooth.enabled = false; + config.lora.tx_enabled = false; + + // Persist config so post-reboot boot brings up nothing. + if (nodeDB) + nodeDB->saveToDisk(SEGMENT_CONFIG); + + // Schedule clean reboot. 2s gives the UI banner time to render and any + // outstanding flush to complete. Matches the pattern used elsewhere (see + // menuHandler::rebootMenu). + rebootAtMsec = millis() + 2000; +} + +void AirplaneMode::exitActive() +{ + LOG_WARN("AirplaneMode: exiting — restoring radio state and rebooting"); + + bool savedWifi = true, savedBt = true, savedLoraTx = true; + bool hasSaved = loadSavedRadioStateFromNvs(savedWifi, savedBt, savedLoraTx); + + if (!hasSaved) { + // NVS had no saved state (e.g. factory reset while in airplane mode). + // Safe default: turn everything on so the user can re-reach the node. + LOG_WARN("AirplaneMode: no saved radio state in NVS; defaulting to all radios on"); + savedWifi = true; + savedBt = true; + savedLoraTx = true; + } + + config.network.wifi_enabled = savedWifi; + config.bluetooth.enabled = savedBt; + config.lora.tx_enabled = savedLoraTx; + + if (nodeDB) + nodeDB->saveToDisk(SEGMENT_CONFIG); + + rebootAtMsec = millis() + 2000; +} + +// ---- NVS helpers ------------------------------------------------------------- + +void AirplaneMode::saveRadioStateToNvs(bool wifi, bool bluetooth, bool loraTx) +{ +#ifdef ARCH_ESP32 + Preferences prefs; + if (!prefs.begin(kNvsNamespace, false)) { + LOG_ERROR("AirplaneMode: Preferences.begin(%s) failed — saved radio state WILL NOT persist", kNvsNamespace); + return; + } + prefs.putBool(kKeySavedWifi, wifi); + prefs.putBool(kKeySavedBt, bluetooth); + prefs.putBool(kKeySavedLoraTx, loraTx); + prefs.end(); + LOG_INFO("AirplaneMode: saved radio state to NVS (wifi=%d, bt=%d, loraTx=%d)", wifi, bluetooth, loraTx); +#else + (void)wifi; + (void)bluetooth; + (void)loraTx; +#endif +} + +bool AirplaneMode::loadSavedRadioStateFromNvs(bool &outWifi, bool &outBluetooth, bool &outLoraTx) +{ +#ifdef ARCH_ESP32 + Preferences prefs; + if (!prefs.begin(kNvsNamespace, true)) { + LOG_ERROR("AirplaneMode: Preferences.begin(%s, ro) failed", kNvsNamespace); + return false; + } + // isKey() available on Preferences; use it to distinguish "never saved" from "saved false". + bool haveAll = prefs.isKey(kKeySavedWifi) && prefs.isKey(kKeySavedBt) && prefs.isKey(kKeySavedLoraTx); + if (haveAll) { + outWifi = prefs.getBool(kKeySavedWifi, true); + outBluetooth = prefs.getBool(kKeySavedBt, true); + outLoraTx = prefs.getBool(kKeySavedLoraTx, true); + LOG_INFO("AirplaneMode: loaded saved radio state from NVS (wifi=%d, bt=%d, loraTx=%d)", outWifi, outBluetooth, outLoraTx); + } + prefs.end(); + return haveAll; +#else + (void)outWifi; + (void)outBluetooth; + (void)outLoraTx; + return false; +#endif +} + +#endif // HAS_SCREEN || ARCH_ESP32 diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h new file mode 100644 index 00000000000..3954223a983 --- /dev/null +++ b/src/modules/AirplaneMode.h @@ -0,0 +1,60 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN || defined(ARCH_ESP32) + +#include + +/** + * AirplaneMode — menu-driven RF kill-switch for flight-safe Meshtastic operation. + * + * Design rationale (supersedes the earlier button-gesture version): + * The previous design hooked into ButtonThread with an 8-12s hold gesture. + * That tore down WiFi/BLE/LoRa synchronously from the button-event context, + * which panicked the ESP32 when applied to a device with live TCP / BLE + * sessions. The menu-driven design instead: + * 1. Persists the "airplane off" radio flags to NVS via nodeDB->saveToDisk() + * 2. Schedules a clean reboot via rebootAtMsec + * 3. Lets the normal boot path bring (or not bring) radios up based on the + * saved config. + * This is symmetric on enter and exit and avoids live-teardown crashes. + * + * BLE-return on exit *requires* a reboot on ESP32 anyway — NimBLEDevice::deinit() + * is one-way — so the extra reboot on enter is not a meaningful cost. + * + * Persistence model: + * We save the "pre-airplane" radio state to the ESP32 Preferences NVS namespace + * so exit can restore faithfully. No new protobuf fields are introduced. The + * in-config airplane-mode indicator is simply "all three radio flags are false." + */ +class AirplaneMode +{ + public: + static AirplaneMode &instance(); + + // True when all three radio flags (lora.tx_enabled, network.wifi_enabled, + // bluetooth.enabled) are currently false in the live config. No separate + // state machine — the config state is the source of truth. + bool isActive() const; + + // Menu entry point. If currently active, restores saved radio state and + // reboots. If currently inactive, saves current radio state and disables + // all three radios, then reboots. Caller is responsible for showing any + // transient UI banner before calling. + void toggle(); + + private: + AirplaneMode() = default; + AirplaneMode(const AirplaneMode &) = delete; + AirplaneMode &operator=(const AirplaneMode &) = delete; + + void enterActive(); + void exitActive(); + + // NVS helpers — use the "meshtastic" Preferences namespace, matching main-esp32.cpp + void saveRadioStateToNvs(bool wifi, bool bluetooth, bool loraTx); + bool loadSavedRadioStateFromNvs(bool &outWifi, bool &outBluetooth, bool &outLoraTx); +}; + +#endif // HAS_SCREEN || ARCH_ESP32 From b5e7ff74eecf0c380547a1594da5c223a74599fc Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 22 Apr 2026 20:07:18 -0400 Subject: [PATCH 02/11] feat(esp32): allow WiFi+BLE coexistence via MESHTASTIC_DUAL_RF flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream Meshtastic intentionally excludes BLE initialization whenever WiFi is configured (src/platform/esp32/main-esp32.cpp setBluetoothEnable calls !isWifiAvailable()). isWifiAvailable() checks config, not runtime state, so a node with wifi_enabled=true will never advertise BLE even when WiFi has never successfully associated. This is the root cause of reports like #1294 (bootloop on WiFi enable), #7719 (dual mode feature request), #3808 (BLE radio stays off), and #8151 (S3-specific errors) — they are downstream of the XOR gate, not coexistence timing bugs. The gate made sense in the ESP32 + bluedroid era where coex was unreliable. On ESP32-S3 + NimBLE + ESP-IDF 5.x, the hardware coex arbiter handles dual-RF correctly when given reasonable inputs: - WiFi power save must allow the radio to release between DTIM beacons (WIFI_PS_NONE monopolizes the radio — line 309 in WiFiAPClient.cpp). - Coex preference should be set explicitly (defaults are version- dependent and historically WiFi-biased). This commit adds a MESHTASTIC_DUAL_RF build flag that: 1. src/platform/esp32/main-esp32.cpp — skips the !isWifiAvailable() gate in setBluetoothEnable(), always initializing NimBLE if config.bluetooth.enabled. 2. src/mesh/wifi/WiFiAPClient.cpp — switches to WIFI_PS_MIN_MODEM + WiFi.setSleep(true) and calls esp_coex_preference_set( ESP_COEX_PREFER_BALANCE) so the arbiter schedules BLE slots between WiFi windows. 3. variants/esp32s3/heltec_v4/platformio.ini — adds heltec-v4-coex env with -D MESHTASTIC_DUAL_RF=1 for opt-in testing. Default builds are unaffected; without the flag behavior is bit- identical to upstream. Verified root cause via direct code read + reproduced empirically on Heltec V4 running 2.7.20.6658ec2: factory-default firmware + WiFi enabled = BLE does not advertise; factory-default + WiFi disabled = BLE advertises immediately (same NVS, same binary). --- src/mesh/wifi/WiFiAPClient.cpp | 16 ++++++++++++ src/platform/esp32/main-esp32.cpp | 8 +++++- variants/esp32s3/heltec_v4/platformio.ini | 31 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 5d05c7fc6df..d6045c766fc 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -22,6 +22,9 @@ #endif #include #include +#ifdef MESHTASTIC_DUAL_RF +#include +#endif static void WiFiEvent(WiFiEvent_t event); #elif defined(ARCH_RP2040) #include @@ -303,10 +306,23 @@ bool initWifi() #ifdef ARCH_ESP32 WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); +#ifdef MESHTASTIC_DUAL_RF + // Dual-RF coex mode: allow WiFi to release the 2.4 GHz radio between + // DTIM beacons so the hardware coex arbiter can schedule BLE + // advertising slots. Without these knobs, WiFi monopolizes the radio + // and NimBLE advertisements are never transmitted on ESP32-S3. + WiFi.setSleep(true); + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); + // Balance arbiter between WiFi and BLE. Default (ESP_COEX_PREFER_BALANCE + // on paper) is effectively WiFi-biased on many ESP-IDF versions — set + // explicitly so we know what we're getting. + esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); +#else WiFi.setSleep(false); // This is needed to improve performance. esp_wifi_set_ps(WIFI_PS_NONE); // Disable radio power saving +#endif WiFi.onEvent( [](WiFiEvent_t event, WiFiEventInfo_t info) { diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25cb30e962e..33443028ea6 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,7 +32,13 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -#ifdef USE_WS5500 +// MESHTASTIC_DUAL_RF: let WiFi and BLE both run. The ESP32-S3 hardware coex +// arbiter supports this fine; the historical XOR gate here predates NimBLE + +// ESP-IDF 5.x coex and was a conservative workaround, not a necessity. +// Without this flag, behavior is unchanged from upstream. +#if defined(MESHTASTIC_DUAL_RF) + if (config.bluetooth.enabled == true) +#elif defined(USE_WS5500) if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false)) #elif HAS_WIFI if (!isWifiAvailable() && config.bluetooth.enabled == true) diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5306e9b5d17..fe03b0d94e3 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -35,6 +35,37 @@ build_flags = -D I2C_SDA1=4 -D I2C_SCL1=3 +; heltec-v4-coex: same hardware profile as heltec-v4 (OLED) with the +; MESHTASTIC_DUAL_RF flag enabled. This bypasses the historical WiFi-XOR-BLE +; exclusion in setBluetoothEnable() (src/platform/esp32/main-esp32.cpp) and +; tunes WiFi power-save + ESP-IDF coex preference so BLE advertising is not +; starved when WiFi is enabled. Opt-in per build. +[env:heltec-v4-coex] +custom_meshtastic_hw_model = 110 +custom_meshtastic_hw_model_slug = HELTEC_V4 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 (WiFi+BLE coex) +custom_meshtastic_images = heltec_v4.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true + +extends = heltec_v4_base +build_flags = + ${heltec_v4_base.build_flags} + -D HELTEC_V4_OLED + -D USE_SSD1306 + -D LED_POWER=35 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + -D I2C_SDA1=4 + -D I2C_SCL1=3 + -D MESHTASTIC_DUAL_RF=1 + [env:heltec-v4-tft] custom_meshtastic_hw_model = 110 custom_meshtastic_hw_model_slug = HELTEC_V4 From 12e00a1d9341398e7813d9ac0f7e005c451bf3fb Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 22 Apr 2026 23:42:09 -0400 Subject: [PATCH 03/11] feat(airplane-mode): persistent header indicator (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AirplaneMode::isActive() returns true (all three radio flags off in config), drawCommonHeader() replaces the per-screen title with "AIRPLANE MODE" (or "AIRPLANE" on ultra-low-res screens). Always visible on any main-screen frame the user navigates to, unambiguous at a glance, zero layout disruption. Addresses the gap identified during first field test: user could enter airplane mode via menu, but had no clear on-screen confirmation it was active — absence of WiFi/BLE icons isn't the same as an explicit indicator. Implementation: 6-line conditional in the existing title render block. No new assets, no new overlay, no new render pass. --- src/graphics/SharedUIDisplay.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ec50654aef3..159d89af9ea 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -8,6 +8,7 @@ #include "graphics/draw/UIRenderer.h" #include "main.h" #include "meshtastic/config.pb.h" +#include "modules/AirplaneMode.h" #include "modules/ExternalNotificationModule.h" #include "power.h" #include @@ -121,7 +122,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } // === Screen Title === - const char *headerTitle = titleStr ? titleStr : ""; + // When airplane mode is active (all three radios disabled), overwrite the + // screen title with an unambiguous indicator. This replaces what would + // otherwise be "Nodes" / "Messages" / etc., but in airplane mode the per- + // screen title is less informative than the system-wide status anyway. + const char *headerTitle; + if (AirplaneMode::instance().isActive()) { + headerTitle = (currentResolution == ScreenResolution::UltraLow) ? "AIRPLANE" : "AIRPLANE MODE"; + } else { + headerTitle = titleStr ? titleStr : ""; + } const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); const int titleX = (SCREEN_WIDTH - titleWidth) / 2; UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); From 70abccea97e855375383f92ffc3fc5799b064eb5 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 00:09:24 -0400 Subject: [PATCH 04/11] fix(airplane-mode): replace overlong text indicator with 8x8 icon (#3) Previous commit 6bfefb3 replaced the screen title with literal text "AIRPLANE MODE" (or "AIRPLANE" on ultra-low-res). That text was wide enough on the 128-pixel OLED to collide with the battery percentage on the left and the mail/mute/time cluster on the right. Swap the text replacement for a compact 8x8 XBM airplane icon drawn at the title's center position. Icon width is similar to a single glyph of the normal title font, so no possible overlap with any of the surrounding header elements at any supported screen resolution. Icon design: stylized top-down airplane silhouette (nose + wings + fuselage + tail) in 8 bytes, PROGMEM-resident. --- src/graphics/SharedUIDisplay.cpp | 40 ++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 159d89af9ea..c6398d9580c 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -17,6 +17,22 @@ namespace graphics { +// Small airplane icon for the status bar when airplane mode is active. +// 8x8 XBM, LSB-first. Stylized top-down silhouette: nose, wings, fuselage, tail. +static constexpr uint8_t kAirplaneIconWidth = 8; +static constexpr uint8_t kAirplaneIconHeight = 8; +static const unsigned char kAirplaneIcon[] PROGMEM = { + 0b00011000, // ...##... nose + 0b00011000, // ...##... + 0b11111111, // ######## main wings + 0b11111111, // ######## + 0b00011000, // ...##... fuselage + 0b00011000, // ...##... + 0b00111100, // ..####.. tail wings + 0b00111100, // ..####.. +}; + + ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenwidth) { @@ -122,19 +138,23 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } // === Screen Title === - // When airplane mode is active (all three radios disabled), overwrite the - // screen title with an unambiguous indicator. This replaces what would - // otherwise be "Nodes" / "Messages" / etc., but in airplane mode the per- - // screen title is less informative than the system-wide status anyway. - const char *headerTitle; if (AirplaneMode::instance().isActive()) { - headerTitle = (currentResolution == ScreenResolution::UltraLow) ? "AIRPLANE" : "AIRPLANE MODE"; + // Draw a compact airplane icon centered in the title position. Icon is + // much narrower than "AIRPLANE MODE" text was, so it doesn't collide + // with the battery % on the left or the mail/mute/time cluster on the + // right. The per-screen title is suppressed while in airplane mode — + // the system-wide indicator takes priority over e.g. "Nodes" / "Home". + const int iconX = (screenW - kAirplaneIconWidth) / 2; + const int iconY = y + (highlightHeight - kAirplaneIconHeight) / 2; + // Inverted header background already set BLACK color on white bar; + // drawXbm respects the current color, so the icon renders correctly. + display->drawXbm(iconX, iconY, kAirplaneIconWidth, kAirplaneIconHeight, kAirplaneIcon); } else { - headerTitle = titleStr ? titleStr : ""; + const char *headerTitle = titleStr ? titleStr : ""; + const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); + const int titleX = (SCREEN_WIDTH - titleWidth) / 2; + UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } - const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); - const int titleX = (SCREEN_WIDTH - titleWidth) / 2; - UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); From de10b368711fea7022423988188cf2eb2c1f1b02 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 00:48:39 -0400 Subject: [PATCH 05/11] style(airplane-mode): simplify for upstream review (#3) Pre-review cleanup pass. Net -137 lines. - AirplaneMode: drop NVS persistence of pre-airplane radio state. Exit sets all three radios to enabled (the 99% expected outcome); users who had a radio manually disabled before entering can re-disable it after exit. Saves ~100 lines of Preferences boilerplate. - AirplaneMode.h: trim doc block, drop deleted copy/assign ctors, drop unused private helpers (enter/exitActive merged into toggle). - images.h: move the airplane icon into the neighbor-pattern location with airplane_width / airplane_height / airplane[] matching mail_width / mail[]. - SharedUIDisplay.cpp: drop inline bitmap + ASCII-art comments; use the shared icon from images.h. - WiFiAPClient.cpp / main-esp32.cpp: trim coex patch comments to 1-2 lines each. - MenuHandler.cpp: drop explanatory comment on always-available label. --- src/graphics/SharedUIDisplay.cpp | 28 +------- src/graphics/draw/MenuHandler.cpp | 2 - src/graphics/images.h | 6 ++ src/mesh/wifi/WiFiAPClient.cpp | 8 +-- src/modules/AirplaneMode.cpp | 113 ++---------------------------- src/modules/AirplaneMode.h | 49 ++----------- src/platform/esp32/main-esp32.cpp | 6 +- 7 files changed, 25 insertions(+), 187 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index c6398d9580c..a092d2d4be8 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -17,21 +17,6 @@ namespace graphics { -// Small airplane icon for the status bar when airplane mode is active. -// 8x8 XBM, LSB-first. Stylized top-down silhouette: nose, wings, fuselage, tail. -static constexpr uint8_t kAirplaneIconWidth = 8; -static constexpr uint8_t kAirplaneIconHeight = 8; -static const unsigned char kAirplaneIcon[] PROGMEM = { - 0b00011000, // ...##... nose - 0b00011000, // ...##... - 0b11111111, // ######## main wings - 0b11111111, // ######## - 0b00011000, // ...##... fuselage - 0b00011000, // ...##... - 0b00111100, // ..####.. tail wings - 0b00111100, // ..####.. -}; - ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenwidth) { @@ -139,16 +124,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // === Screen Title === if (AirplaneMode::instance().isActive()) { - // Draw a compact airplane icon centered in the title position. Icon is - // much narrower than "AIRPLANE MODE" text was, so it doesn't collide - // with the battery % on the left or the mail/mute/time cluster on the - // right. The per-screen title is suppressed while in airplane mode — - // the system-wide indicator takes priority over e.g. "Nodes" / "Home". - const int iconX = (screenW - kAirplaneIconWidth) / 2; - const int iconY = y + (highlightHeight - kAirplaneIconHeight) / 2; - // Inverted header background already set BLACK color on white bar; - // drawXbm respects the current color, so the icon renders correctly. - display->drawXbm(iconX, iconY, kAirplaneIconWidth, kAirplaneIconHeight, kAirplaneIcon); + const int iconX = (screenW - airplane_width) / 2; + const int iconY = y + (highlightHeight - airplane_height) / 2; + display->drawXbm(iconX, iconY, airplane_width, airplane_height, airplane); } else { const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 73efe671fc4..7dc1f1aaf48 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -2414,8 +2414,6 @@ void menuHandler::powerMenu() optionsArray[options] = "Shutdown"; optionsEnumArray[options++] = Shutdown; - // Airplane mode always-available — no build-flag gate. Label reflects - // current state so the user knows what Confirm will do. optionsArray[options] = AirplaneMode::instance().isActive() ? "Exit Airplane" : "Airplane Mode"; optionsEnumArray[options++] = Airplane; diff --git a/src/graphics/images.h b/src/graphics/images.h index 66fcbc79c5d..63ce217c80f 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -71,6 +71,12 @@ const unsigned char batteryBitmap_sidegaps_v[] PROGMEM = {0b10000010, 0b10000010 // Lightning Bolt const unsigned char lightning_bolt_v[] PROGMEM = {0b00000100, 0b00000110, 0b00011111, 0b00001100, 0b00000100}; +#define airplane_width 8 +#define airplane_height 8 +static const unsigned char airplane[] PROGMEM = { + 0b00011000, 0b00011000, 0b11111111, 0b11111111, 0b00011000, 0b00011000, 0b00111100, 0b00111100, +}; + #define mail_width 10 #define mail_height 7 static const unsigned char mail[] PROGMEM = { diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index d6045c766fc..a15e5312007 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -307,15 +307,9 @@ bool initWifi() WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); #ifdef MESHTASTIC_DUAL_RF - // Dual-RF coex mode: allow WiFi to release the 2.4 GHz radio between - // DTIM beacons so the hardware coex arbiter can schedule BLE - // advertising slots. Without these knobs, WiFi monopolizes the radio - // and NimBLE advertisements are never transmitted on ESP32-S3. + // Let WiFi release the radio between DTIM beacons so BLE gets coex slots. WiFi.setSleep(true); esp_wifi_set_ps(WIFI_PS_MIN_MODEM); - // Balance arbiter between WiFi and BLE. Default (ESP_COEX_PREFER_BALANCE - // on paper) is effectively WiFi-biased on many ESP-IDF versions — set - // explicitly so we know what we're getting. esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); #else WiFi.setSleep(false); diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index 535131ce0ef..5898c013a90 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -6,17 +6,6 @@ #include "configuration.h" #include "main.h" -#ifdef ARCH_ESP32 -#include -#endif - -// NVS key names — kept short to fit in NVS's 15-char key limit. -// Namespace is shared with main-esp32.cpp's "meshtastic" namespace. -static const char *kNvsNamespace = "meshtastic"; -static const char *kKeySavedWifi = "apm_saved_wifi"; // 14 chars -static const char *kKeySavedBt = "apm_saved_bt"; // 12 chars -static const char *kKeySavedLoraTx = "apm_saved_lora"; // 14 chars - AirplaneMode &AirplaneMode::instance() { static AirplaneMode singleton; @@ -25,59 +14,17 @@ AirplaneMode &AirplaneMode::instance() bool AirplaneMode::isActive() const { - // Airplane mode = all three radio flags disabled in the live config. - return (!config.network.wifi_enabled) && (!config.bluetooth.enabled) && (!config.lora.tx_enabled); + return !config.network.wifi_enabled && !config.bluetooth.enabled && !config.lora.tx_enabled; } void AirplaneMode::toggle() { - if (isActive()) - exitActive(); - else - enterActive(); -} - -void AirplaneMode::enterActive() -{ - LOG_WARN("AirplaneMode: entering — saving radio state and rebooting"); - - // Stash the current (pre-airplane) radio state into NVS so exit can restore exactly. - saveRadioStateToNvs(config.network.wifi_enabled, config.bluetooth.enabled, config.lora.tx_enabled); - - // Disable all three. No synchronous teardown here — the reboot handles it. - config.network.wifi_enabled = false; - config.bluetooth.enabled = false; - config.lora.tx_enabled = false; - - // Persist config so post-reboot boot brings up nothing. - if (nodeDB) - nodeDB->saveToDisk(SEGMENT_CONFIG); - - // Schedule clean reboot. 2s gives the UI banner time to render and any - // outstanding flush to complete. Matches the pattern used elsewhere (see - // menuHandler::rebootMenu). - rebootAtMsec = millis() + 2000; -} - -void AirplaneMode::exitActive() -{ - LOG_WARN("AirplaneMode: exiting — restoring radio state and rebooting"); - - bool savedWifi = true, savedBt = true, savedLoraTx = true; - bool hasSaved = loadSavedRadioStateFromNvs(savedWifi, savedBt, savedLoraTx); + bool turningOn = !isActive(); + LOG_WARN("AirplaneMode: %s", turningOn ? "entering" : "exiting"); - if (!hasSaved) { - // NVS had no saved state (e.g. factory reset while in airplane mode). - // Safe default: turn everything on so the user can re-reach the node. - LOG_WARN("AirplaneMode: no saved radio state in NVS; defaulting to all radios on"); - savedWifi = true; - savedBt = true; - savedLoraTx = true; - } - - config.network.wifi_enabled = savedWifi; - config.bluetooth.enabled = savedBt; - config.lora.tx_enabled = savedLoraTx; + config.network.wifi_enabled = !turningOn; + config.bluetooth.enabled = !turningOn; + config.lora.tx_enabled = !turningOn; if (nodeDB) nodeDB->saveToDisk(SEGMENT_CONFIG); @@ -85,52 +32,4 @@ void AirplaneMode::exitActive() rebootAtMsec = millis() + 2000; } -// ---- NVS helpers ------------------------------------------------------------- - -void AirplaneMode::saveRadioStateToNvs(bool wifi, bool bluetooth, bool loraTx) -{ -#ifdef ARCH_ESP32 - Preferences prefs; - if (!prefs.begin(kNvsNamespace, false)) { - LOG_ERROR("AirplaneMode: Preferences.begin(%s) failed — saved radio state WILL NOT persist", kNvsNamespace); - return; - } - prefs.putBool(kKeySavedWifi, wifi); - prefs.putBool(kKeySavedBt, bluetooth); - prefs.putBool(kKeySavedLoraTx, loraTx); - prefs.end(); - LOG_INFO("AirplaneMode: saved radio state to NVS (wifi=%d, bt=%d, loraTx=%d)", wifi, bluetooth, loraTx); -#else - (void)wifi; - (void)bluetooth; - (void)loraTx; #endif -} - -bool AirplaneMode::loadSavedRadioStateFromNvs(bool &outWifi, bool &outBluetooth, bool &outLoraTx) -{ -#ifdef ARCH_ESP32 - Preferences prefs; - if (!prefs.begin(kNvsNamespace, true)) { - LOG_ERROR("AirplaneMode: Preferences.begin(%s, ro) failed", kNvsNamespace); - return false; - } - // isKey() available on Preferences; use it to distinguish "never saved" from "saved false". - bool haveAll = prefs.isKey(kKeySavedWifi) && prefs.isKey(kKeySavedBt) && prefs.isKey(kKeySavedLoraTx); - if (haveAll) { - outWifi = prefs.getBool(kKeySavedWifi, true); - outBluetooth = prefs.getBool(kKeySavedBt, true); - outLoraTx = prefs.getBool(kKeySavedLoraTx, true); - LOG_INFO("AirplaneMode: loaded saved radio state from NVS (wifi=%d, bt=%d, loraTx=%d)", outWifi, outBluetooth, outLoraTx); - } - prefs.end(); - return haveAll; -#else - (void)outWifi; - (void)outBluetooth; - (void)outLoraTx; - return false; -#endif -} - -#endif // HAS_SCREEN || ARCH_ESP32 diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h index 3954223a983..907a5d1870f 100644 --- a/src/modules/AirplaneMode.h +++ b/src/modules/AirplaneMode.h @@ -6,55 +6,20 @@ #include -/** - * AirplaneMode — menu-driven RF kill-switch for flight-safe Meshtastic operation. - * - * Design rationale (supersedes the earlier button-gesture version): - * The previous design hooked into ButtonThread with an 8-12s hold gesture. - * That tore down WiFi/BLE/LoRa synchronously from the button-event context, - * which panicked the ESP32 when applied to a device with live TCP / BLE - * sessions. The menu-driven design instead: - * 1. Persists the "airplane off" radio flags to NVS via nodeDB->saveToDisk() - * 2. Schedules a clean reboot via rebootAtMsec - * 3. Lets the normal boot path bring (or not bring) radios up based on the - * saved config. - * This is symmetric on enter and exit and avoids live-teardown crashes. - * - * BLE-return on exit *requires* a reboot on ESP32 anyway — NimBLEDevice::deinit() - * is one-way — so the extra reboot on enter is not a meaningful cost. - * - * Persistence model: - * We save the "pre-airplane" radio state to the ESP32 Preferences NVS namespace - * so exit can restore faithfully. No new protobuf fields are introduced. The - * in-config airplane-mode indicator is simply "all three radio flags are false." - */ +// Menu-driven RF kill-switch. Disables LoRa TX, WiFi, and BLE by persisting +// config flags to false and rebooting. Exit restores all three. ESP32 NimBLE +// deinit is one-way so a reboot is required to restore BLE anyway; entering +// also reboots for symmetry and to avoid tearing down live connections. class AirplaneMode { public: static AirplaneMode &instance(); - // True when all three radio flags (lora.tx_enabled, network.wifi_enabled, - // bluetooth.enabled) are currently false in the live config. No separate - // state machine — the config state is the source of truth. + // True when all three radio flags are false in the live config. bool isActive() const; - // Menu entry point. If currently active, restores saved radio state and - // reboots. If currently inactive, saves current radio state and disables - // all three radios, then reboots. Caller is responsible for showing any - // transient UI banner before calling. + // Toggle enter/exit. Persists config, schedules reboot. void toggle(); - - private: - AirplaneMode() = default; - AirplaneMode(const AirplaneMode &) = delete; - AirplaneMode &operator=(const AirplaneMode &) = delete; - - void enterActive(); - void exitActive(); - - // NVS helpers — use the "meshtastic" Preferences namespace, matching main-esp32.cpp - void saveRadioStateToNvs(bool wifi, bool bluetooth, bool loraTx); - bool loadSavedRadioStateFromNvs(bool &outWifi, bool &outBluetooth, bool &outLoraTx); }; -#endif // HAS_SCREEN || ARCH_ESP32 +#endif diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 33443028ea6..f51921f088a 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,10 +32,8 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -// MESHTASTIC_DUAL_RF: let WiFi and BLE both run. The ESP32-S3 hardware coex -// arbiter supports this fine; the historical XOR gate here predates NimBLE + -// ESP-IDF 5.x coex and was a conservative workaround, not a necessity. -// Without this flag, behavior is unchanged from upstream. +// MESHTASTIC_DUAL_RF: bypass the WiFi-XOR-BLE gate so both can run on the +// hardware coex arbiter. #if defined(MESHTASTIC_DUAL_RF) if (config.bluetooth.enabled == true) #elif defined(USE_WS5500) From 44b44cd9f848d9afaa41b85ffe53efb4fc49badf Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:02:13 -0400 Subject: [PATCH 06/11] =?UTF-8?q?style:=20address=20Gemini=20review=20?= =?UTF-8?q?=E2=80=94=20rename=20icon=20+=20document=20isActive=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - images.h / SharedUIDisplay.cpp: rename airplane -> icon_airplane to match the newer icon_* convention (icon_mail, icon_compass, icon_system, icon_radio, etc.) and avoid a generic global name. - AirplaneMode.cpp: document isActive() derivation. State is deliberately inferred from live config rather than persisted, so any path that disables all three radios (menu, CLI, phone app) surfaces as airplane mode, and exit via the menu re-enables all three. Gemini flagged optionsArray[4] as a buffer overflow (BLOCKER); verified at MenuHandler.cpp:2407 it is already sized [5] — false positive, no action needed. Left the config.bluetooth.enabled == true comparisons alone for local consistency with the neighboring USE_WS5500 and HAS_WIFI branches in the same #if/#elif ladder. --- src/graphics/SharedUIDisplay.cpp | 6 +++--- src/graphics/images.h | 6 +++--- src/modules/AirplaneMode.cpp | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index a092d2d4be8..8bcefa37022 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -124,9 +124,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // === Screen Title === if (AirplaneMode::instance().isActive()) { - const int iconX = (screenW - airplane_width) / 2; - const int iconY = y + (highlightHeight - airplane_height) / 2; - display->drawXbm(iconX, iconY, airplane_width, airplane_height, airplane); + const int iconX = (screenW - icon_airplane_width) / 2; + const int iconY = y + (highlightHeight - icon_airplane_height) / 2; + display->drawXbm(iconX, iconY, icon_airplane_width, icon_airplane_height, icon_airplane); } else { const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); diff --git a/src/graphics/images.h b/src/graphics/images.h index 63ce217c80f..98a54aa2a63 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -71,9 +71,9 @@ const unsigned char batteryBitmap_sidegaps_v[] PROGMEM = {0b10000010, 0b10000010 // Lightning Bolt const unsigned char lightning_bolt_v[] PROGMEM = {0b00000100, 0b00000110, 0b00011111, 0b00001100, 0b00000100}; -#define airplane_width 8 -#define airplane_height 8 -static const unsigned char airplane[] PROGMEM = { +#define icon_airplane_width 8 +#define icon_airplane_height 8 +static const unsigned char icon_airplane[] PROGMEM = { 0b00011000, 0b00011000, 0b11111111, 0b11111111, 0b00011000, 0b00011000, 0b00111100, 0b00111100, }; diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index 5898c013a90..c61c847efc5 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -14,6 +14,9 @@ AirplaneMode &AirplaneMode::instance() bool AirplaneMode::isActive() const { + // Derived from live config: if all three radios are disabled, we consider + // the device in airplane mode regardless of how it got there (menu, CLI, + // or phone app). Exiting via the menu re-enables all three. return !config.network.wifi_enabled && !config.bluetooth.enabled && !config.lora.tx_enabled; } From e51c2cff6757eac8e231bdea954b35ab240a493a Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:15:48 -0400 Subject: [PATCH 07/11] feat(airplane-mode): restore pre-airplane radio state on exit (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Gemini review cycle 2 [MAJOR]: previous version unconditionally enabled WiFi, BLE, and LoRa TX on exit, overwriting any manual pre-airplane configuration. A user who had WiFi disabled for power reasons, or BLE disabled for privacy, would find them enabled after airplane-mode exit — a destructive behavior upstream would reject. Persist the pre-airplane state of config.network.wifi_enabled, config.bluetooth.enabled, and config.lora.tx_enabled to three ESP32 Preferences NVS keys on enter. On exit, load from NVS and apply; if NVS has no saved state (e.g. first run, or NVS wiped while active), fall back to all-radios-on with a LOG_WARN. Also tightens the header guard from #if HAS_SCREEN || ARCH_ESP32 to #if HAS_SCREEN (addresses Gemini's [QUESTION] — the feature is menu-driven only, the ARCH_ESP32 branch predates the simplified design and no longer adds value). Implementation keeps the NVS path behind #ifdef ARCH_ESP32 so non- ESP32 screen variants (nRF52 with screen, RP2040 with screen) still compile; they just fall back to the default-all-on exit behavior since Preferences is ESP32-only. --- src/modules/AirplaneMode.cpp | 66 ++++++++++++++++++++++++++++++++---- src/modules/AirplaneMode.h | 2 +- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index c61c847efc5..d8342ffd0ba 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -1,11 +1,21 @@ #include "AirplaneMode.h" -#if HAS_SCREEN || defined(ARCH_ESP32) +#if HAS_SCREEN #include "NodeDB.h" #include "configuration.h" #include "main.h" +#ifdef ARCH_ESP32 +#include +#endif + +// Shared with main-esp32.cpp's reboot counter namespace. +static constexpr const char *kNvsNs = "meshtastic"; +static constexpr const char *kKeyWifi = "apm_sv_wifi"; +static constexpr const char *kKeyBt = "apm_sv_bt"; +static constexpr const char *kKeyLora = "apm_sv_lora"; + AirplaneMode &AirplaneMode::instance() { static AirplaneMode singleton; @@ -16,18 +26,62 @@ bool AirplaneMode::isActive() const { // Derived from live config: if all three radios are disabled, we consider // the device in airplane mode regardless of how it got there (menu, CLI, - // or phone app). Exiting via the menu re-enables all three. + // or phone app). return !config.network.wifi_enabled && !config.bluetooth.enabled && !config.lora.tx_enabled; } +#ifdef ARCH_ESP32 +static void savePreAirplaneState(bool wifi, bool bt, bool lora) +{ + Preferences prefs; + if (!prefs.begin(kNvsNs, false)) { + LOG_ERROR("AirplaneMode: Preferences.begin failed; saved state WILL NOT persist"); + return; + } + prefs.putBool(kKeyWifi, wifi); + prefs.putBool(kKeyBt, bt); + prefs.putBool(kKeyLora, lora); + prefs.end(); +} + +static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) +{ + Preferences prefs; + if (!prefs.begin(kNvsNs, true)) + return false; + bool have = prefs.isKey(kKeyWifi) && prefs.isKey(kKeyBt) && prefs.isKey(kKeyLora); + if (have) { + wifi = prefs.getBool(kKeyWifi, true); + bt = prefs.getBool(kKeyBt, true); + lora = prefs.getBool(kKeyLora, true); + } + prefs.end(); + return have; +} +#endif + void AirplaneMode::toggle() { bool turningOn = !isActive(); LOG_WARN("AirplaneMode: %s", turningOn ? "entering" : "exiting"); - config.network.wifi_enabled = !turningOn; - config.bluetooth.enabled = !turningOn; - config.lora.tx_enabled = !turningOn; + if (turningOn) { +#ifdef ARCH_ESP32 + savePreAirplaneState(config.network.wifi_enabled, config.bluetooth.enabled, config.lora.tx_enabled); +#endif + config.network.wifi_enabled = false; + config.bluetooth.enabled = false; + config.lora.tx_enabled = false; + } else { + bool wifi = true, bt = true, lora = true; +#ifdef ARCH_ESP32 + if (!loadPreAirplaneState(wifi, bt, lora)) + LOG_WARN("AirplaneMode: no saved pre-airplane state; defaulting all radios on"); +#endif + config.network.wifi_enabled = wifi; + config.bluetooth.enabled = bt; + config.lora.tx_enabled = lora; + } if (nodeDB) nodeDB->saveToDisk(SEGMENT_CONFIG); @@ -35,4 +89,4 @@ void AirplaneMode::toggle() rebootAtMsec = millis() + 2000; } -#endif +#endif // HAS_SCREEN diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h index 907a5d1870f..e2a5fe30b9c 100644 --- a/src/modules/AirplaneMode.h +++ b/src/modules/AirplaneMode.h @@ -2,7 +2,7 @@ #include "configuration.h" -#if HAS_SCREEN || defined(ARCH_ESP32) +#if HAS_SCREEN #include From 13e332cff0a86abe9fd72534622e77cfb77e3cc1 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:30:03 -0400 Subject: [PATCH 08/11] fix(airplane-mode): explicit NVS active flag + wider guard (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Gemini cycle 3 review. [BLOCKER] isActive() derived from live config introduces a race: if a radio is re-enabled externally (phone app, CLI) while airplane mode is active, isActive() flips false and the next toggle() treats the visit as ENTER rather than EXIT, overwriting the original pre-airplane saved state with a corrupted snapshot. Fix: store an explicit bool flag in NVS (apm_active) and consult it from isActive(); toggle() reads and writes it atomically with the save/restore of per-radio state. [MAJOR] The module was guarded by #if HAS_SCREEN which prevented headless builds from compiling the feature at all, blocking any future CLI or Admin API integration. The screen is only needed by callers (MenuHandler, SharedUIDisplay), not by the module itself. Move the guard to the call sites: the module now compiles on any platform, is fully functional on ARCH_ESP32 (Preferences NVS backend), and is a documented no-op elsewhere until an alternative persistence path is added. [MINOR] loadPreAirplaneState now LOG_ERRORs on Preferences::begin failure, matching the save-side pattern. Also added the same to readActiveFlag / writeActiveFlag. toggle() on non-ESP32 platforms logs a warning and returns — the module is callable but does nothing without NVS persistence available. Safe to compile everywhere; behaviorally inert where unsupported. --- src/modules/AirplaneMode.cpp | 57 ++++++++++++++++++++++++++---------- src/modules/AirplaneMode.h | 19 ++++++------ 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index d8342ffd0ba..44ba57e6c59 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -1,7 +1,5 @@ #include "AirplaneMode.h" -#if HAS_SCREEN - #include "NodeDB.h" #include "configuration.h" #include "main.h" @@ -12,6 +10,7 @@ // Shared with main-esp32.cpp's reboot counter namespace. static constexpr const char *kNvsNs = "meshtastic"; +static constexpr const char *kKeyActive = "apm_active"; static constexpr const char *kKeyWifi = "apm_sv_wifi"; static constexpr const char *kKeyBt = "apm_sv_bt"; static constexpr const char *kKeyLora = "apm_sv_lora"; @@ -22,20 +21,35 @@ AirplaneMode &AirplaneMode::instance() return singleton; } -bool AirplaneMode::isActive() const +#ifdef ARCH_ESP32 +static bool readActiveFlag() { - // Derived from live config: if all three radios are disabled, we consider - // the device in airplane mode regardless of how it got there (menu, CLI, - // or phone app). - return !config.network.wifi_enabled && !config.bluetooth.enabled && !config.lora.tx_enabled; + Preferences prefs; + if (!prefs.begin(kNvsNs, true)) { + LOG_ERROR("AirplaneMode: Preferences.begin(ro) failed; assuming inactive"); + return false; + } + bool active = prefs.isKey(kKeyActive) ? prefs.getBool(kKeyActive, false) : false; + prefs.end(); + return active; +} + +static void writeActiveFlag(bool active) +{ + Preferences prefs; + if (!prefs.begin(kNvsNs, false)) { + LOG_ERROR("AirplaneMode: Preferences.begin(rw) failed; active flag WILL NOT persist"); + return; + } + prefs.putBool(kKeyActive, active); + prefs.end(); } -#ifdef ARCH_ESP32 static void savePreAirplaneState(bool wifi, bool bt, bool lora) { Preferences prefs; if (!prefs.begin(kNvsNs, false)) { - LOG_ERROR("AirplaneMode: Preferences.begin failed; saved state WILL NOT persist"); + LOG_ERROR("AirplaneMode: Preferences.begin(rw) failed; saved radio state WILL NOT persist"); return; } prefs.putBool(kKeyWifi, wifi); @@ -47,8 +61,10 @@ static void savePreAirplaneState(bool wifi, bool bt, bool lora) static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) { Preferences prefs; - if (!prefs.begin(kNvsNs, true)) + if (!prefs.begin(kNvsNs, true)) { + LOG_ERROR("AirplaneMode: Preferences.begin(ro) failed; cannot read saved radio state"); return false; + } bool have = prefs.isKey(kKeyWifi) && prefs.isKey(kKeyBt) && prefs.isKey(kKeyLora); if (have) { wifi = prefs.getBool(kKeyWifi, true); @@ -58,35 +74,44 @@ static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) prefs.end(); return have; } +#endif // ARCH_ESP32 + +bool AirplaneMode::isActive() const +{ +#ifdef ARCH_ESP32 + return readActiveFlag(); +#else + return false; #endif +} void AirplaneMode::toggle() { +#ifdef ARCH_ESP32 bool turningOn = !isActive(); LOG_WARN("AirplaneMode: %s", turningOn ? "entering" : "exiting"); if (turningOn) { -#ifdef ARCH_ESP32 savePreAirplaneState(config.network.wifi_enabled, config.bluetooth.enabled, config.lora.tx_enabled); -#endif config.network.wifi_enabled = false; config.bluetooth.enabled = false; config.lora.tx_enabled = false; + writeActiveFlag(true); } else { bool wifi = true, bt = true, lora = true; -#ifdef ARCH_ESP32 if (!loadPreAirplaneState(wifi, bt, lora)) LOG_WARN("AirplaneMode: no saved pre-airplane state; defaulting all radios on"); -#endif config.network.wifi_enabled = wifi; config.bluetooth.enabled = bt; config.lora.tx_enabled = lora; + writeActiveFlag(false); } if (nodeDB) nodeDB->saveToDisk(SEGMENT_CONFIG); rebootAtMsec = millis() + 2000; +#else + LOG_WARN("AirplaneMode: toggle unsupported on this platform (no NVS backend)"); +#endif } - -#endif // HAS_SCREEN diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h index e2a5fe30b9c..2996e3ff4fe 100644 --- a/src/modules/AirplaneMode.h +++ b/src/modules/AirplaneMode.h @@ -2,24 +2,23 @@ #include "configuration.h" -#if HAS_SCREEN - #include -// Menu-driven RF kill-switch. Disables LoRa TX, WiFi, and BLE by persisting -// config flags to false and rebooting. Exit restores all three. ESP32 NimBLE -// deinit is one-way so a reboot is required to restore BLE anyway; entering -// also reboots for symmetry and to avoid tearing down live connections. +// RF kill-switch. Disables LoRa TX, WiFi, and BLE by persisting config flags +// and rebooting. Exit restores the pre-airplane state of each radio. +// +// State of record is an explicit NVS flag (not derived from live config), so +// external config mutations (e.g. phone app re-enabling a radio mid-flight) +// don't confuse the enter/exit transition. Today only the ESP32 backend +// persists state; on other platforms toggle() is a no-op. class AirplaneMode { public: static AirplaneMode &instance(); - // True when all three radio flags are false in the live config. + // True when airplane mode is active per the persisted flag. bool isActive() const; - // Toggle enter/exit. Persists config, schedules reboot. + // Toggle enter/exit. Persists config + state flag, schedules reboot. void toggle(); }; - -#endif From 5c00ee0e3a7632be3f7f15358aa02299730755b6 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:33:02 -0400 Subject: [PATCH 09/11] perf(airplane-mode): cache isActive() result; rename NVS keys (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 4 Gemini review fixes. [BLOCKER] isActive() is called every frame from drawCommonHeader; the previous implementation did a blocking NVS read on each call. Added module-level static cache (s_cachedActive, s_cachedActiveLoaded) populated lazily on first access and kept in sync by toggle(). Hot path is now a pair of bool loads. [MINOR] Renamed NVS keys apm_sv_* to apm_save_* for readability. All keys remain within the 15-char NVS limit (apm_save_wifi = 13, apm_save_lora = 13, apm_save_bt = 11). MAJOR (commit shape: split into two PRs) is acknowledged and is the plan for upstream submission — this fork PR is a unified staging review. Upstream work is tracked as two separate issues/PRs (one for coex, one for airplane mode) so each gets independent maintainer review. --- src/modules/AirplaneMode.cpp | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index 44ba57e6c59..867cdf21fb3 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -11,9 +11,15 @@ // Shared with main-esp32.cpp's reboot counter namespace. static constexpr const char *kNvsNs = "meshtastic"; static constexpr const char *kKeyActive = "apm_active"; -static constexpr const char *kKeyWifi = "apm_sv_wifi"; -static constexpr const char *kKeyBt = "apm_sv_bt"; -static constexpr const char *kKeyLora = "apm_sv_lora"; +static constexpr const char *kKeySaveWifi = "apm_save_wifi"; +static constexpr const char *kKeySaveBt = "apm_save_bt"; +static constexpr const char *kKeySaveLora = "apm_save_lora"; + +// isActive() is called every frame from drawCommonHeader; cache the flag to +// avoid a blocking NVS read on each render. Initialized lazily on first +// access, invalidated/rewritten by toggle(). +static bool s_cachedActive = false; +static bool s_cachedActiveLoaded = false; AirplaneMode &AirplaneMode::instance() { @@ -52,9 +58,9 @@ static void savePreAirplaneState(bool wifi, bool bt, bool lora) LOG_ERROR("AirplaneMode: Preferences.begin(rw) failed; saved radio state WILL NOT persist"); return; } - prefs.putBool(kKeyWifi, wifi); - prefs.putBool(kKeyBt, bt); - prefs.putBool(kKeyLora, lora); + prefs.putBool(kKeySaveWifi, wifi); + prefs.putBool(kKeySaveBt, bt); + prefs.putBool(kKeySaveLora, lora); prefs.end(); } @@ -65,11 +71,11 @@ static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) LOG_ERROR("AirplaneMode: Preferences.begin(ro) failed; cannot read saved radio state"); return false; } - bool have = prefs.isKey(kKeyWifi) && prefs.isKey(kKeyBt) && prefs.isKey(kKeyLora); + bool have = prefs.isKey(kKeySaveWifi) && prefs.isKey(kKeySaveBt) && prefs.isKey(kKeySaveLora); if (have) { - wifi = prefs.getBool(kKeyWifi, true); - bt = prefs.getBool(kKeyBt, true); - lora = prefs.getBool(kKeyLora, true); + wifi = prefs.getBool(kKeySaveWifi, true); + bt = prefs.getBool(kKeySaveBt, true); + lora = prefs.getBool(kKeySaveLora, true); } prefs.end(); return have; @@ -79,7 +85,11 @@ static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) bool AirplaneMode::isActive() const { #ifdef ARCH_ESP32 - return readActiveFlag(); + if (!s_cachedActiveLoaded) { + s_cachedActive = readActiveFlag(); + s_cachedActiveLoaded = true; + } + return s_cachedActive; #else return false; #endif @@ -97,6 +107,7 @@ void AirplaneMode::toggle() config.bluetooth.enabled = false; config.lora.tx_enabled = false; writeActiveFlag(true); + s_cachedActive = true; } else { bool wifi = true, bt = true, lora = true; if (!loadPreAirplaneState(wifi, bt, lora)) @@ -105,7 +116,9 @@ void AirplaneMode::toggle() config.bluetooth.enabled = bt; config.lora.tx_enabled = lora; writeActiveFlag(false); + s_cachedActive = false; } + s_cachedActiveLoaded = true; if (nodeDB) nodeDB->saveToDisk(SEGMENT_CONFIG); From 32051adaf612deabe9d0fc7d340995ad56b249b3 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:36:02 -0400 Subject: [PATCH 10/11] refactor(airplane-mode): cache flags as member variables (#3) Cycle 5 Gemini [MINOR]: moved the isActive() cache from file-static variables to mutable private members of the AirplaneMode singleton for proper encapsulation. mutable because isActive() is const. No behavior change; purely hygiene. --- src/modules/AirplaneMode.cpp | 20 +++++++------------- src/modules/AirplaneMode.h | 7 +++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp index 867cdf21fb3..35d67b0ab64 100644 --- a/src/modules/AirplaneMode.cpp +++ b/src/modules/AirplaneMode.cpp @@ -15,12 +15,6 @@ static constexpr const char *kKeySaveWifi = "apm_save_wifi"; static constexpr const char *kKeySaveBt = "apm_save_bt"; static constexpr const char *kKeySaveLora = "apm_save_lora"; -// isActive() is called every frame from drawCommonHeader; cache the flag to -// avoid a blocking NVS read on each render. Initialized lazily on first -// access, invalidated/rewritten by toggle(). -static bool s_cachedActive = false; -static bool s_cachedActiveLoaded = false; - AirplaneMode &AirplaneMode::instance() { static AirplaneMode singleton; @@ -85,11 +79,11 @@ static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) bool AirplaneMode::isActive() const { #ifdef ARCH_ESP32 - if (!s_cachedActiveLoaded) { - s_cachedActive = readActiveFlag(); - s_cachedActiveLoaded = true; + if (!cachedActiveLoaded) { + cachedActive = readActiveFlag(); + cachedActiveLoaded = true; } - return s_cachedActive; + return cachedActive; #else return false; #endif @@ -107,7 +101,7 @@ void AirplaneMode::toggle() config.bluetooth.enabled = false; config.lora.tx_enabled = false; writeActiveFlag(true); - s_cachedActive = true; + cachedActive = true; } else { bool wifi = true, bt = true, lora = true; if (!loadPreAirplaneState(wifi, bt, lora)) @@ -116,9 +110,9 @@ void AirplaneMode::toggle() config.bluetooth.enabled = bt; config.lora.tx_enabled = lora; writeActiveFlag(false); - s_cachedActive = false; + cachedActive = false; } - s_cachedActiveLoaded = true; + cachedActiveLoaded = true; if (nodeDB) nodeDB->saveToDisk(SEGMENT_CONFIG); diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h index 2996e3ff4fe..f75f3e4c299 100644 --- a/src/modules/AirplaneMode.h +++ b/src/modules/AirplaneMode.h @@ -21,4 +21,11 @@ class AirplaneMode // Toggle enter/exit. Persists config + state flag, schedules reboot. void toggle(); + + private: + // Cached active flag to avoid a blocking NVS read per isActive() call + // (called every frame from drawCommonHeader). Populated lazily; kept in + // sync by toggle(). mutable so isActive() can remain const. + mutable bool cachedActive = false; + mutable bool cachedActiveLoaded = false; }; From 38c90b308d3350a046e28ec0363620fb76718b4f Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Apr 2026 02:40:06 -0400 Subject: [PATCH 11/11] refactor(coex): address cycle 6 Gemini feedback (#3) [MAJOR] platformio.ini: heltec-v4-coex now extends env:heltec-v4 instead of duplicating all pin defines and display flags. Cuts ~20 lines of duplication; adds only the COEX flag and display name override. Verifies the upstream-preferred pattern works with PlatformIO's env-to-env inheritance. [MINOR] Rename MESHTASTIC_DUAL_RF to MESHTASTIC_WIFI_BLE_COEX for clarity. Flag name now describes the specific behavior (WiFi and BLE running simultaneously) rather than the generic "dual RF" phrasing. Touches main-esp32.cpp, WiFiAPClient.cpp, platformio.ini. [MINOR] SharedUIDisplay.cpp: use local screenW parameter instead of SCREEN_WIDTH macro for title centering, matching the neighbor pattern in the same function. --- src/graphics/SharedUIDisplay.cpp | 2 +- src/mesh/wifi/WiFiAPClient.cpp | 4 +-- src/platform/esp32/main-esp32.cpp | 4 +-- variants/esp32s3/heltec_v4/platformio.ini | 32 +++++------------------ 4 files changed, 11 insertions(+), 31 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 8bcefa37022..976a29e01e9 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -130,7 +130,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); - const int titleX = (SCREEN_WIDTH - titleWidth) / 2; + const int titleX = (screenW - titleWidth) / 2; UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } } diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index a15e5312007..f7e4d3cb5fb 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -22,7 +22,7 @@ #endif #include #include -#ifdef MESHTASTIC_DUAL_RF +#ifdef MESHTASTIC_WIFI_BLE_COEX #include #endif static void WiFiEvent(WiFiEvent_t event); @@ -306,7 +306,7 @@ bool initWifi() #ifdef ARCH_ESP32 WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); -#ifdef MESHTASTIC_DUAL_RF +#ifdef MESHTASTIC_WIFI_BLE_COEX // Let WiFi release the radio between DTIM beacons so BLE gets coex slots. WiFi.setSleep(true); esp_wifi_set_ps(WIFI_PS_MIN_MODEM); diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index f51921f088a..8fe9d009c0d 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,9 +32,9 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -// MESHTASTIC_DUAL_RF: bypass the WiFi-XOR-BLE gate so both can run on the +// MESHTASTIC_WIFI_BLE_COEX: bypass the WiFi-XOR-BLE gate so both can run on the // hardware coex arbiter. -#if defined(MESHTASTIC_DUAL_RF) +#if defined(MESHTASTIC_WIFI_BLE_COEX) if (config.bluetooth.enabled == true) #elif defined(USE_WS5500) if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false)) diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index fe03b0d94e3..1b6117b97a8 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -36,35 +36,15 @@ build_flags = -D I2C_SCL1=3 ; heltec-v4-coex: same hardware profile as heltec-v4 (OLED) with the -; MESHTASTIC_DUAL_RF flag enabled. This bypasses the historical WiFi-XOR-BLE -; exclusion in setBluetoothEnable() (src/platform/esp32/main-esp32.cpp) and -; tunes WiFi power-save + ESP-IDF coex preference so BLE advertising is not -; starved when WiFi is enabled. Opt-in per build. +; MESHTASTIC_WIFI_BLE_COEX flag enabled. Inherits all pin defines and +; display flags from the heltec-v4 env; adds only the coex build flag +; and a distinguishing display name. [env:heltec-v4-coex] -custom_meshtastic_hw_model = 110 -custom_meshtastic_hw_model_slug = HELTEC_V4 -custom_meshtastic_architecture = esp32-s3 -custom_meshtastic_actively_supported = true -custom_meshtastic_support_level = 1 +extends = env:heltec-v4 custom_meshtastic_display_name = Heltec V4 (WiFi+BLE coex) -custom_meshtastic_images = heltec_v4.svg -custom_meshtastic_tags = Heltec -custom_meshtastic_requires_dfu = true -custom_meshtastic_partition_scheme = 16MB -custom_meshtastic_has_mui = true - -extends = heltec_v4_base build_flags = - ${heltec_v4_base.build_flags} - -D HELTEC_V4_OLED - -D USE_SSD1306 - -D LED_POWER=35 - -D RESET_OLED=21 - -D I2C_SDA=17 - -D I2C_SCL=18 - -D I2C_SDA1=4 - -D I2C_SCL1=3 - -D MESHTASTIC_DUAL_RF=1 + ${env:heltec-v4.build_flags} + -D MESHTASTIC_WIFI_BLE_COEX=1 [env:heltec-v4-tft] custom_meshtastic_hw_model = 110