mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 15:17:54 +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:
@@ -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 <mode>`
|
||||
- `set fan.lo <celsius>`
|
||||
- `set fan.hi <celsius>`
|
||||
|
||||
**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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+49
-10
@@ -387,6 +387,10 @@ body.tab-cli{padding-bottom:0}
|
||||
<span class="tgl"><input type="checkbox" data-k="cad"><u></u></span></div>
|
||||
<div class="sw"><span><b>RX boosted gain</b><i>SX126x receivers only</i></span>
|
||||
<span class="tgl"><input type="checkbox" data-k="radio.rxgain"><u></u></span></div>
|
||||
<div class="sw hide" id="sw-fem-rx"><span><b>FEM RX gain</b><i>External front-end module LNA</i></span>
|
||||
<span class="tgl"><input type="checkbox" data-k="radio.fem.rxgain"><u></u></span></div>
|
||||
<div class="sw hide" id="sw-fem-tx"><span><b>FEM TX gain</b><i>Front-end PA level. Station G3 needs the PA PL1 jumper removed.</i></span>
|
||||
<span class="tgl"><input type="checkbox" data-k="radio.fem.txgain"><u></u></span></div>
|
||||
<div class="sw"><span><b>Repeat</b><i>Forward mesh traffic. Off = listen-only (still observes and publishes).</i></span>
|
||||
<span class="tgl"><input type="checkbox" data-k="repeat"><u></u></span></div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
@@ -576,7 +580,7 @@ body.tab-cli{padding-bottom:0}
|
||||
<script>
|
||||
"use strict";
|
||||
var SENTINEL="********";
|
||||
var st={mode:"",authed:false,cfg:null,orig:{},dirty:{},presets:[],nslots:6,statsTimer:0,statsOn:false,statsInflight:false,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
|
||||
var st={mode:"",authed:false,cfg:null,orig:{},dirty:{},presets:[],nslots:6,boardCmds:[],statsTimer:0,statsOn:false,statsInflight:false,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
|
||||
|
||||
function $(s){return document.querySelector(s)}
|
||||
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
|
||||
@@ -642,6 +646,10 @@ function boot(){
|
||||
st.mode=s.mode;st.authed=s.auth;st.needsSetup=!!s.needs_setup;st.nslots=s.active_slots||s.runtime_slots||6;
|
||||
if(s.max_cmds>0)CLI_MAX=s.max_cmds;
|
||||
st.fw=s.fw||"";st.build=s.build_date||"";st.role=s.role||"";st.board=s.board||"";
|
||||
// Board-specific CLI commands this node answered for at startup. Controls
|
||||
// for anything absent stay hidden: on this board they are not commands.
|
||||
st.boardCmds=(s.board_cmds||"").split(",").filter(Boolean);
|
||||
applyBoardCmds();
|
||||
$("#h-name").textContent=s.name||"MeshCore";
|
||||
$("#h-sub").textContent=s.role+" · "+shortVer()+" · "+s.board;
|
||||
var b=$("#h-badge");b.classList.remove("hide");
|
||||
@@ -689,6 +697,8 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
|
||||
case"tx":return String(r.tx);case"af":return String(r.af);
|
||||
case"rxdelay":return String(r.rxdelay);case"txdelay":return String(r.txdelay);
|
||||
case"cad":return r.cad?"on":"off";case"radio.rxgain":return r.rxgain?"on":"off";
|
||||
case"radio.fem.rxgain":return r.fem_rxgain?"on":"off";
|
||||
case"radio.fem.txgain":return r.fem_txgain?"on":"off";
|
||||
case"repeat":return r.repeat?"on":"off";
|
||||
case"flood.max":return String(r.flood_max);
|
||||
case"flood.max.advert":return String(r.flood_max_advert);
|
||||
@@ -1525,7 +1535,6 @@ var CLI_KEYS=[
|
||||
["dutycycle","Duty cycle percent (writes airtime factor)",0],
|
||||
["cad","Listen before transmit",0,"on|off"],
|
||||
["radio.rxgain","SX126x RX boosted gain",0,"on|off"],
|
||||
["radio.fem.rxgain","Front-end module RX gain",0],
|
||||
["radio.watchdog","Restart the radio if silent this long {0-120 min}",0],
|
||||
["int.thresh","Interference threshold",0],
|
||||
["agc.reset.interval","AGC reset interval in seconds",0],
|
||||
@@ -1586,6 +1595,27 @@ var CLI_KEYS=[
|
||||
["bridge.channel","Bridge channel",0],
|
||||
["bridge.secret","Bridge shared secret",0]
|
||||
];
|
||||
/* Keys that come from Board::handleCommand(), not CommonCLI, so they exist only
|
||||
on some boards. The node probes its own board at startup and names the ones it
|
||||
answers in /api/status (board_cmds); the rest are never offered, because on
|
||||
this board they are not commands at all. `gate` is the probed name — fan.lo
|
||||
and fan.hi have no getter of their own and ride on `fan`.
|
||||
[gate, key, description, mode, values] */
|
||||
var CLI_BOARD_KEYS=[
|
||||
["radio.fem.rxgain","radio.fem.rxgain","Front-end module RX gain",0,"on|off"],
|
||||
["radio.fem.txgain","radio.fem.txgain","Front-end module TX gain",0,"on|off"],
|
||||
["fan","fan","Fan mode, temperature and cooldown",0,"on|off|auto"],
|
||||
["fan","fan.lo","Fan-off temperature in C {0-100, below fan.hi}",2],
|
||||
["fan","fan.hi","Fan-on temperature in C {above fan.lo, max 120}",2]
|
||||
];
|
||||
function boardHas(gate){return st.boardCmds.indexOf(gate)>=0}
|
||||
// Board-specific rows in the settings form. Hidden unless the node answered for
|
||||
// the command at startup, so the form never shows a control that cannot apply.
|
||||
function applyBoardCmds(){
|
||||
$("#sw-fem-rx").classList.toggle("hide",!boardHas("radio.fem.rxgain"));
|
||||
$("#sw-fem-tx").classList.toggle("hide",!boardHas("radio.fem.txgain"));
|
||||
}
|
||||
|
||||
// Per-slot keys, expanded across the slots this board actually runs.
|
||||
// [field, description, values]
|
||||
var CLI_SLOT=[
|
||||
@@ -1603,15 +1633,20 @@ var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,tr
|
||||
|
||||
var cli={built:0,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false,pwd:false};
|
||||
|
||||
// Rebuilt when the slot count changes: `active_slots` decides how many
|
||||
// mqttN.* keys actually exist on this board.
|
||||
// Rebuilt when the board's surface changes: `active_slots` decides how many
|
||||
// mqttN.* keys exist, and board_cmds which of the board-specific keys do.
|
||||
function cliTable(){
|
||||
if(cli.built===st.nslots)return cli.tbl;
|
||||
var built=st.nslots+"/"+st.boardCmds.join(",");
|
||||
if(cli.built===built)return cli.tbl;
|
||||
var t=CLI_VERBS.slice();
|
||||
CLI_KEYS.forEach(function(k){
|
||||
var vals=k[3]?" {"+k[3]+"}":"";
|
||||
if(k[2]!==2)t.push(["get "+k[0],k[1]]);
|
||||
if(k[2]!==1)t.push(["set "+k[0]+" ",k[1]+vals]);
|
||||
function addKey(key,desc,mode,vals){
|
||||
vals=vals?" {"+vals+"}":"";
|
||||
if(mode!==2)t.push(["get "+key,desc]);
|
||||
if(mode!==1)t.push(["set "+key+" ",desc+vals]);
|
||||
}
|
||||
CLI_KEYS.forEach(function(k){addKey(k[0],k[1],k[2],k[3])});
|
||||
CLI_BOARD_KEYS.forEach(function(k){
|
||||
if(boardHas(k[0]))addKey(k[1],k[2],k[3],k[4]);
|
||||
});
|
||||
for(var n=1;n<=st.nslots;n++){
|
||||
CLI_SLOT.forEach(function(f){
|
||||
@@ -1619,7 +1654,7 @@ function cliTable(){
|
||||
t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]);
|
||||
});
|
||||
}
|
||||
cli.tbl=t;cli.built=st.nslots;
|
||||
cli.tbl=t;cli.built=built;
|
||||
return t;
|
||||
}
|
||||
// Value completions for `set <key> `. Returns null when the key has no enum,
|
||||
@@ -1634,6 +1669,10 @@ function cliEnum(key){
|
||||
for(var i=0;i<CLI_KEYS.length;i++){
|
||||
if(CLI_KEYS[i][0]===key)return CLI_KEYS[i][3]?CLI_KEYS[i][3].split("|"):null;
|
||||
}
|
||||
for(var j=0;j<CLI_BOARD_KEYS.length;j++){
|
||||
var b=CLI_BOARD_KEYS[j];
|
||||
if(b[1]===key&&boardHas(b[0]))return b[4]?b[4].split("|"):null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user