Add runtime FEM control and fix full Companion boot

This commit is contained in:
mikecarper
2026-08-10 17:10:18 -07:00
parent f40615ab56
commit 968ca3363b
10 changed files with 162 additions and 28 deletions
+23 -5
View File
@@ -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-match-spec>: Build all firmwares for build targets containing the string given for <build-match-spec>.
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
+15 -1
View File
@@ -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
+10
View File
@@ -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}
+40 -16
View File
@@ -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 <on|off>\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 <on|off>\r\n");
Serial.print(" card\r\n");
Serial.print(" import <meshcore://card>\r\n");
Serial.print(" clock\r\n");
+1
View File
@@ -272,6 +272,7 @@ private:
void checkCLIRescueCmd();
void checkSerialInterface();
bool applyAndSaveFemRxGain(bool enabled);
#ifdef ENABLE_USB_INTERFACE
ContactInfo* getTerminalRecipient();
void printTerminalPath(const ContactInfo& recipient);
+8 -3
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <stdint.h>
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
+9 -1
View File
@@ -7,6 +7,7 @@
#include "../MQTTRuntimeBufferLifecycle.h"
#include "../MQTTTopicRouter.h"
#include "../TxtDataHelpers.h"
#include "../WiFiPowerSave.h"
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Timezone.h>
@@ -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) {
+28 -2
View File
@@ -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) {
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/WiFiPowerSave.h>
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;