From f1912ff184c9101b8cb5cb5e799d58e242fbf059 Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Mon, 27 Apr 2026 03:15:00 +0300 Subject: [PATCH] Added support for MeshCoreTel broker. --- docs/custom-cli.md | 7 +- examples/simple_repeater/MyMesh.cpp | 8 ++ src/helpers/mqtt/MQTTPrefs.cpp | 2 +- src/helpers/mqtt/MQTTUplink.cpp | 80 ++++++++------ src/helpers/mqtt/MQTTUplink.h | 8 +- src/helpers/web/WebPanelServer.cpp | 157 ++++++++++++++++++++++++++-- 6 files changed, 216 insertions(+), 46 deletions(-) diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 8150e0de..bc56f2ef 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -14,7 +14,7 @@ These commands are available on `*_repeater_mqtt` firmware targets. - `get mqtt.statuscfg`: shows whether periodic status messages are enabled as a simple `on` or `off` value. Most users can just use `get mqtt.status`. - `get mqtt.client_version`: shows the MQTT `client_version` string published by the repeater. - `get mqtt.iata`: shows the IATA/location code used in MQTT topics. -- `set mqtt.iata `: sets the IATA/location code, for example `MEL`. +- `set mqtt.iata `: sets the IATA/location code, for example `MOW`. - `set mqtt.iata UNSET`: marks MQTT IATA as not configured yet. While it is `UNSET`, enabled MQTT brokers stay disconnected until a real code is saved. ### MQTT Identity @@ -38,6 +38,9 @@ These commands are available on `*_repeater_mqtt` firmware targets. ### MQTT Endpoints +- `set mqtt.meshcoretel on|off` +- `get mqtt.meshcoretel` +- `set mqtt.letsmesh-us on|off` - `get mqtt.eastmesh-au` - `set mqtt.eastmesh-au on|off` - `get mqtt.letsmesh-eu` @@ -49,7 +52,7 @@ Notes: - new repeater MQTT installs default `mqtt.iata` to `UNSET` - `letsmesh-eu` and `letsmesh-us` remain off by default unless already configured in saved prefs -- if `mqtt.iata` is `UNSET`, `eastmesh-au`, `letsmesh-eu`, and `letsmesh-us` will not connect even if they are toggled on +- if `mqtt.iata` is `UNSET`, `meshcoretel`, `eastmesh-au`, `letsmesh-eu`, and `letsmesh-us` will not connect even if they are toggled on Legacy dotted aliases are also accepted: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a615f00f..b1379030 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2308,6 +2308,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off"); } else if (memcmp(command, "get mqtt.letsmesh-us", 21) == 0 || memcmp(command, "get mqtt.letsmesh.us", 21) == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x04) ? "on" : "off"); + } else if (memcmp(command, "get mqtt.meshcoretel", 20) == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x08) ? "on" : "off"); } else if (memcmp(command, "set mqtt.tx ", 12) == 0) { mqtt.setTxEnabled(memcmp(&command[12], "on", 2) == 0); strcpy(reply, "OK"); @@ -2356,6 +2358,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.meshcoretel ", 21) == 0) { + if (mqtt.setEndpointEnabled(0x08, memcmp(&command[21], "on", 2) == 0)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - max 2 mqtt brokers"); + } #endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index a9dde6b0..8f43f0bc 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -67,7 +67,7 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata)); } prefs.status_interval_ms = kFixedStatusIntervalMs; - prefs.enabled_mask &= 0x07; + prefs.enabled_mask &= 0x0F; return true; } diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 30ec85ac..21cf3ba4 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -17,7 +17,7 @@ #include #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.14.1" + #define FIRMWARE_VERSION "v1.15.0 (VBart EastMesh)" #endif #ifndef FIRMWARE_BUILD_DATE @@ -95,12 +95,15 @@ void logMqttMemorySnapshot(const char*, const char* = nullptr) { } -const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = { - {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit}, +const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[4] = { + {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", + kEastmeshBit, false, nullptr, nullptr}, {"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", - kLetsmeshEuBit}, + kLetsmeshEuBit, false, nullptr, nullptr}, {"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", - kLetsmeshUsBit}, + kLetsmeshUsBit, false, nullptr, nullptr}, + {"meshcoretel", "meshcoretel", "meshcoretel.ru", "mqtt://meshcoretel.ru:1883", + kMeshcoretelBit, true, "meshcore", "meshcore"}, }; MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) @@ -109,7 +112,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 < 3; ++i) { + for (size_t i = 0; i < 4; ++i) { memset(&_brokers[i], 0, sizeof(_brokers[i])); _brokers[i].spec = &kBrokerSpecs[i]; } @@ -125,7 +128,7 @@ bool MQTTUplink::savePrefs() { } bool MQTTUplink::hasEnabledBroker() const { - return (_prefs.enabled_mask & 0x07) != 0; + return (_prefs.enabled_mask & 0x0F) != 0; } bool MQTTUplink::isUnsetIataValue(const char* iata) { @@ -277,7 +280,11 @@ void MQTTUplink::refreshBrokerIdentity(BrokerState& broker) { if (broker.spec == nullptr) { return; } - snprintf(broker.username, sizeof(broker.username), "v1_%s", _device_id); + if (broker.spec->username != nullptr) { + snprintf(broker.username, sizeof(broker.username), "%s", broker.spec->username); + } else { + snprintf(broker.username, sizeof(broker.username), "v1_%s", _device_id); + } snprintf(broker.client_id, sizeof(broker.client_id), "mqtt_%s-%.6s", broker.spec->key, _device_id); formatTopic(broker.status_topic, sizeof(broker.status_topic), "status"); } @@ -621,9 +628,11 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { return; } - time_t now = time(nullptr); - if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) { - destroyBroker(broker, false); + if (!broker.spec->plain_tcp) { + time_t now = time(nullptr); + if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) { + destroyBroker(broker, false); + } } unsigned long now_ms = millis(); @@ -646,26 +655,32 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { broker.last_connect_attempt = now_ms; broker.reconnect_pending = false; - if (!refreshToken(broker)) { + if (!broker.spec->plain_tcp && !refreshToken(broker)) { broker.reconnect_pending = true; broker.next_connect_attempt = now_ms + kBrokerRetryBaseMillis; return; } refreshBrokerState(broker); - MQTT_LOG("%s mqtt init host=%s port=%d path=%s client_id=%s", broker.spec->label, broker.spec->host, 443, "/mqtt", - broker.client_id); + MQTT_LOG("%s mqtt init host=%s uri=%s plain_tcp=%d client_id=%s", + broker.spec->label, broker.spec->host, broker.spec->uri, + broker.spec->plain_tcp ? 1 : 0, broker.client_id); logMqttMemorySnapshot("init-pre", broker.spec->label); esp_mqtt_client_config_t cfg = {}; #if ESP_IDF_VERSION_MAJOR >= 5 - cfg.broker.address.hostname = broker.spec->host; - cfg.broker.address.port = 443; - cfg.broker.address.transport = MQTT_TRANSPORT_OVER_WSS; - cfg.broker.address.path = "/mqtt"; - cfg.broker.verification.certificate = mqtt_ca_certs::kCombinedPem; + if (broker.spec->plain_tcp) { + cfg.broker.address.uri = broker.spec->uri; + cfg.credentials.authentication.password = broker.spec->password; + } else { + cfg.broker.address.hostname = broker.spec->host; + cfg.broker.address.port = 443; + cfg.broker.address.transport = MQTT_TRANSPORT_OVER_WSS; + cfg.broker.address.path = "/mqtt"; + cfg.broker.verification.certificate = mqtt_ca_certs::kCombinedPem; + cfg.credentials.authentication.password = broker.token; + } cfg.credentials.username = broker.username; cfg.credentials.client_id = broker.client_id; - cfg.credentials.authentication.password = broker.token; cfg.session.keepalive = 30; cfg.session.last_will.topic = broker.status_topic; cfg.session.last_will.msg = broker.offline_payload; @@ -677,10 +692,18 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { cfg.buffer.size = 768; cfg.buffer.out_size = 1280; #else - cfg.host = broker.spec->host; - cfg.port = 443; + if (broker.spec->plain_tcp) { + cfg.uri = broker.spec->uri; + cfg.password = broker.spec->password; + } else { + cfg.host = broker.spec->host; + cfg.port = 443; + cfg.transport = MQTT_TRANSPORT_OVER_WSS; + cfg.path = "/mqtt"; + cfg.cert_pem = mqtt_ca_certs::kCombinedPem; + cfg.password = broker.token; + } cfg.username = broker.username; - cfg.password = broker.token; cfg.client_id = broker.client_id; cfg.keepalive = 30; cfg.buffer_size = 768; @@ -688,13 +711,10 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { cfg.reconnect_timeout_ms = 10000; cfg.network_timeout_ms = 10000; cfg.disable_auto_reconnect = true; - cfg.transport = MQTT_TRANSPORT_OVER_WSS; - cfg.cert_pem = mqtt_ca_certs::kCombinedPem; cfg.lwt_topic = broker.status_topic; cfg.lwt_msg = broker.offline_payload; cfg.lwt_qos = 1; cfg.lwt_retain = 1; - cfg.path = "/mqtt"; #endif broker.client = esp_mqtt_client_init(&cfg); @@ -721,7 +741,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 & 0x07); + uint8_t normalized_mask = normalizeEnabledMask(_prefs.enabled_mask & 0x0F); if (normalized_mask != _prefs.enabled_mask) { _prefs.enabled_mask = normalized_mask; savePrefs(); @@ -865,16 +885,16 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { return "retry"; }; - snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s status:%s tx:%s", + snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s meshcoretel:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%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), + broker_state(kMeshcoretelBit), broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit), _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 & 0x07; + uint8_t next_mask = _prefs.enabled_mask & 0x0F; 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 b61ad5a6..1792103a 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -74,6 +74,9 @@ private: const char* host; const char* uri; uint8_t bit; + bool plain_tcp; + const char* username; + const char* password; }; struct BrokerState { @@ -110,11 +113,12 @@ private: static constexpr uint8_t kEastmeshBit = 0x01; static constexpr uint8_t kLetsmeshEuBit = 0x02; static constexpr uint8_t kLetsmeshUsBit = 0x04; + static constexpr uint8_t kMeshcoretelBit = 0x08; static constexpr uint8_t kMaxEnabledBrokers = 2; - static const BrokerSpec kBrokerSpecs[3]; + static const BrokerSpec kBrokerSpecs[4]; static bool isUnsetIataValue(const char* iata); - BrokerState _brokers[3]; + BrokerState _brokers[4]; static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); bool hasEnabledBroker() const; diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 5b397bd9..2d84f240 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -828,6 +828,35 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -913,7 +942,30 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
-
+
+
+
Meshcoretel
+
+
+
+
+
+
Meshcoretel RU
+
Off
+
+
+
+ + +
+ +
+
+
+
EastMesh
@@ -964,7 +1016,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
-
If EastMesh is enabled, use only one LetsMesh broker. Enable both LetsMesh brokers only when EastMesh is off.
+
Only two brokers can be enabled at the same time.
@@ -2074,7 +2126,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } if (inlineWarning) { inlineWarning.textContent = showWarning - ? "MQTT IATA is unset. Set it before enabling EastMesh or LetsMesh brokers." + ? "MQTT IATA is unset. Set it before enabling any MQTT brokers." : ""; } } @@ -2223,13 +2275,25 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( function clampLetsmeshModeIndex(index) { const eastmesh = document.getElementById("mqttEastmeshAu"); const eastmeshEnabled = !!(eastmesh && eastmesh.checked); + const meshcoretel = document.getElementById("mqttMeshcoretel"); + const meshcoretelEnabled = !!(meshcoretel && meshcoretel.checked); const bounded = Math.max(0, Math.min(3, index)); - return eastmeshEnabled && bounded === 3 ? 1 : bounded; + if ((eastmeshEnabled || meshcoretelEnabled) && bounded === 3) { + return 1; + } + if (eastmeshEnabled && meshcoretelEnabled && bounded > 0) { + return 0; + } + return bounded; } function refreshLetsmeshModeUi() { const mode = getLetsmeshMode(); const eastmesh = document.getElementById("mqttEastmeshAu"); const eastmeshEnabled = !!(eastmesh && eastmesh.checked); + const meshcoretel = document.getElementById("mqttMeshcoretel"); + const meshcoretelEnabled = !!(meshcoretel && meshcoretel.checked); + const bothBlocked = eastmeshEnabled || meshcoretelEnabled; + const allBlocked = eastmeshEnabled && meshcoretelEnabled; const state = document.getElementById("mqttLetsmeshModeState"); const labels = { off:"Off", eu:"EU", us:"US", both:"Both" }; if (state) { @@ -2240,11 +2304,15 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (slider) { slider.max = "3"; slider.value = String(clampLetsmeshModeIndex(getLetsmeshModeIndex(mode))); + slider.disabled = allBlocked; } document.querySelectorAll("[data-letsmesh-label]").forEach((label) => { const labelMode = label.dataset.letsmeshLabel; label.classList.toggle("active", labelMode === mode); - label.classList.toggle("disabled", eastmeshEnabled && labelMode === "both"); + label.classList.toggle("disabled", + (bothBlocked && labelMode === "both") || + (allBlocked && labelMode !== "off") + ); }); } function setBrokerToggle(inputId, state) { @@ -2262,6 +2330,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } else if (inputId === "mqttEastmeshAu") { refreshEastmeshModeUi(); refreshLetsmeshModeUi(); + } else if (inputId === "mqttMeshcoretel") { + refreshMeshcoretelModeUi(); } } async function loadBrokerState(cmd, inputId, options = {}) { @@ -2380,8 +2450,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( mqttIataSelect.addEventListener("change", refreshMqttIataWarning); } async function setEastmeshMode(enabled) { - if (enabled && getLetsmeshMode() === "both") { - await setLetsmeshMode("eu"); + if (enabled) { + if (getLetsmeshMode() === "both") { + await setLetsmeshMode("eu"); + } + const meshcoretel = document.getElementById("mqttMeshcoretel"); + if (meshcoretel && meshcoretel.checked && getLetsmeshMode() !== "off") { + await setLetsmeshMode("off"); + } } const result = await runCommand(enabled ? "set mqtt.eastmesh-au on" : "set mqtt.eastmesh-au off"); if (!result.ok) { @@ -2392,6 +2468,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( setBrokerToggle("mqttEastmeshAu", enabled ? "on" : "off"); refreshEastmeshModeUi(); refreshLetsmeshModeUi(); + refreshMeshcoretelModeUi(); } const eastmeshModeSlider = document.getElementById("mqttEastmeshMode"); if (eastmeshModeSlider) { @@ -2404,9 +2481,22 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } async function setLetsmeshMode(mode) { const eastmesh = document.getElementById("mqttEastmeshAu"); - if (mode === "both" && eastmesh && eastmesh.checked) { - refreshLetsmeshModeUi(); - return; + const eastmeshEnabled = !!(eastmesh && eastmesh.checked); + const meshcoretel = document.getElementById("mqttMeshcoretel"); + const meshcoretelEnabled = !!(meshcoretel && meshcoretel.checked); + if (mode === "both") { + if (eastmeshEnabled) { + refreshLetsmeshModeUi(); + return; + } + if (meshcoretelEnabled) { + refreshLetsmeshModeUi(); + return; + } + } + if (mode !== "off" && meshcoretelEnabled && eastmeshEnabled) { + refreshLetsmeshModeUi(); + return; } const desired = { eu: mode === "eu" || mode === "both", @@ -2440,6 +2530,49 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( setLetsmeshMode(modes[index] || "off"); }); } + function refreshMeshcoretelModeUi() { + const input = document.getElementById("mqttMeshcoretel"); + const enabled = !!(input && input.checked); + const slider = document.getElementById("mqttMeshcoretelMode"); + if (slider) slider.value = enabled ? "1" : "0"; + const state = document.getElementById("mqttMeshcoretelState"); + if (state) { + state.textContent = enabled ? "On" : "Off"; + state.classList.toggle("on", enabled); + } + document.querySelectorAll("[data-meshcoretel-label]").forEach((label) => { + label.classList.toggle("active", label.dataset.meshcoretelLabel === (enabled ? "on" : "off")); + }); + } + async function setMeshcoretelMode(enabled) { + if (enabled) { + if (getLetsmeshMode() === "both") { + await setLetsmeshMode("eu"); + } + const eastmesh = document.getElementById("mqttEastmeshAu"); + if (eastmesh && eastmesh.checked && getLetsmeshMode() !== "off") { + await setLetsmeshMode("off"); + } + } + const result = await runCommand(enabled ? "set mqtt.meshcoretel on" : "set mqtt.meshcoretel off"); + if (!result.ok) { + refreshMeshcoretelModeUi(); + return; + } + setBrokerToggle("mqttMeshcoretel", enabled ? "on" : "off"); + refreshMeshcoretelModeUi(); + refreshLetsmeshModeUi(); + refreshEastmeshModeUi(); + } + const meshcoretelModeSlider = document.getElementById("mqttMeshcoretelMode"); + if (meshcoretelModeSlider) { + meshcoretelModeSlider.addEventListener("input", () => { + meshcoretelModeSlider.value = (Number.parseInt(meshcoretelModeSlider.value, 10) || 0) >= 1 ? "1" : "0"; + }); + meshcoretelModeSlider.addEventListener("change", () => { + setMeshcoretelMode((Number.parseInt(meshcoretelModeSlider.value, 10) || 0) >= 1); + }); + } document.getElementById("saveOwnerInfo").onclick = () => { const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|"); runCommand("set owner.info " + value); @@ -2568,7 +2701,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( () => loadField("get mqtt.email", "mqttEmail", null, quiet), () => loadBrokerState("get mqtt.eastmesh-au", "mqttEastmeshAu", quiet), () => loadBrokerState("get mqtt.letsmesh-eu", "mqttLetsmeshEu", quiet), - () => loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs", quiet) + () => loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs", quiet), + () => loadBrokerState("get mqtt.meshcoretel", "mqttMeshcoretel", quiet) ]); statusEl.textContent = "Ready"; } catch (error) { @@ -2581,6 +2715,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } refreshEastmeshModeUi(); refreshLetsmeshModeUi(); + refreshMeshcoretelModeUi(); initApp();