Browse Source

Added support for MeshCoreTel broker.

Valentin V. Bartenev 3 months ago
parent
commit
f1912ff184

+ 5 - 2
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 <code>`: sets the IATA/location code, for example `MEL`.
+- `set mqtt.iata <code>`: 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:
 

+ 8 - 0
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

+ 1 - 1
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;
 }
 

+ 50 - 30
src/helpers/mqtt/MQTTUplink.cpp

@@ -17,7 +17,7 @@
 #include <time.h>
 
 #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) {

+ 6 - 2
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;

+ 146 - 11
src/helpers/web/WebPanelServer.cpp

@@ -828,6 +828,35 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
               <optgroup label="Configuration">
                 <option value="UNSET">UNSET - To be configured</option>
               </optgroup>
+              <optgroup label="Russia">
+                <option value="BSK">BSK - Biysk</option>
+                <option value="CEE">CEE - Cherepovets</option>
+                <option value="CEK">CEK - Chelyabinsk</option>
+                <option value="GOJ">GOJ - Nizhny Novgorod</option>
+                <option value="IKT">IKT - Irkutsk</option>
+                <option value="IWA">IWA - Ivanovo</option>
+                <option value="KHV">KHV - Khabarovsk</option>
+                <option value="KLD">KLD - Tver</option>
+                <option value="KLF">KLF - Kaluga</option>
+                <option value="KVX">KVX - Kirov</option>
+                <option value="KZN">KZN - Kazan</option>
+                <option value="LPK">LPK - Lipetsk</option>
+                <option value="MOW">MOW - Moscow</option>
+                <option value="OVB">OVB - Novosibirsk</option>
+                <option value="ROV">ROV - Rostov-on-Don</option>
+                <option value="RZN">RZN - Ryazan</option>
+                <option value="SVX">SVX - Yekaterinburg</option>
+                <option value="TBW">TBW - Tambov</option>
+                <option value="TYA">TYA - Tula</option>
+                <option value="UFA">UFA - Ufa</option>
+                <option value="VOG">VOG - Volgograd</option>
+                <option value="VOZ">VOZ - Voronezh</option>
+                <option value="VSG">VSG - Lugansk</option>
+                <option value="VVO">VVO - Vladivostok</option>
+              </optgroup>
+              <optgroup label="France">
+                <option value="CHR">CHR - Chateauroux</option>
+              </optgroup>
               <optgroup label="ACT">
                 <option value="CBR">CBR - Canberra</option>
               </optgroup>
@@ -913,7 +942,30 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
 	        </div>
 	        <div class="field-card">
 	          <label class="label">MQTT Servers</label>
-	          <div class="broker-stack">
+	          <div class="broker-stack" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr)">
+	            <div class="broker-group">
+	              <div class="broker-group-title">Meshcoretel</div>
+	              <div class="broker-grid single">
+	                <div class="broker-card">
+	                  <div class="broker-mode">
+	                    <div class="broker-row">
+	                      <div class="broker-copy">
+	                        <div class="broker-title">Meshcoretel RU</div>
+	                        <div class="broker-state" id="mqttMeshcoretelState">Off</div>
+	                      </div>
+	                    </div>
+	                    <div class="mode-slider">
+	                      <input id="mqttMeshcoretelMode" type="range" min="0" max="1" step="1" value="0" aria-label="Meshcoretel mode">
+	                      <div class="mode-labels two" aria-hidden="true">
+	                        <div class="mode-label" data-meshcoretel-label="off">Off</div>
+	                        <div class="mode-label" data-meshcoretel-label="on">On</div>
+	                      </div>
+	                    </div>
+	                    <input id="mqttMeshcoretel" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
+	                  </div>
+	                </div>
+	              </div>
+	            </div>
 	            <div class="broker-group">
 	              <div class="broker-group-title">EastMesh</div>
 		              <div class="broker-grid single">
@@ -964,7 +1016,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
 	              </div>
 	            </div>
 	          </div>
-	          <div class="panel-note">If EastMesh is enabled, use only one LetsMesh broker. Enable both LetsMesh brokers only when EastMesh is off.</div>
+	          <div class="panel-note">Only two brokers can be enabled at the same time.</div>
 	          <div id="mqttBrokerWarning" class="panel-warning"></div>
 	        </div>
 	      </div>
@@ -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();
   </script>
 </body>