mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-08-28 00:44:06 +00:00
feat(docs): add local testing instructions for MQTT functionality
Include detailed instructions for local testing of observer and WiFi functionality without hardware. Document the use of a mock backend and Wokwi ESP32-S3 simulation for easier development and testing. Enhance the MQTT implementation documentation to improve developer experience and facilitate testing workflows.
This commit is contained in:
@@ -534,6 +534,26 @@ serial and use the CLI directly (e.g. `set wifi.ssid ...`, `set wifi.pwd ...`,
|
||||
`get wifi.status`, `stop webconfig`). Serial access always works regardless of
|
||||
the portal state.
|
||||
|
||||
### Local testing without hardware
|
||||
|
||||
Two ways to iterate on observer/WiFi functionality without flashing a device:
|
||||
|
||||
- **Portal UI** — run the mock backend and open the real portal in a browser:
|
||||
`python3 scripts/webconfig_mock_server.py` (add `--setup` for the first-boot
|
||||
wizard), then browse to `http://localhost:8080/`. It serves `webui/index.html`
|
||||
and mirrors the firmware's `/api/*` contract (reqid handshake, reboot gating,
|
||||
validation, secret masking), so the portal JS runs against realistic
|
||||
responses. Stdlib only; no account.
|
||||
- **Boot / WiFi / MQTT / CLI / OLED** — the Wokwi ESP32-S3 sim. Build
|
||||
`pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin` (LoRa radio
|
||||
stubbed via `SimRadio`, WiFi pre-seeded to `Wokwi-GUEST`), then run the sim
|
||||
from `wokwi.toml`/`diagram.json` (VS Code Wokwi extension or `wokwi-cli`).
|
||||
Outbound MQTT works on the free gateway; incoming (browser → on-device portal)
|
||||
needs Wokwi's paid Private Gateway — use the mock backend above for portal UI.
|
||||
|
||||
Backend handler logic is covered by host unit tests under `test/` (`pio test -e
|
||||
native`); see [test/README.md](test/README.md) for the suites and how to run them.
|
||||
|
||||
## Command Architecture
|
||||
|
||||
The CLI commands are organized into two levels:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"author": "MeshCore",
|
||||
"editor": "wokwi",
|
||||
"parts": [
|
||||
{ "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} },
|
||||
{ "type": "board-ssd1306", "id": "oled", "top": -110, "left": 90,
|
||||
"attrs": { "i2cAddress": "0x3c" } }
|
||||
],
|
||||
"connections": [
|
||||
[ "esp:3V3", "oled:VCC", "red", [] ],
|
||||
[ "esp:GND.1", "oled:GND", "black", [] ],
|
||||
[ "esp:17", "oled:SDA", "green", [] ],
|
||||
[ "esp:18", "oled:SCL", "yellow", [] ],
|
||||
[ "esp:TX", "$serialMonitor:RX", "", [] ],
|
||||
[ "esp:RX", "$serialMonitor:TX", "", [] ]
|
||||
]
|
||||
}
|
||||
@@ -995,6 +995,25 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
// load persisted prefs
|
||||
_cli.loadPrefs(_fs);
|
||||
|
||||
#ifdef SIM_WIFI_SSID
|
||||
// Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the
|
||||
// observer auto-joins the simulator's network and brings the MQTT bridge up
|
||||
// (WiFi is driven by the bridge task), instead of raising the setup AP that
|
||||
// the emulator can't model. No-op for real firmware (flag never defined).
|
||||
{
|
||||
MQTTPrefs* obs = _cli.getObserverPrefs();
|
||||
if (obs->wifi_ssid[0] == 0) {
|
||||
strncpy(obs->wifi_ssid, SIM_WIFI_SSID, sizeof(obs->wifi_ssid) - 1);
|
||||
obs->wifi_ssid[sizeof(obs->wifi_ssid) - 1] = 0;
|
||||
#ifdef SIM_WIFI_PWD
|
||||
strncpy(obs->wifi_password, SIM_WIFI_PWD, sizeof(obs->wifi_password) - 1);
|
||||
obs->wifi_password[sizeof(obs->wifi_password) - 1] = 0;
|
||||
#endif
|
||||
_prefs.bridge_enabled = 1; // WiFi comes up via the MQTT bridge task
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
acl.load(_fs, self_id);
|
||||
// TODO: key_store.begin();
|
||||
region_map.load(_fs);
|
||||
|
||||
@@ -160,6 +160,8 @@ lib_deps =
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
|
||||
; ----------------- TESTING ---------------------
|
||||
; Host GoogleTest suites for the fork's pure logic. See test/README.md.
|
||||
; Run: `pio test -e native` (all) or `pio test -e native -f <suite>` (one).
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local mock of the WebConfig portal backend, for iterating on webui/index.html
|
||||
in a real browser with no firmware, no flashing, and no paid emulator account.
|
||||
|
||||
It serves the real webui/index.html and implements the same /api/* contract as
|
||||
src/helpers/esp32/WebConfigServer.cpp — including the 202+reqid handshake, the
|
||||
pending -> done result polling, aggregate-success reboot gating, secret masking
|
||||
(********), and the IATA / owner-key / length validation the firmware enforces.
|
||||
So the browser drives the actual portal JS (wizard, save/poll/reqid, effective
|
||||
value handling, reboot overlay, stats, scan) against realistic responses.
|
||||
|
||||
It does NOT run the C++ handlers (that's what test/ gtest covers) or the
|
||||
AsyncTCP transport — it's a frontend + contract harness.
|
||||
|
||||
Usage:
|
||||
python3 scripts/webconfig_mock_server.py # LAN mode (login: password)
|
||||
python3 scripts/webconfig_mock_server.py --setup # first-boot setup wizard
|
||||
python3 scripts/webconfig_mock_server.py --port 9000 --active-slots 2
|
||||
Then open http://localhost:8080/ (or the chosen port). Editing index.html and
|
||||
refreshing shows changes immediately — the page is re-read per request.
|
||||
|
||||
Stdlib only; no pip install.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
SENTINEL = "********"
|
||||
ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag
|
||||
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
|
||||
SCAN_SECS = 0.8
|
||||
|
||||
# Destination buffer sizes (chars, minus the NUL) — mirrors the MQTTPrefs fields
|
||||
# the firmware validates in CommonCLI_Observer.cpp.
|
||||
LEN_LIMITS = {
|
||||
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
|
||||
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
|
||||
}
|
||||
SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63,
|
||||
"token": 47, "topic": 95, "audience": 63}
|
||||
|
||||
# Preset names + what the UI must collect (mirrors handlePresets()).
|
||||
PRESETS = (
|
||||
[(n, "none") for n in (
|
||||
"analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "waev",
|
||||
"meshomatic", "cascadiamesh", "tennmesh", "nashmesh", "ctmesh", "chimesh",
|
||||
"meshat.se", "eastidahomesh", "coloradomesh", "dutchmeshcore-1",
|
||||
"dutchmeshcore-2", "meshcore-ca-1", "meshcore-ca-2", "meshcore-fi",
|
||||
"bostonmesh", "rflab", "ipnt.uk", "flmesh", "corecomms")]
|
||||
+ [("meshrank", "token"), ("inwmesh", "userpass")]
|
||||
)
|
||||
|
||||
SCAN_NETWORKS = [
|
||||
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False},
|
||||
{"ssid": "HomeNet", "rssi": -55, "enc": True},
|
||||
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True},
|
||||
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True},
|
||||
{"ssid": "OpenGuest", "rssi": -83, "enc": False},
|
||||
]
|
||||
|
||||
|
||||
def default_config(setup_mode):
|
||||
return {
|
||||
"radio": {
|
||||
"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0,
|
||||
"rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True,
|
||||
"repeat": True, "flood_max": 64, "flood_max_advert": 8,
|
||||
"flood_max_unscoped": 8, "loop_detect": "moderate",
|
||||
"name": "MockNode", "lat": 39.7392, "lon": -104.9903,
|
||||
"advert_interval": 240, "flood_advert_interval": 6,
|
||||
},
|
||||
"wifi": {
|
||||
# setup mode = unconfigured (empty ssid -> wizard); LAN mode = joined
|
||||
"ssid": "" if setup_mode else "HomeNet",
|
||||
"pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET
|
||||
"powersave": "min",
|
||||
},
|
||||
"mqtt": {
|
||||
"origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN",
|
||||
"status": True, "packets": True, "raw": False, "tx": "advert", "rx": True,
|
||||
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
|
||||
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
|
||||
"snmp_community": "public",
|
||||
"slots": [_slot() for _ in range(6)],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _slot():
|
||||
return {"preset": "none", "server": "", "port": 8883, "username": "",
|
||||
"password": "", "token": "", "topic": "", "audience": ""}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, args):
|
||||
self.lock = threading.Lock()
|
||||
self.setup_mode = args.setup
|
||||
self.active_slots = args.active_slots
|
||||
self.cfg = default_config(args.setup)
|
||||
self.start = time.time()
|
||||
self.session = None # cookie token when logged in (LAN mode)
|
||||
self.batch = {"state": "idle"}
|
||||
self.scan_started = None
|
||||
|
||||
# ---- auth -------------------------------------------------------------
|
||||
def is_authed(self, headers):
|
||||
if self.setup_mode:
|
||||
return True # setup mode: proximity trust, no auth
|
||||
if not self.session:
|
||||
return False
|
||||
cookie = headers.get("Cookie", "")
|
||||
m = re.search(r"wcs=([0-9a-f]+)", cookie)
|
||||
return bool(m and m.group(1) == self.session)
|
||||
|
||||
# ---- config serialization (masks secrets, like handleConfigGet) -------
|
||||
def config_json(self):
|
||||
c = copy.deepcopy(self.cfg)
|
||||
c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else ""
|
||||
for s in c["mqtt"]["slots"]:
|
||||
s["password"] = SENTINEL if s["password"] else ""
|
||||
s["token"] = SENTINEL if s["token"] else ""
|
||||
return c
|
||||
|
||||
def status_json(self, authed):
|
||||
return {
|
||||
"mode": "setup" if self.setup_mode else "lan",
|
||||
"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)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set-command application + validation (mirrors the firmware's setters enough
|
||||
# to produce realistic per-field OK / Error replies for the UI chips).
|
||||
# ---------------------------------------------------------------------------
|
||||
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")}
|
||||
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"),
|
||||
"timezone.offset": ("mqtt", "timezone_offset")}
|
||||
FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"),
|
||||
"af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"),
|
||||
"txdelay": ("radio", "txdelay")}
|
||||
STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"),
|
||||
"wifi.powersave": ("wifi", "powersave"), "loop.detect": ("radio", "loop_detect"),
|
||||
"mqtt.origin": ("mqtt", "origin"), "mqtt.ntp": ("mqtt", "ntp"),
|
||||
"mqtt.email": ("mqtt", "email"), "timezone": ("mqtt", "timezone"),
|
||||
"snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")}
|
||||
SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")}
|
||||
|
||||
|
||||
def _hex64(v):
|
||||
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
|
||||
|
||||
|
||||
def apply_set(cfg, key, val):
|
||||
"""Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for
|
||||
the fields where it matters (length, IATA, owner key, port, radio combo)."""
|
||||
# length guard for the plain string fields
|
||||
if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]:
|
||||
return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key])
|
||||
|
||||
if key == "radio":
|
||||
try:
|
||||
f, bw, sf, cr = val.split(",")
|
||||
f, bw, sf, cr = float(f), float(bw), int(sf), int(cr)
|
||||
except ValueError:
|
||||
return False, "Error, invalid radio params"
|
||||
if not (150 <= f <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8):
|
||||
return False, "Error, invalid radio params"
|
||||
cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr)
|
||||
return True, "OK - reboot to apply"
|
||||
|
||||
if key == "mqtt.iata":
|
||||
if val == "":
|
||||
cfg["mqtt"]["iata"] = ""
|
||||
return True, "OK - IATA cleared"
|
||||
if len(val) != 3 or not val.isalnum() or not val.isascii():
|
||||
return False, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)"
|
||||
cfg["mqtt"]["iata"] = val.upper()
|
||||
return True, "OK"
|
||||
|
||||
if key == "mqtt.owner":
|
||||
if val == "":
|
||||
cfg["mqtt"]["owner"] = ""
|
||||
return True, "OK - owner key cleared"
|
||||
if not _hex64(val):
|
||||
return False, "Error: public key must be 64 hex characters (32 bytes)"
|
||||
cfg["mqtt"]["owner"] = val
|
||||
return True, "OK"
|
||||
|
||||
m = re.match(r"^mqtt([1-6])\.(\w+)$", key)
|
||||
if m:
|
||||
return apply_slot_set(cfg, int(m.group(1)) - 1, m.group(2), val)
|
||||
|
||||
if key in BOOL_KEYS:
|
||||
sec, f = BOOL_KEYS[key]
|
||||
cfg[sec][f] = (val == "on")
|
||||
return True, "OK"
|
||||
if key in INT_KEYS:
|
||||
sec, f = INT_KEYS[key]
|
||||
try:
|
||||
cfg[sec][f] = int(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
return True, "OK"
|
||||
if key in FLOAT_KEYS:
|
||||
sec, f = FLOAT_KEYS[key]
|
||||
try:
|
||||
cfg[sec][f] = float(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
return True, "OK"
|
||||
if key in SECRET_STR_KEYS:
|
||||
sec, f = SECRET_STR_KEYS[key]
|
||||
cfg[sec][f] = val
|
||||
return True, "OK"
|
||||
if key in STR_KEYS:
|
||||
sec, f = STR_KEYS[key]
|
||||
cfg[sec][f] = val
|
||||
return True, "OK"
|
||||
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
|
||||
|
||||
|
||||
def apply_slot_set(cfg, idx, field, val):
|
||||
slot = cfg["mqtt"]["slots"][idx]
|
||||
if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]:
|
||||
return False, "Error: %s too long (max %d chars)" % (field, SLOT_LEN_LIMITS[field])
|
||||
if field == "port":
|
||||
try:
|
||||
p = int(val)
|
||||
except ValueError:
|
||||
return False, "Error: port must be between 1 and 65535"
|
||||
if not (1 <= p <= 65535):
|
||||
return False, "Error: port must be between 1 and 65535"
|
||||
slot["port"] = p
|
||||
return True, "OK"
|
||||
if field in ("preset", "server", "username", "password", "token", "topic", "audience"):
|
||||
slot[field] = val
|
||||
if field == "token":
|
||||
return True, "OK - slot %d token set" % (idx + 1)
|
||||
return True, "OK"
|
||||
return False, "Error: unknown slot field"
|
||||
|
||||
|
||||
def is_secret_key(key):
|
||||
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP handler
|
||||
# ---------------------------------------------------------------------------
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args): # concise one-line log
|
||||
print(" %s %s" % (self.command, self.path))
|
||||
|
||||
# -- helpers --
|
||||
def _json(self, code, obj, extra_headers=None):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
for k, v in (extra_headers or {}):
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
return self.rfile.read(n) if n else b""
|
||||
|
||||
def _need_auth(self):
|
||||
if not ST.is_authed(self.headers):
|
||||
self._json(401, {"error": "auth"})
|
||||
return True
|
||||
return False
|
||||
|
||||
# -- GET --
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path == "/":
|
||||
return self._serve_index()
|
||||
if path == "/api/status":
|
||||
return self._json(200, ST.status_json(ST.is_authed(self.headers)))
|
||||
if path == "/api/presets":
|
||||
return self._json(200, {"presets": [{"name": n, "needs": nd} for n, nd in PRESETS]})
|
||||
if path == "/api/config":
|
||||
if self._need_auth():
|
||||
return
|
||||
with ST.lock:
|
||||
return self._json(200, ST.config_json())
|
||||
if path == "/api/config/result":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_result()
|
||||
if path == "/api/stats":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._json(200, self._stats())
|
||||
if path == "/api/scan":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._scan()
|
||||
return self._json(404, {"error": "not found"})
|
||||
|
||||
# -- POST --
|
||||
def do_POST(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path == "/api/login":
|
||||
return self._login()
|
||||
if path == "/api/logout":
|
||||
ST.session = None
|
||||
return self._json(200, {"ok": True}, [("Set-Cookie", "wcs=; Max-Age=0; Path=/")])
|
||||
if path == "/api/config":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_post()
|
||||
if path == "/api/reboot":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._json(200, {"ok": True})
|
||||
if path == "/api/portal/exit":
|
||||
return self._json(200, {"ok": True, "url": "http://localhost:%d/" % PORT})
|
||||
return self._json(404, {"error": "not found"})
|
||||
|
||||
# -- endpoint impls --
|
||||
def _serve_index(self):
|
||||
try:
|
||||
with open(INDEX_HTML, "rb") as f: # re-read each time -> live edits
|
||||
html = f.read()
|
||||
except OSError:
|
||||
self.send_error(500, "webui/index.html not found")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(html)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(html)
|
||||
|
||||
def _login(self):
|
||||
if ST.setup_mode:
|
||||
return self._json(200, {"ok": True})
|
||||
try:
|
||||
body = json.loads(self._read_body() or b"{}")
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "bad request"})
|
||||
if body.get("password") != ADMIN_PASSWORD:
|
||||
return self._json(401, {"error": "wrong password"})
|
||||
ST.session = secrets.token_hex(16)
|
||||
return self._json(200, {"ok": True},
|
||||
[("Set-Cookie", "wcs=%s; HttpOnly; SameSite=Lax; Path=/" % ST.session)])
|
||||
|
||||
def _config_post(self):
|
||||
raw = self._read_body()
|
||||
if len(raw) > 4096:
|
||||
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", "")
|
||||
reboot = bool(body.get("reboot", False))
|
||||
setmap = body.get("set", {}) or {}
|
||||
|
||||
with ST.lock:
|
||||
if ST.batch.get("state") == "pending":
|
||||
return self._json(409, {"error": "busy", "reqid": ST.batch.get("reqid", "")})
|
||||
# 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)]
|
||||
if not entries and not reboot:
|
||||
return self._json(400, {"error": "no changes"})
|
||||
# apply now, but expose as pending->done to exercise polling
|
||||
results, all_ok = [], True
|
||||
for k, v in entries:
|
||||
ok, reply = apply_set(ST.cfg, k, str(v))
|
||||
if not ok:
|
||||
all_ok = False
|
||||
results.append({"key": k, "reply": reply})
|
||||
ST.batch = {"state": "pending", "reqid": reqid, "results": results,
|
||||
"all_ok": all_ok, "reboot": reboot,
|
||||
"done_at": time.time() + BATCH_PENDING_SECS}
|
||||
return self._json(202, {"state": "pending", "count": len(entries), "reqid": reqid})
|
||||
|
||||
def _config_result(self):
|
||||
with ST.lock:
|
||||
b = ST.batch
|
||||
if b.get("state") == "idle":
|
||||
return self._json(200, {"state": "idle"})
|
||||
if b["state"] == "pending" and time.time() < b["done_at"]:
|
||||
return self._json(200, {"state": "pending", "reqid": b["reqid"]})
|
||||
b["state"] = "done" # stays readable until next POST
|
||||
return self._json(200, {
|
||||
"state": "done", "reqid": b["reqid"], "all_ok": b["all_ok"],
|
||||
"reboot": b["reboot"] and b["all_ok"], "results": b["results"],
|
||||
})
|
||||
|
||||
def _scan(self):
|
||||
rescan = "rescan=1" in self.path
|
||||
now = time.time()
|
||||
if rescan or ST.scan_started is None:
|
||||
ST.scan_started = now
|
||||
return self._json(200, {"state": "scanning"})
|
||||
if now - ST.scan_started < SCAN_SECS:
|
||||
return self._json(200, {"state": "scanning"})
|
||||
return self._json(200, {"state": "done", "networks": SCAN_NETWORKS})
|
||||
|
||||
def _stats(self):
|
||||
up = int(time.time() - ST.start)
|
||||
slots = []
|
||||
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})
|
||||
return {
|
||||
"uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000,
|
||||
"heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5,
|
||||
"airtime_s": up // 20, "rx_airtime_s": up // 8, "recv": 512 + up,
|
||||
"sent": 88 + up // 3, "rx_err": 3, "sent_flood": 40, "sent_direct": 48,
|
||||
"recv_flood": 300, "recv_direct": 212, "tx_queue": 0, "mqtt_queue": 0,
|
||||
"wifi_rssi": -58, "ip": "192.168.1.42", "slots": slots,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
global ST, PORT
|
||||
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)")
|
||||
args = ap.parse_args()
|
||||
ST, PORT = State(args), args.port
|
||||
|
||||
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(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "CommonCLI.h"
|
||||
#include "TxtDataHelpers.h"
|
||||
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
|
||||
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
|
||||
#include <Utils.h>
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <WiFi.h>
|
||||
@@ -92,19 +93,16 @@ static int getMQTTPresetNameCount() {
|
||||
return MQTT_PRESET_COUNT + 2; // built-ins + custom + none
|
||||
}
|
||||
|
||||
static bool isValidNtpHostname(const char* host) {
|
||||
if (!host || host[0] == '\0') return false;
|
||||
size_t len = strlen(host);
|
||||
if (len > 63) return false;
|
||||
if (host[0] == '.' || host[len - 1] == '.') return false;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = host[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-')) {
|
||||
return false;
|
||||
}
|
||||
// Reject a value that wouldn't fit its destination MQTTPrefs buffer (which must
|
||||
// hold the string plus a NUL) so an over-long CLI/web submission fails loudly
|
||||
// instead of being silently truncated. Fills reply and returns true when too
|
||||
// long. reply is the caller's 160-byte command buffer.
|
||||
static bool valueTooLong(const char* val, size_t bufsize, char* reply, const char* label) {
|
||||
if (!mqttValueFits(val, bufsize)) {
|
||||
snprintf(reply, 160, "Error: %s too long (max %u chars)", label, (unsigned)(bufsize - 1));
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char* getMQTTPresetNameByIndex(int index) {
|
||||
@@ -163,6 +161,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
bool handled = true;
|
||||
if (memcmp(config, "snmp.community ", 15) == 0) {
|
||||
if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.snmp_community, &config[15], sizeof(_mqtt_prefs.snmp_community));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - restart to apply");
|
||||
@@ -200,6 +199,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else if (memcmp(config, "mqtt.origin ", 12) == 0) {
|
||||
if (valueTooLong(&config[12], sizeof(_mqtt_prefs.mqtt_origin), reply, "origin")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_origin, &config[12], sizeof(_mqtt_prefs.mqtt_origin));
|
||||
StrHelper::stripSurroundingQuotes(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin));
|
||||
savePrefs();
|
||||
@@ -217,12 +217,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else {
|
||||
// A region code goes straight into MQTT topic paths, so require exactly
|
||||
// three alphanumeric characters (real IATA codes are 3 letters, e.g. DEN).
|
||||
bool valid = (iata_len == 3);
|
||||
for (size_t i = 0; valid && i < iata_len; i++) {
|
||||
char c = iata[i];
|
||||
valid = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
|
||||
}
|
||||
if (!valid) {
|
||||
if (!mqttIataValid(iata)) {
|
||||
strcpy(reply, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)");
|
||||
} else {
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_iata, iata, sizeof(_mqtt_prefs.mqtt_iata));
|
||||
@@ -272,7 +267,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
const char* host = &config[9];
|
||||
while (*host == ' ') host++;
|
||||
bool clearing = strcmp(host, "none") == 0;
|
||||
if (!clearing && !isValidNtpHostname(host)) {
|
||||
if (!clearing && !mqttNtpHostnameValid(host)) {
|
||||
strcpy(reply, "Error: invalid NTP hostname");
|
||||
} else {
|
||||
if (clearing) {
|
||||
@@ -300,10 +295,12 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#endif
|
||||
}
|
||||
} else if (memcmp(config, "wifi.ssid ", 10) == 0) {
|
||||
if (valueTooLong(&config[10], sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.wifi_ssid, &config[10], sizeof(_mqtt_prefs.wifi_ssid));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else if (memcmp(config, "wifi.pwd ", 9) == 0) {
|
||||
if (valueTooLong(&config[9], sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.wifi_password, &config[9], sizeof(_mqtt_prefs.wifi_password));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
@@ -347,6 +344,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#endif
|
||||
}
|
||||
} else if (memcmp(config, "timezone ", 9) == 0) {
|
||||
if (valueTooLong(&config[9], sizeof(_mqtt_prefs.timezone_string), reply, "timezone")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.timezone_string, &config[9], sizeof(_mqtt_prefs.timezone_string));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
@@ -407,6 +405,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
strcpy(reply, "Error: unknown preset. Use 'get mqtt.presets'");
|
||||
}
|
||||
} else if (memcmp(subcmd, "server ", 7) == 0) {
|
||||
if (valueTooLong(&subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]), reply, "server")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[slot], &subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]));
|
||||
savePrefs();
|
||||
// Reconfigure the slot so the new host reaches the live connection (other
|
||||
@@ -425,16 +424,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
strcpy(reply, "Error: port must be between 1 and 65535");
|
||||
}
|
||||
} else if (memcmp(subcmd, "username ", 9) == 0) {
|
||||
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]), reply, "username")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]));
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
strcpy(reply, "OK");
|
||||
} else if (memcmp(subcmd, "password ", 9) == 0) {
|
||||
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]), reply, "password")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]));
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
strcpy(reply, "OK");
|
||||
} else if (memcmp(subcmd, "token ", 6) == 0) {
|
||||
if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]), reply, "token")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_token[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]));
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
@@ -442,6 +444,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else if (memcmp(subcmd, "topic ", 6) == 0) {
|
||||
if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], "custom") != 0) {
|
||||
sprintf(reply, "Error: topic template only applies to custom preset slots");
|
||||
} else if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), reply, "topic")) {
|
||||
return true;
|
||||
} else {
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_topic[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]));
|
||||
savePrefs();
|
||||
@@ -449,6 +453,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
sprintf(reply, "OK - slot %d topic: %s", slot + 1, _mqtt_prefs.mqtt_slot_topic[slot]);
|
||||
}
|
||||
} else if (memcmp(subcmd, "audience ", 9) == 0) {
|
||||
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), reply, "audience")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_audience[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]));
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
@@ -488,34 +493,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
strcpy(reply, "OK");
|
||||
} else if (memcmp(config, "mqtt.owner ", 11) == 0) {
|
||||
const char* owner_key = &config[11];
|
||||
int key_len = strlen(owner_key);
|
||||
if (key_len == 0) {
|
||||
if (owner_key[0] == '\0') {
|
||||
// Owner key is optional — empty clears it (previously this errored, so a
|
||||
// set key could never be removed via the portal/CLI).
|
||||
_mqtt_prefs.mqtt_owner_public_key[0] = '\0';
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - owner key cleared");
|
||||
} else if (key_len == 64) {
|
||||
bool valid_key = true;
|
||||
for (int i = 0; i < key_len; i++) {
|
||||
if (!((owner_key[i] >= '0' && owner_key[i] <= '9') ||
|
||||
(owner_key[i] >= 'A' && owner_key[i] <= 'F') ||
|
||||
(owner_key[i] >= 'a' && owner_key[i] <= 'f'))) {
|
||||
valid_key = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid_key) {
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Error: invalid hex characters in public key");
|
||||
}
|
||||
} else if (mqttOwnerKeyValid(owner_key)) {
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)");
|
||||
}
|
||||
} else if (memcmp(config, "mqtt.email ", 11) == 0) {
|
||||
if (valueTooLong(&config[11], sizeof(_mqtt_prefs.mqtt_email), reply, "email")) return true;
|
||||
StrHelper::strncpy(_mqtt_prefs.mqtt_email, &config[11], sizeof(_mqtt_prefs.mqtt_email));
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Pure, dependency-free validators for the observer's CLI/web configuration
|
||||
// inputs. Factored out of CommonCLI_Observer.cpp so the exact logic the setters
|
||||
// enforce can be unit-tested on the host (see test/test_observer_validation)
|
||||
// rather than only through the full CLI object.
|
||||
|
||||
// IATA region code: exactly three ASCII alphanumerics. The value is placed
|
||||
// directly into MQTT topic paths (meshcore/{iata}/...), so anything else (wrong
|
||||
// length, spaces, topic separators) is rejected. Case is preserved here; the
|
||||
// setter uppercases after validation.
|
||||
static inline bool mqttIataValid(const char* s) {
|
||||
if (!s || strlen(s) != 3) return false;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char c = s[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Owner public key: exactly 64 hex characters (a 32-byte Ed25519 key), any case.
|
||||
static inline bool mqttOwnerKeyValid(const char* s) {
|
||||
if (!s || strlen(s) != 64) return false;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
char c = s[i];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// NTP hostname: non-empty, <= 63 chars, made of letters/digits/'.'/'-', with no
|
||||
// leading or trailing dot. ("none" is handled as a clear by the caller.)
|
||||
static inline bool mqttNtpHostnameValid(const char* host) {
|
||||
if (!host || host[0] == '\0') return false;
|
||||
size_t len = strlen(host);
|
||||
if (len > 63) return false;
|
||||
if (host[0] == '.' || host[len - 1] == '.') return false;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = host[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A value fits its fixed destination buffer, which must hold the string plus a
|
||||
// NUL terminator (so the usable length is bufsize - 1). Used to reject an
|
||||
// over-long submission up front instead of silently truncating it.
|
||||
static inline bool mqttValueFits(const char* s, size_t bufsize) {
|
||||
return s != NULL && bufsize > 0 && strlen(s) < bufsize;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h> // strcmp/memcmp used by the inline preset helpers below
|
||||
|
||||
// Maximum number of configurable MQTT connection slots (available to all builds for struct layout).
|
||||
// Used in NodePrefs/MQTTPrefs for persistent storage — do NOT change without migration.
|
||||
static const int MAX_MQTT_SLOTS = 6;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Expand the {iata} {device} {token} {type} placeholders in a custom MQTT topic
|
||||
// template. Factored out of MQTTBridge::substituteTopicTemplate so the (bounded)
|
||||
// string expansion can be unit-tested on the host; the bridge passes its cached
|
||||
// _iata / _device_id, the slot token, and the message-type string.
|
||||
//
|
||||
// Returns false on buffer overflow or an empty result. buf is always
|
||||
// NUL-terminated. A null value substitutes as empty; an unknown "{...}" token is
|
||||
// copied through verbatim.
|
||||
static inline bool mqttSubstituteTopic(const char* tmpl, const char* iata,
|
||||
const char* device, const char* token,
|
||||
const char* type_str, char* buf, size_t buf_size) {
|
||||
if (!buf || buf_size == 0) return false;
|
||||
if (!iata) iata = "";
|
||||
if (!device) device = "";
|
||||
if (!token) token = "";
|
||||
if (!type_str) type_str = "";
|
||||
|
||||
size_t out = 0;
|
||||
const char* p = tmpl ? tmpl : "";
|
||||
while (*p && out < buf_size - 1) {
|
||||
const char* sub = NULL;
|
||||
size_t adv = 0;
|
||||
if (strncmp(p, "{iata}", 6) == 0) {
|
||||
sub = iata; adv = 6;
|
||||
} else if (strncmp(p, "{device}", 8) == 0) {
|
||||
sub = device; adv = 8;
|
||||
} else if (strncmp(p, "{token}", 7) == 0) {
|
||||
sub = token; adv = 7;
|
||||
} else if (strncmp(p, "{type}", 6) == 0) {
|
||||
sub = type_str; adv = 6;
|
||||
}
|
||||
if (sub) {
|
||||
size_t len = strlen(sub);
|
||||
if (out + len >= buf_size) {
|
||||
buf[out] = '\0'; // keep buf terminated even on the overflow path
|
||||
return false;
|
||||
}
|
||||
memcpy(buf + out, sub, len);
|
||||
out += len;
|
||||
p += adv;
|
||||
} else {
|
||||
buf[out++] = *p++;
|
||||
}
|
||||
}
|
||||
buf[out] = '\0';
|
||||
return out > 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <string.h>
|
||||
#include "MQTTPresets.h" // MAX_MQTT_SLOTS
|
||||
|
||||
// Classification of the config keys the web portal is allowed to drive through
|
||||
// the CLI `set` handlers. Factored out of WebConfigServer.cpp so the allowlist
|
||||
// and the (attacker-facing) key parsing can be unit-tested on the host without
|
||||
// pulling in the whole ESP32 web server (see test/test_webconfig_keys).
|
||||
//
|
||||
// Everything here is pure string logic. The functions are `static inline` so
|
||||
// each translation unit that includes this gets its own copy (there are only
|
||||
// two: WebConfigServer.cpp and the test), avoiding any ODR concern.
|
||||
|
||||
// Keys mapping to CLI `set <key> <value>` handlers. Everything not listed here
|
||||
// is rejected, so a crafted request can't reach arbitrary commands (`erase`,
|
||||
// `password`, ...) through the batch.
|
||||
static const char* const WC_ALLOWED_SET_KEYS[] = {
|
||||
// NodePrefs (radio / node)
|
||||
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
|
||||
"cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval",
|
||||
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
|
||||
// MQTTPrefs (WiFi / MQTT / misc observer)
|
||||
"wifi.ssid", "wifi.pwd", "wifi.powersave",
|
||||
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
|
||||
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email",
|
||||
"timezone", "timezone.offset", "snmp", "snmp.community",
|
||||
};
|
||||
static const char* const WC_ALLOWED_SLOT_KEYS[] = {
|
||||
"preset", "server", "port", "username", "password", "token", "topic", "audience",
|
||||
};
|
||||
|
||||
// True when `key` is a well-formed per-slot key ("mqttN.<field>" with N in
|
||||
// 1..MAX_MQTT_SLOTS). The shortest such key is "mqttN.x" (7 chars), and this
|
||||
// probes key[4..6], so the length guard must come first — an attacker-supplied
|
||||
// "mqtt" or "m" would otherwise read past the terminator.
|
||||
static inline bool wcIsSlotKeyPrefix(const char* key) {
|
||||
return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0
|
||||
&& key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.';
|
||||
}
|
||||
|
||||
static inline bool wcIsAllowedSetKey(const char* key) {
|
||||
for (size_t i = 0; i < sizeof(WC_ALLOWED_SET_KEYS) / sizeof(WC_ALLOWED_SET_KEYS[0]); i++) {
|
||||
if (strcmp(key, WC_ALLOWED_SET_KEYS[i]) == 0) return true;
|
||||
}
|
||||
// mqtt<1-6>.<field>
|
||||
if (wcIsSlotKeyPrefix(key)) {
|
||||
for (size_t i = 0; i < sizeof(WC_ALLOWED_SLOT_KEYS) / sizeof(WC_ALLOWED_SLOT_KEYS[0]); i++) {
|
||||
if (strcmp(&key[6], WC_ALLOWED_SLOT_KEYS[i]) == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keys carrying a secret whose stored value is masked with the placeholder in
|
||||
// the UI; a POST echoing the placeholder for one of these is dropped (unchanged).
|
||||
static inline bool wcIsSecretKey(const char* key) {
|
||||
if (strcmp(key, "wifi.pwd") == 0) return true;
|
||||
if (wcIsSlotKeyPrefix(key)
|
||||
&& (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "MQTTBridge.h"
|
||||
#include "../MQTTMessageBuilder.h"
|
||||
#include "../MQTTTopicTemplate.h"
|
||||
#include "../TxtDataHelpers.h"
|
||||
#include <NTPClient.h>
|
||||
#include <WiFiUdp.h>
|
||||
@@ -1885,45 +1886,9 @@ bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool
|
||||
// ---------------------------------------------------------------------------
|
||||
bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) {
|
||||
const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw";
|
||||
const char* token = _obs->mqtt_slot_token[slot_index];
|
||||
|
||||
size_t out = 0;
|
||||
const char* p = tmpl;
|
||||
while (*p && out < buf_size - 1) {
|
||||
if (*p == '{') {
|
||||
if (strncmp(p, "{iata}", 6) == 0) {
|
||||
size_t len = strlen(_iata);
|
||||
if (out + len >= buf_size) return false;
|
||||
memcpy(buf + out, _iata, len);
|
||||
out += len;
|
||||
p += 6;
|
||||
} else if (strncmp(p, "{device}", 8) == 0) {
|
||||
size_t len = strlen(_device_id);
|
||||
if (out + len >= buf_size) return false;
|
||||
memcpy(buf + out, _device_id, len);
|
||||
out += len;
|
||||
p += 8;
|
||||
} else if (strncmp(p, "{token}", 7) == 0) {
|
||||
size_t len = strlen(token);
|
||||
if (out + len >= buf_size) return false;
|
||||
memcpy(buf + out, token, len);
|
||||
out += len;
|
||||
p += 7;
|
||||
} else if (strncmp(p, "{type}", 6) == 0) {
|
||||
size_t len = strlen(type_str);
|
||||
if (out + len >= buf_size) return false;
|
||||
memcpy(buf + out, type_str, len);
|
||||
out += len;
|
||||
p += 6;
|
||||
} else {
|
||||
buf[out++] = *p++;
|
||||
}
|
||||
} else {
|
||||
buf[out++] = *p++;
|
||||
}
|
||||
}
|
||||
buf[out] = '\0';
|
||||
return out > 0;
|
||||
// Pure expansion lives in helpers/MQTTTopicTemplate.h (host-tested).
|
||||
return mqttSubstituteTopic(tmpl, _iata, _device_id,
|
||||
_obs->mqtt_slot_token[slot_index], type_str, buf, buf_size);
|
||||
}
|
||||
|
||||
bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/MQTTPresets.h>
|
||||
#include <helpers/WebConfigKeys.h>
|
||||
#include <helpers/bridges/MQTTBridge.h>
|
||||
|
||||
#include "WebConfigHtml.h"
|
||||
@@ -20,52 +21,11 @@
|
||||
// so an untouched password field never overwrites the stored value.
|
||||
static const char SECRET_SENTINEL[] = "********";
|
||||
|
||||
// Keys the web UI may drive through the CLI `set` handlers. Everything else
|
||||
// is rejected, so a crafted request can't reach arbitrary commands (`erase`,
|
||||
// `password`, ...) through the batch.
|
||||
static const char* const ALLOWED_SET_KEYS[] = {
|
||||
// NodePrefs (radio / node)
|
||||
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
|
||||
"cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval",
|
||||
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
|
||||
// MQTTPrefs (WiFi / MQTT / misc observer)
|
||||
"wifi.ssid", "wifi.pwd", "wifi.powersave",
|
||||
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
|
||||
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email",
|
||||
"timezone", "timezone.offset", "snmp", "snmp.community",
|
||||
};
|
||||
static const char* const ALLOWED_SLOT_KEYS[] = {
|
||||
"preset", "server", "port", "username", "password", "token", "topic", "audience",
|
||||
};
|
||||
|
||||
// Shortest slot key is "mqttN.x" (mqtt + digit + '.' + 1-char field) = 7 chars.
|
||||
// The prefix probe below indexes key[4..6], so it must never run on a shorter
|
||||
// string — an attacker-supplied "mqtt" or "m" would otherwise read past the
|
||||
// terminator. strcmp() is null-safe, so the exact-match loop needs no guard.
|
||||
static bool isSlotKeyPrefix(const char* key) {
|
||||
return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0
|
||||
&& key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.';
|
||||
}
|
||||
|
||||
static bool isAllowedSetKey(const char* key) {
|
||||
for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) {
|
||||
if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) return true;
|
||||
}
|
||||
// mqtt<1-6>.<field>
|
||||
if (isSlotKeyPrefix(key)) {
|
||||
for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) {
|
||||
if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool isSecretKey(const char* key) {
|
||||
if (strcmp(key, "wifi.pwd") == 0) return true;
|
||||
if (isSlotKeyPrefix(key)
|
||||
&& (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true;
|
||||
return false;
|
||||
}
|
||||
// Key classification (allowlist, secret detection, slot-prefix parsing) lives in
|
||||
// helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep
|
||||
// the call sites below readable.
|
||||
static inline bool isAllowedSetKey(const char* key) { return wcIsAllowedSetKey(key); }
|
||||
static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); }
|
||||
|
||||
// Constant-time-ish comparison so login timing doesn't leak a prefix match.
|
||||
static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// A no-hardware stand-in for the LoRa radio, so observer firmware can boot and
|
||||
// run in an emulator (e.g. Wokwi) that models the ESP32-S3 + WiFi + display but
|
||||
// has no SX1262. It's a drop-in for the concrete `radio_driver` used by the
|
||||
// examples: it implements the mesh::Radio interface plus the RadioLibWrapper
|
||||
// methods MyMesh/main call directly (setParams, setTxPower, getRngSeed, packet
|
||||
// counters, …). Transmits "succeed" instantly with no RF; nothing is ever
|
||||
// received. WiFi/MQTT/CLI/display all run normally on top of it.
|
||||
//
|
||||
// Compiled only into *_sim builds (guarded by SIM_BUILD in the target). Never
|
||||
// pulled into real firmware.
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#if defined(ESP_PLATFORM)
|
||||
#include <esp_system.h> // esp_random()
|
||||
#endif
|
||||
|
||||
static inline uint32_t _simRandom() {
|
||||
#if defined(ESP_PLATFORM)
|
||||
return esp_random();
|
||||
#else
|
||||
return (uint32_t)millis() * 2654435761u;
|
||||
#endif
|
||||
}
|
||||
|
||||
// RNG for creating a LocalIdentity without radio noise (used by radio_new_identity()).
|
||||
class SimRNG : public mesh::RNG {
|
||||
public:
|
||||
void random(uint8_t* dest, size_t sz) override {
|
||||
for (size_t i = 0; i < sz; i++) dest[i] = (uint8_t)_simRandom();
|
||||
}
|
||||
};
|
||||
|
||||
class SimRadio : public mesh::Radio {
|
||||
uint32_t n_recv, n_sent, n_recv_errors;
|
||||
unsigned long _send_started;
|
||||
public:
|
||||
explicit SimRadio(mesh::MainBoard& /*board*/) : n_recv(0), n_sent(0),
|
||||
n_recv_errors(0), _send_started(0) {}
|
||||
|
||||
// --- mesh::Radio pure virtuals ---
|
||||
void begin() override {}
|
||||
int recvRaw(uint8_t* /*bytes*/, int /*sz*/) override { return 0; } // never receives
|
||||
uint32_t getEstAirtimeFor(int len_bytes) override {
|
||||
return (uint32_t)(len_bytes < 0 ? 0 : len_bytes) * 10 + 10; // rough, non-zero
|
||||
}
|
||||
float packetScore(float /*snr*/, int /*packet_len*/) override { return 0.0f; }
|
||||
bool startSendRaw(const uint8_t* /*bytes*/, int /*len*/) override {
|
||||
_send_started = millis();
|
||||
n_sent++;
|
||||
return true; // "sent" instantly
|
||||
}
|
||||
bool isSendComplete() override { return true; }
|
||||
void onSendFinished() override { _send_started = 0; }
|
||||
bool isInRecvMode() const override { return true; }
|
||||
|
||||
// --- mesh::Radio overrides with useful sim values ---
|
||||
int getNoiseFloor() const override { return -110; }
|
||||
uint32_t getPacketsRecvErrors() const override { return n_recv_errors; }
|
||||
float getLastRSSI() const override { return -80.0f; }
|
||||
float getLastSNR() const override { return 9.0f; }
|
||||
|
||||
// --- concrete RadioLibWrapper surface called directly on radio_driver ---
|
||||
void setParams(float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/) {}
|
||||
void setTxPower(int8_t /*dbm*/) {}
|
||||
uint32_t getRngSeed() { return _simRandom(); }
|
||||
uint32_t getPacketsRecv() const { return n_recv; }
|
||||
uint32_t getPacketsSent() const { return n_sent; }
|
||||
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
|
||||
void setRxBoostedGainMode(bool) {}
|
||||
bool getRxBoostedGainMode() const { return false; }
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
# Host unit tests
|
||||
|
||||
Fast, hardware-free unit tests for the fork's pure logic, run on the host with
|
||||
GoogleTest via PlatformIO's `native` environment. They cover the extractable
|
||||
observer/WebConfig logic (validation, preset table, topic templates, key
|
||||
parsing) — the parts that don't depend on the ESP32, radio, or network stack.
|
||||
Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised
|
||||
separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
pio test -e native # all suites
|
||||
pio test -e native -f test_webconfig_keys # a single suite
|
||||
```
|
||||
|
||||
A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions
|
||||
passed). PlatformIO's "0 test cases" line is just its Unity-style counter and
|
||||
does not reflect the GoogleTest count — run the built binary directly
|
||||
(`.pio/build/native/program`) to see the per-assertion breakdown.
|
||||
|
||||
## Suites
|
||||
|
||||
| Suite | Source under test | Covers |
|
||||
|-------|-------------------|--------|
|
||||
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
|
||||
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation — including boundaries and nulls |
|
||||
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) |
|
||||
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
|
||||
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
|
||||
|
||||
## Conventions (and how to add a suite)
|
||||
|
||||
- Each `test/test_<name>/` directory builds into its **own** GoogleTest program
|
||||
and must define its own `main()` (`::testing::InitGoogleTest` + `RUN_ALL_TESTS`).
|
||||
- Tests are **host-only**: include only pure headers. Arduino/crypto stubs live
|
||||
in `test/mocks/` (on the include path via `-I test/mocks`).
|
||||
- Firmware headers are included from `src` (via `-I src`, e.g.
|
||||
`#include "helpers/MQTTPresets.h"`). Some are guarded or ESP-flavored, so a
|
||||
suite may need shims **before** the include — e.g. `test_mqtt_presets` does
|
||||
`#define WITH_MQTT_BRIDGE 1` (the preset table is behind that flag) and
|
||||
`#define PROGMEM` (the embedded CA-cert strings are PROGMEM-qualified).
|
||||
- To add a suite: create `test/test_<name>/test_<name>.cpp` with a `main()`, and
|
||||
add any host-only source it links to the `native` env's `build_src_filter` in
|
||||
`platformio.ini` (header-only code needs no source entry). No other wiring.
|
||||
- Keep logic testable by extracting pure functions into headers (as
|
||||
`MQTTObserverValidation.h` / `WebConfigKeys.h` / `MQTTTopicTemplate.h` do) and
|
||||
having the firmware call the same functions.
|
||||
@@ -0,0 +1,138 @@
|
||||
// Host tests for the MQTT observer preset table and lookup helpers
|
||||
// (src/helpers/MQTTPresets.h). Pure logic — no ESP/radio dependencies.
|
||||
//
|
||||
// The preset table and lookup functions are compiled only for observer builds,
|
||||
// so opt into that feature flag before including the header (the definitions are
|
||||
// pure C++/data with no ESP dependencies).
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
#define PROGMEM // host build: the CA-cert strings in MQTTPresets.h are PROGMEM-qualified
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include "helpers/MQTTPresets.h"
|
||||
|
||||
// ---- findMQTTPreset -------------------------------------------------------
|
||||
|
||||
TEST(MQTTPresets, FindKnownPreset) {
|
||||
const MQTTPresetDef* p = findMQTTPreset("analyzer-us");
|
||||
ASSERT_NE(nullptr, p);
|
||||
EXPECT_STREQ("analyzer-us", p->name);
|
||||
EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type);
|
||||
EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style);
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, FindReturnsTablePointer) {
|
||||
// The returned pointer must be into the table, not a copy.
|
||||
const MQTTPresetDef* p = findMQTTPreset("meshrank");
|
||||
ASSERT_NE(nullptr, p);
|
||||
bool in_table = false;
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
if (p == &MQTT_PRESETS[i]) { in_table = true; break; }
|
||||
}
|
||||
EXPECT_TRUE(in_table);
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, UnknownAndEmptyReturnNull) {
|
||||
EXPECT_EQ(nullptr, findMQTTPreset("does-not-exist"));
|
||||
EXPECT_EQ(nullptr, findMQTTPreset(""));
|
||||
EXPECT_EQ(nullptr, findMQTTPreset(nullptr));
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, NoneAndCustomAreNotTablePresets) {
|
||||
// "none"/"custom" are virtual presets handled by the CLI, not table entries.
|
||||
EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_NONE));
|
||||
EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_CUSTOM));
|
||||
EXPECT_STREQ("none", MQTT_PRESET_NONE);
|
||||
EXPECT_STREQ("custom", MQTT_PRESET_CUSTOM);
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, LookupIsCaseSensitive) {
|
||||
EXPECT_EQ(nullptr, findMQTTPreset("Analyzer-US"));
|
||||
}
|
||||
|
||||
// ---- table integrity ------------------------------------------------------
|
||||
|
||||
TEST(MQTTPresets, EveryNameIsUniqueAndNonEmpty) {
|
||||
std::set<std::string> names;
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
ASSERT_NE(nullptr, MQTT_PRESETS[i].name) << "preset " << i << " has null name";
|
||||
EXPECT_NE('\0', MQTT_PRESETS[i].name[0]) << "preset " << i << " has empty name";
|
||||
auto res = names.insert(MQTT_PRESETS[i].name);
|
||||
EXPECT_TRUE(res.second) << "duplicate preset name: " << MQTT_PRESETS[i].name;
|
||||
}
|
||||
EXPECT_EQ((size_t)MQTT_PRESET_COUNT, names.size());
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, EveryPresetHasAServerUrl) {
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
ASSERT_NE(nullptr, MQTT_PRESETS[i].server_url) << MQTT_PRESETS[i].name;
|
||||
EXPECT_NE('\0', MQTT_PRESETS[i].server_url[0]) << MQTT_PRESETS[i].name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, JwtPresetsCarryAnAudience) {
|
||||
// JWT auth needs an audience (the field doubles as the broker host here).
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
if (MQTT_PRESETS[i].auth_type == MQTT_AUTH_JWT) {
|
||||
EXPECT_NE(nullptr, MQTT_PRESETS[i].jwt_audience)
|
||||
<< MQTT_PRESETS[i].name << " is JWT but has no audience";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, NamesFitTheSlotPresetBuffer) {
|
||||
// Stored preset name goes into mqtt_slot_preset[MAX][24]; keep < 24 chars.
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
EXPECT_LT(strlen(MQTT_PRESETS[i].name), (size_t)24)
|
||||
<< MQTT_PRESETS[i].name << " too long for slot-preset buffer";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mqttPresetNeedsSlotCredentials ---------------------------------------
|
||||
|
||||
TEST(MQTTPresets, EmbeddedUserpassDoesNotNeedSlotCredentials) {
|
||||
// tennmesh ships an embedded username+password.
|
||||
const MQTTPresetDef* p = findMQTTPreset("tennmesh");
|
||||
ASSERT_NE(nullptr, p);
|
||||
EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type);
|
||||
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p));
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, UserpassWithoutEmbeddedCredsNeedsSlotCredentials) {
|
||||
// inwmesh is USERPASS with null user/pass -> must come from mqttN.username/password.
|
||||
const MQTTPresetDef* p = findMQTTPreset("inwmesh");
|
||||
ASSERT_NE(nullptr, p);
|
||||
EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type);
|
||||
EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p));
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, NonUserpassNeverNeedsSlotCredentials) {
|
||||
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
|
||||
if (MQTT_PRESETS[i].auth_type != MQTT_AUTH_USERPASS) {
|
||||
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(&MQTT_PRESETS[i]))
|
||||
<< MQTT_PRESETS[i].name;
|
||||
}
|
||||
}
|
||||
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(nullptr));
|
||||
}
|
||||
|
||||
TEST(MQTTPresets, MeshrankIsTokenStyleNoAuth) {
|
||||
const MQTTPresetDef* p = findMQTTPreset("meshrank");
|
||||
ASSERT_NE(nullptr, p);
|
||||
EXPECT_EQ(MQTT_TOPIC_MESHRANK, p->topic_style);
|
||||
EXPECT_EQ(MQTT_AUTH_NONE, p->auth_type);
|
||||
}
|
||||
|
||||
// ---- slot count constants -------------------------------------------------
|
||||
|
||||
TEST(MQTTPresets, SlotCountsAreSane) {
|
||||
EXPECT_GT(RUNTIME_MQTT_SLOTS, 0);
|
||||
EXPECT_LE(RUNTIME_MQTT_SLOTS, MAX_MQTT_SLOTS);
|
||||
EXPECT_EQ(6, MAX_MQTT_SLOTS); // persisted layout — must not drift without migration
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Host tests for the observer input validators shared by the CLI setters
|
||||
// (src/helpers/MQTTObserverValidation.h): IATA, owner key, NTP hostname, and
|
||||
// the buffer-fit check behind the #17 length validation.
|
||||
#include <gtest/gtest.h>
|
||||
#include <string>
|
||||
#include "helpers/MQTTObserverValidation.h"
|
||||
|
||||
// ---- IATA: exactly 3 alphanumerics ---------------------------------------
|
||||
|
||||
TEST(IataValid, AcceptsThreeLetters) {
|
||||
EXPECT_TRUE(mqttIataValid("DEN"));
|
||||
EXPECT_TRUE(mqttIataValid("den")); // case handled (setter uppercases after)
|
||||
EXPECT_TRUE(mqttIataValid("LAX"));
|
||||
}
|
||||
|
||||
TEST(IataValid, AcceptsThreeAlphanumerics) {
|
||||
EXPECT_TRUE(mqttIataValid("D3N"));
|
||||
EXPECT_TRUE(mqttIataValid("2M0"));
|
||||
}
|
||||
|
||||
TEST(IataValid, RejectsWrongLength) {
|
||||
EXPECT_FALSE(mqttIataValid(""));
|
||||
EXPECT_FALSE(mqttIataValid("D"));
|
||||
EXPECT_FALSE(mqttIataValid("DE"));
|
||||
EXPECT_FALSE(mqttIataValid("DENV"));
|
||||
EXPECT_FALSE(mqttIataValid("DENVER"));
|
||||
}
|
||||
|
||||
TEST(IataValid, RejectsNonAlphanumeric) {
|
||||
EXPECT_FALSE(mqttIataValid("D-N")); // topic separator-ish
|
||||
EXPECT_FALSE(mqttIataValid("D N")); // space
|
||||
EXPECT_FALSE(mqttIataValid("D/N")); // MQTT topic separator
|
||||
EXPECT_FALSE(mqttIataValid("D+N")); // MQTT wildcard
|
||||
EXPECT_FALSE(mqttIataValid("D#N")); // MQTT wildcard
|
||||
}
|
||||
|
||||
TEST(IataValid, RejectsNull) {
|
||||
EXPECT_FALSE(mqttIataValid(nullptr));
|
||||
}
|
||||
|
||||
// ---- owner key: exactly 64 hex -------------------------------------------
|
||||
|
||||
static std::string hexKey(int len, char fill = 'a') { return std::string(len, fill); }
|
||||
|
||||
TEST(OwnerKeyValid, Accepts64Hex) {
|
||||
EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'a').c_str()));
|
||||
EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'F').c_str()));
|
||||
EXPECT_TRUE(mqttOwnerKeyValid(
|
||||
"0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdef0123"));
|
||||
}
|
||||
|
||||
TEST(OwnerKeyValid, RejectsWrongLength) {
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(""));
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(hexKey(63).c_str()));
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(hexKey(65).c_str()));
|
||||
}
|
||||
|
||||
TEST(OwnerKeyValid, RejectsNonHex) {
|
||||
std::string k = hexKey(64);
|
||||
k[10] = 'g'; // not a hex digit
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
|
||||
k[10] = 'z';
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
|
||||
k[10] = ' ';
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
|
||||
}
|
||||
|
||||
TEST(OwnerKeyValid, RejectsNull) {
|
||||
EXPECT_FALSE(mqttOwnerKeyValid(nullptr));
|
||||
}
|
||||
|
||||
// ---- NTP hostname ---------------------------------------------------------
|
||||
|
||||
TEST(NtpHostnameValid, AcceptsTypicalHosts) {
|
||||
EXPECT_TRUE(mqttNtpHostnameValid("pool.ntp.org"));
|
||||
EXPECT_TRUE(mqttNtpHostnameValid("time.google.com"));
|
||||
EXPECT_TRUE(mqttNtpHostnameValid("1.2.3.4"));
|
||||
EXPECT_TRUE(mqttNtpHostnameValid("a"));
|
||||
}
|
||||
|
||||
TEST(NtpHostnameValid, LengthBoundaryIs63) {
|
||||
EXPECT_TRUE(mqttNtpHostnameValid(std::string(63, 'a').c_str()));
|
||||
EXPECT_FALSE(mqttNtpHostnameValid(std::string(64, 'a').c_str()));
|
||||
}
|
||||
|
||||
TEST(NtpHostnameValid, RejectsEmptyAndNull) {
|
||||
EXPECT_FALSE(mqttNtpHostnameValid(""));
|
||||
EXPECT_FALSE(mqttNtpHostnameValid(nullptr));
|
||||
}
|
||||
|
||||
TEST(NtpHostnameValid, RejectsLeadingOrTrailingDot) {
|
||||
EXPECT_FALSE(mqttNtpHostnameValid(".pool.ntp.org"));
|
||||
EXPECT_FALSE(mqttNtpHostnameValid("pool.ntp.org."));
|
||||
}
|
||||
|
||||
TEST(NtpHostnameValid, RejectsInvalidChars) {
|
||||
EXPECT_FALSE(mqttNtpHostnameValid("a_b")); // underscore
|
||||
EXPECT_FALSE(mqttNtpHostnameValid("a b")); // space
|
||||
EXPECT_FALSE(mqttNtpHostnameValid("http://x")); // scheme / slashes
|
||||
}
|
||||
|
||||
// ---- buffer-fit (the #17 length check) -----------------------------------
|
||||
|
||||
TEST(ValueFits, FitsWhenShorterThanBuffer) {
|
||||
EXPECT_TRUE(mqttValueFits("abc", 4)); // 3 < 4 (room for NUL)
|
||||
EXPECT_TRUE(mqttValueFits("", 1)); // empty fits any 1+ buffer
|
||||
}
|
||||
|
||||
TEST(ValueFits, RejectsWhenExactlyBufferSizeOrLonger) {
|
||||
EXPECT_FALSE(mqttValueFits("abcd", 4)); // 4 == 4, no room for NUL
|
||||
EXPECT_FALSE(mqttValueFits("abcde", 4));
|
||||
EXPECT_FALSE(mqttValueFits("x", 1));
|
||||
}
|
||||
|
||||
TEST(ValueFits, RealBufferBoundaries) {
|
||||
// Mirrors the actual MQTTPrefs field sizes the setters pass sizeof() for.
|
||||
EXPECT_TRUE(mqttValueFits(std::string(63, 'p').c_str(), 64)); // wifi_password[64]
|
||||
EXPECT_FALSE(mqttValueFits(std::string(64, 'p').c_str(), 64));
|
||||
EXPECT_TRUE(mqttValueFits(std::string(31, 's').c_str(), 32)); // wifi_ssid[32]
|
||||
EXPECT_FALSE(mqttValueFits(std::string(32, 's').c_str(), 32));
|
||||
EXPECT_TRUE(mqttValueFits(std::string(47, 't').c_str(), 48)); // slot token[48]
|
||||
EXPECT_FALSE(mqttValueFits(std::string(48, 't').c_str(), 48));
|
||||
EXPECT_TRUE(mqttValueFits(std::string(95, 'x').c_str(), 96)); // slot topic[96]
|
||||
EXPECT_FALSE(mqttValueFits(std::string(96, 'x').c_str(), 96));
|
||||
}
|
||||
|
||||
TEST(ValueFits, RejectsNullOrZeroBuffer) {
|
||||
EXPECT_FALSE(mqttValueFits(nullptr, 32));
|
||||
EXPECT_FALSE(mqttValueFits("abc", 0));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Host tests for the MQTT custom-topic placeholder expansion
|
||||
// (src/helpers/MQTTTopicTemplate.h), the pure core of
|
||||
// MQTTBridge::substituteTopicTemplate.
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstring>
|
||||
#include "helpers/MQTTTopicTemplate.h"
|
||||
|
||||
static const char* IATA = "DEN";
|
||||
static const char* DEV = "abcdef0123456789";
|
||||
static const char* TOK = "tok123";
|
||||
|
||||
TEST(TopicTemplate, SubstitutesAllPlaceholders) {
|
||||
char buf[128];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("meshcore/{iata}/{device}/{type}", IATA, DEV, TOK, "status",
|
||||
buf, sizeof(buf)));
|
||||
EXPECT_STREQ("meshcore/DEN/abcdef0123456789/status", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, TokenPlaceholder) {
|
||||
char buf[128];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("meshrank/uplink/{token}/{device}/packets",
|
||||
IATA, DEV, TOK, "packets", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("meshrank/uplink/tok123/abcdef0123456789/packets", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, RepeatedPlaceholder) {
|
||||
char buf[64];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("{iata}-{iata}", IATA, DEV, TOK, "raw", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("DEN-DEN", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, LiteralWithNoPlaceholders) {
|
||||
char buf[64];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("plain/topic/path", IATA, DEV, TOK, "status", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("plain/topic/path", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, UnknownBracesCopiedVerbatim) {
|
||||
char buf[64];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("a/{bogus}/{iata}", IATA, DEV, TOK, "status", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("a/{bogus}/DEN", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, TypeStringVaries) {
|
||||
char buf[64];
|
||||
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "status", buf, sizeof(buf));
|
||||
EXPECT_STREQ("status", buf);
|
||||
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "packets", buf, sizeof(buf));
|
||||
EXPECT_STREQ("packets", buf);
|
||||
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "raw", buf, sizeof(buf));
|
||||
EXPECT_STREQ("raw", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, NullValuesSubstituteEmpty) {
|
||||
char buf[64];
|
||||
ASSERT_TRUE(mqttSubstituteTopic("x/{token}/y", IATA, DEV, nullptr, "status", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("x//y", buf);
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, OverflowReturnsFalseNoWrite) {
|
||||
// Substituting {device} (16 chars) into a template won't fit an 8-byte buffer.
|
||||
char buf[8];
|
||||
EXPECT_FALSE(mqttSubstituteTopic("{device}", IATA, DEV, TOK, "status", buf, sizeof(buf)));
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, LiteralOverflowTruncatesAndNulTerminates) {
|
||||
char buf[5];
|
||||
// Literal longer than the buffer: fills up to buf_size-1 and NUL-terminates.
|
||||
mqttSubstituteTopic("abcdefghij", IATA, DEV, TOK, "status", buf, sizeof(buf));
|
||||
EXPECT_EQ('\0', buf[4]);
|
||||
EXPECT_EQ((size_t)4, strlen(buf));
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, AlwaysNulTerminatedAndBounded) {
|
||||
// Fuzz-ish: many buffer sizes never overrun and always NUL-terminate.
|
||||
const char* tmpl = "meshcore/{iata}/{device}/{token}/{type}/tail";
|
||||
for (size_t sz = 1; sz <= 80; sz++) {
|
||||
char buf[96];
|
||||
memset(buf, 0x7f, sizeof(buf));
|
||||
mqttSubstituteTopic(tmpl, IATA, DEV, TOK, "packets", buf, sz);
|
||||
EXPECT_LT(strlen(buf), sz) << "size " << sz; // fits with room for NUL
|
||||
EXPECT_EQ('\0', buf[strlen(buf)]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, ZeroBufferOrNullFails) {
|
||||
char buf[8];
|
||||
EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", buf, 0));
|
||||
EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", nullptr, 8));
|
||||
}
|
||||
|
||||
TEST(TopicTemplate, EmptyTemplateReturnsFalse) {
|
||||
char buf[8];
|
||||
EXPECT_FALSE(mqttSubstituteTopic("", IATA, DEV, TOK, "status", buf, sizeof(buf)));
|
||||
EXPECT_STREQ("", buf);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Host tests for the WebConfig key allowlist / secret / slot-prefix helpers
|
||||
// (src/helpers/WebConfigKeys.h). These parse attacker-supplied POST keys, so
|
||||
// coverage of the length-guard and boundary cases matters for safety.
|
||||
#include <gtest/gtest.h>
|
||||
#include "helpers/WebConfigKeys.h"
|
||||
|
||||
// ---- allowlist ------------------------------------------------------------
|
||||
|
||||
TEST(WebConfigKeys, AllowsKnownScalarKeys) {
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("name"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("radio"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("repeat"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("snmp.community"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("timezone.offset"));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, AllowsPerSlotKeys) {
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) {
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("erase"));
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("password"));
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("reboot"));
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("bogus"));
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field
|
||||
EXPECT_FALSE(wcIsAllowedSetKey(""));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, SlotIndexBoundsMatchMaxSlots) {
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("mqtt0.preset")); // slot 0 invalid
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.preset")); // last valid slot
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("mqtt7.preset")); // beyond MAX_MQTT_SLOTS
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("mqtt9.preset"));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, IsCaseSensitive) {
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("Name"));
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset"));
|
||||
}
|
||||
|
||||
// ---- short-key OOB guard --------------------------------------------------
|
||||
// The slot-prefix probe indexes key[4..6]; these short strings must be rejected
|
||||
// without ever reading past the terminator.
|
||||
|
||||
TEST(WebConfigKeys, ShortKeysRejectedSafely) {
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix(""));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("m"));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mq"));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqt"));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt")); // 4 chars — no digit/dot
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1")); // 5 chars — no dot
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1.")); // 6 chars — no field char
|
||||
EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt1.x")); // 7 chars — minimum valid
|
||||
// Same guard via the public allowlist/secret entry points:
|
||||
EXPECT_FALSE(wcIsAllowedSetKey("mqtt"));
|
||||
EXPECT_FALSE(wcIsSecretKey("m"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt"));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, SlotPrefixDigitRange) {
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt0.x"));
|
||||
EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt6.x"));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt7.x"));
|
||||
EXPECT_FALSE(wcIsSlotKeyPrefix("mqttA.x")); // non-digit
|
||||
}
|
||||
|
||||
// ---- secret classification ------------------------------------------------
|
||||
|
||||
TEST(WebConfigKeys, SecretKeysDetected) {
|
||||
EXPECT_TRUE(wcIsSecretKey("wifi.pwd"));
|
||||
EXPECT_TRUE(wcIsSecretKey("mqtt1.password"));
|
||||
EXPECT_TRUE(wcIsSecretKey("mqtt3.token"));
|
||||
EXPECT_TRUE(wcIsSecretKey("mqtt6.password"));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, NonSecretKeysNotFlagged) {
|
||||
EXPECT_FALSE(wcIsSecretKey("wifi.ssid"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.server"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt.origin"));
|
||||
EXPECT_FALSE(wcIsSecretKey("name"));
|
||||
EXPECT_FALSE(wcIsSecretKey(""));
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
|
||||
// A secret key must be one the portal can actually set, or the masking is moot.
|
||||
const char* secrets[] = {"wifi.pwd", "mqtt1.password", "mqtt1.token",
|
||||
"mqtt6.password", "mqtt6.token"};
|
||||
for (const char* k : secrets) {
|
||||
EXPECT_TRUE(wcIsSecretKey(k)) << k;
|
||||
EXPECT_TRUE(wcIsAllowedSetKey(k)) << k;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -159,6 +159,16 @@ lib_deps =
|
||||
0neblock/SNMP_Agent
|
||||
paulstoffregen/Time@1.6.1
|
||||
|
||||
; Emulator build (Wokwi): identical to the observer above but with the LoRa radio
|
||||
; stubbed (SIM_BUILD -> SimRadio) and WiFi pre-seeded to the Wokwi network so it
|
||||
; boots straight into WiFi/MQTT/CLI without hardware or flashing. See wokwi/.
|
||||
[env:Heltec_v3_repeater_observer_mqtt_sim]
|
||||
extends = env:Heltec_v3_repeater_observer_mqtt
|
||||
build_flags =
|
||||
${env:Heltec_v3_repeater_observer_mqtt.build_flags}
|
||||
-D SIM_BUILD=1
|
||||
-D SIM_WIFI_SSID='"Wokwi-GUEST"'
|
||||
|
||||
[env:Heltec_v3_room_server]
|
||||
extends = Heltec_lora32_v3
|
||||
build_flags =
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
HeltecV3Board board;
|
||||
|
||||
#ifdef SIM_BUILD
|
||||
SimRadio radio_driver(board); // no-op radio for emulator builds
|
||||
#else
|
||||
#if defined(P_LORA_SCLK)
|
||||
static SPIClass spi;
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
|
||||
@@ -11,6 +14,7 @@ HeltecV3Board board;
|
||||
#endif
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
#endif
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
@@ -31,8 +35,10 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
bool radio_init() {
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
|
||||
#ifdef SIM_BUILD
|
||||
return true; // no SPI radio to bring up
|
||||
#elif defined(P_LORA_SCLK)
|
||||
return radio.std_init(&spi);
|
||||
#else
|
||||
return radio.std_init();
|
||||
@@ -40,7 +46,12 @@ bool radio_init() {
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
#ifdef SIM_BUILD
|
||||
SimRNG rng;
|
||||
return mesh::LocalIdentity(&rng);
|
||||
#else
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <HeltecV3Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#ifdef SIM_BUILD
|
||||
// Emulator build (e.g. Wokwi): no SX1262 hardware — use the no-op SimRadio so
|
||||
// the firmware boots and runs WiFi/MQTT/CLI/display. See src/helpers/sim/.
|
||||
#include <helpers/sim/SimRadio.h>
|
||||
#include <HeltecV3Board.h>
|
||||
#else
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <HeltecV3Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#endif
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
@@ -14,7 +21,11 @@
|
||||
#endif
|
||||
|
||||
extern HeltecV3Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
#ifdef SIM_BUILD
|
||||
extern SimRadio radio_driver;
|
||||
#else
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
#endif
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Wokwi config for the ESP32-S3 observer simulation (no hardware, no flashing).
|
||||
#
|
||||
# Build the sim firmware first (produces the merged flash image Wokwi needs):
|
||||
# pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin
|
||||
#
|
||||
# Then start the simulation either way:
|
||||
# - VS Code: install the "Wokwi Simulator" extension, open diagram.json, press play
|
||||
# - CLI: wokwi-cli . (needs a free WOKWI_CLI_TOKEN)
|
||||
#
|
||||
# The firmware is seeded to auto-join the "Wokwi-GUEST" network, so it boots
|
||||
# straight into WiFi + MQTT. The LoRa radio is stubbed (SimRadio); mesh RX/TX is
|
||||
# not simulated. Drive the CLI over the serial monitor (get/set/start webconfig).
|
||||
[wokwi]
|
||||
version = 1
|
||||
firmware = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware-merged.bin"
|
||||
elf = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware.elf"
|
||||
Reference in New Issue
Block a user