feat(mqtt): add MeshMapper broker, retire LetsMesh, Primary/Secondary broker
This commit is contained in:
@@ -142,10 +142,11 @@ uv run --group docs zensical build
|
||||
|
||||
### MQTT Repeater Additions
|
||||
|
||||
- hardcoded broker support for:
|
||||
- curated broker support for:
|
||||
- `eastmesh-au`
|
||||
- `letsmesh-eu`
|
||||
- `letsmesh-us`
|
||||
- `meshmapper`
|
||||
- `letsmesh-eu` (retired)
|
||||
- `letsmesh-us` (retired)
|
||||
- WSS transport at `/mqtt`
|
||||
- JWT auth using the device identity
|
||||
- CLI controls for:
|
||||
|
||||
@@ -65,10 +65,12 @@ No-argument `get` commands must be entered exactly as shown.
|
||||
|
||||
- `get mqtt.eastmesh-au`
|
||||
- `set mqtt.eastmesh-au on|off`
|
||||
- `get mqtt.letsmesh-eu`
|
||||
- `set mqtt.letsmesh-eu on|off`
|
||||
- `get mqtt.letsmesh-us`
|
||||
- `set mqtt.letsmesh-us on|off`
|
||||
- `get mqtt.meshmapper`
|
||||
- `set mqtt.meshmapper on|off`
|
||||
- `get mqtt.letsmesh-eu` (retired)
|
||||
- `set mqtt.letsmesh-eu on|off` (retired)
|
||||
- `get mqtt.letsmesh-us` (retired)
|
||||
- `set mqtt.letsmesh-us on|off` (retired)
|
||||
- `get mqtt.custom`
|
||||
- `set mqtt.custom on|off`
|
||||
- `get mqtt.custom.host`
|
||||
@@ -85,11 +87,13 @@ No-argument `get` commands must be entered exactly as shown.
|
||||
Notes:
|
||||
|
||||
- new observer installs default `mqtt.iata` to `UNSET`
|
||||
- `letsmesh-eu` and `letsmesh-us` remain off by default unless already configured in saved prefs
|
||||
- a maximum of two MQTT brokers can be enabled at once
|
||||
- `meshmapper` is the curated global broker (`wss://mqtt.meshmapper.net:443/mqtt`); like `eastmesh-au` it uses WSS, verified TLS, and MeshCore JWT auth with the broker host as the token audience
|
||||
- `letsmesh-eu` and `letsmesh-us` are retired (LetsMesh is no longer maintained) and off by default; any saved selections are cleared once on upgrade, though the endpoints stay selectable for legacy use
|
||||
- if `mqtt.iata` is `UNSET`, enabled MQTT brokers will not connect
|
||||
- custom MQTT uses the configured username and password, not JWT authentication
|
||||
- custom MQTT defaults to TCP; set `mqtt.custom.transport wss` for MQTT over secure WebSockets using the fixed `/mqtt` websocket path and the ESP-IDF x509 root CA bundle
|
||||
- in `get mqtt.status`, custom MQTT is shown as `custom:<transport>:<state>`, for example `custom:wss:up`; `conn` means connecting and `up` means connected
|
||||
- `get mqtt.status` reports the two enabled broker slots as `p:<broker>:<state>` (primary) and `s:<broker>:<state>` (secondary), or `-` when a slot is empty; custom appears as `custom:<transport>:<state>` (for example `p:custom:wss:up`); `conn` means connecting and `up` means connected
|
||||
- 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
|
||||
|
||||
@@ -171,7 +171,7 @@ Typical first steps after flashing:
|
||||
- set `mqtt.iata`
|
||||
- confirm `get mqtt.status`
|
||||
- optionally set `mqtt.owner` and `mqtt.email`
|
||||
- optionally enable `letsmesh-eu` or `letsmesh-us`
|
||||
- optionally enable a second broker such as `meshmapper`
|
||||
|
||||
### Observer ESP-NOW
|
||||
|
||||
|
||||
@@ -253,8 +253,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`, `letsmesh-us`, and custom MQTT.
|
||||
- custom MQTT `host:port`, TCP/WSS transport, username, and password fields.
|
||||
- MQTT brokers: **Primary MQTT** and **Secondary MQTT** dropdowns, each selecting one of `eastmesh-au`, `meshmapper`, `Custom`, the retired `letsmesh-eu`/`letsmesh-us`, or `None`. The two slots enforce the two-broker maximum, and a broker chosen in one slot is disabled in the other.
|
||||
- custom MQTT `host:port`, TCP/WSS transport, username, and password fields, shown when `Custom` is selected in either slot.
|
||||
|
||||
`UNSET - To be configured` is the default for new observer installs until a real saved value exists.
|
||||
|
||||
|
||||
@@ -2444,6 +2444,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off");
|
||||
} else if (strcmp(command, "get mqtt.eastmesh-au") == 0 || strcmp(command, "get mqtt.eastmesh.au") == 0) {
|
||||
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x01) ? "on" : "off");
|
||||
} else if (strcmp(command, "get mqtt.meshmapper") == 0) {
|
||||
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x10) ? "on" : "off");
|
||||
} else if (strcmp(command, "get mqtt.letsmesh-eu") == 0 || strcmp(command, "get mqtt.letsmesh.eu") == 0) {
|
||||
sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off");
|
||||
} else if (strcmp(command, "get mqtt.letsmesh-us") == 0 || strcmp(command, "get mqtt.letsmesh.us") == 0) {
|
||||
@@ -2496,6 +2498,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
} else {
|
||||
strcpy(reply, "Err - max 2 mqtt brokers");
|
||||
}
|
||||
} else if (memcmp(command, "set mqtt.meshmapper ", 20) == 0) {
|
||||
if (mqtt.setEndpointEnabled(0x10, memcmp(&command[20], "on", 2) == 0)) {
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Err - max 2 mqtt brokers");
|
||||
}
|
||||
} else if (memcmp(command, "set mqtt.letsmesh-eu ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.eu ", 21) == 0) {
|
||||
if (mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0)) {
|
||||
strcpy(reply, "OK");
|
||||
|
||||
@@ -23,6 +23,7 @@ void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) {
|
||||
prefs.legacy_wifi_powersave = 0;
|
||||
prefs.status_interval_ms = kFixedStatusIntervalMs;
|
||||
prefs.custom_port = 1883;
|
||||
prefs.brokers_migrated = 1; // Fresh installs need no LetsMesh retirement migration.
|
||||
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));
|
||||
@@ -74,7 +75,14 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) {
|
||||
prefs.custom_transport = 0;
|
||||
}
|
||||
prefs.status_interval_ms = kFixedStatusIntervalMs;
|
||||
prefs.enabled_mask &= 0x0F;
|
||||
prefs.enabled_mask &= 0x1F;
|
||||
if (!prefs.brokers_migrated) {
|
||||
// LetsMesh is retired. Clear any saved EU/US selections once so dead brokers
|
||||
// stop retrying; the endpoints remain re-selectable afterwards.
|
||||
prefs.enabled_mask &= ~static_cast<uint8_t>(0x02 | 0x04);
|
||||
prefs.brokers_migrated = 1;
|
||||
save(fs, prefs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ struct MQTTPrefs {
|
||||
char custom_username[65];
|
||||
char custom_password[96];
|
||||
uint8_t custom_transport;
|
||||
// Appended fields must stay at the end: older prefs files are shorter and
|
||||
// read back as zero here, which drives one-time migrations (see load()).
|
||||
uint8_t brokers_migrated;
|
||||
};
|
||||
|
||||
class MQTTPrefsStore {
|
||||
|
||||
@@ -127,8 +127,10 @@ void logMqttMemorySnapshot(const char*, const char* = nullptr) {
|
||||
|
||||
}
|
||||
|
||||
const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[4] = {
|
||||
const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[kBrokerCount] = {
|
||||
{"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit, false},
|
||||
{"meshmapper", "meshmapper", "mqtt.meshmapper.net", "wss://mqtt.meshmapper.net:443/mqtt", kMeshmapperBit, false},
|
||||
// Retired: LetsMesh is no longer maintained. Kept selectable for legacy nodes; new nodes should use meshmapper.
|
||||
{"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt",
|
||||
kLetsmeshEuBit, false},
|
||||
{"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt",
|
||||
@@ -142,7 +144,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 < 4; ++i) {
|
||||
for (size_t i = 0; i < kBrokerCount; ++i) {
|
||||
memset(&_brokers[i], 0, sizeof(_brokers[i]));
|
||||
_brokers[i].spec = &kBrokerSpecs[i];
|
||||
}
|
||||
@@ -167,7 +169,7 @@ bool MQTTUplink::isTokenRefreshInProgress() const {
|
||||
}
|
||||
|
||||
bool MQTTUplink::hasEnabledBroker() const {
|
||||
return (_prefs.enabled_mask & 0x0F) != 0;
|
||||
return (_prefs.enabled_mask & kBrokerMask) != 0;
|
||||
}
|
||||
|
||||
uint8_t MQTTUplink::countEnabledBrokers() const {
|
||||
@@ -195,8 +197,10 @@ bool MQTTUplink::isUnsetIataValue(const char* iata) {
|
||||
}
|
||||
|
||||
const char* MQTTUplink::brokerCaCert(const BrokerSpec& spec) {
|
||||
if (spec.bit == kEastmeshBit) {
|
||||
return mqtt_ca_certs::kEastmeshIsrgRootX1Pem;
|
||||
// Note: ESP32 targets with the certificate bundle attach it instead of these PEMs;
|
||||
// this fallback only applies on builds without CONFIG_MBEDTLS_CERTIFICATE_BUNDLE.
|
||||
if (spec.bit == kEastmeshBit || spec.bit == kMeshmapperBit) {
|
||||
return mqtt_ca_certs::kEastmeshIsrgRootX1Pem; // Let's Encrypt ISRG Root X1
|
||||
}
|
||||
return mqtt_ca_certs::kLetsmeshWe1Pem;
|
||||
}
|
||||
@@ -1056,7 +1060,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 & 0x0F);
|
||||
uint8_t normalized_mask = normalizeEnabledMask(_prefs.enabled_mask & kBrokerMask);
|
||||
if (normalized_mask != _prefs.enabled_mask) {
|
||||
_prefs.enabled_mask = normalized_mask;
|
||||
savePrefs();
|
||||
@@ -1203,18 +1207,47 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const {
|
||||
return "retry";
|
||||
};
|
||||
|
||||
// Only ever up to two brokers are enabled, so report the active slots instead of
|
||||
// every broker. This keeps the reply short for LoRa and is stable as brokers are added.
|
||||
auto format_slot = [this, &broker_state](const BrokerState* broker, char* buf, size_t buf_size) {
|
||||
if (broker == nullptr || broker->spec == nullptr) {
|
||||
StrHelper::strncpy(buf, "-", buf_size);
|
||||
} else if (broker->spec->custom) {
|
||||
snprintf(buf, buf_size, "custom:%s:%s", getCustomTransport(), broker_state(broker->spec->bit));
|
||||
} else {
|
||||
snprintf(buf, buf_size, "%s:%s", broker->spec->key, broker_state(broker->spec->bit));
|
||||
}
|
||||
};
|
||||
|
||||
const BrokerState* primary = nullptr;
|
||||
const BrokerState* secondary = nullptr;
|
||||
for (const BrokerState& broker : _brokers) {
|
||||
if (broker.spec == nullptr || (_prefs.enabled_mask & broker.spec->bit) == 0) {
|
||||
continue;
|
||||
}
|
||||
if (primary == nullptr) {
|
||||
primary = &broker;
|
||||
} else if (secondary == nullptr) {
|
||||
secondary = &broker;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
char primary_slot[40];
|
||||
char secondary_slot[40];
|
||||
format_slot(primary, primary_slot, sizeof(primary_slot));
|
||||
format_slot(secondary, secondary_slot, sizeof(secondary_slot));
|
||||
|
||||
snprintf(reply, reply_size,
|
||||
"> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s custom:%s:%s status:%s tx:%s",
|
||||
"> wifi:%s ntp:%s iata:%s p:%s s:%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),
|
||||
getCustomTransport(), broker_state(kCustomBit), _prefs.status_enabled ? "on" : "off",
|
||||
_prefs.iata, primary_slot, secondary_slot, _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 & 0x0F;
|
||||
uint8_t next_mask = _prefs.enabled_mask & kBrokerMask;
|
||||
if (enabled) {
|
||||
next_mask = normalizeEnabledMask(next_mask | bit);
|
||||
if ((next_mask & bit) == 0) {
|
||||
|
||||
@@ -130,12 +130,15 @@ private:
|
||||
static constexpr uint8_t kLetsmeshEuBit = 0x02;
|
||||
static constexpr uint8_t kLetsmeshUsBit = 0x04;
|
||||
static constexpr uint8_t kCustomBit = 0x08;
|
||||
static constexpr uint8_t kMeshmapperBit = 0x10;
|
||||
static constexpr uint8_t kBrokerMask = 0x1F;
|
||||
static constexpr uint8_t kMaxEnabledBrokers = 2;
|
||||
static const BrokerSpec kBrokerSpecs[4];
|
||||
static constexpr size_t kBrokerCount = 5;
|
||||
static const BrokerSpec kBrokerSpecs[kBrokerCount];
|
||||
static bool isUnsetIataValue(const char* iata);
|
||||
static const char* brokerCaCert(const BrokerSpec& spec);
|
||||
|
||||
BrokerState _brokers[4];
|
||||
BrokerState _brokers[kBrokerCount];
|
||||
|
||||
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);
|
||||
|
||||
+124
-233
@@ -422,7 +422,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
.placeholder-slot { display:block; width:44px; height:44px; }
|
||||
.savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; }
|
||||
.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-stack { display:grid; gap:12px; align-items:start; }
|
||||
.broker-slots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.broker-slot { display:grid; gap:6px; align-content: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; }
|
||||
@@ -530,7 +532,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
body { font-size:15px; }
|
||||
main { padding:16px; }
|
||||
.card { padding:16px; margin-bottom:14px; }
|
||||
.row, .row3, .row-command, .metric-grid, .trend-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-grid, .broker-grid.single, .broker-grid.two, .broker-grid.one-two { grid-template-columns:1fr; }
|
||||
.row, .row3, .row-command, .metric-grid, .trend-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-slots, .broker-grid, .broker-grid.single, .broker-grid.two, .broker-grid.one-two { grid-template-columns:1fr; }
|
||||
.inline-actions { grid-template-columns:minmax(0,1fr) auto auto; }
|
||||
.fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; }
|
||||
.row-command button { width:100%; }
|
||||
@@ -963,76 +965,24 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<div class="field-card">
|
||||
<label class="label">MQTT Servers</label>
|
||||
<div class="broker-stack">
|
||||
<div class="broker-group">
|
||||
<div class="broker-group-title">EastMesh</div>
|
||||
<div class="broker-grid single">
|
||||
<div class="broker-card">
|
||||
<div class="broker-mode">
|
||||
<div class="broker-row">
|
||||
<div class="broker-copy">
|
||||
<div class="broker-title">EastMesh AU</div>
|
||||
<div class="broker-state" id="mqttEastmeshAuState">Off</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-slider">
|
||||
<input id="mqttEastmeshMode" type="range" min="0" max="1" step="1" value="0" aria-label="EastMesh mode">
|
||||
<div class="mode-labels two" aria-hidden="true">
|
||||
<div class="mode-label" data-eastmesh-label="off">Off</div>
|
||||
<div class="mode-label" data-eastmesh-label="on">On</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="mqttEastmeshAu" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
</div>
|
||||
</div>
|
||||
<div class="broker-slots">
|
||||
<div class="broker-slot">
|
||||
<label class="label" for="mqttPrimaryBroker">Primary MQTT</label>
|
||||
<select id="mqttPrimaryBroker" aria-label="Primary MQTT broker"></select>
|
||||
</div>
|
||||
<div class="broker-slot">
|
||||
<label class="label" for="mqttSecondaryBroker">Secondary MQTT</label>
|
||||
<select id="mqttSecondaryBroker" aria-label="Secondary MQTT broker"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="broker-group">
|
||||
<div class="broker-group-title">LetsMesh</div>
|
||||
<div class="broker-grid single">
|
||||
<div class="broker-card">
|
||||
<div class="broker-mode">
|
||||
<div class="broker-row">
|
||||
<div class="broker-copy">
|
||||
<div class="broker-title">LetsMesh Mode</div>
|
||||
<div class="broker-state" id="mqttLetsmeshModeState">Off</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-slider">
|
||||
<input id="mqttLetsmeshMode" type="range" min="0" max="3" step="1" value="0" aria-label="LetsMesh mode">
|
||||
<div class="mode-labels" aria-hidden="true">
|
||||
<div class="mode-label" data-letsmesh-label="off">Off</div>
|
||||
<div class="mode-label" data-letsmesh-label="eu">EU</div>
|
||||
<div class="mode-label" data-letsmesh-label="us">US</div>
|
||||
<div class="mode-label" data-letsmesh-label="both">Both</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="mqttLetsmeshEu" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<input id="mqttLetsmeshUs" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
</div>
|
||||
</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>
|
||||
<input id="mqttEastmeshAu" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<input id="mqttMeshmapper" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<input id="mqttLetsmeshEu" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<input id="mqttLetsmeshUs" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<input id="mqttCustom" class="visually-hidden" type="checkbox" tabindex="-1" aria-hidden="true">
|
||||
<div class="broker-group wide" id="mqttCustomConfig" style="display:none">
|
||||
<div class="broker-group-title">Custom MQTT settings</div>
|
||||
<div class="broker-grid">
|
||||
<div class="broker-card">
|
||||
<div class="field-card">
|
||||
<label class="label" for="mqttCustomEndpoint">Host:Port</label>
|
||||
@@ -2752,88 +2702,71 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function getLetsmeshMode() {
|
||||
const eu = document.getElementById("mqttLetsmeshEu");
|
||||
const us = document.getElementById("mqttLetsmeshUs");
|
||||
const euOn = eu && eu.checked;
|
||||
const usOn = us && us.checked;
|
||||
if (euOn && usOn) return "both";
|
||||
if (euOn) return "eu";
|
||||
if (usOn) return "us";
|
||||
return "off";
|
||||
}
|
||||
function refreshEastmeshModeUi() {
|
||||
const input = document.getElementById("mqttEastmeshAu");
|
||||
const enabled = !!(input && input.checked);
|
||||
const slider = document.getElementById("mqttEastmeshMode");
|
||||
if (slider) {
|
||||
slider.value = enabled ? "1" : "0";
|
||||
}
|
||||
document.querySelectorAll("[data-eastmesh-label]").forEach((label) => {
|
||||
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;
|
||||
}
|
||||
function clampLetsmeshModeIndex(index) {
|
||||
const eastmesh = document.getElementById("mqttEastmeshAu");
|
||||
const eastmeshEnabled = !!(eastmesh && eastmesh.checked);
|
||||
const bounded = Math.max(0, Math.min(3, index));
|
||||
return eastmeshEnabled && bounded === 3 ? 1 : bounded;
|
||||
}
|
||||
function refreshLetsmeshModeUi() {
|
||||
const mode = getLetsmeshMode();
|
||||
const eastmesh = document.getElementById("mqttEastmeshAu");
|
||||
const eastmeshEnabled = !!(eastmesh && eastmesh.checked);
|
||||
const state = document.getElementById("mqttLetsmeshModeState");
|
||||
const labels = { off:"Off", eu:"EU", us:"US", both:"Both" };
|
||||
if (state) {
|
||||
state.textContent = labels[mode] || "Off";
|
||||
state.classList.toggle("on", mode !== "off");
|
||||
}
|
||||
const slider = document.getElementById("mqttLetsmeshMode");
|
||||
if (slider) {
|
||||
slider.max = "3";
|
||||
slider.value = String(clampLetsmeshModeIndex(getLetsmeshModeIndex(mode)));
|
||||
}
|
||||
document.querySelectorAll("[data-letsmesh-label]").forEach((label) => {
|
||||
const labelMode = label.dataset.letsmeshLabel;
|
||||
label.classList.toggle("active", labelMode === mode);
|
||||
label.classList.toggle("disabled", eastmeshEnabled && labelMode === "both");
|
||||
});
|
||||
}
|
||||
function setBrokerToggle(inputId, state) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
const enabled = state === "on";
|
||||
input.checked = enabled;
|
||||
const label = document.getElementById(inputId + "State");
|
||||
if (label) {
|
||||
label.textContent = enabled ? "On" : "Off";
|
||||
label.classList.toggle("on", enabled);
|
||||
}
|
||||
if (inputId === "mqttLetsmeshEu" || inputId === "mqttLetsmeshUs") {
|
||||
refreshLetsmeshModeUi();
|
||||
} else if (inputId === "mqttEastmeshAu") {
|
||||
refreshEastmeshModeUi();
|
||||
refreshLetsmeshModeUi();
|
||||
} else if (inputId === "mqttCustom") {
|
||||
refreshCustomMqttModeUi();
|
||||
}
|
||||
}
|
||||
const MQTT_BROKERS = [
|
||||
{ key:"eastmesh-au", input:"mqttEastmeshAu", label:"EastMesh AU", set:"set mqtt.eastmesh-au" },
|
||||
{ key:"meshmapper", input:"mqttMeshmapper", label:"MeshMapper", set:"set mqtt.meshmapper" },
|
||||
{ key:"custom", input:"mqttCustom", label:"Custom", set:"set mqtt.custom", custom:true },
|
||||
{ key:"letsmesh-eu", input:"mqttLetsmeshEu", label:"LetsMesh EU (retired)", set:"set mqtt.letsmesh-eu" },
|
||||
{ key:"letsmesh-us", input:"mqttLetsmeshUs", label:"LetsMesh US (retired)", set:"set mqtt.letsmesh-us" }
|
||||
];
|
||||
const MQTT_SLOT_IDS = ["mqttPrimaryBroker", "mqttSecondaryBroker"];
|
||||
function brokerByKey(key) {
|
||||
return MQTT_BROKERS.find((broker) => broker.key === key) || null;
|
||||
}
|
||||
function isBrokerChecked(broker) {
|
||||
const input = document.getElementById(broker.input);
|
||||
return !!(input && input.checked);
|
||||
}
|
||||
function enabledBrokerKeys() {
|
||||
return MQTT_BROKERS.filter(isBrokerChecked).map((broker) => broker.key);
|
||||
}
|
||||
function buildBrokerSlotOptions() {
|
||||
const optionsHtml = ['<option value="">None</option>']
|
||||
.concat(MQTT_BROKERS.map((broker) => `<option value="${broker.key}">${broker.label}</option>`))
|
||||
.join("");
|
||||
MQTT_SLOT_IDS.forEach((slotId) => {
|
||||
const select = document.getElementById(slotId);
|
||||
if (select) select.innerHTML = optionsHtml;
|
||||
});
|
||||
}
|
||||
function refreshBrokerSlotOptionStates() {
|
||||
const selected = MQTT_SLOT_IDS.map((id) => {
|
||||
const select = document.getElementById(id);
|
||||
return select ? select.value : "";
|
||||
});
|
||||
MQTT_SLOT_IDS.forEach((slotId, idx) => {
|
||||
const select = document.getElementById(slotId);
|
||||
if (!select) return;
|
||||
const other = selected[idx === 0 ? 1 : 0];
|
||||
Array.from(select.options).forEach((option) => {
|
||||
option.disabled = option.value !== "" && option.value === other;
|
||||
});
|
||||
});
|
||||
}
|
||||
function refreshCustomConfigVisibility() {
|
||||
const config = document.getElementById("mqttCustomConfig");
|
||||
if (!config) return;
|
||||
const customSelected = MQTT_SLOT_IDS.some((id) => {
|
||||
const select = document.getElementById(id);
|
||||
return !!(select && select.value === "custom");
|
||||
});
|
||||
config.style.display = customSelected ? "" : "none";
|
||||
}
|
||||
function refreshBrokerSlotsFromState() {
|
||||
const enabled = enabledBrokerKeys();
|
||||
MQTT_SLOT_IDS.forEach((slotId, idx) => {
|
||||
const select = document.getElementById(slotId);
|
||||
if (select) select.value = enabled[idx] || "";
|
||||
});
|
||||
refreshBrokerSlotOptionStates();
|
||||
refreshCustomConfigVisibility();
|
||||
}
|
||||
function setBrokerToggle(inputId, state) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
input.checked = state === "on";
|
||||
refreshBrokerSlotsFromState();
|
||||
}
|
||||
async function loadBrokerState(cmd, inputId, options = {}) {
|
||||
const result = await runCommand(cmd, options);
|
||||
if (!result.ok) return;
|
||||
@@ -3043,47 +2976,43 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
}
|
||||
buildRegionStateOptions();
|
||||
document.getElementById("saveRegionBtn").onclick = () => saveRegionSelection();
|
||||
async function setEastmeshMode(enabled) {
|
||||
if (enabled && getLetsmeshMode() === "both") {
|
||||
await setLetsmeshMode("eu");
|
||||
}
|
||||
const result = await runCommand(enabled ? "set mqtt.eastmesh-au on" : "set mqtt.eastmesh-au off");
|
||||
if (!result.ok) {
|
||||
refreshEastmeshModeUi();
|
||||
refreshLetsmeshModeUi();
|
||||
return;
|
||||
}
|
||||
setBrokerToggle("mqttEastmeshAu", enabled ? "on" : "off");
|
||||
refreshEastmeshModeUi();
|
||||
refreshLetsmeshModeUi();
|
||||
}
|
||||
const eastmeshModeSlider = document.getElementById("mqttEastmeshMode");
|
||||
if (eastmeshModeSlider) {
|
||||
eastmeshModeSlider.addEventListener("input", () => {
|
||||
eastmeshModeSlider.value = (Number.parseInt(eastmeshModeSlider.value, 10) || 0) >= 1 ? "1" : "0";
|
||||
});
|
||||
eastmeshModeSlider.addEventListener("change", () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
buildBrokerSlotOptions();
|
||||
async function applyBrokerSlots() {
|
||||
const desired = MQTT_SLOT_IDS.map((id) => {
|
||||
const sel = document.getElementById(id);
|
||||
return sel ? sel.value : "";
|
||||
});
|
||||
if (desired[0] && desired[0] === desired[1]) {
|
||||
refreshBrokerSlotsFromState();
|
||||
return;
|
||||
}
|
||||
const desiredKeys = desired.filter((value) => value);
|
||||
const current = enabledBrokerKeys();
|
||||
const toDisable = current.filter((key) => !desiredKeys.includes(key));
|
||||
const toEnable = desiredKeys.filter((key) => !current.includes(key));
|
||||
// Disable removed brokers first so we never exceed the firmware's two-broker limit mid-swap.
|
||||
for (const key of toDisable) {
|
||||
const broker = brokerByKey(key);
|
||||
const result = await runCommand(broker.set + " off");
|
||||
if (!result.ok) { refreshBrokerSlotsFromState(); return; }
|
||||
const input = document.getElementById(broker.input);
|
||||
if (input) input.checked = false;
|
||||
}
|
||||
for (const key of toEnable) {
|
||||
const broker = brokerByKey(key);
|
||||
const result = await runCommand(broker.set + " on");
|
||||
if (!result.ok) { refreshBrokerSlotsFromState(); return; }
|
||||
const input = document.getElementById(broker.input);
|
||||
if (input) input.checked = true;
|
||||
}
|
||||
refreshBrokerSlotsFromState();
|
||||
}
|
||||
MQTT_SLOT_IDS.forEach((slotId) => {
|
||||
const select = document.getElementById(slotId);
|
||||
if (select) {
|
||||
select.addEventListener("change", () => applyBrokerSlots());
|
||||
}
|
||||
});
|
||||
document.getElementById("refreshCustomEndpointBtn").onclick = () => loadCustomEndpoint();
|
||||
document.getElementById("saveCustomEndpointBtn").onclick = () => saveCustomEndpoint();
|
||||
const customTransportSlider = document.getElementById("mqttCustomTransport");
|
||||
@@ -3096,44 +3025,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
setCustomTransport((Number.parseInt(customTransportSlider.value, 10) || 0) >= 1 ? "wss" : "tcp");
|
||||
});
|
||||
}
|
||||
async function setLetsmeshMode(mode) {
|
||||
const eastmesh = document.getElementById("mqttEastmeshAu");
|
||||
if (mode === "both" && eastmesh && eastmesh.checked) {
|
||||
refreshLetsmeshModeUi();
|
||||
return;
|
||||
}
|
||||
const desired = {
|
||||
eu: mode === "eu" || mode === "both",
|
||||
us: mode === "us" || mode === "both"
|
||||
};
|
||||
const currentEu = document.getElementById("mqttLetsmeshEu").checked;
|
||||
const currentUs = document.getElementById("mqttLetsmeshUs").checked;
|
||||
const commands = [];
|
||||
if (currentEu && !desired.eu) commands.push(["mqttLetsmeshEu", "set mqtt.letsmesh-eu off", "off"]);
|
||||
if (currentUs && !desired.us) commands.push(["mqttLetsmeshUs", "set mqtt.letsmesh-us off", "off"]);
|
||||
if (!currentEu && desired.eu) commands.push(["mqttLetsmeshEu", "set mqtt.letsmesh-eu on", "on"]);
|
||||
if (!currentUs && desired.us) commands.push(["mqttLetsmeshUs", "set mqtt.letsmesh-us on", "on"]);
|
||||
for (const [inputId, command, nextState] of commands) {
|
||||
const result = await runCommand(command);
|
||||
if (!result.ok) {
|
||||
refreshLetsmeshModeUi();
|
||||
return;
|
||||
}
|
||||
setBrokerToggle(inputId, nextState);
|
||||
}
|
||||
refreshLetsmeshModeUi();
|
||||
}
|
||||
const letsmeshModeSlider = document.getElementById("mqttLetsmeshMode");
|
||||
if (letsmeshModeSlider) {
|
||||
letsmeshModeSlider.addEventListener("input", () => {
|
||||
letsmeshModeSlider.value = String(clampLetsmeshModeIndex(Number.parseInt(letsmeshModeSlider.value, 10) || 0));
|
||||
});
|
||||
letsmeshModeSlider.addEventListener("change", () => {
|
||||
const modes = ["off", "eu", "us", "both"];
|
||||
const index = clampLetsmeshModeIndex(Number.parseInt(letsmeshModeSlider.value, 10) || 0);
|
||||
setLetsmeshMode(modes[index] || "off");
|
||||
});
|
||||
}
|
||||
document.getElementById("saveOwnerInfo").onclick = () => {
|
||||
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
|
||||
runCommand("set owner.info " + value);
|
||||
@@ -3300,6 +3191,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
() => loadField("get mqtt.owner", "mqttOwner", null, quiet),
|
||||
() => loadField("get mqtt.email", "mqttEmail", null, quiet),
|
||||
() => loadBrokerState("get mqtt.eastmesh-au", "mqttEastmeshAu", quiet),
|
||||
() => loadBrokerState("get mqtt.meshmapper", "mqttMeshmapper", quiet),
|
||||
() => loadBrokerState("get mqtt.letsmesh-eu", "mqttLetsmeshEu", quiet),
|
||||
() => loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs", quiet),
|
||||
() => loadBrokerState("get mqtt.custom", "mqttCustom", quiet),
|
||||
@@ -3317,8 +3209,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
}
|
||||
if (isCurrentPageLoad(generation)) loadRadioPresets();
|
||||
}
|
||||
refreshEastmeshModeUi();
|
||||
refreshLetsmeshModeUi();
|
||||
refreshBrokerSlotsFromState();
|
||||
navigateToPage(isStatsPage ? "/stats" : "/app", { replace:true });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user