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:
mikecarper committed 2026-08-14 08:53:02 -07:00
commit a04a284a3f
74 files changed
+7857 -2483

No files matched your search

+66 -17
View File
@@ -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:
+235
View File
@@ -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())
+88
View File
@@ -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
+571 -10
View File
@@ -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()