feat(webconfig): implement /api/cli on the device
The terminal has been driving the mock since it was built. This is the firmware
side, so it works on hardware.
Same 202 + reqid + poll contract as a config save, for the same reason:
CommonCLI touches prefs, the radio and the filesystem, none of which may be
reached from the async_tcp task. Commands go into the deferred slot and tick()
drains them on the loop task. Unlike a save this is not allowlisted — reaching
what the serial console reaches is the point, and execCommand() already passes
sender_timestamp 0, so the terminal gets exactly the serial console's
privilege. Authentication is the boundary, as it is there.
The CLI shares the config batch's slot rather than owning a second MAX_BATCH
array: both drain on the loop task, both are single-slot, and a duplicate would
cost ~8 KB of permanently resident RAM. Sharing also makes a save and a CLI run
mutually exclusive, which they must be. Each reader checks the kind, so neither
can serve the other's results.
Three things the mock could not have taught us:
- Board::reboot() does not return, so a drained `reboot` would take the node
down before the client read a single result. It is answered rather than
executed, and the batch arms the existing deferred-reboot path once the
results have been read — withheld if any command failed, exactly as a save
withholds one. clkreboot/poweroff/ota update do real work on the way down
and cannot be faked, so they still drop the connection; the UI warns first.
- `password <new>` echoes the new password in its reply. The config path
already scrubbed that by key; a CLI entry has no key, so it is matched on
the command. CLI commands are also kept out of the serial log entirely —
the browser session and the serial console are different audiences.
- MAX_BATCH is 24, not the 64 the page assumed. It is reported as
status.max_cmds instead of hardcoded, so the cap cannot drift.
Results stream and page (kCliResultPage = 8), and "done" means the client has
been handed every result, not merely that execution finished — otherwise a
client that stops polling at "done" loses the last page. Commands are never
echoed back: they may carry a secret, and the client matches by index.
New decisions live in WebConfigBatch.h with the rest, covered by three host
tests. Builds clean for heltec_v4_repeater_observer_mqtt; 22 batch + 14 keys
tests pass; the CLI audit reports 119/119 against the updated mock.
This commit is contained in:
1 parent
58b8b90560
commit
556d02f765
7 files changed
+446
-37
No files matched your search
@@ -82,6 +82,9 @@ class Client:
|
||||
self.base = base
|
||||
r = self._open("/api/login", b'{"password":"password"}')
|
||||
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)
|
||||
|
||||
def _open(self, path, data=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -91,10 +94,16 @@ class Client:
|
||||
self.base + path, data=data, headers=headers,
|
||||
method="POST" if data is not None else "GET"))
|
||||
|
||||
def run(self, cmds, chunk=40):
|
||||
def run(self, cmds):
|
||||
"""[(command, result)]. The node never echoes the command back — it may
|
||||
carry a secret — so results pair with what was sent, by index."""
|
||||
out = []
|
||||
for i in range(0, len(cmds), chunk):
|
||||
out += self._sequence(cmds[i:i + chunk])
|
||||
for i in range(0, len(cmds), self.max_cmds):
|
||||
chunk = cmds[i:i + self.max_cmds]
|
||||
results = self._sequence(chunk)
|
||||
if len(results) != len(chunk):
|
||||
sys.exit("node returned %d results for %d commands" % (len(results), len(chunk)))
|
||||
out += list(zip(chunk, results))
|
||||
return out
|
||||
|
||||
def _sequence(self, cmds):
|
||||
@@ -108,10 +117,14 @@ class Client:
|
||||
if e.code != 409:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
# Results stream and page, so keep reading from a cursor until the node
|
||||
# says done — "done" arrives only once every result has been handed over.
|
||||
out = []
|
||||
while True:
|
||||
r = json.load(self._open("/api/cli/result?reqid=" + reqid))
|
||||
r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out))))
|
||||
out += r.get("results", [])
|
||||
if r["state"] == "done":
|
||||
return r["results"]
|
||||
return out
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
@@ -126,15 +139,16 @@ def main():
|
||||
|
||||
cmds = table()
|
||||
unexpected = []
|
||||
for res in cli.run(cmds):
|
||||
for cmd, res in cli.run(cmds):
|
||||
if res["ok"]:
|
||||
continue
|
||||
want = EXPECTED_FAILURES.get(res["cmd"])
|
||||
want = EXPECTED_FAILURES.get(cmd)
|
||||
if want and want in res["reply"]:
|
||||
continue
|
||||
unexpected.append((res["cmd"], res["reply"]))
|
||||
unexpected.append((cmd, res["reply"]))
|
||||
print("commands offered by autocomplete : %d" % len(cmds))
|
||||
print("answered : %d" % (len(cmds) - len(unexpected)))
|
||||
print("sequence cap reported by the node: %d" % cli.max_cmds)
|
||||
for cmd, reply in unexpected:
|
||||
print(" FAIL %-30s %s" % (cmd, reply))
|
||||
failures += unexpected
|
||||
@@ -142,7 +156,7 @@ def main():
|
||||
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):
|
||||
setr, getr = results[i * 2], results[i * 2 + 1]
|
||||
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
|
||||
if setr["ok"] and getr["reply"] == want:
|
||||
continue
|
||||
print(" FAIL %-30s got %r, wanted %r (set: %s)"
|
||||
|
||||
Reference in new issue
Block a user