Merge remote-tracking branch 'review/observer-firmware' into keymindCascade

# Conflicts:
#	.github/workflows/build-observer-firmwares.yml
#	.gitignore
#	MQTT_IMPLEMENTATION.md
#	MQTT_INTERNALS.md
#	README.md
#	STABILITY_TESTABILITY_HANDOFF.md
#	docs/cli_commands.md
#	examples/companion_radio/MyMesh.cpp
#	examples/simple_repeater/MyMesh.cpp
#	examples/simple_repeater/UITask.cpp
#	examples/simple_room_server/MyMesh.cpp
#	examples/simple_room_server/UITask.cpp
#	examples/simple_sensor/SensorMesh.cpp
#	platformio.ini
#	scripts/generate_webconfig_html.py
#	scripts/webconfig_mock_server.py
#	src/helpers/CommonCLI.cpp
#	src/helpers/CommonCLI.h
#	src/helpers/JWTHelper.cpp
#	src/helpers/MQTTDefaults.h
#	src/helpers/MQTTMessageBuilder.cpp
#	src/helpers/MQTTPayloadBuilder.h
#	src/helpers/MQTTPresets.h
#	src/helpers/bridges/MQTTBridge.h
#	src/helpers/esp32/WebConfigServer.cpp
#	src/helpers/esp32/WebConfigServer.h
#	src/helpers/ui/SH1106Display.cpp
#	variants/lilygo_tbeam_SX1262/platformio.ini
#	variants/lilygo_tbeam_SX1276/platformio.ini
#	variants/lilygo_tlora_v2_1/platformio.ini
#	variants/thinknode_m7/platformio.ini
#	webui/index.html
This commit is contained in:
mikecarper committed 2026-08-14 08:53:02 -07:00
commit a04a284a3f
74 files changed
+7857 -2483

No files matched your search

