feat: add custom mqtt support

This commit is contained in:
Jared Dohrman
2026-05-25 09:51:37 +10:00
parent af81ead9b5
commit 4aa2a3dabd
8 changed files with 360 additions and 43 deletions
+13 -1
View File
@@ -68,12 +68,24 @@ No-argument `get` commands must be entered exactly as shown.
- `set mqtt.letsmesh-eu on|off`
- `get mqtt.letsmesh-us`
- `set mqtt.letsmesh-us on|off`
- `get mqtt.custom`
- `set mqtt.custom on|off`
- `get mqtt.custom.host`
- `set mqtt.custom.host <host>`
- `get mqtt.custom.port`
- `set mqtt.custom.port <port>`
- `get mqtt.custom.username`
- `set mqtt.custom.username <username>`
- `get mqtt.custom.password`: shows `set` when a custom password is configured.
- `set mqtt.custom.password <password>`
Notes:
- new observer 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`, enabled MQTT brokers will not connect
- custom MQTT uses normal MQTT over TCP with the configured username and password, not JWT authentication
- custom MQTT uses the same `meshcore/<IATA>/<device>/<leaf>` topics as the curated brokers
- turning off a connected broker publishes a retained MQTT status update with `"status":"offline"` before the client disconnects
- changing `mqtt.iata` away from a configured value also publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path
+4 -2
View File
@@ -247,7 +247,8 @@ This section includes:
- `mqtt.iata`: selected from a curated east-coast/south-east list.
- `mqtt.owner`: owner public key.
- `mqtt.email`: owner contact email.
- MQTT server toggles: `eastmesh-au`, `letsmesh-eu`, and `letsmesh-us`.
- MQTT server toggles: `eastmesh-au`, `letsmesh-eu`, `letsmesh-us`, and custom MQTT.
- custom MQTT host, port, username, and password fields.
`UNSET - To be configured` is the default for new observer installs until a real saved value exists.
@@ -257,9 +258,10 @@ Notes:
- while `mqtt.iata` is `UNSET`, enabled MQTT brokers do not attempt to connect
- the current MQTT server states are loaded when the page opens
- you can toggle each MQTT server on or off from this panel
- custom MQTT uses normal MQTT over TCP with the configured username and password, not JWT authentication
- 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
- if all three servers are enabled at once, the panel shows a warning recommending two at most
- at most two MQTT brokers can be enabled at once
## `/stats` Overview
+40
View File
@@ -2350,6 +2350,16 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off");
} else if (strcmp(command, "get mqtt.letsmesh-us") == 0 || strcmp(command, "get mqtt.letsmesh.us") == 0) {
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x04) ? "on" : "off");
} else if (strcmp(command, "get mqtt.custom") == 0) {
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x08) ? "on" : "off");
} else if (strcmp(command, "get mqtt.custom.host") == 0) {
sprintf(reply, "> %s", mqtt.getCustomHost()[0] ? mqtt.getCustomHost() : "-");
} else if (strcmp(command, "get mqtt.custom.port") == 0) {
sprintf(reply, "> %u", static_cast<unsigned>(mqtt.getCustomPort()));
} else if (strcmp(command, "get mqtt.custom.username") == 0) {
sprintf(reply, "> %s", mqtt.getCustomUsername()[0] ? mqtt.getCustomUsername() : "-");
} else if (strcmp(command, "get mqtt.custom.password") == 0) {
sprintf(reply, "> %s", mqtt.hasCustomPassword() ? "set" : "-");
} else if (memcmp(command, "set mqtt.tx ", 12) == 0) {
mqtt.setTxEnabled(memcmp(&command[12], "on", 2) == 0);
strcpy(reply, "OK");
@@ -2398,6 +2408,36 @@ 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.custom ", 16) == 0) {
if (mqtt.setEndpointEnabled(0x08, memcmp(&command[16], "on", 2) == 0)) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - max 2 mqtt brokers");
}
} else if (memcmp(command, "set mqtt.custom.host ", 21) == 0) {
if (mqtt.setCustomHost(&command[21])) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - bad mqtt.custom.host");
}
} else if (memcmp(command, "set mqtt.custom.port ", 21) == 0) {
if (mqtt.setCustomPort(&command[21])) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - bad mqtt.custom.port");
}
} else if (memcmp(command, "set mqtt.custom.username ", 25) == 0) {
if (mqtt.setCustomUsername(&command[25])) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - bad mqtt.custom.username");
}
} else if (memcmp(command, "set mqtt.custom.password ", 25) == 0) {
if (mqtt.setCustomPassword(&command[25])) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - bad mqtt.custom.password");
}
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
+5 -1
View File
@@ -22,6 +22,7 @@ void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) {
prefs.deprecated_web_stats_enabled = 0;
prefs.legacy_wifi_powersave = 0;
prefs.status_interval_ms = kFixedStatusIntervalMs;
prefs.custom_port = 1883;
StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata));
#ifdef WIFI_SSID
StrHelper::strncpy(prefs.legacy_wifi_ssid, WIFI_SSID, sizeof(prefs.legacy_wifi_ssid));
@@ -66,8 +67,11 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) {
if (prefs.iata[0] == 0) {
StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata));
}
if (prefs.custom_port == 0) {
prefs.custom_port = 1883;
}
prefs.status_interval_ms = kFixedStatusIntervalMs;
prefs.enabled_mask &= 0x07;
prefs.enabled_mask &= 0x0F;
return true;
}
+5 -1
View File
@@ -29,7 +29,11 @@ struct MQTTPrefs {
char owner_public_key[65];
char owner_email[96];
uint8_t deprecated_web_stats_enabled;
uint8_t reserved[3];
uint16_t custom_port;
char custom_host[96];
char custom_username[65];
char custom_password[96];
uint8_t reserved[1];
};
class MQTTPrefsStore {
+173 -33
View File
@@ -18,6 +18,7 @@
#endif
#include <helpers/TxtDataHelpers.h>
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
@@ -121,12 +122,13 @@ 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},
{"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt",
kLetsmeshEuBit},
kLetsmeshEuBit, false},
{"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt",
kLetsmeshUsBit},
kLetsmeshUsBit, false},
{"custom", "custom", nullptr, nullptr, kCustomBit, true},
};
MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
@@ -135,7 +137,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];
}
@@ -156,7 +158,7 @@ bool MQTTUplink::isTokenRefreshInProgress() const {
}
bool MQTTUplink::hasEnabledBroker() const {
return (_prefs.enabled_mask & 0x07) != 0;
return (_prefs.enabled_mask & 0x0F) != 0;
}
uint8_t MQTTUplink::countEnabledBrokers() const {
@@ -190,6 +192,30 @@ const char* MQTTUplink::brokerCaCert(const BrokerSpec& spec) {
return mqtt_ca_certs::kLetsmeshWe1Pem;
}
const char* MQTTUplink::brokerHost(const BrokerState& broker) const {
if (broker.spec == nullptr) {
return "";
}
return broker.spec->custom ? _prefs.custom_host : broker.spec->host;
}
uint16_t MQTTUplink::brokerPort(const BrokerState& broker) const {
if (broker.spec != nullptr && broker.spec->custom) {
return _prefs.custom_port != 0 ? _prefs.custom_port : 1883;
}
return 443;
}
bool MQTTUplink::isBrokerConfigured(const BrokerState& broker) const {
if (broker.spec == nullptr) {
return false;
}
if (!broker.spec->custom) {
return true;
}
return _prefs.custom_host[0] != 0 && _prefs.custom_username[0] != 0 && _prefs.custom_password[0] != 0;
}
uint8_t MQTTUplink::normalizeEnabledMask(uint8_t mask) {
uint8_t normalized = 0;
uint8_t count = 0;
@@ -236,12 +262,15 @@ bool MQTTUplink::preflightBroker(BrokerState& broker) const {
if (broker.spec == nullptr) {
return false;
}
if (broker.spec->custom) {
return isBrokerConfigured(broker);
}
logMqttMemorySnapshot("preflight-pre", broker.spec->label);
WiFiClientSecure client;
client.setCACert(brokerCaCert(*broker.spec));
client.setTimeout(kWsPreflightTimeoutMillis);
if (!client.connect(broker.spec->host, 443)) {
if (!client.connect(brokerHost(broker), brokerPort(broker))) {
MQTT_LOG("%s preflight failed: tcp/tls connect", broker.spec->label);
client.stop();
logMqttMemorySnapshot("preflight-failed", broker.spec->label);
@@ -255,8 +284,10 @@ bool MQTTUplink::preflightBroker(BrokerState& broker) const {
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"User-Agent: MeshCore-EastMesh\r\n"
"Host: ");
client.print(broker.spec->host);
client.print(":443\r\n\r\n");
client.print(brokerHost(broker));
client.print(":");
client.print(brokerPort(broker));
client.print("\r\n\r\n");
char response[kWsPreflightResponseSize];
size_t used = 0;
@@ -454,7 +485,11 @@ void MQTTUplink::refreshBrokerIdentity(BrokerState& broker) {
if (broker.spec == nullptr) {
return;
}
snprintf(broker.username, sizeof(broker.username), "v1_%s", _device_id);
if (broker.spec->custom) {
StrHelper::strncpy(broker.username, _prefs.custom_username, sizeof(broker.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");
}
@@ -481,6 +516,11 @@ void MQTTUplink::refreshBrokerState(BrokerState& broker) {
}
bool MQTTUplink::refreshToken(BrokerState& broker) {
if (broker.spec != nullptr && broker.spec->custom) {
broker.token_expires_at = 0;
return true;
}
time_t now = time(nullptr);
if (now < kMinSaneEpoch) {
MQTT_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast<unsigned long>(now));
@@ -498,7 +538,7 @@ bool MQTTUplink::refreshToken(BrokerState& broker) {
time_t expires_at = now + kTokenLifetimeSecs;
const char* owner = _prefs.owner_public_key[0] ? _prefs.owner_public_key : nullptr;
const char* email = _prefs.owner_email[0] ? _prefs.owner_email : nullptr;
if (!JWTHelper::createAuthToken(*_identity, broker.spec->host, now, expires_at, broker.token, kBrokerTokenSize,
if (!JWTHelper::createAuthToken(*_identity, brokerHost(broker), now, expires_at, broker.token, kBrokerTokenSize,
owner, email)) {
freeScratchBuffer(broker.token);
broker.token = nullptr;
@@ -803,7 +843,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) {
}
bool enabled = (_prefs.enabled_mask & broker.spec->bit) != 0;
bool iata_configured = !isUnsetIataValue(_prefs.iata);
if (!enabled || !iata_configured) {
if (!enabled || !iata_configured || !isBrokerConfigured(broker)) {
if (broker.client != nullptr || broker.token != nullptr || broker.connected || broker.connect_announced ||
broker.reconnect_pending || broker.next_connect_attempt != 0 || broker.last_connect_attempt != 0 ||
broker.reconnect_failures != 0 || broker.token_expires_at != 0) {
@@ -818,7 +858,8 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) {
time_t now = time(nullptr);
unsigned long now_ms = millis();
if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) {
if (!broker.spec->custom && broker.client != nullptr && broker.token_expires_at > 0 &&
now + kTokenRefreshSlackSecs >= broker.token_expires_at) {
_token_refresh_count++;
_token_refresh_active_until_ms = now_ms + kTokenRefreshEventWindowMs;
MQTT_LOG("%s token refresh reconnect count=%lu", broker.spec->label,
@@ -881,23 +922,29 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) {
}
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 port=%u path=%s client_id=%s", broker.spec->label, brokerHost(broker),
static_cast<unsigned>(brokerPort(broker)), broker.spec->custom ? "-" : "/mqtt", 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.address.hostname = brokerHost(broker);
cfg.broker.address.port = brokerPort(broker);
cfg.broker.address.transport = broker.spec->custom ? MQTT_TRANSPORT_OVER_TCP : MQTT_TRANSPORT_OVER_WSS;
if (!broker.spec->custom) {
cfg.broker.address.path = "/mqtt";
}
if (broker.spec->custom) {
cfg.credentials.authentication.password = _prefs.custom_password;
} else {
#if defined(MQTT_USE_CRT_BUNDLE)
cfg.broker.verification.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH;
cfg.broker.verification.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH;
#else
cfg.broker.verification.certificate = brokerCaCert(*broker.spec);
cfg.broker.verification.certificate = brokerCaCert(*broker.spec);
#endif
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;
@@ -909,10 +956,10 @@ 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;
cfg.host = brokerHost(broker);
cfg.port = brokerPort(broker);
cfg.username = broker.username;
cfg.password = broker.token;
cfg.password = broker.spec->custom ? _prefs.custom_password : broker.token;
cfg.client_id = broker.client_id;
cfg.keepalive = 30;
cfg.buffer_size = 768;
@@ -920,17 +967,19 @@ 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.transport = broker.spec->custom ? MQTT_TRANSPORT_OVER_TCP : MQTT_TRANSPORT_OVER_WSS;
if (!broker.spec->custom) {
#if defined(MQTT_USE_CRT_BUNDLE)
cfg.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH;
cfg.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH;
#else
cfg.cert_pem = brokerCaCert(*broker.spec);
cfg.cert_pem = brokerCaCert(*broker.spec);
#endif
cfg.path = "/mqtt";
}
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);
@@ -958,7 +1007,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();
@@ -1087,6 +1136,9 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const {
if (broker == nullptr) {
return "retry";
}
if (!isBrokerConfigured(*broker)) {
return "unconfigured";
}
if (broker->connected) {
return "up";
}
@@ -1102,16 +1154,17 @@ 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 eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s custom:%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),
_prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off");
broker_state(kCustomBit), _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) {
@@ -1224,6 +1277,89 @@ bool MQTTUplink::setOwnerEmail(const char* owner_email) {
return savePrefs();
}
bool MQTTUplink::setCustomHost(const char* host) {
if (host == nullptr) {
return false;
}
char cleaned[sizeof(_prefs.custom_host)];
memset(cleaned, 0, sizeof(cleaned));
size_t oi = 0;
for (size_t i = 0; host[i] != 0 && oi + 1 < sizeof(cleaned); ++i) {
unsigned char c = static_cast<unsigned char>(host[i]);
if (c <= ' ' || c == '/' || c == ':' || c == '\\') {
return false;
}
cleaned[oi++] = static_cast<char>(c);
}
cleaned[oi] = 0;
bool changed = strcmp(cleaned, _prefs.custom_host) != 0;
StrHelper::strncpy(_prefs.custom_host, cleaned, sizeof(_prefs.custom_host));
bool saved = savePrefs();
if (saved && changed) {
for (BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.spec->bit == kCustomBit) {
destroyBroker(broker);
}
}
}
return saved;
}
bool MQTTUplink::setCustomPort(const char* port) {
if (port == nullptr || port[0] == 0) {
return false;
}
char* end = nullptr;
unsigned long parsed = strtoul(port, &end, 10);
if (end == port || *end != 0 || parsed == 0 || parsed > 65535UL) {
return false;
}
bool changed = _prefs.custom_port != static_cast<uint16_t>(parsed);
_prefs.custom_port = static_cast<uint16_t>(parsed);
bool saved = savePrefs();
if (saved && changed) {
for (BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.spec->bit == kCustomBit) {
destroyBroker(broker);
}
}
}
return saved;
}
bool MQTTUplink::setCustomUsername(const char* username) {
if (username == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.custom_username, username, sizeof(_prefs.custom_username));
refreshIdentityStrings();
bool saved = savePrefs();
if (saved) {
for (BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.spec->bit == kCustomBit) {
destroyBroker(broker);
}
}
}
return saved;
}
bool MQTTUplink::setCustomPassword(const char* password) {
if (password == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.custom_password, password, sizeof(_prefs.custom_password));
bool saved = savePrefs();
if (saved) {
for (BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.spec->bit == kCustomBit) {
destroyBroker(broker);
}
}
}
return saved;
}
bool MQTTUplink::isAnyBrokerConnected() const {
for (const BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.connected) {
@@ -1280,6 +1416,10 @@ bool MQTTUplink::setIata(const char*) { return false; }
bool MQTTUplink::isActive() const { return false; }
bool MQTTUplink::setOwnerPublicKey(const char*) { return false; }
bool MQTTUplink::setOwnerEmail(const char*) { return false; }
bool MQTTUplink::setCustomHost(const char*) { return false; }
bool MQTTUplink::setCustomPort(const char*) { return false; }
bool MQTTUplink::setCustomUsername(const char*) { return false; }
bool MQTTUplink::setCustomPassword(const char*) { return false; }
bool MQTTUplink::sendStatusNow() { return false; }
bool MQTTUplink::isAnyBrokerConnected() const { return false; }
const char* MQTTUplink::getAggregateBrokerState() const { return "down"; }
+15 -2
View File
@@ -62,6 +62,14 @@ public:
const char* getOwnerPublicKey() const { return _prefs.owner_public_key; }
bool setOwnerEmail(const char* owner_email);
const char* getOwnerEmail() const { return _prefs.owner_email; }
bool setCustomHost(const char* host);
const char* getCustomHost() const { return _prefs.custom_host; }
bool setCustomPort(const char* port);
uint16_t getCustomPort() const { return _prefs.custom_port; }
bool setCustomUsername(const char* username);
const char* getCustomUsername() const { return _prefs.custom_username; }
bool setCustomPassword(const char* password);
bool hasCustomPassword() const { return _prefs.custom_password[0] != 0; }
bool sendStatusNow();
bool isAnyBrokerConnected() const;
const char* getAggregateBrokerState() const;
@@ -77,6 +85,7 @@ private:
const char* host;
const char* uri;
uint8_t bit;
bool custom;
};
struct BrokerState {
@@ -116,12 +125,13 @@ private:
static constexpr uint8_t kEastmeshBit = 0x01;
static constexpr uint8_t kLetsmeshEuBit = 0x02;
static constexpr uint8_t kLetsmeshUsBit = 0x04;
static constexpr uint8_t kCustomBit = 0x08;
static constexpr uint8_t kMaxEnabledBrokers = 2;
static const BrokerSpec kBrokerSpecs[3];
static const BrokerSpec kBrokerSpecs[4];
static bool isUnsetIataValue(const char* iata);
static const char* brokerCaCert(const BrokerSpec& spec);
BrokerState _brokers[3];
BrokerState _brokers[4];
static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data);
static void scheduleBrokerRetry(BrokerState& broker, unsigned long now_ms, bool count_failure);
@@ -131,6 +141,9 @@ private:
static uint8_t normalizeEnabledMask(uint8_t mask);
bool hasConnectHeadroom(const BrokerState& broker) const;
bool preflightBroker(BrokerState& broker) const;
const char* brokerHost(const BrokerState& broker) const;
uint16_t brokerPort(const BrokerState& broker) const;
bool isBrokerConfigured(const BrokerState& broker) const;
void formatTopic(char* dst, size_t dst_size, const char* leaf) const;
void refreshIdentityStrings();
void refreshBrokerIdentity(BrokerState& broker);
+105 -3
View File
@@ -411,6 +411,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
.savebtn:hover { background:var(--accent-hover); }
.broker-stack { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,2fr); gap:12px; align-items:start; }
.broker-group { display:grid; gap:8px; align-content:start; }
.broker-group.wide { grid-column:1 / -1; }
.broker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
.broker-grid.single { grid-template-columns:1fr; }
.broker-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
@@ -982,8 +983,68 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
</div>
</div>
</div>
<div class="broker-group wide">
<div class="broker-group-title">Custom</div>
<div class="broker-grid one-two">
<div class="broker-card">
<div class="broker-mode">
<div class="broker-row">
<div class="broker-copy">
<div class="broker-title">Custom MQTT</div>
<div class="broker-state" id="mqttCustomState">Off</div>
</div>
</div>
<div class="mode-slider">
<input id="mqttCustomMode" type="range" min="0" max="1" step="1" value="0" aria-label="Custom MQTT mode">
<div class="mode-labels two" aria-hidden="true">
<div class="mode-label" data-custom-mqtt-label="off">Off</div>
<div class="mode-label" data-custom-mqtt-label="on">On</div>
</div>
</div>
<input id="mqttCustom" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
</div>
</div>
<div class="broker-card">
<div class="row">
<div class="field-card">
<label class="label" for="mqttCustomHost">Host</label>
<div class="inline-actions">
<input id="mqttCustomHost" placeholder="mqtt.example.net" maxlength="95">
<button class="iconbtn" data-load-cmd="get mqtt.custom.host" data-load-input="mqttCustomHost" title="Refresh custom MQTT host">&#8635;</button>
<button class="savebtn" data-prefix="set mqtt.custom.host " data-input="mqttCustomHost">Save</button>
</div>
</div>
<div class="field-card">
<label class="label" for="mqttCustomPort">Port</label>
<div class="inline-actions">
<input id="mqttCustomPort" inputmode="numeric" placeholder="1883" maxlength="5">
<button class="iconbtn" data-load-cmd="get mqtt.custom.port" data-load-input="mqttCustomPort" title="Refresh custom MQTT port">&#8635;</button>
<button class="savebtn" data-prefix="set mqtt.custom.port " data-input="mqttCustomPort">Save</button>
</div>
</div>
</div>
<div class="row">
<div class="field-card">
<label class="label" for="mqttCustomUsername">Username</label>
<div class="inline-actions">
<input id="mqttCustomUsername" placeholder="username" maxlength="64">
<button class="iconbtn" data-load-cmd="get mqtt.custom.username" data-load-input="mqttCustomUsername" title="Refresh custom MQTT username">&#8635;</button>
<button class="savebtn" data-prefix="set mqtt.custom.username " data-input="mqttCustomUsername">Save</button>
</div>
</div>
<div class="field-card">
<label class="label" for="mqttCustomPassword">Password</label>
<div class="inline-actions two-actions">
<input id="mqttCustomPassword" type="password" placeholder="password" maxlength="95">
<button class="savebtn" data-prefix="set mqtt.custom.password " data-input="mqttCustomPassword">Save</button>
</div>
</div>
</div>
</div>
</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">A maximum of two MQTT brokers can be enabled at once.</div>
<div id="mqttBrokerWarning" class="panel-warning"></div>
</div>
</div>
@@ -2203,7 +2264,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 MQTT brokers."
: "";
}
}
@@ -2251,6 +2312,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
if (inputId === "nodeName") {
await loadField("get name", "nodeName", null, { recordHistory:false, updateInput:false });
}
if (inputId === "mqttCustomPassword") {
input.value = "";
}
}
async function loadField(cmd, inputId, format, options = {}) {
const result = await runCommand(cmd, options);
@@ -2261,6 +2325,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
} else if (format === "uppercase") {
value = value.toUpperCase();
}
if ((inputId === "mqttCustomHost" || inputId === "mqttCustomUsername") && value === "-") {
value = "";
}
document.getElementById(inputId).value = value;
if (inputId === "mqttIata") {
mqttIataLoaded = true;
@@ -2359,6 +2426,17 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
label.classList.toggle("active", label.dataset.eastmeshLabel === (enabled ? "on" : "off"));
});
}
function refreshCustomMqttModeUi() {
const input = document.getElementById("mqttCustom");
const enabled = !!(input && input.checked);
const slider = document.getElementById("mqttCustomMode");
if (slider) {
slider.value = enabled ? "1" : "0";
}
document.querySelectorAll("[data-custom-mqtt-label]").forEach((label) => {
label.classList.toggle("active", label.dataset.customMqttLabel === (enabled ? "on" : "off"));
});
}
function getLetsmeshModeIndex(mode) {
const order = { off:0, eu:1, us:2, both:3 };
return Object.prototype.hasOwnProperty.call(order, mode) ? order[mode] : 0;
@@ -2405,6 +2483,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
} else if (inputId === "mqttEastmeshAu") {
refreshEastmeshModeUi();
refreshLetsmeshModeUi();
} else if (inputId === "mqttCustom") {
refreshCustomMqttModeUi();
}
}
async function loadBrokerState(cmd, inputId, options = {}) {
@@ -2571,6 +2651,24 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
setEastmeshMode((Number.parseInt(eastmeshModeSlider.value, 10) || 0) >= 1);
});
}
async function setCustomMqttMode(enabled) {
const result = await runCommand(enabled ? "set mqtt.custom on" : "set mqtt.custom off");
if (!result.ok) {
refreshCustomMqttModeUi();
return;
}
setBrokerToggle("mqttCustom", enabled ? "on" : "off");
refreshCustomMqttModeUi();
}
const customMqttModeSlider = document.getElementById("mqttCustomMode");
if (customMqttModeSlider) {
customMqttModeSlider.addEventListener("input", () => {
customMqttModeSlider.value = (Number.parseInt(customMqttModeSlider.value, 10) || 0) >= 1 ? "1" : "0";
});
customMqttModeSlider.addEventListener("change", () => {
setCustomMqttMode((Number.parseInt(customMqttModeSlider.value, 10) || 0) >= 1);
});
}
async function setLetsmeshMode(mode) {
const eastmesh = document.getElementById("mqttEastmeshAu");
if (mode === "both" && eastmesh && eastmesh.checked) {
@@ -2759,7 +2857,11 @@ 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.custom", "mqttCustom", quiet),
() => loadField("get mqtt.custom.host", "mqttCustomHost", null, quiet),
() => loadField("get mqtt.custom.port", "mqttCustomPort", null, quiet),
() => loadField("get mqtt.custom.username", "mqttCustomUsername", null, quiet)
]);
if (!isCurrentPageLoad(generation)) return;
statusEl.textContent = "Ready";