diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py
index 86885ac4..cd63cf93 100644
--- a/scripts/webconfig_mock_server.py
+++ b/scripts/webconfig_mock_server.py
@@ -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.
@@ -122,6 +127,7 @@ 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.scan_started = None
# ---- auth -------------------------------------------------------------
@@ -200,6 +206,15 @@ def apply_set(cfg, key, val):
ADMIN_PASSWORD = val
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(",")
@@ -340,6 +355,134 @@ def is_secret_key(key):
return key == "wifi.pwd" 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.
+# ---------------------------------------------------------------------------
+CLI_MAX_CMDS = 64 # per POSTed sequence
+CLI_CMD_SECS = 0.25 # simulated per-command execution time
+
+# 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)),
+}
+
+
+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):
+ if key in GETTERS:
+ return True, GETTERS[key](cfg)
+ 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, "Error: unknown config key '%s'" % key
+ 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":
+ return True, "v1.7.1-mock (observer)"
+ 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, "OK - filesystem erased, rebooting"
+ 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")
+ 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?"
+ if cli_read_key(cfg, key) is None and not re.match(r"^mqtt[1-6]\.", key):
+ return False, "Error: unknown config key '%s'" % key
+ return apply_set(cfg, key, val.strip())
+ return False, "Error: unknown command '%s'" % cmd[:40]
+
+
def valid_reqid(reqid):
return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid))
@@ -393,6 +536,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 +562,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
@@ -526,6 +677,84 @@ 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 %d)" % 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", "cmd": c[:32]})
+
+ 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": [],
+ "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"])]
+ ok, reply = run_cli(ST.cfg, cmd)
+ job["results"].append({"cmd": cmd, "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
+ return self._json(200, {
+ "state": j["state"], "reqid": reqid, "total": len(j["cmds"]),
+ "from": frm, "results": j["results"][frm:],
+ })
+
def _scan(self):
rescan = "rescan=1" in self.path
now = time.time()
diff --git a/webui/index.html b/webui/index.html
index c634d78f..6d7e0fc2 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -118,6 +118,85 @@ 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 b{font-weight:500;color:var(--tgrn);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.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)}
+.term-out .rep{color:#a9b7c6}
+.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}
+ Tab complete↑↓ history
+ Esc dismisspaste multiple lines to run a sequence
+
+
+
@@ -961,13 +1066,20 @@ function loadConfigSoft(){ // re-sync accepted values without clobbering chips/r
/* ---------- tabs / app ---------- */
function enterApp(){
show("#v-app");
+ // Setup mode authenticates by proximity — there is no admin password yet. The
+ // form batch is allowlisted (WebConfigKeys.h) so that is safe there, but the
+ // CLI is the entire command surface, `erase` and `set prv.key` included, so
+ // it stays behind a real login.
+ $('#tabs button[data-t="cli"]').classList.toggle("hide",st.mode==="setup");
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
}
$("#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 +1397,604 @@ 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. */
+var CLI_MAX=64; // commands per submitted sequence
+// [command, description]
+var CLI_VERBS=[
+ ["ver","Firmware version"],
+ ["board","Board and hardware info"],
+ ["clock","Show the device clock (UTC)"],
+ ["clock sync","Set the device clock from this browser"],
+ ["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}"],
+ ["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"],
+ ["log","Show the packet log summary"],
+ ["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","Raise the manual firmware-upload AP"],
+ ["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"],
+ ["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 ` */
+var CLI_KEYS=[
+ ["name","Node name",0],
+ ["lat","Advert latitude",0],
+ ["lon","Advert longitude",0],
+ ["public.key","This node's public key",1],
+ ["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};
+
+// 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 `. 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=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 · "+($("#h-sub").textContent||""));
+ 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 read any setting\n"+
+ "set 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 ` 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'+(m.pre?""+head+"":head)+tail+""+
+ (e[1]?''+esc(e[1])+"":"")+"";
+ });
+ 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(ncur.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. */
+var CLI_RISK=[
+ [/^erase$/,"erases the filesystem — stored settings and this node's identity"],
+ [/^(reboot|clkreboot)$/,"restarts the node"],
+ [/^(poweroff|shutdown)$/,"powers the node off"],
+ [/^ota update$/,"downloads and flashes new firmware, then reboots"],
+ [/^start ota$/,"drops this portal and raises the firmware-upload AP"],
+ [/^stop webconfig$/,"stops this portal"],
+ [/^set wifi\.(ssid|pwd)\b/,"changes WiFi — this page will drop"],
+ [/^set radio\b/,"changes radio parameters — a wrong value takes this node off the air"],
+ [/^set freq\b/,"changes the frequency — a wrong value takes this node off the air"],
+ [/^password\b/,"changes the admin password"],
+ [/^set prv\.key\b/,"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);
+ }
+ 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}
+ 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){
+ cliEcho("cmd",x.cmd);
+ // `ok` is advisory; the node's own OK/Error convention is authoritative
+ var ok=(x.ok!=null)?x.ok:!/^\s*(err|error)\b/i.test(x.reply||"");
+ if(x.reply)cliEcho(ok?"rep":"err",x.reply);
+ });
+ from+=(r.results||[]).length;
+ 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);
+ }).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){
+ var touched=false,restart=null;
+ cmds.forEach(function(c){
+ if(/^(set|password)\s/.test(c))touched=true;
+ if(/^(reboot|clkreboot)$/.test(c))restart=restart||"reboot";
+ if(/^erase$/.test(c))restart=restart||"erase";
+ if(/^(poweroff|shutdown)$/.test(c))restart="off";
+ });
+ if(touched)loadConfigSoft();
+ if(restart==="off")showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…");
+ else if(restart)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"))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