From 93a9c80305544018553ede2906d61eba655bb0cd Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Tue, 23 Jun 2026 09:22:17 +1000 Subject: [PATCH] feat(mqtt): add MeshMapper broker, retire LetsMesh, Primary/Secondary broker --- README.md | 7 +- eastmesh-docs/custom-cli.md | 16 +- eastmesh-docs/releases.md | 2 +- eastmesh-docs/web-panel.md | 4 +- examples/simple_repeater/MyMesh.cpp | 8 + src/helpers/mqtt/MQTTPrefs.cpp | 10 +- src/helpers/mqtt/MQTTPrefs.h | 3 + src/helpers/mqtt/MQTTUplink.cpp | 55 ++++- src/helpers/mqtt/MQTTUplink.h | 7 +- src/helpers/web/WebPanelServer.cpp | 357 ++++++++++------------------ 10 files changed, 210 insertions(+), 259 deletions(-) diff --git a/README.md b/README.md index 7f6440a3..ecbbed2f 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,11 @@ uv run --group docs zensical build ### MQTT Repeater Additions -- hardcoded broker support for: +- curated broker support for: - `eastmesh-au` - - `letsmesh-eu` - - `letsmesh-us` + - `meshmapper` + - `letsmesh-eu` (retired) + - `letsmesh-us` (retired) - WSS transport at `/mqtt` - JWT auth using the device identity - CLI controls for: diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index b1df7217..04c230cf 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -65,10 +65,12 @@ No-argument `get` commands must be entered exactly as shown. - `get mqtt.eastmesh-au` - `set mqtt.eastmesh-au on|off` -- `get mqtt.letsmesh-eu` -- `set mqtt.letsmesh-eu on|off` -- `get mqtt.letsmesh-us` -- `set mqtt.letsmesh-us on|off` +- `get mqtt.meshmapper` +- `set mqtt.meshmapper on|off` +- `get mqtt.letsmesh-eu` (retired) +- `set mqtt.letsmesh-eu on|off` (retired) +- `get mqtt.letsmesh-us` (retired) +- `set mqtt.letsmesh-us on|off` (retired) - `get mqtt.custom` - `set mqtt.custom on|off` - `get mqtt.custom.host` @@ -85,11 +87,13 @@ No-argument `get` commands must be entered exactly as shown. Notes: - new observer installs default `mqtt.iata` to `UNSET` -- `letsmesh-eu` and `letsmesh-us` remain off by default unless already configured in saved prefs +- a maximum of two MQTT brokers can be enabled at once +- `meshmapper` is the curated global broker (`wss://mqtt.meshmapper.net:443/mqtt`); like `eastmesh-au` it uses WSS, verified TLS, and MeshCore JWT auth with the broker host as the token audience +- `letsmesh-eu` and `letsmesh-us` are retired (LetsMesh is no longer maintained) and off by default; any saved selections are cleared once on upgrade, though the endpoints stay selectable for legacy use - if `mqtt.iata` is `UNSET`, enabled MQTT brokers will not connect - custom MQTT uses the configured username and password, not JWT authentication - custom MQTT defaults to TCP; set `mqtt.custom.transport wss` for MQTT over secure WebSockets using the fixed `/mqtt` websocket path and the ESP-IDF x509 root CA bundle -- in `get mqtt.status`, custom MQTT is shown as `custom::`, for example `custom:wss:up`; `conn` means connecting and `up` means connected +- `get mqtt.status` reports the two enabled broker slots as `p::` (primary) and `s::` (secondary), or `-` when a slot is empty; custom appears as `custom::` (for example `p:custom:wss:up`); `conn` means connecting and `up` means connected - custom MQTT uses the same `meshcore///` topics as the curated brokers - turning off a connected broker publishes a retained MQTT status update with `"status":"offline"` before the client disconnects - changing `mqtt.iata` away from a configured value also publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path diff --git a/eastmesh-docs/releases.md b/eastmesh-docs/releases.md index 5f527ac7..2c33e1d5 100644 --- a/eastmesh-docs/releases.md +++ b/eastmesh-docs/releases.md @@ -171,7 +171,7 @@ Typical first steps after flashing: - set `mqtt.iata` - confirm `get mqtt.status` - optionally set `mqtt.owner` and `mqtt.email` -- optionally enable `letsmesh-eu` or `letsmesh-us` +- optionally enable a second broker such as `meshmapper` ### Observer ESP-NOW diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index 7d751929..dce014b8 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -253,8 +253,8 @@ This section includes: - `mqtt.iata`: selected from a curated east-coast/south-east list. - `mqtt.owner`: owner public key. - `mqtt.email`: owner contact email. -- MQTT server toggles: `eastmesh-au`, `letsmesh-eu`, `letsmesh-us`, and custom MQTT. -- custom MQTT `host:port`, TCP/WSS transport, username, and password fields. +- MQTT brokers: **Primary MQTT** and **Secondary MQTT** dropdowns, each selecting one of `eastmesh-au`, `meshmapper`, `Custom`, the retired `letsmesh-eu`/`letsmesh-us`, or `None`. The two slots enforce the two-broker maximum, and a broker chosen in one slot is disabled in the other. +- custom MQTT `host:port`, TCP/WSS transport, username, and password fields, shown when `Custom` is selected in either slot. `UNSET - To be configured` is the default for new observer installs until a real saved value exists. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f7c21091..9996b884 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2444,6 +2444,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off"); } else if (strcmp(command, "get mqtt.eastmesh-au") == 0 || strcmp(command, "get mqtt.eastmesh.au") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x01) ? "on" : "off"); + } else if (strcmp(command, "get mqtt.meshmapper") == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x10) ? "on" : "off"); } else if (strcmp(command, "get mqtt.letsmesh-eu") == 0 || strcmp(command, "get mqtt.letsmesh.eu") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off"); } else if (strcmp(command, "get mqtt.letsmesh-us") == 0 || strcmp(command, "get mqtt.letsmesh.us") == 0) { @@ -2496,6 +2498,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else { strcpy(reply, "Err - max 2 mqtt brokers"); } + } else if (memcmp(command, "set mqtt.meshmapper ", 20) == 0) { + if (mqtt.setEndpointEnabled(0x10, memcmp(&command[20], "on", 2) == 0)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - max 2 mqtt brokers"); + } } else if (memcmp(command, "set mqtt.letsmesh-eu ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.eu ", 21) == 0) { if (mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0)) { strcpy(reply, "OK"); diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index 10d43f54..c276d8db 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -23,6 +23,7 @@ void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) { prefs.legacy_wifi_powersave = 0; prefs.status_interval_ms = kFixedStatusIntervalMs; prefs.custom_port = 1883; + prefs.brokers_migrated = 1; // Fresh installs need no LetsMesh retirement migration. StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata)); #ifdef WIFI_SSID StrHelper::strncpy(prefs.legacy_wifi_ssid, WIFI_SSID, sizeof(prefs.legacy_wifi_ssid)); @@ -74,7 +75,14 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { prefs.custom_transport = 0; } prefs.status_interval_ms = kFixedStatusIntervalMs; - prefs.enabled_mask &= 0x0F; + prefs.enabled_mask &= 0x1F; + if (!prefs.brokers_migrated) { + // LetsMesh is retired. Clear any saved EU/US selections once so dead brokers + // stop retrying; the endpoints remain re-selectable afterwards. + prefs.enabled_mask &= ~static_cast(0x02 | 0x04); + prefs.brokers_migrated = 1; + save(fs, prefs); + } return true; } diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h index f6a3604b..337ad5af 100644 --- a/src/helpers/mqtt/MQTTPrefs.h +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -34,6 +34,9 @@ struct MQTTPrefs { char custom_username[65]; char custom_password[96]; uint8_t custom_transport; + // Appended fields must stay at the end: older prefs files are shorter and + // read back as zero here, which drives one-time migrations (see load()). + uint8_t brokers_migrated; }; class MQTTPrefsStore { diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index cec9500a..352c58ce 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -127,8 +127,10 @@ void logMqttMemorySnapshot(const char*, const char* = nullptr) { } -const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[4] = { +const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[kBrokerCount] = { {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit, false}, + {"meshmapper", "meshmapper", "mqtt.meshmapper.net", "wss://mqtt.meshmapper.net:443/mqtt", kMeshmapperBit, false}, + // Retired: LetsMesh is no longer maintained. Kept selectable for legacy nodes; new nodes should use meshmapper. {"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", kLetsmeshEuBit, false}, {"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", @@ -142,7 +144,7 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) { memset(_device_id, 0, sizeof(_device_id)); MQTTPrefsStore::setDefaults(_prefs); - for (size_t i = 0; i < 4; ++i) { + for (size_t i = 0; i < kBrokerCount; ++i) { memset(&_brokers[i], 0, sizeof(_brokers[i])); _brokers[i].spec = &kBrokerSpecs[i]; } @@ -167,7 +169,7 @@ bool MQTTUplink::isTokenRefreshInProgress() const { } bool MQTTUplink::hasEnabledBroker() const { - return (_prefs.enabled_mask & 0x0F) != 0; + return (_prefs.enabled_mask & kBrokerMask) != 0; } uint8_t MQTTUplink::countEnabledBrokers() const { @@ -195,8 +197,10 @@ bool MQTTUplink::isUnsetIataValue(const char* iata) { } const char* MQTTUplink::brokerCaCert(const BrokerSpec& spec) { - if (spec.bit == kEastmeshBit) { - return mqtt_ca_certs::kEastmeshIsrgRootX1Pem; + // Note: ESP32 targets with the certificate bundle attach it instead of these PEMs; + // this fallback only applies on builds without CONFIG_MBEDTLS_CERTIFICATE_BUNDLE. + if (spec.bit == kEastmeshBit || spec.bit == kMeshmapperBit) { + return mqtt_ca_certs::kEastmeshIsrgRootX1Pem; // Let's Encrypt ISRG Root X1 } return mqtt_ca_certs::kLetsmeshWe1Pem; } @@ -1056,7 +1060,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { void MQTTUplink::begin(FILESYSTEM* fs) { _fs = fs; MQTTPrefsStore::load(_fs, _prefs); - uint8_t normalized_mask = normalizeEnabledMask(_prefs.enabled_mask & 0x0F); + uint8_t normalized_mask = normalizeEnabledMask(_prefs.enabled_mask & kBrokerMask); if (normalized_mask != _prefs.enabled_mask) { _prefs.enabled_mask = normalized_mask; savePrefs(); @@ -1203,18 +1207,47 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { return "retry"; }; + // Only ever up to two brokers are enabled, so report the active slots instead of + // every broker. This keeps the reply short for LoRa and is stable as brokers are added. + auto format_slot = [this, &broker_state](const BrokerState* broker, char* buf, size_t buf_size) { + if (broker == nullptr || broker->spec == nullptr) { + StrHelper::strncpy(buf, "-", buf_size); + } else if (broker->spec->custom) { + snprintf(buf, buf_size, "custom:%s:%s", getCustomTransport(), broker_state(broker->spec->bit)); + } else { + snprintf(buf, buf_size, "%s:%s", broker->spec->key, broker_state(broker->spec->bit)); + } + }; + + const BrokerState* primary = nullptr; + const BrokerState* secondary = nullptr; + for (const BrokerState& broker : _brokers) { + if (broker.spec == nullptr || (_prefs.enabled_mask & broker.spec->bit) == 0) { + continue; + } + if (primary == nullptr) { + primary = &broker; + } else if (secondary == nullptr) { + secondary = &broker; + break; + } + } + + char primary_slot[40]; + char secondary_slot[40]; + format_slot(primary, primary_slot, sizeof(primary_slot)); + format_slot(secondary, secondary_slot, sizeof(secondary_slot)); + snprintf(reply, reply_size, - "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s custom:%s:%s status:%s tx:%s", + "> wifi:%s ntp:%s iata:%s p:%s s:%s status:%s tx:%s", (_network != nullptr && _network->isWifiConnected()) ? "up" : "down", (_network != nullptr && _network->hasTimeSync()) ? "up" : "wait", - _prefs.iata, - broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit), - getCustomTransport(), broker_state(kCustomBit), _prefs.status_enabled ? "on" : "off", + _prefs.iata, primary_slot, secondary_slot, _prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off"); } bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { - uint8_t next_mask = _prefs.enabled_mask & 0x0F; + uint8_t next_mask = _prefs.enabled_mask & kBrokerMask; if (enabled) { next_mask = normalizeEnabledMask(next_mask | bit); if ((next_mask & bit) == 0) { diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 2fd112ec..0f106004 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -130,12 +130,15 @@ private: static constexpr uint8_t kLetsmeshEuBit = 0x02; static constexpr uint8_t kLetsmeshUsBit = 0x04; static constexpr uint8_t kCustomBit = 0x08; + static constexpr uint8_t kMeshmapperBit = 0x10; + static constexpr uint8_t kBrokerMask = 0x1F; static constexpr uint8_t kMaxEnabledBrokers = 2; - static const BrokerSpec kBrokerSpecs[4]; + static constexpr size_t kBrokerCount = 5; + static const BrokerSpec kBrokerSpecs[kBrokerCount]; static bool isUnsetIataValue(const char* iata); static const char* brokerCaCert(const BrokerSpec& spec); - BrokerState _brokers[4]; + BrokerState _brokers[kBrokerCount]; static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); static void scheduleBrokerRetry(BrokerState& broker, unsigned long now_ms, bool count_failure); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 9ee93293..c58c7cbc 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -422,7 +422,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .placeholder-slot { display:block; width:44px; height:44px; } .savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; } .savebtn:hover { background:var(--accent-hover); } - .broker-stack { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,2fr); gap:12px; align-items:start; } + .broker-stack { display:grid; gap:12px; align-items:start; } + .broker-slots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } + .broker-slot { display:grid; gap:6px; align-content:start; } .broker-group { display:grid; gap:8px; align-content:start; } .broker-group.wide { grid-column:1 / -1; } .broker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } @@ -530,7 +532,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( body { font-size:15px; } main { padding:16px; } .card { padding:16px; margin-bottom:14px; } - .row, .row3, .row-command, .metric-grid, .trend-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-grid, .broker-grid.single, .broker-grid.two, .broker-grid.one-two { grid-template-columns:1fr; } + .row, .row3, .row-command, .metric-grid, .trend-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-slots, .broker-grid, .broker-grid.single, .broker-grid.two, .broker-grid.one-two { grid-template-columns:1fr; } .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; } .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; } .row-command button { width:100%; } @@ -963,76 +965,24 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
-
-
EastMesh
-
-
-
-
-
-
EastMesh AU
-
Off
-
-
-
- - -
- -
-
+
+
+ + +
+
+ +
-
-
LetsMesh
-
-
-
-
-
-
LetsMesh Mode
-
Off
-
-
-
- - -
- - -
-
-
-
-
-
Custom
-
-
-
-
-
-
Custom MQTT
-
Off
-
-
-
- - -
- -
-
+ + + + + +