feat(wifi): runtime WiFi TX power control (wifi.txpower)

Adds `set/get wifi.txpower default|2..20` so the WiFi TX power cap can be
changed per node without a rebuild.

- Stored as whole dBm in MQTTPrefs (/mqtt.json wifi.tx_power). 0 means the
  build's MQTT_WIFI_TX_POWER, so saving prefs never pins today's default and
  a later build can still move a board's default. Files without the key load
  as board default; out-of-range values are repaired to it.
- WifiTxPowerPolicy holds parsing, the build-default clamp to the SDK's
  [8, 84] quarter-dBm range, and formatting. No quantization table: replies
  report esp_wifi_get_max_tx_power() readback, since rounding is per chip.
- Applied via esp_wifi_set_max_tx_power() right after WiFi.mode(WIFI_STA)
  (so association is capped), after every association, immediately from the
  CLI, and when the setup AP starts and tears down back to STA.
- WebConfig: TX power selector with the radio's reported value; config JSON
  gains wifi.txpower and wifi.txpower_applied; key allowlisted; mock server
  and CLI audit updated.
- Host tests for the policy and for serializer round-trip/repair.
This commit is contained in:
agessaman committed 2026-10-22 16:06:12 -07:00
1 parent efa1b54934
commit 45cf7eb74f
19 files changed
+383 -13

No files matched your search

