diff --git a/docs/cli_commands.md b/docs/cli_commands.md index aec48720..5001f699 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -322,6 +322,27 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a --- +#### View the fan state, or change how it is driven, on supported boards +**Usage:** +- `get fan` +- `set fan ` +- `set fan.lo ` +- `set fan.hi ` + +**Parameters:** +- `mode`: `on`|`off`|`auto` +- `celsius`: `fan.lo` is 0-100 and must be below `fan.hi`; `fan.hi` is at most 120 and must be above `fan.lo` + +**Default:** `auto`, with `fan.lo` 45 and `fan.hi` 60 + +**Notes:** +- Currently the T-Beam 1W, the only board with a fan under software control. +- `get fan` reports the mode, the measured temperature, whether the fan is running, and the remaining cooldown, e.g. `auto 52.4C fan=on cd=18s`. The temperature reads `n/a` when the NTC value is implausible. +- In `auto` the fan starts at `fan.hi` and stops at `fan.lo`; the gap between them is what keeps it from chattering around one threshold. +- `fan.lo` and `fan.hi` are set-only: `get fan` reports the mode and current state, not the thresholds. + +--- + ### System #### View or change this node's name diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index 3cd24db4..1b3767d4 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -15,6 +15,7 @@ that come back an error, so the two stay honest about each other. Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only. """ +import glob import json import os import re @@ -30,12 +31,7 @@ INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") # Errors that are the correct answer, not a gap. EXPECTED_FAILURES = { - # Runtime-gated on the real device by Board::canControlLoRaFemLna(); the - # command exists in every build and the board answers for itself. The mock - # board is a Heltec V3, which has no front-end module. - "get radio.fem.rxgain": "unsupported", - "set radio.fem.rxgain on": "unsupported", - # Guarded by the firmware the same way when no alert PSK is configured. + # Guarded by the firmware when no alert PSK is configured. "alert test": "not configured", } @@ -44,8 +40,11 @@ SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota", "stop webconfig", "ota update", "start webconfig", "start webconfig ap"} -def table(): - """The commands autocomplete offers, read straight out of the page.""" +def table(board_cmds=()): + """The commands autocomplete offers, read straight out of the page. + + `board_cmds` is what /api/status said this board answers; the CLI_BOARD_KEYS + entries gated on anything else are not offered and so not driven.""" html = open(INDEX_HTML, encoding="utf-8").read() def section(start, end): @@ -53,17 +52,27 @@ def table(): verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS=")) keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)', - section("var CLI_KEYS=", "var CLI_SLOT=")) + section("var CLI_KEYS=", "var CLI_BOARD_KEYS=")) fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES=")) gets = ["get " + k for k, mode in keys if mode != "2"] gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields] # Verbs taking an argument need a value the node will accept; those are # covered by the round-trip probes below rather than guessed at here. + gets += ["get " + k for gate, k, mode in board_table() + if mode != "2" and gate in board_cmds] plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP] return gets + plain +def board_table(): + """[(gate, key, mode)] from CLI_BOARD_KEYS — the keys the page offers only + when /api/status says this board answers for their gate.""" + html = open(INDEX_HTML, encoding="utf-8").read() + section = html[html.index("var CLI_BOARD_KEYS="):html.index("// Per-slot keys")] + return re.findall(r'\["([^"]+)","([^"]+)","(?:[^"\\]|\\.)*",(\d)', section) + + # Where top-level commands are implemented. MyMesh handles a few before # delegating to CommonCLI, which is exactly how discover.* stayed missing from # the table for so long: grepping CommonCLI alone does not see them. @@ -73,17 +82,56 @@ COMMAND_SOURCES = [ "examples/simple_repeater/MyMesh.cpp", ] -# Firmware commands the table deliberately does not offer. Everything below -# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the -# portal never pretends to run something it cannot. +# Board::handleCommand() is dispatched BEFORE CommonCLI's own table, and the FEM +# commands now live there rather than in CommonCLI. A board file contributes +# whole commands ("get radio.fem.rxgain"), not the bare verbs the sources above +# yield, so the two are collected separately and merged. +BOARD_SOURCES = "variants/*/*Board.cpp" + +# Firmware commands the table deliberately does not offer. NOT_OFFERED = { "tls.bundletest", # TLS debugging, not an operator command "start ota", # binds port 80, which the portal is already using + "stop ota", # nothing to stop: `start ota` cannot run from here "clock sync", # takes its time from the caller; a web request has none "log", # streams to Serial and stalls the radio ("log start" is offered) "get acl", # streams to Serial, returns nothing } +# Of those, the ones /api/cli does NOT reject at POST (wcCliUnavailable). They +# are left out of the table rather than blocked, because running them is +# harmless — `stop ota` just reports that no OTA server is running, which is +# always true here. Everything else in NOT_OFFERED must come back a 400 with a +# reason, so the portal never pretends to run something it cannot. +NOT_REFUSED = {"tls.bundletest", "stop ota"} + + +def webconfig_variants(): + """Variant directories whose build serves the portal (ESP32 + MQTT bridge). + + Only these boards can put a command in front of this page; a board command + on an nRF52 variant is real but unreachable from here, so it is not a gap. + """ + out = set() + for ini in glob.glob(os.path.join(HERE, "..", "variants", "*", "platformio.ini")): + if "WITH_MQTT_BRIDGE" in open(ini, encoding="utf-8").read(): + out.add(os.path.basename(os.path.dirname(ini))) + return out + + +def board_commands(): + """Whole commands Board::handleCommand() answers, across portal variants.""" + variants = webconfig_variants() + found = set() + for path in glob.glob(os.path.join(HERE, "..", *BOARD_SOURCES.split("/"))): + if os.path.basename(os.path.dirname(path)) not in variants: + continue + src = open(path, encoding="utf-8").read() + body = src[src.find("::handleCommand"):] + for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', body): + found.add(lit.strip()) + return found + def firmware_commands(): """Top-level command literals the firmware dispatches on.""" @@ -96,7 +144,7 @@ def firmware_commands(): continue for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src): found.add(lit.strip()) - return found - NOT_OFFERED + return (found | board_commands()) - NOT_OFFERED ROUND_TRIPS = [ ("set radio.watchdog 30", "get radio.watchdog", "30"), ("set dutycycle 25", "get dutycycle", "25.0"), @@ -105,6 +153,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"), + # 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 # words for a non-serial one (wcIsSecretReadCommand). ("set guest.password hunter2", "get guest.password", "******** (serial only)"), @@ -112,6 +162,14 @@ ROUND_TRIPS = [ ] +# Gated on /api/status's board_cmds, keyed by the gate the page probes for. +BOARD_ROUND_TRIPS = { + "radio.fem.rxgain": [("set radio.fem.rxgain off", "get radio.fem.rxgain", "off")], + "radio.fem.txgain": [("set radio.fem.txgain on", "get radio.fem.txgain", "on")], + "fan": [("set fan on", "get fan", "on 41.0C fan=on cd=0s")], +} + + class Client: def __init__(self, base): self.base = base @@ -119,7 +177,10 @@ class Client: self.cookie = r.headers["Set-Cookie"].split(";")[0] # The node caps a sequence at MAX_BATCH and reports it; chunk to match # rather than hardcoding a number that drifts when the slot is resized. - self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24) + status = json.load(self._open("/api/status")) + self.max_cmds = status.get("max_cmds", 24) + # Board::handleCommand() commands this node probed for at startup. + self.board_cmds = [c for c in status.get("board_cmds", "").split(",") if c] def _open(self, path, data=None): headers = {"Content-Type": "application/json"} @@ -172,7 +233,7 @@ def main(): failures = [] - cmds = table() + cmds = table(cli.board_cmds) unexpected = [] for cmd, res in cli.run(cmds): if res["ok"]: @@ -182,6 +243,7 @@ def main(): continue unexpected.append((cmd, res["reply"])) print("commands offered by autocomplete : %d" % len(cmds)) + print("board commands this node answers : %s" % (", ".join(cli.board_cmds) or "none")) print("answered : %d" % (len(cmds) - len(unexpected))) print("sequence cap reported by the node: %d" % cli.max_cmds) for cmd, reply in unexpected: @@ -191,17 +253,39 @@ def main(): # The reverse direction: a command the firmware implements but the table # never offers is invisible to the check above, because the check only ever # drives what the table already knows about. - offered = " ".join(cmds) + " " + " ".join( - re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read())) + # A board command is a whole `get`/`set` line, so the get-only list the audit + # drives cannot decide it is offered: `set fan.lo` has no `get` counterpart in + # the table at all, and a board key this mock does not claim is absent from + # `cmds` while still being offerable. Both are expanded from the page here. + html = open(INDEX_HTML, encoding="utf-8").read() + keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)', + html[html.index("var CLI_KEYS="):html.index("var CLI_BOARD_KEYS=")]) + offered = " ".join(cmds) + " " \ + + " ".join("set " + k for k, mode in keys if mode != "1") + " " \ + + " ".join("get %s set %s" % (k, k) for _, k, _ in board_table()) + " " \ + + " ".join(re.findall(r'\["([^"]+)","', html)) missing = sorted(c for c in firmware_commands() if c not in offered) print("\nfirmware commands not in the table: %d" % len(missing)) for c in missing: print(" MISSING %s" % c) failures += [(c, "not offered by autocomplete") for c in missing] - results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]]) - print("\nround-trips : %d" % len(ROUND_TRIPS)) - for i, (setc, getc, want) in enumerate(ROUND_TRIPS): + # The page asks the node for each gate by name (`get `), so a gate that + # no board getter answers would hide its keys on every board, silently. + fw = firmware_commands() + gates = sorted({gate for gate, _, _ in board_table()}) + bad_gates = [g for g in gates if "get " + g not in fw] + print("\nboard-command gates : %d" % len(gates)) + for g in bad_gates: + print(" FAIL %-30s no board answers `get %s`" % (g, g)) + failures += [(g, "gate has no getter") for g in bad_gates] + + probes = list(ROUND_TRIPS) + for gate in cli.board_cmds: + probes += BOARD_ROUND_TRIPS.get(gate, []) + results = cli.run([c for probe in probes for c in probe[:2]]) + print("\nround-trips : %d" % len(probes)) + for i, (setc, getc, want) in enumerate(probes): setr, getr = results[i * 2][1], results[i * 2 + 1][1] # `get` answers "> value"; compare the value, as the terminal displays it got = re.sub(r"^>\s?", "", getr["reply"]) @@ -214,7 +298,7 @@ def main(): # Commands the portal refuses must be refused clearly, not run and fudged. print("\nrefused with a reason : ", end="") refused = [] - for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}): + for cmd in sorted(NOT_OFFERED - NOT_REFUSED): try: cli._sequence([cmd]) refused.append((cmd, "was accepted, expected a 400")) @@ -222,7 +306,8 @@ def main(): body = json.load(e) if e.code == 400 else {} if e.code != 400 or not body.get("error"): refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code)) - print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1)) + checked = len(NOT_OFFERED) - len(NOT_REFUSED) + print("%d/%d" % (checked - len(refused), checked)) for cmd, why in refused: print(" FAIL %-30s %s" % (cmd, why)) failures += refused diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 74c6b730..4a36e555 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -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", diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index 47fd4149..c518be9a 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -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", diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 6aab8da0..e14088be 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -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; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 16ff47ba..b9083447 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -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, diff --git a/test/test_webconfig_keys/test_webconfig_keys.cpp b/test/test_webconfig_keys/test_webconfig_keys.cpp index 3903453b..9ce017f7 100644 --- a/test/test_webconfig_keys/test_webconfig_keys.cpp +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -10,6 +10,8 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) { EXPECT_TRUE(wcIsAllowedSetKey("name")); EXPECT_TRUE(wcIsAllowedSetKey("radio")); EXPECT_TRUE(wcIsAllowedSetKey("repeat")); + EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.rxgain")); // Board::handleCommand(), not CommonCLI + EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.txgain")); EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid")); EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata")); EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors")); diff --git a/webui/index.html b/webui/index.html index 3102d352..c1c72e49 100644 --- a/webui/index.html +++ b/webui/index.html @@ -387,6 +387,10 @@ body.tab-cli{padding-bottom:0}
RX boosted gainSX126x receivers only
+
FEM RX gainExternal front-end module LNA +
+
FEM TX gainFront-end PA level. Station G3 needs the PA PL1 jumper removed. +
RepeatForward mesh traffic. Off = listen-only (still observes and publishes).
@@ -576,7 +580,7 @@ body.tab-cli{padding-bottom:0}