diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 4ccfed42..e1d92347 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -285,6 +285,7 @@ RX/TX, filter, and neighbors changes apply live. `get mqtt.analyzer.us` and | `get/set wifi.ssid ` | Up to 31 characters | | `get/set wifi.pwd ` | 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`. diff --git a/MQTT_INTERNALS.md b/MQTT_INTERNALS.md index dca97dc7..a4eec053 100644 --- a/MQTT_INTERNALS.md +++ b/MQTT_INTERNALS.md @@ -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, diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c3622ad5..885a6188 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9d893139..140aa89d 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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(); diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index 40d7c0c0..683eb416 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -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 diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 8f1407c3..51a7ffff 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -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"]), diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c78f7423..10d986af 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -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 #include #include @@ -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 diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index b8a6e3d2..e4015419 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -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); diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index d901cfac..cfcbcd11 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -5,6 +5,7 @@ #include #include #include +#include #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(_tx_power); _prefs->wifi_power_save = static_cast(_power_save); _prefs->network_setup_complete = static_cast(_setup_complete); return true; diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 772aa25c..c721df67 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -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) { diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index a2e39ab5..e1e51833 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -4,6 +4,7 @@ #include "MQTTConnectionPolicy.h" #include "WifiPowerSavePolicy.h" +#include "WifiTxPowerPolicy.h" #include #include @@ -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 _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)) { diff --git a/src/helpers/NetworkLink.h b/src/helpers/NetworkLink.h index 914ef1e3..13e75ad6 100644 --- a/src/helpers/NetworkLink.h +++ b/src/helpers/NetworkLink.h @@ -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. diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index 6cb48a1d..87312a8b 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -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", diff --git a/src/helpers/WifiTxPowerPolicy.h b/src/helpers/WifiTxPowerPolicy.h new file mode 100644 index 00000000..1fabc984 --- /dev/null +++ b/src/helpers/WifiTxPowerPolicy.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include + +// 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 diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 837cae30..380af08d 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -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. diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index c4c51b7d..1e5ae952 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -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; diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index f71670af..e7789f5b 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -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); diff --git a/test/test_wifi_tx_power_policy/test_wifi_tx_power_policy.cpp b/test/test_wifi_tx_power_policy/test_wifi_tx_power_policy.cpp new file mode 100644 index 00000000..37028910 --- /dev/null +++ b/test/test_wifi_tx_power_policy/test_wifi_tx_power_policy.cpp @@ -0,0 +1,74 @@ +#include "helpers/WifiTxPowerPolicy.h" + +#include + +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(); +} diff --git a/webui/index.html b/webui/index.html index 9b1ec76d..34defa13 100644 --- a/webui/index.html +++ b/webui/index.html @@ -483,6 +483,9 @@ body.tab-cli{padding-bottom:0}
+
+ +

Maintenance

@@ -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],