Merge pull request #12 from xJARiD/develop

fix(mqtt): clear legacy prefs, restore status publishing, and expand …
Šī revīzija ir iekļauta:
xJARiD
2026-04-10 22:28:50 +10:00
revīziju iesūtīja GitHub
revīzija 7d6dee33d5
9 mainīti faili ar 219 papildinājumiem un 97 dzēšanām
-14
Parādīt failu
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 139 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M3.232,3.582C2.789,3.582 2.368,3.934 2.289,4.369L0.013,16.964C-0.066,17.399 0.229,17.751 0.671,17.751L3.087,17.751C3.529,17.751 3.951,17.399 4.03,16.964L4.935,11.951L6.592,17.293C6.672,17.572 6.923,17.751 7.235,17.751L10.434,17.751C10.746,17.751 11.062,17.572 11.243,17.293L14.835,11.925L13.924,16.964C13.844,17.399 14.14,17.751 14.583,17.751L16.998,17.751C17.44,17.751 17.862,17.399 17.941,16.964L20.217,4.369C20.298,3.934 20.002,3.582 19.56,3.582L16.46,3.582C16.147,3.582 15.831,3.761 15.65,4.04L9.76,12.872C9.668,13.013 9.446,12.975 9.397,12.81L6.976,4.04C6.895,3.761 6.645,3.582 6.332,3.582L3.232,3.582Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M20.853,17.751C20.853,17.751 32.797,17.751 32.797,17.751C33.063,17.751 33.317,17.538 33.364,17.278L33.863,14.504C33.91,14.242 33.733,14.031 33.467,14.031L25.166,14.031C25.077,14.031 25.019,13.96 25.034,13.873L25.281,12.508C25.296,12.421 25.38,12.35 25.469,12.35L32.146,12.35C32.411,12.35 32.665,12.137 32.712,11.877L33.157,9.421C33.204,9.159 33.027,8.949 32.761,8.949L26.085,8.949C25.996,8.949 25.938,8.877 25.953,8.79L26.216,7.328C26.232,7.241 26.316,7.17 26.405,7.17L34.706,7.17C34.971,7.17 35.226,6.957 35.272,6.695L35.756,4.021C35.804,3.761 35.627,3.548 35.361,3.548L23.417,3.548C22.975,3.548 22.551,3.902 22.473,4.337L20.191,16.962C20.114,17.397 20.409,17.751 20.853,17.751Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M45.291,17.749L45.291,17.751L45.705,17.751C47.783,17.751 49.767,16.095 50.136,14.052L50.375,12.727C50.744,10.685 49.359,9.029 47.28,9.029L40.882,9.029C40.617,9.029 40.44,8.818 40.487,8.556L40.649,7.664C40.696,7.402 40.95,7.191 41.215,7.191L49.87,7.191C50.313,7.191 50.735,6.839 50.814,6.404L51.183,4.368C51.262,3.931 50.966,3.579 50.523,3.579L41.063,3.579C38.985,3.579 37,5.235 36.631,7.278L36.37,8.723C36.001,10.767 37.386,12.422 39.465,12.422L45.863,12.422C46.128,12.422 46.305,12.633 46.258,12.895L46.138,13.565C46.091,13.826 45.837,14.037 45.571,14.037L36.675,14.037C36.233,14.037 35.811,14.389 35.732,14.824L35.346,16.962C35.267,17.397 35.562,17.749 36.005,17.749L45.291,17.749Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M67.068,3.575C67.068,3.575 64.393,3.575 64.393,3.575C63.951,3.575 63.529,3.927 63.45,4.361L62.654,8.766C62.639,8.853 62.554,8.923 62.466,8.923L57.282,8.923C57.193,8.923 57.135,8.853 57.15,8.766L57.946,4.361C58.023,3.927 57.73,3.575 57.287,3.575L54.613,3.575C54.17,3.575 53.748,3.927 53.669,4.361L51.392,16.964C51.313,17.399 51.608,17.751 52.05,17.751L54.725,17.751C55.168,17.751 55.589,17.399 55.668,16.964L56.48,12.478C56.495,12.392 56.58,12.32 56.668,12.32L61.852,12.32C61.941,12.32 61.999,12.39 61.984,12.478L61.174,16.964C61.096,17.399 61.391,17.751 61.834,17.751L64.508,17.751C64.951,17.751 65.372,17.399 65.451,16.964L67.729,4.361C67.804,3.927 67.511,3.575 67.068,3.575Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M71.102,17.751C71.102,17.751 78.96,17.751 78.96,17.751C79.402,17.751 79.824,17.399 79.903,16.964L80.288,14.824C80.367,14.389 80.072,14.037 79.629,14.037L72.808,14.037C72.542,14.037 72.365,13.826 72.412,13.565L73.48,7.686C73.527,7.426 73.781,7.213 74.045,7.213L80.866,7.213C81.309,7.213 81.73,6.861 81.81,6.427L82.188,4.335C82.267,3.9 81.971,3.548 81.529,3.548L73.691,3.548C71.618,3.548 69.638,5.197 69.265,7.234L68.011,14.046C67.639,16.091 69.022,17.751 71.102,17.751Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M95.833,3.529C95.833,3.529 87.654,3.529 87.654,3.529C85.576,3.529 83.592,5.186 83.223,7.228L81.99,14.052C81.621,16.094 83.006,17.751 85.084,17.751L93.263,17.751C95.341,17.751 97.326,16.095 97.695,14.052L98.928,7.228C99.297,5.186 97.911,3.529 95.833,3.529ZM93.488,13.567C93.44,13.828 93.186,14.039 92.921,14.039L86.762,14.039C86.496,14.039 86.319,13.828 86.366,13.567L87.434,7.663C87.481,7.402 87.735,7.191 88,7.191L94.157,7.191C94.423,7.191 94.6,7.402 94.553,7.663L93.488,13.567Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M99.884,17.751L102.557,17.751C102.999,17.751 103.421,17.399 103.5,16.965L103.973,14.348C103.988,14.261 104.073,14.19 104.161,14.19L107.397,14.186C107.557,14.186 107.69,14.265 107.756,14.395L109.281,17.37C109.458,17.722 109.78,17.764 110.751,17.749C111.32,17.756 112.184,17.713 113.577,17.713C114.025,17.713 114.3,17.244 114.079,16.853L112.413,13.953C112.37,13.876 112.417,13.772 112.509,13.727C113.795,13.102 114.814,11.889 115.068,10.487L115.649,7.262C116.02,5.218 114.635,3.562 112.557,3.562L102.448,3.562C102.006,3.562 101.584,3.914 101.505,4.349L99.225,16.964C99.146,17.399 99.442,17.751 99.884,17.751L99.884,17.751ZM105.255,7.268C105.27,7.181 105.354,7.11 105.443,7.11L110.674,7.11C111.069,7.11 111.331,7.424 111.261,7.812L110.877,9.933C110.806,10.319 110.431,10.634 110.038,10.634L104.806,10.634C104.718,10.634 104.66,10.564 104.675,10.475L105.255,7.268Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M116.642,17.751C116.642,17.751 128.586,17.751 128.586,17.751C128.851,17.751 129.105,17.538 129.152,17.278L129.651,14.504C129.698,14.242 129.521,14.031 129.256,14.031L120.955,14.031C120.866,14.031 120.808,13.96 120.823,13.873L121.069,12.508C121.084,12.421 121.169,12.35 121.257,12.35L127.934,12.35C128.2,12.35 128.454,12.137 128.501,11.877L128.945,9.421C128.992,9.159 128.815,8.949 128.55,8.949L121.873,8.949C121.785,8.949 121.726,8.877 121.741,8.79L122.005,7.328C122.02,7.241 122.105,7.17 122.193,7.17L130.495,7.17C130.76,7.17 131.014,6.957 131.061,6.695L131.545,4.021C131.592,3.761 131.415,3.548 131.15,3.548L119.206,3.548C118.763,3.548 118.34,3.902 118.261,4.337L115.98,16.962C115.902,17.397 116.198,17.751 116.642,17.751Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M134.674,0C134.674,0 132.059,0 132.059,0C131.965,0 131.877,0.074 131.86,0.166L131.783,0.594C131.766,0.686 131.828,0.76 131.921,0.76L132.745,0.76C132.764,0.76 132.776,0.775 132.773,0.793L132.406,2.819C132.39,2.91 132.452,2.984 132.545,2.984L133.108,2.984C133.201,2.984 133.29,2.91 133.307,2.819L133.673,0.793C133.676,0.775 133.694,0.76 133.713,0.76L134.536,0.76C134.629,0.76 134.718,0.686 134.735,0.594L134.812,0.166C134.828,0.074 134.767,0 134.674,0Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M135.278,0.002C135.185,0.002 135.096,0.076 135.079,0.167L134.6,2.819C134.583,2.91 134.646,2.984 134.739,2.984L135.247,2.984C135.34,2.984 135.429,2.91 135.446,2.819L135.636,1.763L135.985,2.888C136.002,2.947 136.055,2.984 136.121,2.984L136.794,2.984C136.86,2.984 136.926,2.947 136.964,2.888L137.72,1.758L137.528,2.819C137.512,2.91 137.574,2.984 137.667,2.984L138.176,2.984C138.269,2.984 138.358,2.91 138.374,2.819L138.853,0.167C138.87,0.076 138.808,0.002 138.715,0.002L138.062,0.002C137.997,0.002 137.93,0.039 137.892,0.098L136.652,1.957C136.633,1.987 136.586,1.979 136.575,1.944L136.066,0.098C136.049,0.039 135.996,0.002 135.93,0.002L135.278,0.002Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

