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} @@ -249,6 +328,7 @@ canvas{width:100%;height:56px;display:block} +
@@ -410,6 +490,31 @@ canvas{width:100%;height:56px;display:block}
No data yet.
+ +
+
+
+
+ meshcore + + +
+
+
+
+ > + + +
+
+
+ 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