mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-10-23 04:21:28 +00:00
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:
1 parent
efa1b54934
commit
45cf7eb74f
19 files changed
+383
-13
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user