+1
View File
@@ -358,6 +358,7 @@ public:
uint32_t getNumRecvDirect() const { return n_recv_direct; }
uint16_t getErrFlags() const { return _err_flags; } // Get error flags
bool hasOutbound() const { return outbound != NULL; }
bool isCurrentOutbound(const Packet* packet) const { return outbound == packet; }
void resetStats() {
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
_err_flags = 0;
+22 -4
View File
@@ -52,6 +52,8 @@ static void resetToUf2Bootloader() {
#include <WiFiClientSecure.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#else
#include <malloc.h> // mallinfo() for the `memory` command on nRF52/RP2040
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
@@ -2026,6 +2028,16 @@ void CommonCLI::loadMQTTPrefs(
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs read failed, using defaults (file preserved)");
} else {
has_observer_fields = plan.observer_fields_present;
// Written by a later build with appended fields. Everything this
// binary knows loaded normally; say so, because the next `set` will
// rewrite the file at this length and drop the newer settings.
if (file_size - sizeof(MQTTPrefsHeader) > plan.payload_len) {
MESH_DEBUG_PRINTLN(
"MQTT: /mqtt_prefs written by newer firmware (%u > %u bytes); "
"config loaded, newer settings ignored and dropped on next save",
(unsigned)(file_size - sizeof(MQTTPrefsHeader)),
(unsigned)plan.payload_len);
}
}
if (file) file.close();
} else if (plan.rewrite_legacy) {
@@ -2182,12 +2194,15 @@ bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) {
}
// Write header and payload sequentially so the transaction needs no second
// full-size (2.8 KiB) staging buffer on constrained targets.
const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader();
// full-size (2.8 KiB) staging buffer on constrained targets. The length is
// the shortest that still round-trips this config, so a node with default
// packet filters keeps writing a payload older firmware can read.
const size_t payload_len = MQTTPrefsCodec::payloadLenFor(_mqtt_prefs);
const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader(payload_len);
MQTTPrefsFileStore store(fs);
const MQTTPrefsAtomicStore::Result result = MQTTPrefsAtomicStore::write(
store, (const uint8_t *)&header, sizeof(header),
(const uint8_t *)&_mqtt_prefs, sizeof(_mqtt_prefs));
(const uint8_t *)&_mqtt_prefs, payload_len);
if (!MQTTPrefsAtomicStore::committed(result)) {
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt_prefs save failed at %s; source preserved",
mqttPrefsSaveResultName(result));
@@ -2514,8 +2529,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "ERR: webconfig not supported on this build");
}
#endif
#ifdef ESP_PLATFORM
} else if (memcmp(command, "memory", 6) == 0) {
#ifdef ESP_PLATFORM
sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d",
ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(),
_callbacks->getQueueSize(),
@@ -2523,6 +2538,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
(int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
(int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM));
#else
struct mallinfo mi = mallinfo();
sprintf(reply, "Heap: free=%d, used=%d", (int)mi.fordblks, (int)mi.uordblks);
#endif
} else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) {
// Manual OTA: bring up the board's browser server for a hand-uploaded binary.
+222 -89
View File
@@ -8,6 +8,7 @@
#include <helpers/MQTTPrefs.h>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/RegionMap.h>
#include <helpers/ConfigSerializer.h>
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -106,90 +107,91 @@
#define TELEMETRY_ACCESS_ALL 0
#define TELEMETRY_ACCESS_ACL 1
struct NodePrefs { // persisted to file
float airtime_factor;
char node_name[32];
double node_lat, node_lon;
char password[16];
float freq;
int8_t tx_power_dbm;
uint8_t disable_fwd;
uint8_t advert_interval; // minutes / 2
uint8_t flood_advert_interval; // hours
float rx_delay_base;
float tx_delay_factor;
char guest_password[16];
float direct_tx_delay_factor;
uint32_t guard;
uint8_t sf;
uint8_t cr;
uint8_t allow_read_only;
uint8_t multi_acks;
float bw;
uint8_t flood_max;
uint8_t flood_max_unscoped;
uint8_t flood_max_advert;
uint8_t interference_threshold;
uint8_t agc_reset_interval; // secs / 4
class NodePrefs : public ConfigSerializer { // persisted to file
public:
float airtime_factor = 0;
char node_name[32] = {};
double node_lat = 0, node_lon = 0;
char password[16] = {};
float freq = 0;
int8_t tx_power_dbm = 0;
uint8_t disable_fwd = 0;
uint8_t advert_interval = 0; // minutes / 2
uint8_t flood_advert_interval = 0; // hours
float rx_delay_base = 0;
float tx_delay_factor = 0;
char guest_password[16] = {};
float direct_tx_delay_factor = 0;
uint32_t guard = 0;
uint8_t sf = 0;
uint8_t cr = 0;
uint8_t allow_read_only = 0;
uint8_t multi_acks = 0;
float bw = 0;
uint8_t flood_max = 0;
uint8_t flood_max_unscoped = 0;
uint8_t flood_max_advert = 0;
uint8_t interference_threshold = 0;
uint8_t agc_reset_interval = 0; // secs / 4
// Bridge settings
uint8_t bridge_enabled; // boolean
uint16_t bridge_delay; // milliseconds (default 500 ms)
uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logRx)
uint32_t bridge_baud; // 9600, 19200, 38400, 57600, 115200 (default 115200)
uint8_t bridge_channel; // 1-14 (ESP-NOW only)
char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only)
uint8_t bridge_enabled = 0; // boolean
uint16_t bridge_delay = 0; // milliseconds (default 500 ms)
uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logRx)
uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200)
uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only)
char bridge_secret[16] = {}; // for XOR encryption of bridge packets (ESP-NOW only)
// Power setting
uint8_t powersaving_enabled; // boolean
uint8_t reboot_interval; // hours, 0-255 (default 0=disable)
uint8_t powersaving_enabled = 0; // boolean
uint8_t reboot_interval = 0; // hours, 0-255 (default 0=disable)
// Gps settings
uint8_t gps_enabled;
uint32_t gps_interval; // in seconds
uint8_t advert_loc_policy;
uint32_t discovery_mod_timestamp;
float adc_multiplier;
char owner_info[120];
uint8_t gps_enabled = 0;
uint32_t gps_interval = 0; // in seconds
uint8_t advert_loc_policy = 0;
uint32_t discovery_mod_timestamp = 0;
float adc_multiplier = 0;
char owner_info[120] = {};
// NOTE: member order below matches mcarper/keymindCascade (upstream/dev order).
// It is in-memory only - /com_prefs is read/written field-by-field in loadPrefsInt/
// savePrefs, whose canonical file order (identical to the flex fleet's through
// offset 294, keymind retry tail at 295+) is what devices actually persist.
uint8_t rx_boosted_gain; // power settings
uint8_t radio_fem_rxgain; // LoRa FEM RX gain setting
uint8_t path_hash_mode; // which path mode to use when sending
uint8_t loop_detect;
uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean)
uint8_t rx_boosted_gain = 0; // power settings
uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting
uint8_t path_hash_mode = 0; // which path mode to use when sending
uint8_t loop_detect = 0;
uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean)
// LR2021 side-detector SFs are appended at /com_prefs offset 856. Their
// in-memory placement here does not shift the established binary layout.
uint8_t extra_sf[4] = {};
uint8_t radio_fem_txgain; // LoRa FEM TX gain; persisted at /com_prefs offset 860
uint8_t retry_preset;
uint8_t direct_retry_attempts;
uint16_t direct_retry_base_ms;
uint16_t direct_retry_step_ms;
uint16_t direct_retry_snr_margin_x4;
int8_t direct_retry_cr4_snr_x4;
int8_t direct_retry_cr5_snr_x4;
int8_t direct_retry_cr7_snr_x4;
int8_t direct_retry_cr8_snr_x4;
uint8_t direct_retry_enabled;
uint8_t direct_retry_cr_enabled;
uint8_t direct_retry_prefs_magic[2];
uint8_t flood_retry_attempts;
uint8_t flood_retry_max_path;
uint8_t flood_retry_prefixes[FLOOD_RETRY_PREFIX_SLOTS][FLOOD_RETRY_PREFIX_LEN];
uint8_t flood_retry_bridge_enabled;
uint8_t flood_retry_bridge_buckets[FLOOD_RETRY_BRIDGE_BUCKETS][FLOOD_RETRY_BUCKET_PREFIXES][FLOOD_RETRY_PREFIX_LEN];
uint8_t flood_retry_ignore_prefixes[FLOOD_RETRY_IGNORE_PREFIXES][FLOOD_RETRY_PREFIX_LEN];
uint8_t flood_retry_advert_enabled;
uint8_t battery_alert_enabled;
uint8_t battery_alert_low_percent;
uint8_t battery_alert_critical_percent;
uint8_t direct_retry_recent_enabled;
uint8_t flood_channel_data_enabled;
uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain; persisted at /com_prefs offset 860
uint8_t retry_preset = 0;
uint8_t direct_retry_attempts = 0;
uint16_t direct_retry_base_ms = 0;
uint16_t direct_retry_step_ms = 0;
uint16_t direct_retry_snr_margin_x4 = 0;
int8_t direct_retry_cr4_snr_x4 = 0;
int8_t direct_retry_cr5_snr_x4 = 0;
int8_t direct_retry_cr7_snr_x4 = 0;
int8_t direct_retry_cr8_snr_x4 = 0;
uint8_t direct_retry_enabled = 0;
uint8_t direct_retry_cr_enabled = 0;
uint8_t direct_retry_prefs_magic[2] = {};
uint8_t flood_retry_attempts = 0;
uint8_t flood_retry_max_path = 0;
uint8_t flood_retry_prefixes[FLOOD_RETRY_PREFIX_SLOTS][FLOOD_RETRY_PREFIX_LEN] = {};
uint8_t flood_retry_bridge_enabled = 0;
uint8_t flood_retry_bridge_buckets[FLOOD_RETRY_BRIDGE_BUCKETS][FLOOD_RETRY_BUCKET_PREFIXES][FLOOD_RETRY_PREFIX_LEN] = {};
uint8_t flood_retry_ignore_prefixes[FLOOD_RETRY_IGNORE_PREFIXES][FLOOD_RETRY_PREFIX_LEN] = {};
uint8_t flood_retry_advert_enabled = 0;
uint8_t battery_alert_enabled = 0;
uint8_t battery_alert_low_percent = 0;
uint8_t battery_alert_critical_percent = 0;
uint8_t direct_retry_recent_enabled = 0;
uint8_t flood_channel_data_enabled = 0;
// Retains the removed flood.channel.block byte at its established file
// offset so every following preference remains upgrade-compatible.
uint8_t legacy_flood_channel_block_max_hops;
uint8_t flood_channel_data_max_hops;
uint8_t telemetry_access;
uint8_t legacy_flood_channel_block_max_hops = 0;
uint8_t flood_channel_data_max_hops = 0;
uint8_t telemetry_access = 0;
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of
// NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays
@@ -198,24 +200,155 @@ struct NodePrefs { // persisted to file
#if defined(ENABLE_OTA)
// OTA config (persisted; synced to OtaContext on load, written on change). 0 = conservative defaults.
uint8_t ota_autofetch; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only)
uint8_t ota_autoinstall; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only)
uint8_t ota_signer_count; // # of allowlisted signer pubkeys below
uint8_t ota_signers[4][32]; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
uint8_t ota_autofetch = 0; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only)
uint8_t ota_autoinstall = 0; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only)
uint8_t ota_signer_count = 0; // # of allowlisted signer pubkeys below
uint8_t ota_signers[4][32] = {}; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
uint16_t ota_checkpoint_blocks = 0; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
uint16_t ota_advert_interval = 0; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
uint8_t ota_max_hops = 0; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
#endif
uint8_t rx_powersaving_enabled; // boolean
uint32_t rx_ps_rx_us;
uint32_t rx_ps_sleep_us;
uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
char battery_alert_region[31]; // named scope for low-battery floods; empty = no alert scope
uint8_t flood_retry_group_max_path; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate
uint8_t rx_watchdog_enabled; // repeater RX-inactivity reboot watchdog (boolean)
uint8_t system_watchdog_enabled; // nRF52 main-loop hardware watchdog (boolean; default on)
uint8_t rx_powersaving_enabled = 0; // boolean
uint32_t rx_ps_rx_us = 0;
uint32_t rx_ps_sleep_us = 0;
uint8_t rx_ps_level = 0; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
uint8_t rx_ps_preamble = 0; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
char battery_alert_region[31] = {}; // named scope for low-battery floods; empty = no alert scope
uint8_t flood_retry_group_max_path = 0; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate
uint8_t rx_watchdog_enabled = 0; // repeater RX-inactivity reboot watchdog (boolean)
uint8_t system_watchdog_enabled = 0; // nRF52 main-loop hardware watchdog (boolean; default on)
private:
class RadioPrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("freq", _parent->freq);
def("bw", _parent->bw);
def("sf", _parent->sf);
def("cr", _parent->cr);
def("cad", _parent->cad_enabled);
def("int_thr", _parent->interference_threshold);
def("rxgain", _parent->rx_boosted_gain);
def("fem_rxgain", _parent->radio_fem_rxgain);
def("fem_txgain", _parent->radio_fem_txgain);
def("tx", _parent->tx_power_dbm);
def("af", _parent->airtime_factor);
def("rxdelay", _parent->rx_delay_base);
def("f_txdelay", _parent->tx_delay_factor);
def("d_txdelay", _parent->direct_tx_delay_factor);
def("agc_int", _parent->agc_reset_interval);
def("hash_mode", _parent->path_hash_mode);
def("multi_ack", _parent->multi_acks);
}
public:
explicit RadioPrefs(NodePrefs* parent) : _parent(parent) { }
};
RadioPrefs radio;
class BridgePrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("en", _parent->bridge_enabled);
def("delay", _parent->bridge_delay);
def("src", _parent->bridge_pkt_src);
def("baud", _parent->bridge_baud);
def("ch", _parent->bridge_channel);
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
}
public:
explicit BridgePrefs(NodePrefs* parent) : _parent(parent) { }
};
BridgePrefs bridge;
class GPSPrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("en", _parent->gps_enabled);
def("int", _parent->gps_interval);
def("adv_loc", _parent->advert_loc_policy);
}
public:
explicit GPSPrefs(NodePrefs* parent) : _parent(parent) { }
};
GPSPrefs gps;
class PowerPrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("adc_mult", _parent->adc_multiplier);
def("pwr_sav_en", _parent->powersaving_enabled);
}
public:
explicit PowerPrefs(NodePrefs* parent) : _parent(parent) { }
};
PowerPrefs power;
class RepeatPrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("disable", _parent->disable_fwd);
def("f_max", _parent->flood_max);
def("f_max_uns", _parent->flood_max_unscoped);
def("f_max_adv", _parent->flood_max_advert);
def("loop", _parent->loop_detect);
}
public:
explicit RepeatPrefs(NodePrefs* parent) : _parent(parent) { }
};
RepeatPrefs repeat;
class RoomPrefs : public ConfigSerializer {
NodePrefs* _parent;
protected:
void structure() override {
def("rd_only", _parent->allow_read_only);
}
public:
explicit RoomPrefs(NodePrefs* parent) : _parent(parent) { }
};
RoomPrefs room;
protected:
void structure() override {
def("name", node_name, sizeof(node_name));
def("pass", password, sizeof(password));
def("guest", guest_password, sizeof(guest_password));
def("owner", owner_info, sizeof(owner_info));
def("adv_int", advert_interval);
def("f_adv_int", flood_advert_interval);
def("lat", node_lat);
def("lon", node_lon);
def("disc_mod", discovery_mod_timestamp);
def("radio", radio);
def("bridge", bridge);
def("gps", gps);
def("repeat", repeat);
def("room", room);
def("power", power);
}
public:
NodePrefs()
: ConfigSerializer(), radio(this), bridge(this), gps(this), power(this),
repeat(this), room(this) { }
};
#ifdef WITH_MQTT_BRIDGE
@@ -518,7 +651,7 @@ class CommonCLI {
// run on defaults and saveMQTTPrefs() must not overwrite the source file.
bool _mqtt_prefs_hold = false;
#endif
bool _com_prefs_needs_upgrade = false; // old-format /com_prefs detected; rewrite once after load
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
+37 -2
View File
@@ -31,6 +31,7 @@
#include "bridges/MQTTBridge.h"
#include "MQTTConnectionPolicy.h" // classifySlotActivation() -- "will this slot connect here?"
#include "MQTTDefaults.h"
#include "MQTTPacketFilter.h"
#endif
// Local copy of the busted-libc-safe atoi (the original in CommonCLI.cpp is static).
@@ -294,7 +295,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
#elif defined(WITH_MQTT_BRIDGE)
} else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0 ||
memcmp(config, "mqtt.neighbors ", 15) == 0) {
strcpy(reply, "Err - not supported (requires PSRAM)");
strcpy(reply, "Err - neighbors not enabled in this build");
#endif
} else if (memcmp(config, "mqtt.ntp ", 9) == 0) {
const char* host = &config[9];
@@ -539,6 +540,32 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
saveObserverPrefs();
_callbacks->restartBridgeSlot(slot);
sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1);
} else if (strcmp(subcmd, "filter") == 0 ||
strncmp(subcmd, "filter ", 7) == 0) {
// Empty/bare input resets to the backwards-compatible all-types default.
const char* filter_value = subcmd[6] == '\0' ? "" : &subcmd[7];
uint16_t filter_mask = 0;
if (!MQTTPacketFilter::parse(filter_value, &filter_mask)) {
strcpy(reply, "Error: filter must be all, none, or a CSV of types 0-15 / names (advert,txt_msg,...)");
} else {
_mqtt_prefs.mqtt_slot_packet_filter[slot] = filter_mask;
savePrefs();
char filter_text[MQTTPacketFilter::kFilterTextSize];
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
snprintf(reply, 160, "OK - slot %d packet types: %s", slot + 1, filter_text);
// A non-default filter extends /mqtt_prefs past what pre-filter
// firmware can read (see MQTTPrefsCodec::payloadLenFor), so say when
// that cost buys nothing: slots beyond the runtime array are never
// published to on this board, the same warning `preset` gives.
if (slot >= RUNTIME_MQTT_SLOTS &&
filter_mask != MQTTPacketFilter::kAllPacketTypes) {
size_t used = strlen(reply);
if (used < 158) {
snprintf(reply + used, 160 - used,
" (slot inactive on this hardware; blocks firmware rollback)");
}
}
}
} else {
sprintf(reply, "unknown config: %s", config);
}
@@ -825,7 +852,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#elif defined(WITH_MQTT_BRIDGE)
} else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0 ||
memcmp(config, "mqtt.neighbors", 14) == 0) {
strcpy(reply, "Err - not supported (requires PSRAM)");
strcpy(reply, "Err - neighbors not enabled in this build");
#endif
} else if (memcmp(config, "mqtt.ntp.diag", 13) == 0 && (config[13] == '\0' || config[13] == ' ')) {
#if defined(PORTABLE_MQTT_OBSERVER)
@@ -888,6 +915,14 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else {
strcpy(reply, "> (not set - custom slots use username/password auth)");
}
} else if (strcmp(subcmd, "filter") == 0) {
char filter_text[MQTTPacketFilter::kFilterTextSize];
if (MQTTPacketFilter::format(_mqtt_prefs.mqtt_slot_packet_filter[slot],
filter_text, sizeof(filter_text))) {
snprintf(reply, 160, "> %s", filter_text);
} else {
strcpy(reply, "Error: invalid stored packet filter");
}
} else if (memcmp(subcmd, "diag", 4) == 0) {
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
} else {
+6 -3
View File
@@ -1,7 +1,10 @@
#include "JWTHelper.h"
// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the
// arduino_base exclusion, so the contents are guarded here rather than in
// the build filter -- same idiom as helpers/esp32/WebConfigServer.cpp.
#ifdef WITH_MQTT_BRIDGE
#include "JWTHelper.h"
#include <ArduinoJson.h>
#include <SHA256.h>
#include <string.h>
@@ -202,4 +205,4 @@ size_t JWTHelper::createPayload(
return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize);
}
#endif
#endif // WITH_MQTT_BRIDGE
+4
View File
@@ -18,6 +18,10 @@ static const uint32_t kDefaultJwtLifetimeSecs = 86400UL;
static const uint32_t kMaxJwtStaggerSecs = 300UL;
static const uint32_t kMinimumValidEpoch = 1000000000UL;
static const uint32_t kJwtClockThreshold = 1735689600UL; // 2025-01-01 UTC
// A wall clock at or past this instant was set from a real source (NTP or an
// admin); anything earlier is the firmware's unset-clock default (1715770351,
// 15 May 2024), so a delta spanning the sync is meaningless.
static const uint32_t kSyncedClockEpoch = kJwtClockThreshold;
// Unsigned subtraction is intentionally used: it is the standard millis()
// idiom and remains correct across a single 32-bit counter rollover.
+4
View File
@@ -5,6 +5,7 @@
#include <string.h>
#include <MeshCore.h>
#include "MQTTPrefs.h"
#include "MQTTPacketFilter.h"
#include "MQTTPresets.h"
// Compile-time defaults for fresh /mqtt_prefs (override via platformio build_flags).
@@ -82,6 +83,9 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
MQTT_DEFAULT_SLOT5_PRESET);
mqttDefaultSlotPreset(prefs->mqtt_slot_preset[5], sizeof(prefs->mqtt_slot_preset[5]),
MQTT_DEFAULT_SLOT6_PRESET);
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
prefs->mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes;
}
if (MQTT_DEFAULT_IATA[0] != '\0') {
strncpy(prefs->mqtt_iata, MQTT_DEFAULT_IATA, sizeof(prefs->mqtt_iata) - 1);
+33 -6
View File
@@ -1,7 +1,10 @@
#include "MQTTMessageBuilder.h"
// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the
// arduino_base exclusion, so the contents are guarded here rather than in
// the build filter -- same idiom as helpers/esp32/WebConfigServer.cpp.
#ifdef WITH_MQTT_BRIDGE
#include "MQTTMessageBuilder.h"
#include "MQTTPayloadBuilder.h"
#include <ArduinoJson.h>
#include <cstring>
@@ -114,14 +117,38 @@ int MQTTMessageBuilder::buildNeighborsMessage(
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
size_t buffer_size,
int total_neighbors,
int queried_neighbors,
bool truncated
) {
return MQTTPayloadBuilder::buildNeighborsMessage(
doc, origin, origin_id, timestamp, self_scopes, neighbors, neighbor_count,
buffer, buffer_size);
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
neighbors, neighbor_count, buffer, buffer_size, total_neighbors,
queried_neighbors, truncated);
}
size_t MQTTMessageBuilder::measureNeighborsMessageBase(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors
) {
return MQTTPayloadBuilder::measureNeighborsMessageBase(
origin, origin_id, timestamp, self_scopes, self_default_scope,
total_neighbors);
}
size_t MQTTMessageBuilder::measureNeighborsMessageEntry(
const NeighborsMessageEntry& neighbor
) {
return MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbor);
}
int MQTTMessageBuilder::buildPacketJSON(
@@ -348,4 +375,4 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex
bytesToHex(raw_buf, raw_len, hex, hex_size);
}
#endif
#endif // WITH_MQTT_BRIDGE
+14 -1
View File
@@ -166,11 +166,24 @@ public:
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
size_t buffer_size,
int total_neighbors = -1,
int queried_neighbors = -1,
bool truncated = false
);
static size_t measureNeighborsMessageBase(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors
);
static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor);
/**
* Convert packet to JSON message
+226
View File
@@ -0,0 +1,226 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
// Pure per-broker packet-type allowlist helpers. MeshCore payload types occupy
// the low four bits of the packet header, so a uint16_t stores the complete
// 0..15 allowlist without dynamic allocation.
namespace MQTTPacketFilter {
static const uint8_t kMinPacketType = 0;
static const uint8_t kMaxPacketType = 15;
static const uint16_t kAllPacketTypes = 0xFFFFu;
// "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" plus the terminator.
static const size_t kFilterTextSize = 38;
inline bool isAsciiSpace(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
inline bool tokenEquals(const char* begin, size_t len, const char* token) {
return token != nullptr && strlen(token) == len && memcmp(begin, token, len) == 0;
}
// Payload-type spellings accepted alongside the decimal form, mirroring the
// PAYLOAD_TYPE_* names in src/Packet.h. Types 12-14 are reserved upstream and
// have no name, so they stay reachable only by number.
struct NamedPacketType {
const char* name;
uint8_t type;
};
inline const NamedPacketType* namedPacketTypes(size_t* count_out) {
static const NamedPacketType kNames[] = {
{"req", 0}, {"response", 1}, {"txt_msg", 2}, {"ack", 3},
{"advert", 4}, {"grp_txt", 5}, {"grp_data", 6}, {"anon_req", 7},
{"path", 8}, {"trace", 9}, {"multipart", 10}, {"control", 11},
{"raw_custom", 15},
};
if (count_out != nullptr) *count_out = sizeof(kNames) / sizeof(kNames[0]);
return kNames;
}
// Resolve one already-trimmed list entry to a payload type. Decimal and named
// spellings are interchangeable within a list; "all"/"none" are whole-value
// keywords and deliberately do not resolve here, so "all,2" stays invalid.
inline bool resolveToken(const char* begin, size_t len, uint8_t* type_out) {
if (begin == nullptr || type_out == nullptr || len == 0) return false;
if (*begin >= '0' && *begin <= '9') {
unsigned value = 0;
for (size_t i = 0; i < len; ++i) {
if (begin[i] < '0' || begin[i] > '9') return false;
value = value * 10u + static_cast<unsigned>(begin[i] - '0');
if (value > kMaxPacketType) return false;
}
*type_out = static_cast<uint8_t>(value);
return true;
}
size_t name_count = 0;
const NamedPacketType* names = namedPacketTypes(&name_count);
for (size_t i = 0; i < name_count; ++i) {
if (tokenEquals(begin, len, names[i].name)) {
*type_out = names[i].type;
return true;
}
}
return false;
}
// Parse an allowlist value. Empty input means "all" so WebConfig can clear a
// field and retain the same backwards-compatible default as an older
// /mqtt_prefs file. Keywords and type names are deliberately lowercase; "all"
// and "none" cannot be mixed into a list. Entries may be decimal (0..15) or
// named, may carry surrounding ASCII whitespace, and may be repeated.
inline bool parse(const char* input, uint16_t* mask_out) {
if (input == nullptr || mask_out == nullptr) return false;
const char* begin = input;
while (*begin && isAsciiSpace(*begin)) begin++;
const char* end = begin + strlen(begin);
while (end > begin && isAsciiSpace(end[-1])) end--;
const size_t len = static_cast<size_t>(end - begin);
if (len == 0 || tokenEquals(begin, len, "all")) {
*mask_out = kAllPacketTypes;
return true;
}
if (tokenEquals(begin, len, "none")) {
*mask_out = 0;
return true;
}
uint16_t parsed = 0;
const char* cursor = begin;
for (;;) {
const char* comma = cursor;
while (comma < end && *comma != ',') comma++;
const char* token_begin = cursor;
const char* token_end = comma;
while (token_begin < token_end && isAsciiSpace(*token_begin)) token_begin++;
while (token_end > token_begin && isAsciiSpace(token_end[-1])) token_end--;
uint8_t type = 0;
if (!resolveToken(token_begin, static_cast<size_t>(token_end - token_begin), &type)) {
return false;
}
parsed |= static_cast<uint16_t>(1u << type);
if (comma >= end) break;
cursor = comma + 1;
}
*mask_out = parsed;
return true;
}
// Format masks deterministically for CLI/API output. Subsets are emitted in
// ascending order; the two useful extremes use concise keywords.
inline bool format(uint16_t mask, char* output, size_t output_size) {
if (output == nullptr || output_size == 0) return false;
output[0] = '\0';
const char* keyword = nullptr;
if (mask == kAllPacketTypes) keyword = "all";
else if (mask == 0) keyword = "none";
if (keyword != nullptr) {
const size_t len = strlen(keyword);
if (output_size <= len) return false;
memcpy(output, keyword, len + 1);
return true;
}
char formatted[kFilterTextSize];
size_t pos = 0;
bool first = true;
for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) {
if ((mask & static_cast<uint16_t>(1u << type)) == 0) continue;
if (!first) formatted[pos++] = ',';
if (type >= 10) formatted[pos++] = '1';
formatted[pos++] = static_cast<char>('0' + (type % 10));
first = false;
}
formatted[pos] = '\0';
if (output_size <= pos) return false;
memcpy(output, formatted, pos + 1);
return true;
}
// The keyword shortcut above means format() never actually emits the whole
// 0..15 list, but the emit loop is written for every type, so the internal
// buffer must still hold it: 22 digits + 15 separators + NUL.
static_assert(kFilterTextSize >= 38, "filter text buffer must hold the full 0..15 list");
inline bool allows(uint16_t mask, uint8_t packet_type) {
return packet_type <= kMaxPacketType &&
(mask & static_cast<uint16_t>(1u << packet_type)) != 0;
}
// How many types a mask allows. Lets a length-constrained reply fall back to
// "N/16" instead of a clipped list, which would read as a different allowlist.
inline uint8_t countTypes(uint16_t mask) {
uint8_t total = 0;
for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) {
if ((mask & static_cast<uint16_t>(1u << type)) != 0) total++;
}
return total;
}
// Conservative "could any configured broker want this type?" mask, used to
// reject a packet before it is copied into the publish queue. Disabled slots
// contribute nothing; a slot whose topic style later turns out not to support
// the publication is still counted here, so the gate never drops a packet the
// per-slot pass would have published.
inline uint16_t enabledUnion(const uint16_t* masks, const bool* enabled, size_t count) {
if (masks == nullptr) return 0;
uint16_t combined = 0;
for (size_t i = 0; i < count; ++i) {
if (enabled != nullptr && !enabled[i]) continue;
combined = static_cast<uint16_t>(combined | masks[i]);
}
return combined;
}
// True when every slot still carries the default all-types mask, i.e. nothing
// depends on the packet-filter tail of /mqtt_prefs being written.
inline bool allMasksDefault(const uint16_t* masks, size_t count) {
if (masks == nullptr) return true;
for (size_t i = 0; i < count; ++i) {
if (masks[i] != kAllPacketTypes) return false;
}
return true;
}
// Stage one of the publish gate: everything decidable before a packet is
// serialised or a topic is built. Cheap enough to run per packet per slot.
inline bool slotCandidate(bool slot_enabled, uint16_t mask, uint8_t packet_type) {
return slot_enabled && allows(mask, packet_type);
}
// The complete gate, once the slot's topic support is known. Eligibility
// deliberately excludes connection state: a temporarily disconnected broker
// that is configured for this type still requires the shared queue's existing
// bounded retry policy.
//
// eligiblePacketSlots() calls this directly, using slotCandidate() first only
// to skip the topic build for slots the mask already rejects. A slot that
// passes both therefore has its topic built again in the publish loop; that
// second build is the accepted cost of not carrying six 128-byte topics on the
// MQTT task stack.
inline bool slotEligible(bool slot_enabled, bool topic_supported,
uint16_t mask, uint8_t packet_type) {
return slotCandidate(slot_enabled, mask, packet_type) && topic_supported;
}
// A fully filtered/topic-incompatible packet is intentionally complete. Once
// any eligible target exists, at least one actual publish must succeed.
inline bool publishComplete(bool has_eligible_target, bool any_publish_succeeded) {
return any_publish_succeeded || !has_eligible_target;
}
} // namespace MQTTPacketFilter
+83 -20
View File
@@ -186,47 +186,110 @@ int MQTTPayloadBuilder::buildRawMessage(
return serializeComplete(root, buffer, buffer_size);
}
static JsonArray buildNeighborsMessageBase(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors,
int queried_neighbors,
bool truncated
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
if (total_neighbors >= 0) {
root["total_neighbors"] = total_neighbors;
root["queried_neighbors"] = queried_neighbors >= 0 ? queried_neighbors : total_neighbors;
root["truncated"] = truncated;
}
JsonObject self = root["self"].to<JsonObject>();
self["scopes"] = self_scopes ? self_scopes : "";
self["default_scope"] = self_default_scope ? self_default_scope : "";
return root["neighbors"].to<JsonArray>();
}
static void addNeighborsMessageEntry(
JsonArray& arr,
const MQTTPayloadBuilder::NeighborsMessageEntry& neighbor
) {
JsonObject nb = arr.add<JsonObject>();
nb["pubkey"] = neighbor.pubkey_hex;
nb["snr"] = neighbor.snr;
if (neighbor.heard_unknown) {
nb["heard_secs_ago"] = nullptr; // age unknown, not zero
} else {
nb["heard_secs_ago"] = neighbor.heard_secs_ago;
}
nb["scopes"] = neighbor.scopes ? neighbor.scopes : "";
nb["status"] = neighbor.status;
}
size_t MQTTPayloadBuilder::measureNeighborsMessageBase(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors
) {
JsonDocument doc;
buildNeighborsMessageBase(
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
total_neighbors, total_neighbors, false);
return measureJson(doc);
}
size_t MQTTPayloadBuilder::measureNeighborsMessageEntry(
const NeighborsMessageEntry& neighbor
) {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
addNeighborsMessageEntry(arr, neighbor);
JsonObject entry = arr[0];
return measureJson(entry);
}
int MQTTPayloadBuilder::buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
size_t buffer_size,
int total_neighbors,
int queried_neighbors,
bool truncated
) {
if (!buffer || buffer_size == 0) return 0;
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
JsonObject self = root["self"].to<JsonObject>();
self["scopes"] = self_scopes ? self_scopes : "";
JsonArray arr = root["neighbors"].to<JsonArray>();
if (measureJson(root) >= buffer_size) return 0;
JsonArray arr = buildNeighborsMessageBase(
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
total_neighbors, queried_neighbors, truncated);
if (doc.overflowed() || arr.isNull() || measureJson(doc) >= buffer_size) return 0;
for (int i = 0; i < neighbor_count; i++) {
JsonObject nb = arr.add<JsonObject>();
nb["pubkey"] = neighbors[i].pubkey_hex;
nb["snr"] = neighbors[i].snr;
nb["heard_secs_ago"] = neighbors[i].heard_secs_ago;
nb["scopes"] = neighbors[i].scopes ? neighbors[i].scopes : "";
nb["status"] = neighbors[i].status;
addNeighborsMessageEntry(arr, neighbors[i]);
if (doc.overflowed()) return 0;
// Entries arrive ordered most- to least-useful. Stop as soon as the next
// one would fill the fixed publish buffer, dropping the remaining tail so
// document growth stays bounded.
if (measureJson(root) >= buffer_size) {
if (measureJson(doc) >= buffer_size) {
arr.remove(arr.size() - 1);
if (total_neighbors >= 0) doc["truncated"] = true;
break;
}
}
return serializeComplete(root, buffer, buffer_size);
return serializeComplete(doc.as<JsonObject>(), buffer, buffer_size);
}
+23 -1
View File
@@ -75,6 +75,11 @@ public:
uint32_t heard_secs_ago;
const char* scopes;
const char* status;
// True renders heard_secs_ago as JSON null, for a neighbour whose stored
// stamp cannot yield an age. No default initializer: the struct stays an
// aggregate for the device toolchain, and a zeroed tail means "age known",
// so size-measurement callers keep reserving the widest numeric value.
bool heard_unknown;
};
// Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic.
@@ -86,9 +91,26 @@ public:
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
size_t buffer_size,
int total_neighbors = -1,
int queried_neighbors = -1,
bool truncated = false
);
// Exact serialized-size components used by paced neighbor discovery. The
// base reserves the largest progress metadata values for this snapshot;
// callers add each entry size plus one byte for commas after the first.
static size_t measureNeighborsMessageBase(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors
);
static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor);
};
+52 -9
View File
@@ -2,6 +2,7 @@
#include <string.h>
#include "MQTTPacketFilter.h"
#include "MQTTPrefsStorage.h"
#ifdef WITH_MQTT_BRIDGE
@@ -37,23 +38,44 @@ struct DecodePlan {
static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE;
static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE;
static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE;
static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE;
static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize;
inline MQTTPrefsHeader makeHeader() {
// Shortest payload length that still round-trips this configuration.
//
// The packet-filter tail is the only optional part of the current layout, and
// its default (all types) is exactly what a pre-filter decoder supplies for a
// missing tail. So a device whose filters are all default keeps writing the
// 2864-byte payload that pre-filter firmware can still read. That matters
// because /mqtt_prefs also carries the WiFi credentials: an unrecognised
// longer payload sends older firmware to defaults with no network, and it
// refuses to overwrite the file, so the node cannot be recovered over the air.
// Touching any filter opts that node into the longer payload -- a deliberate,
// operator-initiated trade rather than a side effect of upgrading.
inline size_t payloadLenFor(const MQTTPrefs& prefs) {
return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter,
MQTT_PREFS_SLOT_COUNT)
? kV1PreFilterPayloadSize
: kV1BaselinePayloadSize;
}
inline MQTTPrefsHeader makeHeader(size_t payload_len) {
MQTTPrefsHeader header;
memcpy(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic));
header.version = MQTT_PREFS_VERSION;
header.payload_len = static_cast<uint16_t>(kV1BaselinePayloadSize);
header.payload_len = static_cast<uint16_t>(payload_len);
return header;
}
inline size_t encode(const MQTTPrefs& prefs, uint8_t* output, size_t output_size) {
if (output == nullptr || output_size < kEncodedSize) return 0;
const MQTTPrefsHeader header = makeHeader();
const size_t payload_len = payloadLenFor(prefs);
const size_t encoded_size = sizeof(MQTTPrefsHeader) + payload_len;
if (output == nullptr || output_size < encoded_size) return 0;
const MQTTPrefsHeader header = makeHeader(payload_len);
memcpy(output, &header, sizeof(header));
memcpy(output + sizeof(header), &prefs, sizeof(prefs));
return kEncodedSize;
memcpy(output + sizeof(header), &prefs, payload_len);
return encoded_size;
}
inline bool isMagicPrefix(const uint8_t* input, size_t available) {
@@ -92,14 +114,35 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil
if (header.payload_len != payload_available) {
return corruptPlan();
}
// A same-version append is still unknown to this binary. Holding the
// file prevents a downgrade from discarding it on the next CLI save.
// A longer same-version payload was written by a later build that
// appended fields. Within a version tag the layout is append-only, so
// every byte this binary knows is present and correctly positioned -- read
// the baseline prefix and ignore the tail.
//
// This is the downgrade contract, and it is deliberately asymmetric:
// refusing the file would cost the operator WiFi credentials and every
// broker slot (a node with no network and no portal, recoverable only
// over serial), whereas reading it costs only the settings the newer
// build added. Losing a later feature's settings is the acceptable half.
//
// The tail survives until something actually writes: saveMQTTPrefs()
// rewrites at this binary's own length, so a rollback that changes no
// observer setting and is later rolled forward keeps the newer fields
// intact. Only an explicit `set` while downgraded drops them.
//
// A layout change that is NOT a pure append must bump MQTT_PREFS_VERSION;
// the version check above is what makes this rule safe.
if (header.payload_len > kV1BaselinePayloadSize) {
return {Source::UnsupportedVersion, false, true, false, 0};
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
}
if (header.payload_len == kV1BaselinePayloadSize) {
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
}
if (header.payload_len == kV1PreFilterPayloadSize) {
// Written before the per-slot packet-filter tail. Defaults supply an
// all-types mask for every slot.
return {Source::Current, false, false, true, kV1PreFilterPayloadSize};
}
if (header.payload_len == kV1PreNeighborsPayloadSize) {
// Written by observer/webconfig firmware before the neighbors tail
// existed. The observer fields ARE present; only the neighbors tail is
+25 -4
View File
@@ -117,6 +117,11 @@ struct MQTTPrefs {
// interchangeable (see the offsetof static_asserts below).
uint8_t mqtt_neighbors_enabled;
uint32_t mqtt_neighbors_interval;
// Per-slot payload-type allow masks. Bit N controls MeshCore packet type N
// for both packets and raw MQTT topics. Appended so older v1 payloads load
// with the default all-types masks intact.
uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT];
};
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
@@ -129,14 +134,28 @@ static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERV
static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL;
static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL;
// Version-1 has three payload layouts this firmware can decode. Never infer a
// compatible payload from an arbitrary shorter size: raw prefs have no checksum.
// Version-1 has four payload layouts this firmware can decode. Never infer a
// compatible payload from an arbitrary SHORTER size: raw prefs have no
// checksum, so a short length has to match a boundary that was really shipped.
//
// A LONGER v1 payload is different and is always readable: within a version tag
// the layout is append-only, so a later build's file still starts with this
// binary's exact baseline. classify() reads that prefix and ignores the tail
// rather than rejecting the file -- see the downgrade contract there. Any change
// that is not a pure append MUST bump MQTT_PREFS_VERSION instead.
// - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*).
// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet.
// - FULL (2864): current baseline, with the neighbors tail.
// - PRE_FILTER (2864): neighbors tail, no per-slot packet filters.
// - FULL (2876): current baseline, with six uint16_t filter masks.
//
// FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor()
// keeps emitting PRE_FILTER while every slot holds the all-types default, so a
// node that never touches a filter stays readable by pre-filter firmware. See
// the rollback note there -- /mqtt_prefs also carries the WiFi credentials.
static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736;
static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860;
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2864;
static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864;
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2876;
// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files
// are deployed legacy layouts and continue to be distinguished by size.
@@ -266,6 +285,8 @@ static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857,
"neighbors enable flag must sit at the flex-compatible offset");
static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE,
"neighbors interval offset must equal the pre-neighbors payload size");
static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE,
"packet filters must begin at the pre-filter payload boundary");
static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed");
static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed");
static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144,
+9 -3
View File
@@ -33,7 +33,7 @@ enum MQTTAuthType : uint8_t {
enum MQTTTopicStyle : uint8_t {
MQTT_TOPIC_MESHCORE, // meshcore/{iata}/{device_id}/{status|packets|raw}
MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/packets (packets only)
MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/{type} (no raw)
};
struct MQTTPresetDef {
@@ -79,7 +79,7 @@ static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) {
}
// Number of built-in presets
static const int MQTT_PRESET_COUNT = 29;
static const int MQTT_PRESET_COUNT = 34;
// Keep the certificate and preset tables in one translation unit. Defining
// these as header-local constants created a complete flash copy in every MQTT
@@ -165,13 +165,15 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "ctmesh", "mqtt://mqtt.ctmesh.org:1883", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, "meshdev", "large4cats"},
{ "chimesh", "wss://mqtt.chimesh.org:443", "mqtt.chimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshat.se", "wss://meshcore-mqtt.meshat.se:443", "meshcore-mqtt.meshat.se", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "eastidahomesh", "wss://broker.eastidahomesh.net:443", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "eastidahomesh", "mqtt://live.eastidahomesh.com:1883", nullptr, nullptr, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "coloradomesh", "wss://mqtt.meshcore.coloradomesh.org:443","mqtt.meshcore.coloradomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "dutchmeshcore-1", "wss://collector1.dutchmeshcore.nl:443/mqtt", "collector1.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "dutchmeshcore-2", "wss://collector2.dutchmeshcore.nl:443/mqtt", "collector2.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshcore-ca-1", "wss://mqtt1.meshcore.ca:443/mqtt", "mqtt1.meshcore.ca", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshcore-ca-2", "wss://mqtt2.meshcore.ca:443/mqtt", "mqtt2.meshcore.ca", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshcore-fi", "wss://mc-mqtt.meshcore.fi:443/", "mc-mqtt.meshcore.fi", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "okimesh-1", "wss://mqtt1.okimesh.org:9002/mqtt", "mqtt1.okimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "okimesh-2", "wss://mqtt2.okimesh.org:9002/mqtt", "mqtt2.okimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "inwmesh", "mqtts://scope.inwmesh.org:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "bostonmesh", "wss://mqttmc01.bostonme.sh:443/mqtt", "mqttmc01.bostonme.sh", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "rflab", "wss://mqtt.rflab.io:443", "mqtt.rflab.io", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
@@ -183,6 +185,10 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "mesh-chaun14", "mqtt://mqtt.mesh.chaun14.fr:1884", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, MQTT_USERPASS_USERNAME_PUBKEY, nullptr },
// LetsMesh-compatible JWT; TLS is Let's Encrypt (ISRG Root X1), not GTS.
{ "wcmesh", "wss://mqtt.wcmesh.com:443", "mqtt.wcmesh.com", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "atvirastinklas","wss://mqtt-mc.atvirastinklas.lt:443", "mqtt-mc.atvirastinklas.lt", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
// JWT token auth; LE Gen-Y ECDSA chain (YE2 -> Root YE -> X2) still anchors at ISRG Root X1.
{ "gomesh", "wss://mqtt.gomesh.dev:443", "mqtt.gomesh.dev", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "idahomesh", "wss://mqtt.idahomesh.org:443/mqtt", "mqtt.idahomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
};
#else
+9 -6
View File
@@ -45,11 +45,12 @@ static inline bool mqttWriteTopic(char* buf, size_t buf_size, const char* format
return written > 0 && (size_t)written < buf_size;
}
// Build the complete topic for one publication. MeshRank is deliberately
// packets-only; status, raw, and neighbors are unsupported by the current
// broker contract (the type != PACKETS guard below rejects them all).
// MeshCore routes require a configured IATA and device id. Custom templates may
// omit either placeholder, so their individual values are allowed to be empty.
// Build the complete topic for one publication. MeshRank takes status, packets,
// and neighbors under meshrank/uplink/{token}/{device}/, using the same type
// suffixes as the MeshCore layout, and requires a per-slot token rather than an
// IATA. MeshCore routes require a configured IATA and device id. Custom
// templates may omit either placeholder, so their individual values are allowed
// to be empty.
static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type,
const char* custom_template,
const char* iata, const char* device,
@@ -69,7 +70,9 @@ static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type
return mqttWriteTopic(buf, buf_size, "meshcore/%s/%s/%s", iata, device, type_name);
case MQTT_ROUTE_MESHRANK:
if (type != MQTT_PUBLICATION_PACKETS || !token || token[0] == '\0' ||
// Raw is deliberately withheld: highest-volume topic, and the broker does
// not consume it. observer-firmware still sends it -- keep this on merge.
if (type == MQTT_PUBLICATION_RAW || !token || token[0] == '\0' ||
!device || device[0] == '\0') {
return false;
}
+105
View File
@@ -1,5 +1,6 @@
#pragma once
#include <stddef.h> // size_t / NULL for the reply classifiers below
#include <stdint.h>
// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot /
@@ -193,6 +194,110 @@ static inline uint32_t confirmSetupHandoffRebootAt(uint32_t now) {
return scheduleAt(now, kSetupHandoffRebootConfirmMs);
}
// --------------------------------------------------------------------------
// CLI sequences (/api/cli). The terminal shares this one deferred-command slot
// with config saves rather than owning a second MAX_BATCH array: both drain on
// the loop task, both are single-slot, and a duplicate would cost ~8 KB of
// permanently resident RAM. Sharing also makes a save and a CLI run mutually
// exclusive, which they must be.
//
// Two things differ from a config save:
// 1. results stream. A save's results appear only when the whole batch is
// Done; a CLI read hands back whatever has executed so far, so a pasted
// sequence fills the terminal command by command.
// 2. the reboot is not requested by a `reboot` flag on the request but by the
// word `reboot` appearing in the sequence. It is deferred rather than
// executed, because Board::reboot() does not return and would take the node
// down before the client could read a single result.
// --------------------------------------------------------------------------
// Results returned by one read. Bounds the JSON document built on the
// async_tcp task; a longer sequence pages across successive reads.
static const int kCliResultPage = 8;
static inline int cliPageCount(int from, int produced, int page) {
const int pending = produced - from;
if (pending <= 0) return 0;
return pending > page ? page : pending;
}
// "done" means the client has been handed every result, not merely that
// execution finished: the last page may still be unread, and a client that
// stops polling at "done" would lose it.
static inline bool cliReadIsFinal(State state, int from, int page_count, int total) {
return state == State::Done && from + page_count >= total;
}
// A trailing `reboot` is withheld when any command in the sequence failed,
// exactly as a config save's is. The operator asked for the reboot, but
// rebooting into a half-applied config -- over a link they may not get back --
// is the worse failure, and the result body reports the refusal.
static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) {
return has_reboot && all_ok;
}
// CommonCLI has no single failure convention. Testing only for an "Err" prefix
// let five other shapes through as success -- including "Unknown command" and
// "unknown config: x", the two an operator hits most -- which coloured them
// green AND let a queued reboot proceed after them.
//
// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp.
// This list is the fragile part of the design: a new failure string added there
// is silently a success here. Which is exactly why it must not be what decides
// whether to reboot -- see cliReplyConfirmsWrite.
static inline bool cliReplyIsFailure(const char* r) {
if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK"
static const char* const kPrefixes[] = {
"Err", "ERR", "err", // "Err - ", "ERR: ", "Error: "
"(ERR", // "(ERR: clock cannot go backwards)"
"Unknown command",
"unknown config",
"??", // "??: <key>" from the get fallthrough
"Can't find", // "Can't find GPS"
};
for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) {
const char* p = kPrefixes[i];
size_t n = 0;
while (p[n]) n++;
bool match = true;
for (size_t j = 0; j < n; j++) {
if (r[j] != p[j]) { match = false; break; }
}
if (match) return true;
}
// "File system erase: Err" reports the failure at the END of the reply.
for (size_t i = 0; r[i]; i++) {
if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' &&
r[i + 4] == 'r') return true;
}
return false;
}
// Whether a command's reply is allowed to gate the deferred reboot.
//
// Only writes are, and only writes have a reply convention worth trusting:
// every setter answers with an "OK" prefix. Diagnostics do not -- `memory`
// answers "Free: ...", a getter answers "> value" -- so letting them gate would
// mean guessing, and guessing wrong here either strands the operator (a
// harmless `memory` blocks their reboot) or reboots into a config that did not
// apply. The question the gate exists to answer is narrower than "did anything
// fail": it is "did every setting I asked for actually take".
static inline bool cliReplyGatesReboot(const char* cmd) {
if (cmd == NULL) return false;
const char* set = "set ";
const char* pwd = "password ";
bool is_set = true, is_pwd = true;
for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; }
for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; }
return is_set || is_pwd;
}
// A write took effect iff its reply starts with "OK" -- the one convention every
// setter in CommonCLI actually keeps.
static inline bool cliWriteSucceeded(const char* reply) {
return reply != NULL && reply[0] == 'O' && reply[1] == 'K';
}
// --------------------------------------------------------------------------
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
// --------------------------------------------------------------------------
+23
View File
@@ -31,6 +31,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
};
static const char* const WC_ALLOWED_SLOT_KEYS[] = {
"preset", "server", "port", "username", "password", "token", "topic", "audience",
"filter",
};
// True when `key` is a well-formed per-slot key ("mqttN.<field>" with N in
@@ -82,6 +83,28 @@ static inline bool wcIsSecretKey(const char* key) {
return false;
}
// CommonCLI answers a secret getter in plaintext only for the serial console
// (sender_timestamp 0) and masks it for remote callers. The web CLI executes
// with sender_timestamp 0 -- that is what makes `erase`, `stats-*` and `set freq`
// reachable -- so it would otherwise inherit the serial console's plaintext
// answers for an HTTP request. This says which `get` commands must be masked
// back down, restoring the distinction for a caller not at the serial port.
//
// Writing these has always been possible from the portal; reading them never
// was, because handleConfigGet masks them (wcIsSecretKey). The two are different
// capabilities: replacing a WiFi password does not reveal the current one, and
// replacing an identity does not reveal the existing private key.
static inline bool wcIsSecretReadCommand(const char* cmd) {
if (strncmp(cmd, "get ", 4) != 0) return false;
const char* key = cmd + 4;
while (*key == ' ') key++;
if (strcmp(key, "prv.key") == 0) return true; // this node's identity
if (strcmp(key, "guest.password") == 0) return true;
if (strcmp(key, "alert.psk") == 0) return true;
if (strcmp(key, "bridge.secret") == 0) return true;
return wcIsSecretKey(key); // wifi.pwd, mqttN.password, mqttN.token
}
// Browser-generated request IDs are exactly eight random bytes encoded as
// hexadecimal. Keeping the grammar deliberately small makes the ID safe to
// echo in JSON/logs and prevents an empty or truncated ID from weakening the
+192 -39
View File
@@ -351,10 +351,17 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) {
// drops=<outbox>/<skipped>: outbox-cap drops vs. memory-pressure skips.
int pos = 0;
replyAppendf(buf, bufsize, &pos, "> Free=%d Max=%d q:%d/%d Outbox=%u drops=%lu/%d |",
replyAppendf(buf, bufsize, &pos, "> Free=%d Max=%d q:%d/%d Outbox=%u drops=%lu/%d",
(int)ESP.getFreeHeap(), (int)ESP.getMaxAllocHeap(),
q, MAX_QUEUE_SIZE, (unsigned)outbox_total,
outbox_drops, b->_skipped_publishes);
// filt=<n>: packets the per-slot type filters rejected before the queue.
// Omitted while zero so an unfiltered node's reply keeps its former length --
// the per-slot list below is what usually gets clamped away first.
if (b->_filtered_packets > 0) {
replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets);
}
replyAppendf(buf, bufsize, &pos, " |");
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (!b->_slots[i].enabled || !b->_slots[i].client) continue;
replyAppendf(buf, bufsize, &pos, " s%d=%lu/%lu", i + 1,
@@ -392,6 +399,9 @@ bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out)
out->has_publish_counts = true;
out->publish_ok = slot.client ? slot.client->getPublishOk() : 0;
out->publish_err = slot.client ? slot.client->getPublishErr() : 0;
// Lets the portal show why a healthy slot is quiet.
out->filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index]
: MQTTPacketFilter::kAllPacketTypes;
return true;
}
@@ -498,6 +508,17 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
// replyAppendf clamps pos on every call, so the chained appends below can't
// walk past the reply buffer even if the accumulated text exceeds it (A1).
// A non-default filter is the one healthy-looking reason for a slot to stop
// publishing, so it has to appear -- but appended last. replyAppendf clamps at
// the 160-byte reply, and an error tail (TLS + mbedTLS + errno + age) can
// already reach ~117 chars, so putting the filter first would push the
// operator's diagnostic detail off the end of a failing slot's line.
const uint16_t filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index]
: MQTTPacketFilter::kAllPacketTypes;
char filter_text[MQTTPacketFilter::kFilterTextSize];
const bool show_filter = filter_mask != MQTTPacketFilter::kAllPacketTypes &&
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
int pos = 0;
replyAppendf(buf, bufsize, &pos, "> mqtt%d: %s", slot_index + 1, state);
if (slot.disconnect_count > 0) {
@@ -508,14 +529,10 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
}
}
// If connected with no errors, we're done
// Connected with no errors: nothing more to say about the connection.
if (slot.connected && slot.last_error_time == 0) {
replyAppendf(buf, bufsize, &pos, ", no errors");
return;
}
// Show last error if we have one
if (slot.last_error_time > 0) {
} else if (slot.last_error_time > 0) {
// TLS error with human-friendly description
if (slot.last_tls_err != 0) {
const char* desc = tlsErrorStr(slot.last_tls_err);
@@ -545,6 +562,26 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
} else if (!slot.connected) {
replyAppendf(buf, bufsize, &pos, ", no error info");
}
// Appended last so it never displaces connection diagnostics. replyAppendf
// clamps rather than overflows, but a clipped type list is worse than no
// list: "...,13,14," parses as a real, different allowlist, and this is the
// one line an operator reads to find out why a slot is quiet. So the exact
// text is only emitted when it fits whole; otherwise fall back to a count,
// which cannot be misread. `get mqttN.filter` always has the exact value.
if (show_filter) {
static const int kFilterLabelLen = (int)sizeof(", filter:") - 1;
const int remaining = pos < (int)bufsize ? (int)bufsize - 1 - pos : 0;
const uint8_t allowed = MQTTPacketFilter::countTypes(filter_mask);
const int compact_len = kFilterLabelLen + (allowed >= 10 ? 5 : 4); // "N/16"
if (kFilterLabelLen + (int)strlen(filter_text) <= remaining) {
replyAppendf(buf, bufsize, &pos, ", filter:%s", filter_text);
} else if (compact_len <= remaining) {
replyAppendf(buf, bufsize, &pos, ", filter:%u/16", (unsigned)allowed);
}
// Neither fits: the slot is reporting so much error detail that the filter
// is the least useful field on the line. Omit it rather than mislead.
}
}
// Bounded cooperative-stop timeout for end() (see MQTTLifecycle::Coordinator).
@@ -718,18 +755,23 @@ void MQTTBridge::allocateRuntimeBuffers() {
_publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc));
_status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
_status_json_buffer, STATUS_JSON_BUFFER_SIZE, psram_malloc));
#if defined(WITH_MQTT_NEIGHBORS)
// Persistent neighbors JSON buffer. Unlike status/packet there is no stack
// fallback: the feature is PSRAM-gated, so a nullptr simply disables publishing
// (requestPublishNeighbors/publishNeighbors both no-op on nullptr).
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
_neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc));
#endif
MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s",
_last_raw_data ? "PSRAM" : "unavailable",
_publish_json_buffer ? "PSRAM" : "stack fallback",
_status_json_buffer ? "PSRAM" : "stack fallback");
#endif
#if defined(WITH_MQTT_NEIGHBORS)
// Persistent neighbors JSON buffer, heap-allocated on every board: too large to
// keep inline in the bridge object the way the non-PSRAM status/packet buffers
// are. psram_malloc() falls back to internal DRAM, so this works without PSRAM.
// Unlike status/packet there is no stack fallback -- a nullptr simply disables
// publishing (requestPublishNeighbors/publishNeighbors both no-op on nullptr).
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
_neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc));
MQTT_DEBUG_PRINTLN("Neighbors buffer: %s",
_neighbors_json_buffer ? "ready" : "unavailable");
#endif
}
void MQTTBridge::releaseRuntimeBuffers() {
@@ -740,13 +782,15 @@ void MQTTBridge::releaseRuntimeBuffers() {
_publish_json_buffer, psram_free));
_status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
_status_json_buffer, psram_free));
#endif
#if defined(WITH_MQTT_NEIGHBORS)
// Paired with the unconditional allocation in allocateRuntimeBuffers().
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
_neighbors_json_buffer, psram_free));
_neighbors_publish_len = 0;
_neighbors_publish_pending.store(false, std::memory_order_release);
#endif
#endif
// Never pair a newly allocated raw buffer with metadata from a prior bridge
// run. This also makes non-PSRAM restarts discard their stale raw cache.
@@ -2322,7 +2366,7 @@ void MQTTBridge::publishStatusToSlot(int index) {
// Build per-slot topic (handles IATA check for meshcore, token check for meshrank)
char status_topic[128];
if (!buildTopicForSlot(index, MSG_STATUS, status_topic, sizeof(status_topic))) {
return; // Slot doesn't support status (e.g., meshrank) or missing required config
return; // Slot is missing required topic configuration
}
// Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure.
@@ -2804,15 +2848,16 @@ void MQTTBridge::loop() {
void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
if (!_initialized || !_obs->mqtt_packets_enabled || !_obs->mqtt_rx_enabled) return;
// Check if we have any enabled slots to send to
bool has_valid_slots = false;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].client) {
has_valid_slots = true;
break;
}
// Drop before the queue copy when no configured slot allows this payload
// type. A QueuedPacket carries the packet plus up to 256 bytes of raw radio
// data and crosses to Core 0, so filtering here (not at publish time) is
// what actually saves the queue slot and the memcpy. This also subsumes the
// older "is any slot configured at all?" pre-check.
bool filtered = false;
if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) {
if (filtered) _filtered_packets++;
return;
}
if (!has_valid_slots) return;
// Queue packet for transmission
queuePacket(packet, false);
@@ -2830,6 +2875,13 @@ void MQTTBridge::sendPacket(mesh::Packet *packet) {
if (!_identity || memcmp(_identity->pub_key, packet->payload, PUB_KEY_SIZE) != 0) return;
}
// Same pre-queue filter gate as the RX path.
bool filtered = false;
if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) {
if (filtered) _filtered_packets++;
return;
}
// Queue packet for transmission
queuePacket(packet, true);
}
@@ -2921,7 +2973,9 @@ void MQTTBridge::processPacketQueue() {
_last_raw_timestamp = millis();
}
bool packet_eligible = false;
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
packet_eligible,
queued.has_raw_data ? queued.raw_data : nullptr,
queued.has_raw_data ? queued.raw_len : 0,
queued.snr, queued.rssi);
@@ -2929,15 +2983,21 @@ void MQTTBridge::processPacketQueue() {
// Publish raw if enabled (live from prefs so `set mqtt.raw` applies without
// a bridge restart)
bool raw_eligible = false;
bool raw_published = false;
if (_obs->mqtt_raw_enabled) {
raw_published = publishRaw(&queued.packet_copy);
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
}
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
// Decide intentional completion once across the entire queue item. An
// ineligible raw path (for example, MeshRank, which does not take raw)
// must not hide a failed eligible structured publish.
const bool queue_complete = MQTTPacketFilter::publishComplete(
packet_eligible || raw_eligible,
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
const MQTTPacketQueuePolicy::RetryDecision retry =
MQTTPacketQueuePolicy::retryDecision(
any_published, queued.retry_attempts,
queue_complete, queued.retry_attempts,
static_cast<uint32_t>(now_ms));
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
queued.retry_attempts = retry.retry_attempts;
@@ -3048,22 +3108,30 @@ void MQTTBridge::processPacketQueue() {
_last_raw_timestamp = millis();
}
bool packet_eligible = false;
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
packet_eligible,
queued.has_raw_data ? queued.raw_data : nullptr,
queued.has_raw_data ? queued.raw_len : 0,
queued.snr, queued.rssi);
// No taskYIELD() on non-ESP32 platforms (non-FreeRTOS, cooperative scheduling not needed)
// Live from prefs so `set mqtt.raw` applies without a bridge restart.
bool raw_eligible = false;
bool raw_published = false;
if (_obs->mqtt_raw_enabled) {
raw_published = publishRaw(&queued.packet_copy);
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
}
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
// Decide intentional completion once across the entire queue item. An
// ineligible raw path (for example, MeshRank, which does not take raw)
// must not hide a failed eligible structured publish.
const bool queue_complete = MQTTPacketFilter::publishComplete(
packet_eligible || raw_eligible,
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
const MQTTPacketQueuePolicy::RetryDecision retry =
MQTTPacketQueuePolicy::retryDecision(
any_published, queued.retry_attempts,
queue_complete, queued.retry_attempts,
static_cast<uint32_t>(now_ms));
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
queued.retry_attempts = retry.retry_attempts;
@@ -3110,6 +3178,69 @@ void MQTTBridge::processPacketQueue() {
// Publishing
// ---------------------------------------------------------------------------
// Which slots will actually take this payload type, resolved in two stages so
// the cheap test runs first: the filter/enabled check costs a bit test, while
// the topic build costs an snprintf. Both are far cheaper than the ~2 KB JSON
// serialisation they gate, so this must complete before a caller builds a
// message -- a slot that cannot form a topic (MeshRank raw, or a meshcore
// preset with no IATA set) would otherwise pay for a document nobody receives.
//
// A slot's connection state is deliberately not consulted: a configured but
// disconnected broker is still a target, so the packet stays on the queue for
// the existing bounded retry rather than being dropped as complete.
uint8_t MQTTBridge::eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type) {
static_assert(RUNTIME_MQTT_SLOTS <= 8, "eligible slot mask must fit in uint8_t");
// Per-slot filters cover packet traffic only. Status and neighbors are
// documented as never filtered, so refuse those publication types here
// rather than silently applying a packet mask if a caller is added later.
if (!_obs || (type != MSG_PACKETS && type != MSG_RAW)) return 0;
uint8_t eligible_slots = 0;
char topic[128];
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
const bool slot_enabled = _slots[i].enabled && _slots[i].client != nullptr;
// Load once so a live CLI/WebConfig update cannot split this packet's
// decision across two different masks.
const uint16_t filter_mask = _obs->mqtt_slot_packet_filter[i];
if (!MQTTPacketFilter::slotCandidate(slot_enabled, filter_mask, packet_type)) continue;
const bool topic_supported = buildTopicForSlot(i, type, topic, sizeof(topic));
if (MQTTPacketFilter::slotEligible(slot_enabled, topic_supported,
filter_mask, packet_type)) {
eligible_slots |= static_cast<uint8_t>(1u << i);
}
}
return eligible_slots;
}
// Conservative pre-queue gate: the OR of every configured slot's mask. Runs on
// Core 1 in the radio callback, so it must stay cheap -- a packet no broker
// wants is rejected before it is copied into the queue at all. `filtered`
// separates "a broker is configured but none wants this type" (worth counting)
// from "no broker configured at all" (the pre-existing silent drop).
bool MQTTBridge::shouldQueuePacketType(uint8_t packet_type, bool& filtered) {
filtered = false;
if (!_obs) return false;
uint16_t masks[RUNTIME_MQTT_SLOTS];
bool enabled[RUNTIME_MQTT_SLOTS];
bool any_enabled = false;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
masks[i] = _obs->mqtt_slot_packet_filter[i];
enabled[i] = _slots[i].enabled && _slots[i].client != nullptr;
any_enabled = any_enabled || enabled[i];
}
if (!any_enabled) return false;
if (MQTTPacketFilter::allows(
MQTTPacketFilter::enabledUnion(masks, enabled, RUNTIME_MQTT_SLOTS),
packet_type)) {
return true;
}
filtered = true;
return false;
}
bool MQTTBridge::publishStatus() {
if (!_cached_has_connected_slots) {
return false;
@@ -3200,8 +3331,8 @@ bool MQTTBridge::publishStatus() {
}
}
}
// If no connected slot accepts status topics (e.g. meshrank is packets-only),
// treat as success to avoid infinite retry loops
// If no connected slot accepts status topics, treat as success to avoid
// infinite retry loops
if (published || !any_slot_wants_status) {
if (published) MQTT_DEBUG_PRINTLN("Status published");
return true;
@@ -3212,10 +3343,19 @@ bool MQTTBridge::publishStatus() {
}
bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
bool& has_eligible_target,
const uint8_t* raw_data, int raw_len,
float snr, float rssi) {
has_eligible_target = false;
if (!packet) return false;
const uint8_t packet_type = packet->getPayloadType();
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_PACKETS);
has_eligible_target = eligible_slots != 0;
// Filtered out everywhere, or no slot can form a packets topic: intentionally
// complete, and nothing below (JSON build included) is worth paying for.
if (!has_eligible_target) return false;
refreshOriginFromPrefs();
// Memory pressure check: Skip publishes when there's not enough contiguous
@@ -3321,7 +3461,11 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
bool published = false;
char topic[128];
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
// Eligibility already proved this slot's topic builds; rebuilding it is
// one snprintf, cheaper than carrying a RUNTIME_MQTT_SLOTS x 128 topic
// cache on the MQTT task's 8 KB stack alongside the JSON buffer.
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) {
if (publishToSlot(i, topic, active_buffer, false)) {
published = true;
@@ -3331,7 +3475,6 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
}
return published;
} else {
uint8_t packet_type = packet->getPayloadType();
if (packet_type == 4 || packet_type == 9) {
MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len);
}
@@ -3339,9 +3482,17 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
return false;
}
bool MQTTBridge::publishRaw(mesh::Packet* packet) {
bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) {
has_eligible_target = false;
if (!packet) return false;
const uint8_t packet_type = packet->getPayloadType();
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_RAW);
has_eligible_target = eligible_slots != 0;
// Filtered out everywhere, or no slot has a raw topic (MeshRank does not take
// raw): intentionally complete, and no JSON is built.
if (!has_eligible_target) return false;
refreshOriginFromPrefs();
#if defined(BOARD_HAS_PSRAM)
@@ -3372,7 +3523,8 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet) {
bool published = false;
char topic[128];
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) {
if (publishToSlot(i, topic, active_buffer, false)) {
published = true;
@@ -3419,11 +3571,12 @@ bool MQTTBridge::publishNeighbors() {
char topic[128];
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
// MeshRank slots reject non-packets by contract, so buildTopicForSlot
// returns false for them here and the slot is skipped.
// Slots that cannot form a neighbors topic are skipped here.
if (buildTopicForSlot(i, MSG_NEIGHBORS, topic, sizeof(topic))) {
// Neighbor snapshots are periodically refreshed. Publish synchronously
// at QoS 0 to avoid the QoS 1 outbox, retaining where the broker allows.
bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false;
if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 1)) {
if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 0)) {
published = true;
}
}
+52 -7
View File
@@ -10,6 +10,7 @@
#include <WiFiUdp.h>
#include <Timezone.h>
#include "helpers/JWTHelper.h"
#include "helpers/MQTTPacketFilter.h"
#include "helpers/MQTTPresets.h"
#include "helpers/MQTTLifecycle.h"
#include <atomic>
@@ -52,11 +53,15 @@ struct MQTTNodeInfo {
bool repeat_when_nonzero = true;
};
// Periodic neighbors publication is PSRAM-only: it needs a persistent ~10 KB JSON
// buffer plus a second transient one while the mesh builds the table, and it keys
// off the mesh neighbor cache (sized by MAX_NEIGHBOURS). Every neighbors-specific
// member, method, and code block in this bridge is gated on WITH_MQTT_NEIGHBORS.
#if defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0
// Periodic neighbors publication keys off the mesh neighbor cache (sized by
// MAX_NEIGHBOURS) and needs a persistent JSON buffer plus a second transient one
// while the mesh builds the table. PSRAM boards get it automatically; non-PSRAM
// boards must opt in per variant with MQTT_NEIGHBORS_WITHOUT_PSRAM, which spends
// internal DRAM the TLS stack also needs (see NEIGHBORS_JSON_BUFFER_SIZE). Every
// neighbors-specific member, method, and code block here is gated on
// WITH_MQTT_NEIGHBORS.
#if defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0 && \
(defined(BOARD_HAS_PSRAM) || defined(MQTT_NEIGHBORS_WITHOUT_PSRAM))
#define WITH_MQTT_NEIGHBORS 1
#endif
@@ -349,6 +354,10 @@ private:
unsigned long _last_memory_check;
bool _memory_pressure = false; // Cached max-alloc verdict; re-sampled at most once per interval in publishPacket() so the heap walk isn't paid per-packet under pressure
int _skipped_publishes; // Exposed via SNMP; count of publishes skipped when max_alloc is too low
// Packets rejected by the per-slot filters before reaching the queue. Written
// on Core 1 (radio callbacks), read on Core 0 for `mqtt.stats`; a torn read of
// a diagnostic counter is harmless, so no atomic is warranted.
unsigned long _filtered_packets = 0;
// Status publish retry tracking
unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish)
@@ -393,6 +402,8 @@ private:
enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW, MSG_NEIGHBORS };
bool buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size);
bool substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size);
uint8_t eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type);
bool shouldQueuePacketType(uint8_t packet_type, bool& filtered);
// Internal methods - slot management
// Lifetime model (Phase 1 of MQTT memory-defrag):
@@ -428,10 +439,10 @@ private:
void mqttTaskLoop(); // Main loop for MQTT task
void initializeWiFiInTask(); // WiFi initialization moved to task
#endif
bool publishPacket(mesh::Packet* packet, bool is_tx,
bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target,
const uint8_t* raw_data = nullptr, int raw_len = 0,
float snr = 0.0f, float rssi = 0.0f);
bool publishRaw(mesh::Packet* packet);
bool publishRaw(mesh::Packet* packet, bool& has_eligible_target);
#if defined(WITH_MQTT_NEIGHBORS)
// Publishes the pending _neighbors_json_buffer to every connected slot's
// neighbors topic. Runs on the MQTT task (Core 0) only.
@@ -537,7 +548,38 @@ public:
#if defined(WITH_MQTT_NEIGHBORS)
// Single source of truth for the neighbors JSON size, used by both the bridge's
// persistent buffer and the mesh's transient build buffer.
#if defined(BOARD_HAS_PSRAM)
static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240;
#else
// Without PSRAM all three neighbors allocations (persistent buffer, transient
// build buffer, ArduinoJson pool) come out of internal DRAM, which each TLS
// slot also needs ~40 KB of. A quarter-size buffer keeps the peak near 13 KB
// instead of ~35 KB; the builder drops the tail and sets "truncated" once the
// next entry will not fit, so the table degrades instead of failing.
static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 4096;
#endif
// The ArduinoJson pool is NOT bounded by the text buffer: v7 hands out pool
// blocks in fixed 4096-byte chunks, so a table that just fits the text buffer
// can still need well over it in pool. Budgeting the pool at the text size
// starves it, and a starved pool sets doc.overflowed() -- which drops the whole
// publish instead of truncating. Measured need is 12541 B for 50 entries and
// 4286 B for 21, so allow a spare block over each.
#if defined(BOARD_HAS_PSRAM)
static const size_t NEIGHBORS_DOC_POOL_BUDGET = 16384;
#else
static const size_t NEIGHBORS_DOC_POOL_BUDGET = 12288;
#endif
// Entries per publish. One 4096-byte pool block holds 21, so staying under
// that keeps the non-PSRAM pool to a single block (~4.3 KB rather than
// ~8.4 KB) and the whole publish peak near 13 KB of internal DRAM. PSRAM
// boards are limited only by the text buffer and the neighbour cache.
#if defined(BOARD_HAS_PSRAM)
static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = MAX_NEIGHBOURS;
#else
static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = 20;
#endif
// Called by the mesh (Core 1) once a neighbor-discovery pass has built the
// table JSON. Copies it into the persistent PSRAM buffer and raises the
@@ -629,6 +671,9 @@ public:
bool has_publish_counts;
unsigned long publish_ok;
unsigned long publish_err;
// Raw packet-type allowlist. Kept as the mask rather than canonical text so
// the 1 KB stats JSON stays compact; the portal renders it.
uint16_t filter_mask;
};
static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out);
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
File diff suppressed because it is too large. Load diff
+358 -90
View File
@@ -20,6 +20,7 @@
#ifdef WITH_MQTT_BRIDGE
#include <helpers/MQTTPrefs.h>
#include <helpers/MQTTPresets.h>
#include <helpers/MQTTPacketFilter.h>
#include <helpers/bridges/MQTTBridge.h>
#endif
@@ -58,6 +59,89 @@ static bool isSecretKey(const char* key) {
return key && wcIsSecretKey(key);
}
// Commands whose CLI handler never returns would take the node down mid-drain,
// before the client could read a single result. `reboot` is deferred instead:
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
// path once the operator has read the results. The rest (clkreboot, poweroff,
// ota update) do real work on the way down and cannot be faked, so they run
// normally and the connection drops -- the UI warns before sending them.
// CommonCLI dispatches on a 6-byte PREFIX (memcmp(command, "reboot", 6)), so
// `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly here
// let those through to the CLI, which took the node down mid-drain with no
// results and no deferral -- the precise failure the deferral exists to avoid.
// Whatever CommonCLI would treat as a reboot, this must intercept.
static inline bool wcIsDeferredReboot(const char* cmd) {
return strncmp(cmd, "reboot", 6) == 0;
}
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
// POST so nothing in the sequence runs, rather than failing halfway with a
// reply that does not explain itself. Returns the reason, or NULL if fine.
static const char* wcCliUnavailable(const char* cmd) {
// ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind
// check and answers "Started" regardless. The portal already holds port 80,
// so from here it can only leak the allocation, inhibit sleep, and lie.
if (strncmp(cmd, "start ota", 9) == 0) {
return "start ota needs port 80, which this portal is using. "
"Run it from the serial console, or use `ota update`.";
}
// `clock sync` sets the clock from the CALLER's timestamp. Web requests carry
// none (execCommand passes 0), so CommonCLI always rejects it as moving the
// clock backwards. `time <epoch>` is the one that works over this transport.
if (strncmp(cmd, "clock sync", 10) == 0) {
return "clock sync takes its time from the caller, which a web request has "
"no way to supply. Use `time <epoch-seconds>` instead.";
}
// Both write their real output to Serial and hand back a stub the terminal
// would render as success. Bare `log` also streams a whole file from the loop
// task, stalling the mesh and the radio while it does.
if (strcmp(cmd, "log") == 0) {
return "log writes the packet log to the serial console, not here, and "
"blocks the radio while it does. Use `log start` / `log stop`.";
}
if (strcmp(cmd, "get acl") == 0) {
return "get acl writes to the serial console, not here.";
}
return NULL;
}
// The `password` command echoes the new password back in its reply, and replies
// are served to the client over the open setup AP. The config path overwrites it
// by key; a CLI entry has no key, so match on the command itself.
static inline bool wcCliEchoesSecret(const char* cmd) {
return strncmp(cmd, "password ", 9) == 0;
}
// CommonCLI splits its surface by CALLER, not by command: a serial caller
// (sender_timestamp 0, physical access) reads secrets in plaintext, while a
// remote one gets "******** (serial only)". Its own comments say so -- "Serial
// only (WiFi creds grant LAN access); remote sees set/unset".
//
// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq`
// reachable from the terminal at all. Left alone, that also claims
// physical-access trust for an HTTP request: `get prv.key` would hand this
// node's identity to anyone associated with the open setup AP, and `get
// wifi.pwd` would hand over the operator's network. Reading a secret and
// writing one are not the same capability -- the wizard has always been able to
// REPLACE these; nothing in the portal could ever READ them, because
// /api/config masks them (wcIsSecretKey).
//
// So the command surface stays whole and only the READ is masked, restoring the
// distinction CommonCLI intended for a caller who is not at the serial port.
// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand),
// beside the rest of the secret classification and host-tested with it.
// Keep the set/unset signal, which is the useful part and what CommonCLI itself
// reports remotely; only the value goes. A getter answers "> value".
static void wcMaskSecretReply(char* reply) {
const char* val = reply;
if (val[0] == '>' && val[1] == ' ') val += 2;
const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0);
strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)");
}
// Reply classification lives in WebConfigBatch.h with the rest of the decisions
// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded),
// so the shapes CommonCLI actually emits are enumerated in one host-tested place.
// 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) {
size_t la = strnlen(a, max_len), lb = strnlen(b, max_len);
@@ -90,13 +174,29 @@ struct WCLock {
WebConfigServer* WebConfigServer::_active = NULL;
AsyncWebServer* WebConfigServer::_host = NULL;
static volatile bool webconfig_button_toggle_requested = false;
// Out-of-line definitions for the in-class-initialised constants. An in-class
// initialiser is only a declaration under C++11 (what the xtensa-esp32
// toolchain builds with), so any use that binds a reference rather than reading
// the value -- ArduinoJson takes its argument as `const T&` -- needs the symbol to
// exist. Comparisons like `count >= MAX_BATCH` never did, which is why this only
// surfaced when MAX_BATCH started being reported in JSON, and then only on the
// targets where the compiler happened not to fold it.
const int WebConfigServer::MAX_BATCH;
const size_t WebConfigServer::MAX_BODY;
const uint32_t WebConfigServer::STOP_WARN_MS;
// Protects the permanent route host's active-session pointer and handler
// references across the loop and async_tcp cores. The critical sections only
// copy a pointer/update a counter; handlers themselves never run under it.
static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED;
WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi,
const uint8_t* pub_key, const char* fw_ver,
const char* build_date,
const char* role, const char* board_name)
: _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key),
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
_fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
_cli_enabled = loadCliEnabled(true);
@@ -645,10 +745,8 @@ void WebConfigServer::finalizeTeardown() {
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
_setup_wifi_handoff_ip[0] = 0;
_cli_state = CLI_IDLE;
_cli_reqid[0] = 0;
_cli_command[0] = 0;
_cli_reply[0] = 0;
_batch_kind = BATCH_CONFIG;
_admin_pwd_set = false;
_session_token[0] = 0;
_stats_json[0] = 0;
if (_cb) _cb->onWebConfigStopped();
@@ -763,7 +861,6 @@ void WebConfigServer::tick(uint32_t now) {
if (_dns) _dns->processNextRequest();
if (_cli_state == CLI_PENDING) drainCliCommand();
if (_setup_wifi_handoff_pending) serviceSetupWiFiHandoff(now);
if (_batch_state == BATCH_PENDING && !_setup_wifi_handoff_pending) {
drainBatch(now);
@@ -818,26 +915,6 @@ void WebConfigServer::tick(uint32_t now) {
}
}
void WebConfigServer::drainCliCommand() {
char command[160];
{
WCLock lock(_mux);
if (_cli_state != CLI_PENDING) return;
strncpy(command, _cli_command, sizeof(command) - 1);
command[sizeof(command) - 1] = 0;
}
char reply[160] = "";
_cb->execAdminCommand(command, reply);
if (reply[0] == 0) strcpy(reply, "(no reply)");
WCLock lock(_mux);
if (_cli_state != CLI_PENDING) return;
strncpy(_cli_reply, reply, sizeof(_cli_reply) - 1);
_cli_reply[sizeof(_cli_reply) - 1] = 0;
_cli_state = CLI_DONE;
}
void WebConfigServer::finishBatch(uint32_t now) {
WCLock lock(_mux);
_batch_state = BATCH_DONE;
@@ -916,10 +993,28 @@ void WebConfigServer::drainBatch(uint32_t now) {
BatchEntry& e = _batch[_batch_next++];
e.reply[0] = 0;
uint32_t t0 = millis();
const bool admin_pwd = wcIsAdminPasswordKey(e.key);
const char* value = admin_pwd ? e.cmd + strlen("password ")
: e.cmd + strlen("set ") + strlen(e.key) + 1;
if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) {
if (_batch_kind == BATCH_CLI) {
if (wcIsDeferredReboot(e.cmd)) {
strcpy(e.reply, "OK - reboot queued");
} else {
_cb->execAdminCommand(e.cmd, e.reply);
}
if (e.reply[0] == 0) strcpy(e.reply, "(no reply)");
const bool set_admin_pwd = wcCliEchoesSecret(e.cmd);
if (set_admin_pwd) {
strcpy(e.reply, "OK");
_admin_pwd_set = true;
}
if (wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply);
if (WebConfigBatch::cliReplyGatesReboot(e.cmd)) {
_batch_all_ok = WebConfigBatch::nextAllOk(
_batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply));
}
} else {
const bool admin_pwd = wcIsAdminPasswordKey(e.key);
const char* value = admin_pwd ? e.cmd + strlen("password ")
: e.cmd + strlen("set ") + strlen(e.key) + 1;
if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) {
if (!value[0] || strlen(value) >= sizeof(_wifi_ssid)) {
strcpy(e.reply, "Error: WiFi SSID must be 1-31 characters");
} else {
@@ -967,13 +1062,23 @@ void WebConfigServer::drainBatch(uint32_t now) {
} else if (strncmp(e.reply, "OK", 2) == 0 && strcmp(e.key, "wifi.powersave") == 0) {
_wifi_power_save = strcmp(value, "max") == 0 ? 2 : strcmp(value, "min") == 0 ? 0 : 1;
}
}
if (e.reply[0] == 0) strcpy(e.reply, "OK");
_batch_all_ok = WebConfigBatch::nextAllOk(
_batch_all_ok, strncmp(e.reply, "OK", 2) == 0);
}
if (e.reply[0] == 0) strcpy(e.reply, "OK");
_batch_all_ok = WebConfigBatch::nextAllOk(
_batch_all_ok, strncmp(e.reply, "OK", 2) == 0);
_batch_last_cmd = millis();
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
e.key, (unsigned long)(_batch_last_cmd - t0));
// Config entries are named by their (non-secret) key. A CLI command is
// deliberately not logged: the operator can see what they typed, and a
// `set wifi.pwd` or `password` from the terminal must not reach the serial
// log, which is a different audience from the browser session.
if (_batch_kind == BATCH_CLI) {
Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count,
(unsigned long)(_batch_last_cmd - t0));
} else {
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
e.key, (unsigned long)(_batch_last_cmd - t0));
}
if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) {
return; // more commands next tick
}
@@ -1218,6 +1323,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
doc["node_id"] = node_id;
doc["fw"] = _fw_ver;
// The page shows a trimmed version -- base + build number + channel -- and
// pairs it with this, the way `ver` does. Both come from the same defines.
doc["build_date"] = _build_date;
doc["role"] = _role;
doc["board"] = _board_name;
doc["uptime_s"] = millis() / 1000;
@@ -1234,6 +1342,10 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["cli"] = _cli_enabled && _mode == MODE_LAN
&& WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal();
doc["capabilities"] = node.capabilities;
// Commands the terminal may submit at once. The CLI shares the config
// batch's fixed slot, so the cap is MAX_BATCH -- reported rather than
// duplicated in the page, which cannot know how this build was sized.
doc["max_cmds"] = MAX_BATCH;
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
@@ -1374,6 +1486,15 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
s["token"] = obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : "";
s["topic"] = (const char*)obs->mqtt_slot_topic[i];
s["audience"] = (const char*)obs->mqtt_slot_audience[i];
char filter_text[MQTTPacketFilter::kFilterTextSize];
if (MQTTPacketFilter::format(obs->mqtt_slot_packet_filter[i],
filter_text, sizeof(filter_text))) {
// Mutable char input is copied into the ArduinoJson document; the
// stack buffer is reused on the next slot.
s["filter"] = filter_text;
} else {
s["filter"] = "all";
}
}
}
#endif
@@ -1406,13 +1527,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
JsonObject set = doc["set"];
WCLock lock(_mux);
if (_cli_state == CLI_PENDING) {
req->send(409, "application/json",
"{\"error\":\"CLI command is still running\"}");
return;
}
const WebConfigBatch::State bstate = toSpecState(_batch_state);
const bool reqid_matches = strcmp(reqid, _batch_reqid) == 0;
const bool reqid_matches = _batch_kind == BATCH_CONFIG
&& strcmp(reqid, _batch_reqid) == 0;
const WebConfigBatch::PostOutcome pre =
WebConfigBatch::classifyPost(bstate, reqid_matches, 1, reboot_after);
if (pre == WebConfigBatch::PostOutcome::Replay) {
@@ -1476,11 +1593,32 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
// smuggle in a second command. Admin password uses the top-level command.
int pos = admin_pwd ? snprintf(e.cmd, sizeof(e.cmd), "password ")
: snprintf(e.cmd, sizeof(e.cmd), "set %s ", key);
for (const char* p = val; *p && pos < (int)sizeof(e.cmd) - 1; p++) {
bool overflow = false;
for (const char* p = val; *p; p++) {
if (*p == '\r' || *p == '\n') continue;
if (pos >= (int)sizeof(e.cmd) - 1) { overflow = true; break; }
e.cmd[pos++] = *p;
}
e.cmd[pos] = 0;
// A value that doesn't fit used to be truncated here and applied anyway.
// For length-checked keys the CLI would reject the remainder, but a key
// whose grammar stays valid when clipped (mqttN.filter: "advert,2" cut to
// "advert") would silently persist a *different* setting and still answer
// OK. Refuse the batch instead -- the caller can shorten and retry.
if (overflow) {
// Name the key, like the "bad key" rejection above: a 20-field batch is
// rejected whole, so without it the operator has nothing to correct.
char safe_key[33];
strncpy(safe_key, key, sizeof(safe_key) - 1);
safe_key[sizeof(safe_key) - 1] = 0;
StaticJsonDocument<128> ed;
ed["error"] = "value too long";
ed["key"] = safe_key;
String out;
serializeJson(ed, out);
req->send(400, "application/json", out);
return;
}
count++;
}
if (WebConfigBatch::classifyPost(bstate, reqid_matches, count, reboot_after) ==
@@ -1488,6 +1626,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
req->send(400, "application/json", "{\"error\":\"no changes\"}");
return;
}
_batch_kind = BATCH_CONFIG;
_batch_count = count;
_batch_next = 0;
_batch_reboot = reboot_after;
@@ -1531,9 +1670,13 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
// fires but no branch print follows, the handler is blocked on _mux.
Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state);
WCLock lock(_mux);
const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult(
toSpecState(_batch_state),
strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
// A CLI sequence occupying the shared slot is not a config save, whatever the
// reqid says: its entries have no `key` and its results belong to the
// terminal's reader. Treat it as unknown here (and vice versa there).
const bool mine = (_batch_kind == BATCH_CONFIG) &&
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
const WebConfigBatch::ResultOutcome outcome =
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
Serial.println("WC: result read -> idle");
StaticJsonDocument<64> idle;
@@ -1605,6 +1748,22 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
req->send(res);
}
// ---------------------------------------------------------------------------
// CLI terminal (/api/cli). Same 202 + reqid + poll contract as a config save,
// and for the same reason: CommonCLI touches prefs, the radio and the
// filesystem, none of which may be reached from the async_tcp task. The
// commands go into the shared deferred slot and tick() drains them.
//
// Unlike a save this is not allowlisted. It is restricted to authenticated LAN
// mode and uses the remote-admin callback, so serial-only secrets stay hidden.
// ---------------------------------------------------------------------------
// Commands whose CLI handler never returns would take the node down mid-drain,
// before the client could read a single result. `reboot` is deferred instead:
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
// path once the operator has read the results. The rest (clkreboot, poweroff,
// ota update) do real work on the way down and cannot be faked, so they run
// normally and the connection drops -- the UI warns before sending them.
void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
if (_mode != MODE_LAN) {
req->send(403, "application/json",
@@ -1621,69 +1780,137 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
return;
}
if (!checkAuth(req)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (req->contentLength() > 512) {
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
if (req->contentLength() > MAX_BODY || req->_tempObject == NULL) {
req->send(413, "application/json", "{\"error\":\"body too large\"}");
return;
}
const char* body = (const char*)req->_tempObject;
DynamicJsonDocument doc(512);
DynamicJsonDocument doc(6144);
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
req->send(400, "application/json", "{\"error\":\"bad json\"}");
return;
}
const char* reqid = doc["reqid"] | "";
const char* command = doc["command"] | "";
if (!wcIsValidReqId(reqid)) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
if (!wcIsValidCliCommand(command)) {
req->send(400, "application/json",
"{\"error\":\"command must be one nonblank line up to 159 bytes\"}");
JsonArray cmds = doc["cmds"];
if (cmds.isNull()) {
req->send(400, "application/json", "{\"error\":\"no commands\"}");
return;
}
WCLock lock(_mux);
const bool same_request = strcmp(reqid, _cli_reqid) == 0;
if (_cli_state == CLI_PENDING || same_request) {
if (!same_request) {
StaticJsonDocument<96> busy;
busy["error"] = "busy";
busy["reqid"] = (const char*)_cli_reqid;
String out;
serializeJson(busy, out);
req->send(409, "application/json", out);
return;
}
StaticJsonDocument<96> replay;
replay["state"] = _cli_state == CLI_DONE ? "done" : "pending";
replay["reqid"] = (const char*)_cli_reqid;
// Replay/Busy exactly as a config save classifies them: a repeated POST is
// acknowledged rather than executed twice, and a different sequence while one
// is still draining is refused.
const WebConfigBatch::State bstate = toSpecState(_batch_state);
const bool reqid_matches = _batch_kind == BATCH_CLI
&& strcmp(reqid, _batch_reqid) == 0;
const WebConfigBatch::PostOutcome pre =
WebConfigBatch::classifyPost(bstate, reqid_matches, 1 /* count unknown yet */, false);
if (pre == WebConfigBatch::PostOutcome::Replay) {
StaticJsonDocument<96> ack;
ack["state"] = (bstate == WebConfigBatch::State::Done) ? "done" : "running";
ack["total"] = _batch_count;
ack["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(replay, out);
serializeJson(ack, out);
req->send(202, "application/json", out);
return;
}
if (_batch_state == BATCH_PENDING) {
req->send(409, "application/json",
"{\"error\":\"configuration change is still running\"}");
if (pre == WebConfigBatch::PostOutcome::Busy) {
StaticJsonDocument<96> bd;
bd["error"] = "busy";
bd["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(bd, out);
req->send(409, "application/json", out);
return;
}
strncpy(_cli_reqid, reqid, sizeof(_cli_reqid) - 1);
_cli_reqid[sizeof(_cli_reqid) - 1] = 0;
strncpy(_cli_command, command, sizeof(_cli_command) - 1);
_cli_command[sizeof(_cli_command) - 1] = 0;
_cli_reply[0] = 0;
_cli_state = CLI_PENDING;
int count = 0;
bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false;
for (JsonVariant v : cmds) {
const char* raw = v.as<const char*>();
if (!raw) continue;
if (count >= MAX_BATCH) {
StaticJsonDocument<96> ed;
ed["error"] = "too many commands";
ed["max"] = MAX_BATCH;
String out;
serializeJson(ed, out);
req->send(413, "application/json", out);
return;
}
// Strip CR/LF so one entry cannot smuggle a second command past the
// operator's confirmation, and skip whatever is left blank.
BatchEntry& e = _batch[count];
int pos = 0;
for (const char* p = raw; *p; p++) {
if (*p == '\r' || *p == '\n') continue;
if (pos == 0 && (*p == ' ' || *p == '\t')) continue; // leading space
if (pos >= (int)sizeof(e.cmd) - 1) {
req->send(400, "application/json", "{\"error\":\"command too long\"}");
return;
}
e.cmd[pos++] = *p;
}
while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--;
e.cmd[pos] = 0;
if (pos == 0) continue;
// Reject before anything runs, so a sequence never half-applies and then
// stops on a command that was never going to work here.
const char* why = wcCliUnavailable(e.cmd);
if (why) {
StaticJsonDocument<256> ed;
ed["error"] = why;
String out;
serializeJson(ed, out);
req->send(400, "application/json", out);
return;
}
e.key[0] = 0; // CLI entries have no config key
if (wcIsDeferredReboot(e.cmd)) defer_reboot = true;
if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true;
if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true;
count++;
}
if (count == 0) {
req->send(400, "application/json", "{\"error\":\"no commands\"}");
return;
}
// The same invariant handleConfigPost enforces, and for the same reason: the
// reboot is what commits first onboarding, and a node that reboots onto the
// LAN still holding the factory password is a known credential on someone
// else's network. The terminal warned about this client-side, which is a
// reminder, not a rule -- a pasted script or a direct POST ignored it.
if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set &&
(defer_reboot || seq_sets_ssid)) {
req->send(400, "application/json",
"{\"error\":\"admin password required for initial setup -- "
"run `password <new-password>` first\"}");
return;
}
_batch_kind = BATCH_CLI;
_batch_count = count;
_batch_next = 0;
_batch_reboot = defer_reboot;
_batch_reboot_armed = false;
_batch_all_ok = true;
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot);
StaticJsonDocument<96> ack;
ack["state"] = "pending";
ack["reqid"] = (const char*)_cli_reqid;
ack["state"] = "running";
ack["total"] = count;
ack["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(ack, out);
req->send(202, "application/json", out);
@@ -1700,10 +1927,12 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
"{\"error\":\"terminal unavailable on this role\"}");
return;
}
if (!checkAuth(req)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
if (!_cli_enabled) {
req->send(403, "application/json",
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
return;
}
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
if (!req->hasParam("reqid")) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
@@ -1713,9 +1942,21 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
int from = 0;
if (req->hasParam("from")) {
from = req->getParam("from")->value().toInt();
if (from < 0) from = 0;
}
WCLock lock(_mux);
if (_cli_state == CLI_IDLE) {
// A config save occupying the slot is not this client's sequence, whatever
// the reqid says; treat it as unknown rather than serving `set` results
// through the terminal's reader.
const bool mine = (_batch_kind == BATCH_CLI) &&
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
const WebConfigBatch::ResultOutcome outcome =
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
StaticJsonDocument<64> idle;
idle["state"] = "idle";
idle["reqid"] = requested_reqid;
@@ -1724,15 +1965,42 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
req->send(200, "application/json", out);
return;
}
if (strcmp(requested_reqid.c_str(), _cli_reqid) != 0) {
if (outcome == WebConfigBatch::ResultOutcome::Unknown) {
req->send(404, "application/json", "{\"error\":\"unknown request\"}");
return;
}
DynamicJsonDocument doc(512);
doc["state"] = _cli_state == CLI_DONE ? "done" : "pending";
doc["reqid"] = (const char*)_cli_reqid;
if (_cli_state == CLI_DONE) doc["reply"] = (const char*)_cli_reply;
// Results stream: hand back whatever has drained since the client's cursor,
// capped so the document stays small on the async_tcp task.
const int produced = _batch_next;
const int page = WebConfigBatch::cliPageCount(from, produced, WebConfigBatch::kCliResultPage);
const bool final_read = WebConfigBatch::cliReadIsFinal(toSpecState(_batch_state),
from, page, _batch_count);
DynamicJsonDocument doc(4096);
doc["state"] = final_read ? "done" : "running";
doc["reqid"] = (const char*)_batch_reqid;
doc["total"] = _batch_count;
doc["from"] = from;
JsonArray results = doc.createNestedArray("results");
for (int i = from; i < from + page; i++) {
JsonObject r = results.createNestedObject();
// The command is deliberately NOT echoed: it may hold a password or token,
// and the client already has the sequence it sent. It matches by index.
r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply);
r["reply"] = (const char*)_batch[i].reply;
}
if (final_read) {
doc["all_ok"] = _batch_all_ok;
const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok);
doc["reboot"] = rebooting;
// Tell the operator why a `reboot` they asked for is not happening.
if (_batch_reboot && !_batch_all_ok) doc["reboot_withheld"] = true;
if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot,
_batch_all_ok, _batch_reboot_armed)) {
_batch_reboot_armed = true;
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
}
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
+20 -13
View File
@@ -117,6 +117,7 @@ public:
// repeater/room-server builds), false when the companion/MQTT runtime owns it.
WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi,
const uint8_t* pub_key, const char* fw_ver,
const char* build_date,
const char* role, const char* board_name);
~WebConfigServer();
@@ -179,18 +180,26 @@ private:
static const size_t MAX_BODY = 4096;
static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs;
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
enum CliState : uint8_t { CLI_IDLE = 0, CLI_PENDING, CLI_DONE };
static WebConfigBatch::State toSpecState(BatchState state) {
switch (state) {
// What filled the shared slot. A config save comes from allowlisted form
// fields; a CLI sequence is arbitrary commands typed into the terminal. They
// share the slot (see WebConfigBatch.h) but differ in how results are read
// and in whether `key` means anything, so every reader checks the kind.
enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI };
// BatchState and WebConfigBatch::State are deliberately kept as separate
// types (the enum is stored in a volatile member and used in prints); this
// is the one conversion point.
static WebConfigBatch::State toSpecState(BatchState s) {
switch (s) {
case BATCH_PENDING: return WebConfigBatch::State::Pending;
case BATCH_DONE: return WebConfigBatch::State::Done;
default: return WebConfigBatch::State::Idle;
}
}
struct BatchEntry {
char key[24]; // allowlisted config key (echoed back to the UI)
char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries
char cmd[160]; // full CLI command (may contain secrets - never echoed)
char reply[160];
char reply[160]; // CLI reply budget, same 160 bytes the serial console gets
};
Callbacks* _cb;
@@ -198,6 +207,7 @@ private:
bool _owns_wifi;
const uint8_t* _pub_key;
const char* _fw_ver;
const char* _build_date;
const char* _role;
const char* _board_name;
@@ -213,6 +223,10 @@ private:
char _wifi_password[64] = {0};
uint8_t _wifi_power_save = 1;
bool _cli_enabled = true;
// A `password` command has succeeded this session. Lets the CLI satisfy the
// initial-setup invariant across separate submissions; the form batch always
// sends the password with the rest, so it never needed the memory.
bool _admin_pwd_set = false;
char _ap_ssid[33] = {0};
WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker;
bool _retry_saved_wifi_in_setup = false;
@@ -227,6 +241,7 @@ private:
// Command batch: filled by async_tcp under _mux, drained by tick().
volatile BatchState _batch_state = BATCH_IDLE;
volatile BatchKind _batch_kind = BATCH_CONFIG;
uint8_t _batch_count = 0;
uint8_t _batch_next = 0; // drain progress (one command per tick)
uint32_t _batch_last_cmd = 0;
@@ -240,13 +255,6 @@ private:
char _setup_wifi_handoff_ip[16] = {0};
BatchEntry _batch[MAX_BATCH];
// Browser terminal command: filled by async_tcp under _mux and executed by
// tick() on the loop task. Separate from the allowlisted config batch.
volatile CliState _cli_state = CLI_IDLE;
char _cli_reqid[24] = {0};
char _cli_command[160] = {0};
char _cli_reply[160] = {0};
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
@@ -282,7 +290,6 @@ private:
void drainBatch(uint32_t now);
void serviceSetupWiFiHandoff(uint32_t now);
void finishBatch(uint32_t now);
void drainCliCommand();
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
+8 -1
View File
@@ -29,6 +29,7 @@ bool SH1106Display::begin()
// preferred alternate explicitly; otherwise try the other address in the
// pair. Always run the Adafruit init so its buffer and device objects exist,
// even when no panel answers the probe.
_initialized = false;
uint8_t addr = 0;
if (i2c_probe(Wire, DISPLAY_ADDRESS)) {
addr = DISPLAY_ADDRESS;
@@ -43,17 +44,23 @@ bool SH1106Display::begin()
addr = DISPLAY_ADDRESS ^ 1;
}
bool ok = display.begin(addr ? addr : DISPLAY_ADDRESS, true);
return addr != 0 && ok;
_initialized = addr != 0 && ok;
return _initialized;
}
void SH1106Display::turnOn()
{
if (!_initialized) return;
display.oled_command(SH110X_DISPLAYON);
_isOn = true;
}
void SH1106Display::turnOff()
{
if (!_initialized) {
_isOn = false;
return;
}
display.oled_command(SH110X_DISPLAYOFF);
_isOn = false;
}
+6 -2
View File
@@ -17,16 +17,20 @@
class SH1106Display : public DisplayDriver
{
Adafruit_SH1106G display;
bool _initialized;
bool _isOn;
uint8_t _color;
bool i2c_probe(TwoWire &wire, uint8_t addr);
public:
SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; }
SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) {
_initialized = false;
_isOn = false;
}
bool begin();
bool isOn() override { return _isOn; }
bool isOn() override { return _initialized && _isOn; }
void turnOn() override;
void turnOff() override;
void clear() override;