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

+76 -7
View File
@@ -101,6 +101,9 @@ def default_config(setup_mode):
"radio": {
"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0,
"rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True,
# Stored in NodePrefs like any other radio pref; only the COMMAND
# is board-specific, so the value exists even where nothing drives it.
"fem_rxgain": True, "fem_txgain": False,
"repeat": True, "flood_max": 64, "flood_max_advert": 8,
"flood_max_unscoped": 8, "loop_detect": "moderate",
"name": "MockNode", "lat": 39.7392, "lon": -104.9903,
@@ -149,6 +152,9 @@ class State:
self.lock = threading.Lock()
self.setup_mode = args.setup
self.active_slots = args.active_slots
# Board::handleCommand() commands this "board" answers. Empty by default:
# the mock is a Heltec V3, which implements no such hook at all.
self.board_cmds = [c for c in args.board_cmds.split(",") if c]
self.cfg = default_config(args.setup)
# latched at AP start, like WebConfigServer::_initial_setup
self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == ""
@@ -192,6 +198,7 @@ class State:
"role": "Repeater", "board": "Heltec V3 (mock)",
"uptime_s": int(time.time() - self.start),
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
"board_cmds": ",".join(self.board_cmds),
"max_cmds": CLI_MAX_CMDS,
}
@@ -253,9 +260,6 @@ def apply_set(cfg, key, val):
ADMIN_PASSWORD = val
return True, "OK"
if key == "radio.fem.rxgain":
return False, "Error: unsupported" # no FEM on the mock board, see GETTERS
if key == "dutycycle":
try:
dc = float(val)
@@ -340,13 +344,74 @@ def apply_set(cfg, key, val):
sec, f = STR_KEYS[key]
cfg[sec][f] = val
return True, "OK"
gate = BOARD_CMD_GATE.get(key)
if gate:
if gate not in ST.board_cmds:
return False, "unknown config: %s" % key # no such command on this board
return apply_board_set(cfg, key, val)
# Strict fallthrough: this function is the single authority on what can be
# set, for the batch and the CLI alike. Accepting unknown keys here once hid
# the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`.
# Verbatim shape from CommonCLI::handleSetCmd's fallthrough.
#
# This is also where the board-specific commands land. `radio.fem.*` and
# `fan*` come from Board::handleCommand(), which is dispatched ahead of
# CommonCLI; the mock board is a Heltec V3 and implements no such hook, so
# they reach this fallthrough exactly as they do on the real thing.
return False, "unknown config: %s" % key
# Commands that reach Board::handleCommand() rather than CommonCLI. Which ones a
# node answers is a property of the board, so the portal probes for them at
# startup and reports the answers in /api/status; --board-cmds picks which ones
# this mock claims. `fan.lo` / `fan.hi` have no getter and ride on `fan`.
BOARD_CMD_GATE = {
"radio.fem.rxgain": "radio.fem.rxgain",
"radio.fem.txgain": "radio.fem.txgain",
"fan": "fan",
"fan.lo": "fan",
"fan.hi": "fan",
}
BOARD_STATE = {"fan": "auto", "fan.lo": 45, "fan.hi": 60}
FEM_FIELD = {"radio.fem.rxgain": "fem_rxgain", "radio.fem.txgain": "fem_txgain"}
def apply_board_set(cfg, key, val):
if key in FEM_FIELD:
if val not in ("on", "off"):
return False, "Error: state must be on or off"
cfg["radio"][FEM_FIELD[key]] = val == "on"
return True, "OK - LoRa FEM %s gain %s" % ("RX" if "rx" in key else "TX", val)
if key == "fan":
if val not in ("on", "off", "auto"):
return False, "Error: fan must be on, off, or auto"
BOARD_STATE["fan"] = val
return True, "OK - fan %s" % val
try:
n = int(val)
except ValueError:
return False, "Error: expected a number"
if key == "fan.lo" and not (0 <= n <= 100 and n < BOARD_STATE["fan.hi"]):
return False, "Error: fan.lo must be 0..100 and < fan.hi"
if key == "fan.hi" and not (BOARD_STATE["fan.lo"] < n <= 120):
return False, "Error: fan.hi must be > fan.lo and <= 120"
BOARD_STATE[key] = n
return True, "OK - %s %d" % (key, n)
def board_get(cfg, key):
"""Getter reply, or None when this board does not answer the command."""
if BOARD_CMD_GATE.get(key) not in ST.board_cmds:
return None
if key == "fan":
return "%s 41.0C fan=%s cd=0s" % (
BOARD_STATE["fan"], "on" if BOARD_STATE["fan"] == "on" else "off")
if key in FEM_FIELD:
return "on" if cfg["radio"][FEM_FIELD[key]] else "off"
return None # fan.lo / fan.hi are set-only on the board too
# Payload-type names accepted alongside the decimal form. Mirrors
# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved
# upstream and stay reachable by number only.
@@ -516,10 +581,6 @@ GETTERS = {
"mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"),
"mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1"
% (100 + int(time.time() - ST.start))),
# Runtime-gated on the real device (Board::canControlLoRaFemLna), not
# compiled out — the command exists everywhere and the board answers for
# itself. The mock board is a Heltec V3, which has no FEM.
"radio.fem.rxgain": lambda c: None,
}
@@ -547,6 +608,11 @@ def cli_get(cfg, key):
def _cli_get_value(cfg, key):
if key in BOARD_CMD_GATE:
val = board_get(cfg, key)
# Not answered: the board has no hook for it, so CommonCLI's own getter
# fallthrough is what replies — exactly as on the real thing.
return (True, val) if val is not None else (False, "??: %s" % key)
if key in GETTERS:
val = GETTERS[key](cfg)
return (True, val) if val is not None else (False, "Error: unsupported")
@@ -1053,6 +1119,9 @@ def main():
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode")
ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)")
ap.add_argument("--board-cmds", default="",
help="comma list of Board::handleCommand() commands to answer, e.g. "
"radio.fem.rxgain,radio.fem.txgain,fan (default: none, like a Heltec V3)")
ap.add_argument("--fw-version", default=FW_VERSION,
help="version string to report, shaped like build.sh's embedded one")
ap.add_argument("--minify", action="store_true",