Preserve MQTT settings across slot changes

This commit is contained in:
mikecarper committed 2026-09-08 18:55:00 -07:00
1 parent aa8caa4ed2
commit cb79dcd4e3
16 files changed
+2209 -1999

No files matched your search

+1 -1
View File
@@ -437,7 +437,7 @@ Each slot (1-6) supports the following commands:
- `set mqttN.preset custom` - Set slot N to custom broker (configure server/port/username/password)
- `set mqttN.preset none` - Disable slot N
- `set mqttN.server <hostname>` - Set custom server hostname for slot N
- `set mqttN.port <port>` - Set custom server port for slot N (1-65535)
- `set mqttN.port <port>` - Set custom server port for slot N (0-65535; 0 clears the override and uses a full URI's default port)
- `set mqttN.username <username>` - Set username for slot N (`custom` preset, or presets like `inwmesh` that require per-device credentials)
- `set mqttN.password <password>` - Set password for slot N (`custom` preset, or presets like `inwmesh` that require per-device credentials)
- `set mqttN.token <token>` - Set per-slot token (required for MeshRank preset)
+4 -1
View File
@@ -253,7 +253,10 @@ get mqtt.status
Set `mqtt1.username` / `mqtt1.password` if required by that broker. Configure only
as many slots as the board supports. `set mqtt1.preset none` disables slot 1;
other configured slots remain enabled. `set mqtt.status off` disables status
other configured slots remain enabled, within the build's supported slot count.
Disabled and partially configured slots remain saved across reboot. For a custom
server URL, `set mqtt1.port 0` clears the port override so the URI supplies it.
`set mqtt.status off` disables status
messages, not MQTT itself. See the [MQTT reference](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md)
for presets, TLS, credentials, and slot limits.
+9 -6
View File
@@ -1906,8 +1906,10 @@ void MyMesh::begin(bool has_display, bool radio_available) {
_mqtt_enabled = CompanionMqttSetupPortal::loadEnabled();
// Keep settings available to the USB CLI even when this boot selects BLE.
applyMQTTDefaults(&_mqtt_prefs);
_mqtt_configured = CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
if (!_mqtt_configured) applyMQTTDefaults(&_mqtt_prefs);
if (!CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs)) {
applyMQTTDefaults(&_mqtt_prefs);
}
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
// Companion WiFi owns the connection and its mesh-wifi NVS setting is
// canonical. Keep MQTT reconnects from restoring a stale MQTT-pref value.
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
@@ -3356,7 +3358,8 @@ void MyMesh::onConfigBatchEnd() {
// loadStoredConfig only replaces its destination after validation. Pass
// the live preferences directly: another 2.8 KB copy here nests with the
// loader's scratch copy and NVS calls, overflowing the ESP32 loop stack.
_mqtt_configured = CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
// The standalone Companion setting remains canonical even if MQTT config
// verification reloaded an older copy of this field.
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
@@ -3751,9 +3754,9 @@ void MyMesh::execCommand(char* cmd, char* reply) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_host[slot],
sizeof(_mqtt_prefs.mqtt_slot_host[slot]), value);
} else if (strcmp(field, "port") == 0) {
long port;
if (!wcParseLong(value, 1, 65535, port)) {
strcpy(reply, "Error: port must be 1-65535");
uint16_t port;
if (!mesh::cli::parseMqttPort(value, port)) {
strcpy(reply, "Error: port must be 0-65535 (0 uses the URI default)");
return;
}
_mqtt_prefs.mqtt_slot_port[slot] = static_cast<uint16_t>(port);
+1
View File
@@ -98,6 +98,7 @@ def firmware_commands():
found.add(lit.strip())
return found - NOT_OFFERED
ROUND_TRIPS = [
("set mqtt1.port 0", "get mqtt1.port", "0"),
("set usb.logging on", "get usb.logging", "on"),
("set usb.logging off", "get usb.logging", "off"),
("set powersaving on", "get powersaving", "on"),
+3 -3
View File
@@ -467,9 +467,9 @@ def apply_slot_set(cfg, idx, field, val):
try:
p = int(val)
except ValueError:
return False, "Error: port must be between 1 and 65535"
if not (1 <= p <= 65535):
return False, "Error: port must be between 1 and 65535"
return False, "Error: port must be 0-65535 (0 uses the URI default)"
if not (0 <= p <= 65535):
return False, "Error: port must be 0-65535 (0 uses the URI default)"
slot["port"] = p
return True, "OK"
if field == "filter":
+8
View File
@@ -675,6 +675,14 @@ inline bool parseUnsignedIntegerStrict(const char* text, uint32_t& result) {
return true;
}
// Zero clears a custom port override; a full broker URI supplies its default.
inline bool parseMqttPort(const char* text, uint16_t& result) {
uint32_t value;
if (!parseUnsignedIntegerStrict(text, value) || value > 65535) return false;
result = static_cast<uint16_t>(value);
return true;
}
// Copy and split a bounded CLI argument string on runs of spaces/tabs. This
// provides scanf-like token handling without pulling scanf into firmware. The
// caller controls the maximum accepted token count through field_capacity.
+3 -3
View File
@@ -574,14 +574,14 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (memcmp(subcmd, "port ", 5) == 0) {
int port = atoi(&subcmd[5]);
if (port > 0 && port <= 65535) {
uint16_t port;
if (mesh::cli::parseMqttPort(&subcmd[5], port)) {
_mqtt_prefs.mqtt_slot_port[slot] = port;
saveObserverPrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: port must be between 1 and 65535");
strcpy(reply, "Error: port must be 0-65535 (0 uses the URI default)");
}
} else if (memcmp(subcmd, "username ", 9) == 0) {
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]), reply, "username")) return true;
+28 -21
View File
@@ -612,31 +612,38 @@ bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) {
return false;
}
const char* preset = loaded.mqtt_slot_preset[0];
const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0;
const MQTTPresetDef* preset_def = custom ? nullptr : findMQTTPreset(preset);
if (!preset[0] || (!custom && !preset_def)) return false;
if (custom) {
const char* host = loaded.mqtt_slot_host[0];
if (!host[0]
|| (loaded.mqtt_slot_port[0] == 0 && strstr(host, "://") == nullptr)
|| (!loaded.mqtt_slot_topic[0][0] && !isThreeLetterCode(loaded.mqtt_iata))) {
return false;
}
} else {
if (preset_def->topic_style == MQTT_TOPIC_MESHCORE
&& !isThreeLetterCode(loaded.mqtt_iata)) return false;
if (preset_def->topic_style == MQTT_TOPIC_MESHRANK
&& !loaded.mqtt_slot_token[0][0]) return false;
if (mqttPresetNeedsSlotCredentials(preset_def)
&& (!loaded.mqtt_slot_username[0][0] || !loaded.mqtt_slot_password[0][0])) {
return false;
}
}
// Incomplete and disabled slots are valid saved settings. Readiness is a
// separate decision, so disabling slot 1 or saving a broker in stages cannot
// discard all slots and credentials on the next boot.
prefs = loaded;
return true;
}
bool CompanionMqttSetupPortal::hasConfiguredSlot(const MQTTPrefs& prefs) {
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
const char* preset = prefs.mqtt_slot_preset[i];
if (!preset[0] || strcmp(preset, MQTT_PRESET_NONE) == 0) continue;
if (strcmp(preset, MQTT_PRESET_CUSTOM) == 0) {
const char* host = prefs.mqtt_slot_host[i];
if (host[0] && (prefs.mqtt_slot_port[i] != 0 || strstr(host, "://"))
&& (prefs.mqtt_slot_topic[i][0] || isThreeLetterCode(prefs.mqtt_iata))) {
return true;
}
continue;
}
const MQTTPresetDef* def = findMQTTPreset(preset);
if (!def) continue;
if (def->topic_style == MQTT_TOPIC_MESHCORE
&& !isThreeLetterCode(prefs.mqtt_iata)) continue;
if (def->topic_style == MQTT_TOPIC_MESHRANK
&& !prefs.mqtt_slot_token[i][0]) continue;
if (mqttPresetNeedsSlotUsername(def) && !prefs.mqtt_slot_username[i][0]) continue;
if (mqttPresetNeedsSlotPassword(def) && !prefs.mqtt_slot_password[i][0]) continue;
return true;
}
return false;
}
bool CompanionMqttSetupPortal::loadEnabled() {
Preferences nvs;
if (!nvs.begin(NVS_NAMESPACE, false)) return true;
+1
View File
@@ -20,6 +20,7 @@ public:
bool isActive() const { return _active; }
static bool loadStoredConfig(MQTTPrefs& prefs);
static bool hasConfiguredSlot(const MQTTPrefs& prefs);
static bool saveStoredConfig(const MQTTPrefs& prefs);
static bool loadEnabled();
static bool saveEnabled(bool enabled);
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -1671,7 +1671,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
#ifdef WITH_MQTT_BRIDGE
doc["runtime_slots"] = has_mqtt ? RUNTIME_MQTT_SLOTS : 0;
doc["max_slots"] = has_mqtt ? MAX_MQTT_SLOTS : 0;
doc["active_slots"] = has_mqtt ? MQTTBridge::getMaxActiveSlots() : 0;
const int active_slots = MQTTBridge::getMaxActiveSlots();
doc["active_slots"] = has_mqtt
? (active_slots < RUNTIME_MQTT_SLOTS ? active_slots : RUNTIME_MQTT_SLOTS) : 0;
#else
doc["runtime_slots"] = 0;
doc["max_slots"] = 0;
@@ -114,6 +114,22 @@ TEST(CLICommandUtils, NormalizesCapitalizedSetVerb) {
EXPECT_STREQ("set altpath 600000,0d2784,F8DADA", command);
}
TEST(CLICommandUtils, MqttPortSupportsDefaultAndRejectsMalformedValues) {
uint16_t port = 99;
EXPECT_TRUE(mesh::cli::parseMqttPort("0", port));
EXPECT_EQ(0, port);
EXPECT_TRUE(mesh::cli::parseMqttPort("1883", port));
EXPECT_EQ(1883, port);
EXPECT_TRUE(mesh::cli::parseMqttPort("65535", port));
EXPECT_EQ(65535, port);
const char* invalid[] = {nullptr, "", "-1", "65536", "1883junk", "999999999999999999999"};
for (const char* value : invalid) {
port = 99;
EXPECT_FALSE(mesh::cli::parseMqttPort(value, port));
EXPECT_EQ(99, port);
}
}
TEST(CLICommandUtils, LoggingToggleHasOneGrammarForEveryRole) {
bool enabled = false, reboot = false;
ASSERT_TRUE(mesh::cli::parseLoggingToggle("on", enabled, reboot));
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Exercise the firmware's MQTT NVS loader and slot readiness on the host."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src/helpers/CompanionMqttSetupPortal.cpp"
def function(source, signature):
start = source.index(signature)
opening = source.index("{", start)
depth = 1
end = opening + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end] + "\n"
PREAMBLE = r"""
#include <cassert>
#include <cctype>
#include <cstring>
#include <helpers/CompanionMqttPrefsNvs.h>
constexpr int MAX_MQTT_SLOTS = MQTT_PREFS_SLOT_COUNT;
constexpr int RUNTIME_MQTT_SLOTS = TEST_RUNTIME_SLOTS;
constexpr int MQTT_TOPIC_MESHCORE = 0, MQTT_TOPIC_MESHRANK = 1;
const char* MQTT_PRESET_CUSTOM = "custom";
const char* MQTT_PRESET_NONE = "none";
const char* NVS_NAMESPACE = "mesh-mqtt";
const char* NVS_VERSION_KEY = "version";
const char* NVS_PREFS_KEY = "prefs";
struct MQTTPresetDef { int topic_style; bool username, password; };
const MQTTPresetDef* findMQTTPreset(const char* name) {
static MQTTPresetDef analyzer{MQTT_TOPIC_MESHCORE, false, false};
static MQTTPresetDef credentials{MQTT_TOPIC_MESHCORE, true, false};
if (strcmp(name, "analyzer-us") == 0) return &analyzer;
if (strcmp(name, "username-only") == 0) return &credentials;
return nullptr;
}
bool mqttPresetNeedsSlotUsername(const MQTTPresetDef* p) { return p->username; }
bool mqttPresetNeedsSlotPassword(const MQTTPresetDef* p) { return p->password; }
void applyMQTTDefaults(MQTTPrefs* p) {
memset(p, 0, sizeof(*p));
for (auto& preset : p->mqtt_slot_preset) strcpy(preset, "none");
}
MQTTPrefs saved;
struct Preferences {
bool begin(const char*, bool) { return true; }
bool isKey(const char*) { return true; }
uint16_t getUShort(const char*, uint16_t) { return MQTT_PREFS_VERSION; }
size_t getBytesLength(const char*) { return CompanionMqttPrefsNvs::kWriteSize; }
size_t getBytes(const char*, void* dest, size_t length) {
memcpy(dest, &saved, length);
return length;
}
void end() {}
};
struct CompanionMqttSetupPortal {
static bool loadStoredConfig(MQTTPrefs&);
static bool hasConfiguredSlot(const MQTTPrefs&);
};
"""
SCENARIOS = r"""
int main() {
MQTTPrefs result{};
applyMQTTDefaults(&saved);
// Disabled slots still carry reusable settings and credentials.
strcpy(saved.mqtt_slot_password[0], "keep-this-secret");
strcpy(saved.mqtt_slot_host[0], "configured.example");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(memcmp(&result, &saved, sizeof(saved)) == 0);
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
// Saving a custom broker in stages must not reset it on the next boot.
strcpy(saved.mqtt_slot_preset[0], "custom");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(strcmp(result.mqtt_slot_host[0], "configured.example") == 0);
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
// A full URI needs no separate port override. Topic replaces IATA for custom.
strcpy(saved.mqtt_slot_host[0], "mqtt://127.0.0.1:1");
strcpy(saved.mqtt_slot_topic[0], "test/{device}/{type}");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(CompanionMqttSetupPortal::hasConfiguredSlot(result));
if (RUNTIME_MQTT_SLOTS > 1) {
// Slot 1 may be disabled while another slot is the only usable broker.
applyMQTTDefaults(&saved);
strcpy(saved.mqtt_slot_preset[1], "custom");
strcpy(saved.mqtt_slot_host[1], "mqtt://127.0.0.1:1");
strcpy(saved.mqtt_slot_topic[1], "test");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(CompanionMqttSetupPortal::hasConfiguredSlot(result));
assert(strcmp(result.mqtt_slot_preset[0], "none") == 0);
}
// Saved slots outside this image's runtime limit remain stored, but do not run.
applyMQTTDefaults(&saved);
strcpy(saved.mqtt_slot_preset[MAX_MQTT_SLOTS - 1], "custom");
strcpy(saved.mqtt_slot_host[MAX_MQTT_SLOTS - 1], "mqtt://127.0.0.1:1");
strcpy(saved.mqtt_slot_topic[MAX_MQTT_SLOTS - 1], "test");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
assert(memcmp(&result, &saved, sizeof(saved)) == 0);
applyMQTTDefaults(&saved);
strcpy(saved.mqtt_slot_preset[0], "analyzer-us");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
strcpy(saved.mqtt_iata, "SEA");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(CompanionMqttSetupPortal::hasConfiguredSlot(result));
strcpy(saved.mqtt_slot_preset[0], "username-only");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
strcpy(saved.mqtt_slot_username[0], "user");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(CompanionMqttSetupPortal::hasConfiguredSlot(result));
// Corrupt records still fail without overwriting live preferences.
MQTTPrefs before = result;
saved.mqtt_tx_enabled = 255;
assert(!CompanionMqttSetupPortal::loadStoredConfig(result));
assert(memcmp(&before, &result, sizeof(before)) == 0);
saved.mqtt_tx_enabled = 0;
memset(saved.mqtt_slot_password[0], 'X', sizeof(saved.mqtt_slot_password[0]));
assert(!CompanionMqttSetupPortal::loadStoredConfig(result));
assert(memcmp(&before, &result, sizeof(before)) == 0);
}
"""
class CompanionMqttConfigTests(unittest.TestCase):
def test_saved_settings_and_readiness_are_independent(self):
source = SOURCE.read_text()
actual = source[source.index("template <size_t N>"):
source.index("static bool readLine")]
for signature in (
"static bool isThreeLetterCode",
"bool CompanionMqttSetupPortal::loadStoredConfig",
"bool CompanionMqttSetupPortal::hasConfiguredSlot",
):
actual += function(source, signature)
with tempfile.TemporaryDirectory(prefix="companion-mqtt-config-") as tmp:
cpp = Path(tmp) / "config.cpp"
cpp.write_text(PREAMBLE + actual + SCENARIOS)
for slots in (1, 3):
with self.subTest(runtime_slots=slots):
binary = Path(tmp) / ("config-%d" % slots)
compile_result = subprocess.run([
"c++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DWITH_MQTT_BRIDGE=1", "-DTEST_RUNTIME_SLOTS=%d" % slots,
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary),
], text=True, capture_output=True)
self.assertEqual(compile_result.returncode, 0, compile_result.stderr)
result = subprocess.run([str(binary)], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -15,6 +15,7 @@ struct MQTTBridge { void end(); };
struct CompanionMqttSetupPortal {
static bool saveStoredConfig(const MQTTPrefs&);
static bool loadStoredConfig(MQTTPrefs&);
static bool hasConfiguredSlot(const MQTTPrefs&);
};
struct MyMesh {
void onConfigBatchEnd();
+2 -2
View File
@@ -116,7 +116,7 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
def test_companion_console_runs_commands_and_uses_role_suggestions(self):
status, config = self.setup_values()
status.update(mode="lan", cli=True, mqtt=True, active_slots=1,
status.update(mode="lan", cli=True, mqtt=True, active_slots=5, runtime_slots=1,
password_supported=False)
prelude = """
<script>
@@ -188,7 +188,7 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
"set mqtt1.preset ", "get name", "get radio"):
self.assertIn(command, suggestions)
for command in ("erase", "password ", "setperm ", "get prv.key",
"set bridge.enabled ", "get mqtt1.password"):
"set bridge.enabled ", "get mqtt1.password", "set mqtt2.preset "):
self.assertNotIn(command, suggestions)
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
+3 -3
View File
@@ -741,7 +741,7 @@ function boot(){
st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;
st.radioOptional=!!s.radio_optional;
st.needsSetup=!!s.needs_setup;st.caps=s.capabilities||0;
st.nslots=st.hasMqtt?(s.active_slots||s.runtime_slots||0):0;
st.nslots=st.hasMqtt?Math.min(s.active_slots||s.runtime_slots||0,s.runtime_slots||s.active_slots||0):0;
if(s.max_cmds>0)CLI_MAX=s.max_cmds;
st.fw=s.fw||"";st.build=s.build_date||"";st.role=s.role||"";st.board=s.board||"";
$("#h-name").textContent=s.name||"MeshCore";
@@ -1128,7 +1128,7 @@ function buildSlots(sel){
+slotFieldHtml(i,"username","text","Username","")
+slotFieldHtml(i,"password","password","Password","")
+slotFieldHtml(i,"server","text","Server host","")
+slotFieldHtml(i,"port","number","Port","")
+slotFieldHtml(i,"port","number","Port","0 uses the default port of a full server URL")
+slotFieldHtml(i,"topic","text","Topic template","Placeholders: {iata} {device} {type} {token}")
+slotFieldHtml(i,"audience","text","JWT audience","Optional - blank for user/pass auth")
+packetFilterHtml(i)
@@ -1944,7 +1944,7 @@ var CLI_KEYS=[
var CLI_SLOT=[
["preset","preset",1],
["server","custom broker hostname"],
["port","broker port {1-65535}"],
["port","broker port {0-65535; 0 uses URI default}"],
["username","username"],
["password","password"],
["token","token (required by some presets)"],