Pirms

Platums:  |  Augstums:  |  Izmērs: 7.1 KiB

+2 -1
Parādīt failu
@@ -11,7 +11,7 @@ These commands are available on `*_repeater_mqtt` firmware targets.
### MQTT Status And Routing ### MQTT Status And Routing
- `get mqtt.status` - `get mqtt.status`
- shows WiFi, NTP, IATA, endpoint status, and TX state - shows WiFi, NTP, IATA, endpoint status, status publishing state, and TX state
- `get mqtt.statuscfg` - `get mqtt.statuscfg`
- shows whether periodic status messages are enabled - shows whether periodic status messages are enabled
- `get mqtt.iata` - `get mqtt.iata`
@@ -107,6 +107,7 @@ When the repeater web panel is enabled, it only allows a limited command set.
That allowlist currently includes: That allowlist currently includes:
- `clock`
- `get mqtt.status` - `get mqtt.status`
- `get web` - `get web`
- `get web.status` - `get web.status`
+2 -1
Parādīt failu
@@ -90,6 +90,7 @@ This is a small terminal for allowlisted commands.
- press `Enter` to run the command - press `Enter` to run the command
- command history is shown in the terminal box below - command history is shown in the terminal box below
- save buttons elsewhere in the page also show the generated command and the reply here - save buttons elsewhere in the page also show the generated command and the reply here
- `clock` is available here if you want to check the repeater's current board time
This makes it easy to see exactly what the panel sent to the repeater. This makes it easy to see exactly what the panel sent to the repeater.
@@ -201,7 +202,7 @@ Use the repeater admin password, not the guest password.
### A command says it is not allowlisted ### A command says it is not allowlisted
The panel intentionally limits what can be run from the browser. Use the serial CLI for commands outside the web allowlist. The panel intentionally limits what can be run from the browser. Use the serial CLI for commands outside the web allowlist. `clock` is included, but most maintenance and debug commands are still serial-only.
### Stats or settings do not refresh ### Stats or settings do not refresh
+10 -3
Parādīt failu
@@ -1317,7 +1317,15 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
} else { } else {
strcpy(reply, "Err - bad mqtt.email"); strcpy(reply, "Err - bad mqtt.email");
} }
} else if (memcmp(command, "get mqtt.status", 15) == 0) { } else if (strcmp(command, "send mqtt.status") == 0) {
if (mqtt.sendStatusNow()) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - mqtt status unavailable");
}
} else if (strcmp(command, "get mqtt.statuscfg") == 0) {
sprintf(reply, "> %s", mqtt.isStatusEnabled() ? "on" : "off");
} else if (strcmp(command, "get mqtt.status") == 0) {
mqtt.formatStatusReply(reply, 160); mqtt.formatStatusReply(reply, 160);
} else if (strcmp(command, "get web.status") == 0 || strcmp(command, "get web") == 0) { } else if (strcmp(command, "get web.status") == 0 || strcmp(command, "get web") == 0) {
mqtt.formatWebStatusReply(reply, 160); mqtt.formatWebStatusReply(reply, 160);
@@ -1337,8 +1345,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sprintf(reply, "> %s", mqtt.isPacketsEnabled() ? "on" : "off"); sprintf(reply, "> %s", mqtt.isPacketsEnabled() ? "on" : "off");
} else if (memcmp(command, "get mqtt.raw", 12) == 0) { } else if (memcmp(command, "get mqtt.raw", 12) == 0) {
sprintf(reply, "> %s", mqtt.isRawEnabled() ? "on" : "off"); sprintf(reply, "> %s", mqtt.isRawEnabled() ? "on" : "off");
} else if (memcmp(command, "get mqtt.statuscfg", 18) == 0) {
sprintf(reply, "> %s", mqtt.isStatusEnabled() ? "on" : "off");
} else if (memcmp(command, "get mqtt.tx", 11) == 0) { } else if (memcmp(command, "get mqtt.tx", 11) == 0) {
sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off"); sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off");
} else if (memcmp(command, "get mqtt.eastmesh-au", 20) == 0 || memcmp(command, "get mqtt.eastmesh.au", 20) == 0) { } else if (memcmp(command, "get mqtt.eastmesh-au", 20) == 0 || memcmp(command, "get mqtt.eastmesh.au", 20) == 0) {
@@ -1436,6 +1442,7 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
}; };
bool allowed = bool allowed =
matches_exact("clock") ||
matches_exact("get mqtt.status") || matches_exact("get mqtt.status") ||
matches_exact("get web.status") || matches_exact("get web.status") ||
matches_exact("get web") || matches_exact("get web") ||
+10 -4
Parādīt failu
@@ -44,14 +44,20 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) {
return false; return false;
} }
size_t bytes_to_read = min(static_cast<size_t>(file.size()), sizeof(prefs)); MQTTPrefs persisted{};
bool ok = bytes_to_read >= sizeof(prefs.magic) && size_t bytes_to_read = min(static_cast<size_t>(file.size()), sizeof(persisted));
file.read(reinterpret_cast<uint8_t*>(&prefs), bytes_to_read) == bytes_to_read; bool ok = bytes_to_read >= sizeof(persisted.magic) &&
file.read(reinterpret_cast<uint8_t*>(&persisted), bytes_to_read) == bytes_to_read;
file.close(); file.close();
if (!ok || prefs.magic != kMagic) { if (!ok || persisted.magic != kMagic) {
// Legacy or corrupt MQTT prefs should not survive migration. Remove the
// stale file and persist a clean current-format default copy immediately.
fs->remove(kFilename);
save(fs, prefs);
return false; return false;
} }
prefs = persisted;
if (prefs.wifi_powersave > 2) { if (prefs.wifi_powersave > 2) {
prefs.wifi_powersave = 0; prefs.wifi_powersave = 0;
} }
+34 -16
Parādīt failu
@@ -164,6 +164,27 @@ void MQTTUplink::reconnectWifi() {
_last_wifi_attempt = 0; _last_wifi_attempt = 0;
} }
bool MQTTUplink::sendStatusNow() {
if (!_running || !_have_time_sync || WiFi.status() != WL_CONNECTED) {
return false;
}
bool any_connected = false;
for (const BrokerState& broker : _brokers) {
if ((_prefs.enabled_mask & broker.spec->bit) != 0 && broker.connected && broker.client != nullptr) {
any_connected = true;
break;
}
}
if (!any_connected) {
return false;
}
publishStatus(true);
_last_status_publish = millis();
return true;
}
void MQTTUplink::makeSafeToken(const char* input, char* output, size_t output_size) { void MQTTUplink::makeSafeToken(const char* input, char* output, size_t output_size) {
if (output_size == 0) { if (output_size == 0) {
return; return;
@@ -334,6 +355,7 @@ void MQTTUplink::destroyBroker(BrokerState& broker) {
broker.client = nullptr; broker.client = nullptr;
} }
broker.connected = false; broker.connected = false;
broker.connect_announced = false;
} }
void MQTTUplink::queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain) { void MQTTUplink::queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain) {
@@ -728,10 +750,13 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) {
ensureWebServer(); ensureWebServer();
for (BrokerState& broker : _brokers) { for (BrokerState& broker : _brokers) {
bool was_connected = broker.connected;
ensureBroker(broker); ensureBroker(broker);
if (!was_connected && broker.connected) { if (broker.connected && !broker.connect_announced) {
publishOnlineStatus(broker); publishOnlineStatus(broker);
broker.connect_announced = true;
_last_status_publish = millis();
} else if (!broker.connected) {
broker.connect_announced = false;
} }
} }
@@ -808,10 +833,10 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const {
return "retry"; return "retry";
}; };
snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s tx:%s", snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s status:%s tx:%s",
getWifiStateLabel(_prefs, _wifi_started), _have_time_sync ? "up" : "wait", _prefs.iata, getWifiStateLabel(_prefs, _wifi_started), _have_time_sync ? "up" : "wait", _prefs.iata,
broker_state(_brokers[0]), broker_state(_brokers[1]), broker_state(_brokers[2]), broker_state(_brokers[0]), broker_state(_brokers[1]), broker_state(_brokers[2]),
_prefs.tx_enabled ? "on" : "off"); _prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off");
} }
void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const {
@@ -877,9 +902,7 @@ bool MQTTUplink::setWebEnabled(bool enabled) {
#if WITH_WEB_PANEL #if WITH_WEB_PANEL
_prefs.web_enabled = enabled ? 1 : 0; _prefs.web_enabled = enabled ? 1 : 0;
bool ok = savePrefs(); bool ok = savePrefs();
if (_prefs.web_enabled == 0) { if (_prefs.web_enabled != 0) {
stopWebServer();
} else {
ensureWebServer(); ensureWebServer();
} }
return ok; return ok;
@@ -960,9 +983,7 @@ bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) {
if (owner_public_key[0] == 0) { if (owner_public_key[0] == 0) {
_prefs.owner_public_key[0] = 0; _prefs.owner_public_key[0] = 0;
bool ok = savePrefs(); return savePrefs();
reconnectWifi();
return ok;
} }
if (strlen(owner_public_key) != 64) { if (strlen(owner_public_key) != 64) {
@@ -976,9 +997,7 @@ bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) {
_prefs.owner_public_key[i] = toupper(static_cast<unsigned char>(owner_public_key[i])); _prefs.owner_public_key[i] = toupper(static_cast<unsigned char>(owner_public_key[i]));
} }
_prefs.owner_public_key[64] = 0; _prefs.owner_public_key[64] = 0;
bool ok = savePrefs(); return savePrefs();
reconnectWifi();
return ok;
} }
bool MQTTUplink::setOwnerEmail(const char* owner_email) { bool MQTTUplink::setOwnerEmail(const char* owner_email) {
@@ -986,9 +1005,7 @@ bool MQTTUplink::setOwnerEmail(const char* owner_email) {
return false; return false;
} }
StrHelper::strncpy(_prefs.owner_email, owner_email, sizeof(_prefs.owner_email)); StrHelper::strncpy(_prefs.owner_email, owner_email, sizeof(_prefs.owner_email));
bool ok = savePrefs(); return savePrefs();
reconnectWifi();
return ok;
} }
void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const {
@@ -1077,6 +1094,7 @@ bool MQTTUplink::setOwnerPublicKey(const char*) { return false; }
bool MQTTUplink::setOwnerEmail(const char*) { return false; } bool MQTTUplink::setOwnerEmail(const char*) { return false; }
void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); }
void MQTTUplink::reconnectWifi() {} void MQTTUplink::reconnectWifi() {}
bool MQTTUplink::sendStatusNow() { return false; }
#endif #endif
+2
Parādīt failu
@@ -77,6 +77,7 @@ public:
const char* getOwnerEmail() const { return _prefs.owner_email; } const char* getOwnerEmail() const { return _prefs.owner_email; }
void formatWifiStatusReply(char* reply, size_t reply_size) const; void formatWifiStatusReply(char* reply, size_t reply_size) const;
void reconnectWifi(); void reconnectWifi();
bool sendStatusNow();
private: private:
#if defined(ESP_PLATFORM) #if defined(ESP_PLATFORM)
@@ -92,6 +93,7 @@ private:
const BrokerSpec* spec; const BrokerSpec* spec;
esp_mqtt_client_handle_t client; esp_mqtt_client_handle_t client;
bool connected; bool connected;
bool connect_announced;
unsigned long last_connect_attempt; unsigned long last_connect_attempt;
time_t token_expires_at; time_t token_expires_at;
char username[70]; char username[70];
+158 -58
Parādīt failu
@@ -159,13 +159,13 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
--button-secondary-text:#e6eaf0; --button-secondary-text:#e6eaf0;
} }
html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; } html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
body { min-height:100vh; margin:0; font:16px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; background:transparent; color:var(--text); transition:background .2s ease,color .2s ease; } body { min-height:100vh; margin:0; background:transparent; color:var(--text); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace; font-size:16px; line-height:1.4; transition:background .2s ease,color .2s ease; }
main { max-width:920px; margin:0 auto; padding:24px; } main { max-width:920px; margin:0 auto; padding:24px; }
.theme-fab { position:fixed; top:18px; right:18px; width:48px; height:48px; border-radius:999px; display:flex; align-items:center; justify-content:center; z-index:20; box-shadow:0 12px 28px rgba(0,0,0,.18); font-size:22px; line-height:1; }
.card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; } .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
h1,h2,h3 { margin:0 0 12px; font-size:18px; } h1,h2,h3 { margin:0 0 12px; font-size:18px; }
p { color:var(--text-muted); margin:8px 0 0; } p { color:var(--text-muted); margin:8px 0 0; }
input, textarea, button, select { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font:inherit; } input:not([type="checkbox"]), textarea, button, select { width:100%; min-height:44px; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font-family:inherit; font-size:inherit; line-height:inherit; }
input:not([type="checkbox"]), textarea, select, button { -webkit-appearance:none; appearance:none; }
textarea { min-height:100px; resize:vertical; } textarea { min-height:100px; resize:vertical; }
button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; } button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; }
button:hover { background:var(--accent-hover); } button:hover { background:var(--accent-hover); }
@@ -187,12 +187,26 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
.label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; } .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
.fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; } .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
.iconbtn { width:44px; padding:12px 0; } .iconbtn { width:44px; padding:12px 0; }
.placeholder-slot { display:block; width:44px; height:44px; }
.savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; } .savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; }
.savebtn:hover { background:var(--accent-hover); } .savebtn:hover { background:var(--accent-hover); }
.broker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
.broker-card { background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:12px; display:grid; gap:10px; }
.broker-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.broker-copy { display:grid; gap:4px; min-width:0; }
.broker-title { font-size:12px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
.broker-state { font-size:13px; color:var(--text); }
.broker-state.on { color:var(--accent); }
.switch { position:relative; display:inline-flex; width:54px; height:32px; flex:0 0 auto; }
.switch input { position:absolute; opacity:0; width:0; height:0; min-height:0; padding:0; border:0; -webkit-appearance:none; appearance:none; }
.slider { position:absolute; inset:0; border-radius:999px; background:var(--background); border:1px solid var(--border); transition:background .2s ease,border-color .2s ease; cursor:pointer; }
.slider::before { content:""; position:absolute; width:24px; height:24px; left:3px; top:3px; border-radius:50%; background:var(--surface1); box-shadow:0 2px 8px rgba(0,0,0,.18); transition:transform .2s ease,background .2s ease; }
.switch input:checked + .slider { background:var(--accent); border-color:transparent; }
.switch input:checked + .slider::before { transform:translateX(22px); background:#fff; }
.panel-warning { min-height:1.4em; font-size:13px; color:var(--status-red); }
.themebtn { padding:10px 14px; } .themebtn { padding:10px 14px; }
#app { display:none; }
#status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; } #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
.terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; } .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font-family:inherit; font-size:14px; line-height:1.45; }
.term-entry { margin:0 0 12px; } .term-entry { margin:0 0 12px; }
.term-cmd { color:var(--terminal-cmd); } .term-cmd { color:var(--terminal-cmd); }
.term-out { white-space:pre-wrap; color:var(--text); } .term-out { white-space:pre-wrap; color:var(--text); }
@@ -231,7 +245,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
body { font-size:15px; } body { font-size:15px; }
main { padding:16px; } main { padding:16px; }
.card { padding:16px; margin-bottom:14px; } .card { padding:16px; margin-bottom:14px; }
.row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics { grid-template-columns:1fr; } .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-grid { grid-template-columns:1fr; }
.inline-actions { grid-template-columns:minmax(0,1fr) auto auto; } .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; }
.fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; } .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; }
.row-command button { width:100%; } .row-command button { width:100%; }
@@ -247,6 +261,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
.actions-group.center { grid-template-columns:1fr; justify-items:center; } .actions-group.center { grid-template-columns:1fr; justify-items:center; }
#actionsPanel .actions-group button { width:100%; } #actionsPanel .actions-group button { width:100%; }
#actionsPanel .actions-group.center button { width:auto; min-width:56px; } #actionsPanel .actions-group.center button { width:auto; min-width:56px; }
.broker-row { gap:10px; }
.row > div + div, .row3 > div + div { margin-top:4px; } .row > div + div, .row3 > div + div { margin-top:4px; }
.row > div[style*="align-self:end"] { padding-top:4px; } .row > div[style*="align-self:end"] { padding-top:4px; }
} }
@@ -303,7 +318,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
</div> </div>
</section> </section>
<section class="card" id="cliPanel"> <section class="card" id="cliPanel" style="display:none">
<h2>Run CLI Command</h2> <h2>Run CLI Command</h2>
<div class="row-command"> <div class="row-command">
<input id="command" placeholder="get mqtt.status"> <input id="command" placeholder="get mqtt.status">
@@ -350,7 +365,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
<label class="label" for="guestPassword">Guest Password</label> <label class="label" for="guestPassword">Guest Password</label>
<div class="inline-actions"> <div class="inline-actions">
<input id="guestPassword" type="password" placeholder="new guest password"> <input id="guestPassword" type="password" placeholder="new guest password">
<span></span> <span class="placeholder-slot" aria-hidden="true"></span>
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button> <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
</div> </div>
</div> </div>
@@ -358,7 +373,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
<label class="label" for="privateKey">Private Key</label> <label class="label" for="privateKey">Private Key</label>
<div class="inline-actions"> <div class="inline-actions">
<input id="privateKey" placeholder="64-hex-char private key"> <input id="privateKey" placeholder="64-hex-char private key">
<span></span> <span class="placeholder-slot" aria-hidden="true"></span>
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button> <button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
</div> </div>
<p>Changing the private key requires a reboot to apply.</p> <p>Changing the private key requires a reboot to apply.</p>
@@ -488,16 +503,58 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
<button class="savebtn" data-prefix="set mqtt.owner " data-input="mqttOwner">Save</button> <button class="savebtn" data-prefix="set mqtt.owner " data-input="mqttOwner">Save</button>
</div> </div>
</div> </div>
<div class="field-card"> <div class="field-card">
<label class="label" for="mqttEmail">MQTT Email</label> <label class="label" for="mqttEmail">MQTT Email</label>
<div class="inline-actions"> <div class="inline-actions">
<input id="mqttEmail" type="email" placeholder="owner@example.com"> <input id="mqttEmail" type="email" placeholder="owner@example.com">
<button class="iconbtn" data-load-cmd="get mqtt.email" data-load-input="mqttEmail" title="Refresh MQTT email">&#8635;</button> <button class="iconbtn" data-load-cmd="get mqtt.email" data-load-input="mqttEmail" title="Refresh MQTT email">&#8635;</button>
<button class="savebtn" data-prefix="set mqtt.email " data-input="mqttEmail">Save</button> <button class="savebtn" data-prefix="set mqtt.email " data-input="mqttEmail">Save</button>
</div> </div>
</div> </div>
</div> <div class="field-card">
</section> <label class="label">MQTT Servers</label>
<div class="broker-grid">
<div class="broker-card">
<div class="broker-row">
<div class="broker-copy">
<div class="broker-title">EastMesh AU</div>
<div class="broker-state" id="mqttEastmeshAuState">Off</div>
</div>
<label class="switch" aria-label="Toggle EastMesh AU">
<input id="mqttEastmeshAu" type="checkbox" data-broker-on="set mqtt.eastmesh-au on" data-broker-off="set mqtt.eastmesh-au off">
<span class="slider"></span>
</label>
</div>
</div>
<div class="broker-card">
<div class="broker-row">
<div class="broker-copy">
<div class="broker-title">LetsMesh EU</div>
<div class="broker-state" id="mqttLetsmeshEuState">Off</div>
</div>
<label class="switch" aria-label="Toggle LetsMesh EU">
<input id="mqttLetsmeshEu" type="checkbox" data-broker-on="set mqtt.letsmesh-eu on" data-broker-off="set mqtt.letsmesh-eu off">
<span class="slider"></span>
</label>
</div>
</div>
<div class="broker-card">
<div class="broker-row">
<div class="broker-copy">
<div class="broker-title">LetsMesh US</div>
<div class="broker-state" id="mqttLetsmeshUsState">Off</div>
</div>
<label class="switch" aria-label="Toggle LetsMesh US">
<input id="mqttLetsmeshUs" type="checkbox" data-broker-on="set mqtt.letsmesh-us on" data-broker-off="set mqtt.letsmesh-us off">
<span class="slider"></span>
</label>
</div>
</div>
</div>
<div id="mqttBrokerWarning" class="panel-warning"></div>
</div>
</div>
</section>
<section class="card" id="statsPanel" style="display:none"> <section class="card" id="statsPanel" style="display:none">
<h2>Stats</h2> <h2>Stats</h2>
@@ -846,16 +903,44 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
return JSON.parse(text); return JSON.parse(text);
}); });
} }
function applyBootstrapData(data) { function applyBootstrapData(data) {
if (!data) return; if (!data) return;
if (typeof data.name === "string") document.getElementById("nodeName").value = data.name; if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata; if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner; if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
if (typeof data.mqtt_email === "string") document.getElementById("mqttEmail").value = data.mqtt_email; if (typeof data.mqtt_email === "string") document.getElementById("mqttEmail").value = data.mqtt_email;
if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval; if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval; if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max; if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
} if (typeof data.mqtt_eastmesh_au === "string") setBrokerToggle("mqttEastmeshAu", data.mqtt_eastmesh_au);
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu);
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us);
updateBrokerWarning();
}
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);
}
}
function updateBrokerWarning() {
const enabled = ["mqttEastmeshAu", "mqttLetsmeshEu", "mqttLetsmeshUs"].filter((inputId) => {
const input = document.getElementById(inputId);
return input && input.checked;
}).length;
document.getElementById("mqttBrokerWarning").textContent = enabled >= 3 ? "Running all 3 MQTT servers is not recommended. Prefer 2 at most." : "";
}
async function loadBrokerState(cmd, inputId) {
const result = await runCommand(cmd);
if (!result.ok) return;
setBrokerToggle(inputId, parseReplyValue(result.text));
updateBrokerWarning();
}
async function refreshStats() { async function refreshStats() {
const getStatsBtn = document.getElementById("getStatsBtn"); const getStatsBtn = document.getElementById("getStatsBtn");
getStatsBtn.disabled = true; getStatsBtn.disabled = true;
@@ -898,19 +983,22 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
} }
token = text.trim(); token = text.trim();
showAuthedUi(true); showAuthedUi(true);
try { try {
applyBootstrapData(await fetchJson("/api/bootstrap")); applyBootstrapData(await fetchJson("/api/bootstrap"));
} catch (_) { } catch (_) {
await Promise.all([ await Promise.all([
loadField("get name", "nodeName"), loadField("get name", "nodeName"),
loadField("get mqtt.iata", "mqttIata"), loadField("get mqtt.iata", "mqttIata"),
loadField("get mqtt.owner", "mqttOwner"), loadField("get mqtt.owner", "mqttOwner"),
loadField("get mqtt.email", "mqttEmail"), loadField("get mqtt.email", "mqttEmail"),
loadField("get advert.interval", "advertInterval"), loadBrokerState("get mqtt.eastmesh-au", "mqttEastmeshAu"),
loadField("get flood.advert.interval", "floodInterval"), loadBrokerState("get mqtt.letsmesh-eu", "mqttLetsmeshEu"),
loadField("get flood.max", "floodMax") loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"),
]); loadField("get advert.interval", "advertInterval"),
} loadField("get flood.advert.interval", "floodInterval"),
loadField("get flood.max", "floodMax")
]);
}
}; };
document.getElementById("password").addEventListener("keydown", (event) => { document.getElementById("password").addEventListener("keydown", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -926,13 +1014,22 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
document.getElementById("runBtn").click(); document.getElementById("runBtn").click();
} }
}); });
document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd)); document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input)); document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat)); document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
document.getElementById("saveOwnerInfo").onclick = () => { document.querySelectorAll("[data-broker-on]").forEach((input) => input.addEventListener("change", () => {
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|"); const label = document.getElementById(input.id + "State");
runCommand("set owner.info " + value); if (label) {
}; label.textContent = input.checked ? "On" : "Off";
label.classList.toggle("on", input.checked);
}
updateBrokerWarning();
runCommand(input.checked ? input.dataset.brokerOn : input.dataset.brokerOff);
}));
document.getElementById("saveOwnerInfo").onclick = () => {
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
runCommand("set owner.info " + value);
};
document.getElementById("rebootBtn").onclick = async () => { document.getElementById("rebootBtn").onclick = async () => {
if (confirm("Reboot the repeater now?")) { if (confirm("Reboot the repeater now?")) {
await runCommand("reboot"); await runCommand("reboot");
@@ -1127,15 +1224,18 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
const struct { const struct {
const char* key; const char* key;
const char* command; const char* command;
} fields[] = { } fields[] = {
{"name", "get name"}, {"name", "get name"},
{"mqtt_iata", "get mqtt.iata"}, {"mqtt_iata", "get mqtt.iata"},
{"mqtt_owner", "get mqtt.owner"}, {"mqtt_owner", "get mqtt.owner"},
{"mqtt_email", "get mqtt.email"}, {"mqtt_email", "get mqtt.email"},
{"advert_interval", "get advert.interval"}, {"mqtt_eastmesh_au", "get mqtt.eastmesh-au"},
{"flood_interval", "get flood.advert.interval"}, {"mqtt_letsmesh_eu", "get mqtt.letsmesh-eu"},
{"flood_max", "get flood.max"}, {"mqtt_letsmesh_us", "get mqtt.letsmesh-us"},
}; {"advert_interval", "get advert.interval"},
{"flood_interval", "get flood.advert.interval"},
{"flood_max", "get flood.max"},
};
size_t offset = 0; size_t offset = 0;
json[offset++] = '{'; json[offset++] = '{';
@@ -464,6 +464,7 @@ build_flags =
-D WIFI_PWD='"mypwd"' -D WIFI_PWD='"mypwd"'
-D MQTT_DEFAULT_IATA='"MEL"' -D MQTT_DEFAULT_IATA='"MEL"'
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
build_src_filter = ${heltec_v4_oled.build_src_filter} build_src_filter = ${heltec_v4_oled.build_src_filter}
+<helpers/mqtt/*.cpp> +<helpers/mqtt/*.cpp>
+<helpers/ui/SSD1306Display.cpp> +<helpers/ui/SSD1306Display.cpp>