From 968ca3363bc38f53cb83069456e4fc3f55fc29da Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 10 Aug 2026 17:10:18 -0700 Subject: [PATCH] Add runtime FEM control and fix full Companion boot --- build.sh | 28 ++++++++-- docs/companion_radio_full.md | 16 +++++- docs/terminal_chat_cli.md | 10 ++++ examples/companion_radio/MyMesh.cpp | 56 +++++++++++++------ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/main.cpp | 11 +++- src/helpers/WiFiPowerSave.h | 18 ++++++ src/helpers/bridges/MQTTBridge.cpp | 10 +++- src/helpers/esp32/WebConfigServer.cpp | 30 +++++++++- .../test_cli_command_utils.cpp | 10 ++++ 10 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 src/helpers/WiFiPowerSave.h diff --git a/build.sh b/build.sh index 49ba38ad..b7f7355a 100755 --- a/build.sh +++ b/build.sh @@ -87,8 +87,8 @@ Commands: build-full-esp32-firmwares: Build only feature-complete ESP32 MQTT profiles with up to 254 neighbors, LoRa OTA, and expanded dual-OTA partitions. build-full-esp32-logging-firmwares: Build only feature-complete ESP32 profiles with up to 254 neighbors, logging, MQTT disabled, LoRa OTA, and expanded dual-OTA partitions. build-matching-firmwares : Build all firmwares for build targets containing the string given for . - build-companion-firmwares: Build all companion firmwares for all build targets. - build-full-companion-firmwares: Build all full Companion firmwares for supported ESP32 and nRF52 targets. + build-companion-firmwares: Build canonical companion firmwares; legacy _femoff targets remain available as direct builds. + build-full-companion-firmwares: Build canonical full Companion firmwares for supported ESP32 and nRF52 targets. build-repeater-firmwares: Build all repeater firmwares with 254 neighbors, except DRAM-limited targets that retain 50. build-room-server-firmwares: Build all chat room server firmwares for all build targets. build-sensor-firmwares: Build all sensor firmwares for all build targets. @@ -474,12 +474,12 @@ prompt_for_build_mode() { "Build all firmwares" "Build all firmwares in 5 profiles (standard, logging, MQTT, full ESP32 MQTT, full ESP32 logging without MQTT)" "Build all repeater firmwares" - "Build all companion firmwares" + "Build canonical companion firmwares (FEM RX gain is runtime configurable)" "Build all chat room server firmwares" "Build all sensor firmwares" "Build only FULL ESP32 MQTT firmwares (all features, MQTT, and LoRa OTA)" "Build only FULL ESP32 logging firmwares (all features, logging, no MQTT, and LoRa OTA)" - "Build all full Companion firmwares (all available transports and host-backed LoRa OTA)" + "Build canonical full Companion firmwares (runtime FEM control and host-backed LoRa OTA)" ) echo "No command provided. Select a build action:" @@ -2695,8 +2695,23 @@ resolve_all_firmwares() { get_supported_pio_envs } +is_legacy_companion_femoff_target() { + case "${1,,}" in + *companion_radio_*_femoff) + return 0 + ;; + esac + return 1 +} + resolve_companion_firmwares() { - get_pio_envs_for_variant_role companion + local env_name + + while IFS= read -r env_name; do + if ! is_legacy_companion_femoff_target "$env_name"; then + printf '%s\n' "$env_name" + fi + done < <(get_pio_envs_for_variant_role companion) } resolve_full_companion_firmwares() { @@ -2705,6 +2720,9 @@ resolve_full_companion_firmwares() { for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do if is_supported_build_env "$env_name" \ && is_companion_radio_full_target "$env_name"; then + if is_legacy_companion_femoff_target "$env_name"; then + continue + fi printf '%s\n' "$env_name" fi done diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 0dc54125..392132e3 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -39,7 +39,7 @@ bash build.sh build-firmware RAK_4631_companion_radio_full \ --firmware-version v1.17.0 ``` -To build every available full Companion target, select the corresponding +To build every canonical full Companion target, select the corresponding interactive menu item or run: ```bash @@ -47,6 +47,20 @@ bash build.sh build-full-companion-firmwares \ --firmware-version v1.17.0 ``` +Heltec `_femon` Companion firmware can switch the external FEM receive gain at +runtime, so the Companion bulk-build commands omit the redundant legacy +`_femoff` targets. Those targets remain available through an explicit +`build-firmware` command for compatibility. In WebConfig, use the **FEM RX +boost** switch. From the USB terminal, use: + +```text +get radio.fem.rxgain +set radio.fem.rxgain off +set radio.fem.rxgain on +``` + +The selected state is applied immediately and retained after reboot. + Artifacts are written to `out/` by default. On 4 MB ESP32 boards, the full target uses a single 3 MB application diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 836df6d5..8b2e4f47 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -84,6 +84,16 @@ set af {air-time-factor} ``` Sets the transmit air-time-factor. Deprecated - use `set dutycycle` instead. +``` +get radio.fem.rxgain +set radio.fem.rxgain {on|off} +``` +Shows or changes the external FEM receive-path LNA on supported Heltec boards. +The change is applied immediately, saved across reboots, and recalibrates the +radio noise floor. This is separate from the SX126x `radio.rxgain` setting. +Boards whose FEM receive path cannot be controlled report the setting as +unsupported. + ``` time {epoch-secs} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8b79c47f..923986cd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2019,17 +2019,10 @@ void MyMesh::execCommand(char* cmd, char* reply) { strcpy(reply, "Error: must be on or off"); } else if (!board.canControlLoRaFemLna()) { strcpy(reply, "Error: unsupported"); + } else if (!applyAndSaveFemRxGain(enabled)) { + strcpy(reply, "Error: failed to apply FEM RX gain"); } else { - bool changed = board.isLoRaFemLnaEnabled() != enabled; - if (!board.setLoRaFemLnaEnabled(enabled)) { - strcpy(reply, "Error: failed to apply FEM RX gain"); - } else { - if (changed) _radio->recalibrateNoiseFloor(); - _prefs.radio_fem_rxgain = enabled; - _prefs.radio_fem_rxgain_override = 1; - savePrefs(); - strcpy(reply, "OK"); - } + strcpy(reply, "OK"); } return; } @@ -3215,12 +3208,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (!board.canControlLoRaFemLna()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else if (value <= 1) { - bool changed = board.isLoRaFemLnaEnabled() != (value != 0); - if (board.setLoRaFemLnaEnabled(value != 0)) { - if (changed) _radio->recalibrateNoiseFloor(); - _prefs.radio_fem_rxgain = value; - _prefs.radio_fem_rxgain_override = 1; - savePrefs(); + if (applyAndSaveFemRxGain(value != 0)) { writeOKFrame(); } else { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); @@ -3428,6 +3416,19 @@ void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) { } } +bool MyMesh::applyAndSaveFemRxGain(bool enabled) { + if (!board.canControlLoRaFemLna()) return false; + + const bool changed = board.isLoRaFemLnaEnabled() != enabled; + if (!board.setLoRaFemLnaEnabled(enabled)) return false; + + if (changed) _radio->recalibrateNoiseFloor(); + _prefs.radio_fem_rxgain = enabled ? 1 : 0; + _prefs.radio_fem_rxgain_override = 1; + savePrefs(); + return true; +} + #ifdef ENABLE_USB_INTERFACE void MyMesh::enterTerminalMode() { _terminal_mode = true; @@ -4087,6 +4088,13 @@ void MyMesh::handleTerminalCommand(char* command) { } } else if (strncmp(command, "import ", 7) == 0) { importTerminalCard(command + 7); + } else if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!board.canControlLoRaFemLna()) { + Serial.print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + } else { + Serial.printf(" FEM RX gain: %s\r\n", + board.isLoRaFemLnaEnabled() ? "on" : "off"); + } } else if (strncmp(command, "set ", 4) == 0) { const char* config = command + 4; if (strncmp(config, "af ", 3) == 0) { @@ -4113,6 +4121,20 @@ void MyMesh::handleTerminalCommand(char* command) { _prefs.freq = constrain((float)atof(config + 5), 150.0f, 2500.0f); savePrefs(); Serial.print(" OK - reboot to apply\r\n"); + } else if (strncmp(config, "radio.fem.rxgain", 16) == 0 + && (config[16] == 0 || config[16] == ' ' + || config[16] == '\t')) { + const char* value = config + 16; + while (*value == ' ' || *value == '\t') value++; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + Serial.print(" ERROR: use set radio.fem.rxgain \r\n"); + } else if (!board.canControlLoRaFemLna()) { + Serial.print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { + Serial.print(" ERROR: failed to apply FEM RX gain\r\n"); + } else { + Serial.printf(" OK - FEM RX gain %s\r\n", value); + } } else { Serial.printf(" ERROR: unknown setting: %s\r\n", config); } @@ -4122,6 +4144,8 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strcmp(command, "help") == 0) { Serial.print("Commands:\r\n"); Serial.print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); + Serial.print(" get radio.fem.rxgain\r\n"); + Serial.print(" set radio.fem.rxgain \r\n"); Serial.print(" card\r\n"); Serial.print(" import \r\n"); Serial.print(" clock\r\n"); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index eef526d5..72d68f6a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -272,6 +272,7 @@ private: void checkCLIRescueCmd(); void checkSerialInterface(); + bool applyAndSaveFemRxGain(bool enabled); #ifdef ENABLE_USB_INTERFACE ContactInfo* getTerminalRecipient(); void printTerminalPath(const ContactInfo& recipient); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a872fd1c..25e47a69 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -590,10 +590,15 @@ void setup() { WIFI_DEBUG_PRINTLN("%s", web_reply); } #endif - // Disable WiFi modem power-save after starting WebConfig: that startup may - // restore a saved ESP-IDF WiFi power policy. Companion radios must keep - // modem sleep disabled because its pauses can stall SX1262 SPI/DIO service. + // ESP-IDF requires WiFi modem sleep when Bluetooth is active. Full ESP32 + // companions provide both transports, so use the lowest sleep policy there; + // WiFi-only companions keep modem sleep disabled to avoid pauses in SX1262 + // SPI/DIO service. +#if defined(BLE_PIN_CODE) + WiFi.setSleep(true); +#else WiFi.setSleep(false); +#endif wifi_interface.begin(TCP_PORT); interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); #ifdef ENABLE_OTA diff --git a/src/helpers/WiFiPowerSave.h b/src/helpers/WiFiPowerSave.h new file mode 100644 index 00000000..a76fed09 --- /dev/null +++ b/src/helpers/WiFiPowerSave.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace mesh { +namespace wifi { + +// MeshCore stores WiFi power save as: 0=min, 1=none, 2=max. ESP32 WiFi and +// Bluetooth coexistence requires modem sleep, so "none" must fall back to the +// minimum sleep policy whenever Bluetooth is active. +inline uint8_t effectivePowerSave(uint8_t configured, + bool bluetooth_active) { + if (configured > 2) configured = 1; + return bluetooth_active && configured == 1 ? 0 : configured; +} + +} // namespace wifi +} // namespace mesh diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 3c2b85f3..f499982f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -7,6 +7,7 @@ #include "../MQTTRuntimeBufferLifecycle.h" #include "../MQTTTopicRouter.h" #include "../TxtDataHelpers.h" +#include "../WiFiPowerSave.h" #include #include #include @@ -2561,7 +2562,14 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { _wifi_reconnect_backoff_attempt = 0; #ifdef ESP_PLATFORM wifi_ps_type_t ps_mode; - uint8_t ps_pref = _obs->wifi_power_save; + uint8_t ps_pref = mesh::wifi::effectivePowerSave( + _obs->wifi_power_save, +#if defined(BLE_PIN_CODE) && defined(WIFI_SSID) + true +#else + false +#endif + ); if (ps_pref == 1) { ps_mode = WIFI_PS_NONE; } else if (ps_pref == 2) { diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index cca1be20..25f139ce 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -26,6 +26,20 @@ #include "WebConfigHtml.h" #include "helpers/CLICommandUtils.h" #include "helpers/WebConfigKeys.h" +#include "helpers/WiFiPowerSave.h" + +static bool bluetoothWiFiCoexistenceRequired() { +#if defined(BLE_PIN_CODE) && defined(WIFI_SSID) + return true; +#else + return false; +#endif +} + +static uint8_t effectiveWiFiPowerSave(uint8_t configured) { + return mesh::wifi::effectivePowerSave( + configured, bluetoothWiFiCoexistenceRequired()); +} // Placeholder sent instead of stored secrets; POSTs carrying it are dropped // so an untouched password field never overwrites the stored value. @@ -106,6 +120,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow loadStandaloneWiFi(_wifi_ssid, sizeof(_wifi_ssid), _wifi_password, sizeof(_wifi_password), &_wifi_power_save); } + _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); } WebConfigServer::~WebConfigServer() { @@ -165,12 +180,13 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len, ssid[ssid_len - 1] = 0; strncpy(password, stored_password.c_str(), password_len - 1); password[password_len - 1] = 0; - if (power_save) *power_save = stored_ps <= 2 ? stored_ps : 1; + if (power_save) *power_save = effectiveWiFiPowerSave(stored_ps); return stored_ssid.length() != 0; } bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password, uint8_t power_save) { + power_save = effectiveWiFiPowerSave(power_save); if (!ssid || !ssid[0] || strlen(ssid) >= 32 || (password && strlen(password) >= 64) || power_save > 2) { return false; @@ -236,6 +252,11 @@ bool WebConfigServer::setStandaloneWiFiPowerSave(const char* value, char* reply, snprintf(reply, reply_len, "Error: power save must be none, min, or max"); return false; } + if (effectiveWiFiPowerSave(power_save) != power_save) { + snprintf(reply, reply_len, + "Error: power save none is unavailable while Bluetooth is active"); + return false; + } Preferences nvs; if (!nvs.begin("mesh-wifi", false)) { @@ -542,6 +563,7 @@ bool WebConfigServer::startAutoMode(char reply[]) { _setup_reconnect_deadline = 0; _wifi_reconnect_tracker.noteDisconnected(millis()); WiFi.begin(_wifi_ssid, _wifi_password); + _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); wifi_ps_type_t ps_mode = _wifi_power_save == 1 ? WIFI_PS_NONE : _wifi_power_save == 2 ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; @@ -917,7 +939,11 @@ void WebConfigServer::drainBatch(uint32_t now) { } } else if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.powersave") == 0) { if (strcmp(value, "min") == 0) _wifi_power_save = 0; - else if (strcmp(value, "none") == 0) _wifi_power_save = 1; + else if (strcmp(value, "none") == 0 + && bluetoothWiFiCoexistenceRequired()) { + strcpy(e.reply, + "Error: power save none is unavailable while Bluetooth is active"); + } else if (strcmp(value, "none") == 0) _wifi_power_save = 1; else if (strcmp(value, "max") == 0) _wifi_power_save = 2; else strcpy(e.reply, "Error: must be none, min, or max"); if (e.reply[0] == 0) { diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index fb777575..da4fcf95 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -1,6 +1,7 @@ #include #include +#include TEST(CLICommandUtils, NormalizesCapitalizedSetVerb) { char command[] = "Set altpath 600000,0d2784,F8DADA"; @@ -291,6 +292,15 @@ TEST(CLICommandUtils, ValidatesStandaloneWiFiValues) { EXPECT_FALSE(mesh::cli::parseStandaloneWiFiPowerSave("off", power_save)); } +TEST(CLICommandUtils, EnforcesWiFiSleepForBluetoothCoexistence) { + EXPECT_EQ(0, mesh::wifi::effectivePowerSave(0, true)); + EXPECT_EQ(0, mesh::wifi::effectivePowerSave(1, true)); + EXPECT_EQ(2, mesh::wifi::effectivePowerSave(2, true)); + EXPECT_EQ(1, mesh::wifi::effectivePowerSave(1, false)); + EXPECT_EQ(0, mesh::wifi::effectivePowerSave(99, true)); + EXPECT_EQ(1, mesh::wifi::effectivePowerSave(99, false)); +} + TEST(CLICommandUtils, MatchesDiscoverNeighborsWithoutPrefixCollisions) { using mesh::cli::NoArgCommandMatch;