+2 -1
View File
@@ -285,6 +285,7 @@ RX/TX, filter, and neighbors changes apply live. `get mqtt.analyzer.us` and
| `get/set wifi.ssid <ssid>` | Up to 31 characters |
| `get/set wifi.pwd <password>` | Up to 63 characters; `get` shows it on serial only. `set wifi.pwd ` (trailing space) for an open network |
| `get/set wifi.powersave none\|min\|max` | Default `none` |
| `get/set wifi.txpower default\|2..20` | WiFi TX power cap in whole dBm; `default` follows the build's `MQTT_WIFI_TX_POWER`. Applies at once and on every WiFi start, including the setup AP. Replies show what the radio actually applied, which the chip rounds to its own steps |
| `get link.status` | Active medium, IP, signal, uptime, or the last disconnect reason |
| `get link.diag` | Why Ethernet or WiFi was chosen |
| `get link.dns` | Live DNS servers and what each medium's DHCP lease supplied |
@@ -596,7 +597,7 @@ esptool.py write_flash 0x0 .pio/build/LilyGo_T3S3_sx1262_repeater_observer_mqtt/
| `WITH_MQTT_BRIDGE=1` | MQTT bridge (required) |
| `WITH_SNMP=1` | SNMP agent |
| `MQTT_DEBUG=1` | Debug logging |
| `MQTT_WIFI_TX_POWER` | WiFi TX power (default `WIFI_POWER_11dBm`) |
| `MQTT_WIFI_TX_POWER` | Board default WiFi TX power (default `WIFI_POWER_11dBm`); `set wifi.txpower` overrides it per node |
These set fresh-install defaults (see `src/helpers/MQTTDefaults.h`). They don't change a
node that already has `/mqtt.json`.
+1 -1
View File
@@ -175,7 +175,7 @@ first property of the root object. The main shape is:
```text
{version:1,
wifi:{ssid:"...",password:"...",power_save:1},
wifi:{ssid:"...",password:"...",power_save:1,tx_power:0},
time:{timezone:"...",utc_offset:0,ntp_server:"..."},
mqtt:{origin:"...",iata:"SEA",packets_enabled:1,raw_enabled:0,
tx_enabled:2,rx_enabled:1,
+2
View File
@@ -1173,6 +1173,8 @@ void MyMesh::begin(FILESYSTEM *fs) {
#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM)
NetworkLink& boot_network = activeNetworkLink();
// Retained before any Wi-Fi start, so the first association is capped too.
boot_network.setWifiTxPower(_cli.getObserverPrefs()->wifi_tx_power);
if (boot_network.isAutomatic()) {
applyNetworkHostname("");
MQTTPrefs* obs = _cli.getObserverPrefs();
+2
View File
@@ -973,6 +973,8 @@ void MyMesh::begin(FILESYSTEM *fs) {
#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM)
NetworkLink& boot_network = activeNetworkLink();
// Retained before any Wi-Fi start, so the first association is capped too.
boot_network.setWifiTxPower(_cli.getObserverPrefs()->wifi_tx_power);
if (boot_network.isAutomatic()) {
applyNetworkHostname("");
MQTTPrefs* obs = _cli.getObserverPrefs();
+2
View File
@@ -160,6 +160,8 @@ ROUND_TRIPS = [
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
("set path.hash.mode 2", "get path.hash.mode", "2"),
("set mqtt.iata den", "get mqtt.iata", "DEN"),
("set wifi.txpower 15", "get wifi.txpower", "15 dBm, applied 15 dBm"),
("set wifi.txpower default", "get wifi.txpower", "default, applied 11 dBm"),
# Board commands, exercised only when this board answers for them (see
# BOARD_ROUND_TRIPS below); the settings form drives the two FEM keys.
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
+24
View File
@@ -114,6 +114,8 @@ def default_config(setup_mode):
"ssid": "" if setup_mode else "HomeNet",
"pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET
"powersave": "min",
# stored dBm, 0 = board default; *_applied is the radio's readback
"txpower": 0, "txpower_applied": 11,
},
"mqtt": {
"origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN",
@@ -252,6 +254,11 @@ def _hex64(v):
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
def _fmt_dbm(cfg):
a = cfg["wifi"]["txpower_applied"]
return ("%g" % a) if a is not None else None
def apply_set(cfg, key, val):
"""Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for
the fields where it matters (length, IATA, owner key, port, radio combo)."""
@@ -297,6 +304,20 @@ def apply_set(cfg, key, val):
cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr)
return True, "OK - reboot to apply"
if key == "wifi.txpower":
# Mirrors WifiTxPowerPolicy::parse; the mock radio applies it unrounded.
word = val.split(" ")[0]
if word in ("default", "0"):
dbm = 0
elif word.isdigit() and 2 <= int(word) <= 20:
dbm = int(word)
else:
return False, "Error: must be default or 2..20 (dBm)"
cfg["wifi"]["txpower"] = dbm
cfg["wifi"]["txpower_applied"] = dbm or 11
stored = "%d dBm" % dbm if dbm else "default"
return True, "OK - TX power %s (applied %s dBm)" % (stored, _fmt_dbm(cfg))
if key == "mqtt.iata":
if val == "":
cfg["mqtt"]["iata"] = ""
@@ -569,6 +590,9 @@ def cli_reply_is_failure(reply):
# Commands the device answers but that have no config-key equivalent.
GETTERS = {
"wifi.txpower": lambda c: "%s, applied %s dBm" % (
"%d dBm" % c["wifi"]["txpower"] if c["wifi"]["txpower"] else "default",
_fmt_dbm(c)),
"freq": lambda c: "%.3f" % c["radio"]["freq"],
"bw": lambda c: "%.2f" % c["radio"]["bw"],
"sf": lambda c: str(c["radio"]["sf"]),
+48
View File
@@ -18,6 +18,7 @@
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
#include "NetworkLink.h"
#include "WifiPowerSavePolicy.h" // one powersave value->mode/name mapping
#include "WifiTxPowerPolicy.h" // wifi.txpower parsing and dBm formatting
#include <Utils.h>
#include <climits>
#include <new>
@@ -472,6 +473,34 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
}
#else
sprintf(reply, "OK - saved as %s", ps_name);
#endif
}
} else if (memcmp(config, "wifi.txpower ", 13) == 0) {
uint8_t tx_value;
if (!WifiTxPowerPolicy::parse(&config[13], &tx_value)) {
sprintf(reply, "Error: must be default or %u..%u (dBm)",
(unsigned)WifiTxPowerPolicy::kMinDbm, (unsigned)WifiTxPowerPolicy::kMaxDbm);
} else {
_mqtt_prefs.wifi_tx_power = tx_value;
if (!persistObserverPrefs(reply)) return true;
char stored[12];
if (tx_value == WifiTxPowerPolicy::kBoardDefault) {
strcpy(stored, "default");
} else {
sprintf(stored, "%u dBm", (unsigned)tx_value);
}
#ifdef ESP_PLATFORM
// The radio quantizes the request per chip, so report its readback.
const int8_t applied = activeNetworkLink().setWifiTxPower(tx_value);
if (applied >= 0) {
char applied_dbm[8];
WifiTxPowerPolicy::formatQdbm(applied_dbm, sizeof(applied_dbm), applied);
sprintf(reply, "OK - TX power %s (applied %s dBm)", stored, applied_dbm);
} else {
sprintf(reply, "OK - saved as %s (will apply when WiFi starts)", stored);
}
#else
sprintf(reply, "OK - saved as %s", stored);
#endif
}
} else if (memcmp(config, "timezone ", 9) == 0) {
@@ -1148,6 +1177,25 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
}
} else if (memcmp(config, "wifi.powersave", 14) == 0) {
sprintf(reply, "> %s", WifiPowerSavePolicy::nameFor(_mqtt_prefs.wifi_power_save));
} else if (memcmp(config, "wifi.txpower", 12) == 0) {
char stored[12];
if (_mqtt_prefs.wifi_tx_power == WifiTxPowerPolicy::kBoardDefault) {
strcpy(stored, "default");
} else {
sprintf(stored, "%u dBm", (unsigned)_mqtt_prefs.wifi_tx_power);
}
#ifdef ESP_PLATFORM
const int8_t applied = activeNetworkLink().appliedWifiTxPower();
if (applied >= 0) {
char applied_dbm[8];
WifiTxPowerPolicy::formatQdbm(applied_dbm, sizeof(applied_dbm), applied);
sprintf(reply, "> %s, applied %s dBm", stored, applied_dbm);
} else {
sprintf(reply, "> %s, not applied yet (WiFi off)", stored);
}
#else
sprintf(reply, "> %s", stored);
#endif
} else if (memcmp(config, "timezone.offset", 15) == 0) {
// Must precede the "timezone" (8-byte) check below — that prefix-matches
// "timezone.offset" too, so the more-specific key has to come first or
+1
View File
@@ -71,6 +71,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
prefs->mqtt_advert_verify = 0;
prefs->mqtt_status_interval = 300000;
prefs->wifi_power_save = 1;
prefs->wifi_tx_power = 0; // board default
mqttDefaultSlotPreset(prefs->mqtt_slot_preset[0], sizeof(prefs->mqtt_slot_preset[0]),
MQTT_DEFAULT_SLOT1_PRESET);
+11 -3
View File
@@ -5,6 +5,7 @@
#include <helpers/MQTTPresets.h>
#include <helpers/MQTTPrefsStorage.h>
#include <helpers/WifiPowerSavePolicy.h>
#include <helpers/WifiTxPowerPolicy.h>
#ifdef WITH_MQTT_BRIDGE
@@ -40,20 +41,22 @@ class MQTTPrefsSerializer : public ConfigSerializer {
class WifiPrefs : public ConfigSerializer {
MQTTPrefs* _prefs;
int32_t _power_save, _setup_complete;
int32_t _power_save, _setup_complete, _tx_power;
bool _seen_ssid = false, _seen_password = false, _seen_power_save = false;
bool _seen_setup_complete = false;
bool _seen_setup_complete = false, _seen_tx_power = false;
protected:
void structure() override {
defStrict("ssid", _prefs->wifi_ssid, sizeof(_prefs->wifi_ssid), _seen_ssid);
defStrict("password", _prefs->wifi_password, sizeof(_prefs->wifi_password), _seen_password);
defStrict("power_save", _power_save, _seen_power_save);
defStrict("setup_complete", _setup_complete, _seen_setup_complete);
defStrict("tx_power", _tx_power, _seen_tx_power);
}
public:
explicit WifiPrefs(MQTTPrefs* prefs)
: _prefs(prefs), _power_save(prefs->wifi_power_save),
_setup_complete(prefs->network_setup_complete) {}
_setup_complete(prefs->network_setup_complete),
_tx_power(prefs->wifi_tx_power) {}
bool apply(bool* repaired) {
if (_power_save < 0 || _power_save > WifiPowerSavePolicy::kMaxStoredValue) {
_power_save = 1;
@@ -63,6 +66,11 @@ class MQTTPrefsSerializer : public ConfigSerializer {
_setup_complete = 0;
*repaired = true;
}
if (!WifiTxPowerPolicy::isValidStored(_tx_power)) {
_tx_power = WifiTxPowerPolicy::kBoardDefault;
*repaired = true;
}
_prefs->wifi_tx_power = static_cast<uint8_t>(_tx_power);
_prefs->wifi_power_save = static_cast<uint8_t>(_power_save);
_prefs->network_setup_complete = static_cast<uint8_t>(_setup_complete);
return true;
+4
View File
@@ -140,6 +140,10 @@ struct MQTTPrefs {
// one observer can publish under different region codes to brokers whose
// region maps differ. Runtime only, like display_timeout_secs.
char mqtt_slot_iata[MQTT_PREFS_SLOT_COUNT][8];
// WiFi TX power cap in whole dBm; 0 follows the build's MQTT_WIFI_TX_POWER
// (see WifiTxPowerPolicy). Runtime only, like display_timeout_secs.
uint8_t wifi_tx_power;
};
static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) {
+47 -5
View File
@@ -4,6 +4,7 @@
#include "MQTTConnectionPolicy.h"
#include "WifiPowerSavePolicy.h"
#include "WifiTxPowerPolicy.h"
#include <atomic>
#include <climits>
@@ -227,6 +228,34 @@ class WiFiNetworkLink final : public NetworkLinkBase {
char _password[65] = {};
unsigned long _last_reconnect_attempt = 0;
uint8_t _reconnect_backoff_attempt = 0;
// Written by the CLI (loop task), read by begin()/maintain() (MQTT task).
std::atomic<uint8_t> _tx_power_stored{WifiTxPowerPolicy::kBoardDefault};
#ifdef MQTT_WIFI_TX_POWER
static constexpr int32_t kBuildTxPowerQdbm = (int32_t)MQTT_WIFI_TX_POWER;
#else
static constexpr int32_t kBuildTxPowerQdbm = WifiTxPowerPolicy::kFallbackQdbm;
#endif
static int8_t readTxPower() {
if (WiFi.getMode() == WIFI_MODE_NULL) return -1;
int8_t qdbm = -1;
return esp_wifi_get_max_tx_power(&qdbm) == ESP_OK ? qdbm : -1;
}
// Called directly rather than through WiFi.setTxPower(): wifi_power_t differs
// between Arduino-ESP32 2.x and 3.x, and the readback is the only record of
// what the chip actually quantized the request to.
int8_t applyTxPower() {
if (WiFi.getMode() == WIFI_MODE_NULL) return -1;
const int8_t requested = WifiTxPowerPolicy::qdbmFor(
_tx_power_stored.load(std::memory_order_acquire), kBuildTxPowerQdbm);
const esp_err_t set_result = esp_wifi_set_max_tx_power(requested);
const int8_t applied = readTxPower();
NETWORK_DEBUG_PRINTLN("WiFi TX power: requested %d, applied %d qdBm (err=%d)",
(int)requested, (int)applied, (int)set_result);
return applied;
}
// One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The
// stored default is `none`; `min` means MIN_MODEM here exactly as the CLI
@@ -244,16 +273,21 @@ class WiFiNetworkLink final : public NetworkLinkBase {
esp_wifi_get_ps(&applied);
NETWORK_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)",
WifiPowerSavePolicy::nameFor(wifi_power_save), (int)applied);
#ifdef MQTT_WIFI_TX_POWER
WiFi.setTxPower(MQTT_WIFI_TX_POWER);
#else
WiFi.setTxPower(WIFI_POWER_11dBm);
#endif
// Kept after association as well as at start: some Arduino/IDF versions
// reset the cap when the station (re)starts or the country is applied.
applyTxPower();
}
public:
const char* mediumName() const override { return "wifi"; }
NetworkMedium medium() const override { return NetworkMedium::WiFi; }
int8_t setWifiTxPower(uint8_t stored) override {
_tx_power_stored.store(stored, std::memory_order_release);
return applyTxPower();
}
int8_t reapplyWifiTxPower() override { return applyTxPower(); }
int8_t appliedWifiTxPower() const override { return readTxPower(); }
const char* statusName() const override {
switch (WiFi.status()) {
case WL_CONNECTED: return "connected";
@@ -292,6 +326,8 @@ class WiFiNetworkLink final : public NetworkLinkBase {
if (_hostname[0] != '\0') WiFi.setHostname(_hostname);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
// Before WiFi.begin() so association and the handshake honor the cap too.
applyTxPower();
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
@@ -747,6 +783,12 @@ class AutomaticNetworkLink final : public NetworkLink {
_wifi.setHostname(hostname);
}
// The fallback STA and the setup AP share one radio, so the cap goes to the
// Wi-Fi link whichever medium is selected.
int8_t setWifiTxPower(uint8_t stored) override { return _wifi.setWifiTxPower(stored); }
int8_t reapplyWifiTxPower() override { return _wifi.reapplyWifiTxPower(); }
int8_t appliedWifiTxPower() const override { return _wifi.appliedWifiTxPower(); }
bool begin(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started.load(std::memory_order_acquire)) {
+10
View File
@@ -40,6 +40,16 @@ class NetworkLink {
}
virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0;
// Stored `wifi.txpower` (see WifiTxPowerPolicy). Retained for every later
// radio start and association, and applied at once if the radio is running.
// Returns the quarter-dBm the radio reports afterwards, -1 if not running.
virtual int8_t setWifiTxPower(uint8_t stored) { (void)stored; return -1; }
// Re-applies the retained value, e.g. after something else restarted the
// radio (setup AP). Same return as setWifiTxPower().
virtual int8_t reapplyWifiTxPower() { return -1; }
// Quarter-dBm the radio reports now, -1 if it is not running.
virtual int8_t appliedWifiTxPower() const { return -1; }
// Automatic selectors are boot-owned so first-run services can use the
// chosen link before MQTT starts. Compatibility Wi-Fi builds keep the
// original MQTT-task-owned begin() path.
+1 -1
View File
@@ -23,7 +23,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
"repeat", "advert.interval", "flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave",
"wifi.ssid", "wifi.pwd", "wifi.powersave", "wifi.txpower",
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.advert.verify", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval",
"mqtt.ntp", "mqtt.owner", "mqtt.email",
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// The single mapping between the stored `wifi.txpower` preference, its CLI
// form, and the quarter-dBm argument of esp_wifi_set_max_tx_power().
//
// Stored values are whole dBm, with 0 meaning "use the build's
// MQTT_WIFI_TX_POWER". Every write saves this field, so storing the build value
// itself would pin it on every node and a later firmware could no longer move a
// board's default. Keep 0 as the sentinel; never renumber it.
//
// The radio quantizes the request differently per chip and IDF version, so no
// table lives here: callers read back esp_wifi_get_max_tx_power() and report
// what was actually applied.
namespace WifiTxPowerPolicy {
static const uint8_t kBoardDefault = 0;
static const uint8_t kMinDbm = 2;
static const uint8_t kMaxDbm = 20;
// esp_wifi_set_max_tx_power() rejects anything outside [8, 84].
static const int8_t kMinQdbm = 8;
static const int8_t kMaxQdbm = 84;
// Arduino's WIFI_POWER_11dBm, used when a build sets no MQTT_WIFI_TX_POWER.
static const int8_t kFallbackQdbm = 44;
static inline bool isValidStored(int32_t stored) {
return stored == kBoardDefault || (stored >= kMinDbm && stored <= kMaxDbm);
}
// A build default outside the SDK range (e.g. WIFI_POWER_MINUS_1dBm) would be
// rejected outright and leave the radio at full power, so clamp it instead.
static inline int8_t clampQdbm(int32_t qdbm) {
if (qdbm < kMinQdbm) return kMinQdbm;
if (qdbm > kMaxQdbm) return kMaxQdbm;
return (int8_t)qdbm;
}
// Corrupt stored values read as the board default rather than as some power.
static inline int8_t qdbmFor(uint8_t stored, int32_t build_default_qdbm) {
if (stored == kBoardDefault || !isValidStored(stored)) {
return clampQdbm(build_default_qdbm);
}
return (int8_t)(stored * 4);
}
// Accepts `default`, `0` or a whole number of dBm in [kMinDbm, kMaxDbm],
// optionally followed by trailing text (the observer setters take the rest of
// the command line). Returns false and leaves *out untouched for anything else.
static inline bool parse(const char* value, uint8_t* out) {
if (value == nullptr || out == nullptr) return false;
if (strncmp(value, "default", 7) == 0 && (value[7] == '\0' || value[7] == ' ')) {
*out = kBoardDefault;
return true;
}
if (value[0] < '0' || value[0] > '9') return false;
char* end = nullptr;
long dbm = strtol(value, &end, 10);
if (end == value || (*end != '\0' && *end != ' ')) return false;
if (!isValidStored(dbm)) return false;
*out = (uint8_t)dbm;
return true;
}
// "11", "16.5", "8.25": quarter-dBm as the shortest exact decimal.
static inline void formatQdbm(char* out, size_t out_size, int8_t qdbm) {
if (out == nullptr || out_size == 0) return;
const int whole = qdbm / 4;
switch (qdbm % 4) {
case 1: snprintf(out, out_size, "%d.25", whole); break;
case 2: snprintf(out, out_size, "%d.5", whole); break;
case 3: snprintf(out, out_size, "%d.75", whole); break;
default: snprintf(out, out_size, "%d", whole); break;
}
}
} // namespace WifiTxPowerPolicy
+1
View File
@@ -1346,6 +1346,7 @@ void MQTTBridge::mqttTask(void* parameter) {
void MQTTBridge::initializeNetworkInTask() {
MQTT_DEBUG_PRINTLN("Initializing %s network in MQTT task...", _network->mediumName());
_network->setWifiTxPower(_obs->wifi_tx_power);
// begin() is idempotent and deliberately leaves an already-up link alone.
// MQTT end()/begin() cycles therefore keep the transport alive for OTA and do
// not race the first DNS lookup after a bridge restart.
+15
View File
@@ -298,6 +298,9 @@ bool WebConfigServer::startSetupMode(char reply[]) {
strcpy(reply, "Err: failed to start AP");
return false;
}
// AP_STA restarts the radio at the SDK default, ~20 dBm; boards that brown
// out under Wi-Fi TX need the cap during setup as much as in service.
activeNetworkLink().reapplyWifiTxPower();
delay(100); // let the AP netif settle before reading its IP
IPAddress ip = WiFi.softAPIP();
@@ -445,6 +448,7 @@ void WebConfigServer::finalizeTeardown() {
WiFi.mode(WIFI_OFF);
} else {
WiFi.mode(WIFI_STA);
activeNetworkLink().reapplyWifiTxPower();
}
_was_setup_ap = false;
}
@@ -954,6 +958,17 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
wifi["ssid"] = (const char*)_obs->wifi_ssid;
wifi["pwd"] = _obs->wifi_password[0] ? SECRET_SENTINEL : "";
wifi["powersave"] = WifiPowerSavePolicy::nameFor(_obs->wifi_power_save);
// Stored dBm (0 = board default) plus what the radio reports, so the form
// can show the quantized value instead of echoing the request.
wifi["txpower"] = _obs->wifi_tx_power;
{
const int8_t applied = activeNetworkLink().appliedWifiTxPower();
if (applied >= 0) {
wifi["txpower_applied"] = applied / 4.0f;
} else {
wifi["txpower_applied"] = nullptr;
}
}
JsonObject mqtt = doc.createNestedObject("mqtt");
mqtt["origin"] = (const char*)_obs->mqtt_origin;
@@ -439,6 +439,51 @@ TEST(MQTTPrefsSerializer, DisplayFlipRoundTripsAndRepairs) {
EXPECT_EQ(0, prefs.display_flip);
}
// 0 is "board default", not 0 dBm; it must survive a save so a later build can
// still move the default. Files from older firmware have no tx_power at all.
TEST(MQTTPrefsSerializer, WifiTxPowerRoundTripsDefaultsAndRepairs) {
MQTTPrefs source = defaults();
EXPECT_EQ(WifiTxPowerPolicy::kBoardDefault, source.wifi_tx_power);
for (uint8_t dbm : {(uint8_t)0, (uint8_t)2, (uint8_t)15, (uint8_t)20}) {
source.wifi_tx_power = dbm;
OutputStream output;
MQTTPrefsSerializer writer(&source);
ASSERT_TRUE(writer.saveSerial(output)) << (int)dbm;
MQTTPrefs loaded = defaults();
loaded.wifi_tx_power = 7;
InputStream input(output.text());
MQTTPrefsSerializer reader(&loaded);
ASSERT_TRUE(reader.loadSerial(input)) << (int)dbm;
bool repaired = false;
ASSERT_TRUE(reader.apply(&repaired)) << (int)dbm;
EXPECT_FALSE(repaired) << (int)dbm;
EXPECT_EQ(dbm, loaded.wifi_tx_power);
}
MQTTPrefs legacy = defaults();
InputStream old_file("{version:1,wifi:{ssid:\"home\",power_save:1}}");
MQTTPrefsSerializer legacy_serializer(&legacy);
ASSERT_TRUE(legacy_serializer.loadSerial(old_file));
bool repaired = false;
ASSERT_TRUE(legacy_serializer.apply(&repaired));
EXPECT_FALSE(repaired);
EXPECT_EQ(WifiTxPowerPolicy::kBoardDefault, legacy.wifi_tx_power);
for (const char* bogus_text : {"{version:1,wifi:{tx_power:1}}",
"{version:1,wifi:{tx_power:21}}",
"{version:1,wifi:{tx_power:-3}}"}) {
MQTTPrefs prefs = defaults();
InputStream bogus(bogus_text);
MQTTPrefsSerializer bogus_serializer(&prefs);
ASSERT_TRUE(bogus_serializer.loadSerial(bogus)) << bogus_text;
repaired = false;
ASSERT_TRUE(bogus_serializer.apply(&repaired)) << bogus_text;
EXPECT_TRUE(repaired) << bogus_text;
EXPECT_EQ(WifiTxPowerPolicy::kBoardDefault, prefs.wifi_tx_power) << bogus_text;
}
}
TEST(MQTTPrefsSerializer, AdvertVerifyDefaultsOffRoundTripsAndRepairs) {
MQTTPrefs source = defaults();
EXPECT_EQ(0, source.mqtt_advert_verify);
@@ -0,0 +1,74 @@
#include "helpers/WifiTxPowerPolicy.h"
#include <gtest/gtest.h>
using namespace WifiTxPowerPolicy;
// Stored 0 must keep following the build flag: every save writes this field,
// so a stored 11 would pin today's default on every node in the fleet.
TEST(WifiTxPowerPolicy, BoardDefaultFollowsBuildValue) {
EXPECT_EQ(44, qdbmFor(kBoardDefault, 44));
EXPECT_EQ(34, qdbmFor(kBoardDefault, 34)); // WIFI_POWER_8_5dBm, not 8 dBm
EXPECT_EQ(78, qdbmFor(kBoardDefault, 78));
}
TEST(WifiTxPowerPolicy, ExplicitValuesAreWholeDbm) {
EXPECT_EQ(8, qdbmFor(2, 44));
EXPECT_EQ(60, qdbmFor(15, 44));
EXPECT_EQ(80, qdbmFor(20, 44));
}
// WIFI_POWER_MINUS_1dBm (-4) is rejected by the SDK, which would leave the
// radio at its full-power default instead of the cap the build asked for.
TEST(WifiTxPowerPolicy, BuildValueIsClampedToSdkRange) {
EXPECT_EQ(kMinQdbm, qdbmFor(kBoardDefault, -4));
EXPECT_EQ(kMaxQdbm, qdbmFor(kBoardDefault, 120));
}
TEST(WifiTxPowerPolicy, CorruptStoredValueReadsAsBoardDefault) {
EXPECT_FALSE(isValidStored(1));
EXPECT_FALSE(isValidStored(21));
EXPECT_FALSE(isValidStored(-1));
EXPECT_EQ(44, qdbmFor(1, 44));
EXPECT_EQ(44, qdbmFor(200, 44));
}
TEST(WifiTxPowerPolicy, ParsesCliArgumentsExactly) {
uint8_t out = 0xFF;
ASSERT_TRUE(parse("default", &out));
EXPECT_EQ(kBoardDefault, out);
ASSERT_TRUE(parse("0", &out));
EXPECT_EQ(kBoardDefault, out);
ASSERT_TRUE(parse("2", &out));
EXPECT_EQ(2, out);
ASSERT_TRUE(parse("20 trailing", &out));
EXPECT_EQ(20, out);
out = 0xFF;
EXPECT_FALSE(parse("1", &out));
EXPECT_FALSE(parse("21", &out));
EXPECT_FALSE(parse("-5", &out));
EXPECT_FALSE(parse("11.5", &out));
EXPECT_FALSE(parse("11dBm", &out));
EXPECT_FALSE(parse("defaults", &out));
EXPECT_FALSE(parse("", &out));
EXPECT_FALSE(parse(nullptr, &out));
EXPECT_EQ(0xFF, out);
}
TEST(WifiTxPowerPolicy, FormatsQuarterDbmExactly) {
char buf[8];
formatQdbm(buf, sizeof(buf), 44);
EXPECT_STREQ("11", buf);
formatQdbm(buf, sizeof(buf), 66);
EXPECT_STREQ("16.5", buf);
formatQdbm(buf, sizeof(buf), 33);
EXPECT_STREQ("8.25", buf);
formatQdbm(buf, sizeof(buf), 79);
EXPECT_STREQ("19.75", buf);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+13 -2
View File
@@ -483,6 +483,9 @@ body.tab-cli{padding-bottom:0}
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" maxlength="63" autocomplete="off"></div>
<div class="f"><label>Power save</label>
<select data-k="wifi.powersave"><option value="none">None (default, lowest latency)</option><option value="min">Minimum</option><option value="max">Maximum (battery)</option></select></div>
<div class="f"><label>TX power</label>
<select data-k="wifi.txpower" style="max-width:160px"><option value="default">Board default</option><option value="20">20 dBm</option><option value="19">19 dBm</option><option value="18">18 dBm</option><option value="17">17 dBm</option><option value="16">16 dBm</option><option value="15">15 dBm</option><option value="14">14 dBm</option><option value="13">13 dBm</option><option value="12">12 dBm</option><option value="11">11 dBm</option><option value="10">10 dBm</option><option value="9">9 dBm</option><option value="8">8 dBm</option><option value="7">7 dBm</option><option value="6">6 dBm</option><option value="5">5 dBm</option><option value="4">4 dBm</option><option value="3">3 dBm</option><option value="2">2 dBm</option></select>
<div class="hint" id="wifi-txp-cur"></div></div>
</div>
<div class="card">
<h2>Maintenance</h2>
@@ -711,6 +714,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"flood.advert.interval":return String(r.flood_advert_interval);
case"password":return"";
case"wifi.ssid":return w.ssid;case"wifi.pwd":return w.pwd;case"wifi.powersave":return w.powersave;
case"wifi.txpower":return w.txpower?String(w.txpower):"default";
case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata;
case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off";
case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off";
@@ -747,7 +751,7 @@ function loadConfig(){
});
$$("[data-cfm]").forEach(function(el){el.value=""}); // confirm twins have no stored value
$$("[data-rg]").forEach(function(el){el.value=String(c.radio[el.dataset.rg]);el.classList.remove("dirty")});
st.orig.radio=radioCombo();afHint();
st.orig.radio=radioCombo();afHint();txpHint();
for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
syncPacketFilters();
updateSaveBar();
@@ -762,6 +766,12 @@ function markDirty(k,v,el){
// The CLI's `dutycycle` is not its own pref — it just writes airtime_factor as
// 100/(dc)-1. Showing the equivalent duty cycle beside `af` gives the number
// operators actually reason about without a second field fighting over one pref.
// The radio rounds the cap to a chip-specific step, so show its own readback.
function txpHint(){
var e=$("#wifi-txp-cur");if(!e||!st.cfg)return;
var a=st.cfg.wifi.txpower_applied;
e.textContent=a==null?"Applies when WiFi starts.":"Radio reports "+a+" dBm.";
}
function afHint(){
var e=$("#af-dc");if(!e)return;
var af=parseFloat($('[data-k="af"]').value);
@@ -1092,7 +1102,7 @@ function pollResult(cb,errs,idles,acked,reqid,submitted){
}
function loadConfigSoft(){ // re-sync accepted values without clobbering chips/rejected edits
api("/api/config").then(function(c){
st.cfg=c;
st.cfg=c;txpHint();
$$("[data-k]").forEach(function(el){
var k=el.dataset.k,stored=cfgVal(k);
st.orig[k]=stored;
@@ -1565,6 +1575,7 @@ var CLI_KEYS=[
["wifi.ssid","WiFi network name",0],
["wifi.pwd","WiFi password",0],
["wifi.powersave","WiFi power-save mode",0,"none|min|max"],
["wifi.txpower","WiFi TX power cap in dBm {2-20}, or default",0,"default|8|11|15|20"],
["link.status","Selected network, IP, signal and uptime",1],
["link.diag","Ethernet selection and fallback diagnostics",1],
["link.dns","DNS servers, gateway and per-network lease DNS",1],