Merge remote-tracking branch 'review/observer-firmware' into keymindCascade
# Conflicts: # .github/workflows/build-observer-firmwares.yml # .gitignore # MQTT_IMPLEMENTATION.md # MQTT_INTERNALS.md # README.md # STABILITY_TESTABILITY_HANDOFF.md # docs/cli_commands.md # examples/companion_radio/MyMesh.cpp # examples/simple_repeater/MyMesh.cpp # examples/simple_repeater/UITask.cpp # examples/simple_room_server/MyMesh.cpp # examples/simple_room_server/UITask.cpp # examples/simple_sensor/SensorMesh.cpp # platformio.ini # scripts/generate_webconfig_html.py # scripts/webconfig_mock_server.py # src/helpers/CommonCLI.cpp # src/helpers/CommonCLI.h # src/helpers/JWTHelper.cpp # src/helpers/MQTTDefaults.h # src/helpers/MQTTMessageBuilder.cpp # src/helpers/MQTTPayloadBuilder.h # src/helpers/MQTTPresets.h # src/helpers/bridges/MQTTBridge.h # src/helpers/esp32/WebConfigServer.cpp # src/helpers/esp32/WebConfigServer.h # src/helpers/ui/SH1106Display.cpp # variants/lilygo_tbeam_SX1262/platformio.ini # variants/lilygo_tbeam_SX1276/platformio.ini # variants/lilygo_tlora_v2_1/platformio.ini # variants/thinknode_m7/platformio.ini # webui/index.html
This commit is contained in:
commit
a04a284a3f
74 files changed
+7857
-2483
No files matched your search
@@ -6,43 +6,81 @@
|
||||
# that replace extra_scripts still compile from a clean checkout; this script
|
||||
# refreshes it whenever the source page (or this script) is newer.
|
||||
#
|
||||
# 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
|
||||
|
||||
# PlatformIO/SCons injects Import(). Keep the generator directly runnable as
|
||||
# well so maintainers can refresh and verify the tracked header without first
|
||||
# starting a firmware build.
|
||||
try:
|
||||
Import("env") # noqa: F821
|
||||
except NameError:
|
||||
pass
|
||||
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 needs_rebuild():
|
||||
if not os.path.isfile(OUTPUT):
|
||||
return True
|
||||
out_mtime = os.path.getmtime(OUTPUT)
|
||||
if os.path.getmtime(SOURCE) > out_mtime:
|
||||
return True
|
||||
if os.path.isfile(SCRIPT) and os.path.getmtime(SCRIPT) > out_mtime:
|
||||
return True
|
||||
return False
|
||||
def content_hash():
|
||||
# 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())
|
||||
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()
|
||||
|
||||
|
||||
def stored_hash():
|
||||
try:
|
||||
with open(OUTPUT, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith(HASH_MARKER):
|
||||
return line[len(HASH_MARKER):].strip()
|
||||
if line.startswith("#"): # reached the C preprocessor lines
|
||||
break
|
||||
except OSError:
|
||||
return None
|
||||
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():
|
||||
@@ -50,11 +88,20 @@ def main():
|
||||
status("ERROR: %s not found" % SOURCE)
|
||||
sys.exit(2)
|
||||
|
||||
if not needs_rebuild():
|
||||
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
|
||||
|
||||
with open(SOURCE, "rb") as f:
|
||||
raw = f.read()
|
||||
src_hash = content_hash()
|
||||
if os.path.isfile(OUTPUT) and stored_hash() == src_hash:
|
||||
return
|
||||
|
||||
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)
|
||||
@@ -63,6 +110,7 @@ def main():
|
||||
lines = []
|
||||
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
|
||||
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
|
||||
lines.append("%s%s" % (HASH_MARKER, src_hash))
|
||||
lines.append("#pragma once")
|
||||
lines.append("#include <stdint.h>")
|
||||
lines.append("#include <pgmspace.h>")
|
||||
@@ -80,7 +128,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)))
|
||||
|
||||
|
||||
# This script is attached only to esp32_base. Generate unconditionally here:
|
||||
|
||||
@@ -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
|
||||
@@ -47,9 +65,17 @@ LEN_LIMITS = {
|
||||
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
|
||||
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
|
||||
}
|
||||
# "filter" is absent on purpose: it is a bitmask, not a text buffer, so it has
|
||||
# no destination-buffer limit. It is still bounded by the shared CLI command
|
||||
# budget below, like every other key.
|
||||
SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63,
|
||||
"token": 47, "topic": 95, "audience": 63}
|
||||
|
||||
# BatchEntry::cmd[160] in WebConfigServer.cpp holds "set <key> <value>" plus a
|
||||
# NUL. Over-long values are rejected there rather than truncated, because a
|
||||
# clipped value can still be valid and would persist as a different setting.
|
||||
BATCH_CMD_SIZE = 160
|
||||
|
||||
# Preset names + what the UI must collect (mirrors handlePresets()).
|
||||
PRESETS = (
|
||||
[(n, "none") for n in (
|
||||
@@ -92,14 +118,30 @@ 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": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _slot():
|
||||
return {"preset": "none", "server": "", "port": 8883, "username": "",
|
||||
"password": "", "token": "", "topic": "", "audience": ""}
|
||||
"password": "", "token": "", "topic": "", "audience": "",
|
||||
"filter": "all"}
|
||||
|
||||
|
||||
class State:
|
||||
@@ -113,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 -------------------------------------------------------------
|
||||
@@ -128,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 ""
|
||||
@@ -140,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -153,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"),
|
||||
@@ -171,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)
|
||||
@@ -191,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(",")
|
||||
@@ -211,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"] = ""
|
||||
@@ -250,7 +340,57 @@ 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
|
||||
# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved
|
||||
# upstream and stay reachable by number only.
|
||||
PACKET_TYPE_NAMES = {
|
||||
"req": 0, "response": 1, "txt_msg": 2, "ack": 3, "advert": 4,
|
||||
"grp_txt": 5, "grp_data": 6, "anon_req": 7, "path": 8, "trace": 9,
|
||||
"multipart": 10, "control": 11, "raw_custom": 15,
|
||||
}
|
||||
|
||||
|
||||
def packet_filter_mask(text):
|
||||
"""Canonical filter text -> bitmask, for the stats payload."""
|
||||
if text == "all":
|
||||
return 0xFFFF
|
||||
if text == "none":
|
||||
return 0
|
||||
mask = 0
|
||||
for token in text.split(","):
|
||||
mask |= 1 << int(token)
|
||||
return mask
|
||||
|
||||
|
||||
def canonical_packet_filter(val):
|
||||
"""Mirror of MQTTPacketFilter::parse + ::format. Returns None if invalid."""
|
||||
stripped = val.strip()
|
||||
if stripped == "" or stripped == "all":
|
||||
return "all"
|
||||
if stripped == "none":
|
||||
return "none"
|
||||
mask = 0
|
||||
for part in stripped.split(","):
|
||||
token = part.strip()
|
||||
if re.fullmatch(r"[0-9]+", token):
|
||||
packet_type = int(token)
|
||||
if packet_type > 15:
|
||||
return None
|
||||
elif token in PACKET_TYPE_NAMES:
|
||||
packet_type = PACKET_TYPE_NAMES[token]
|
||||
else:
|
||||
return None
|
||||
mask |= 1 << packet_type
|
||||
if mask == 0xFFFF:
|
||||
return "all"
|
||||
return ",".join(str(i) for i in range(16) if mask & (1 << i))
|
||||
|
||||
|
||||
def apply_slot_set(cfg, idx, field, val):
|
||||
@@ -266,6 +406,13 @@ def apply_slot_set(cfg, idx, field, val):
|
||||
return False, "Error: port must be between 1 and 65535"
|
||||
slot["port"] = p
|
||||
return True, "OK"
|
||||
if field == "filter":
|
||||
canonical = canonical_packet_filter(val)
|
||||
if canonical is None:
|
||||
return False, ("Error: filter must be all, none, or a CSV of "
|
||||
"types 0-15 / names (advert,txt_msg,...)")
|
||||
slot["filter"] = canonical
|
||||
return True, "OK - slot %d packet types: %s" % (idx + 1, canonical)
|
||||
if field in ("preset", "server", "username", "password", "token", "topic", "audience"):
|
||||
slot[field] = val
|
||||
if field == "token":
|
||||
@@ -275,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):
|
||||
@@ -331,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
|
||||
@@ -353,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
|
||||
@@ -369,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)))
|
||||
@@ -424,6 +848,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# drop unchanged secrets (sentinel), like the firmware does
|
||||
entries = [(k, v) for k, v in setmap.items()
|
||||
if not (is_secret_key(k) and v == SENTINEL)]
|
||||
# Same command-budget rejection the firmware applies while building
|
||||
# BatchEntry::cmd; CR/LF are stripped there and don't count.
|
||||
for k, v in entries:
|
||||
prefix = "password " if k == "password" else "set %s " % k
|
||||
stripped = str(v).replace("\r", "").replace("\n", "")
|
||||
if len(prefix) + len(stripped) > BATCH_CMD_SIZE - 1:
|
||||
return self._json(400, {"error": "value too long", "key": k[:32]})
|
||||
if not entries and not reboot:
|
||||
return self._json(400, {"error": "no changes"})
|
||||
# apply now, but expose as pending->done to exercise polling
|
||||
@@ -457,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()
|
||||
@@ -473,8 +1023,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
for i, s in enumerate(ST.cfg["mqtt"]["slots"]):
|
||||
if s["preset"] == "none":
|
||||
continue
|
||||
slots.append({"n": i + 1, "name": s["preset"], "state": "ok",
|
||||
"ok": 100 + up, "err": 0})
|
||||
row = {"n": i + 1, "name": s["preset"], "state": "ok",
|
||||
"ok": 100 + up, "err": 0}
|
||||
# Mirrors buildStatsJson(): "filt" carries the raw mask and is
|
||||
# omitted entirely for the all-types default.
|
||||
mask = packet_filter_mask(s.get("filter", "all"))
|
||||
if mask != 0xFFFF:
|
||||
row["filt"] = mask
|
||||
slots.append(row)
|
||||
return {
|
||||
"uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000,
|
||||
"heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5,
|
||||
@@ -486,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()
|
||||
|
||||
Reference in new issue
Block a user