From 6680e53ef5340f87bc4755624d946dcc588012d9 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Sat, 11 Apr 2026 16:40:45 +1000 Subject: [PATCH] fix: improve MQTT/web memory usage and streamline broker controls --- examples/simple_repeater/MyMesh.cpp | 24 +- src/helpers/mqtt/MQTTUplink.cpp | 185 +++++-- src/helpers/mqtt/MQTTUplink.h | 15 +- src/helpers/web/WebPanelServer.cpp | 679 +++++++++++++++++++------- src/helpers/web/WebPanelServer.h | 5 + variants/eastmesh_mqtt/platformio.ini | 2 +- 6 files changed, 661 insertions(+), 249 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e5ab7ca7..3f8e7ead 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -462,7 +462,8 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } #endif #ifdef WITH_MQTT_UPLINK - mqtt.publishPacket(*pkt, false, (int)_radio->getLastRSSI(), _radio->getLastSNR()); + mqtt.publishPacket(*pkt, false, (int)_radio->getLastRSSI(), _radio->getLastSNR(), (int)(score * 1000), + (int)_radio->getEstAirtimeFor(len)); #endif if (_logging) { @@ -1408,14 +1409,23 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply mqtt.setStatusEnabled(memcmp(&command[16], "on", 2) == 0); strcpy(reply, "OK"); } else if (memcmp(command, "set mqtt.eastmesh-au ", 21) == 0 || memcmp(command, "set mqtt.eastmesh.au ", 21) == 0) { - mqtt.setEndpointEnabled(0x01, memcmp(&command[21], "on", 2) == 0); - strcpy(reply, "OK"); + if (mqtt.setEndpointEnabled(0x01, memcmp(&command[21], "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) { - mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0); - strcpy(reply, "OK"); + if (mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - max 2 mqtt brokers"); + } } else if (memcmp(command, "set mqtt.letsmesh-us ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.us ", 21) == 0) { - mqtt.setEndpointEnabled(0x04, memcmp(&command[21], "on", 2) == 0); - strcpy(reply, "OK"); + if (mqtt.setEndpointEnabled(0x04, memcmp(&command[21], "on", 2) == 0)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - max 2 mqtt brokers"); + } #endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 92a51191..fe745afd 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -51,6 +51,7 @@ namespace { constexpr unsigned long kWifiRetryMillis = 15000; constexpr unsigned long kWifiConnectTimeoutMillis = 45000; constexpr unsigned long kBrokerRetryMillis = 10000; +constexpr size_t kBrokerTokenSize = 640; constexpr time_t kTokenLifetimeSecs = 3600; constexpr time_t kTokenRefreshSlackSecs = 300; constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z @@ -144,6 +145,28 @@ bool MQTTUplink::hasEnabledBroker() const { return (_prefs.enabled_mask & 0x07) != 0; } +uint8_t MQTTUplink::normalizeEnabledMask(uint8_t mask) { + uint8_t normalized = 0; + uint8_t count = 0; + for (const BrokerSpec& spec : kBrokerSpecs) { + if ((mask & spec.bit) != 0) { + if (count >= kMaxEnabledBrokers) { + break; + } + normalized |= spec.bit; + ++count; + } + } + return normalized; +} + +void MQTTUplink::formatTopic(char* dst, size_t dst_size, const char* leaf) const { + if (dst == nullptr || dst_size == 0) { + return; + } + snprintf(dst, dst_size, "meshcore/%s/%s/%s", _prefs.iata, _device_id, leaf); +} + bool MQTTUplink::isActive() const { return _running && hasEnabledBroker(); } @@ -171,7 +194,7 @@ bool MQTTUplink::sendStatusNow() { bool any_connected = false; for (const BrokerState& broker : _brokers) { - if ((_prefs.enabled_mask & broker.spec->bit) != 0 && broker.connected && broker.client != nullptr) { + if (broker.spec != nullptr && broker.connected && broker.client != nullptr) { any_connected = true; break; } @@ -296,16 +319,20 @@ const char* MQTTUplink::getPowerSaveLabel(uint8_t mode) { void MQTTUplink::refreshIdentityStrings() { bytesToHexUpper(_identity->pub_key, PUB_KEY_SIZE, _device_id, sizeof(_device_id)); - for (size_t i = 0; i < 3; ++i) { - BrokerState& broker = _brokers[i]; - 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); - snprintf(broker.status_topic, sizeof(broker.status_topic), "meshcore/%s/%s/status", _prefs.iata, _device_id); - snprintf(broker.packets_topic, sizeof(broker.packets_topic), "meshcore/%s/%s/packets", _prefs.iata, _device_id); - snprintf(broker.raw_topic, sizeof(broker.raw_topic), "meshcore/%s/%s/raw", _prefs.iata, _device_id); + for (BrokerState& broker : _brokers) { + refreshBrokerIdentity(broker); } } +void MQTTUplink::refreshBrokerIdentity(BrokerState& broker) { + if (broker.spec == nullptr) { + return; + } + 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"); +} + void MQTTUplink::refreshBrokerState(BrokerState& broker) { char safe_name[40]; makeSafeToken(board.getManufacturerName(), safe_name, sizeof(safe_name)); @@ -333,11 +360,21 @@ bool MQTTUplink::refreshToken(BrokerState& broker) { return false; } + if (broker.token == nullptr) { + broker.token = allocScratchBuffer(kBrokerTokenSize); + if (broker.token == nullptr) { + MQTT_LOG("%s token alloc failed", broker.spec->label); + return false; + } + } + 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, sizeof(broker.token), + if (!JWTHelper::createAuthToken(*_identity, broker.spec->host, now, expires_at, broker.token, kBrokerTokenSize, owner, email)) { + freeScratchBuffer(broker.token); + broker.token = nullptr; MQTT_LOG("%s token creation failed", broker.spec->label); return false; } @@ -354,8 +391,11 @@ void MQTTUplink::destroyBroker(BrokerState& broker) { esp_mqtt_client_destroy(broker.client); broker.client = nullptr; } + freeScratchBuffer(broker.token); + broker.token = nullptr; broker.connected = false; broker.connect_announced = false; + broker.token_expires_at = 0; } void MQTTUplink::queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain) { @@ -393,7 +433,7 @@ int MQTTUplink::buildStatusJson(char* buffer, size_t buffer_size, bool online) c } int MQTTUplink::buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, - float snr) const { + float snr, int score, int duration) const { uint8_t raw[256]; int raw_len = packet.writeTo(raw); char* raw_hex = allocScratchBuffer(520); @@ -423,24 +463,47 @@ int MQTTUplink::buildPacketJson(char* buffer, size_t buffer_size, const mesh::Pa char path_info[128]; snprintf(path_info, sizeof(path_info), "path_%dx%d_%db", (int)packet.getPathHashCount(), (int)packet.getPathHashSize(), (int)packet.getPathByteLen()); - int len = snprintf(buffer, buffer_size, - "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," - "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," - "\"route\":\"D\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," - "\"hash\":\"%s\",\"path\":\"%s\"}", - origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, packet.getPayloadType(), - packet.payload_len, raw_hex, snr, rssi, hash_hex, path_info); + int len; + if (score >= 0) { + len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"D\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"score\":\"%d\",\"duration\":\"%d\",\"hash\":\"%s\",\"path\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, + packet.getPayloadType(), packet.payload_len, raw_hex, snr, rssi, score, duration, hash_hex, + path_info); + } else { + len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"D\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"hash\":\"%s\",\"path\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, + packet.getPayloadType(), packet.payload_len, raw_hex, snr, rssi, hash_hex, path_info); + } freeScratchBuffer(raw_hex); return len; } - int len = snprintf(buffer, buffer_size, - "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," - "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," - "\"route\":\"F\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," - "\"hash\":\"%s\"}", - origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, packet.getPayloadType(), - packet.payload_len, raw_hex, snr, rssi, hash_hex); + int len; + if (score >= 0) { + len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"F\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"score\":\"%d\",\"duration\":\"%d\",\"hash\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, + packet.getPayloadType(), packet.payload_len, raw_hex, snr, rssi, score, duration, hash_hex); + } else { + len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"F\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"hash\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, + packet.getPayloadType(), packet.payload_len, raw_hex, snr, rssi, hash_hex); + } freeScratchBuffer(raw_hex); return len; } @@ -493,7 +556,7 @@ void MQTTUplink::publishStatus(bool online) { return; } for (BrokerState& broker : _brokers) { - if ((_prefs.enabled_mask & broker.spec->bit) != 0) { + if (broker.spec != nullptr) { queuePublish(broker, broker.status_topic, payload, true); } } @@ -624,6 +687,9 @@ void MQTTUplink::updateTimeSync() { } void MQTTUplink::ensureBroker(BrokerState& broker) { + if (broker.spec == nullptr) { + return; + } bool enabled = (_prefs.enabled_mask & broker.spec->bit) != 0; if (!enabled) { destroyBroker(broker); @@ -674,8 +740,8 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { cfg.network.reconnect_timeout_ms = 10000; cfg.network.timeout_ms = 10000; cfg.network.disable_auto_reconnect = false; - cfg.buffer.size = 1024; - cfg.buffer.out_size = 1024; + cfg.buffer.size = 768; + cfg.buffer.out_size = 1280; #else cfg.host = broker.spec->host; cfg.port = 443; @@ -683,8 +749,8 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { cfg.password = broker.token; cfg.client_id = broker.client_id; cfg.keepalive = 30; - cfg.buffer_size = 1024; - cfg.out_buffer_size = 1024; + cfg.buffer_size = 768; + cfg.out_buffer_size = 1280; cfg.reconnect_timeout_ms = 10000; cfg.network_timeout_ms = 10000; cfg.disable_auto_reconnect = false; @@ -715,6 +781,11 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { void MQTTUplink::begin(FILESYSTEM* fs) { _fs = fs; MQTTPrefsStore::load(_fs, _prefs); + uint8_t normalized_mask = normalizeEnabledMask(_prefs.enabled_mask & 0x07); + if (normalized_mask != _prefs.enabled_mask) { + _prefs.enabled_mask = normalized_mask; + savePrefs(); + } refreshIdentityStrings(); _running = true; _last_status_publish = millis(); @@ -748,6 +819,10 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { ensureWifi(); updateTimeSync(); ensureWebServer(); + if (_web_panel.isRunning() && _web_panel.shouldAutoLock(millis())) { + WEB_LOG("idle lock"); + _web_panel.lockSession(); + } for (BrokerState& broker : _brokers) { ensureBroker(broker); @@ -767,29 +842,31 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { } } -void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr) { +void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr, int score, int duration) { if (!_running || !_have_time_sync || !hasEnabledBroker() || !_prefs.packets_enabled) { return; } if (is_tx && !_prefs.tx_enabled) { return; } - MQTT_LOG("packet dir=%s type=%u payload_len=%u rssi=%d snr=%.1f", is_tx ? "tx" : "rx", packet.getPayloadType(), - packet.payload_len, rssi, snr); + MQTT_LOG("packet dir=%s type=%u payload_len=%u rssi=%d snr=%.1f score=%d duration=%d", + is_tx ? "tx" : "rx", packet.getPayloadType(), packet.payload_len, rssi, snr, score, duration); char* payload = allocScratchBuffer(1280); if (payload == nullptr) { return; } - int len = buildPacketJson(payload, 1280, packet, is_tx, rssi, snr); + int len = buildPacketJson(payload, 1280, packet, is_tx, rssi, snr, score, duration); if (len <= 0 || static_cast(len) >= 1280) { freeScratchBuffer(payload); return; } + char topic[128]; + formatTopic(topic, sizeof(topic), "packets"); for (BrokerState& broker : _brokers) { - if ((_prefs.enabled_mask & broker.spec->bit) != 0) { - queuePublish(broker, broker.packets_topic, payload, false); + if (broker.spec != nullptr) { + queuePublish(broker, topic, payload, false); } } freeScratchBuffer(payload); @@ -808,26 +885,37 @@ void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, return; } + formatTopic(topic, sizeof(topic), "raw"); for (BrokerState& broker : _brokers) { - if ((_prefs.enabled_mask & broker.spec->bit) != 0) { - queuePublish(broker, broker.raw_topic, raw_payload, false); + if (broker.spec != nullptr) { + queuePublish(broker, topic, raw_payload, false); } } freeScratchBuffer(raw_payload); } void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { - auto broker_state = [this](const BrokerState& broker) -> const char* { - if ((_prefs.enabled_mask & broker.spec->bit) == 0) { + auto broker_state = [this](uint8_t bit) -> const char* { + if ((_prefs.enabled_mask & bit) == 0) { return "off"; } - if (broker.connected) { + const BrokerState* broker = nullptr; + for (const BrokerState& candidate : _brokers) { + if (candidate.spec != nullptr && candidate.spec->bit == bit) { + broker = &candidate; + break; + } + } + if (broker == nullptr) { + return "retry"; + } + if (broker->connected) { return "up"; } if (WiFi.status() != WL_CONNECTED || !_have_time_sync) { return "wait"; } - if (broker.client != nullptr) { + if (broker->client != nullptr) { return "conn"; } return "retry"; @@ -835,7 +923,7 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { 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, - broker_state(_brokers[0]), broker_state(_brokers[1]), broker_state(_brokers[2]), + broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit), _prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off"); } @@ -865,11 +953,16 @@ void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { } bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { + uint8_t next_mask = _prefs.enabled_mask & 0x07; if (enabled) { - _prefs.enabled_mask |= bit; + next_mask = normalizeEnabledMask(next_mask | bit); + if ((next_mask & bit) == 0) { + return false; + } } else { - _prefs.enabled_mask &= ~bit; + next_mask &= ~bit; } + _prefs.enabled_mask = next_mask; savePrefs(); return true; } @@ -904,6 +997,8 @@ bool MQTTUplink::setWebEnabled(bool enabled) { bool ok = savePrefs(); if (_prefs.web_enabled != 0) { ensureWebServer(); + } else { + stopWebServer(); } return ok; #else @@ -1074,7 +1169,7 @@ bool MQTTUplink::savePrefs() { return false; } void MQTTUplink::begin(FILESYSTEM*) {} void MQTTUplink::end() {} void MQTTUplink::loop(const MQTTStatusSnapshot&) {} -void MQTTUplink::publishPacket(const mesh::Packet&, bool, int, float) {} +void MQTTUplink::publishPacket(const mesh::Packet&, bool, int, float, int, int) {} void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } bool MQTTUplink::setEndpointEnabled(uint8_t, bool) { return false; } diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index b8a4cba2..fea5bbf0 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -42,7 +42,8 @@ public: void begin(FILESYSTEM* fs); void end(); void loop(const MQTTStatusSnapshot& snapshot); - void publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr); + void publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr, int score = -1, + int duration = -1); bool isRunning() const { return _running; } bool isActive() const; @@ -97,11 +98,9 @@ private: unsigned long last_connect_attempt; time_t token_expires_at; char username[70]; - char token[512]; + char* token; char client_id[48]; char status_topic[128]; - char packets_topic[128]; - char raw_topic[128]; char offline_payload[256]; }; #endif @@ -126,6 +125,7 @@ private: static constexpr uint8_t kEastmeshBit = 0x01; static constexpr uint8_t kLetsmeshEuBit = 0x02; static constexpr uint8_t kLetsmeshUsBit = 0x04; + static constexpr uint8_t kMaxEnabledBrokers = 2; static const BrokerSpec kBrokerSpecs[3]; BrokerState _brokers[3]; @@ -136,7 +136,10 @@ private: void ensureWifi(); void updateTimeSync(); bool hasEnabledBroker() const; + static uint8_t normalizeEnabledMask(uint8_t mask); + void formatTopic(char* dst, size_t dst_size, const char* leaf) const; void refreshIdentityStrings(); + void refreshBrokerIdentity(BrokerState& broker); void refreshBrokerState(BrokerState& broker); void ensureBroker(BrokerState& broker); void destroyBroker(BrokerState& broker); @@ -145,8 +148,8 @@ private: void publishOnlineStatus(BrokerState& broker); void queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain); int buildStatusJson(char* buffer, size_t buffer_size, bool online) const; - int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, - float snr) const; + int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr, + int score, int duration) const; int buildRawJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr) const; static wifi_ps_type_t toEspPowerSave(uint8_t mode); static const char* getPowerSaveLabel(uint8_t mode); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index a8bd07db..90dfc68e 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -12,11 +12,20 @@ namespace { -constexpr size_t kWebServerStackSize = 8192; +#ifndef WEB_PANEL_STACK_SIZE + #define WEB_PANEL_STACK_SIZE 6144 +#endif + +#ifndef WEB_PANEL_IDLE_TIMEOUT_MS + #define WEB_PANEL_IDLE_TIMEOUT_MS (15UL * 60UL * 1000UL) +#endif + +constexpr size_t kWebServerStackSize = WEB_PANEL_STACK_SIZE; constexpr size_t kWebPasswordBufferSize = 80; constexpr size_t kWebCommandBufferSize = 192; constexpr size_t kWebReplyBufferSize = 256; -constexpr size_t kWebJsonBufferSize = 2048; +constexpr size_t kWebPageChunkSize = 768; +constexpr unsigned long kWebIdleTimeoutMs = WEB_PANEL_IDLE_TIMEOUT_MS; #if defined(MQTT_DEBUG) && MQTT_DEBUG #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__) @@ -50,69 +59,173 @@ void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) dst[(di < dst_size) ? di : (dst_size - 1)] = 0; } -size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) { - if (dst == nullptr || dst_size == 0) { - return offset; +esp_err_t sendChunk(httpd_req_t* req, const char* text) { + return httpd_resp_sendstr_chunk(req, text != nullptr ? text : ""); +} + +esp_err_t sendProgmemChunked(httpd_req_t* req, const char* text) { + if (text == nullptr) { + return httpd_resp_send_chunk(req, nullptr, 0); } - for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) { - char c = src[i]; - if (c == '\\' || c == '"') { - if (offset + 2 >= dst_size) break; - dst[offset++] = '\\'; - dst[offset++] = c; - } else if (c == '\n') { - if (offset + 2 >= dst_size) break; - dst[offset++] = '\\'; - dst[offset++] = 'n'; - } else if (c == '\r') { - if (offset + 2 >= dst_size) break; - dst[offset++] = '\\'; - dst[offset++] = 'r'; - } else if (c == '\t') { - if (offset + 2 >= dst_size) break; - dst[offset++] = '\\'; - dst[offset++] = 't'; - } else { - dst[offset++] = c; + + const size_t len = strlen(text); + size_t offset = 0; + while (offset < len) { + const size_t chunk_len = ((len - offset) > kWebPageChunkSize) ? kWebPageChunkSize : (len - offset); + if (httpd_resp_send_chunk(req, &text[offset], chunk_len) != ESP_OK) { + httpd_resp_send_chunk(req, nullptr, 0); + return ESP_FAIL; } + offset += chunk_len; } - dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0; - return offset; + return httpd_resp_send_chunk(req, nullptr, 0); } -bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { - int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key); - if (written < 0 || offset + static_cast(written) >= dst_size) { - return false; +esp_err_t sendJsonEscapedChunk(httpd_req_t* req, const char* src) { + char chunk[48]; + size_t offset = 0; + + for (size_t i = 0; src != nullptr && src[i] != 0; ++i) { + const char* escape = nullptr; + char c = src[i]; + switch (c) { + case '\\': + escape = "\\\\"; + break; + case '"': + escape = "\\\""; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + break; + } + + const char* fragment = escape; + char single[2] = {c, 0}; + if (fragment == nullptr) { + fragment = single; + } + size_t frag_len = strlen(fragment); + if (offset + frag_len >= sizeof(chunk) - 1) { + chunk[offset] = 0; + if (sendChunk(req, chunk) != ESP_OK) { + return ESP_FAIL; + } + offset = 0; + } + memcpy(&chunk[offset], fragment, frag_len); + offset += frag_len; } - offset += static_cast(written); - offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : ""); - if (offset + 2 >= dst_size) { - return false; + + if (offset > 0) { + chunk[offset] = 0; + return sendChunk(req, chunk); } - dst[offset++] = '"'; - dst[offset] = 0; - return true; + return ESP_OK; } -bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { - int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key); - if (written < 0 || offset + static_cast(written) >= dst_size) { - return false; +esp_err_t sendJsonFieldChunk(httpd_req_t* req, const char* key, const char* value, bool comma) { + char prefix[40]; + int written = snprintf(prefix, sizeof(prefix), "%s\"%s\":\"", comma ? "," : "", key); + if (written < 0 || static_cast(written) >= sizeof(prefix)) { + return ESP_FAIL; } - offset += static_cast(written); - if (value == nullptr) { - value = "null"; + if (sendChunk(req, prefix) != ESP_OK) { + return ESP_FAIL; } - written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value); - if (written < 0 || offset + static_cast(written) >= dst_size) { - return false; + if (sendJsonEscapedChunk(req, value != nullptr ? value : "") != ESP_OK) { + return ESP_FAIL; } - offset += static_cast(written); - return true; + return sendChunk(req, "\""); } -const char kWebPanelHtml[] PROGMEM = R"HTML( +const char kWebPanelLoginHtml[] PROGMEM = R"HTML( + + + + + + Repeater Login + + + +
+
+

