mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 14:07:58 +00:00
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:
+107
-22
@@ -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 <gate>`), 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
|
||||
|
||||
Reference in New Issue
Block a user