From 9e569e92b41890d3d14acf4b309eec1fd8059e28 Mon Sep 17 00:00:00 2001 From: xJARiD Date: Thu, 9 Apr 2026 16:56:34 +1000 Subject: [PATCH 1/3] Delete CNAME --- CNAME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 CNAME diff --git a/CNAME b/CNAME deleted file mode 100644 index 1b7d1671..00000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.meshcore.nz \ No newline at end of file From 90d2c9302fd2268c710dee2cc87d27c66fc07a3d Mon Sep 17 00:00:00 2001 From: prabathbr Date: Wed, 24 Jun 2026 21:42:22 +1000 Subject: [PATCH 2/3] test MQTT bridge --- eastmesh-docs/custom-cli.md | 28 ++- eastmesh-docs/web-panel.md | 2 + examples/simple_repeater/MyMesh.cpp | 8 + examples/simple_repeater/MyMesh.h | 7 + src/helpers/CommonCLI.cpp | 61 ++++- src/helpers/CommonCLI.h | 6 +- src/helpers/bridges/MQTTBridge.cpp | 308 ++++++++++++++++++++++++++ src/helpers/bridges/MQTTBridge.h | 52 +++++ src/helpers/web/WebPanelServer.cpp | 53 ++++- variants/eastmesh_mqtt/platformio.ini | 4 +- 10 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 src/helpers/bridges/MQTTBridge.cpp create mode 100644 src/helpers/bridges/MQTTBridge.h diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index 04c230cf..4d06fab4 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -126,7 +126,7 @@ Default servers are `au.pool.ntp.org`, `time.google.com`, and `time.cloudflare.c ### ESP-NOW Bridge Settings For Observer ESP-NOW Builds -These commands are available on `*_repeater_observer_espnow` firmware targets. +These commands are available on `*_repeater_observer_espnow` firmware targets that use the ESP-NOW bridge transport. Bridge commands are for local ESP-NOW bridge use between nearby repeaters, such as linking repeaters on `Australia (Narrow)` and `Australia (Mid)`. They are not MQTT-over-WAN, VPN, or internet bridge controls. @@ -156,6 +156,32 @@ Example: OK ``` +### MQTT Bridge Settings For Observer MQTT Bridge Builds + +These commands are available on observer builds that compile with the MQTT mesh bridge transport (for example `Xiao_S3_WIO_repeater_observer_espnow` when built with `WITH_MQTT_BRIDGE`). + +The MQTT bridge forwards raw mesh packets over a shared topic at a peer MQTT broker. It is separate from MQTT uplink publishing to EastMesh or MeshMapper brokers. + +- `get bridge.type`: returns `mqtt` on MQTT bridge builds. +- `get bridge.peer.host`: shows the configured peer MQTT broker host. +- `set bridge.peer.host `: sets the peer MQTT broker host and restarts the bridge. +- `get bridge.peer.port`: shows the configured peer MQTT broker port (defaults to `1883` when unset). +- `set bridge.peer.port `: sets the peer MQTT broker port and restarts the bridge. +- `get bridge.secret` / `set bridge.secret `: shared XOR key used by all bridge nodes on the same bridge network. + +Both bridge nodes must use the same peer broker address, port, and `bridge.secret`. Mesh packets are published and subscribed on topic `meshcore/bridge/packets`. + +Example: + +```text +> set bridge.peer.host 192.168.1.10 +OK +> set bridge.peer.port 1883 +OK +> set bridge.secret my-shared-secret +OK +``` + ### Web Panel Controls - `get web` diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index dce014b8..399cf135 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -255,6 +255,7 @@ This section includes: - `mqtt.email`: owner contact email. - 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. +- mesh bridge peer MQTT `host:port`, shown on MQTT bridge builds (`get bridge.type` returns `mqtt`). This is separate from MQTT uplink brokers and points at the shared peer broker used for bidirectional mesh packet bridging. `UNSET - To be configured` is the default for new observer installs until a real saved value exists. @@ -269,6 +270,7 @@ Notes: - turning off a connected MQTT server publishes retained offline status before the client disconnects - changing `mqtt.iata` away from a configured value publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path - at most two MQTT brokers can be enabled at once +- on MQTT bridge builds, both bridge nodes must use the same peer broker host, port, and `bridge.secret` ## `/stats` Overview diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9996b884..1dd57add 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -533,6 +533,8 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender reply_data[8] |= 0x01; // is bridge, type UART #elif WITH_ESPNOW_BRIDGE reply_data[8] |= 0x03; // is bridge, type ESP-NOW +#elif WITH_MQTT_BRIDGE + reply_data[8] |= 0x04; // is bridge, type MQTT #endif if (_prefs.disable_fwd) { // is this repeater currently disabled reply_data[8] |= 0x80; // is disabled @@ -1198,6 +1200,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) #endif +#if defined(WITH_MQTT_BRIDGE) + , bridge(&_prefs, _mgr, &rtc) +#endif #if defined(WITH_MQTT_UPLINK) , mqtt(rtc, self_id) #endif @@ -1254,6 +1259,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.bridge_channel = 1; // channel 1 StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); +#if defined(WITH_MQTT_BRIDGE) + _prefs.bridge_peer_port = 1883; +#endif // GPS defaults _prefs.gps_enabled = 0; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 192b937b..b8bd5657 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -23,6 +23,11 @@ #define WITH_BRIDGE #endif +#ifdef WITH_MQTT_BRIDGE +#include "helpers/bridges/MQTTBridge.h" +#define WITH_BRIDGE +#endif + #ifdef WITH_MQTT_UPLINK #include #endif @@ -170,6 +175,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelComm RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; +#elif defined(WITH_MQTT_BRIDGE) + MQTTBridge bridge; #endif #ifdef WITH_MQTT_UPLINK MQTTUplink mqtt; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e5ed752b..7afb705d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -103,7 +103,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->flood_max_advert)) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 } - // next: 296 + if (file.available() >= (int)sizeof(_prefs->bridge_peer_host)) { + file.read((uint8_t *)&_prefs->bridge_peer_host, sizeof(_prefs->bridge_peer_host)); // 296 + } + if (file.available() >= (int)sizeof(_prefs->bridge_peer_port)) { + file.read((uint8_t *)&_prefs->bridge_peer_port, sizeof(_prefs->bridge_peer_port)); // 360 + } + // next: 362 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -125,6 +131,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); + if (_prefs->bridge_peer_port > 65535) { + _prefs->bridge_peer_port = 1883; + } _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); @@ -200,7 +209,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->fan_mode, sizeof(_prefs->fan_mode)); // 292 file.write((uint8_t *)&_prefs->fan_timeout_secs, sizeof(_prefs->fan_timeout_secs)); // 293 file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 - // next: 296 + file.write((uint8_t *)&_prefs->bridge_peer_host, sizeof(_prefs->bridge_peer_host)); // 296 + file.write((uint8_t *)&_prefs->bridge_peer_port, sizeof(_prefs->bridge_peer_port)); // 360 + // next: 362 file.close(); } @@ -770,6 +781,42 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->restartBridge(); savePrefs(); strcpy(reply, "OK"); +#endif +#ifdef WITH_MQTT_BRIDGE + } else if (memcmp(config, "bridge.peer.host ", 17) == 0) { + const char* host = &config[17]; + size_t oi = 0; + char cleaned[sizeof(_prefs->bridge_peer_host)]; + memset(cleaned, 0, sizeof(cleaned)); + for (size_t i = 0; host[i] != 0 && oi + 1 < sizeof(cleaned); ++i) { + unsigned char c = static_cast(host[i]); + if (c <= ' ' || c == '/' || c == ':' || c == '\\') { + strcpy(reply, "Error: invalid host"); + return; + } + cleaned[oi++] = static_cast(c); + } + cleaned[oi] = 0; + StrHelper::strncpy(_prefs->bridge_peer_host, cleaned, sizeof(_prefs->bridge_peer_host)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "bridge.peer.port ", 17) == 0) { + char* end = nullptr; + unsigned long parsed = strtoul(&config[17], &end, 10); + if (end == &config[17] || *end != 0 || parsed == 0 || parsed > 65535UL) { + strcpy(reply, "Error: port must be 1-65535"); + return; + } + _prefs->bridge_peer_port = static_cast(parsed); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "bridge.secret ", 14) == 0) { + StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); #endif } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); @@ -884,6 +931,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep "rs232" #elif WITH_ESPNOW_BRIDGE "espnow" +#elif WITH_MQTT_BRIDGE + "mqtt" #else "none" #endif @@ -905,6 +954,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel); } else if (memcmp(config, "bridge.secret", 13) == 0) { sprintf(reply, "> %s", _prefs->bridge_secret); +#endif +#ifdef WITH_MQTT_BRIDGE + } else if (memcmp(config, "bridge.peer.host", 16) == 0) { + sprintf(reply, "> %s", _prefs->bridge_peer_host); + } else if (memcmp(config, "bridge.peer.port", 16) == 0) { + sprintf(reply, "> %u", _prefs->bridge_peer_port != 0 ? _prefs->bridge_peer_port : 1883); + } else if (memcmp(config, "bridge.secret", 13) == 0) { + sprintf(reply, "> %s", _prefs->bridge_secret); #endif } else if (memcmp(config, "bootloader.ver", 14) == 0) { #ifdef NRF52_PLATFORM diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 7310ac78..a7673766 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -6,7 +6,7 @@ #include #include -#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE) #define WITH_BRIDGE #endif @@ -50,7 +50,7 @@ struct NodePrefs { // persisted to file uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logTx) 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) + char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW / MQTT) // Power setting uint8_t powersaving_enabled; // boolean // Gps settings @@ -65,6 +65,8 @@ struct NodePrefs { // persisted to file uint8_t loop_detect; uint8_t fan_mode; uint16_t fan_timeout_secs; + char bridge_peer_host[64]; // peer MQTT broker host (MQTT bridge only) + uint16_t bridge_peer_port; // peer MQTT broker port (MQTT bridge only, default 1883) }; class CommonCLICallbacks { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp new file mode 100644 index 00000000..362fca4f --- /dev/null +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -0,0 +1,308 @@ +#include "MQTTBridge.h" + +#ifdef WITH_MQTT_BRIDGE + +#if defined(ESP_PLATFORM) + +#include +#include +#include +#include + +#ifndef BRIDGE_DEBUG + #define BRIDGE_DEBUG 0 +#endif + +#if BRIDGE_DEBUG + #define BRIDGE_DEBUG_PRINTLN(...) Serial.printf(__VA_ARGS__) +#else + #define BRIDGE_DEBUG_PRINTLN(...) do { } while (0) +#endif + +namespace { +constexpr unsigned long kConnectRetryBaseMillis = 10000; +constexpr unsigned long kConnectRetryMaxMillis = 120000; + +unsigned long connectRetryDelayMillis(uint8_t failures) { + unsigned long delay_ms = kConnectRetryBaseMillis; + if (failures > 0) { + uint8_t shifts = min(failures - 1, 3); + delay_ms <<= shifts; + } + if (delay_ms > kConnectRetryMaxMillis) { + delay_ms = kConnectRetryMaxMillis; + } + return delay_ms; +} + +bool peerHostConfigured(const NodePrefs *prefs) { + return prefs != nullptr && prefs->bridge_peer_host[0] != 0; +} + +uint16_t peerPort(const NodePrefs *prefs) { + return prefs->bridge_peer_port != 0 ? prefs->bridge_peer_port : 1883; +} +} // namespace + +MQTTBridge *MQTTBridge::_instance = nullptr; +const char *MQTTBridge::kBridgeTopic = "meshcore/bridge/packets"; + +MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) + : BridgeBase(prefs, mgr, rtc), _client(nullptr), _connected(false), _started(false), + _next_connect_attempt(0), _reconnect_failures(0) { + _instance = this; + _client_id[0] = 0; +} + +void MQTTBridge::xorCrypt(uint8_t *data, size_t len) { + size_t keyLen = strlen(_prefs->bridge_secret); + if (keyLen == 0) { + return; + } + for (size_t i = 0; i < len; i++) { + data[i] ^= _prefs->bridge_secret[i % keyLen]; + } +} + +void MQTTBridge::destroyClient() { + if (_client != nullptr) { + esp_mqtt_client_stop(_client); + esp_mqtt_client_destroy(_client); + _client = nullptr; + } + _connected = false; + _started = false; +} + +void MQTTBridge::begin() { + BRIDGE_DEBUG_PRINTLN("MQTT bridge initializing\n"); + if (!peerHostConfigured(_prefs)) { + BRIDGE_DEBUG_PRINTLN("MQTT bridge peer host not configured\n"); + return; + } + + uint8_t mac[6]; + WiFi.macAddress(mac); + snprintf(_client_id, sizeof(_client_id), "mc-br-%02x%02x%02x", mac[3], mac[4], mac[5]); + + _initialized = true; + _next_connect_attempt = 0; + _reconnect_failures = 0; +} + +void MQTTBridge::end() { + BRIDGE_DEBUG_PRINTLN("MQTT bridge stopping\n"); + destroyClient(); + _initialized = false; +} + +void MQTTBridge::onMqttConnected() { + _connected = true; + _reconnect_failures = 0; + _next_connect_attempt = 0; + if (_client != nullptr) { + esp_mqtt_client_subscribe(_client, kBridgeTopic, 0); + } + BRIDGE_DEBUG_PRINTLN("MQTT bridge connected to %s:%u\n", _prefs->bridge_peer_host, + static_cast(peerPort(_prefs))); +} + +void MQTTBridge::onMqttDisconnected() { + _connected = false; + _started = false; + destroyClient(); + _next_connect_attempt = millis() + connectRetryDelayMillis(_reconnect_failures); + if (_reconnect_failures < 255) { + ++_reconnect_failures; + } +} + +void MQTTBridge::mqttEventHandler(void *handler_args, esp_event_base_t, int32_t event_id, void *event_data) { + auto *bridge = static_cast(handler_args); + if (bridge == nullptr) { + return; + } + + switch (event_id) { + case MQTT_EVENT_CONNECTED: + bridge->onMqttConnected(); + break; + case MQTT_EVENT_DISCONNECTED: + bridge->onMqttDisconnected(); + break; + case MQTT_EVENT_DATA: { + auto *event = static_cast(event_data); + if (event != nullptr && event->data_len > 0) { + bridge->handleMqttData(reinterpret_cast(event->data), + static_cast(event->data_len)); + } + break; + } + default: + break; + } +} + +bool MQTTBridge::ensureClient() { + if (_client != nullptr) { + return true; + } + if (!peerHostConfigured(_prefs)) { + return false; + } + if (WiFi.status() != WL_CONNECTED) { + return false; + } + + esp_mqtt_client_config_t cfg = {}; +#if ESP_IDF_VERSION_MAJOR >= 5 + cfg.broker.address.hostname = _prefs->bridge_peer_host; + cfg.broker.address.port = peerPort(_prefs); + cfg.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; + cfg.credentials.client_id = _client_id; + cfg.session.keepalive = 30; + cfg.network.reconnect_timeout_ms = 10000; + cfg.network.timeout_ms = 10000; + cfg.network.disable_auto_reconnect = true; + cfg.buffer.size = MAX_MQTT_PACKET_SIZE; + cfg.buffer.out_size = MAX_MQTT_PACKET_SIZE; +#else + cfg.host = _prefs->bridge_peer_host; + cfg.port = peerPort(_prefs); + cfg.transport = MQTT_TRANSPORT_OVER_TCP; + cfg.client_id = _client_id; + cfg.keepalive = 30; + cfg.buffer_size = MAX_MQTT_PACKET_SIZE; + cfg.out_buffer_size = MAX_MQTT_PACKET_SIZE; + cfg.reconnect_timeout_ms = 10000; + cfg.network_timeout_ms = 10000; + cfg.disable_auto_reconnect = true; +#endif + + _client = esp_mqtt_client_init(&cfg); + if (_client == nullptr) { + BRIDGE_DEBUG_PRINTLN("MQTT bridge client init failed\n"); + return false; + } + + esp_mqtt_client_register_event(_client, MQTT_EVENT_ANY, &MQTTBridge::mqttEventHandler, this); + if (esp_mqtt_client_start(_client) != ESP_OK) { + BRIDGE_DEBUG_PRINTLN("MQTT bridge client start failed\n"); + destroyClient(); + return false; + } + + _started = true; + return true; +} + +void MQTTBridge::loop() { + if (!_initialized) { + return; + } + + if (WiFi.status() != WL_CONNECTED) { + if (_client != nullptr) { + destroyClient(); + } + return; + } + + if (_client == nullptr) { + if (_next_connect_attempt != 0 && millis() < _next_connect_attempt) { + return; + } + ensureClient(); + } +} + +void MQTTBridge::handleMqttData(const uint8_t *data, size_t len) { + if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) { + BRIDGE_DEBUG_PRINTLN("MQTT RX packet too small, len=%u\n", static_cast(len)); + return; + } + + if (len > MAX_MQTT_PACKET_SIZE) { + BRIDGE_DEBUG_PRINTLN("MQTT RX packet too large, len=%u\n", static_cast(len)); + return; + } + + uint16_t received_magic = (data[0] << 8) | data[1]; + if (received_magic != BRIDGE_PACKET_MAGIC) { + BRIDGE_DEBUG_PRINTLN("MQTT RX invalid magic 0x%04X\n", received_magic); + return; + } + + uint8_t decrypted[MAX_MQTT_PACKET_SIZE]; + const size_t encryptedDataLen = len - BRIDGE_MAGIC_SIZE; + memcpy(decrypted, data + BRIDGE_MAGIC_SIZE, encryptedDataLen); + + xorCrypt(decrypted, encryptedDataLen); + + uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1]; + const size_t payloadLen = encryptedDataLen - BRIDGE_CHECKSUM_SIZE; + + if (!validateChecksum(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen, received_checksum)) { + BRIDGE_DEBUG_PRINTLN("MQTT RX checksum mismatch, rcv=0x%04X\n", received_checksum); + return; + } + + mesh::Packet *pkt = _mgr->allocNew(); + if (!pkt) { + return; + } + + if (pkt->readFrom(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen)) { + onPacketReceived(pkt); + } else { + _mgr->free(pkt); + } +} + +void MQTTBridge::sendPacket(mesh::Packet *packet) { + if (!_initialized || !_connected || _client == nullptr || packet == nullptr) { + return; + } + + if (_seen_packets.hasSeen(packet)) { + return; + } + + uint8_t sizingBuffer[MAX_PAYLOAD_SIZE]; + uint16_t meshPacketLen = packet->writeTo(sizingBuffer); + if (meshPacketLen > MAX_PAYLOAD_SIZE) { + BRIDGE_DEBUG_PRINTLN("MQTT TX packet too large (payload=%u, max=%u)\n", meshPacketLen, + static_cast(MAX_PAYLOAD_SIZE)); + return; + } + + uint8_t buffer[MAX_MQTT_PACKET_SIZE]; + buffer[0] = (BRIDGE_PACKET_MAGIC >> 8) & 0xFF; + buffer[1] = BRIDGE_PACKET_MAGIC & 0xFF; + + const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE; + memcpy(buffer + packetOffset, sizingBuffer, meshPacketLen); + + uint16_t checksum = fletcher16(buffer + packetOffset, meshPacketLen); + buffer[2] = (checksum >> 8) & 0xFF; + buffer[3] = checksum & 0xFF; + + xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE); + + const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen; + int msg_id = esp_mqtt_client_publish(_client, kBridgeTopic, reinterpret_cast(buffer), + static_cast(totalPacketSize), 0, 0); + if (msg_id >= 0) { + BRIDGE_DEBUG_PRINTLN("MQTT TX, len=%u\n", meshPacketLen); + } else { + BRIDGE_DEBUG_PRINTLN("MQTT TX FAILED\n"); + } +} + +void MQTTBridge::onPacketReceived(mesh::Packet *packet) { + handleReceivedPacket(packet); +} + +#endif // ESP_PLATFORM + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h new file mode 100644 index 00000000..bfa9880a --- /dev/null +++ b/src/helpers/bridges/MQTTBridge.h @@ -0,0 +1,52 @@ +#pragma once + +#include "helpers/bridges/BridgeBase.h" + +#ifdef WITH_MQTT_BRIDGE + +#if defined(ESP_PLATFORM) +#include +#endif + +/** + * @brief Bridge implementation using MQTT for bidirectional mesh packet transport + * + * Publishes and subscribes on a shared topic at a peer MQTT broker. Uses the same + * binary framing and XOR encryption as other bridge types for network isolation. + */ +class MQTTBridge : public BridgeBase { +private: + static MQTTBridge *_instance; + + static void mqttEventHandler(void *handler_args, esp_event_base_t base, int32_t event_id, + void *event_data); + + static const char *kBridgeTopic; + static const size_t MAX_MQTT_PACKET_SIZE = 512; + static const size_t MAX_PAYLOAD_SIZE = MAX_MQTT_PACKET_SIZE - (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE); + + esp_mqtt_client_handle_t _client; + bool _connected; + bool _started; + unsigned long _next_connect_attempt; + uint8_t _reconnect_failures; + char _client_id[24]; + + void xorCrypt(uint8_t *data, size_t len); + void destroyClient(); + bool ensureClient(); + void handleMqttData(const uint8_t *data, size_t len); + void onMqttConnected(); + void onMqttDisconnected(); + +public: + MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc); + + void begin() override; + void end() override; + void loop() override; + void onPacketReceived(mesh::Packet *packet) override; + void sendPacket(mesh::Packet *packet) override; +}; + +#endif diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index c58c7cbc..4c463283 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1026,6 +1026,15 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
A maximum of two MQTT brokers can be enabled at once.
+ @@ -2833,6 +2842,39 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (!portResult.ok) return; input.value = `${parsed.host}:${parsed.port}`; } + async function loadBridgePeerEndpoint(options = {}) { + const config = document.getElementById("bridgePeerConfig"); + if (!config) return; + const typeResult = await runCommand("get bridge.type", options); + if (!typeResult.ok || parseReplyValue(typeResult.text) !== "mqtt") { + config.style.display = "none"; + return; + } + config.style.display = ""; + const hostResult = await runCommand("get bridge.peer.host", options); + if (!hostResult.ok) return; + const portResult = await runCommand("get bridge.peer.port", options); + if (!portResult.ok) return; + const host = parseReplyValue(hostResult.text); + const port = parseReplyValue(portResult.text); + const input = document.getElementById("bridgePeerEndpoint"); + if (!input) return; + input.value = host ? `${host}:${port}` : ""; + } + async function saveBridgePeerEndpoint() { + const input = document.getElementById("bridgePeerEndpoint"); + if (!input) return; + const parsed = parseCustomEndpoint(input.value); + if (!parsed) { + statusEl.textContent = "Use host:port, for example 192.168.1.10:1883"; + return; + } + const hostResult = await runCommand("set bridge.peer.host " + parsed.host); + if (!hostResult.ok) return; + const portResult = await runCommand("set bridge.peer.port " + parsed.port); + if (!portResult.ok) return; + input.value = `${parsed.host}:${parsed.port}`; + } async function loadRadioConfig(options = {}) { const result = await runCommand("get radio", options); if (!result.ok) { @@ -3015,6 +3057,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( }); document.getElementById("refreshCustomEndpointBtn").onclick = () => loadCustomEndpoint(); document.getElementById("saveCustomEndpointBtn").onclick = () => saveCustomEndpoint(); + const refreshBridgePeerEndpointBtn = document.getElementById("refreshBridgePeerEndpointBtn"); + if (refreshBridgePeerEndpointBtn) { + refreshBridgePeerEndpointBtn.onclick = () => loadBridgePeerEndpoint(); + } + const saveBridgePeerEndpointBtn = document.getElementById("saveBridgePeerEndpointBtn"); + if (saveBridgePeerEndpointBtn) { + saveBridgePeerEndpointBtn.onclick = () => saveBridgePeerEndpoint(); + } const customTransportSlider = document.getElementById("mqttCustomTransport"); if (customTransportSlider) { customTransportSlider.addEventListener("input", () => { @@ -3197,7 +3247,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( () => loadBrokerState("get mqtt.custom", "mqttCustom", quiet), () => loadCustomEndpoint(quiet), () => loadCustomTransport(quiet), - () => loadField("get mqtt.custom.username", "mqttCustomUsername", null, quiet) + () => loadField("get mqtt.custom.username", "mqttCustomUsername", null, quiet), + () => loadBridgePeerEndpoint(quiet) ]); if (!isCurrentPageLoad(generation)) return; statusEl.textContent = "Ready"; diff --git a/variants/eastmesh_mqtt/platformio.ini b/variants/eastmesh_mqtt/platformio.ini index e64f4aac..694fd1eb 100644 --- a/variants/eastmesh_mqtt/platformio.ini +++ b/variants/eastmesh_mqtt/platformio.ini @@ -1156,6 +1156,6 @@ build_src_filter = ${env:WHY2025_badge_repeater_observer.build_src_filter} extends = env:Xiao_S3_WIO_repeater_observer build_flags = ${env:Xiao_S3_WIO_repeater_observer.build_flags} - -D WITH_ESPNOW_BRIDGE=1 + -D WITH_MQTT_BRIDGE=1 build_src_filter = ${env:Xiao_S3_WIO_repeater_observer.build_src_filter} - + + + From e706d9044ceafe4f8d26f028f05fc5655c58f402 Mon Sep 17 00:00:00 2001 From: prabathbr Date: Wed, 24 Jun 2026 23:04:42 +1000 Subject: [PATCH 3/3] test with single radio - mqtt success at broker --- .../setup-build-environment/action.yml | 7 + ...h-build-observer-mqtt-bridge-firmwares.yml | 41 ++++++ AGENTS.md | 4 +- README.md | 123 ++++++++++++++++++ RELEASE.md | 9 +- eastmesh-build.sh | 12 ++ eastmesh-docs/boards.md | 4 +- eastmesh-docs/custom-cli.md | 12 +- eastmesh-docs/index.md | 9 +- eastmesh-docs/local-builds.md | 17 ++- eastmesh-docs/releases.md | 24 +++- eastmesh-docs/web-panel.md | 4 +- release-notes.yml | 25 ++++ src/helpers/CommonCLI.cpp | 26 +++- src/helpers/CommonCLI.h | 2 + src/helpers/bridges/MQTTBridge.cpp | 45 +++++-- src/helpers/bridges/MQTTBridge.h | 2 + src/helpers/mqtt/MQTTPrefs.cpp | 12 ++ src/helpers/mqtt/MQTTPrefs.h | 1 + src/helpers/web/WebPanelServer.cpp | 21 ++- variants/eastmesh_mqtt/platformio.ini | 9 ++ 21 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/eastmesh-build-observer-mqtt-bridge-firmwares.yml diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 3354c260..01c79f2e 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -44,6 +44,7 @@ runs: # companion-wifi-v1.15.0 # repeater-bridge-espnow-v1.15.0 # observer-eastmesh-bridge-espnow-v2026.5.1 + # observer-eastmesh-bridge-mqtt-v2026.7.0 # observer-eastmesh-v2026.5.1 # with OFFICIAL_MESHCORE_VERSION=v1.15.0 configured as a GitHub variable. # @@ -85,6 +86,12 @@ runs: exit 1 fi EASTMESH_VERSION="${BASH_REMATCH[1]}" + elif [[ "$GIT_TAG_NAME" =~ ^observer-eastmesh-bridge-mqtt-(v[[:alnum:]._-]+)$ ]]; then + if [[ -z "$STATIC_OFFICIAL_VERSION" ]]; then + echo "OFFICIAL_MESHCORE_VERSION must be set for EastMesh release tags" >&2 + exit 1 + fi + EASTMESH_VERSION="${BASH_REMATCH[1]}" elif [[ "$GIT_TAG_NAME" =~ ^companion-wifi-(v[[:alnum:]._-]+)$ ]]; then FIRMWARE_VERSION="${BASH_REMATCH[1]}" elif [[ "$GIT_TAG_NAME" =~ ^repeater-bridge-espnow-(v[[:alnum:]._-]+)$ ]]; then diff --git a/.github/workflows/eastmesh-build-observer-mqtt-bridge-firmwares.yml b/.github/workflows/eastmesh-build-observer-mqtt-bridge-firmwares.yml new file mode 100644 index 00000000..3b47c631 --- /dev/null +++ b/.github/workflows/eastmesh-build-observer-mqtt-bridge-firmwares.yml @@ -0,0 +1,41 @@ +name: EastMesh Build Observer MQTT Bridge Firmwares + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - "observer-eastmesh-bridge-mqtt-v*" + +jobs: + build: + runs-on: ubuntu-latest + env: + OFFICIAL_MESHCORE_VERSION: ${{ vars.OFFICIAL_MESHCORE_VERSION }} + PLATFORMIO_BUILD_FLAGS: -UMQTT_DEBUG + steps: + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Firmwares + run: /usr/bin/env bash eastmesh-build.sh build-observer-mqtt-bridge-firmwares + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v7 + with: + name: observer-bridge-mqtt-firmwares + path: out + + - name: Create Release + uses: softprops/action-gh-release@v3 + if: startsWith(github.ref, 'refs/tags/') + with: + name: Observer EastMesh Firmware with MQTT Bridge ${{ env.RELEASE_VERSION }} + body: "" + draft: true + files: out/* diff --git a/AGENTS.md b/AGENTS.md index 56908eaf..e38df7ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,6 +172,7 @@ Current tag formats: git tag companion-wifi-v1.15.0 git tag repeater-bridge-espnow-v1.15.0 git tag observer-eastmesh-bridge-espnow-v2026.5.1 +git tag observer-eastmesh-bridge-mqtt-v2026.7.0 git tag observer-eastmesh-v2026.5.1 ``` @@ -180,8 +181,9 @@ Rules: - `companion-wifi` tags use the upstream MeshCore version directly - `repeater-bridge-espnow` tags use the upstream MeshCore version directly - `observer-eastmesh-bridge-espnow` tags use the EastMesh release version in the tag +- `observer-eastmesh-bridge-mqtt` tags use the EastMesh release version in the tag - `observer-eastmesh` tags use the EastMesh release version in the tag -- GitHub Actions variable `OFFICIAL_MESHCORE_VERSION` supplies the upstream base version for Observer EastMesh and Observer ESP-NOW EastMesh release builds +- GitHub Actions variable `OFFICIAL_MESHCORE_VERSION` supplies the upstream base version for Observer EastMesh, Observer ESP-NOW EastMesh, and Observer MQTT Bridge EastMesh release builds - if the upstream MeshCore release version changes, update `OFFICIAL_MESHCORE_VERSION` in GitHub before cutting release tags Typical release flow: diff --git a/README.md b/README.md index ecbbed2f..8988e468 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ CoreScope () offers visibility into the network, inclu - native WiFi - MQTT over WSS with JWT auth - optional local HTTPS config panel on supported ESP32 targets +- `*_repeater_observer_mqtt_bridge` firmware targets that add a **bidirectional MQTT mesh bridge** to a peer broker (separate from MQTT uplink) +- `*_repeater_observer_espnow` firmware targets that add a local ESP-NOW mesh bridge - `*_companion_radio_wifi` firmware targets for WiFi-connected companion devices - EastMesh-specific release workflows and versioning on top of upstream MeshCore releases - docs and release guidance for EastMesh users instead of the full upstream MeshCore docs set @@ -127,6 +129,8 @@ uv run --group docs zensical build - shared EastMesh observer env definitions - [`examples/simple_repeater/MyMesh.cpp`](./examples/simple_repeater/MyMesh.cpp) - repeater CLI wiring, MQTT command surface, and web allowlist integration +- [`src/helpers/bridges/MQTTBridge.cpp`](./src/helpers/bridges/MQTTBridge.cpp) + - bidirectional MQTT mesh bridge (peer broker TCP, topic `meshcore/bridge/packets`) - [`src/helpers/mqtt/MQTTUplink.cpp`](./src/helpers/mqtt/MQTTUplink.cpp) - MQTT uplink implementation, HTTPS web panel, WSS/JWT handling, and repeater WiFi control - [`examples/companion_radio`](./examples/companion_radio) @@ -158,6 +162,121 @@ uv run --group docs zensical build - owner public key and email - local web panel enablement +### MQTT Mesh Bridge (Observer) + +The `observer-eastmesh-bridge-mqtt` release track (`*_repeater_observer_mqtt_bridge`) bridges **raw mesh packets** between repeaters through a **peer MQTT broker you run** (for example Mosquitto on a LAN PC). This is **not** the same as MQTT uplink to EastMesh/MeshMapper: + +| | MQTT uplink (observer) | MQTT mesh bridge | +| --- | --- | --- | +| Purpose | Publish JSON telemetry to curated brokers | Forward encrypted mesh packets between radios | +| Brokers | `eastmesh-au`, `meshmapper`, custom WSS | Your peer broker (`host:port`, TCP) | +| Topic | `meshcore//...` (per IATA) | `meshcore/bridge/packets` (fixed) | +| Default on bridge builds | Off by default (enable in web panel / CLI) | On when `bridge.enabled` is on | + +**First supported target:** `Xiao_S3_WIO_repeater_observer_mqtt_bridge` + +**Related track:** `observer-eastmesh-bridge-espnow` (`*_repeater_observer_espnow`) uses ESP-NOW instead of MQTT for local bridging. + +Build example: + +```bash +uv run pio run -e Xiao_S3_WIO_repeater_observer_mqtt_bridge +``` + +Flash (update): + +```bash +uv run pio run -e Xiao_S3_WIO_repeater_observer_mqtt_bridge -t upload --upload-port COM10 +``` + +After a **full flash erase**, flash the merged image at `0x0` instead of only `firmware.bin`: + +```bash +uv run pio run -e Xiao_S3_WIO_repeater_observer_mqtt_bridge -t mergebin +uv run pio pkg exec -p tool-esptoolpy -- esptool.py --chip esp32s3 --port COM10 write_flash 0x0 .pio/build/Xiao_S3_WIO_repeater_observer_mqtt_bridge/firmware-merged.bin +``` + +#### Peer Mosquitto broker (LAN) + +Each bridge node connects to the **same** peer broker with the **same** credentials and `bridge.secret`. + +1. Install [Mosquitto](https://mosquitto.org/download/) on a machine reachable from both repeaters (for example `192.168.1.145`). +2. Edit `mosquitto.conf` (Windows service install: `C:\Program Files\Mosquitto\mosquitto.conf`) and add: + +```conf +listener 1883 0.0.0.0 +allow_anonymous false +password_file C:\Program Files\Mosquitto\passwd +``` + +For lab/testing only, `allow_anonymous true` works without a password file. + +3. Create a user (admin CMD): + +```cmd +cd "C:\Program Files\Mosquitto" +mosquitto_passwd -c passwd bridgeuser +``` + +4. Restart the **service** (do not run a second `mosquitto -v` while the service owns port 1883): + +```powershell +Restart-Service mosquitto +``` + +5. Confirm LAN listen and open the firewall: + +```cmd +netstat -ano | findstr ":1883" +``` + +Expect `0.0.0.0:1883`, not only `127.0.0.1:1883`. + +6. Test from another machine: + +```cmd +mosquitto_pub -h 192.168.1.145 -p 1883 -u bridgeuser -P your-password -t test -m hello +mosquitto_sub -h 192.168.1.145 -p 1883 -u bridgeuser -P your-password -t meshcore/bridge/packets -v +``` + +Binary garbage on `meshcore/bridge/packets` is normal — payloads are XOR-encrypted mesh frames, not text. + +#### Repeater configuration + +Configure **both** bridge nodes identically for broker access; use the same `bridge.secret` on every node in the bridge group. + +Serial or web panel CLI: + +```text +set wifi.ssid YourNetwork +set wifi.pwd YourWiFiPassword +set bridge.peer.host 192.168.1.145 +set bridge.peer.port 1883 +set bridge.peer.username bridgeuser +set bridge.peer.password your-password +set bridge.secret your-shared-bridge-secret +``` + +Web panel: **MQTT Settings** → **Mesh bridge peer MQTT** (`host:port`, username, password). The section appears when `get bridge.type` returns `mqtt`. + +Useful checks: + +```text +get bridge.type +get bridge.peer.host +get bridge.enabled +``` + +Default admin password on dev builds is usually `password` unless you changed it. + +Packets use magic `0xC03E`, a Fletcher checksum, XOR encryption with `bridge.secret`, then publish/subscribe on `meshcore/bridge/packets`. Duplicate detection limits loops when both nodes see the same traffic. + +More detail: + +- [Custom CLI — MQTT bridge settings](./eastmesh-docs/custom-cli.md) +- [Web panel — MQTT settings and bridge peer fields](./eastmesh-docs/web-panel.md) +- [Releases — track comparison](./eastmesh-docs/releases.md) + ### Local Web Panel On supported `*_repeater_observer` ESP32 targets, the repeater can expose a local HTTPS config panel over WiFi. @@ -197,6 +316,7 @@ These rescue commands are only available after entering `CLI Rescue`: - `.github/workflows/eastmesh-build-observer-firmwares.yml` - `.github/workflows/eastmesh-build-repeater-bridge-espnow-firmwares.yml` - `.github/workflows/eastmesh-build-observer-espnow-firmwares.yml` +- `.github/workflows/eastmesh-build-observer-mqtt-bridge-firmwares.yml` - `.github/workflows/eastmesh-pr-build-check.yml` - `.github/workflows/eastmesh-push-build-check.yml` - `.github/workflows/eastmesh-github-pages.yml` @@ -209,6 +329,7 @@ The current release workflows intentionally focus only on: - `repeater-bridge-espnow` - `observer-eastmesh` - `observer-eastmesh-bridge-espnow` +- `observer-eastmesh-bridge-mqtt` ## Release Tags @@ -219,6 +340,7 @@ git tag companion-wifi-v1.14.1 git tag repeater-bridge-espnow-v1.15.0 git tag observer-eastmesh-v2026.5.1 git tag observer-eastmesh-bridge-espnow-v2026.5.1 +git tag observer-eastmesh-bridge-mqtt-v2026.7.0 ``` Companion WiFi uses the upstream MeshCore version in the tag. @@ -242,3 +364,4 @@ Current docs pages: - [Download and Flash Releases](./eastmesh-docs/releases.md) - [Build Locally With uv](./eastmesh-docs/local-builds.md) - [Custom CLI Commands](./eastmesh-docs/custom-cli.md) +- [Web Panel](./eastmesh-docs/web-panel.md) diff --git a/RELEASE.md b/RELEASE.md index cec3c0e1..2adab788 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,11 +19,13 @@ Push one or more of the following tag formats to trigger the matching firmware r - `companion-wifi-v1.15.0` - `repeater-bridge-espnow-v1.15.0` - `observer-eastmesh-bridge-espnow-v2026.5.1` +- `observer-eastmesh-bridge-mqtt-v2026.7.0` - `observer-eastmesh-v2026.5.1` Use the upstream MeshCore version in `companion-wifi-v1.15.0`. Use the upstream MeshCore version in `repeater-bridge-espnow-v1.15.0`. Use the EastMesh release version in `observer-eastmesh-bridge-espnow-v2026.5.1`. +Use the EastMesh release version in `observer-eastmesh-bridge-mqtt-v2026.7.0`. Use the EastMesh release version in `observer-eastmesh-v2026.5.1`. Each tag triggers a separate workflow: @@ -31,6 +33,7 @@ Each tag triggers a separate workflow: - `companion-wifi-v*` builds companion WiFi firmware - `repeater-bridge-espnow-v*` builds repeater ESP-NOW bridge firmware - `observer-eastmesh-bridge-espnow-v*` builds Observer ESP-NOW firmware +- `observer-eastmesh-bridge-mqtt-v*` builds Observer MQTT bridge firmware - `observer-eastmesh-v*` builds Observer firmware You can push one, or more tags on the same commit, and they will all build separately. @@ -42,6 +45,7 @@ During the GitHub Actions build: - `companion-wifi` uses the version in the tag as `FIRMWARE_VERSION` - `repeater-bridge-espnow` uses the version in the tag as `FIRMWARE_VERSION` - `observer-eastmesh-bridge-espnow` uses `OFFICIAL_MESHCORE_VERSION` as `FIRMWARE_VERSION` and the EastMesh version from the tag as `EASTMESH_VERSION` +- `observer-eastmesh-bridge-mqtt` uses `OFFICIAL_MESHCORE_VERSION` as `FIRMWARE_VERSION` and the EastMesh version from the tag as `EASTMESH_VERSION` - `observer-eastmesh` uses `OFFICIAL_MESHCORE_VERSION` as `FIRMWARE_VERSION` and the EastMesh version from the tag as `EASTMESH_VERSION` The resulting version string depends on the workflow: @@ -49,6 +53,7 @@ The resulting version string depends on the workflow: - `companion-wifi`: `v1.15.0-` - `repeater-bridge-espnow`: `v1.15.0-` - `observer-eastmesh-bridge-espnow`: `v1.15.0-eastmesh-v2026.5.1-` +- `observer-eastmesh-bridge-mqtt`: `v1.15.0-eastmesh-v2026.7.0-` - `observer-eastmesh`: `v1.15.0-eastmesh-v2026.5.1-` Example: @@ -76,8 +81,9 @@ Example: git tag companion-wifi-v1.15.0 git tag repeater-bridge-espnow-v1.15.0 git tag observer-eastmesh-bridge-espnow-v2026.5.1 +git tag observer-eastmesh-bridge-mqtt-v2026.7.0 git tag observer-eastmesh-v2026.5.1 -git push origin companion-wifi-v1.15.0 repeater-bridge-espnow-v1.15.0 observer-eastmesh-bridge-espnow-v2026.5.1 observer-eastmesh-v2026.5.1 +git push origin companion-wifi-v1.15.0 repeater-bridge-espnow-v1.15.0 observer-eastmesh-bridge-espnow-v2026.5.1 observer-eastmesh-bridge-mqtt-v2026.7.0 observer-eastmesh-v2026.5.1 ``` ## Supported Tags @@ -85,4 +91,5 @@ git push origin companion-wifi-v1.15.0 repeater-bridge-espnow-v1.15.0 observer-e - `companion-wifi-v1.15.0` - `repeater-bridge-espnow-v1.15.0` - `observer-eastmesh-bridge-espnow-v2026.5.1` +- `observer-eastmesh-bridge-mqtt-v2026.7.0` - `observer-eastmesh-v2026.5.1` diff --git a/eastmesh-build.sh b/eastmesh-build.sh index 48955a21..b27002c6 100755 --- a/eastmesh-build.sh +++ b/eastmesh-build.sh @@ -37,6 +37,7 @@ Commands: build-repeater-bridge-espnow-firmwares: Build all repeater ESP-NOW bridge firmwares for all build targets. build-observer-firmwares: Build all observer firmwares for all build targets. build-observer-espnow-firmwares: Build all observer ESP-NOW firmwares for all build targets. + build-observer-mqtt-bridge-firmwares: Build all observer MQTT bridge firmwares for all build targets. build-room-server-firmwares: Build all chat room server firmwares for all build targets. Examples: @@ -64,6 +65,9 @@ $ sh eastmesh-build.sh build-observer-firmwares Build all observer ESP-NOW firmwares $ sh eastmesh-build.sh build-observer-espnow-firmwares +Build all observer MQTT bridge firmwares +$ sh eastmesh-build.sh build-observer-mqtt-bridge-firmwares + Build all chat room server firmwares $ sh eastmesh-build.sh build-room-server-firmwares @@ -327,6 +331,12 @@ build_repeater_observer_espnow_firmwares() { } +build_repeater_observer_mqtt_bridge_firmwares() { + + build_all_firmwares_by_suffix "_repeater_observer_mqtt_bridge" + +} + build_room_server_firmwares() { # # build specific room server firmwares @@ -380,6 +390,8 @@ elif [[ $1 == "build-observer-firmwares" ]]; then build_repeater_observer_firmwares elif [[ $1 == "build-observer-espnow-firmwares" ]]; then build_repeater_observer_espnow_firmwares +elif [[ $1 == "build-observer-mqtt-bridge-firmwares" ]]; then + build_repeater_observer_mqtt_bridge_firmwares elif [[ $1 == "build-room-server-firmwares" ]]; then build_room_server_firmwares fi diff --git a/eastmesh-docs/boards.md b/eastmesh-docs/boards.md index 9fff8bc8..a6e8ec63 100644 --- a/eastmesh-docs/boards.md +++ b/eastmesh-docs/boards.md @@ -26,7 +26,8 @@ For bridge repeaters, think local radio linking between nearby repeaters: - use bridge firmware when two local repeaters need to exchange traffic across different MeshCore radio configs, such as `Australia (Narrow)` and `Australia (Mid)` - pure ESP-NOW bridge repeaters do not need MQTT, the EastMesh web panel, or a screen - prioritise stable power, suitable antennas, and placement over display features -- use `observer-eastmesh-bridge-espnow` only when the same repeater also needs MQTT uplink; otherwise `repeater-bridge-espnow` keeps the role simpler +- use `observer-eastmesh-bridge-espnow` only when the same repeater also needs MQTT uplink and a local ESP-NOW bridge; otherwise `repeater-bridge-espnow` keeps the role simpler +- use `observer-eastmesh-bridge-mqtt` when the same repeater needs MQTT uplink and bidirectional MQTT mesh bridging through a peer broker Other notes: @@ -52,6 +53,7 @@ The tables below are built from the repo's PlatformIO board metadata and variant - `companion-wifi` boards are for app-connected companion devices. - `observer-eastmesh` boards are for Wi-Fi repeaters that publish to MQTT. - `repeater-bridge-espnow` and `observer-eastmesh-bridge-espnow` boards are for local ESP-NOW bridge use between nearby repeaters on different MeshCore radio configs. +- `observer-eastmesh-bridge-mqtt` boards bridge mesh packets through a shared MQTT topic at a peer broker you configure. Bridge firmware is not MQTT-over-WAN or VPN bridging. Use it when two local repeaters need to exchange traffic across radio configs such as `Australia (Narrow)` and `Australia (Mid)`. diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index 4d06fab4..0e9be13e 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -158,7 +158,7 @@ OK ### MQTT Bridge Settings For Observer MQTT Bridge Builds -These commands are available on observer builds that compile with the MQTT mesh bridge transport (for example `Xiao_S3_WIO_repeater_observer_espnow` when built with `WITH_MQTT_BRIDGE`). +These commands are available on `*_repeater_observer_mqtt_bridge` firmware targets (release track `observer-eastmesh-bridge-mqtt`). The MQTT bridge forwards raw mesh packets over a shared topic at a peer MQTT broker. It is separate from MQTT uplink publishing to EastMesh or MeshMapper brokers. @@ -167,9 +167,13 @@ The MQTT bridge forwards raw mesh packets over a shared topic at a peer MQTT bro - `set bridge.peer.host `: sets the peer MQTT broker host and restarts the bridge. - `get bridge.peer.port`: shows the configured peer MQTT broker port (defaults to `1883` when unset). - `set bridge.peer.port `: sets the peer MQTT broker port and restarts the bridge. +- `get bridge.peer.username` / `set bridge.peer.username `: optional peer MQTT broker username. +- `get bridge.peer.password` / `set bridge.peer.password `: optional peer MQTT broker password (`get` returns `set` or `-`, not the stored value). - `get bridge.secret` / `set bridge.secret `: shared XOR key used by all bridge nodes on the same bridge network. -Both bridge nodes must use the same peer broker address, port, and `bridge.secret`. Mesh packets are published and subscribed on topic `meshcore/bridge/packets`. +Both bridge nodes must use the same peer broker address, port, credentials, and `bridge.secret`. + +On `*_repeater_observer_mqtt_bridge` builds, MQTT uplink brokers are **disabled by default** so the mesh bridge client is not competing with EastMesh/MeshMapper WSS uplink at boot. Enable uplink brokers manually in MQTT Settings when needed. Example: @@ -178,6 +182,10 @@ Example: OK > set bridge.peer.port 1883 OK +> set bridge.peer.username meshbridge +OK +> set bridge.peer.password your-mqtt-password +OK > set bridge.secret my-shared-secret OK ``` diff --git a/eastmesh-docs/index.md b/eastmesh-docs/index.md index 70c40711..51c91753 100644 --- a/eastmesh-docs/index.md +++ b/eastmesh-docs/index.md @@ -1,17 +1,20 @@ # MeshCore EastMesh Docs -MeshCore-EastMesh keeps the upstream MeshCore firmware intact and publishes four firmware tracks, depending on how the device needs to connect: +MeshCore-EastMesh keeps the upstream MeshCore firmware intact and publishes five firmware tracks, depending on how the device needs to connect: - `companion-wifi`: use this for Wi-Fi-connected companion devices. It stays closest to upstream MeshCore and adds the EastMesh Wi-Fi rescue/configuration commands. - `repeater-bridge-espnow`: use this when you need a plain upstream-style repeater ESP-NOW bridge without MQTT uplink or the EastMesh web panel. - `observer-eastmesh`: use this for a Wi-Fi repeater that should publish to an MQTT broker and, on supported ESP32 boards, offer the optional local web panel for setup and troubleshooting. - `observer-eastmesh-bridge-espnow`: use this when one repeater needs both MQTT uplink and ESP-NOW bridge duties, including bridge channel/secret controls for keeping the bridge aligned with Wi-Fi. +- `observer-eastmesh-bridge-mqtt`: use this when one repeater needs both MQTT uplink and bidirectional MQTT mesh bridging through a peer broker you configure in the web panel or CLI. !!! note "Bridge tracks are local radio bridges" - The bridge tracks are for bridging two nearby repeaters that operate on different MeshCore radio configs, for example one repeater on `Australia (Narrow)` and another on `Australia (Mid)`. + The ESP-NOW bridge tracks are for bridging two nearby repeaters that operate on different MeshCore radio configs, for example one repeater on `Australia (Narrow)` and another on `Australia (Mid)`. - They are not MQTT-over-WAN, VPN, or internet bridge releases. MQTT is still the uplink/visibility path for MQTT firmware; it is not used to tunnel mesh traffic between distant sites. + The MQTT bridge track uses a shared topic at a peer MQTT broker for mesh packet bridging. It is separate from MQTT uplink publishing to EastMesh or MeshMapper. + + Bridge tracks are not MQTT-over-WAN, VPN, or internet tunnel releases. If you want guidance first, start with: diff --git a/eastmesh-docs/local-builds.md b/eastmesh-docs/local-builds.md index b988f1e2..1acd29af 100644 --- a/eastmesh-docs/local-builds.md +++ b/eastmesh-docs/local-builds.md @@ -29,6 +29,7 @@ uv run pio run -e heltec_v4_repeater_observer uv run pio run -e heltec_v4_companion_radio_wifi uv run pio run -e heltec_v4_repeater_bridge_espnow uv run pio run -e heltec_v4_repeater_observer_espnow +uv run pio run -e Xiao_S3_WIO_repeater_observer_mqtt_bridge ``` Flash a target: @@ -82,7 +83,7 @@ This produces versioned artifacts in `out/`. Versioning rule: - `companion-wifi` and `repeater-bridge-espnow` use the upstream MeshCore version as `FIRMWARE_VERSION` -- `observer-eastmesh` and `observer-eastmesh-bridge-espnow` use the upstream MeshCore version as `FIRMWARE_VERSION` plus the EastMesh release version as `EASTMESH_VERSION` +- `observer-eastmesh`, `observer-eastmesh-bridge-espnow`, and `observer-eastmesh-bridge-mqtt` use the upstream MeshCore version as `FIRMWARE_VERSION` plus the EastMesh release version as `EASTMESH_VERSION` ## Supported `repeater_observer` Boards @@ -143,6 +144,20 @@ T_Beam_S3_Supreme_SX1262_repeater_bridge_espnow T_Beam_S3_Supreme_SX1262_repeater_observer_espnow ``` +## Supported `repeater_observer_mqtt_bridge` Boards + +At present only the Xiao S3 WIO observer MQTT bridge target is defined: + +```text +Xiao_S3_WIO_repeater_observer_mqtt_bridge +``` + +List all observer MQTT bridge targets: + +```bash +bash eastmesh-build.sh list | grep '_repeater_observer_mqtt_bridge' +``` + Bridge firmware is for local ESP-NOW bridge use between nearby repeaters. It is not MQTT-over-WAN or VPN bridging. ## Supported `companion_radio_wifi` Boards diff --git a/eastmesh-docs/releases.md b/eastmesh-docs/releases.md index 2c33e1d5..174de088 100644 --- a/eastmesh-docs/releases.md +++ b/eastmesh-docs/releases.md @@ -22,7 +22,7 @@ If you are not sure which track you need, start with `companion-wifi` for app-co ## Pick Your Track -EastMesh publishes four release tracks: +EastMesh publishes five release tracks: | Track | Use it when | Firmware filename suffix | | ----- | ----------- | ------------------------ | @@ -30,12 +30,15 @@ EastMesh publishes four release tracks: | `observer-eastmesh` | You want a repeater with Wi-Fi and MQTT uplink, usually feeding broker visibility such as EastMesh/CoreScope. | `*_repeater_observer` | | `repeater-bridge-espnow` | You want a local ESP-NOW bridge between nearby repeaters, without MQTT uplink or the EastMesh web panel. | `*_repeater_bridge_espnow` | | `observer-eastmesh-bridge-espnow` | You want one repeater to provide both MQTT uplink and local ESP-NOW bridge duties. | `*_repeater_observer_espnow` | +| `observer-eastmesh-bridge-mqtt` | You want one repeater to provide both MQTT uplink and bidirectional MQTT mesh bridging to a peer broker. | `*_repeater_observer_mqtt_bridge` | !!! note "Bridge firmware is not a WAN bridge" - Bridge tracks are for bridging two nearby repeaters that operate on different MeshCore radio configs, for example `Australia (Narrow)` and `Australia (Mid)`. + ESP-NOW bridge tracks are for bridging two nearby repeaters that operate on different MeshCore radio configs, for example `Australia (Narrow)` and `Australia (Mid)`. - They do not use MQTT to tunnel mesh traffic over the internet, WAN links, or VPNs. + The MQTT bridge track forwards mesh packets through a shared topic at a peer MQTT broker you configure. It is separate from MQTT uplink publishing to EastMesh or MeshMapper. + + Bridge tracks do not use MQTT uplink brokers to tunnel mesh traffic over the internet, WAN links, or VPNs. ## Pick The Right Asset @@ -48,6 +51,7 @@ Examples: - `heltec_v4_repeater_observer-v1.15.0-eastmesh-v2026.5.1-abcdef-merged.bin` - `heltec_v4_repeater_bridge_espnow-v1.15.0-abcdef.bin` - `heltec_v4_repeater_observer_espnow-v1.15.0-eastmesh-v2026.5.1-abcdef.bin` +- `Xiao_S3_WIO_repeater_observer_mqtt_bridge-v1.15.0-eastmesh-v2026.7.0-abcdef.bin` The important part is the board/env prefix: @@ -55,6 +59,7 @@ The important part is the board/env prefix: - `*_repeater_observer` - `*_repeater_bridge_espnow` - `*_repeater_observer_espnow` +- `*_repeater_observer_mqtt_bridge` ## Which File To Flash @@ -187,6 +192,19 @@ Typical first steps after flashing: - set `bridge.channel` to match that Wi-Fi channel - set the same `bridge.secret` on every local ESP-NOW bridge node that should talk together +### Observer MQTT Bridge + +`repeater_observer_mqtt_bridge` builds combine the observer role with bidirectional MQTT mesh bridging. + +Typical first steps after flashing: + +- set `wifi.ssid` +- set `wifi.pwd` +- set `mqtt.iata` +- confirm `get mqtt.status` +- set `bridge.peer.host` and `bridge.peer.port` to your peer MQTT broker +- set the same `bridge.secret` on every MQTT bridge node that should talk together + ### Repeater ESP-NOW Bridge `repeater_bridge_espnow` builds are for local ESP-NOW bridge nodes without MQTT uplink. diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index 399cf135..77f84f0f 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -255,7 +255,7 @@ This section includes: - `mqtt.email`: owner contact email. - 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. -- mesh bridge peer MQTT `host:port`, shown on MQTT bridge builds (`get bridge.type` returns `mqtt`). This is separate from MQTT uplink brokers and points at the shared peer broker used for bidirectional mesh packet bridging. +- mesh bridge peer MQTT `host:port`, optional username and password, shown on MQTT bridge builds (`get bridge.type` returns `mqtt`). This is separate from MQTT uplink brokers and points at the shared peer broker used for bidirectional mesh packet bridging. `UNSET - To be configured` is the default for new observer installs until a real saved value exists. @@ -270,7 +270,7 @@ Notes: - turning off a connected MQTT server publishes retained offline status before the client disconnects - changing `mqtt.iata` away from a configured value publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path - at most two MQTT brokers can be enabled at once -- on MQTT bridge builds, both bridge nodes must use the same peer broker host, port, and `bridge.secret` +- on MQTT bridge builds, both bridge nodes must use the same peer broker host, port, credentials, and `bridge.secret` ## `/stats` Overview diff --git a/release-notes.yml b/release-notes.yml index ed261b0a..258c34b3 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -4,6 +4,7 @@ tracks: - repeater-bridge-espnow - observer-eastmesh - observer-eastmesh-bridge-espnow + - observer-eastmesh-bridge-mqtt generated_from: "Adjacent git tag comparisons plus non-merge commit subjects." releases: @@ -393,3 +394,27 @@ releases: breaking_changes: - "`get mqtt.status` output changed from per-broker fields to `p:`/`s:` broker slots; anything parsing the previous format must be updated." - "Saved LetsMesh EU/US broker selections are cleared once on upgrade; re-enable a broker such as MeshMapper if a second uplink is required." + + - track: observer-eastmesh-bridge-mqtt + version: "2026.7.0" + tag: "observer-eastmesh-bridge-mqtt-v2026.7.0" + date: "2026-06-24" + previous_version: null + summary: "Introduces the observer-eastmesh-bridge-mqtt release track with bidirectional MQTT mesh bridging and web-panel peer broker configuration." + changes: + - type: added + area: bridge + text: "Added `WITH_MQTT_BRIDGE` and `MQTTBridge` for bidirectional mesh packet transport over a shared MQTT topic at a peer broker." + - type: added + area: cli + text: "Added `get/set bridge.peer.host` and `get/set bridge.peer.port` for configuring the peer MQTT broker used by the mesh bridge." + - type: added + area: web + text: "Added mesh bridge peer MQTT host:port controls to the web panel on MQTT bridge builds." + - type: added + area: release + text: "Added the `observer-eastmesh-bridge-mqtt-v*` release tag format, PlatformIO `*_repeater_observer_mqtt_bridge` environments, and CI build workflow." + - type: added + area: boards + text: "Added `Xiao_S3_WIO_repeater_observer_mqtt_bridge` as the first supported observer MQTT bridge target." + breaking_changes: [] diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7afb705d..f2eedcf1 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -109,7 +109,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->bridge_peer_port)) { file.read((uint8_t *)&_prefs->bridge_peer_port, sizeof(_prefs->bridge_peer_port)); // 360 } - // next: 362 + if (file.available() >= (int)sizeof(_prefs->bridge_peer_username)) { + file.read((uint8_t *)&_prefs->bridge_peer_username, sizeof(_prefs->bridge_peer_username)); // 362 + } + if (file.available() >= (int)sizeof(_prefs->bridge_peer_password)) { + file.read((uint8_t *)&_prefs->bridge_peer_password, sizeof(_prefs->bridge_peer_password)); // 427 + } + // next: 523 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -211,7 +217,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 file.write((uint8_t *)&_prefs->bridge_peer_host, sizeof(_prefs->bridge_peer_host)); // 296 file.write((uint8_t *)&_prefs->bridge_peer_port, sizeof(_prefs->bridge_peer_port)); // 360 - // next: 362 + file.write((uint8_t *)&_prefs->bridge_peer_username, sizeof(_prefs->bridge_peer_username)); // 362 + file.write((uint8_t *)&_prefs->bridge_peer_password, sizeof(_prefs->bridge_peer_password)); // 427 + // next: 523 file.close(); } @@ -812,6 +820,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->restartBridge(); savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "bridge.peer.username ", 21) == 0) { + StrHelper::strncpy(_prefs->bridge_peer_username, &config[21], sizeof(_prefs->bridge_peer_username)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "bridge.peer.password ", 21) == 0) { + StrHelper::strncpy(_prefs->bridge_peer_password, &config[21], sizeof(_prefs->bridge_peer_password)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); } else if (memcmp(config, "bridge.secret ", 14) == 0) { StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret)); _callbacks->restartBridge(); @@ -960,6 +978,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", _prefs->bridge_peer_host); } else if (memcmp(config, "bridge.peer.port", 16) == 0) { sprintf(reply, "> %u", _prefs->bridge_peer_port != 0 ? _prefs->bridge_peer_port : 1883); + } else if (memcmp(config, "bridge.peer.username", 20) == 0) { + sprintf(reply, "> %s", _prefs->bridge_peer_username[0] ? _prefs->bridge_peer_username : "-"); + } else if (memcmp(config, "bridge.peer.password", 20) == 0) { + sprintf(reply, "> %s", _prefs->bridge_peer_password[0] ? "set" : "-"); } else if (memcmp(config, "bridge.secret", 13) == 0) { sprintf(reply, "> %s", _prefs->bridge_secret); #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index a7673766..fe092e85 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -67,6 +67,8 @@ struct NodePrefs { // persisted to file uint16_t fan_timeout_secs; char bridge_peer_host[64]; // peer MQTT broker host (MQTT bridge only) uint16_t bridge_peer_port; // peer MQTT broker port (MQTT bridge only, default 1883) + char bridge_peer_username[65]; // peer MQTT broker username (MQTT bridge only) + char bridge_peer_password[96]; // peer MQTT broker password (MQTT bridge only) }; class CommonCLICallbacks { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 362fca4f..49577f5f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -49,7 +49,7 @@ const char *MQTTBridge::kBridgeTopic = "meshcore/bridge/packets"; MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) : BridgeBase(prefs, mgr, rtc), _client(nullptr), _connected(false), _started(false), - _next_connect_attempt(0), _reconnect_failures(0) { + _pending_destroy(false), _next_connect_attempt(0), _reconnect_failures(0) { _instance = this; _client_id[0] = 0; } @@ -72,6 +72,13 @@ void MQTTBridge::destroyClient() { } _connected = false; _started = false; + _pending_destroy = false; +} + +void MQTTBridge::scheduleClientDestroy() { + _connected = false; + _started = false; + _pending_destroy = true; } void MQTTBridge::begin() { @@ -108,9 +115,7 @@ void MQTTBridge::onMqttConnected() { } void MQTTBridge::onMqttDisconnected() { - _connected = false; - _started = false; - destroyClient(); + scheduleClientDestroy(); _next_connect_attempt = millis() + connectRetryDelayMillis(_reconnect_failures); if (_reconnect_failures < 255) { ++_reconnect_failures; @@ -130,6 +135,9 @@ void MQTTBridge::mqttEventHandler(void *handler_args, esp_event_base_t, int32_t case MQTT_EVENT_DISCONNECTED: bridge->onMqttDisconnected(); break; + case MQTT_EVENT_ERROR: + bridge->onMqttDisconnected(); + break; case MQTT_EVENT_DATA: { auto *event = static_cast(event_data); if (event != nullptr && event->data_len > 0) { @@ -160,6 +168,12 @@ bool MQTTBridge::ensureClient() { cfg.broker.address.port = peerPort(_prefs); cfg.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; cfg.credentials.client_id = _client_id; + if (_prefs->bridge_peer_username[0] != 0) { + cfg.credentials.username = _prefs->bridge_peer_username; + } + if (_prefs->bridge_peer_password[0] != 0) { + cfg.credentials.authentication.password = _prefs->bridge_peer_password; + } cfg.session.keepalive = 30; cfg.network.reconnect_timeout_ms = 10000; cfg.network.timeout_ms = 10000; @@ -171,6 +185,12 @@ bool MQTTBridge::ensureClient() { cfg.port = peerPort(_prefs); cfg.transport = MQTT_TRANSPORT_OVER_TCP; cfg.client_id = _client_id; + if (_prefs->bridge_peer_username[0] != 0) { + cfg.username = _prefs->bridge_peer_username; + } + if (_prefs->bridge_peer_password[0] != 0) { + cfg.password = _prefs->bridge_peer_password; + } cfg.keepalive = 30; cfg.buffer_size = MAX_MQTT_PACKET_SIZE; cfg.out_buffer_size = MAX_MQTT_PACKET_SIZE; @@ -201,6 +221,11 @@ void MQTTBridge::loop() { return; } + if (_pending_destroy) { + destroyClient(); + return; + } + if (WiFi.status() != WL_CONNECTED) { if (_client != nullptr) { destroyClient(); @@ -208,12 +233,14 @@ void MQTTBridge::loop() { return; } - if (_client == nullptr) { - if (_next_connect_attempt != 0 && millis() < _next_connect_attempt) { - return; - } - ensureClient(); + if (_client != nullptr) { + return; } + + if (_next_connect_attempt != 0 && millis() < _next_connect_attempt) { + return; + } + ensureClient(); } void MQTTBridge::handleMqttData(const uint8_t *data, size_t len) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index bfa9880a..d9dcf276 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -28,12 +28,14 @@ private: esp_mqtt_client_handle_t _client; bool _connected; bool _started; + bool _pending_destroy; unsigned long _next_connect_attempt; uint8_t _reconnect_failures; char _client_id[24]; void xorCrypt(uint8_t *data, size_t len); void destroyClient(); + void scheduleClientDestroy(); bool ensureClient(); void handleMqttData(const uint8_t *data, size_t len); void onMqttConnected(); diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index c276d8db..93a279aa 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -13,7 +13,12 @@ constexpr uint32_t kFixedStatusIntervalMs = 300000; void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) { memset(&prefs, 0, sizeof(prefs)); prefs.magic = kMagic; +#if defined(WITH_MQTT_BRIDGE) + prefs.enabled_mask = 0x00; + prefs.mqtt_bridge_uplink_migrated = 1; +#else prefs.enabled_mask = 0x01; +#endif prefs.packets_enabled = 1; prefs.raw_enabled = 0; prefs.status_enabled = 1; @@ -83,6 +88,13 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { prefs.brokers_migrated = 1; save(fs, prefs); } +#if defined(WITH_MQTT_BRIDGE) + if (!prefs.mqtt_bridge_uplink_migrated) { + prefs.enabled_mask = 0; + prefs.mqtt_bridge_uplink_migrated = 1; + save(fs, prefs); + } +#endif return true; } diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h index 337ad5af..1112fd18 100644 --- a/src/helpers/mqtt/MQTTPrefs.h +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -37,6 +37,7 @@ struct MQTTPrefs { // 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; + uint8_t mqtt_bridge_uplink_migrated; }; class MQTTPrefsStore { diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 4c463283..2d051923 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1033,7 +1033,24 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( -
Both bridge nodes must use the same peer broker, port, and bridge secret.
+
Both bridge nodes must use the same peer broker, port, credentials, and bridge secret.
+
+
+ +
+ + + +
+
+
+ +
+ + +
+
+
@@ -1230,6 +1247,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( function parseClientEnv(clientEnv) { const env = String(clientEnv || "").trim(); const suffixes = [ + { suffix:"_repeater_observer_mqtt_bridge", firmware:"repeater_observer_mqtt_bridge" }, { suffix:"_repeater_observer_espnow", firmware:"repeater_observer_espnow" }, { suffix:"_repeater_observer", firmware:"repeater_observer" }, { suffix:"_repeater_bridge_espnow", firmware:"repeater_bridge_espnow" }, @@ -2860,6 +2878,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const input = document.getElementById("bridgePeerEndpoint"); if (!input) return; input.value = host ? `${host}:${port}` : ""; + await loadField("get bridge.peer.username", "bridgePeerUsername", null, options); } async function saveBridgePeerEndpoint() { const input = document.getElementById("bridgePeerEndpoint"); diff --git a/variants/eastmesh_mqtt/platformio.ini b/variants/eastmesh_mqtt/platformio.ini index 694fd1eb..ac815ebe 100644 --- a/variants/eastmesh_mqtt/platformio.ini +++ b/variants/eastmesh_mqtt/platformio.ini @@ -1154,8 +1154,17 @@ build_src_filter = ${env:WHY2025_badge_repeater_observer.build_src_filter} [env:Xiao_S3_WIO_repeater_observer_espnow] extends = env:Xiao_S3_WIO_repeater_observer +build_flags = + ${env:Xiao_S3_WIO_repeater_observer.build_flags} + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${env:Xiao_S3_WIO_repeater_observer.build_src_filter} + + + +[env:Xiao_S3_WIO_repeater_observer_mqtt_bridge] +extends = env:Xiao_S3_WIO_repeater_observer build_flags = ${env:Xiao_S3_WIO_repeater_observer.build_flags} -D WITH_MQTT_BRIDGE=1 + -UMQTT_DEBUG build_src_filter = ${env:Xiao_S3_WIO_repeater_observer.build_src_filter} +