diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index 66e1da07..f669d4b4 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -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 ` +- `get mqtt.custom.port` +- `set mqtt.custom.port ` +- `get mqtt.custom.username` +- `set mqtt.custom.username ` +- `get mqtt.custom.password`: shows `set` when a custom password is configured. +- `set mqtt.custom.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///` 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 diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index 2e2f86df..510a46ca 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 877185a7..10771faf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(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 diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index a9dde6b0..ad9b5436 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -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; } diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h index b5f253cc..bfad8d2e 100644 --- a/src/helpers/mqtt/MQTTPrefs.h +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -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 { diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 1a1a8d62..4bef6239 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -18,6 +18,7 @@ #endif #include #include +#include #include #include #include @@ -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(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(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(host[i]); + if (c <= ' ' || c == '/' || c == ':' || c == '\\') { + return false; + } + cleaned[oi++] = static_cast(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(parsed); + _prefs.custom_port = static_cast(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"; } diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 818ee118..e708ae81 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -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); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 70e94c0d..21b9eb02 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -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( +
+
Custom
+
+
+
+
+
+
Custom MQTT
+
Off
+
+
+
+ + +
+ +
+
+
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+
+
+ +
+ + + +
+
+
+ +
+ + +
+
+
+
+
+
-
If EastMesh is enabled, use only one LetsMesh broker. Enable both LetsMesh brokers only when EastMesh is off.
+
A maximum of two MQTT brokers can be enabled at once.
@@ -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";