diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ec50654aef3..976a29e01e9 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 @@ -16,6 +17,7 @@ namespace graphics { + ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenwidth) { @@ -121,10 +123,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } // === Screen Title === - 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); + if (AirplaneMode::instance().isActive()) { + 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); + const int titleX = (screenW - titleWidth) / 2; + UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); + } } display->setTextAlignment(TEXT_ALIGN_LEFT); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index e92ba48394e..7dc1f1aaf48 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,9 @@ void menuHandler::powerMenu() optionsArray[options] = "Shutdown"; optionsEnumArray[options++] = Shutdown; + 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 +2437,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 +2771,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/graphics/images.h b/src/graphics/images.h index 66fcbc79c5d..98a54aa2a63 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 icon_airplane_width 8 +#define icon_airplane_height 8 +static const unsigned char icon_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 5d05c7fc6df..f7e4d3cb5fb 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -22,6 +22,9 @@ #endif #include #include +#ifdef MESHTASTIC_WIFI_BLE_COEX +#include +#endif static void WiFiEvent(WiFiEvent_t event); #elif defined(ARCH_RP2040) #include @@ -303,10 +306,17 @@ bool initWifi() #ifdef ARCH_ESP32 WiFi.onEvent(WiFiEvent); WiFi.setAutoReconnect(true); +#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); + 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/modules/AirplaneMode.cpp b/src/modules/AirplaneMode.cpp new file mode 100644 index 00000000000..35d67b0ab64 --- /dev/null +++ b/src/modules/AirplaneMode.cpp @@ -0,0 +1,124 @@ +#include "AirplaneMode.h" + +#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 *kKeyActive = "apm_active"; +static constexpr const char *kKeySaveWifi = "apm_save_wifi"; +static constexpr const char *kKeySaveBt = "apm_save_bt"; +static constexpr const char *kKeySaveLora = "apm_save_lora"; + +AirplaneMode &AirplaneMode::instance() +{ + static AirplaneMode singleton; + return singleton; +} + +#ifdef ARCH_ESP32 +static bool readActiveFlag() +{ + 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(); +} + +static void savePreAirplaneState(bool wifi, bool bt, bool lora) +{ + Preferences prefs; + if (!prefs.begin(kNvsNs, false)) { + LOG_ERROR("AirplaneMode: Preferences.begin(rw) failed; saved radio state WILL NOT persist"); + return; + } + prefs.putBool(kKeySaveWifi, wifi); + prefs.putBool(kKeySaveBt, bt); + prefs.putBool(kKeySaveLora, lora); + prefs.end(); +} + +static bool loadPreAirplaneState(bool &wifi, bool &bt, bool &lora) +{ + Preferences prefs; + if (!prefs.begin(kNvsNs, true)) { + LOG_ERROR("AirplaneMode: Preferences.begin(ro) failed; cannot read saved radio state"); + return false; + } + bool have = prefs.isKey(kKeySaveWifi) && prefs.isKey(kKeySaveBt) && prefs.isKey(kKeySaveLora); + if (have) { + wifi = prefs.getBool(kKeySaveWifi, true); + bt = prefs.getBool(kKeySaveBt, true); + lora = prefs.getBool(kKeySaveLora, true); + } + prefs.end(); + return have; +} +#endif // ARCH_ESP32 + +bool AirplaneMode::isActive() const +{ +#ifdef ARCH_ESP32 + if (!cachedActiveLoaded) { + cachedActive = readActiveFlag(); + cachedActiveLoaded = true; + } + return cachedActive; +#else + return false; +#endif +} + +void AirplaneMode::toggle() +{ +#ifdef ARCH_ESP32 + bool turningOn = !isActive(); + LOG_WARN("AirplaneMode: %s", turningOn ? "entering" : "exiting"); + + if (turningOn) { + savePreAirplaneState(config.network.wifi_enabled, config.bluetooth.enabled, config.lora.tx_enabled); + config.network.wifi_enabled = false; + config.bluetooth.enabled = false; + config.lora.tx_enabled = false; + writeActiveFlag(true); + cachedActive = true; + } else { + bool wifi = true, bt = true, lora = true; + if (!loadPreAirplaneState(wifi, bt, lora)) + LOG_WARN("AirplaneMode: no saved pre-airplane state; defaulting all radios on"); + config.network.wifi_enabled = wifi; + config.bluetooth.enabled = bt; + config.lora.tx_enabled = lora; + writeActiveFlag(false); + cachedActive = false; + } + cachedActiveLoaded = true; + + if (nodeDB) + nodeDB->saveToDisk(SEGMENT_CONFIG); + + rebootAtMsec = millis() + 2000; +#else + LOG_WARN("AirplaneMode: toggle unsupported on this platform (no NVS backend)"); +#endif +} diff --git a/src/modules/AirplaneMode.h b/src/modules/AirplaneMode.h new file mode 100644 index 00000000000..f75f3e4c299 --- /dev/null +++ b/src/modules/AirplaneMode.h @@ -0,0 +1,31 @@ +#pragma once + +#include "configuration.h" + +#include + +// 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 airplane mode is active per the persisted flag. + bool isActive() const; + + // 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; +}; diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25cb30e962e..8fe9d009c0d 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -32,7 +32,11 @@ void variant_shutdown() {} #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { -#ifdef USE_WS5500 +// MESHTASTIC_WIFI_BLE_COEX: bypass the WiFi-XOR-BLE gate so both can run on the +// hardware coex arbiter. +#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)) #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..1b6117b97a8 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -35,6 +35,17 @@ build_flags = -D I2C_SDA1=4 -D I2C_SCL1=3 +; heltec-v4-coex: same hardware profile as heltec-v4 (OLED) with the +; 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] +extends = env:heltec-v4 +custom_meshtastic_display_name = Heltec V4 (WiFi+BLE coex) +build_flags = + ${env:heltec-v4.build_flags} + -D MESHTASTIC_WIFI_BLE_COEX=1 + [env:heltec-v4-tft] custom_meshtastic_hw_model = 110 custom_meshtastic_hw_model_slug = HELTEC_V4