Repeater Config

+

Use the repeater admin password to unlock the panel. Accept the self-signed certificate warning in your browser first.

+ + +
+
+
+ + + +)HTML"; + +const char kWebPanelAppHtml[] PROGMEM = R"HTML( @@ -190,20 +303,35 @@ const char kWebPanelHtml[] 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-group { display:grid; gap:8px; align-content:start; } .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-grid.single { grid-template-columns:1fr; } + .broker-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); } + .broker-card { background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:12px; display:grid; gap:10px; min-height:124px; align-content:start; } .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-group-title { font-size:12px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; } .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; } + .broker-mode { display:grid; gap:10px; align-content:start; height:100%; } + .mode-slider { display:grid; gap:10px; } + .mode-slider input[type="range"] { width:100%; min-height:auto; padding:0; border:none; background:transparent; appearance:none; -webkit-appearance:none; } + .mode-slider input[type="range"]::-webkit-slider-runnable-track { height:12px; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); border:1px solid var(--border); } + .mode-slider input[type="range"]::-moz-range-track { height:12px; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); border:1px solid var(--border); } + .mode-slider input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:26px; height:26px; margin-top:-8px; border-radius:50%; border:2px solid var(--surface1); background:#ffffff; box-shadow:0 2px 8px rgba(0,0,0,.2); } + .mode-slider input[type="range"]::-moz-range-thumb { width:26px; height:26px; border-radius:50%; border:2px solid var(--surface1); background:#ffffff; box-shadow:0 2px 8px rgba(0,0,0,.2); } + .mode-slider input[type="range"]:disabled { opacity:.55; } + .mode-labels { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; font-size:13px; color:var(--text-muted); text-align:center; } + .mode-labels.two { grid-template-columns:repeat(2,minmax(0,1fr)); } + .mode-label { padding:2px 0; border-radius:999px; transition:background .2s ease,color .2s ease; } + .mode-label.active { background:rgba(63,174,97,.18); color:var(--text); font-weight:700; } + :root[data-theme="dark"] .mode-label.active { background:rgba(73,194,125,.24); } + .mode-label.disabled { opacity:.4; } + .visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .panel-warning { min-height:1.4em; font-size:13px; color:var(--status-red); } + .panel-note { font-size:13px; color:var(--text-muted); } .themebtn { padding:10px 14px; } #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-family:inherit; font-size:14px; line-height:1.45; } @@ -245,7 +373,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML( body { font-size:15px; } main { padding:16px; } .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, .broker-grid { grid-template-columns:1fr; } + .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics, .broker-stack, .broker-grid, .broker-grid.single, .broker-grid.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%; } @@ -269,7 +397,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML(
-
+
@@ -1058,7 +1325,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML( } // namespace WebPanelServer::WebPanelServer() - : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} { + : _runner(nullptr), _server(nullptr), _token{0}, _last_activity_ms(0), _route_context{this} { } void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { @@ -1070,13 +1337,11 @@ bool WebPanelServer::start() { return _server != nullptr; } - if (_token[0] == 0) { - refreshToken(); - } + noteActivity(); httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); config.httpd.max_open_sockets = 2; - config.httpd.max_uri_handlers = 5; + config.httpd.max_uri_handlers = 6; config.httpd.max_resp_headers = 4; config.httpd.backlog_conn = 2; config.httpd.recv_wait_timeout = 2; @@ -1101,11 +1366,13 @@ bool WebPanelServer::start() { } httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context}; + httpd_uri_t app_uri = {.uri = "/app", .method = HTTP_GET, .handler = &WebPanelServer::handleApp, .user_ctx = &_route_context}; httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context}; httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context}; httpd_uri_t bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .user_ctx = &_route_context}; httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context}; httpd_register_uri_handler(_server, &index_uri); + httpd_register_uri_handler(_server, &app_uri); httpd_register_uri_handler(_server, &login_uri); httpd_register_uri_handler(_server, &command_uri); httpd_register_uri_handler(_server, &bootstrap_uri); @@ -1121,6 +1388,7 @@ void WebPanelServer::stop() { _server = nullptr; } _token[0] = 0; + _last_activity_ms = 0; } bool WebPanelServer::isRunning() const { @@ -1131,14 +1399,38 @@ bool WebPanelServer::hasSessionToken() const { return _token[0] != 0; } +bool WebPanelServer::shouldAutoLock(unsigned long now_ms) const { + if (_server == nullptr || _token[0] == 0 || kWebIdleTimeoutMs == 0 || _last_activity_ms == 0) { + return false; + } + return now_ms - _last_activity_ms >= kWebIdleTimeoutMs; +} + +void WebPanelServer::lockSession() { + _token[0] = 0; + _last_activity_ms = 0; +} + esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { return httpd_resp_send_500(req); } + ctx->self->noteActivity(); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); + return sendProgmemChunked(req, kWebPanelLoginHtml); +} + +esp_err_t WebPanelServer::handleApp(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr) { + return httpd_resp_send_500(req); + } + ctx->self->noteActivity(); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return sendProgmemChunked(req, kWebPanelAppHtml); } esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) { @@ -1165,6 +1457,7 @@ esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) { freeScratchBuffer(password); ctx->self->refreshToken(); + ctx->self->noteActivity(); WEB_PANEL_LOG("login accepted"); httpd_resp_set_type(req, "text/plain; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); @@ -1194,6 +1487,7 @@ esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) { return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); } + ctx->self->noteActivity(); memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize); httpd_resp_set_type(req, "text/plain; charset=utf-8"); @@ -1213,11 +1507,10 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } + ctx->self->noteActivity(); char* reply = allocScratchBuffer(kWebReplyBufferSize); - char* json = allocScratchBuffer(kWebJsonBufferSize); - if (reply == nullptr || json == nullptr) { + if (reply == nullptr) { freeScratchBuffer(reply); - freeScratchBuffer(json); return httpd_resp_send_500(req); } @@ -1237,9 +1530,13 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) { {"flood_max", "get flood.max"}, }; - size_t offset = 0; - json[offset++] = '{'; - json[offset] = 0; + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + if (sendChunk(req, "{") != ESP_OK) { + freeScratchBuffer(reply); + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; + } for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); @@ -1250,25 +1547,19 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) { if (strcmp(value, "-") == 0) { value = ""; } - if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) { + if (sendJsonFieldChunk(req, fields[i].key, value, i != 0) != ESP_OK) { freeScratchBuffer(reply); - freeScratchBuffer(json); - return httpd_resp_send_500(req); + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; } } - if (offset + 2 >= kWebJsonBufferSize) { - freeScratchBuffer(reply); - freeScratchBuffer(json); - return httpd_resp_send_500(req); + esp_err_t rc = sendChunk(req, "}"); + if (rc == ESP_OK) { + rc = httpd_resp_sendstr_chunk(req, nullptr); + } else { + httpd_resp_sendstr_chunk(req, nullptr); } - json[offset++] = '}'; - json[offset] = 0; - - httpd_resp_set_type(req, "application/json; charset=utf-8"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN); freeScratchBuffer(reply); - freeScratchBuffer(json); return rc; } @@ -1281,11 +1572,10 @@ esp_err_t WebPanelServer::handleStats(httpd_req_t* req) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } + ctx->self->noteActivity(); char* reply = allocScratchBuffer(kWebReplyBufferSize); - char* json = allocScratchBuffer(kWebJsonBufferSize); - if (reply == nullptr || json == nullptr) { + if (reply == nullptr) { freeScratchBuffer(reply); - freeScratchBuffer(json); return httpd_resp_send_500(req); } @@ -1301,31 +1591,29 @@ esp_err_t WebPanelServer::handleStats(httpd_req_t* req) { {"memory", "memory"}, }; - size_t offset = 0; - json[offset++] = '{'; - json[offset] = 0; + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + if (sendChunk(req, "{") != ESP_OK) { + freeScratchBuffer(reply); + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; + } for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); - if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) { + if (sendJsonFieldChunk(req, fields[i].key, reply, i != 0) != ESP_OK) { freeScratchBuffer(reply); - freeScratchBuffer(json); - return httpd_resp_send_500(req); + httpd_resp_sendstr_chunk(req, nullptr); + return ESP_FAIL; } } - if (offset + 2 >= kWebJsonBufferSize) { - freeScratchBuffer(reply); - freeScratchBuffer(json); - return httpd_resp_send_500(req); + esp_err_t rc = sendChunk(req, "}"); + if (rc == ESP_OK) { + rc = httpd_resp_sendstr_chunk(req, nullptr); + } else { + httpd_resp_sendstr_chunk(req, nullptr); } - json[offset++] = '}'; - json[offset] = 0; - - httpd_resp_set_type(req, "application/json; charset=utf-8"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN); freeScratchBuffer(reply); - freeScratchBuffer(json); return rc; } @@ -1366,6 +1654,10 @@ bool WebPanelServer::isAuthorized(httpd_req_t* req) const { return strcmp(token, _token) == 0; } +void WebPanelServer::noteActivity() { + _last_activity_ms = millis(); +} + #else WebPanelServer::WebPanelServer() @@ -1391,4 +1683,11 @@ bool WebPanelServer::hasSessionToken() const { return false; } +bool WebPanelServer::shouldAutoLock(unsigned long) const { + return false; +} + +void WebPanelServer::lockSession() { +} + #endif diff --git a/src/helpers/web/WebPanelServer.h b/src/helpers/web/WebPanelServer.h index 2d6febe5..96be46e1 100644 --- a/src/helpers/web/WebPanelServer.h +++ b/src/helpers/web/WebPanelServer.h @@ -28,6 +28,8 @@ public: void stop(); bool isRunning() const; bool hasSessionToken() const; + bool shouldAutoLock(unsigned long now_ms) const; + void lockSession(); private: #if defined(ESP_PLATFORM) && WITH_WEB_PANEL @@ -38,9 +40,11 @@ private: WebPanelCommandRunner* _runner; httpd_handle_t _server; char _token[33]; + unsigned long _last_activity_ms; RouteContext _route_context; static esp_err_t handleIndex(httpd_req_t* req); + static esp_err_t handleApp(httpd_req_t* req); static esp_err_t handleLogin(httpd_req_t* req); static esp_err_t handleCommand(httpd_req_t* req); static esp_err_t handleBootstrap(httpd_req_t* req); @@ -49,6 +53,7 @@ private: bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const; void refreshToken(); bool isAuthorized(httpd_req_t* req) const; + void noteActivity(); #else WebPanelCommandRunner* _runner; #endif diff --git a/variants/eastmesh_mqtt/platformio.ini b/variants/eastmesh_mqtt/platformio.ini index 6bf75c79..3474f21c 100644 --- a/variants/eastmesh_mqtt/platformio.ini +++ b/variants/eastmesh_mqtt/platformio.ini @@ -8,7 +8,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - ; -D MQTT_DEBUG=1 + -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"'