feat: add custom mqtt support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">↻</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">↻</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">↻</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";
|
||||
|
||||
Reference in New Issue
Block a user