mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-08-28 21:48:17 +00:00
Merge pull request #43 from agessaman/feat/webconfig-cli
Feat/webconfig cli
This commit is contained in:
+5
-2
@@ -25,7 +25,10 @@ platformio.local.ini
|
||||
.cursor/*
|
||||
.claude/*
|
||||
.cursorrules
|
||||
# Throwaway build worktrees; committing one adds a stray gitlink that makes every
|
||||
# CI checkout warn "No url found for submodule path ... in .gitmodules".
|
||||
# Worktrees checked out inside the repo. Committing one adds a stray gitlink
|
||||
# that makes every CI checkout warn "No url found for submodule path ... in
|
||||
# .gitmodules", and leaves `git status` permanently dirty so the next `git add
|
||||
# -A` re-commits it. .wt-* covers the hand-made ones; .build-wt-* the CI ones.
|
||||
.build-wt-*/
|
||||
.wt-*/
|
||||
scripts/__pycache__/*
|
||||
|
||||
Submodule .wt-station-g3-prod deleted from bfc43e94f8
@@ -1438,7 +1438,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
|
||||
}
|
||||
if (!_webconfig) {
|
||||
_webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this,
|
||||
self_id.pub_key, getFirmwareVer(), getRole(),
|
||||
self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(),
|
||||
_cli.getBoard()->getManufacturerName());
|
||||
}
|
||||
if (force_ap) {
|
||||
|
||||
@@ -1263,7 +1263,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
|
||||
}
|
||||
if (!_webconfig) {
|
||||
_webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this,
|
||||
self_id.pub_key, getFirmwareVer(), getRole(),
|
||||
self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(),
|
||||
_cli.getBoard()->getManufacturerName());
|
||||
}
|
||||
if (force_ap) {
|
||||
|
||||
@@ -14,39 +14,55 @@
|
||||
# hashes two small files and returns, so running it from esp32_base on every
|
||||
# ESP32 build is negligible even for targets that don't compile the portal.
|
||||
#
|
||||
# Comments and indentation are stripped before compressing (see strip_source).
|
||||
# The source page is heavily commented by house style and none of it is worth
|
||||
# flash, so the page ships smaller than it reads.
|
||||
#
|
||||
# Output: src/helpers/esp32/WebConfigHtml.h
|
||||
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
|
||||
# WEBCONFIG_HTML_GZ_LEN - byte length
|
||||
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
|
||||
#
|
||||
# Runnable outside SCons to inspect exactly what gets shipped:
|
||||
# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html
|
||||
# or served directly by the mock backend with its --minify flag.
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
Import("env") # noqa: F821
|
||||
try:
|
||||
Import("env") # noqa: F821
|
||||
except NameError:
|
||||
pass # running standalone (--emit), not as a PIO extra_script
|
||||
|
||||
SOURCE = os.path.join("webui", "index.html")
|
||||
OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h")
|
||||
# __file__ is not defined inside PIO/SCons-executed extra_scripts
|
||||
SCRIPT = os.path.join("scripts", "generate_webconfig_html.py")
|
||||
MINIFIER = os.path.join("scripts", "webconfig_minify.py")
|
||||
HASH_MARKER = "// build-inputs-sha256: "
|
||||
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
|
||||
from webconfig_minify import check_stripped, strip_source # noqa: E402
|
||||
|
||||
|
||||
def status(msg):
|
||||
sys.stderr.write("WebConfig HTML: %s\n" % msg)
|
||||
|
||||
|
||||
def content_hash():
|
||||
# Hash the source page and this generator so any change to either forces a
|
||||
# regenerate, independent of file timestamps.
|
||||
# Hash the source page, this generator and the minifier so any change to
|
||||
# any of them forces a regenerate, independent of file timestamps.
|
||||
h = hashlib.sha256()
|
||||
with open(SOURCE, "rb") as f:
|
||||
h.update(f.read())
|
||||
if os.path.isfile(SCRIPT):
|
||||
h.update(b"\0")
|
||||
with open(SCRIPT, "rb") as f:
|
||||
h.update(f.read())
|
||||
for path in (SCRIPT, MINIFIER):
|
||||
if os.path.isfile(path):
|
||||
h.update(b"\0")
|
||||
with open(path, "rb") as f:
|
||||
h.update(f.read())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
@@ -63,17 +79,37 @@ def stored_hash():
|
||||
return None
|
||||
|
||||
|
||||
def shipped_page():
|
||||
"""The exact bytes the device serves: the source page, stripped."""
|
||||
with open(SOURCE, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
stripped = strip_source(raw)
|
||||
problem = check_stripped(raw, stripped)
|
||||
if problem:
|
||||
status("ERROR: comment stripping corrupted the page (%s)" % problem)
|
||||
sys.exit(2)
|
||||
return raw, stripped
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(SOURCE):
|
||||
status("ERROR: %s not found" % SOURCE)
|
||||
sys.exit(2)
|
||||
|
||||
if "--emit" in sys.argv:
|
||||
dest = sys.argv[sys.argv.index("--emit") + 1]
|
||||
src, stripped = shipped_page()
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(stripped)
|
||||
status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped)))
|
||||
return
|
||||
|
||||
src_hash = content_hash()
|
||||
if os.path.isfile(OUTPUT) and stored_hash() == src_hash:
|
||||
return
|
||||
|
||||
with open(SOURCE, "rb") as f:
|
||||
raw = f.read()
|
||||
src, stripped = shipped_page()
|
||||
raw = stripped.encode("utf-8")
|
||||
|
||||
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
|
||||
gz = gzip.compress(raw, compresslevel=9, mtime=0)
|
||||
@@ -100,7 +136,8 @@ def main():
|
||||
with open(OUTPUT, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
status("%s -> %s (%d bytes raw, %d bytes gzipped)" % (SOURCE, OUTPUT, len(raw), len(gz)))
|
||||
status("%s -> %s (%d bytes source, %d stripped, %d gzipped)"
|
||||
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
|
||||
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the portal terminal's command table against the mock backend.
|
||||
|
||||
Autocomplete in webui/index.html carries its own list of commands. Nothing ties
|
||||
that list to what a node actually answers, so it can quietly drift into offering
|
||||
commands that do not exist — or, more often here, the mock can lag the table and
|
||||
make a perfectly real command look broken.
|
||||
|
||||
This drives every command the table offers through /api/cli and reports the ones
|
||||
that come back an error, so the two stay honest about each other.
|
||||
|
||||
python3 scripts/webconfig_mock_server.py --port 8137 &
|
||||
python3 scripts/webconfig_cli_audit.py
|
||||
|
||||
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("WEBCONFIG_MOCK", "http://localhost:8137")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
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.
|
||||
"alert test": "not configured",
|
||||
}
|
||||
|
||||
# Commands that change the node out from under the audit.
|
||||
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."""
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
|
||||
def section(start, end):
|
||||
return html[html.index(start):html.index(end)]
|
||||
|
||||
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
|
||||
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
|
||||
section("var CLI_KEYS=", "var CLI_SLOT="))
|
||||
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.
|
||||
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
|
||||
return gets + plain
|
||||
|
||||
|
||||
# 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.
|
||||
COMMAND_SOURCES = [
|
||||
"src/helpers/CommonCLI.cpp",
|
||||
"src/helpers/CommonCLI_Observer.cpp",
|
||||
"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.
|
||||
NOT_OFFERED = {
|
||||
"tls.bundletest", # TLS debugging, not an operator command
|
||||
"start ota", # binds port 80, which the portal is already using
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
def firmware_commands():
|
||||
"""Top-level command literals the firmware dispatches on."""
|
||||
found = set()
|
||||
for rel in COMMAND_SOURCES:
|
||||
path = os.path.join(HERE, "..", rel)
|
||||
try:
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except OSError:
|
||||
continue
|
||||
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src):
|
||||
found.add(lit.strip())
|
||||
return found - NOT_OFFERED
|
||||
ROUND_TRIPS = [
|
||||
("set radio.watchdog 30", "get radio.watchdog", "30"),
|
||||
("set dutycycle 25", "get dutycycle", "25.0"),
|
||||
("set alert.mqtt on", "get alert.mqtt", "on"),
|
||||
("set bridge.source tx", "get bridge.source", "tx"),
|
||||
("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"),
|
||||
# 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)"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base):
|
||||
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"}
|
||||
if getattr(self, "cookie", None):
|
||||
headers["Cookie"] = self.cookie
|
||||
return urllib.request.urlopen(urllib.request.Request(
|
||||
self.base + path, data=data, headers=headers,
|
||||
method="POST" if data is not None else "GET"))
|
||||
|
||||
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), 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):
|
||||
reqid = secrets.token_hex(8)
|
||||
body = json.dumps({"reqid": reqid, "cmds": cmds}).encode()
|
||||
for _ in range(200): # the executor frees itself in time
|
||||
try:
|
||||
self._open("/api/cli", body)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
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=%s&from=%d" % (reqid, len(out))))
|
||||
out += r.get("results", [])
|
||||
if r["state"] == "done":
|
||||
return out
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
cli = Client(BASE)
|
||||
except OSError as e:
|
||||
sys.exit("cannot reach the mock at %s (%s)\n"
|
||||
"start it with: python3 scripts/webconfig_mock_server.py --port 8137" % (BASE, e))
|
||||
|
||||
failures = []
|
||||
|
||||
cmds = table()
|
||||
unexpected = []
|
||||
for cmd, res in cli.run(cmds):
|
||||
if res["ok"]:
|
||||
continue
|
||||
want = EXPECTED_FAILURES.get(cmd)
|
||||
if want and want in res["reply"]:
|
||||
continue
|
||||
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
|
||||
|
||||
# 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()))
|
||||
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):
|
||||
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"])
|
||||
if setr["ok"] and got == want:
|
||||
continue
|
||||
print(" FAIL %-30s got %r, wanted %r (set: %s)"
|
||||
% (getc, got, want, setr["reply"]))
|
||||
failures.append((getc, got))
|
||||
|
||||
# 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"}):
|
||||
try:
|
||||
cli._sequence([cmd])
|
||||
refused.append((cmd, "was accepted, expected a 400"))
|
||||
except urllib.error.HTTPError as e:
|
||||
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))
|
||||
for cmd, why in refused:
|
||||
print(" FAIL %-30s %s" % (cmd, why))
|
||||
failures += refused
|
||||
|
||||
print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear"))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comment/indentation stripping for webui/index.html.
|
||||
|
||||
Shared by the build-time generator (which embeds the stripped page in flash)
|
||||
and the mock backend's --minify flag (which serves it), so what you test in a
|
||||
browser is byte-for-byte what the device ships. A second implementation would
|
||||
only drift.
|
||||
|
||||
Stdlib only; imported from a PIO extra_script, so it must stay side-effect free.
|
||||
"""
|
||||
|
||||
# Comment openers/closers per region of the page. The region is tracked so a
|
||||
# `<!--` inside <script> is never treated as a comment, and vice versa.
|
||||
_COMMENTS = {
|
||||
"html": [("<!--", "-->")],
|
||||
"css": [("/*", "*/")],
|
||||
"js": [("/*", "*/")],
|
||||
}
|
||||
|
||||
|
||||
def strip_source(text):
|
||||
"""Drop comments, indentation and blank lines from the page.
|
||||
|
||||
Deliberately line-based and conservative. Only a comment that *starts* its
|
||||
own line is removed, so a `//` inside a URL or a `/*` inside a regex is
|
||||
never mistaken for one; trailing comments survive, which costs a little
|
||||
flash and removes the entire class of "the minifier ate a string" bug.
|
||||
|
||||
Line breaks are preserved. That keeps JS statement boundaries exactly as
|
||||
written (no ASI surprises) and keeps the single collapsed space a newline
|
||||
contributes between HTML inline elements.
|
||||
"""
|
||||
out, mode, closer = [], "html", None
|
||||
for line in text.split("\n"):
|
||||
s = line.strip()
|
||||
|
||||
if closer is not None: # inside a multi-line comment
|
||||
at = s.find(closer)
|
||||
if at < 0:
|
||||
continue
|
||||
s = s[at + len(closer):].strip() # code may follow the close
|
||||
closer = None
|
||||
|
||||
if not s:
|
||||
continue
|
||||
|
||||
for opener, close in _COMMENTS[mode]:
|
||||
if not s.startswith(opener):
|
||||
continue
|
||||
if s.endswith(close) and len(s) > len(opener) + len(close) - 1:
|
||||
s = "" # the whole line is a comment
|
||||
elif close not in s[len(opener):]:
|
||||
closer = close # ... and it continues below
|
||||
s = ""
|
||||
break # else code follows the close
|
||||
if not s: # on this line: leave it be
|
||||
continue
|
||||
|
||||
if mode == "js" and s.startswith("//"):
|
||||
continue
|
||||
|
||||
out.append(s)
|
||||
|
||||
low = s.lower()
|
||||
if mode == "html" and "<style" in low:
|
||||
mode = "css"
|
||||
elif mode == "html" and "<script" in low:
|
||||
mode = "js"
|
||||
elif mode != "html" and ("</style>" in low or "</script>" in low):
|
||||
mode = "html"
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def check_stripped(raw, stripped):
|
||||
"""Guard against a stripper bug silently shipping a broken portal to the
|
||||
fleet. Returns a reason string when the output looks wrong, else None."""
|
||||
for tag in ("<script>", "</script>", "<style>", "</style>", "</body>"):
|
||||
if raw.count(tag) != stripped.count(tag):
|
||||
return "%s count changed" % tag
|
||||
if len(stripped) < len(raw) * 0.5:
|
||||
return "output shrank by more than half (%d -> %d)" % (len(raw), len(stripped))
|
||||
# Only comments and whitespace may go, so no structural token may appear
|
||||
# that the source did not already have.
|
||||
for token in ("{", "}", "(", ")", "<script", "<style"):
|
||||
if stripped.count(token) > raw.count(token):
|
||||
return "gained a %s" % token
|
||||
return None
|
||||
@@ -9,6 +9,11 @@ pending -> done result polling, aggregate-success reboot gating, secret masking
|
||||
So the browser drives the actual portal JS (wizard, save/poll/reqid, effective
|
||||
value handling, reboot overlay, stats, scan) against realistic responses.
|
||||
|
||||
/api/cli is the CLI terminal's backend and has no firmware counterpart yet: it
|
||||
is the proposed contract (202 + reqid, streamed per-command results) executed
|
||||
against a CommonCLI-shaped interpreter, so the terminal UI can be designed
|
||||
against realistic single- and multi-line replies before any of it goes on-device.
|
||||
|
||||
It does NOT run the C++ handlers (that's what test/ gtest covers) or the
|
||||
AsyncTCP transport — it's a frontend + contract harness.
|
||||
|
||||
@@ -28,6 +33,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -36,6 +42,18 @@ from urllib.parse import parse_qs, urlsplit
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
sys.path.insert(0, HERE)
|
||||
# The build-time comment stripper, shared so --minify serves byte-for-byte what
|
||||
# the generator embeds rather than a second implementation that could drift.
|
||||
from webconfig_minify import strip_source # noqa: E402
|
||||
|
||||
MINIFY = False
|
||||
# Overridable with --fw-version to exercise the console's channel labelling:
|
||||
# v1.16.0.5-observer-a1b2c3d release
|
||||
# v1.16.0.5-observer-beta-dev-a1b2c3d dev
|
||||
# v1.16.0 local build, no OTA
|
||||
FW_VERSION = "v1.16.0.5-observer-a1b2c3d"
|
||||
|
||||
SENTINEL = "********"
|
||||
ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag
|
||||
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
|
||||
@@ -100,8 +118,23 @@ def default_config(setup_mode):
|
||||
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
|
||||
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
|
||||
"snmp_community": "public",
|
||||
"neighbors": False, "neighbors_interval": 24,
|
||||
"slots": [_slot() for _ in range(6)],
|
||||
},
|
||||
# Settings the CLI reaches but no portal form does, so they are absent
|
||||
# from /api/config (see config_json) and live only here. Without them
|
||||
# the terminal answers "unknown config key" for perfectly real commands.
|
||||
"cli": {
|
||||
"radio.watchdog": 0, "int.thresh": 0, "agc.reset.interval": 0,
|
||||
"direct.txdelay": 0.0, "multi.acks": 0, "allow.read.only": False,
|
||||
"path.hash.mode": 0, "owner.info": "", "guest.password": "",
|
||||
"adc.multiplier": 1.0,
|
||||
"alert": False, "alert.psk": "", "alert.hashtag": "",
|
||||
"alert.region": "", "alert.interval": 15,
|
||||
"alert.mqtt": False, "alert.wifi": False,
|
||||
"bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200,
|
||||
"bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +155,8 @@ class State:
|
||||
self.start = time.time()
|
||||
self.session = None # cookie token when logged in (LAN mode)
|
||||
self.batch = {"state": "idle"}
|
||||
self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli
|
||||
self.admin_pwd_set = False # satisfies the initial-setup invariant
|
||||
self.scan_started = None
|
||||
|
||||
# ---- auth -------------------------------------------------------------
|
||||
@@ -137,6 +172,7 @@ class State:
|
||||
# ---- config serialization (masks secrets, like handleConfigGet) -------
|
||||
def config_json(self):
|
||||
c = copy.deepcopy(self.cfg)
|
||||
c.pop("cli") # CLI-only settings: not part of this contract
|
||||
c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else ""
|
||||
for s in c["mqtt"]["slots"]:
|
||||
s["password"] = SENTINEL if s["password"] else ""
|
||||
@@ -149,9 +185,14 @@ class State:
|
||||
"auth": authed,
|
||||
"needs_setup": self.cfg["wifi"]["ssid"] == "",
|
||||
"name": self.cfg["radio"]["name"], "node_id": "a1b2c3d4e5f60718",
|
||||
"fw": "v1.7.1-mock", "role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
# Shaped like build.sh's EMBEDDED_VERSION_STRING
|
||||
# (base[.build][-observer][-channel]-hash) so the console's channel
|
||||
# labelling is exercised against a real version, not "v1.x-mock".
|
||||
"fw": FW_VERSION, "build_date": "6 Jun 2026",
|
||||
"role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
"max_cmds": CLI_MAX_CMDS,
|
||||
}
|
||||
|
||||
|
||||
@@ -162,13 +203,15 @@ class State:
|
||||
BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"),
|
||||
"repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"),
|
||||
"mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"),
|
||||
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp")}
|
||||
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"),
|
||||
"mqtt.neighbors": ("mqtt", "neighbors")}
|
||||
INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"),
|
||||
"flood.max.advert": ("radio", "flood_max_advert"),
|
||||
"flood.max.unscoped": ("radio", "flood_max_unscoped"),
|
||||
"advert.interval": ("radio", "advert_interval"),
|
||||
"flood.advert.interval": ("radio", "flood_advert_interval"),
|
||||
"mqtt.interval": ("mqtt", "interval"),
|
||||
"mqtt.neighbors.interval": ("mqtt", "neighbors_interval"),
|
||||
"timezone.offset": ("mqtt", "timezone_offset")}
|
||||
FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"),
|
||||
"af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"),
|
||||
@@ -180,6 +223,16 @@ STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"),
|
||||
"snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")}
|
||||
SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")}
|
||||
|
||||
# The CLI-only settings, typed the same way so apply_set/cli_read_key reach them
|
||||
# through the existing lookups rather than a parallel code path.
|
||||
for _k, _v in default_config(False)["cli"].items():
|
||||
_t = {bool: BOOL_KEYS, int: INT_KEYS, float: FLOAT_KEYS, str: STR_KEYS}[type(_v)]
|
||||
_t[_k] = ("cli", _k)
|
||||
SECRET_STR_KEYS.update({k: ("cli", k) for k in
|
||||
("guest.password", "alert.psk", "bridge.secret")})
|
||||
for _k in SECRET_STR_KEYS:
|
||||
STR_KEYS.pop(_k, None)
|
||||
|
||||
|
||||
def _hex64(v):
|
||||
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
|
||||
@@ -200,6 +253,28 @@ 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)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
if not 0 < dc <= 100:
|
||||
return False, "Error, must be 1-100"
|
||||
cfg["radio"]["af"] = 100.0 / dc - 1 # the CLI stores it as airtime_factor
|
||||
return True, "OK"
|
||||
|
||||
if key in ("freq", "bw", "sf", "cr"):
|
||||
# single-component radio setters, reachable from the CLI but not from
|
||||
# the form batch (which always sends the whole `radio` combo)
|
||||
try:
|
||||
cfg["radio"][key] = int(val) if key in ("sf", "cr") else float(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
return True, "OK - reboot to apply"
|
||||
|
||||
if key == "radio":
|
||||
try:
|
||||
f, bw, sf, cr = val.split(",")
|
||||
@@ -220,6 +295,12 @@ def apply_set(cfg, key, val):
|
||||
cfg["mqtt"]["iata"] = val.upper()
|
||||
return True, "OK"
|
||||
|
||||
if key == "prv.key":
|
||||
# write-only by design: the identity goes in, nothing reads it back
|
||||
if not _hex64(val):
|
||||
return False, "Error: private key must be 64 hex characters"
|
||||
return True, "OK - identity restored, reboot to apply"
|
||||
|
||||
if key == "mqtt.owner":
|
||||
if val == "":
|
||||
cfg["mqtt"]["owner"] = ""
|
||||
@@ -259,7 +340,11 @@ def apply_set(cfg, key, val):
|
||||
sec, f = STR_KEYS[key]
|
||||
cfg[sec][f] = val
|
||||
return True, "OK"
|
||||
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
|
||||
# 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.
|
||||
return False, "unknown config: %s" % key
|
||||
|
||||
|
||||
# Payload-type names accepted alongside the decimal form. Mirrors
|
||||
@@ -337,7 +422,270 @@ def apply_slot_set(cfg, idx, field, val):
|
||||
|
||||
|
||||
def is_secret_key(key):
|
||||
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
# The serial console prints these back; the portal is reachable over the
|
||||
# LAN, so it masks them in `get` replies the way /api/config already does.
|
||||
return key in SECRET_STR_KEYS or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI command execution (backs /api/cli), mirroring CommonCLI enough to give
|
||||
# the terminal UI realistic single- and multi-line replies.
|
||||
#
|
||||
# The portal's `set` batch is allowlisted (WebConfigKeys.h) because it is driven
|
||||
# by form fields; the CLI is deliberately NOT, since its whole point is reaching
|
||||
# the same surface the serial console reaches. Auth is the boundary — exactly
|
||||
# as it is for the serial console and for remote admin over the mesh.
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX_BATCH in WebConfigServer.h: the CLI shares the config batch's fixed slot,
|
||||
# so this is the real cap, reported to the page as status.max_cmds.
|
||||
CLI_MAX_CMDS = 24
|
||||
CLI_RESULT_PAGE = 8 # WebConfigBatch::kCliResultPage
|
||||
CLI_CMD_SECS = 0.25 # simulated per-command execution time
|
||||
# Board::reboot() does not return, so the firmware answers `reboot` itself and
|
||||
# arms the deferred reboot once results have been read (see wcIsDeferredReboot).
|
||||
CLI_DEFERRED_REBOOT = "reboot" # matched as a PREFIX, like CommonCLI does
|
||||
|
||||
# Commands the CLI reaches but the portal cannot honestly serve; rejected at
|
||||
# POST. Mirrors wcCliUnavailable() in WebConfigServer.cpp.
|
||||
CLI_UNAVAILABLE = [
|
||||
("start ota", True, "start ota needs port 80, which this portal is using. "
|
||||
"Run it from the serial console, or use `ota update`."),
|
||||
("clock sync", True, "clock sync takes its time from the caller, which a web request "
|
||||
"has no way to supply. Use `time <epoch-seconds>` instead."),
|
||||
("log", False, "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`."),
|
||||
("get acl", False, "get acl writes to the serial console, not here."),
|
||||
]
|
||||
|
||||
|
||||
def cli_unavailable(cmd):
|
||||
for token, is_prefix, why in CLI_UNAVAILABLE:
|
||||
if cmd.startswith(token) if is_prefix else cmd == token:
|
||||
return why
|
||||
return None
|
||||
|
||||
|
||||
# Failure replies CommonCLI emits that do NOT start with "Err" — the shapes that
|
||||
# made a naive prefix test call them success. Mirrors
|
||||
# WebConfigBatch::cliReplyIsFailure.
|
||||
def cli_reads_secret(cmd):
|
||||
"""Commands that READ a secret. CommonCLI gates these on the caller being
|
||||
the serial console; the portal is not, so the value is masked here the way
|
||||
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
|
||||
if not cmd.startswith("get "):
|
||||
return False
|
||||
key = cmd[4:].strip()
|
||||
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
|
||||
or is_secret_key(key)
|
||||
|
||||
|
||||
def cli_reply_is_failure(reply):
|
||||
if not reply:
|
||||
return False
|
||||
if reply.startswith(("Err", "ERR", "err", "(ERR", "Unknown command",
|
||||
"unknown config", "??", "Can't find")):
|
||||
return True
|
||||
return ": Err" in reply
|
||||
|
||||
# Commands the device answers but that have no config-key equivalent.
|
||||
GETTERS = {
|
||||
"freq": lambda c: "%.3f" % c["radio"]["freq"],
|
||||
"bw": lambda c: "%.2f" % c["radio"]["bw"],
|
||||
"sf": lambda c: str(c["radio"]["sf"]),
|
||||
"cr": lambda c: str(c["radio"]["cr"]),
|
||||
"public.key": lambda c: "a1b2c3d4" * 8,
|
||||
"wifi.status": lambda c: (
|
||||
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
|
||||
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
|
||||
"mqtt.status": lambda c: cli_mqtt_status(c),
|
||||
"mqtt.presets": lambda c: "\n".join(
|
||||
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
|
||||
for i, (n, nd) in enumerate(PRESETS)),
|
||||
"role": lambda c: "Repeater",
|
||||
"acl": lambda c: "a1b2c3d4e5f60718 perms 3\n1122334455667788 perms 1",
|
||||
# not its own pref: the CLI derives it from airtime_factor both ways
|
||||
"dutycycle": lambda c: "%.1f" % (100.0 / (c["radio"]["af"] + 1)),
|
||||
"mqtt.config.valid": lambda c: (
|
||||
"yes" if any(s["preset"] != "none" for s in c["mqtt"]["slots"]) else "no - no slot configured"),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def cli_mqtt_status(cfg):
|
||||
lines = []
|
||||
for i, s in enumerate(cfg["mqtt"]["slots"][:ST.active_slots]):
|
||||
if s["preset"] == "none":
|
||||
lines.append("slot %d: unconfigured" % (i + 1))
|
||||
else:
|
||||
lines.append("slot %d: %-16s connected tx=%d err=0"
|
||||
% (i + 1, s["preset"], 100 + int(time.time() - ST.start)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cli_get(cfg, key):
|
||||
"""Reply to `get <key>`.
|
||||
|
||||
CommonCLI::handleGetCmd answers `> value` — the marker sets the value apart
|
||||
on the serial console. Reproduced here because it is load-bearing: a reply
|
||||
that starts with "> " does not start with "OK", which is what made the
|
||||
firmware's first cut mark every getter a failure.
|
||||
"""
|
||||
ok, val = _cli_get_value(cfg, key)
|
||||
return (ok, "> " + val) if ok else (ok, val)
|
||||
|
||||
|
||||
def _cli_get_value(cfg, key):
|
||||
if key in GETTERS:
|
||||
val = GETTERS[key](cfg)
|
||||
return (True, val) if val is not None else (False, "Error: unsupported")
|
||||
if is_secret_key(key):
|
||||
# The serial console prints these; the portal is reachable over the LAN,
|
||||
# so it masks them the same way /api/config does.
|
||||
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
|
||||
val = cli_read_key(cfg, key)
|
||||
if val is None:
|
||||
return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough
|
||||
return True, str(val)
|
||||
|
||||
|
||||
def cli_read_key(cfg, key):
|
||||
"""Current value of a `set` key, or None when the key is unknown."""
|
||||
m = re.match(r"^mqtt([1-6])\.(\w+)$", key)
|
||||
if m:
|
||||
slot = cfg["mqtt"]["slots"][int(m.group(1)) - 1]
|
||||
return slot.get(m.group(2))
|
||||
for table in (BOOL_KEYS, INT_KEYS, FLOAT_KEYS, STR_KEYS, SECRET_STR_KEYS):
|
||||
if key in table:
|
||||
sec, f = table[key]
|
||||
v = cfg[sec][f]
|
||||
return ("on" if v else "off") if key in BOOL_KEYS else v
|
||||
# keys apply_set() special-cases, so they appear in none of the tables above
|
||||
r = cfg["radio"]
|
||||
return {
|
||||
"name": r["name"], "lat": r["lat"], "lon": r["lon"],
|
||||
"radio": "%.3f,%.2f,%d,%d" % (r["freq"], r["bw"], r["sf"], r["cr"]),
|
||||
"bw": r["bw"], "sf": r["sf"], "cr": r["cr"],
|
||||
"mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"],
|
||||
}.get(key)
|
||||
|
||||
|
||||
def run_cli(cfg, line):
|
||||
"""Execute one command line. Returns (ok, reply); reply may be multi-line."""
|
||||
cmd = line.strip()
|
||||
if cmd == "":
|
||||
return True, ""
|
||||
if cmd == "ver":
|
||||
# Same source as /api/status's fw on the device: both are
|
||||
# FIRMWARE_VERSION, so they must not disagree here either.
|
||||
return True, "%s (Build: 6 Jun 2026)" % FW_VERSION
|
||||
if cmd == "board":
|
||||
return True, "Heltec V3 (mock)"
|
||||
if cmd == "clock":
|
||||
return True, time.strftime("%d/%m/%Y %H:%M:%S", time.gmtime()) + " UTC"
|
||||
if cmd == "advert":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd == "advert.zerohop":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd in ("reboot", "clkreboot"):
|
||||
return True, "OK - rebooting"
|
||||
if cmd in ("poweroff", "shutdown"):
|
||||
return True, "OK - powering off"
|
||||
if cmd == "erase":
|
||||
return True, "File system erase: OK"
|
||||
if cmd == "memory":
|
||||
return True, ("heap free: 142000\nheap min: 118000\n"
|
||||
"largest block: 96000\npsram free: 3980000")
|
||||
if cmd == "neighbors":
|
||||
return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n"
|
||||
"1122334455 -94 dBm snr 2.0 14m ago")
|
||||
# Handled by MyMesh::handleCommand before it delegates to CommonCLI.
|
||||
if cmd == "discover.neighbors":
|
||||
return True, "OK - Discover sent"
|
||||
if cmd == "discover.scopes":
|
||||
return True, "OK - scopes queued (18s discovery remaining)"
|
||||
if cmd.startswith("setperm "):
|
||||
parts = cmd[8:].split()
|
||||
if len(parts) != 2 or not _hex64(parts[0]):
|
||||
return False, "Err - bad params"
|
||||
return True, "OK"
|
||||
if cmd.startswith("clock sync"):
|
||||
# Rejected at POST, but modelled anyway: over the web the caller's
|
||||
# timestamp is 0, so CommonCLI always takes this branch.
|
||||
return False, "(ERR: clock cannot go backwards)"
|
||||
if cmd == "region":
|
||||
return True, "US915"
|
||||
if cmd == "sensor list":
|
||||
return True, "0: battery (mV)\n1: temperature (C)\n2: humidity (%)"
|
||||
if cmd.startswith("sensor get "):
|
||||
return True, "> 22.4"
|
||||
if cmd.startswith("sensor set "):
|
||||
return True, "OK"
|
||||
if cmd.startswith("gps advert "):
|
||||
mode = cmd[11:]
|
||||
if mode not in ("none", "share", "prefs"):
|
||||
return False, "Error, must be none, share or prefs"
|
||||
return True, "OK - advert position: %s" % mode
|
||||
if cmd in ("gps on", "gps off"):
|
||||
return True, "OK - GPS %s" % cmd[4:]
|
||||
if cmd == "gps sync":
|
||||
return True, "OK - clock and location set from GPS"
|
||||
if cmd == "gps setloc":
|
||||
return True, "OK - lat/lon set from the current fix"
|
||||
if cmd == "gps":
|
||||
return True, "GPS: no fix (0 satellites)"
|
||||
if cmd in ("powersaving on", "powersaving off"):
|
||||
return True, "OK - power saving %s" % cmd[12:]
|
||||
if cmd == "powersaving":
|
||||
return True, "off"
|
||||
if cmd.startswith("alert test"):
|
||||
if not ST.cfg["cli"]["alert.psk"]:
|
||||
return False, "Error: alert channel not configured (set alert.psk or set alert.hashtag)"
|
||||
return True, "OK - test alert sent"
|
||||
if cmd.startswith("ota "):
|
||||
return True, ("v1.7.2 available (current v1.7.1-mock)" if cmd == "ota check"
|
||||
else "OK - downloading v1.7.2, will reboot when flashed")
|
||||
if cmd.startswith("start webconfig"):
|
||||
return True, "OK - already running (you are using it)"
|
||||
if cmd == "stop webconfig":
|
||||
return True, "OK - portal stopping"
|
||||
if cmd == "start ota":
|
||||
return True, "OK - upload AP raised at 192.168.4.1"
|
||||
if cmd.startswith("neighbor.remove "):
|
||||
return (True, "OK") if _hex64(cmd[16:]) else (False, "ERR: bad pubkey")
|
||||
if cmd.startswith("tempradio "):
|
||||
return True, "OK - temporary radio params applied (not saved)"
|
||||
if cmd == "clear stats":
|
||||
return True, "OK - stats cleared"
|
||||
if cmd.startswith("stats-"):
|
||||
return True, "recv=512 sent=88 rx_err=3 airtime=41s"
|
||||
if cmd == "log":
|
||||
return True, "packet log: 128 entries, 14 KB"
|
||||
if cmd.startswith("log "):
|
||||
return True, "OK"
|
||||
if cmd.startswith("password "):
|
||||
global ADMIN_PASSWORD
|
||||
ADMIN_PASSWORD = cmd[9:]
|
||||
return True, "OK - password changed"
|
||||
if cmd.startswith("time "):
|
||||
return True, "OK - clock set"
|
||||
if cmd.startswith("get "):
|
||||
return cli_get(cfg, cmd[4:].strip())
|
||||
if cmd.startswith("set "):
|
||||
rest = cmd[4:].strip()
|
||||
key, _, val = rest.partition(" ")
|
||||
if not key:
|
||||
return False, "Error: set what?"
|
||||
# apply_set owns the "is this settable" decision; gating on whether the
|
||||
# key is *readable* rejected write-only and computed ones (`dutycycle`,
|
||||
# `prv.key`, `radio.fem.rxgain`).
|
||||
return apply_set(cfg, key, val.strip())
|
||||
return False, "Unknown command"
|
||||
|
||||
|
||||
def valid_reqid(reqid):
|
||||
@@ -393,6 +741,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_result()
|
||||
if path == "/api/cli/result":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_result()
|
||||
if path == "/api/stats":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -415,6 +767,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_post()
|
||||
if path == "/api/cli":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_post()
|
||||
if path == "/api/reboot":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -431,6 +787,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except OSError:
|
||||
self.send_error(500, "webui/index.html not found")
|
||||
return
|
||||
if MINIFY:
|
||||
# Serve what the device actually serves. The generator strips
|
||||
# comments and indentation before compressing, so --minify is how
|
||||
# you exercise those bytes in a browser rather than trusting that
|
||||
# stripping a 100 KB page never changes its behaviour.
|
||||
html = strip_source(html.decode("utf-8")).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(html)))
|
||||
@@ -526,6 +888,125 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"reboot": b["reboot"] and b["all_ok"], "results": b["results"],
|
||||
})
|
||||
|
||||
# ---- CLI ---------------------------------------------------------------
|
||||
# Same 202 + reqid + poll shape as /api/config, for the same reason: the
|
||||
# commands have to run on the main loop, not the web server's task. The
|
||||
# difference is that results stream -- a pasted sequence fills the terminal
|
||||
# command by command instead of appearing all at once at the end.
|
||||
def _cli_post(self):
|
||||
raw = self._read_body()
|
||||
if len(raw) > 8192:
|
||||
return self._json(413, {"error": "body too large"})
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "bad json"})
|
||||
reqid = body.get("reqid", "")
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
cmds = body.get("cmds")
|
||||
if not isinstance(cmds, list) or not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
if len(cmds) > CLI_MAX_CMDS:
|
||||
return self._json(413, {"error": "too many commands", "max": CLI_MAX_CMDS})
|
||||
cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds]
|
||||
cmds = [c for c in cmds if c]
|
||||
if not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
for c in cmds:
|
||||
if len(c) > BATCH_CMD_SIZE - 1:
|
||||
return self._json(400, {"error": "command too long"})
|
||||
why = cli_unavailable(c)
|
||||
if why:
|
||||
return self._json(400, {"error": why})
|
||||
# Same invariant handleConfigPost enforces (see wcCliUnavailable's
|
||||
# neighbour in WebConfigServer.cpp): first onboarding is committed by the
|
||||
# reboot, and must not commit the factory password onto someone's LAN.
|
||||
if (ST.setup_mode and ST.initial_setup and not ST.admin_pwd_set
|
||||
and not any(c.startswith("password ") for c in cmds)
|
||||
and (any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds)
|
||||
or any(c.startswith("set wifi.ssid ") for c in cmds))):
|
||||
return self._json(400, {"error": "admin password required for initial setup — "
|
||||
"run `password <new-password>` first"})
|
||||
|
||||
with ST.lock:
|
||||
self._cli_advance(ST.cli)
|
||||
if ST.cli.get("state") != "idle" and ST.cli.get("reqid") == reqid:
|
||||
return self._json(202, {"state": ST.cli["state"], "reqid": reqid,
|
||||
"total": len(ST.cli["cmds"])})
|
||||
if ST.cli.get("state") == "running":
|
||||
return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")})
|
||||
ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [],
|
||||
"all_ok": True,
|
||||
"reboot": any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds),
|
||||
"next_at": time.time() + CLI_CMD_SECS}
|
||||
return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)})
|
||||
|
||||
@staticmethod
|
||||
def _cli_advance(job):
|
||||
"""Run whichever queued commands are now due. Execution belongs to the
|
||||
node's loop, not to the client's polling — a client that walks away must
|
||||
not leave the executor claimed forever."""
|
||||
now = time.time()
|
||||
while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"])
|
||||
and now >= job["next_at"]):
|
||||
cmd = job["cmds"][len(job["results"])]
|
||||
if cmd.startswith(CLI_DEFERRED_REBOOT):
|
||||
reply = "OK - reboot queued"
|
||||
else:
|
||||
_, reply = run_cli(ST.cfg, cmd)
|
||||
if cmd.startswith("password "):
|
||||
reply = "OK" # never echo the new password back
|
||||
ST.admin_pwd_set = True
|
||||
elif cli_reads_secret(cmd):
|
||||
val = reply[2:] if reply.startswith("> ") else reply
|
||||
reply = ("> (not set)" if val in ("", "(not set)")
|
||||
else "> ******** (serial only)")
|
||||
ok = not cli_reply_is_failure(reply)
|
||||
# Only writes gate the reboot, and only on the "OK" convention every
|
||||
# setter keeps (WebConfigBatch::cliReplyGatesReboot).
|
||||
if cmd.startswith(("set ", "password ")):
|
||||
job["all_ok"] = job.get("all_ok", True) and reply.startswith("OK")
|
||||
# The command is NOT echoed: it may carry a password or token, and
|
||||
# the client matches results to its own sequence by index.
|
||||
job["results"].append({"ok": ok, "reply": reply})
|
||||
job["next_at"] = now + CLI_CMD_SECS
|
||||
if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]):
|
||||
job["state"] = "done" # stays readable until the next POST
|
||||
|
||||
def _cli_result(self):
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
reqid = query.get("reqid", [""])[0]
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
# `from` lets the client ask only for results it has not rendered yet,
|
||||
# so a long sequence isn't re-sent on every poll.
|
||||
try:
|
||||
frm = max(0, int(query.get("from", ["0"])[0]))
|
||||
except ValueError:
|
||||
frm = 0
|
||||
with ST.lock:
|
||||
j = ST.cli
|
||||
if j.get("state") == "idle":
|
||||
return self._json(200, {"state": "idle", "reqid": reqid})
|
||||
if j.get("reqid") != reqid:
|
||||
return self._json(404, {"error": "unknown request"})
|
||||
self._cli_advance(j) # one command per CLI_CMD_SECS
|
||||
# Results stream, capped per read so the device's JSON document
|
||||
# stays small; a longer sequence pages across reads. "done" means
|
||||
# the client has been handed everything, not just that execution
|
||||
# finished — a client that stops polling at "done" must lose nothing.
|
||||
page = j["results"][frm:frm + CLI_RESULT_PAGE]
|
||||
final = j["state"] == "done" and frm + len(page) >= len(j["cmds"])
|
||||
body = {"state": "done" if final else "running", "reqid": reqid,
|
||||
"total": len(j["cmds"]), "from": frm, "results": page}
|
||||
if final:
|
||||
body["all_ok"] = j["all_ok"]
|
||||
body["reboot"] = j["reboot"] and j["all_ok"]
|
||||
if j["reboot"] and not j["all_ok"]:
|
||||
body["reboot_withheld"] = True
|
||||
return self._json(200, body)
|
||||
|
||||
def _scan(self):
|
||||
rescan = "rescan=1" in self.path
|
||||
now = time.time()
|
||||
@@ -561,17 +1042,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main():
|
||||
global ST, PORT
|
||||
global ST, PORT, MINIFY, FW_VERSION
|
||||
ap = argparse.ArgumentParser(description="Mock WebConfig portal backend")
|
||||
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("--fw-version", default=FW_VERSION,
|
||||
help="version string to report, shaped like build.sh's embedded one")
|
||||
ap.add_argument("--minify", action="store_true",
|
||||
help="serve the comment-stripped page the device ships, not the source")
|
||||
args = ap.parse_args()
|
||||
ST, PORT = State(args), args.port
|
||||
ST, PORT, MINIFY = State(args), args.port, args.minify
|
||||
FW_VERSION = args.fw_version
|
||||
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
mode = "SETUP (wizard)" if args.setup else "LAN (login: %s)" % ADMIN_PASSWORD
|
||||
print("WebConfig mock backend — %s" % mode)
|
||||
print("WebConfig mock backend — %s%s" % (mode, " [minified]" if MINIFY else ""))
|
||||
print(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h> // size_t / NULL for the reply classifiers below
|
||||
#include <stdint.h>
|
||||
|
||||
// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot /
|
||||
@@ -168,6 +169,110 @@ static inline uint32_t confirmRebootAt(uint32_t now) {
|
||||
return scheduleAt(now, kRebootConfirmMs);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI sequences (/api/cli). The terminal shares this one deferred-command slot
|
||||
// with config saves 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.
|
||||
//
|
||||
// Two things differ from a config save:
|
||||
// 1. results stream. A save's results appear only when the whole batch is
|
||||
// Done; a CLI read hands back whatever has executed so far, so a pasted
|
||||
// sequence fills the terminal command by command.
|
||||
// 2. the reboot is not requested by a `reboot` flag on the request but by the
|
||||
// word `reboot` appearing in the sequence. It is deferred rather than
|
||||
// executed, because Board::reboot() does not return and would take the node
|
||||
// down before the client could read a single result.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Results returned by one read. Bounds the JSON document built on the
|
||||
// async_tcp task; a longer sequence pages across successive reads.
|
||||
static const int kCliResultPage = 8;
|
||||
|
||||
static inline int cliPageCount(int from, int produced, int page) {
|
||||
const int pending = produced - from;
|
||||
if (pending <= 0) return 0;
|
||||
return pending > page ? page : pending;
|
||||
}
|
||||
|
||||
// "done" means the client has been handed every result, not merely that
|
||||
// execution finished: the last page may still be unread, and a client that
|
||||
// stops polling at "done" would lose it.
|
||||
static inline bool cliReadIsFinal(State state, int from, int page_count, int total) {
|
||||
return state == State::Done && from + page_count >= total;
|
||||
}
|
||||
|
||||
// A trailing `reboot` is withheld when any command in the sequence failed,
|
||||
// exactly as a config save's is. The operator asked for the reboot, but
|
||||
// rebooting into a half-applied config — over a link they may not get back —
|
||||
// is the worse failure, and the result body reports the refusal.
|
||||
static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) {
|
||||
return has_reboot && all_ok;
|
||||
}
|
||||
|
||||
// CommonCLI has no single failure convention. Testing only for an "Err" prefix
|
||||
// let five other shapes through as success — including "Unknown command" and
|
||||
// "unknown config: x", the two an operator hits most — which coloured them
|
||||
// green AND let a queued reboot proceed after them.
|
||||
//
|
||||
// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp.
|
||||
// This list is the fragile part of the design: a new failure string added there
|
||||
// is silently a success here. Which is exactly why it must not be what decides
|
||||
// whether to reboot — see cliReplyConfirmsWrite.
|
||||
static inline bool cliReplyIsFailure(const char* r) {
|
||||
if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK"
|
||||
static const char* const kPrefixes[] = {
|
||||
"Err", "ERR", "err", // "Err - ", "ERR: ", "Error: "
|
||||
"(ERR", // "(ERR: clock cannot go backwards)"
|
||||
"Unknown command",
|
||||
"unknown config",
|
||||
"??", // "??: <key>" from the get fallthrough
|
||||
"Can't find", // "Can't find GPS"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) {
|
||||
const char* p = kPrefixes[i];
|
||||
size_t n = 0;
|
||||
while (p[n]) n++;
|
||||
bool match = true;
|
||||
for (size_t j = 0; j < n; j++) {
|
||||
if (r[j] != p[j]) { match = false; break; }
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
// "File system erase: Err" reports the failure at the END of the reply.
|
||||
for (size_t i = 0; r[i]; i++) {
|
||||
if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' &&
|
||||
r[i + 4] == 'r') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether a command's reply is allowed to gate the deferred reboot.
|
||||
//
|
||||
// Only writes are, and only writes have a reply convention worth trusting:
|
||||
// every setter answers with an "OK" prefix. Diagnostics do not — `memory`
|
||||
// answers "Free: ...", a getter answers "> value" — so letting them gate would
|
||||
// mean guessing, and guessing wrong here either strands the operator (a
|
||||
// harmless `memory` blocks their reboot) or reboots into a config that did not
|
||||
// apply. The question the gate exists to answer is narrower than "did anything
|
||||
// fail": it is "did every setting I asked for actually take".
|
||||
static inline bool cliReplyGatesReboot(const char* cmd) {
|
||||
if (cmd == NULL) return false;
|
||||
const char* set = "set ";
|
||||
const char* pwd = "password ";
|
||||
bool is_set = true, is_pwd = true;
|
||||
for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; }
|
||||
for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; }
|
||||
return is_set || is_pwd;
|
||||
}
|
||||
|
||||
// A write took effect iff its reply starts with "OK" — the one convention every
|
||||
// setter in CommonCLI actually keeps.
|
||||
static inline bool cliWriteSucceeded(const char* reply) {
|
||||
return reply != NULL && reply[0] == 'O' && reply[1] == 'K';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -82,6 +82,28 @@ static inline bool wcIsSecretKey(const char* key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// CommonCLI answers a secret getter in plaintext only for the serial console
|
||||
// (sender_timestamp 0) and masks it for remote callers. The web CLI executes
|
||||
// with sender_timestamp 0 — that is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable — so it would otherwise inherit the serial console's plaintext
|
||||
// answers for an HTTP request. This says which `get` commands must be masked
|
||||
// back down, restoring the distinction for a caller not at the serial port.
|
||||
//
|
||||
// Writing these has always been possible from the portal; reading them never
|
||||
// was, because handleConfigGet masks them (wcIsSecretKey). The two are different
|
||||
// capabilities: replacing a WiFi password does not reveal the current one, and
|
||||
// replacing an identity does not reveal the existing private key.
|
||||
static inline bool wcIsSecretReadCommand(const char* cmd) {
|
||||
if (strncmp(cmd, "get ", 4) != 0) return false;
|
||||
const char* key = cmd + 4;
|
||||
while (*key == ' ') key++;
|
||||
if (strcmp(key, "prv.key") == 0) return true; // this node's identity
|
||||
if (strcmp(key, "guest.password") == 0) return true;
|
||||
if (strcmp(key, "alert.psk") == 0) return true;
|
||||
if (strcmp(key, "bridge.secret") == 0) return true;
|
||||
return wcIsSecretKey(key); // wifi.pwd, mqttN.password, mqttN.token
|
||||
}
|
||||
|
||||
// Browser-generated request IDs are exactly eight random bytes encoded as
|
||||
// hexadecimal. Keeping the grammar deliberately small makes the ID safe to
|
||||
// echo in JSON/logs and prevents an empty or truncated ID from weakening the
|
||||
|
||||
@@ -28,6 +28,89 @@ static const char SECRET_SENTINEL[] = "********";
|
||||
static inline bool isAllowedSetKey(const char* key) { return wcIsAllowedSetKey(key); }
|
||||
static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); }
|
||||
|
||||
// Commands whose CLI handler never returns would take the node down mid-drain,
|
||||
// before the client could read a single result. `reboot` is deferred instead:
|
||||
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
|
||||
// path once the operator has read the results. The rest (clkreboot, poweroff,
|
||||
// ota update) do real work on the way down and cannot be faked, so they run
|
||||
// normally and the connection drops — the UI warns before sending them.
|
||||
// CommonCLI dispatches on a 6-byte PREFIX (memcmp(command, "reboot", 6)), so
|
||||
// `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly here
|
||||
// let those through to the CLI, which took the node down mid-drain with no
|
||||
// results and no deferral — the precise failure the deferral exists to avoid.
|
||||
// Whatever CommonCLI would treat as a reboot, this must intercept.
|
||||
static inline bool wcIsDeferredReboot(const char* cmd) {
|
||||
return strncmp(cmd, "reboot", 6) == 0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
static const char* wcCliUnavailable(const char* cmd) {
|
||||
// ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind
|
||||
// check and answers "Started" regardless. The portal already holds port 80,
|
||||
// so from here it can only leak the allocation, inhibit sleep, and lie.
|
||||
if (strncmp(cmd, "start ota", 9) == 0) {
|
||||
return "start ota needs port 80, which this portal is using. "
|
||||
"Run it from the serial console, or use `ota update`.";
|
||||
}
|
||||
// `clock sync` sets the clock from the CALLER's timestamp. Web requests carry
|
||||
// none (execCommand passes 0), so CommonCLI always rejects it as moving the
|
||||
// clock backwards. `time <epoch>` is the one that works over this transport.
|
||||
if (strncmp(cmd, "clock sync", 10) == 0) {
|
||||
return "clock sync takes its time from the caller, which a web request has "
|
||||
"no way to supply. Use `time <epoch-seconds>` instead.";
|
||||
}
|
||||
// Both write their real output to Serial and hand back a stub the terminal
|
||||
// would render as success. Bare `log` also streams a whole file from the loop
|
||||
// task, stalling the mesh and the radio while it does.
|
||||
if (strcmp(cmd, "log") == 0) {
|
||||
return "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`.";
|
||||
}
|
||||
if (strcmp(cmd, "get acl") == 0) {
|
||||
return "get acl writes to the serial console, not here.";
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
// The `password` command echoes the new password back in its reply, and replies
|
||||
// are served to the client over the open setup AP. The config path overwrites it
|
||||
// by key; a CLI entry has no key, so match on the command itself.
|
||||
static inline bool wcCliEchoesSecret(const char* cmd) {
|
||||
return strncmp(cmd, "password ", 9) == 0;
|
||||
}
|
||||
|
||||
// CommonCLI splits its surface by CALLER, not by command: a serial caller
|
||||
// (sender_timestamp 0, physical access) reads secrets in plaintext, while a
|
||||
// remote one gets "******** (serial only)". Its own comments say so — "Serial
|
||||
// only (WiFi creds grant LAN access); remote sees set/unset".
|
||||
//
|
||||
// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable from the terminal at all. Left alone, that also claims
|
||||
// physical-access trust for an HTTP request: `get prv.key` would hand this
|
||||
// node's identity to anyone associated with the open setup AP, and `get
|
||||
// wifi.pwd` would hand over the operator's network. Reading a secret and
|
||||
// writing one are not the same capability — the wizard has always been able to
|
||||
// REPLACE these; nothing in the portal could ever READ them, because
|
||||
// /api/config masks them (wcIsSecretKey).
|
||||
//
|
||||
// So the command surface stays whole and only the READ is masked, restoring the
|
||||
// distinction CommonCLI intended for a caller who is not at the serial port.
|
||||
// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand),
|
||||
// beside the rest of the secret classification and host-tested with it.
|
||||
|
||||
// Keep the set/unset signal, which is the useful part and what CommonCLI itself
|
||||
// reports remotely; only the value goes. A getter answers "> value".
|
||||
static void wcMaskSecretReply(char* reply) {
|
||||
const char* val = reply;
|
||||
if (val[0] == '>' && val[1] == ' ') val += 2;
|
||||
const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0);
|
||||
strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)");
|
||||
}
|
||||
// Reply classification lives in WebConfigBatch.h with the rest of the decisions
|
||||
// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded),
|
||||
// so the shapes CommonCLI actually emits are enumerated in one host-tested place.
|
||||
|
||||
// Constant-time-ish comparison so login timing doesn't leak a prefix match.
|
||||
static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) {
|
||||
size_t la = strnlen(a, max_len), lb = strnlen(b, max_len);
|
||||
@@ -50,6 +133,17 @@ struct WCLock {
|
||||
WebConfigServer* WebConfigServer::_active = NULL;
|
||||
AsyncWebServer* WebConfigServer::_host = NULL;
|
||||
|
||||
// Out-of-line definitions for the in-class-initialised constants. An in-class
|
||||
// initialiser is only a declaration under C++11 (what the xtensa-esp32
|
||||
// toolchain builds with), so any use that binds a reference rather than reading
|
||||
// the value — ArduinoJson takes its argument as `const T&` — needs the symbol to
|
||||
// exist. Comparisons like `count >= MAX_BATCH` never did, which is why this only
|
||||
// surfaced when MAX_BATCH started being reported in JSON, and then only on the
|
||||
// targets where the compiler happened not to fold it.
|
||||
const int WebConfigServer::MAX_BATCH;
|
||||
const size_t WebConfigServer::MAX_BODY;
|
||||
const uint32_t WebConfigServer::STOP_WARN_MS;
|
||||
|
||||
// Protects the permanent route host's active-session pointer and handler
|
||||
// references across the loop and async_tcp cores. The critical sections only
|
||||
// copy a pointer/update a counter; handlers themselves never run under it.
|
||||
@@ -57,9 +151,10 @@ static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
WebConfigServer::WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks,
|
||||
const uint8_t* pub_key, const char* fw_ver,
|
||||
const char* build_date,
|
||||
const char* role, const char* board_name)
|
||||
: _prefs(prefs), _obs(obs), _cb(callbacks), _pub_key(pub_key),
|
||||
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
|
||||
_fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) {
|
||||
_mux = xSemaphoreCreateMutex();
|
||||
}
|
||||
|
||||
@@ -315,20 +410,51 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
// WiFi regardless, so a concurrent GET waiting on it costs nothing extra.
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
_cb->execCommand(e.cmd, e.reply);
|
||||
// A CLI `reboot` is answered here rather than executed: Board::reboot()
|
||||
// does not return, so running it would take the node down before the
|
||||
// operator could read whether the preceding commands succeeded. The
|
||||
// batch arms the ordinary deferred reboot on the first result read.
|
||||
if (_batch_kind == BATCH_CLI && wcIsDeferredReboot(e.cmd)) {
|
||||
// Deliberately non-committal: whether the reboot actually happens is
|
||||
// not known until the whole sequence has run (it is withheld if any
|
||||
// command failed), and a later command could still fail after this one.
|
||||
strcpy(e.reply, "OK - reboot queued");
|
||||
} else {
|
||||
_cb->execCommand(e.cmd, e.reply);
|
||||
}
|
||||
if (e.reply[0] == 0) strcpy(e.reply, "OK");
|
||||
// The upstream `password` command echoes the new password back in its
|
||||
// reply, and replies are served to the client over the open setup AP.
|
||||
// Overwrite it: the command cannot fail, so there is nothing to report.
|
||||
if (wcIsAdminPasswordKey(e.key)) strcpy(e.reply, "OK");
|
||||
// Success convention across every allowlisted setter is an "OK" prefix
|
||||
// (the UI relies on the same test); anything else is a rejection.
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(_batch_all_ok,
|
||||
strncmp(e.reply, "OK", 2) == 0);
|
||||
// Config entries carry the key; a CLI entry is matched on the command.
|
||||
const bool set_admin_pwd = wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd);
|
||||
if (set_admin_pwd) strcpy(e.reply, "OK");
|
||||
// Satisfies the initial-setup invariant for the rest of this session, so
|
||||
// the operator can set the password and configure WiFi in separate steps
|
||||
// (the form batch always sends them together and needs no such memory).
|
||||
if (set_admin_pwd) _admin_pwd_set = true;
|
||||
if (_batch_kind == BATCH_CLI && wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply);
|
||||
// What gates the reboot is narrower than "did anything fail": only a
|
||||
// write can leave the node in a config not worth rebooting into, and only
|
||||
// a write has a reply convention ("OK") solid enough to test. Diagnostics
|
||||
// in the sequence neither gate it nor get guessed at.
|
||||
if (_batch_kind != BATCH_CLI || WebConfigBatch::cliReplyGatesReboot(e.cmd)) {
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(
|
||||
_batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply));
|
||||
}
|
||||
}
|
||||
_batch_last_cmd = millis();
|
||||
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
e.key, (unsigned long)(_batch_last_cmd - t0));
|
||||
// Config entries are named by their (non-secret) key. A CLI command is
|
||||
// deliberately not logged: the operator can see what they typed, and a
|
||||
// `set wifi.pwd` or `password` from the terminal must not reach the serial
|
||||
// log, which is a different audience from the browser session.
|
||||
if (_batch_kind == BATCH_CLI) {
|
||||
Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
(unsigned long)(_batch_last_cmd - t0));
|
||||
} else {
|
||||
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
e.key, (unsigned long)(_batch_last_cmd - t0));
|
||||
}
|
||||
if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) {
|
||||
return; // more commands next tick
|
||||
}
|
||||
@@ -418,6 +544,10 @@ void WebConfigServer::registerRoutes() {
|
||||
_server->on("/api/config", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigGet); });
|
||||
_server->on("/api/config", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigPost); },
|
||||
NULL, collectBody);
|
||||
// Same specific-route-first rule as /api/config above.
|
||||
_server->on("/api/cli/result", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliResult); });
|
||||
_server->on("/api/cli", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliPost); },
|
||||
NULL, collectBody);
|
||||
_server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleStats); });
|
||||
_server->on("/api/scan", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleScan); });
|
||||
_server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleReboot); });
|
||||
@@ -487,6 +617,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
|
||||
doc["node_id"] = node_id;
|
||||
doc["fw"] = _fw_ver;
|
||||
// The page shows a trimmed version — base + build number + channel — and
|
||||
// pairs it with this, the way `ver` does. Both come from the same defines.
|
||||
doc["build_date"] = _build_date;
|
||||
doc["role"] = _role;
|
||||
doc["board"] = _board_name;
|
||||
doc["uptime_s"] = millis() / 1000;
|
||||
@@ -495,6 +628,10 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
// Servers the UI should expose: only as many as can actually be active at
|
||||
// once (2 without PSRAM, 5 with). Configuring more never connects.
|
||||
doc["active_slots"] = MQTTBridge::getMaxActiveSlots();
|
||||
// Commands the terminal may submit at once. The CLI shares the config
|
||||
// batch's fixed slot, so the cap is MAX_BATCH — reported rather than
|
||||
// duplicated in the page, which cannot know how this build was sized.
|
||||
doc["max_cmds"] = MAX_BATCH;
|
||||
|
||||
AsyncResponseStream* res = req->beginResponseStream("application/json");
|
||||
serializeJson(doc, *res);
|
||||
@@ -781,6 +918,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
req->send(400, "application/json", "{\"error\":\"no changes\"}");
|
||||
return;
|
||||
}
|
||||
_batch_kind = BATCH_CONFIG;
|
||||
_batch_count = count;
|
||||
_batch_next = 0;
|
||||
_batch_reboot = reboot_after;
|
||||
@@ -820,8 +958,13 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
// fires but no branch print follows, the handler is blocked on _mux.
|
||||
Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state);
|
||||
WCLock lock(_mux);
|
||||
const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult(
|
||||
toSpecState(_batch_state), strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
// A CLI sequence occupying the shared slot is not a config save, whatever the
|
||||
// reqid says: its entries have no `key` and its results belong to the
|
||||
// terminal's reader. Treat it as unknown here (and vice versa there).
|
||||
const bool mine = (_batch_kind == BATCH_CONFIG) &&
|
||||
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
const WebConfigBatch::ResultOutcome outcome =
|
||||
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
|
||||
Serial.println("WC: result read -> idle");
|
||||
StaticJsonDocument<64> idle;
|
||||
@@ -877,6 +1020,238 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
req->send(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI terminal (/api/cli). Same 202 + reqid + poll contract as a config save,
|
||||
// and for the same reason: CommonCLI touches prefs, the radio and the
|
||||
// filesystem, none of which may be reached from the async_tcp task. The
|
||||
// commands go into the shared deferred slot and tick() drains them.
|
||||
//
|
||||
// Unlike a save this is NOT allowlisted. That is the point: the terminal exists
|
||||
// to reach what the serial console reaches, and execCommand() passes
|
||||
// sender_timestamp 0, so it gets the same local privilege the serial console
|
||||
// has. Authentication is the boundary — as it is for serial (physical access)
|
||||
// and for remote admin over the mesh (the admin password).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Commands whose CLI handler never returns would take the node down mid-drain,
|
||||
// before the client could read a single result. `reboot` is deferred instead:
|
||||
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
|
||||
// path once the operator has read the results. The rest (clkreboot, poweroff,
|
||||
// ota update) do real work on the way down and cannot be faked, so they run
|
||||
// normally and the connection drops — the UI warns before sending them.
|
||||
void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { req->send(503); return; }
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
if (req->_tempObject == NULL) {
|
||||
req->send(413, "application/json", "{\"error\":\"body too large\"}");
|
||||
return;
|
||||
}
|
||||
const char* body = (const char*)req->_tempObject;
|
||||
DynamicJsonDocument doc(6144);
|
||||
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad json\"}");
|
||||
return;
|
||||
}
|
||||
const char* reqid = doc["reqid"] | "";
|
||||
if (!wcIsValidReqId(reqid)) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
}
|
||||
JsonArray cmds = doc["cmds"];
|
||||
if (cmds.isNull()) {
|
||||
req->send(400, "application/json", "{\"error\":\"no commands\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
WCLock lock(_mux);
|
||||
// Replay/Busy exactly as a config save classifies them: a repeated POST is
|
||||
// acknowledged rather than executed twice, and a different sequence while one
|
||||
// is still draining is refused.
|
||||
const WebConfigBatch::State bstate = toSpecState(_batch_state);
|
||||
const bool reqid_matches = (strcmp(reqid, _batch_reqid) == 0);
|
||||
const WebConfigBatch::PostOutcome pre =
|
||||
WebConfigBatch::classifyPost(bstate, reqid_matches, 1 /* count unknown yet */, false);
|
||||
if (pre == WebConfigBatch::PostOutcome::Replay) {
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = (bstate == WebConfigBatch::State::Done) ? "done" : "running";
|
||||
ack["total"] = _batch_count;
|
||||
ack["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(ack, out);
|
||||
req->send(202, "application/json", out);
|
||||
return;
|
||||
}
|
||||
if (pre == WebConfigBatch::PostOutcome::Busy) {
|
||||
StaticJsonDocument<96> bd;
|
||||
bd["error"] = "busy";
|
||||
bd["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(bd, out);
|
||||
req->send(409, "application/json", out);
|
||||
return;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false;
|
||||
for (JsonVariant v : cmds) {
|
||||
const char* raw = v.as<const char*>();
|
||||
if (!raw) continue;
|
||||
if (count >= MAX_BATCH) {
|
||||
StaticJsonDocument<96> ed;
|
||||
ed["error"] = "too many commands";
|
||||
ed["max"] = MAX_BATCH;
|
||||
String out;
|
||||
serializeJson(ed, out);
|
||||
req->send(413, "application/json", out);
|
||||
return;
|
||||
}
|
||||
// Strip CR/LF so one entry cannot smuggle a second command past the
|
||||
// operator's confirmation, and skip whatever is left blank.
|
||||
BatchEntry& e = _batch[count];
|
||||
int pos = 0;
|
||||
for (const char* p = raw; *p; p++) {
|
||||
if (*p == '\r' || *p == '\n') continue;
|
||||
if (pos == 0 && (*p == ' ' || *p == '\t')) continue; // leading space
|
||||
if (pos >= (int)sizeof(e.cmd) - 1) {
|
||||
req->send(400, "application/json", "{\"error\":\"command too long\"}");
|
||||
return;
|
||||
}
|
||||
e.cmd[pos++] = *p;
|
||||
}
|
||||
while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--;
|
||||
e.cmd[pos] = 0;
|
||||
if (pos == 0) continue;
|
||||
// Reject before anything runs, so a sequence never half-applies and then
|
||||
// stops on a command that was never going to work here.
|
||||
const char* why = wcCliUnavailable(e.cmd);
|
||||
if (why) {
|
||||
StaticJsonDocument<256> ed;
|
||||
ed["error"] = why;
|
||||
String out;
|
||||
serializeJson(ed, out);
|
||||
req->send(400, "application/json", out);
|
||||
return;
|
||||
}
|
||||
e.key[0] = 0; // CLI entries have no config key
|
||||
if (wcIsDeferredReboot(e.cmd)) defer_reboot = true;
|
||||
if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true;
|
||||
if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true;
|
||||
count++;
|
||||
}
|
||||
if (count == 0) {
|
||||
req->send(400, "application/json", "{\"error\":\"no commands\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The same invariant handleConfigPost enforces, and for the same reason: the
|
||||
// reboot is what commits first onboarding, and a node that reboots onto the
|
||||
// LAN still holding the factory password is a known credential on someone
|
||||
// else's network. The terminal warned about this client-side, which is a
|
||||
// reminder, not a rule — a pasted script or a direct POST ignored it.
|
||||
if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set &&
|
||||
(defer_reboot || seq_sets_ssid)) {
|
||||
req->send(400, "application/json",
|
||||
"{\"error\":\"admin password required for initial setup — "
|
||||
"run `password <new-password>` first\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
_batch_kind = BATCH_CLI;
|
||||
_batch_count = count;
|
||||
_batch_next = 0;
|
||||
_batch_reboot = defer_reboot;
|
||||
_batch_reboot_armed = false;
|
||||
_batch_all_ok = true;
|
||||
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
|
||||
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
|
||||
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
|
||||
Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot);
|
||||
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = "running";
|
||||
ack["total"] = count;
|
||||
ack["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(ack, out);
|
||||
req->send(202, "application/json", out);
|
||||
}
|
||||
|
||||
void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { req->send(503); return; }
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
if (!req->hasParam("reqid")) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
}
|
||||
String requested_reqid = req->getParam("reqid")->value();
|
||||
if (!wcIsValidReqId(requested_reqid.c_str())) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
}
|
||||
int from = 0;
|
||||
if (req->hasParam("from")) {
|
||||
from = req->getParam("from")->value().toInt();
|
||||
if (from < 0) from = 0;
|
||||
}
|
||||
|
||||
WCLock lock(_mux);
|
||||
// A config save occupying the slot is not this client's sequence, whatever
|
||||
// the reqid says; treat it as unknown rather than serving `set` results
|
||||
// through the terminal's reader.
|
||||
const bool mine = (_batch_kind == BATCH_CLI) &&
|
||||
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
const WebConfigBatch::ResultOutcome outcome =
|
||||
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
|
||||
StaticJsonDocument<64> idle;
|
||||
idle["state"] = "idle";
|
||||
idle["reqid"] = requested_reqid;
|
||||
String out;
|
||||
serializeJson(idle, out);
|
||||
req->send(200, "application/json", out);
|
||||
return;
|
||||
}
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Unknown) {
|
||||
req->send(404, "application/json", "{\"error\":\"unknown request\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Results stream: hand back whatever has drained since the client's cursor,
|
||||
// capped so the document stays small on the async_tcp task.
|
||||
const int produced = _batch_next;
|
||||
const int page = WebConfigBatch::cliPageCount(from, produced, WebConfigBatch::kCliResultPage);
|
||||
const bool final_read = WebConfigBatch::cliReadIsFinal(toSpecState(_batch_state),
|
||||
from, page, _batch_count);
|
||||
DynamicJsonDocument doc(4096);
|
||||
doc["state"] = final_read ? "done" : "running";
|
||||
doc["reqid"] = (const char*)_batch_reqid;
|
||||
doc["total"] = _batch_count;
|
||||
doc["from"] = from;
|
||||
JsonArray results = doc.createNestedArray("results");
|
||||
for (int i = from; i < from + page; i++) {
|
||||
JsonObject r = results.createNestedObject();
|
||||
// The command is deliberately NOT echoed: it may hold a password or token,
|
||||
// and the client already has the sequence it sent. It matches by index.
|
||||
r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply);
|
||||
r["reply"] = (const char*)_batch[i].reply;
|
||||
}
|
||||
if (final_read) {
|
||||
doc["all_ok"] = _batch_all_ok;
|
||||
const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok);
|
||||
doc["reboot"] = rebooting;
|
||||
// Tell the operator why a `reboot` they asked for is not happening.
|
||||
if (_batch_reboot && !_batch_all_ok) doc["reboot_withheld"] = true;
|
||||
if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot,
|
||||
_batch_all_ok, _batch_reboot_armed)) {
|
||||
_batch_reboot_armed = true;
|
||||
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
|
||||
}
|
||||
}
|
||||
AsyncResponseStream* res = req->beginResponseStream("application/json");
|
||||
serializeJson(doc, *res);
|
||||
req->send(res);
|
||||
}
|
||||
|
||||
void WebConfigServer::handleStats(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { req->send(503); return; }
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
|
||||
@@ -64,7 +64,7 @@ public:
|
||||
};
|
||||
|
||||
WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks,
|
||||
const uint8_t* pub_key, const char* fw_ver,
|
||||
const uint8_t* pub_key, const char* fw_ver, const char* build_date,
|
||||
const char* role, const char* board_name);
|
||||
~WebConfigServer();
|
||||
|
||||
@@ -98,6 +98,11 @@ private:
|
||||
// than freeing memory still referenced by the async task.
|
||||
static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs;
|
||||
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
|
||||
// What filled the shared slot. A config save comes from allowlisted form
|
||||
// fields; a CLI sequence is arbitrary commands typed into the terminal. They
|
||||
// share the slot (see WebConfigBatch.h) but differ in how results are read
|
||||
// and in whether `key` means anything, so every reader checks the kind.
|
||||
enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI };
|
||||
|
||||
// BatchState and WebConfigBatch::State are deliberately kept as separate
|
||||
// types (the enum is stored in a volatile member and used in prints); this
|
||||
@@ -110,9 +115,9 @@ private:
|
||||
}
|
||||
}
|
||||
struct BatchEntry {
|
||||
char key[24]; // allowlisted config key (echoed back to the UI)
|
||||
char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries
|
||||
char cmd[160]; // full CLI command (may contain secrets - never echoed)
|
||||
char reply[160];
|
||||
char reply[160]; // CLI reply budget, same 160 bytes the serial console gets
|
||||
};
|
||||
|
||||
NodePrefs* _prefs;
|
||||
@@ -120,6 +125,7 @@ private:
|
||||
Callbacks* _cb;
|
||||
const uint8_t* _pub_key;
|
||||
const char* _fw_ver;
|
||||
const char* _build_date;
|
||||
const char* _role;
|
||||
const char* _board_name;
|
||||
|
||||
@@ -130,6 +136,10 @@ private:
|
||||
bool _stopping = false;
|
||||
bool _was_setup_ap = false;
|
||||
bool _initial_setup = false;
|
||||
// A `password` command has succeeded this session. Lets the CLI satisfy the
|
||||
// initial-setup invariant across separate submissions; the form batch always
|
||||
// sends the password with the rest, so it never needed the memory.
|
||||
bool _admin_pwd_set = false;
|
||||
char _ap_ssid[33] = {0};
|
||||
|
||||
// Currently attached session, also used by the display's setup-info poll.
|
||||
@@ -141,6 +151,7 @@ private:
|
||||
|
||||
// Command batch: filled by async_tcp under _mux, drained by tick().
|
||||
volatile BatchState _batch_state = BATCH_IDLE;
|
||||
volatile BatchKind _batch_kind = BATCH_CONFIG;
|
||||
uint8_t _batch_count = 0;
|
||||
uint8_t _batch_next = 0; // drain progress (one command per tick)
|
||||
uint32_t _batch_last_cmd = 0;
|
||||
@@ -199,6 +210,8 @@ private:
|
||||
void handleConfigGet(AsyncWebServerRequest* req);
|
||||
void handleConfigPost(AsyncWebServerRequest* req);
|
||||
void handleConfigResult(AsyncWebServerRequest* req);
|
||||
void handleCliPost(AsyncWebServerRequest* req);
|
||||
void handleCliResult(AsyncWebServerRequest* req);
|
||||
void handleStats(AsyncWebServerRequest* req);
|
||||
void handleScan(AsyncWebServerRequest* req);
|
||||
void handlePresets(AsyncWebServerRequest* req);
|
||||
|
||||
@@ -190,6 +190,108 @@ TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) {
|
||||
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI sequences (/api/cli), which share the deferred-command slot
|
||||
// --------------------------------------------------------------------------
|
||||
TEST(WebConfigBatch, CliReadPagesResultsAndNeverOverrunsWhatHasDrained) {
|
||||
const int page = Batch::kCliResultPage;
|
||||
// Nothing drained past the cursor yet.
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/0, /*produced=*/0, page));
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/3, /*produced=*/3, page));
|
||||
// Partial progress: hand back exactly what exists.
|
||||
EXPECT_EQ(3, Batch::cliPageCount(0, 3, page));
|
||||
EXPECT_EQ(2, Batch::cliPageCount(5, 7, page));
|
||||
// More available than fits in one read: cap at the page size.
|
||||
EXPECT_EQ(page, Batch::cliPageCount(0, page + 5, page));
|
||||
// A cursor beyond what has drained (stale or crafted) yields nothing rather
|
||||
// than a negative count that would index backwards through the batch.
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/9, /*produced=*/4, page));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) {
|
||||
// Still executing: never final, however much has been read.
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Pending, /*from=*/0, /*page=*/8, /*total=*/8));
|
||||
// Execution finished but the client has only seen the first page. Reporting
|
||||
// "done" here would make a client that stops polling lose the rest.
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/0, /*page=*/8, /*total=*/20));
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/8, /*page=*/8, /*total=*/20));
|
||||
// The read that hands over the last result is the final one.
|
||||
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/16, /*page=*/4, /*total=*/20));
|
||||
// Re-reading past the end stays final (polls after the last page).
|
||||
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/20, /*page=*/0, /*total=*/20));
|
||||
}
|
||||
|
||||
// Every string below is a literal lifted from CommonCLI.cpp /
|
||||
// CommonCLI_Observer.cpp. Testing only for an "Err" prefix passed five of these
|
||||
// off as success, which both coloured them green and let a queued reboot go
|
||||
// ahead after them.
|
||||
TEST(WebConfigBatch, CliFailureRepliesAreRecognisedInEveryShapeCommonCLIEmits) {
|
||||
const char* failures[] = {
|
||||
"Err - bad params", // MyMesh setperm
|
||||
"ERR: bad pubkey", // neighbor.remove
|
||||
"Error: IATA code must be exactly 3 letters",// observer setters
|
||||
"(ERR: clock cannot go backwards)", // clock sync, parenthesised
|
||||
"Unknown command", // top-level fallthrough
|
||||
"unknown config: mqtt.nope", // set fallthrough
|
||||
"??: mqtt.nope", // get fallthrough
|
||||
"Can't find GPS", // gps
|
||||
"File system erase: Err", // failure reported at the end
|
||||
};
|
||||
for (const char* f : failures) {
|
||||
EXPECT_TRUE(Batch::cliReplyIsFailure(f)) << f;
|
||||
}
|
||||
|
||||
const char* successes[] = {
|
||||
"OK",
|
||||
"OK - slot 1 preset: meshrank",
|
||||
"> 22", // getter value
|
||||
"> msgs: on, 1: analyzer-us (ok)", // getter, contains "ok"
|
||||
"File system erase: OK", // same shape, succeeded
|
||||
"Free: 142832, Min: 126808", // memory
|
||||
"v1.16.0 (Build: 6 Jun 2026)", // ver
|
||||
};
|
||||
for (const char* s : successes) {
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(s)) << s;
|
||||
}
|
||||
// An empty reply is normalised to "OK" before it ever reaches the client.
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(""));
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(NULL));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, OnlyWritesGateTheDeferredReboot) {
|
||||
// Writes gate it: these are what can leave a config not worth rebooting into.
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("set tx 22"));
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("set mqtt1.preset meshrank"));
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("password hunter2"));
|
||||
// Diagnostics do not. `memory` answering "Free: ..." must not be read as a
|
||||
// failure and strand the operator's reboot, and a getter's "> value" must not
|
||||
// be read as a success either — neither is asked.
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("memory"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("get tx"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("reboot"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("advert"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot(NULL));
|
||||
// "settle" must not be mistaken for a `set`; the space is part of the token.
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("settle"));
|
||||
|
||||
// A write counts only on the "OK" prefix every setter keeps.
|
||||
EXPECT_TRUE(Batch::cliWriteSucceeded("OK"));
|
||||
EXPECT_TRUE(Batch::cliWriteSucceeded("OK - reboot to apply"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded("unknown config: nope"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded("Error: expected a number"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded(""));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) {
|
||||
EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true));
|
||||
// Same rule a config save follows: do not reboot into a half-applied config
|
||||
// over a link the operator may not get back.
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(true, false));
|
||||
// No `reboot` in the sequence: nothing to allow either way.
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(false, true));
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(false, false));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Wrap-around guard shared with the production _reboot_at assignments
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -119,6 +119,45 @@ TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CLI secret reads ----------------------------------------------------
|
||||
// The web CLI runs commands with sender_timestamp 0, which is how CommonCLI
|
||||
// recognises the serial console and answers secret getters in plaintext. These
|
||||
// are the reads that must be masked back down for an HTTP caller.
|
||||
|
||||
TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
|
||||
const char* masked[] = {
|
||||
"get prv.key", // this node's identity — the worst one to leak
|
||||
"get wifi.pwd", // grants the operator's LAN, not just the node
|
||||
"get guest.password",
|
||||
"get alert.psk",
|
||||
"get bridge.secret",
|
||||
"get mqtt1.password", "get mqtt1.token",
|
||||
"get mqtt6.password", "get mqtt6.token",
|
||||
"get wifi.pwd", // extra space after the verb
|
||||
};
|
||||
for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c;
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) {
|
||||
const char* plain[] = {
|
||||
"get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx",
|
||||
"get public.key", // public half, safe to read
|
||||
"get mqtt.owner", // an owner's public key, not a credential
|
||||
};
|
||||
for (const char* c : plain) EXPECT_FALSE(wcIsSecretReadCommand(c)) << c;
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, OnlyMasksReads) {
|
||||
// Writing a secret has always been the portal's job and reveals nothing;
|
||||
// only the read is restricted. Nor may a prefix be mistaken for a `get`.
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("set wifi.pwd hunter2"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("set prv.key aabb"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("password hunter2"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("getwifi.pwd"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("get"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand(""));
|
||||
}
|
||||
|
||||
// ---- request correlation -------------------------------------------------
|
||||
|
||||
TEST(WebConfigKeys, AcceptsExactHexRequestIds) {
|
||||
|
||||
@@ -113,7 +113,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -206,7 +205,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -187,7 +187,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -225,7 +224,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -264,7 +262,6 @@ build_flags =
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -303,7 +300,6 @@ build_flags =
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
|
||||
@@ -126,7 +126,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead).
|
||||
@@ -212,7 +211,6 @@ build_flags =
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -509,7 +507,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead).
|
||||
@@ -561,7 +558,6 @@ build_flags =
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Keep default observer profile less verbose to reduce runtime contention.
|
||||
|
||||
@@ -162,7 +162,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MQTT_MEMORY_DEBUG=1
|
||||
@@ -213,7 +212,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MQTT_MEMORY_DEBUG=1
|
||||
@@ -316,7 +314,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -359,7 +356,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -116,7 +116,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -162,7 +161,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -210,7 +210,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D PERSISTANT_GPS=1
|
||||
-D ENV_SKIP_GPS_DETECT=1
|
||||
@@ -257,7 +256,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D PERSISTANT_GPS=1
|
||||
-D ENV_SKIP_GPS_DETECT=1
|
||||
|
||||
@@ -161,7 +161,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -204,7 +203,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -160,7 +160,6 @@ build_flags =
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D PERSISTANT_GPS=1
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -203,7 +202,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -121,7 +121,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -164,7 +163,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -167,7 +167,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_TASK_STACK_SIZE=16384
|
||||
-D ESP32_CPU_FREQ=240
|
||||
@@ -213,7 +212,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_TASK_STACK_SIZE=16384
|
||||
-D ESP32_CPU_FREQ=240
|
||||
|
||||
@@ -116,7 +116,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -185,7 +184,6 @@ build_flags =
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -188,7 +188,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -315,7 +314,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -176,7 +176,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
@@ -224,7 +223,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -198,7 +198,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
@@ -244,7 +243,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -111,7 +111,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -155,7 +154,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
+818
-4
@@ -27,6 +27,10 @@ header svg{flex:none}
|
||||
.hmeta div{font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.badge{margin-left:auto;flex:none;font-size:11px;font-weight:650;padding:3px 9px;border-radius:99px;background:var(--chip);color:var(--mut)}
|
||||
.badge.setup{background:#f3e8d3;color:#8a5c00}
|
||||
.hicon{flex:none;margin-left:8px;padding:3px 8px;border:1px solid var(--in-line);border-radius:7px;
|
||||
background:var(--in-bg);color:var(--mut);cursor:pointer;line-height:1.3;
|
||||
font:700 12px/1.3 ui-monospace,Menlo,Consolas,monospace}
|
||||
.hicon:hover{color:var(--acc);border-color:var(--acc)}
|
||||
@media (prefers-color-scheme:dark){.badge.setup{background:#3a2f14;color:#e0b45c}}
|
||||
main{max-width:640px;margin:0 auto;padding:16px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:var(--shadow);margin-bottom:14px}
|
||||
@@ -118,6 +122,86 @@ canvas{width:100%;height:56px;display:block}
|
||||
.kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
|
||||
.hide{display:none!important}
|
||||
.note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px}
|
||||
|
||||
/* ---------------- CLI terminal ----------------
|
||||
Deliberately not themed: a console reads as a console in either colour
|
||||
scheme, and the reply colours below are tuned against this one background. */
|
||||
.term{--tf:#d7e0ea;--tdim:#6d7c8f;--tacc:#58a6ff;--tgrn:#57c26e;--terr:#ff7b72;--twarn:#e3b341;
|
||||
background:#0b0e12;color:var(--tf);border:1px solid #202832;border-radius:12px;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-size:13px;line-height:1.5;display:flex;flex-direction:column;overflow:hidden;
|
||||
/* generous by design: cliFit() clamps this down to the room actually left
|
||||
below the tabs, so the terminal fills the screen without scrolling it */
|
||||
height:78vh;min-height:240px}
|
||||
/* >=16px stops iOS zooming the page when the prompt takes focus (same reason the
|
||||
form inputs are 16px) — and on a phone 16px mono is the readable size anyway. */
|
||||
@media(pointer:coarse){.term{font-size:16px}}
|
||||
/* body's bottom padding reserves room for the save bar so it can't cover the
|
||||
last card. The CLI tab sizes itself around the save bar instead (cliFit), so
|
||||
the reservation there would only add dead space below the terminal. */
|
||||
body.tab-cli{padding-bottom:0}
|
||||
.term-hd{display:flex;align-items:center;gap:8px;flex:none;padding:6px 10px;
|
||||
background:#11161d;border-bottom:1px solid #202832;font-size:.82em;color:var(--tdim)}
|
||||
.term-hd .sp{flex:1}
|
||||
.term-hd button{flex:none;background:none;border:1px solid #2a333f;border-radius:5px;
|
||||
color:var(--tdim);font:inherit;padding:1px 8px;cursor:pointer}
|
||||
.term-hd button:hover{color:var(--tf);border-color:#3d4a5a}
|
||||
.term-out{flex:1;overflow-y:auto;overflow-x:hidden;padding:10px;-webkit-overflow-scrolling:touch}
|
||||
.term-out>div{white-space:pre-wrap;word-break:break-word}
|
||||
.term-out .cmd{color:#fff}
|
||||
.term-out .cmd:before{content:"> ";color:var(--tgrn)}
|
||||
/* A reply is green because it succeeded; red is reserved for a node that said
|
||||
no. Anything else reads every `get` as a failure. */
|
||||
.term-out .rep{color:#7ee787}
|
||||
.term-out .err{color:var(--terr)}
|
||||
.term-out .sys{color:var(--tdim)}
|
||||
.term-out .gap{height:.55em}
|
||||
/* zero-height rail: the suggestion list hangs off it and overlays the output
|
||||
instead of reflowing it, so the line you are typing never moves */
|
||||
.term-anchor{position:relative;height:0;flex:none;z-index:2}
|
||||
/* --sugmax is set from the terminal's own height (see cliFit) so the list can
|
||||
never be taller than the box it hangs inside and lose rows off the top */
|
||||
.term-sug{position:absolute;left:0;right:0;bottom:0;max-height:var(--sugmax,44vh);overflow-y:auto;
|
||||
background:#0e131a;border-top:1px solid #202832;box-shadow:0 -10px 24px rgba(0,0,0,.55)}
|
||||
.sg{display:flex;gap:10px;align-items:baseline;padding:5px 10px;cursor:pointer}
|
||||
.sg b{flex:none;font-weight:500;color:#cfe3ff;white-space:pre}
|
||||
.sg b u{color:var(--tacc);font-weight:700;text-decoration:none}
|
||||
.sg i{min-width:0;font-style:normal;font-size:.84em;color:var(--tdim);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.sg.on{background:#1e2b3a;box-shadow:inset 3px 0 0 var(--tacc)}
|
||||
.sg.on b{color:#fff}
|
||||
.sg.on i{color:#8b9bad}
|
||||
.term-line{display:flex;align-items:center;gap:8px;flex:none;padding:8px 10px;
|
||||
background:#0e131a;border-top:1px solid #202832}
|
||||
.term-ps{flex:none;color:var(--tgrn)}
|
||||
#term-in{flex:1;min-width:0;padding:0;border:0;border-radius:0;background:none;color:#fff;
|
||||
font:inherit;outline:none;caret-color:var(--tgrn)}
|
||||
#term-in:focus{border:0;box-shadow:none}
|
||||
#term-in::placeholder{color:#495767}
|
||||
#term-in:disabled{color:var(--tdim)}
|
||||
.term-go{flex:none;padding:2px 10px;border:1px solid #2a333f;border-radius:6px;
|
||||
background:none;color:var(--tdim);font:inherit;cursor:pointer}
|
||||
.term-go:hover{color:var(--tgrn);border-color:#3d4a5a}
|
||||
.term-go:disabled{opacity:.4;cursor:default}
|
||||
/* pasted-sequence confirmation, rendered in the scrollback rather than as a
|
||||
modal so it reads as part of the session (and behaves on a phone) */
|
||||
.term-cfm{border:1px solid #2a333f;border-left:3px solid var(--twarn);border-radius:8px;
|
||||
background:#12181f;padding:8px 10px;margin:6px 0}
|
||||
.term-cfm .h{color:var(--twarn);margin-bottom:5px}
|
||||
/* --n is the width of the "12 " gutter: hang the wrap so a long command's
|
||||
continuation lines up under the command, not under its number */
|
||||
.term-cfm .ln{color:#a9b7c6;white-space:pre-wrap;word-break:break-word;
|
||||
padding-left:var(--n,3ch);text-indent:calc(-1 * var(--n,3ch))}
|
||||
.term-cfm .ln s{text-decoration:none;color:var(--tdim)}
|
||||
.term-cfm .w{color:var(--twarn);margin-top:5px;font-size:.9em}
|
||||
.term-cfm .btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
.term-cfm button{border:1px solid #2a333f;border-radius:6px;background:#1b2530;color:var(--tf);
|
||||
font:inherit;padding:4px 12px;cursor:pointer}
|
||||
.term-cfm button.go{background:#1d3b28;border-color:#2f6b45;color:#8fe0a5}
|
||||
.term-cfm button:disabled{opacity:.45;cursor:default}
|
||||
.cli-tip{font-size:12px;color:var(--mut);margin-top:9px;display:flex;gap:12px;flex-wrap:wrap}
|
||||
.cli-tip kbd{font:inherit;font-family:ui-monospace,Menlo,monospace;background:var(--chip);
|
||||
border-radius:4px;padding:0 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -131,6 +215,12 @@ canvas{width:100%;height:56px;display:block}
|
||||
<div id="h-sub">connecting…</div>
|
||||
</div>
|
||||
<span class="badge hide" id="h-badge"></span>
|
||||
<!-- Setup-mode escape hatch: skip the guided steps and paste a prepared
|
||||
config straight into the console. Deliberately quiet — the wizard is
|
||||
still the path for everyone who isn't looking for this. -->
|
||||
<button class="hicon hide" id="h-console" type="button" onclick="enterConsole()"
|
||||
title="Console — skip setup and paste a prepared config"
|
||||
aria-label="Open console">>_</button>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -249,6 +339,7 @@ canvas{width:100%;height:56px;display:block}
|
||||
<button data-t="mqtt">MQTT</button>
|
||||
<button data-t="wifi">WiFi</button>
|
||||
<button data-t="stats">Stats</button>
|
||||
<button data-t="cli">CLI</button>
|
||||
</div>
|
||||
|
||||
<div id="t-radio">
|
||||
@@ -410,6 +501,33 @@ canvas{width:100%;height:56px;display:block}
|
||||
<div id="stat-slots" style="font-size:13.5px;color:var(--mut)">No data yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="t-cli" class="hide">
|
||||
<div class="card" style="padding:12px">
|
||||
<div class="term" id="term">
|
||||
<div class="term-hd">
|
||||
<span class="sp"></span>
|
||||
<button type="button" class="hide" id="term-setup" onmousedown="event.preventDefault()"
|
||||
onclick="showWizard()">← setup</button>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliHelp()">help</button>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliClear()">clear</button>
|
||||
</div>
|
||||
<div class="term-out" id="term-out"></div>
|
||||
<div class="term-anchor"><div class="term-sug hide" id="term-sug"></div></div>
|
||||
<div class="term-line">
|
||||
<span class="term-ps">></span>
|
||||
<input type="text" id="term-in" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||
spellcheck="false" enterkeyhint="go" placeholder="type a command">
|
||||
<button class="term-go" type="button" onmousedown="event.preventDefault()"
|
||||
onclick="cliSubmit()">run</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-tip">
|
||||
<span><kbd>Tab</kbd> complete</span><span><kbd>↑</kbd><kbd>↓</kbd> history</span>
|
||||
<span><kbd>Esc</kbd> dismiss</span><span>paste multiple lines to run a sequence</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -464,6 +582,18 @@ function $(s){return document.querySelector(s)}
|
||||
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
|
||||
function toast(m){var t=$("#toast");t.textContent=m;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(function(){t.classList.remove("show")},2600)}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&","<":"<",">":">",'"':"""}[c]})}
|
||||
// build.sh embeds base[.build][-observer][-channel]-hash, e.g.
|
||||
// v1.16.0.5-observer-beta-dev-a1b2c3d. What identifies a build to a person is
|
||||
// the base, the published build number and the channel — the -observer variant
|
||||
// tag is the same on every observer build and the commit is for machines. `ver`
|
||||
// still prints the whole string when you want it.
|
||||
function shortVer(){
|
||||
var fw=st.fw||"",m=/^(v?\d+\.\d+\.\d+(?:\.\d+)?)/.exec(fw);
|
||||
var s=m?m[1]:(fw||"unknown version");
|
||||
if(/-dev(-|$)/.test(fw))s+="-dev"; // OTA_CHANNEL_TAG=beta-dev
|
||||
else if(/-beta(-|$)/.test(fw))s+="-beta"; // a beta-only channel, if ever
|
||||
return st.build?s+" ("+st.build+")":s;
|
||||
}
|
||||
|
||||
function api(path,opts){
|
||||
opts=opts||{};
|
||||
@@ -476,7 +606,14 @@ function api(path,opts){
|
||||
}
|
||||
return fetch(path,opts).then(function(r){
|
||||
if(r.status===401){showLogin();throw new Error("auth")}
|
||||
return r.json().then(function(j){
|
||||
// An error response need not carry JSON — a bare 404 from handleNotFound
|
||||
// has an empty body. Letting the parse failure escape would strip the HTTP
|
||||
// status off the error and leave callers unable to tell "no such endpoint"
|
||||
// from "the network dropped". Successful responses must still parse.
|
||||
return r.json().catch(function(){
|
||||
if(r.ok)throw new Error("unreadable reply from the node");
|
||||
return {};
|
||||
}).then(function(j){
|
||||
if(!r.ok&&r.status!==202){
|
||||
// Carry the HTTP status and any batch reqid so callers can tell a
|
||||
// definite rejection (400/409/413) from an ambiguous network failure.
|
||||
@@ -503,10 +640,15 @@ function boot(){
|
||||
// (active_slots: 2 without PSRAM, 5 with). Fall back to the runtime array
|
||||
// size for older firmware that doesn't report it.
|
||||
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||"";
|
||||
$("#h-name").textContent=s.name||"MeshCore";
|
||||
$("#h-sub").textContent=s.role+" · "+s.fw+" · "+s.board;
|
||||
$("#h-sub").textContent=s.role+" · "+shortVer()+" · "+s.board;
|
||||
var b=$("#h-badge");b.classList.remove("hide");
|
||||
if(s.mode==="setup"){b.textContent="SETUP";b.classList.add("setup")}else{b.textContent="LAN"}
|
||||
// Only in setup mode: in LAN mode the console is a tab, and the header is
|
||||
// also on screen before login, where no shortcut into it belongs.
|
||||
$("#h-console").classList.toggle("hide",s.mode!=="setup");
|
||||
return api("/api/presets").catch(function(){return{presets:[]}});
|
||||
}).then(function(p){
|
||||
st.presets=p.presets||[];
|
||||
@@ -518,7 +660,9 @@ function boot(){
|
||||
});
|
||||
}
|
||||
|
||||
function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");updateSaveBar()}
|
||||
function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");
|
||||
document.body.classList.remove("tab-cli"); // re-armed by the tab click when the CLI is the one shown
|
||||
updateSaveBar()}
|
||||
function showLogin(){show("#v-login")}
|
||||
|
||||
/* ---------- login ---------- */
|
||||
@@ -963,11 +1107,22 @@ function enterApp(){
|
||||
show("#v-app");
|
||||
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
|
||||
}
|
||||
// Setup-mode shortcut: straight past the guided steps into the terminal, for
|
||||
// operators who already have a config to paste. Setup mode authenticates by
|
||||
// proximity to the AP, so this hands the whole command surface to anyone in
|
||||
// range — the same trust the wizard already extends, since it can set the admin
|
||||
// password and reflash the node's identity too.
|
||||
function enterConsole(){
|
||||
enterApp();
|
||||
$('#tabs button[data-t="cli"]').click();
|
||||
}
|
||||
$("#tabs").addEventListener("click",function(ev){
|
||||
var b=ev.target.closest("button");if(!b)return;
|
||||
$$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)});
|
||||
["radio","mqtt","wifi","stats"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
|
||||
["radio","mqtt","wifi","stats","cli"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
|
||||
if(b.dataset.t==="stats")startStats();else stopStats();
|
||||
document.body.classList.toggle("tab-cli",b.dataset.t==="cli");
|
||||
if(b.dataset.t==="cli")enterCli();
|
||||
});
|
||||
|
||||
/* ---------- stats ---------- */
|
||||
@@ -1285,6 +1440,665 @@ function showReboot(msg,reconnect,title){
|
||||
},1000);
|
||||
}
|
||||
|
||||
/* ---------- CLI: command reference ----------
|
||||
Drives autocomplete only — the node remains the authority on what it accepts.
|
||||
A trailing space marks a command that takes an argument, so accepting the
|
||||
completion leaves the cursor ready for the value. */
|
||||
// Commands per submitted sequence. The node reports its own limit (MAX_BATCH,
|
||||
// the fixed slot the CLI shares with config saves) in /api/status; this is only
|
||||
// the fallback for firmware that doesn't say.
|
||||
var CLI_MAX=24;
|
||||
// [command, description]
|
||||
var CLI_VERBS=[
|
||||
["ver","Firmware version"],
|
||||
["board","Board and hardware info"],
|
||||
["clock","Show the device clock (UTC)"],
|
||||
// `clock sync` is absent on purpose: it takes its time from the caller's
|
||||
// timestamp, which a web request has none of, so it can only ever fail here.
|
||||
["time ","Set the clock {epoch-seconds}"],
|
||||
["region","Show the configured region"],
|
||||
["memory","Heap and PSRAM free/min/largest block"],
|
||||
["neighbors","Nodes heard recently, with RSSI and age"],
|
||||
["neighbor.remove ","Drop one neighbour {64-hex-char-key}"],
|
||||
// Handled by MyMesh before it delegates to CommonCLI — a whole second
|
||||
// command surface the table missed until discover.* turned up absent.
|
||||
["discover.neighbors","Ask neighbours to identify themselves"],
|
||||
["discover.scopes","Collect neighbour scopes (needs the neighbors build)"],
|
||||
["setperm ","Set a node's ACL permissions {64-hex-char-key} {int8}"],
|
||||
["advert","Send an advert now (flooded)"],
|
||||
["advert.zerohop","Send an advert neighbours will not repeat"],
|
||||
["tempradio ","Try radio params without saving {freq,bw,sf,cr}"],
|
||||
["clear stats","Reset the packet and radio counters"],
|
||||
["stats-core","Core counters (recv, sent, airtime)"],
|
||||
["stats-packets","Per-packet-type counters"],
|
||||
["stats-radio","Radio counters (RSSI, SNR, noise)"],
|
||||
["stats-radio-diag","Extended radio diagnostics"],
|
||||
// Bare `log` is absent: it streams the file to the serial console and stalls
|
||||
// the radio doing it, and hands back only "EOF".
|
||||
["log start","Start packet logging to the filesystem"],
|
||||
["log stop","Stop packet logging"],
|
||||
["log erase","Delete the stored packet logs"],
|
||||
["sensor list","List attached sensors"],
|
||||
["sensor get ","Read one sensor {index}"],
|
||||
["sensor set ","Write one sensor {index} {value}"],
|
||||
["gps on","Power up the GPS"],
|
||||
["gps off","Power down the GPS"],
|
||||
["gps sync","Set the clock and location from the GPS"],
|
||||
["gps setloc","Copy the current GPS fix into lat/lon"],
|
||||
["gps advert none","Do not include GPS position in adverts"],
|
||||
["gps advert share","Advertise the live GPS position"],
|
||||
["gps advert prefs","Advertise the stored lat/lon"],
|
||||
["powersaving","Show the power-saving mode"],
|
||||
["powersaving on","Enable power saving"],
|
||||
["powersaving off","Disable power saving"],
|
||||
["password ","Change the admin password {new-password}"],
|
||||
["alert test","Send a test alert on the configured channel"],
|
||||
["ota check","Check for a newer build (does not flash)"],
|
||||
["ota update","Download and flash the newer build, then reboot"],
|
||||
// `start ota` is absent: it binds port 80, which this portal is already using.
|
||||
["start webconfig","Start this portal on the LAN"],
|
||||
["start webconfig ap","Start this portal on its own setup AP"],
|
||||
["stop webconfig","Stop this portal"],
|
||||
["reboot","Restart the node"],
|
||||
["clkreboot","Restart the node, preserving the clock"],
|
||||
["poweroff","Power the node off"],
|
||||
["shutdown","Power the node off (same as poweroff)"],
|
||||
["erase","Erase the filesystem — settings and identity"]
|
||||
];
|
||||
/* [key, description, mode, values]
|
||||
mode: 0 = get and set, 1 = get only, 2 = set only
|
||||
values: enum offered as value completions after `set <key> ` */
|
||||
var CLI_KEYS=[
|
||||
["name","Node name",0],
|
||||
["lat","Advert latitude",0],
|
||||
["lon","Advert longitude",0],
|
||||
["public.key","This node's public key",1],
|
||||
// `get acl` is absent: it prints to the serial console and returns nothing.
|
||||
["prv.key","Restore an identity {64-hex-char-key}",2],
|
||||
["role","Node role",1],
|
||||
["radio","Radio parameters {freq,bw,sf,cr}",0],
|
||||
["freq","Frequency in MHz",0],
|
||||
["tx","TX power in dBm",0],
|
||||
["af","Airtime factor",0],
|
||||
["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],
|
||||
["rxdelay","RX delay base",0],
|
||||
["txdelay","TX delay factor {0-2}",0],
|
||||
["direct.txdelay","TX delay factor for direct packets {0-2}",0],
|
||||
["repeat","Forward mesh traffic",0,"on|off"],
|
||||
["multi.acks","Extra ACKs to send",0],
|
||||
["allow.read.only","Allow read-only remote access",0,"on|off"],
|
||||
["advert.interval","Local advert interval in minutes (0 = off)",0],
|
||||
["flood.advert.interval","Flood advert interval in hours (0 = off)",0],
|
||||
["flood.max","Max flood hops {0-64}",0],
|
||||
["flood.max.advert","Max advert hops {0-64}",0],
|
||||
["flood.max.unscoped","Max hops for unscoped floods {0-64}",0],
|
||||
["loop.detect","Drop floods already carrying this node's hash",0,"off|minimal|moderate|strict"],
|
||||
["path.hash.mode","Path hash mode {0|1|2}",0],
|
||||
["owner.info","Owner info text (| becomes a newline)",0],
|
||||
["guest.password","Guest password",0],
|
||||
["adc.multiplier","Battery ADC multiplier",0],
|
||||
["wifi.ssid","WiFi network name",0],
|
||||
["wifi.pwd","WiFi password",0],
|
||||
["wifi.powersave","WiFi power-save mode",0,"none|min|max"],
|
||||
["wifi.status","WiFi connection, IP, RSSI and uptime",1],
|
||||
["mqtt.origin","Observer name in published messages",0],
|
||||
["mqtt.iata","IATA region code used in topic paths",0],
|
||||
["mqtt.status","Per-slot connection status",1],
|
||||
["mqtt.stats","Publish/queue counters",1],
|
||||
["mqtt.presets","Available broker presets",1],
|
||||
["mqtt.config.valid","Whether the MQTT config is usable",1],
|
||||
["mqtt.packets","Publish packet messages",0,"on|off"],
|
||||
["mqtt.raw","Also publish full raw frames",0,"on|off"],
|
||||
["mqtt.rx","Publish packets heard over the air",0,"on|off"],
|
||||
["mqtt.tx","Publish packets this node sends",0,"off|on|advert"],
|
||||
["mqtt.interval","Status publish interval {1-60 min}",0],
|
||||
["mqtt.neighbors","Publish the neighbour table (PSRAM boards)",0,"on|off"],
|
||||
["mqtt.neighbors.interval","Neighbour publish interval {12-336 hours}",0],
|
||||
["mqtt.owner","Owner public key {64-hex-char-key}",0],
|
||||
["mqtt.email","Owner email address",0],
|
||||
["mqtt.ntp","NTP server (none clears it)",0],
|
||||
["mqtt.ntp.diag","Last NTP sync result",1],
|
||||
["timezone","POSIX timezone string",0],
|
||||
["timezone.offset","UTC offset in hours {-12 to 14}",0],
|
||||
["snmp","SNMP agent (restart required)",0,"on|off"],
|
||||
["snmp.community","SNMP community string",0],
|
||||
["alert","Alert channel",0,"on|off"],
|
||||
["alert.psk","Alert channel pre-shared key",0],
|
||||
["alert.hashtag","Alert channel hashtag",0],
|
||||
["alert.region","Alert region filter",0],
|
||||
["alert.interval","Minimum minutes between alerts",0],
|
||||
["alert.mqtt","Send alerts to MQTT",0,"on|off"],
|
||||
["alert.wifi","Alert on WiFi problems",0,"on|off"],
|
||||
["bridge.enabled","Serial packet bridge",0,"on|off"],
|
||||
["bridge.source","Packets the bridge carries",0,"rx|tx"],
|
||||
["bridge.baud","Bridge serial baud rate",0],
|
||||
["bridge.delay","Bridge send delay",0],
|
||||
["bridge.channel","Bridge channel",0],
|
||||
["bridge.secret","Bridge shared secret",0]
|
||||
];
|
||||
// Per-slot keys, expanded across the slots this board actually runs.
|
||||
// [field, description, values]
|
||||
var CLI_SLOT=[
|
||||
["preset","preset",1],
|
||||
["server","custom broker hostname"],
|
||||
["port","broker port {1-65535}"],
|
||||
["username","username"],
|
||||
["password","password"],
|
||||
["token","token (required by some presets)"],
|
||||
["topic","custom topic template, e.g. {iata}/{device}/{type}"],
|
||||
["audience","JWT audience — enables Ed25519 auth, blank clears"],
|
||||
["filter","packet types to publish: all, none, or a CSV of names/0-15"]
|
||||
];
|
||||
var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,trace,multipart,control,raw_custom".split(",");
|
||||
|
||||
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.
|
||||
function cliTable(){
|
||||
if(cli.built===st.nslots)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]);
|
||||
});
|
||||
for(var n=1;n<=st.nslots;n++){
|
||||
CLI_SLOT.forEach(function(f){
|
||||
t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
|
||||
t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]);
|
||||
});
|
||||
}
|
||||
cli.tbl=t;cli.built=st.nslots;
|
||||
return t;
|
||||
}
|
||||
// Value completions for `set <key> `. Returns null when the key has no enum,
|
||||
// which is also how the command list is suppressed once a value is being typed.
|
||||
function cliEnum(key){
|
||||
var m=key.match(/^mqtt[1-9]\.(\w+)$/);
|
||||
if(m){
|
||||
if(m[1]==="preset")return st.presets.map(function(p){return p.name}).concat(["custom","none"]);
|
||||
if(m[1]==="filter")return ["all","none"].concat(CLI_TYPES);
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- CLI: scrollback ---------- */
|
||||
function cliEcho(cls,text){
|
||||
var out=$("#term-out");
|
||||
// Keep the view pinned to the newest line only when it already was — a
|
||||
// sequence still running must not yank the scrollback out from under someone
|
||||
// reading further up.
|
||||
var atEnd=out.scrollTop+out.clientHeight>=out.scrollHeight-24;
|
||||
var d=document.createElement("div");
|
||||
d.className=cls;d.textContent=text;
|
||||
out.appendChild(d);
|
||||
while(out.childNodes.length>400)out.removeChild(out.firstChild);
|
||||
if(atEnd)out.scrollTop=out.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
function cliGap(){var d=cliEcho("gap","");return d}
|
||||
function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()}
|
||||
function cliBanner(){
|
||||
cliEcho("sys","MeshCore "+shortVer());
|
||||
cliEcho("sys",(st.role||"")+" · "+(st.board||"")+
|
||||
(/^v?\d+\.\d+\.\d+$/.test(st.fw||"")?" · local build, OTA not configured":""));
|
||||
if(st.mode==="setup"){
|
||||
// The wizard refuses to finish without an admin password; nothing stops a
|
||||
// console-driven setup from rebooting on the factory one, so say so here
|
||||
// rather than only warning at the point of reboot.
|
||||
cliEcho("sys","Setup mode — paste a prepared config, then finish with "+
|
||||
"\"password <new>\" and \"reboot\". \"← setup\" returns to the guided steps.");
|
||||
}
|
||||
cliEcho("sys","Type help for a summary, or a prefix and Tab to complete.");
|
||||
}
|
||||
function cliHelp(){
|
||||
cliGap();
|
||||
cliEcho("cmd","help");
|
||||
cliEcho("rep",
|
||||
"ver / board / clock identity, firmware and time\n"+
|
||||
"get <key> read any setting\n"+
|
||||
"set <key> <value> change any setting\n"+
|
||||
"advert send an advert now\n"+
|
||||
"neighbors nodes heard recently\n"+
|
||||
"memory / stats-core health counters\n"+
|
||||
"get mqtt.status per-slot broker connections\n"+
|
||||
"reboot restart the node");
|
||||
cliEcho("sys","Tab completes any prefix ("+cliTable().length+" commands known). "+
|
||||
"Paste several lines to run them in sequence. clear empties this window.");
|
||||
$("#term-in").focus();
|
||||
}
|
||||
|
||||
/* ---------- CLI: autocomplete ---------- */
|
||||
function cliSugOpen(){return !$("#term-sug").classList.contains("hide")}
|
||||
function cliHideSug(){$("#term-sug").classList.add("hide");$("#term-sug").innerHTML="";cli.sug=[];cli.sel=-1}
|
||||
function cliLoose(hay,q){
|
||||
var parts=q.split(/\s+/);
|
||||
for(var i=0;i<parts.length;i++){if(parts[i]&&hay.indexOf(parts[i])<0)return false}
|
||||
return true;
|
||||
}
|
||||
// Match state for the current input: {list, pre} where `pre` is the number of
|
||||
// leading characters that matched literally (underlined in the row).
|
||||
function cliMatch(raw){
|
||||
var s=raw.replace(/^\s+/,"");
|
||||
if(!s)return{list:[],pre:0};
|
||||
var vs=cliValueMatch(s);
|
||||
if(vs)return vs;
|
||||
var lo=s.toLowerCase(),pre=[],loose=[];
|
||||
cliTable().forEach(function(e){
|
||||
var t=e[0].toLowerCase();
|
||||
if(t.indexOf(lo)===0)pre.push(e);
|
||||
else if(loose.length<40&&cliLoose(t,lo))loose.push(e);
|
||||
});
|
||||
if(pre.length)return{list:pre.slice(0,60),pre:s.length};
|
||||
return{list:loose.slice(0,40),pre:0};
|
||||
}
|
||||
// Once `set <key> ` is complete, complete the VALUE. Keys without an enum
|
||||
// return an empty list so the command rows don't reappear under a typed value.
|
||||
function cliValueMatch(s){
|
||||
var m=s.match(/^set\s+(\S+)\s+(.*)$/);
|
||||
if(!m)return null;
|
||||
var vals=cliEnum(m[1]);
|
||||
if(!vals)return{list:[],pre:0};
|
||||
var head=s.slice(0,s.length-m[2].length),typed=m[2];
|
||||
// filter lists are CSV: complete the segment after the last comma
|
||||
var cut=typed.lastIndexOf(",");
|
||||
if(cut>=0){head+=typed.slice(0,cut+1);typed=typed.slice(cut+1)}
|
||||
var lo=typed.toLowerCase(),out=[];
|
||||
vals.forEach(function(v){
|
||||
if(v.toLowerCase().indexOf(lo)===0)out.push([head+v,cliValueNote(m[1],v)]);
|
||||
});
|
||||
return{list:out,pre:head.length+typed.length};
|
||||
}
|
||||
// The value is already the row's label, so the second column only earns its
|
||||
// place when it says something the value doesn't.
|
||||
function cliValueNote(key,val){
|
||||
if(/^mqtt[1-9]\.preset$/.test(key)){
|
||||
for(var i=0;i<st.presets.length;i++){
|
||||
if(st.presets[i].name!==val)continue;
|
||||
if(st.presets[i].needs==="token")return"needs a token";
|
||||
if(st.presets[i].needs==="userpass")return"needs a username and password";
|
||||
return"";
|
||||
}
|
||||
return val==="none"?"leave this slot unused":(val==="custom"?"configure the broker by hand":"");
|
||||
}
|
||||
if(/^mqtt[1-9]\.filter$/.test(key)){
|
||||
var n=CLI_TYPES.indexOf(val);
|
||||
return n<0?"":"packet type "+(val==="raw_custom"?15:n);
|
||||
}
|
||||
return"";
|
||||
}
|
||||
function cliSug(){
|
||||
var m=cliMatch($("#term-in").value);
|
||||
cli.sug=m.list;cli.sel=-1;
|
||||
var box=$("#term-sug");
|
||||
if(!m.list.length){cliHideSug();return}
|
||||
var h="";
|
||||
m.list.forEach(function(e,i){
|
||||
var head=esc(e[0].slice(0,m.pre)),tail=esc(e[0].slice(m.pre));
|
||||
h+='<div class="sg" data-i="'+i+'"><b>'+(m.pre?"<u>"+head+"</u>":head)+tail+"</b>"+
|
||||
(e[1]?'<i>'+esc(e[1])+"</i>":"")+"</div>";
|
||||
});
|
||||
box.innerHTML=h;box.classList.remove("hide");box.scrollTop=0;
|
||||
}
|
||||
function cliPaint(){
|
||||
$$("#term-sug .sg").forEach(function(el,i){
|
||||
var on=i===cli.sel;el.classList.toggle("on",on);
|
||||
if(on&&el.scrollIntoView)el.scrollIntoView({block:"nearest"});
|
||||
});
|
||||
}
|
||||
function cliMove(d){
|
||||
if(!cli.sug.length)return;
|
||||
cli.sel=cli.sel<0?(d>0?0:cli.sug.length-1):(cli.sel+d+cli.sug.length)%cli.sug.length;
|
||||
cliPaint();
|
||||
}
|
||||
function cliAccept(i){
|
||||
var e=cli.sug[i];if(!e)return;
|
||||
var inp=$("#term-in");
|
||||
inp.value=e[0];inp.focus();
|
||||
cli.hix=-1;
|
||||
cliSug(); // re-filter: a command that takes an argument now offers values
|
||||
}
|
||||
function cliTabKey(){
|
||||
if(!cli.sug.length){cliSug();if(!cli.sug.length)return}
|
||||
if(cli.sel>=0){cliAccept(cli.sel);return}
|
||||
// shell behaviour: extend to the longest prefix every match shares before
|
||||
// committing to any single one
|
||||
var cur=$("#term-in").value.replace(/^\s+/,""),lcp=cli.sug[0][0];
|
||||
cli.sug.forEach(function(e){
|
||||
var n=0;while(n<lcp.length&&n<e[0].length&&lcp.charAt(n)===e[0].charAt(n))n++;
|
||||
lcp=lcp.slice(0,n);
|
||||
});
|
||||
if(lcp.length>cur.length){$("#term-in").value=lcp;cliSug();return}
|
||||
cliAccept(0);
|
||||
}
|
||||
$("#term-sug").addEventListener("mousedown",function(ev){ev.preventDefault()});
|
||||
$("#term-sug").addEventListener("click",function(ev){
|
||||
var r=ev.target.closest(".sg");if(!r)return;
|
||||
cliAccept(+r.dataset.i);
|
||||
});
|
||||
|
||||
/* ---------- CLI: history ----------
|
||||
In memory for the session only, never localStorage: `set wifi.pwd …` and
|
||||
`password …` pass through here and must not outlive the tab. */
|
||||
function cliPush(line){
|
||||
if(cli.hist[cli.hist.length-1]!==line)cli.hist.push(line);
|
||||
if(cli.hist.length>60)cli.hist.shift();
|
||||
cli.hix=-1;
|
||||
}
|
||||
function cliHistMove(d){
|
||||
var inp=$("#term-in");
|
||||
if(!cli.hist.length)return;
|
||||
if(cli.hix<0){cli.draft=inp.value;cli.hix=cli.hist.length}
|
||||
cli.hix+=d;
|
||||
if(cli.hix<0)cli.hix=0;
|
||||
if(cli.hix>=cli.hist.length){cli.hix=-1;inp.value=cli.draft}
|
||||
else inp.value=cli.hist[cli.hix];
|
||||
cliHideSug();
|
||||
var n=inp.value.length;
|
||||
try{inp.setSelectionRange(n,n)}catch(e){}
|
||||
}
|
||||
|
||||
/* ---------- CLI: input ---------- */
|
||||
$("#term-in").addEventListener("input",function(){cli.hix=-1;cliSug()});
|
||||
$("#term-in").addEventListener("blur",function(){setTimeout(cliHideSug,120)});
|
||||
$("#term-in").addEventListener("focus",function(){cliFit()});
|
||||
$("#term-in").addEventListener("keydown",function(ev){
|
||||
var k=ev.key;
|
||||
if(k==="Tab"){ev.preventDefault();cliTabKey();return}
|
||||
if(k==="ArrowUp"||k==="ArrowDown"){
|
||||
var d=k==="ArrowDown"?1:-1;
|
||||
ev.preventDefault();
|
||||
// The list owns the arrows while it is open; history takes them back once
|
||||
// it is dismissed, which is what Esc is for.
|
||||
if(cliSugOpen())cliMove(d);else cliHistMove(d);
|
||||
return;
|
||||
}
|
||||
if(k==="Enter"){
|
||||
ev.preventDefault();
|
||||
// Enter runs what is typed, unless a suggestion was deliberately selected
|
||||
// with the arrows — then it accepts, and a second Enter runs.
|
||||
if(cliSugOpen()&&cli.sel>=0){cliAccept(cli.sel);return}
|
||||
cliSubmit();return;
|
||||
}
|
||||
if(k==="Escape"){
|
||||
if(cliSugOpen()){cliHideSug();return}
|
||||
this.value="";cli.hix=-1;return;
|
||||
}
|
||||
if((k==="l"||k==="L")&&ev.ctrlKey){ev.preventDefault();cliClear()}
|
||||
});
|
||||
// Multi-line paste: a pasted list is a sequence to confirm, not a line to
|
||||
// mangle. (A single-line paste falls through to the browser's own insert.)
|
||||
$("#term-in").addEventListener("paste",function(ev){
|
||||
var cb=ev.clipboardData||window.clipboardData;
|
||||
if(!cb)return;
|
||||
var text=cb.getData("text")||"";
|
||||
if(!/[\r\n]/.test(text))return;
|
||||
ev.preventDefault();
|
||||
var cmds=cliParse(text);
|
||||
// Whatever was already typed joins the first pasted line verbatim — trimming
|
||||
// it would eat the space in a half-typed "set " and silently fuse the words.
|
||||
var carry=this.value;
|
||||
if(carry.trim()&&cmds.length){cmds[0]=carry+cmds[0];this.value=""}
|
||||
if(!cmds.length){cliEcho("sys","Nothing to run — that paste held no commands.");return}
|
||||
if(cmds.length===1){this.value=cmds[0];cliSug();return}
|
||||
cliHideSug();
|
||||
cliConfirm(cmds);
|
||||
});
|
||||
function cliParse(text){
|
||||
var out=[];
|
||||
text.split(/\r\n|\r|\n/).forEach(function(raw){
|
||||
var l=raw.trim();
|
||||
if(!l||l.charAt(0)==="#")return; // blanks and comments
|
||||
l=l.replace(/^(?:[>$]|meshcore\s*[>$#])\s+/,""); // tolerate a pasted transcript
|
||||
if(l)out.push(l);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- CLI: confirmation ----------
|
||||
Rendered into the scrollback rather than as a modal: it reads as part of the
|
||||
session, and on a phone it can't end up behind the keyboard. */
|
||||
// Anchored the way CommonCLI dispatches, which is mostly on a PREFIX: `reboot`
|
||||
// matches the first six bytes, so `reboot now` reboots too. Matching these
|
||||
// exactly (/^reboot$/) let those variants skip the confirmation entirely.
|
||||
var CLI_RISK=[
|
||||
[/^erase$/,"erases the filesystem — stored settings and this node's identity"],
|
||||
[/^clkreboot/,"resets the clock and restarts the node"],
|
||||
[/^reboot/,"restarts the node"],
|
||||
[/^(poweroff|shutdown)/,"powers the node off"],
|
||||
[/^ota update/,"downloads and flashes new firmware, then reboots"],
|
||||
[/^stop webconfig$/,"stops this portal"],
|
||||
[/^set wifi\.(ssid|pwd)\s/,"changes WiFi — this page will drop"],
|
||||
[/^set radio\s/,"changes radio parameters — a wrong value takes this node off the air"],
|
||||
[/^set freq\s/,"changes the frequency — a wrong value takes this node off the air"],
|
||||
[/^password\s/,"changes the admin password"],
|
||||
[/^set prv\.key\s/,"replaces this node's identity"]
|
||||
];
|
||||
function cliRisks(cmds){
|
||||
var seen={},out=[];
|
||||
cmds.forEach(function(c){
|
||||
CLI_RISK.forEach(function(r){
|
||||
if(r[0].test(c)&&!seen[r[1]]){seen[r[1]]=1;out.push(r[1])}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function cliConfirm(cmds){
|
||||
var over=cmds.length>CLI_MAX;
|
||||
if(over)cmds=cmds.slice(0,CLI_MAX);
|
||||
var risks=cliRisks(cmds),one=cmds.length===1;
|
||||
var out=$("#term-out"),box=document.createElement("div");
|
||||
box.className="term-cfm";
|
||||
var h=document.createElement("div");h.className="h";
|
||||
h.textContent=one?"Run this command?":("Run these "+cmds.length+" commands in order?");
|
||||
box.appendChild(h);
|
||||
var w=String(cmds.length).length;
|
||||
box.style.setProperty("--n",(w+2)+"ch");
|
||||
cmds.forEach(function(c,i){
|
||||
var d=document.createElement("div");d.className="ln";
|
||||
var n=document.createElement("s");
|
||||
n.textContent=(new Array(w-String(i+1).length+1)).join(" ")+(i+1)+" ";
|
||||
d.appendChild(n);d.appendChild(document.createTextNode(c));
|
||||
box.appendChild(d);
|
||||
});
|
||||
if(over){
|
||||
var t=document.createElement("div");t.className="w";
|
||||
t.textContent="Only the first "+CLI_MAX+" lines are included — the rest were dropped.";
|
||||
box.appendChild(t);
|
||||
}
|
||||
if(risks.length){
|
||||
var r=document.createElement("div");r.className="w";
|
||||
r.textContent="⚠ This "+risks.join("; and ")+".";
|
||||
box.appendChild(r);
|
||||
}
|
||||
// Onboarding by paste skips the wizard's mandatory admin-password step, and
|
||||
// rebooting is what commits the node to normal operation.
|
||||
if(st.mode==="setup"&&!cli.pwd&&cmds.some(function(c){return /^(reboot|clkreboot)$/.test(c)})){
|
||||
var p=document.createElement("div");p.className="w";
|
||||
// The node enforces this too (handleCliPost) — this is the earlier, kinder
|
||||
// half of the same rule, so the operator finds out before sending.
|
||||
p.textContent="⚠ Set the admin password first: run \"password <new-password>\". "+
|
||||
"The node will refuse to finish setup on the factory one.";
|
||||
box.appendChild(p);
|
||||
}
|
||||
var btns=document.createElement("div");btns.className="btns";
|
||||
var go=document.createElement("button");go.className="go";
|
||||
go.textContent=one?"Run":("Run "+cmds.length+" commands");
|
||||
var no=document.createElement("button");no.textContent="Cancel";
|
||||
btns.appendChild(go);btns.appendChild(no);box.appendChild(btns);
|
||||
function settle(text){
|
||||
btns.remove();
|
||||
var s=document.createElement("div");s.className="w";s.textContent=text;box.appendChild(s);
|
||||
}
|
||||
go.onmousedown=no.onmousedown=function(ev){ev.preventDefault()};
|
||||
go.onclick=function(){settle(one?"Running…":"Running "+cmds.length+" commands…");cliRun(cmds)};
|
||||
no.onclick=function(){settle("Cancelled — nothing was sent.");$("#term-in").focus()};
|
||||
out.appendChild(box);out.scrollTop=out.scrollHeight;
|
||||
$("#term-in").blur(); // the sequence needs an answer before more typing
|
||||
}
|
||||
|
||||
/* ---------- CLI: run ----------
|
||||
Same 202 + reqid + poll contract as a config save, and for the same reason:
|
||||
the commands run on the node's main loop, not inside the request. Results
|
||||
stream back, so a long sequence fills the window as it executes. */
|
||||
function cliSubmit(){
|
||||
if(cli.busy)return;
|
||||
var inp=$("#term-in"),line=inp.value.trim();
|
||||
cliHideSug();
|
||||
if(!line)return;
|
||||
cliPush(line);inp.value="";
|
||||
if(line==="clear"||line==="cls"){cliClear();return}
|
||||
if(line==="help"||line==="?"){cliHelp();return}
|
||||
if(cliRisks([line]).length){cliConfirm([line]);return}
|
||||
cliRun([line]);
|
||||
}
|
||||
function cliBusy(on){
|
||||
cli.busy=on;
|
||||
$("#term-in").disabled=on;
|
||||
$(".term-go").disabled=on;
|
||||
if(!on&&window.matchMedia("(pointer:fine)").matches)$("#term-in").focus();
|
||||
}
|
||||
function cliRun(cmds){
|
||||
cliBusy(true);
|
||||
cliGap();
|
||||
var reqid=mkReqId();
|
||||
var status=cliEcho("sys",cmds.length>1?"running 0/"+cmds.length+"…":"…");
|
||||
post("/api/cli",{reqid:reqid,cmds:cmds}).then(function(){
|
||||
cliPoll(reqid,cmds,0,status,0,0);
|
||||
}).catch(function(e){
|
||||
if(e.message==="auth"){cliBusy(false);status.remove();return}
|
||||
// No such endpoint: this firmware predates the console (or was built
|
||||
// without it). Say so instead of retrying a route that will never exist.
|
||||
if(e.status===404){cliEnd(status,"This firmware has no console endpoint — nothing was sent.");return}
|
||||
if(e.status===409&&e.reqid!==reqid){cliEnd(status,"Another sequence is still running — retry shortly.");return}
|
||||
if(e.status===400||e.status===413){cliEnd(status,e.message||"Rejected by the node.");return}
|
||||
// Ambiguous: the request may have landed even though the reply was lost.
|
||||
// Poll for it rather than reporting a failure that did not happen.
|
||||
cliPoll(reqid,cmds,0,status,0,0);
|
||||
});
|
||||
}
|
||||
function cliEnd(status,msg){
|
||||
cliBusy(false);
|
||||
status.className="err";status.textContent=msg;
|
||||
$("#term-out").scrollTop=$("#term-out").scrollHeight;
|
||||
}
|
||||
function cliPoll(reqid,cmds,from,status,errs,idles){
|
||||
api("/api/cli/result?reqid="+encodeURIComponent(reqid)+"&from="+from,{timeout:5000})
|
||||
.then(function(r){
|
||||
if(r.state!=="idle"&&r.reqid!==reqid){cliEnd(status,"Lost track of this sequence — reload to check the node's state.");return}
|
||||
if(r.state==="idle"){
|
||||
// the POST may still be in flight after a connection blip
|
||||
if(idles<5){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs,idles+1)},400);return}
|
||||
cliEnd(status,"The node never received the command.");return;
|
||||
}
|
||||
(r.results||[]).forEach(function(x,i){
|
||||
// The node never echoes the command back — it may hold a password or a
|
||||
// token, and we already have the sequence we sent. Match by index.
|
||||
cliEcho("cmd",cmds[from+i]||"");
|
||||
// `ok` is advisory; the node's own convention is authoritative, and only
|
||||
// failure has a fixed shape there ("Err"/"ERR:"/"Error:").
|
||||
var ok=(x.ok!=null)?x.ok:!/^\s*err/i.test(x.reply||"");
|
||||
// Getters answer "> value" — that leading marker is the serial console's
|
||||
// way of setting a value apart, and here it collides with the prompt glyph
|
||||
// that means "you typed this". Drop it; the colour already says "reply".
|
||||
var reply=(x.reply||"").replace(/^>\s?/,"");
|
||||
if(reply)cliEcho(ok?"rep":"err",reply);
|
||||
});
|
||||
from+=(r.results||[]).length;
|
||||
// "running" also covers "finished, but more results are still to be paged
|
||||
// over", so keep polling until the node says done.
|
||||
if(r.state!=="done"){
|
||||
if(cmds.length>1)status.textContent="running "+from+"/"+cmds.length+"…";
|
||||
setTimeout(function(){cliPoll(reqid,cmds,from,status,0,0)},250);
|
||||
return;
|
||||
}
|
||||
status.remove();
|
||||
cliBusy(false);
|
||||
cliAfter(cmds,r);
|
||||
}).catch(function(e){
|
||||
if(e.message==="auth"){cliBusy(false);status.remove();return}
|
||||
if(e.status===400||e.status===404){cliEnd(status,"The node no longer has a result for this sequence.");return}
|
||||
if(errs<20){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs+1,idles)},700);return}
|
||||
cliEnd(status,"Lost the connection while the sequence was running — reload to check the node's state.");
|
||||
});
|
||||
}
|
||||
// A CLI `set` writes the same prefs the forms edit, so re-read them or the
|
||||
// other tabs keep showing stale values.
|
||||
function cliAfter(cmds,r){
|
||||
var touched=false;
|
||||
cmds.forEach(function(c){
|
||||
if(/^password\s/.test(c))cli.pwd=true;
|
||||
if(/^(set|password)\s/.test(c))touched=true;
|
||||
});
|
||||
// Same prefix dispatch as the risk list: `poweroff now` powers off too.
|
||||
if(touched)loadConfigSoft();
|
||||
// A `reboot` in the sequence is not run by the CLI — Board::reboot() never
|
||||
// returns, so the node answers it and schedules the restart for after this
|
||||
// read. It withholds it when any command failed, exactly as a config save
|
||||
// does; say which happened rather than leaving the operator to guess.
|
||||
if(r&&r.reboot_withheld){
|
||||
cliEcho("err","Not rebooting — a setting was rejected. Fix it and run \"reboot\" again.");
|
||||
return;
|
||||
}
|
||||
if(r&&r.reboot){
|
||||
showReboot("The node is restarting. This page will try to reconnect automatically.",true);
|
||||
return;
|
||||
}
|
||||
// clkreboot / poweroff / erase-and-flash take the node down themselves, so
|
||||
// there is no result to wait for.
|
||||
cmds.forEach(function(c){
|
||||
if(/^(poweroff|shutdown)/.test(c))showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…");
|
||||
else if(/^clkreboot/.test(c))showReboot("The node is restarting. This page will try to reconnect automatically.",true);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- CLI: fit ----------
|
||||
dvh/vh both ignore the on-screen keyboard, so on a phone the prompt can end
|
||||
up underneath it. visualViewport is the only thing that reports the space
|
||||
actually left, so size the terminal from that while the CLI tab is open. */
|
||||
function cliFit(){
|
||||
var vv=window.visualViewport,t=$("#term");
|
||||
if(!t||$("#t-cli").classList.contains("hide")||$("#v-app").classList.contains("hide"))return;
|
||||
t.style.height=""; // back to the stylesheet's height
|
||||
if(!vv)return;
|
||||
var base=t.offsetHeight;
|
||||
// everything still owed below the terminal — the shortcut row, the card's
|
||||
// padding and margin, main's padding — plus the save bar when it is up
|
||||
var extra=$("main").getBoundingClientRect().bottom-t.getBoundingClientRect().bottom+6;
|
||||
var sb=$("#savebar");if(sb.classList.contains("show"))extra+=sb.offsetHeight;
|
||||
var top=t.getBoundingClientRect().top-vv.offsetTop;
|
||||
var avail=Math.round(vv.height-top-extra);
|
||||
// Only ever shrink. Growing to fill the viewport would lengthen the page,
|
||||
// which lets it scroll, which frees more room — a loop that never settles.
|
||||
if(avail<base)t.style.height=Math.max(180,avail)+"px";
|
||||
t.style.setProperty("--sugmax",Math.round(t.offsetHeight*0.62)+"px");
|
||||
var out=$("#term-out");out.scrollTop=out.scrollHeight;
|
||||
}
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener("resize",cliFit);
|
||||
window.visualViewport.addEventListener("scroll",cliFit);
|
||||
}
|
||||
function enterCli(){
|
||||
$("#term-setup").classList.toggle("hide",st.mode!=="setup");
|
||||
if(!cli.shown){cli.shown=true;cliBanner()}
|
||||
window.scrollTo(0,0); // the terminal is the whole tab; show all of it
|
||||
cliFit();
|
||||
// Don't pop the keyboard just because the tab was opened — on a phone that
|
||||
// hides most of the window the operator came here to read.
|
||||
if(window.matchMedia("(pointer:fine)").matches)$("#term-in").focus({preventScroll:true});
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user