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:
agessaman committed 2026-07-18 13:19:27 -07:00
1 parent b7795720b2
commit cbbd01b620
23 files changed
+1403 -131

No files matched your search

+30 -38
View File
@@ -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");
+60
View File
@@ -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;
}
+3
View File
@@ -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;
+52
View File
@@ -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;
}
+62
View File
@@ -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;
}
+4 -39
View File
@@ -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) {
+6 -46
View File
@@ -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) {
+75
View File
@@ -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; }
};