Merge remote-tracking branch 'review/observer-firmware' into keymindCascade
# Conflicts: # .github/workflows/build-observer-firmwares.yml # .gitignore # MQTT_IMPLEMENTATION.md # MQTT_INTERNALS.md # README.md # STABILITY_TESTABILITY_HANDOFF.md # docs/cli_commands.md # examples/companion_radio/MyMesh.cpp # examples/simple_repeater/MyMesh.cpp # examples/simple_repeater/UITask.cpp # examples/simple_room_server/MyMesh.cpp # examples/simple_room_server/UITask.cpp # examples/simple_sensor/SensorMesh.cpp # platformio.ini # scripts/generate_webconfig_html.py # scripts/webconfig_mock_server.py # src/helpers/CommonCLI.cpp # src/helpers/CommonCLI.h # src/helpers/JWTHelper.cpp # src/helpers/MQTTDefaults.h # src/helpers/MQTTMessageBuilder.cpp # src/helpers/MQTTPayloadBuilder.h # src/helpers/MQTTPresets.h # src/helpers/bridges/MQTTBridge.h # src/helpers/esp32/WebConfigServer.cpp # src/helpers/esp32/WebConfigServer.h # src/helpers/ui/SH1106Display.cpp # variants/lilygo_tbeam_SX1262/platformio.ini # variants/lilygo_tbeam_SX1276/platformio.ini # variants/lilygo_tlora_v2_1/platformio.ini # variants/thinknode_m7/platformio.ini # webui/index.html
This commit is contained in:
commit
a04a284a3f
74 files changed
+7857
-2483
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+1719
-1334
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user