Added support for MeshCoreTel broker.

This commit is contained in:
Valentin V. Bartenev
2026-04-27 03:15:00 +03:00
parent 41a29975a4
commit f1912ff184
6 changed files with 216 additions and 46 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>