feat(webconfig): let the portal discover board-specific CLI commands

The FEM commands moved out of CommonCLI into Board::handleCommand(), and
the T-Beam 1W fan control was added there too, but the portal never caught
up: `radio.fem.txgain` and the fan commands were missing from the terminal
table entirely, while `radio.fem.rxgain` was offered on every board even
though a board with no hook now answers "??:" rather than "unsupported".

Whether a node answers these is a property of the board, not of the build,
so the page cannot know from the firmware version. Ask the board instead:
probeBoardCommands() runs each candidate getter once on the loop task at
startup and keeps the ones that answer, which needs no per-variant list
because Board::handleCommand() already reports whether it handled a
command. /api/status names the survivors and the page hides everything
else, so adding a command to a variant means one entry in WC_BOARD_CMDS
rather than an edit per board.

The two FEM keys also become Radio-panel toggles, gated the same way;
their `set` reaches the board hook through the existing config batch, so
only the allowlist and /api/config needed to grow.

webconfig_cli_audit.py now scans variants/*/*Board.cpp for the boards that
actually build the portal, checks set-only keys in the reverse direction,
and verifies every gate is a command some board answers; the mock gained
--board-cmds so both shapes of board are testable. `stop ota` joins
NOT_OFFERED: `start ota` cannot run from the portal, so it has nothing to
stop.
This commit is contained in:
agessaman committed 2026-09-19 20:51:25 -07:00
1 parent 4dca3688ea
commit 3d2d0b5da6
8 files changed
+319 -41

No files matched your search

+2 -1
View File
@@ -19,7 +19,8 @@
static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.fem.txgain",
"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",
+55 -1
View File
@@ -93,6 +93,21 @@ static inline bool wcIsDeferredReboot(const char* cmd) {
return strncmp(cmd, "reboot", 6) == 0;
}
// Commands answered by Board::handleCommand(), which CommonCLI dispatches ahead
// of its own table. Whether a node answers them is a property of the BOARD, not
// of the build, so the page cannot know from the firmware version alone. Rather
// than mirror a per-board list here — which would have to be edited every time a
// variant grows a command — ask the board itself: probeBoardCommands() runs each
// getter once and keeps the ones that answer.
static const char* const WC_BOARD_CMDS[] = {
"radio.fem.rxgain", // front-end module LNA (Heltec V4/V4 R8/Tracker V2, Station G3)
"radio.fem.txgain", // front-end module PA level (Station G3)
"fan", // thermal fan, and with it fan.lo / fan.hi (T-Beam 1W)
};
static const size_t WC_BOARD_CMD_COUNT = sizeof(WC_BOARD_CMDS) / sizeof(WC_BOARD_CMDS[0]);
static_assert(sizeof(WC_BOARD_CMDS) / sizeof(WC_BOARD_CMDS[0]) <= 8,
"_board_cmds is a uint8_t bitmask");
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
// POST so nothing in the sequence runs, rather than failing halfway with a
// reply that does not explain itself. Returns the reason, or NULL if fine.
@@ -455,6 +470,26 @@ void WebConfigServer::finalizeTeardown() {
if (_cb) _cb->onWebConfigStopped();
}
// Ask the board which of WC_BOARD_CMDS it answers, once per start. The getters
// are pure reads, and the reply says everything needed: CommonCLI answers a real
// getter "> value", while every no-answer path — the "??:" fallthrough a board
// with no hook reaches, and the "Error: unsupported" a board with the hook but
// not the hardware returns — starts with something else.
//
// Loop task only: execCommand() reaches the CLI, which the async task must not.
void WebConfigServer::probeBoardCommands() {
char cmd[48], reply[160];
uint8_t mask = 0;
for (size_t i = 0; i < WC_BOARD_CMD_COUNT; i++) {
snprintf(cmd, sizeof(cmd), "get %s", WC_BOARD_CMDS[i]);
reply[0] = 0;
_cb->execCommand(cmd, reply);
if (reply[0] == '>') mask |= (uint8_t)(1 << i);
}
_board_cmds = mask;
_board_cmds_probed = true;
}
void WebConfigServer::tick(uint32_t now) {
if (_stopping) {
uint32_t refs = handlerRefCount();
@@ -474,6 +509,8 @@ void WebConfigServer::tick(uint32_t now) {
}
if (_mode == MODE_OFF) return;
if (!_board_cmds_probed) probeBoardCommands();
if (_mode == MODE_LAN && _initial_setup && _setup_reminder_at != 0 &&
(int32_t)(now - _setup_reminder_at) >= 0) {
Serial.printf("WC: Ethernet setup http://%s/ code %s\n",
@@ -743,7 +780,7 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
bool authed = checkAuth(req);
DynamicJsonDocument doc(512);
DynamicJsonDocument doc(640);
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = !mqttNetworkSetupComplete(_obs);
@@ -757,6 +794,19 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["build_date"] = _build_date;
doc["role"] = _role;
doc["board"] = _board_name;
// Board-specific CLI commands this board answers; the page hides the controls
// for everything absent here rather than offering one that cannot work.
char board_cmds[80];
size_t n = 0;
board_cmds[0] = 0; // the nothing-supported case
for (size_t i = 0; i < WC_BOARD_CMD_COUNT && n < sizeof(board_cmds) - 1; i++) {
if (!(_board_cmds & (1 << i))) continue;
int w = snprintf(&board_cmds[n], sizeof(board_cmds) - n, "%s%s",
n ? "," : "", WC_BOARD_CMDS[i]);
if (w < 0) break;
n += (size_t)w; // snprintf NUL-terminates; a truncating w stops the loop
}
doc["board_cmds"] = board_cmds;
doc["uptime_s"] = millis() / 1000;
doc["runtime_slots"] = RUNTIME_MQTT_SLOTS;
doc["max_slots"] = MAX_MQTT_SLOTS;
@@ -867,6 +917,10 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
radio["txdelay"] = _prefs->tx_delay_factor;
radio["cad"] = (bool)_prefs->cad_enabled;
radio["rxgain"] = (bool)_prefs->rx_boosted_gain;
// FEM gain is driven by Board::handleCommand(), not CommonCLI, so these are
// the stored intent; a board with no front-end module rejects the `set`.
radio["fem_rxgain"] = (bool)_prefs->radio_fem_rxgain;
radio["fem_txgain"] = (bool)_prefs->radio_fem_txgain;
radio["repeat"] = !(bool)_prefs->disable_fwd; // CLI `repeat on` == disable_fwd 0
radio["flood_max"] = _prefs->flood_max;
radio["flood_max_advert"] = _prefs->flood_max_advert;
+7
View File
@@ -200,6 +200,12 @@ private:
uint32_t _stats_built_at = 0;
char _stats_json[1024] = {0};
// Which Board::handleCommand() commands this board actually answers, as a
// bitmask over WC_BOARD_CMDS. Probed once per start on the loop task and then
// only read, so the async status handler needs no lock for it.
uint8_t _board_cmds = 0;
bool _board_cmds_probed = false;
void createServer();
void registerRoutes();
typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*);
@@ -208,6 +214,7 @@ private:
void detachRoutes();
uint32_t handlerRefCount() const;
void drainBatch(uint32_t now);
void probeBoardCommands();
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,