From fc91f8214e285cbd4e80c84d9598e476bf0f8e29 Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:29 -0600 Subject: [PATCH] TCP scaling, GUI pooling, dev mode, protocol improvements - TCP: wider drain budgets, TCP_NODELAY, no flush, PSRAM buffers, queue announces until hub ID - GUI: object pool Messages/Contacts screens, partial MessageView status, targeted tab refresh - LVGL throttled to 5fps when dimmed, non-blocking RSSI monitor, bounded I2S writes - LXMF batch drain, BLE frame mutex, LoRa interrupt-driven RX, name cache cap 300 - Developer mode in settings: custom radio params (freq/txp/SF/BW/CR/preamble) behind warning --- platformio.ini | 1 + src/audio/AudioNotify.cpp | 8 +- src/config/UserConfig.cpp | 2 + src/config/UserConfig.h | 3 + src/hal/Display.cpp | 6 +- src/hal/Power.h | 1 + src/main.cpp | 62 ++++++--- src/radio/SX1262.cpp | 15 +- src/radio/SX1262.h | 4 + src/reticulum/AnnounceManager.cpp | 11 ++ src/reticulum/AnnounceManager.h | 1 + src/reticulum/LXMFManager.cpp | 18 ++- src/storage/SDStore.cpp | 11 ++ src/transport/BLEInterface.cpp | 26 ++-- src/transport/BLEInterface.h | 2 + src/transport/LoRaInterface.cpp | 4 + src/transport/TCPClientInterface.cpp | 86 ++++++++---- src/transport/TCPClientInterface.h | 15 +- src/transport/WiFiInterface.cpp | 39 ++++++ src/transport/WiFiInterface.h | 5 +- src/ui/LvTabBar.cpp | 30 ++-- src/ui/LvTabBar.h | 1 + src/ui/screens/LvContactsScreen.cpp | 101 +++++++++----- src/ui/screens/LvContactsScreen.h | 7 + src/ui/screens/LvMessageView.cpp | 42 +++++- src/ui/screens/LvMessageView.h | 6 + src/ui/screens/LvMessagesScreen.cpp | 196 ++++++++++++++++----------- src/ui/screens/LvMessagesScreen.h | 20 +++ src/ui/screens/LvSettingsScreen.cpp | 118 ++++++++++------ src/ui/screens/LvSettingsScreen.h | 1 + 30 files changed, 604 insertions(+), 238 deletions(-) diff --git a/platformio.ini b/platformio.ini index fdd5320..e4dc544 100644 --- a/platformio.ini +++ b/platformio.ini @@ -11,6 +11,7 @@ board_build.arduino.memory_type = qio_opi build_flags = -std=gnu++17 -fexceptions + -O2 -DRATDECK=1 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 diff --git a/src/audio/AudioNotify.cpp b/src/audio/AudioNotify.cpp index 1cfdb5d..8000ea0 100644 --- a/src/audio/AudioNotify.cpp +++ b/src/audio/AudioNotify.cpp @@ -77,7 +77,7 @@ void AudioNotify::writeTone(uint16_t freq, uint16_t durationMs) { } size_t written = 0; - i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, portMAX_DELAY); + i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, pdMS_TO_TICKS(200)); free(buf); } @@ -90,7 +90,7 @@ void AudioNotify::writeSilence(uint16_t durationMs) { if (!buf) return; memset(buf, 0, bufSize); size_t written = 0; - i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, portMAX_DELAY); + i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, pdMS_TO_TICKS(200)); free(buf); } @@ -201,11 +201,11 @@ void AudioNotify::playBoot() { // Write entire sequence at once for seamless playback size_t written = 0; - i2s_write(I2S_PORT, buf, pos * sizeof(int16_t), &written, portMAX_DELAY); + i2s_write(I2S_PORT, buf, pos * sizeof(int16_t), &written, pdMS_TO_TICKS(200)); // Flush with silence memset(buf, 0, 512 * sizeof(int16_t)); - i2s_write(I2S_PORT, buf, 512 * sizeof(int16_t), &written, portMAX_DELAY); + i2s_write(I2S_PORT, buf, 512 * sizeof(int16_t), &written, pdMS_TO_TICKS(200)); free(buf); } diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index 69a8510..2f7f850 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -59,6 +59,7 @@ bool UserConfig::parseJson(const String& json) { _settings.audioVolume = doc["audio_vol"] | 80; _settings.displayName = doc["display_name"] | ""; + _settings.devMode = doc["dev_mode"] | false; Serial.println("[CONFIG] Settings loaded"); return true; @@ -100,6 +101,7 @@ String UserConfig::serializeToJson() const { doc["audio_vol"] = _settings.audioVolume; doc["display_name"] = _settings.displayName; + doc["dev_mode"] = _settings.devMode; String json; serializeJson(doc, json); diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 721a7a9..9ddfad0 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -56,6 +56,9 @@ struct UserSettings { // Identity String displayName; + + // Developer mode — unlocks custom radio parameters + bool devMode = false; }; class UserConfig { diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp index e3c93ed..03dbfda 100644 --- a/src/hal/Display.cpp +++ b/src/hal/Display.cpp @@ -33,8 +33,8 @@ void Display::beginLVGL() { lv_init(); - // Allocate double-buffered 10-line strips in PSRAM - const uint32_t bufSize = 320 * 10; + // Allocate double-buffered 20-line strips in PSRAM (halves DMA flush ops per redraw) + const uint32_t bufSize = 320 * 20; s_buf1 = (lv_color_t*)heap_caps_malloc(bufSize * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_8BIT); s_buf2 = (lv_color_t*)heap_caps_malloc(bufSize * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_8BIT); @@ -58,7 +58,7 @@ void Display::beginLVGL() { disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); - Serial.println("[LVGL] Display driver registered (320x240, double-buffered 10-line DMA)"); + Serial.println("[LVGL] Display driver registered (320x240, double-buffered 20-line DMA)"); } void Display::setBrightness(uint8_t level) { diff --git a/src/hal/Power.h b/src/hal/Power.h index 81dc656..77a12f8 100644 --- a/src/hal/Power.h +++ b/src/hal/Power.h @@ -28,6 +28,7 @@ public: enum State { ACTIVE, DIMMED, SCREEN_OFF }; State state() const { return _state; } bool isScreenOn() const { return _state != SCREEN_OFF; } + bool isDimmed() const { return _state == DIMMED; } private: void setState(State newState); diff --git a/src/main.cpp b/src/main.cpp index 4c0c3ee..5b2de87 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -122,7 +122,7 @@ unsigned long loopCycleStart = 0; unsigned long maxLoopTime = 0; unsigned long lastLvglTime = 0; constexpr unsigned long LVGL_INTERVAL_MS = 33; // ~30 FPS -constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 18; // Max cumulative TCP time per loop +constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35; // Max cumulative TCP time per loop bool wifiDeferredAnnounce = false; unsigned long wifiConnectedAt = 0; @@ -238,24 +238,28 @@ void onHotkeyDiag() { Serial.println("======================="); } +// RSSI monitor — non-blocking state machine (sampled in main loop) volatile bool rssiMonitorActive = false; +unsigned long rssiMonitorStart = 0; +unsigned long rssiLastSample = 0; +int rssiMinVal = 0, rssiMaxVal = -200, rssiSampleCount = 0; + void onHotkeyRssiMonitor() { if (!radioOnline) { Serial.println("[RSSI] Radio offline"); return; } - Serial.println("[RSSI] Sampling for 5 seconds..."); - rssiMonitorActive = true; - int minRssi = 0, maxRssi = -200; - unsigned long start = millis(); - int samples = 0; - while (millis() - start < 5000) { - int rssi = radio.currentRssi(); - if (rssi < minRssi) minRssi = rssi; - if (rssi > maxRssi) maxRssi = rssi; - samples++; - Serial.printf("[RSSI] %d dBm\n", rssi); - delay(100); + if (rssiMonitorActive) { + // Already running — cancel + rssiMonitorActive = false; + Serial.printf("[RSSI] Stopped: %d samples, min=%d max=%d dBm\n", + rssiSampleCount, rssiMinVal, rssiMaxVal); + return; } - rssiMonitorActive = false; - Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n", samples, minRssi, maxRssi); + Serial.println("[RSSI] Sampling for 5 seconds (non-blocking)..."); + rssiMonitorActive = true; + rssiMonitorStart = millis(); + rssiLastSample = 0; + rssiMinVal = 0; + rssiMaxVal = -200; + rssiSampleCount = 0; } void onHotkeyRadioTest() { @@ -637,11 +641,11 @@ void setup() { audio.begin(); // Boot complete — transition to Home screen - delay(200); + // Yield to LVGL instead of blocking delay + lvBootScreen.setProgress(0.98f, "Ready"); + for (int i = 0; i < 6; i++) { lv_timer_handler(); delay(1); } lvBootScreen.setProgress(1.0f, "Ready"); - // (LVGL boot renders via lv_timer_handler in setProgress) audio.playBoot(); - delay(400); bootComplete = true; ui.statusBar().setTransportMode("RatDeck"); @@ -879,10 +883,11 @@ void loop() { } } - // 3. LVGL timer handler — throttled to ~30 FPS + // 3. LVGL timer handler — 30 FPS active, 5 FPS dimmed { unsigned long now = millis(); - if (powerMgr.isScreenOn() && now - lastLvglTime >= LVGL_INTERVAL_MS) { + unsigned long lvglInterval = powerMgr.isDimmed() ? 200 : LVGL_INTERVAL_MS; + if (powerMgr.isScreenOn() && now - lastLvglTime >= lvglInterval) { lastLvglTime = now; lv_timer_handler(); } @@ -1006,6 +1011,23 @@ void loop() { ui.render(); } + // 12.5. RSSI monitor (non-blocking, one sample per loop iteration) + if (rssiMonitorActive && radioOnline) { + unsigned long now = millis(); + if (now - rssiMonitorStart >= 5000) { + rssiMonitorActive = false; + Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n", + rssiSampleCount, rssiMinVal, rssiMaxVal); + } else if (now - rssiLastSample >= 100) { + rssiLastSample = now; + int rssi = radio.currentRssi(); + if (rssi < rssiMinVal) rssiMinVal = rssi; + if (rssi > rssiMaxVal) rssiMaxVal = rssi; + rssiSampleCount++; + Serial.printf("[RSSI] %d dBm\n", rssi); + } + } + // 13. Heartbeat for crash diagnosis { unsigned long cycleTime = millis() - loopCycleStart; diff --git a/src/radio/SX1262.cpp b/src/radio/SX1262.cpp index 4870669..d99cceb 100644 --- a/src/radio/SX1262.cpp +++ b/src/radio/SX1262.cpp @@ -386,6 +386,16 @@ void SX1262::receive(int size) { explicitHeaderMode(); } + // Set up DIO1 interrupt for RX done (enables packetAvailable flag) + if (!_onReceive) { + pinMode(_irq, INPUT); + uint8_t irqBuf[8] = {0xFF, 0xFF, 0x00, IRQ_RX_DONE_MASK_6X, 0x00, 0x00, 0x00, 0x00}; + executeOpcode(OP_SET_IRQ_FLAGS_6X, irqBuf, 8); + attachInterrupt(digitalPinToInterrupt(_irq), onDio0Rise, RISING); + } + + packetAvailable = false; + if (_rxen != -1) { rxAntEnable(); } uint8_t mode[3] = {0xFF, 0xFF, 0xFF}; executeOpcode(OP_RX_6X, mode, 3); @@ -688,7 +698,10 @@ float SX1262::getAirtime(uint16_t written) { } void IRAM_ATTR SX1262::onDio0Rise() { - if (_instance) { _instance->handleDio0Rise(); } + if (_instance) { + _instance->packetAvailable = true; + _instance->handleDio0Rise(); + } } void SX1262::handleDio0Rise() { diff --git a/src/radio/SX1262.h b/src/radio/SX1262.h index dee3262..8cb6050 100644 --- a/src/radio/SX1262.h +++ b/src/radio/SX1262.h @@ -145,4 +145,8 @@ private: float _loraSymbolTimeMs = 0; static SX1262* _instance; + +public: + // Interrupt-driven RX availability flag (set by DIO1 ISR) + volatile bool packetAvailable = false; }; diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index bc908fa..ea18f7b 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -173,6 +173,17 @@ void AnnounceManager::received_announce( if (nc == _nameCache.end() || nc->second != name) { _nameCache[destHex] = name; _nameCacheDirty = true; + // Cap name cache size: prune entries not in _nodes or saved contacts + if ((int)_nameCache.size() > MAX_NAME_CACHE) { + for (auto it = _nameCache.begin(); it != _nameCache.end() && (int)_nameCache.size() > MAX_NAME_CACHE; ) { + RNS::Bytes h; h.assignHex(it->first.c_str()); + if (!findNode(h)) { + it = _nameCache.erase(it); + } else { + ++it; + } + } + } } } diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index c59c5a9..6141e21 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -71,6 +71,7 @@ private: unsigned int _globalAnnounceCount = 0; static constexpr unsigned int MAX_GLOBAL_ANNOUNCES_PER_SEC = 10; static constexpr int MAX_NODES = 200; + static constexpr int MAX_NAME_CACHE = 300; static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000; static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index 67f0ed7..ae7c766 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -18,16 +18,21 @@ bool LXMFManager::begin(ReticulumManager* rns, MessageStore* store) { void LXMFManager::loop() { if (_outQueue.empty()) return; unsigned long now = millis(); + int processed = 0; + + for (auto it = _outQueue.begin(); it != _outQueue.end(); ) { + // Time-budgeted: process up to 3 messages within 10ms + if (processed >= 3 || (processed > 0 && millis() - now >= 10)) break; - for (auto it = _outQueue.begin(); it != _outQueue.end(); ++it) { LXMFMessage& msg = *it; // Per-message retry cooldown: 2 seconds between attempts - if (msg.retries > 0 && (now - msg.lastRetryMs) < 2000) continue; + if (msg.retries > 0 && (millis() - msg.lastRetryMs) < 2000) { ++it; continue; } - msg.lastRetryMs = now; + msg.lastRetryMs = millis(); if (sendDirect(msg)) { + processed++; Serial.printf("[LXMF] Queue drain: status=%s dest=%s\n", msg.statusStr(), msg.destHash.toHex().substr(0, 8).c_str()); @@ -42,10 +47,11 @@ void LXMFManager::loop() { if (_statusCb) { _statusCb(peerHex, msg.timestamp, msg.status); } - _outQueue.erase(it); - return; // One send per loop() call to avoid hogging CPU + it = _outQueue.erase(it); + } else { + // sendDirect returned false — message stays in queue, try next + ++it; } - // sendDirect returned false — message stays in queue, try next } } diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp index b815133..6190069 100644 --- a/src/storage/SDStore.cpp +++ b/src/storage/SDStore.cpp @@ -33,6 +33,17 @@ bool SDStore::begin(SPIClass* spi, int csPin) { if (cardType == CARD_SDHC) typeStr = "SDHC"; _ready = true; + + // Increase SPI speed for faster I/O after stable mount at 4MHz + SD.end(); + if (!SD.begin(csPin, *spi, 16000000)) { + // 16MHz failed, fall back to 4MHz + SD.begin(csPin, *spi, 4000000); + Serial.println("[SD] 16MHz failed, using 4MHz"); + } else { + Serial.println("[SD] SPI speed: 16MHz"); + } + Serial.printf("[SD] %s card ready, total=%llu MB, used=%llu MB\n", typeStr, totalBytes() / (1024 * 1024), usedBytes() / (1024 * 1024)); return true; diff --git a/src/transport/BLEInterface.cpp b/src/transport/BLEInterface.cpp index 773c68d..92ff6b3 100644 --- a/src/transport/BLEInterface.cpp +++ b/src/transport/BLEInterface.cpp @@ -15,6 +15,7 @@ BLEInterface::~BLEInterface() { } bool BLEInterface::start() { + _framesMutex = xSemaphoreCreateMutex(); NimBLEDevice::init("Ratdeck"); NimBLEDevice::setMTU(512); @@ -87,8 +88,11 @@ void BLEInterface::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo void BLEInterface::processRxByte(uint8_t b) { if (b == FRAME_START) { if (_rxActive && !_rxFrame.empty()) { - // Complete frame received — queue it - _incomingFrames.push_back(std::move(_rxFrame)); + // Complete frame received — queue it (mutex: called from NimBLE task) + if (_framesMutex && xSemaphoreTake(_framesMutex, pdMS_TO_TICKS(5)) == pdTRUE) { + _incomingFrames.push_back(std::move(_rxFrame)); + xSemaphoreGive(_framesMutex); + } _rxFrame.clear(); } _rxActive = true; @@ -114,14 +118,18 @@ void BLEInterface::processRxByte(uint8_t b) { void BLEInterface::loop() { if (!_active) return; - // Process queued incoming frames - while (!_incomingFrames.empty()) { - auto frame = std::move(_incomingFrames.front()); - _incomingFrames.erase(_incomingFrames.begin()); + // Process queued incoming frames (mutex: _incomingFrames shared with NimBLE task) + if (_framesMutex && xSemaphoreTake(_framesMutex, pdMS_TO_TICKS(2)) == pdTRUE) { + // Swap to local to minimize lock hold time + std::vector> localFrames; + localFrames.swap(_incomingFrames); + xSemaphoreGive(_framesMutex); - if (!frame.empty()) { - RNS::Bytes data(frame.data(), frame.size()); - handle_incoming(data); + for (auto& frame : localFrames) { + if (!frame.empty()) { + RNS::Bytes data(frame.data(), frame.size()); + handle_incoming(data); + } } } } diff --git a/src/transport/BLEInterface.h b/src/transport/BLEInterface.h index 5746914..25b0873 100644 --- a/src/transport/BLEInterface.h +++ b/src/transport/BLEInterface.h @@ -62,7 +62,9 @@ private: bool _rxActive = false; // Queued incoming frames (written by BLE callback, consumed by loop) + // Protected by mutex: onWrite() runs in NimBLE host task, loop() on main task std::vector> _incomingFrames; + SemaphoreHandle_t _framesMutex = nullptr; class BLESideband* _sideband = nullptr; diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index 2199d2f..59e4913 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -111,6 +111,10 @@ void LoRaInterface::loop() { rssi, status, chipMode); } + // Only check for packets when DIO1 interrupt signals one is available + if (!_radio->packetAvailable) return; + _radio->packetAvailable = false; + int packetSize = _radio->parsePacket(); if (packetSize > RNODE_HEADER_L) { // parsePacket() already read the FIFO into packetBuffer() — copy from there diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index b28ae32..5e2aa30 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -8,10 +8,16 @@ TCPClientInterface::TCPClientInterface(const char* host, uint16_t port, const ch _OUT = true; _bitrate = 1000000; _HW_MTU = 500; + _rxBuffer = (uint8_t*)ps_malloc(RX_BUFFER_SIZE); + if (!_rxBuffer) _rxBuffer = (uint8_t*)malloc(RX_BUFFER_SIZE); + _txBuffer = (uint8_t*)ps_malloc(TX_BUFFER_SIZE); + if (!_txBuffer) _txBuffer = (uint8_t*)malloc(TX_BUFFER_SIZE); } TCPClientInterface::~TCPClientInterface() { stop(); + if (_rxBuffer) { free(_rxBuffer); _rxBuffer = nullptr; } + if (_txBuffer) { free(_txBuffer); _txBuffer = nullptr; } } bool TCPClientInterface::start() { @@ -38,10 +44,12 @@ void TCPClientInterface::tryConnect() { _escaped = false; _rxPos = 0; _hubTransportIdKnown = false; + _pendingAnnounces.clear(); _lastRxTime = millis(); // Set TCP write timeout to prevent blocking on half-open sockets _client.setTimeout(5); // 5 second write timeout + _client.setNoDelay(true); // Disable Nagle — send immediately Serial.printf("[TCP] Connected to %s:%d\n", _host.c_str(), _port); } else { @@ -72,26 +80,34 @@ void TCPClientInterface::loop() { return; // Will reconnect on next loop iteration } - // Drain multiple incoming frames per loop (up to 5, time-boxed) + // Drain multiple incoming frames per loop (up to 15, time-boxed) unsigned long tcpStart = millis(); - for (int i = 0; i < 5 && _client.available() && (millis() - tcpStart < TCP_LOOP_BUDGET_MS); i++) { + for (int i = 0; i < 15 && _client.available() && (millis() - tcpStart < TCP_LOOP_BUDGET_MS); i++) { unsigned long rxStart = millis(); int len = readFrame(); if (len > 0) { _lastRxTime = millis(); + _hubRxCount++; - // Learn hub transport_id from incoming Header2 packets + // Learn hub transport_id from incoming Header2 packets (once per connection) if (len >= 35) { uint8_t flags = _rxBuffer[0]; uint8_t header_type = (flags >> 6) & 0x01; - if (header_type == 1) { // Header2 - if (!_hubTransportIdKnown) { - char hex[33]; - for (int j = 0; j < 16; j++) sprintf(hex + j*2, "%02x", _rxBuffer[j+2]); - Serial.printf("[TCP] Learned hub transport_id: %.8s\n", hex); - } + if (header_type == 1 && !_hubTransportIdKnown) { memcpy(_hubTransportId, _rxBuffer + 2, 16); _hubTransportIdKnown = true; + char hex[33]; + for (int j = 0; j < 16; j++) sprintf(hex + j*2, "%02x", _hubTransportId[j]); + Serial.printf("[TCP] Learned hub transport_id: %.8s\n", hex); + + // Flush any announces that were queued before hub ID was known + for (auto& pending : _pendingAnnounces) { + sendFrame(pending.data(), pending.size()); + InterfaceImpl::handle_outgoing(pending); + Serial.printf("[TCP] TX %d bytes (flushed pending announce) to %s:%d\n", + (int)pending.size(), _host.c_str(), _port); + } + _pendingAnnounces.clear(); } } @@ -129,7 +145,12 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { // Build Header2 packet: flags(1) + hops(1) + transport_id(16) + original[2:] size_t new_len = data.size() + 16; - uint8_t wrapped[1024]; + if (new_len > RX_BUFFER_SIZE) { + Serial.printf("[TCP] H1->H2 wrap too large (%d bytes), dropping\n", (int)new_len); + _txDropCount++; + return; + } + uint8_t wrapped[RX_BUFFER_SIZE]; wrapped[0] = new_flags; wrapped[1] = data.data()[1]; // hops memcpy(wrapped + 2, _hubTransportId, 16); // transport_id @@ -144,7 +165,7 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { else if (data.size() >= 35 && memcmp(data.data() + 2, _hubTransportId, 16) != 0) { // Header2 with wrong transport_id → fix it // Transport::outbound() may have used _received_from=destination_hash - uint8_t fixed[1024]; + uint8_t fixed[RX_BUFFER_SIZE]; memcpy(fixed, data.data(), data.size()); memcpy(fixed + 2, _hubTransportId, 16); @@ -157,11 +178,21 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { } } - // Passthrough: announces, correct Header2, pre-hub-discovery - sendFrame(data.data(), data.size()); + // Queue announces until hub transport_id is learned if (!_hubTransportIdKnown) { + uint8_t flags = data.size() >= 1 ? data.data()[0] : 0; + uint8_t packet_type = flags & 0x03; + if (packet_type == 0x01 && _pendingAnnounces.size() < 3) { + _pendingAnnounces.push_back(data); + Serial.printf("[TCP] TX %d bytes (queued announce, hub ID pending) to %s:%d\n", + (int)data.size(), _host.c_str(), _port); + return; + } + sendFrame(data.data(), data.size()); Serial.printf("[TCP] TX %d bytes (no hub ID yet) to %s:%d\n", (int)data.size(), _host.c_str(), _port); } else { + // Passthrough: announces, correct Header2 + sendFrame(data.data(), data.size()); Serial.printf("[TCP] TX %d bytes (passthrough) to %s:%d\n", (int)data.size(), _host.c_str(), _port); } InterfaceImpl::handle_outgoing(data); @@ -170,20 +201,27 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { // HDLC-like framing: [0x7E] [escaped data] [0x7E] // Buffered write — single syscall instead of per-byte writes void TCPClientInterface::sendFrame(const uint8_t* data, size_t len) { - uint8_t buf[1024]; // Max frame size (matches _rxBuffer) + if (!_txBuffer) return; + // Worst case: every byte escapes (2x) + 2 delimiters + size_t maxFrameLen = len * 2 + 2; + if (maxFrameLen > TX_BUFFER_SIZE) { + Serial.printf("[TCP] TX frame too large (%d bytes), dropping\n", (int)len); + _txDropCount++; + return; + } size_t pos = 0; - buf[pos++] = FRAME_START; - for (size_t i = 0; i < len && pos < sizeof(buf) - 2; i++) { + _txBuffer[pos++] = FRAME_START; + for (size_t i = 0; i < len && pos < TX_BUFFER_SIZE - 2; i++) { if (data[i] == FRAME_START || data[i] == FRAME_ESC) { - buf[pos++] = FRAME_ESC; - buf[pos++] = data[i] ^ FRAME_XOR; + _txBuffer[pos++] = FRAME_ESC; + _txBuffer[pos++] = data[i] ^ FRAME_XOR; } else { - buf[pos++] = data[i]; + _txBuffer[pos++] = data[i]; } } - buf[pos++] = FRAME_START; - _client.write(buf, pos); - _client.flush(); + _txBuffer[pos++] = FRAME_START; + _client.write(_txBuffer, pos); + // No flush() — TCP_NODELAY sends immediately without Nagle delay } int TCPClientInterface::readFrame() { @@ -194,7 +232,7 @@ int TCPClientInterface::readFrame() { int bytesRead = 0; constexpr int MAX_BYTES_PER_CALL = 1024; - while (_client.available() && _rxPos < sizeof(_rxBuffer) && bytesRead < MAX_BYTES_PER_CALL) { + while (_client.available() && _rxPos < RX_BUFFER_SIZE && bytesRead < MAX_BYTES_PER_CALL) { uint8_t b = _client.read(); bytesRead++; @@ -229,7 +267,7 @@ int TCPClientInterface::readFrame() { } // Buffer overflow protection - if (_rxPos >= sizeof(_rxBuffer)) { + if (_rxPos >= RX_BUFFER_SIZE) { Serial.printf("[TCP] Frame too large (%d bytes), dropping\n", (int)_rxPos); _inFrame = false; _escaped = false; diff --git a/src/transport/TCPClientInterface.h b/src/transport/TCPClientInterface.h index 9145e07..7cef646 100644 --- a/src/transport/TCPClientInterface.h +++ b/src/transport/TCPClientInterface.h @@ -3,6 +3,7 @@ #include #include #include +#include class TCPClientInterface : public RNS::InterfaceImpl { public: @@ -34,12 +35,22 @@ private: uint16_t _port; unsigned long _lastAttempt = 0; unsigned long _lastRxTime = 0; - uint8_t _rxBuffer[1024]; + uint8_t* _rxBuffer = nullptr; + uint8_t* _txBuffer = nullptr; // PSRAM-allocated send frame buffer + static constexpr size_t RX_BUFFER_SIZE = 2048; + static constexpr size_t TX_BUFFER_SIZE = RX_BUFFER_SIZE * 2 + 2; // Hub transport_id for Header2 wrapping (learned from incoming Header2 packets) uint8_t _hubTransportId[16] = {}; bool _hubTransportIdKnown = false; + // Telemetry counters + unsigned long _hubRxCount = 0; + unsigned long _txDropCount = 0; + + // Pending announces: buffered until hub transport_id is learned + std::vector _pendingAnnounces; + // Persistent HDLC frame reassembly state (survives across loop() calls) bool _inFrame = false; bool _escaped = false; @@ -49,7 +60,7 @@ private: static constexpr uint8_t FRAME_ESC = 0x7D; static constexpr uint8_t FRAME_XOR = 0x20; static constexpr unsigned long TCP_KEEPALIVE_TIMEOUT_MS = 300000; // 5 min - static constexpr unsigned long TCP_LOOP_BUDGET_MS = 12; + static constexpr unsigned long TCP_LOOP_BUDGET_MS = 25; public: unsigned long lastRxTime() const { return _lastRxTime; } diff --git a/src/transport/WiFiInterface.cpp b/src/transport/WiFiInterface.cpp index 72eae44..1e11f29 100644 --- a/src/transport/WiFiInterface.cpp +++ b/src/transport/WiFiInterface.cpp @@ -192,6 +192,45 @@ std::vector WiFiInterface::scanNetworks(int maxResult return results; } +void WiFiInterface::startAsyncScan() { + WiFi.scanDelete(); + WiFi.scanNetworks(true, false, false, 300, 0); // async=true + Serial.println("[WIFI] Async scan started"); +} + +bool WiFiInterface::isScanComplete() { + int result = WiFi.scanComplete(); + return result != WIFI_SCAN_RUNNING; +} + +std::vector WiFiInterface::getScanResults(int maxResults) { + std::vector results; + int n = WiFi.scanComplete(); + if (n <= 0) return results; + + for (int i = 0; i < n; i++) { + String ssid = WiFi.SSID(i); + if (ssid.isEmpty()) continue; + int rssi = WiFi.RSSI(i); + bool enc = WiFi.encryptionType(i) != WIFI_AUTH_OPEN; + bool found = false; + for (auto& r : results) { + if (r.ssid == ssid) { + if (rssi > r.rssi) r.rssi = rssi; + found = true; + break; + } + } + if (!found) results.push_back({ssid, rssi, enc}); + } + WiFi.scanDelete(); + std::sort(results.begin(), results.end(), + [](const ScanResult& a, const ScanResult& b) { return a.rssi > b.rssi; }); + if ((int)results.size() > maxResults) results.resize(maxResults); + Serial.printf("[WIFI] Async scan: %d networks found\n", (int)results.size()); + return results; +} + // HDLC-like framing: [0x7E] [escaped data] [0x7E] void WiFiInterface::sendFrame(WiFiClient& client, const uint8_t* data, size_t len) { client.write(FRAME_START); diff --git a/src/transport/WiFiInterface.h b/src/transport/WiFiInterface.h index 3fab530..22b1248 100644 --- a/src/transport/WiFiInterface.h +++ b/src/transport/WiFiInterface.h @@ -32,13 +32,16 @@ public: void setSTACredentials(const char* ssid, const char* password); bool isSTAConnected() const; - // WiFi scanner + // WiFi scanner (async: call startScan, poll with getScanResults) struct ScanResult { String ssid; int rssi; bool encrypted; }; static std::vector scanNetworks(int maxResults = 15); + static void startAsyncScan(); + static bool isScanComplete(); + static std::vector getScanResults(int maxResults = 15); protected: virtual void send_outgoing(const RNS::Bytes& data) override; diff --git a/src/ui/LvTabBar.cpp b/src/ui/LvTabBar.cpp index 790d127..414e47e 100644 --- a/src/ui/LvTabBar.cpp +++ b/src/ui/LvTabBar.cpp @@ -60,22 +60,28 @@ void LvTabBar::cycleTab(int direction) { void LvTabBar::setUnreadCount(int tab, int count) { if (tab < 0 || tab >= TAB_COUNT) return; + if (_unread[tab] == count) return; // No change _unread[tab] = count; - refreshTabs(); + refreshTab(tab); +} + +void LvTabBar::refreshTab(int idx) { + if (idx < 0 || idx >= TAB_COUNT || !_tabs[idx]) return; + bool active = (idx == _activeTab); + lv_obj_set_style_text_color(_tabs[idx], + lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); + + char buf[24]; + if (_unread[idx] > 0) { + snprintf(buf, sizeof(buf), "%s(%d)", TAB_NAMES[idx], _unread[idx]); + } else { + snprintf(buf, sizeof(buf), "%s", TAB_NAMES[idx]); + } + lv_label_set_text(_tabs[idx], buf); } void LvTabBar::refreshTabs() { for (int i = 0; i < TAB_COUNT; i++) { - bool active = (i == _activeTab); - lv_obj_set_style_text_color(_tabs[i], - lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); - - char buf[24]; - if (_unread[i] > 0) { - snprintf(buf, sizeof(buf), "%s(%d)", TAB_NAMES[i], _unread[i]); - } else { - snprintf(buf, sizeof(buf), "%s", TAB_NAMES[i]); - } - lv_label_set_text(_tabs[i], buf); + refreshTab(i); } } diff --git a/src/ui/LvTabBar.h b/src/ui/LvTabBar.h index 7e4af68..3bea16c 100644 --- a/src/ui/LvTabBar.h +++ b/src/ui/LvTabBar.h @@ -21,6 +21,7 @@ public: private: void refreshTabs(); + void refreshTab(int idx); lv_obj_t* _bar = nullptr; lv_obj_t* _tabs[TAB_COUNT] = {}; diff --git a/src/ui/screens/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp index b784ad7..be61c72 100644 --- a/src/ui/screens/LvContactsScreen.cpp +++ b/src/ui/screens/LvContactsScreen.cpp @@ -28,6 +28,31 @@ void LvContactsScreen::createUI(lv_obj_t* parent) { lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + // Pre-allocate row pool + const lv_font_t* font = &lv_font_montserrat_14; + for (int i = 0; i < ROW_POOL_SIZE; i++) { + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 28); + lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(lbl, ""); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + + _poolRows[i] = row; + _poolNameLabels[i] = lbl; + } + _lastContactCount = -1; rebuildList(); } @@ -35,6 +60,7 @@ void LvContactsScreen::createUI(lv_obj_t* parent) { void LvContactsScreen::onEnter() { _lastContactCount = -1; _selectedIdx = 0; + _viewportStart = 0; rebuildList(); } @@ -48,20 +74,13 @@ void LvContactsScreen::refreshUI() { } void LvContactsScreen::updateSelection(int oldIdx, int newIdx) { - if (oldIdx >= 0 && oldIdx < (int)_rows.size()) { - lv_obj_set_style_bg_color(_rows[oldIdx], lv_color_hex(Theme::BG), 0); - } - if (newIdx >= 0 && newIdx < (int)_rows.size()) { - lv_obj_set_style_bg_color(_rows[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); - lv_obj_scroll_to_view(_rows[newIdx], LV_ANIM_OFF); - } + syncVisibleRows(); } void LvContactsScreen::rebuildList() { if (!_am || !_list) return; _rows.clear(); _contactIndices.clear(); - lv_obj_clean(_list); const auto& nodes = _am->nodes(); for (int i = 0; i < (int)nodes.size(); i++) { @@ -73,6 +92,7 @@ void LvContactsScreen::rebuildList() { if (count == 0) { lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); + for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); return; } @@ -82,32 +102,47 @@ void LvContactsScreen::rebuildList() { if (_selectedIdx >= count) _selectedIdx = count - 1; if (_selectedIdx < 0) _selectedIdx = 0; - const lv_font_t* font = &lv_font_montserrat_14; + syncVisibleRows(); +} - for (int i = 0; i < count; i++) { - const auto& node = nodes[_contactIndices[i]]; - bool selected = (i == _selectedIdx); +void LvContactsScreen::syncVisibleRows() { + if (!_am || !_list) return; + int count = (int)_contactIndices.size(); + const auto& nodes = _am->nodes(); - lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 28); - lv_obj_set_style_bg_color(row, lv_color_hex( - selected ? Theme::SELECTION_BG : Theme::BG), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); - lv_obj_set_style_border_width(row, 1, 0); - lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_set_style_radius(row, 0, 0); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + if (count == 0) { + for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + return; + } - // Display name only - lv_obj_t* lbl = lv_label_create(row); - lv_obj_set_style_text_font(lbl, font, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(lbl, node.name.c_str()); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + // Compute viewport centered on selection + int halfPool = ROW_POOL_SIZE / 2; + _viewportStart = _selectedIdx - halfPool; + if (_viewportStart < 0) _viewportStart = 0; + if (_viewportStart + ROW_POOL_SIZE > count) { + _viewportStart = count - ROW_POOL_SIZE; + if (_viewportStart < 0) _viewportStart = 0; + } - _rows.push_back(row); + for (int i = 0; i < ROW_POOL_SIZE; i++) { + int contactIdx = _viewportStart + i; + if (contactIdx >= count) { + lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + continue; + } + + lv_obj_clear_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + int nodeIdx = _contactIndices[contactIdx]; + if (nodeIdx < 0 || nodeIdx >= (int)nodes.size()) { + lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + continue; + } + const auto& node = nodes[nodeIdx]; + bool isSelected = (contactIdx == _selectedIdx); + + lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex( + isSelected ? Theme::SELECTION_BG : Theme::BG), 0); + lv_label_set_text(_poolNameLabels[i], node.name.c_str()); } } @@ -148,17 +183,15 @@ bool LvContactsScreen::handleKey(const KeyEvent& event) { if (event.up) { if (_selectedIdx > 0) { - int prev = _selectedIdx; _selectedIdx--; - updateSelection(prev, _selectedIdx); + syncVisibleRows(); } return true; } if (event.down) { if (_selectedIdx < count - 1) { - int prev = _selectedIdx; _selectedIdx++; - updateSelection(prev, _selectedIdx); + syncVisibleRows(); } return true; } diff --git a/src/ui/screens/LvContactsScreen.h b/src/ui/screens/LvContactsScreen.h index 852c45c..599098f 100644 --- a/src/ui/screens/LvContactsScreen.h +++ b/src/ui/screens/LvContactsScreen.h @@ -25,6 +25,7 @@ public: private: void rebuildList(); + void syncVisibleRows(); void updateSelection(int oldIdx, int newIdx); AnnounceManager* _am = nullptr; @@ -33,9 +34,15 @@ private: bool _confirmDelete = false; int _lastContactCount = -1; int _selectedIdx = 0; + int _viewportStart = 0; std::vector _contactIndices; // Maps row -> node index in _am->nodes() lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; std::vector _rows; + + // Object pool + static constexpr int ROW_POOL_SIZE = 10; + lv_obj_t* _poolRows[ROW_POOL_SIZE] = {}; + lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {}; }; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index c352f55..946e693 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -111,14 +111,14 @@ void LvMessageView::onEnter() { _lxmf->markRead(_peerHex); // Update unread badge on Messages tab if (_ui) _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); - // Register status callback — update cached message status in-place + // Register status callback — partial update without full rebuild std::string peer = _peerHex; _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, LXMFStatus newStatus) { if (peerHex != peer) return; for (int i = _cachedMsgs.size() - 1; i >= 0; i--) { if (!_cachedMsgs[i].incoming && _cachedMsgs[i].status == LXMFStatus::QUEUED) { _cachedMsgs[i].status = newStatus; - rebuildMessages(); + updateMessageStatus(i, newStatus); break; } } @@ -174,6 +174,8 @@ void LvMessageView::rebuildMessages() { _lastMsgCount = (int)_cachedMsgs.size(); _lastRefreshMs = millis(); lv_obj_clean(_msgScroll); + _statusLabels.clear(); + _textLabels.clear(); const lv_font_t* font = &lv_font_montserrat_12; int maxBubbleW = Theme::CONTENT_W * 3 / 4; @@ -229,7 +231,7 @@ void LvMessageView::rebuildMessages() { lv_obj_set_width(lbl, maxBubbleW - 14); lv_label_set_text(lbl, msg.content.c_str()); - // Status indicator for outgoing + // Status indicator for outgoing (tracked for partial updates) if (!msg.incoming) { const char* ind = "~"; uint32_t indColor = Theme::MUTED; @@ -243,6 +245,11 @@ void LvMessageView::rebuildMessages() { lv_obj_set_style_text_color(statusLbl, lv_color_hex(indColor), 0); lv_label_set_text(statusLbl, ind); lv_obj_align(statusLbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + _statusLabels.push_back(statusLbl); + _textLabels.push_back(lbl); + } else { + _statusLabels.push_back(nullptr); + _textLabels.push_back(nullptr); } // Timestamp below bubble @@ -269,6 +276,35 @@ void LvMessageView::rebuildMessages() { lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF); } +void LvMessageView::updateMessageStatus(int msgIdx, LXMFStatus status) { + if (msgIdx < 0 || msgIdx >= (int)_statusLabels.size()) return; + lv_obj_t* statusLbl = _statusLabels[msgIdx]; + lv_obj_t* textLbl = _textLabels[msgIdx]; + if (!statusLbl) return; // Incoming message, no status label + + // Update status indicator + const char* ind = "~"; + uint32_t indColor = Theme::MUTED; + if (status == LXMFStatus::SENT || status == LXMFStatus::DELIVERED) { + ind = "*"; indColor = Theme::ACCENT; + } else if (status == LXMFStatus::FAILED) { + ind = "!"; indColor = Theme::ERROR_CLR; + } + lv_obj_set_style_text_color(statusLbl, lv_color_hex(indColor), 0); + lv_label_set_text(statusLbl, ind); + + // Update text color to match status + if (textLbl) { + uint32_t textColor = Theme::PRIMARY; + if (status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING) { + textColor = Theme::WARNING_CLR; + } else if (status == LXMFStatus::FAILED) { + textColor = Theme::ERROR_CLR; + } + lv_obj_set_style_text_color(textLbl, lv_color_hex(textColor), 0); + } +} + void LvMessageView::sendCurrentMessage() { if (!_lxmf || _peerHex.empty() || _inputText.empty()) return; diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h index e8c256d..a8ba5f5 100644 --- a/src/ui/screens/LvMessageView.h +++ b/src/ui/screens/LvMessageView.h @@ -43,6 +43,8 @@ private: unsigned long _lastRefreshMs = 0; std::vector _cachedMsgs; + void updateMessageStatus(int msgIdx, LXMFStatus status); + // LVGL widgets lv_obj_t* _header = nullptr; lv_obj_t* _lblHeader = nullptr; @@ -51,5 +53,9 @@ private: lv_obj_t* _textarea = nullptr; lv_obj_t* _btnSend = nullptr; + // Per-message status labels for partial updates (avoids full rebuild) + std::vector _statusLabels; + std::vector _textLabels; + static constexpr unsigned long REFRESH_INTERVAL_MS = 2000; // Check for new messages every 2s }; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index f09216c..041e3d7 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -32,6 +32,62 @@ void LvMessagesScreen::createUI(lv_obj_t* parent) { lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + // Pre-allocate row pool (following LvNodesScreen pattern) + const lv_font_t* nameFont = &lv_font_montserrat_14; + const lv_font_t* smallFont = &lv_font_montserrat_12; + + for (int i = 0; i < ROW_POOL_SIZE; i++) { + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 38); + lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); + + // Unread dot + lv_obj_t* dot = lv_obj_create(row); + lv_obj_set_size(dot, 6, 6); + lv_obj_set_style_radius(dot, 3, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + lv_obj_set_pos(dot, 5, 8); + lv_obj_add_flag(dot, LV_OBJ_FLAG_HIDDEN); + + // Name label (line 1) + lv_obj_t* nameLbl = lv_label_create(row); + lv_obj_set_style_text_font(nameLbl, nameFont, 0); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(nameLbl, ""); + lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 14, 2); + + // Time label (line 1, right) + lv_obj_t* timeLbl = lv_label_create(row); + lv_obj_set_style_text_font(timeLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(timeLbl, ""); + lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 4); + + // Preview label (line 2) + lv_obj_t* prevLbl = lv_label_create(row); + lv_obj_set_style_text_font(prevLbl, smallFont, 0); + lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(prevLbl, ""); + lv_obj_align(prevLbl, LV_ALIGN_BOTTOM_LEFT, 14, -4); + + _poolRows[i] = row; + _poolDots[i] = dot; + _poolNameLabels[i] = nameLbl; + _poolTimeLabels[i] = timeLbl; + _poolPreviewLabels[i] = prevLbl; + } + _lastConvCount = -1; rebuildList(); } @@ -39,6 +95,7 @@ void LvMessagesScreen::createUI(lv_obj_t* parent) { void LvMessagesScreen::onEnter() { _lastConvCount = -1; _selectedIdx = 0; + _viewportStart = 0; rebuildList(); } @@ -51,15 +108,9 @@ void LvMessagesScreen::refreshUI() { } } -// Update only the selection highlight without rebuilding widgets +// Update only the selection highlight via pool sync void LvMessagesScreen::updateSelection(int oldIdx, int newIdx) { - if (oldIdx >= 0 && oldIdx < (int)_rows.size()) { - lv_obj_set_style_bg_color(_rows[oldIdx], lv_color_hex(Theme::BG), 0); - } - if (newIdx >= 0 && newIdx < (int)_rows.size()) { - lv_obj_set_style_bg_color(_rows[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); - lv_obj_scroll_to_view(_rows[newIdx], LV_ANIM_OFF); - } + syncVisibleRows(); } void LvMessagesScreen::rebuildList() { @@ -69,30 +120,21 @@ void LvMessagesScreen::rebuildList() { int count = (int)convs.size(); _lastConvCount = count; _lastUnreadTotal = _lxmf->unreadCount(); - _rows.clear(); _sortedPeers.clear(); - lv_obj_clean(_list); + _sortedConvs.clear(); if (count == 0) { lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); + for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); return; } lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); - // Build conversation info for sorting by most recent - struct ConvInfo { - std::string peerHex; - double lastTs = 0; - std::string preview; - bool hasUnread = false; - }; - - std::vector sorted; - sorted.reserve(count); - + // Build sorted conversation info + _sortedConvs.reserve(count); for (int i = 0; i < count; i++) { ConvInfo ci; ci.peerHex = convs[i]; @@ -102,85 +144,85 @@ void LvMessagesScreen::rebuildList() { ci.preview = s->lastPreview; ci.hasUnread = s->unreadCount > 0; } - sorted.push_back(ci); + // Resolve display name + std::string peerName; + if (_am) peerName = _am->lookupName(ci.peerHex); + ci.displayName = !peerName.empty() ? peerName.substr(0, 15) : ci.peerHex.substr(0, 12); + _sortedConvs.push_back(ci); } - std::sort(sorted.begin(), sorted.end(), [](const ConvInfo& a, const ConvInfo& b) { + std::sort(_sortedConvs.begin(), _sortedConvs.end(), [](const ConvInfo& a, const ConvInfo& b) { return a.lastTs > b.lastTs; }); - for (auto& ci : sorted) _sortedPeers.push_back(ci.peerHex); + for (auto& ci : _sortedConvs) _sortedPeers.push_back(ci.peerHex); if (_selectedIdx >= count) _selectedIdx = count - 1; if (_selectedIdx < 0) _selectedIdx = 0; - const lv_font_t* nameFont = &lv_font_montserrat_14; - const lv_font_t* smallFont = &lv_font_montserrat_12; + syncVisibleRows(); +} - for (int i = 0; i < (int)sorted.size(); i++) { - const auto& ci = sorted[i]; +void LvMessagesScreen::syncVisibleRows() { + if (!_list) return; + int count = (int)_sortedConvs.size(); - lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 38); - lv_obj_set_style_bg_color(row, lv_color_hex( - i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); - lv_obj_set_style_border_width(row, 1, 0); - lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_set_style_radius(row, 0, 0); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + if (count == 0) { + for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + return; + } - // Green dot for unread - if (ci.hasUnread) { - lv_obj_t* dot = lv_obj_create(row); - lv_obj_set_size(dot, 6, 6); - lv_obj_set_style_radius(dot, 3, 0); - lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_set_style_pad_all(dot, 0, 0); - lv_obj_set_pos(dot, 5, 8); + // Compute viewport centered on selection + int halfPool = ROW_POOL_SIZE / 2; + _viewportStart = _selectedIdx - halfPool; + if (_viewportStart < 0) _viewportStart = 0; + if (_viewportStart + ROW_POOL_SIZE > count) { + _viewportStart = count - ROW_POOL_SIZE; + if (_viewportStart < 0) _viewportStart = 0; + } + + for (int i = 0; i < ROW_POOL_SIZE; i++) { + int convIdx = _viewportStart + i; + if (convIdx >= count) { + lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + continue; } - // Peer name (line 1) - std::string peerName; - if (_am) peerName = _am->lookupName(ci.peerHex); - std::string displayName = !peerName.empty() ? - peerName.substr(0, 15) : ci.peerHex.substr(0, 12); + lv_obj_clear_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN); + const auto& ci = _sortedConvs[convIdx]; + bool isSelected = (convIdx == _selectedIdx); - lv_obj_t* nameLbl = lv_label_create(row); - lv_obj_set_style_text_font(nameLbl, nameFont, 0); - lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0); - lv_label_set_text(nameLbl, displayName.c_str()); - lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 14, 2); + // Selection highlight + lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex( + isSelected ? Theme::SELECTION_BG : Theme::BG), 0); - // Timestamp (line 1, right) + // Unread dot + if (ci.hasUnread) { + lv_obj_clear_flag(_poolDots[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(_poolDots[i], LV_OBJ_FLAG_HIDDEN); + } + + // Name + lv_label_set_text(_poolNameLabels[i], ci.displayName.c_str()); + + // Time if (ci.lastTs > 1700000000) { time_t t = (time_t)ci.lastTs; struct tm* tm = localtime(&t); if (tm) { char timeBuf[8]; snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min); - lv_obj_t* timeLbl = lv_label_create(row); - lv_obj_set_style_text_font(timeLbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); - lv_label_set_text(timeLbl, timeBuf); - lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 4); + lv_label_set_text(_poolTimeLabels[i], timeBuf); + } else { + lv_label_set_text(_poolTimeLabels[i], ""); } + } else { + lv_label_set_text(_poolTimeLabels[i], ""); } - // Preview (line 2) - if (!ci.preview.empty()) { - lv_obj_t* prevLbl = lv_label_create(row); - lv_obj_set_style_text_font(prevLbl, smallFont, 0); - lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0); - lv_label_set_text(prevLbl, ci.preview.c_str()); - lv_obj_align(prevLbl, LV_ALIGN_BOTTOM_LEFT, 14, -4); - } - - _rows.push_back(row); + // Preview + lv_label_set_text(_poolPreviewLabels[i], ci.preview.c_str()); } } @@ -276,7 +318,7 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { if (_selectedIdx > 0) { int prev = _selectedIdx; _selectedIdx--; - updateSelection(prev, _selectedIdx); + syncVisibleRows(); } return true; } @@ -284,7 +326,7 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { if (_selectedIdx < count - 1) { int prev = _selectedIdx; _selectedIdx++; - updateSelection(prev, _selectedIdx); + syncVisibleRows(); } return true; } diff --git a/src/ui/screens/LvMessagesScreen.h b/src/ui/screens/LvMessagesScreen.h index b437902..d75c2c7 100644 --- a/src/ui/screens/LvMessagesScreen.h +++ b/src/ui/screens/LvMessagesScreen.h @@ -27,6 +27,7 @@ public: private: void rebuildList(); + void syncVisibleRows(); void updateSelection(int oldIdx, int newIdx); LXMFManager* _lxmf = nullptr; @@ -36,6 +37,7 @@ private: int _lastConvCount = -1; int _lastUnreadTotal = 0; int _selectedIdx = 0; + int _viewportStart = 0; std::vector _sortedPeers; enum LongPressState { LP_NONE, LP_MENU, LP_CONFIRM_DELETE }; LongPressState _lpState = LP_NONE; @@ -44,4 +46,22 @@ private: lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; std::vector _rows; + + // Object pool for conversation rows + static constexpr int ROW_POOL_SIZE = 10; + lv_obj_t* _poolRows[ROW_POOL_SIZE] = {}; + lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {}; + lv_obj_t* _poolPreviewLabels[ROW_POOL_SIZE] = {}; + lv_obj_t* _poolTimeLabels[ROW_POOL_SIZE] = {}; + lv_obj_t* _poolDots[ROW_POOL_SIZE] = {}; + + // Cached sorted conversation data + struct ConvInfo { + std::string peerHex; + double lastTs = 0; + std::string preview; + std::string displayName; + bool hasUnread = false; + }; + std::vector _sortedConvs; }; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index f7da827..f7962a4 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -165,6 +165,39 @@ void LvSettingsScreen::buildItems() { _items.push_back(newId); idx++; } + { + SettingItem devModeItem; + devModeItem.label = "Developer Mode"; + devModeItem.type = SettingType::ACTION; + devModeItem.formatter = [&s](int) { return s.devMode ? String("ON") : String("OFF"); }; + devModeItem.action = [this, &s]() { + if (s.devMode) { + // Already on — just turn it off + s.devMode = false; + _confirmingDevMode = false; + applyAndSave(); + buildItems(); + enterCategory(_categoryIdx); + if (_ui) _ui->lvStatusBar().showToast("Developer mode OFF", 1200); + return; + } + if (!_confirmingDevMode) { + _confirmingDevMode = true; + if (_ui) _ui->lvStatusBar().showToast("WARNING: KNOW YOUR LAWS! Enter=Enable", 5000); + rebuildItemList(); + return; + } + // Confirmed + _confirmingDevMode = false; + s.devMode = true; + applyAndSave(); + buildItems(); + enterCategory(_categoryIdx); + if (_ui) _ui->lvStatusBar().showToast("Developer mode ON", 1200); + }; + _items.push_back(devModeItem); + idx++; + } _categories.push_back({"Device", devStart, idx - devStart, [&s]() { return s.displayName.isEmpty() ? String("(unnamed)") : s.displayName; }}); @@ -202,48 +235,47 @@ void LvSettingsScreen::buildItems() { _items.push_back(presetItem); idx++; } - _items.push_back({"Frequency", SettingType::ENUM_CHOICE, - [&s]() { - if (s.loraFrequency <= 868000000) return 0; - if (s.loraFrequency <= 906000000) return 1; - if (s.loraFrequency <= 915000000) return 2; - return 3; - }, - [&s](int v) { - static const uint32_t freqs[] = {868000000, 906000000, 915000000, 923000000}; - s.loraFrequency = freqs[constrain(v, 0, 3)]; - }, - nullptr, 0, 3, 1, {"868 MHz", "906 MHz", "915 MHz", "923 MHz"}}); - idx++; - _items.push_back({"TX Power", SettingType::INTEGER, - [&s]() { return s.loraTxPower; }, [&s](int v) { s.loraTxPower = v; }, - [](int v) { return String(v) + " dBm"; }, -9, 22, 1}); - idx++; - _items.push_back({"Spread Factor", SettingType::INTEGER, - [&s]() { return s.loraSF; }, [&s](int v) { s.loraSF = v; }, - [](int v) { return String("SF") + String(v); }, 5, 12, 1}); - idx++; - _items.push_back({"Bandwidth", SettingType::ENUM_CHOICE, - [&s]() { - if (s.loraBW <= 62500) return 0; - if (s.loraBW <= 125000) return 1; - if (s.loraBW <= 250000) return 2; - return 3; - }, - [&s](int v) { - static const uint32_t bws[] = {62500, 125000, 250000, 500000}; - s.loraBW = bws[constrain(v, 0, 3)]; - }, - nullptr, 0, 3, 1, {"62.5k", "125k", "250k", "500k"}}); - idx++; - _items.push_back({"Coding Rate", SettingType::INTEGER, - [&s]() { return s.loraCR; }, [&s](int v) { s.loraCR = v; }, - [](int v) { return String("4/") + String(v); }, 5, 8, 1}); - idx++; - _items.push_back({"Preamble", SettingType::INTEGER, - [&s]() { return (int)s.loraPreamble; }, [&s](int v) { s.loraPreamble = v; }, - [](int v) { return String(v); }, 6, 65, 1}); - idx++; + // Custom radio parameters — only visible in Developer Mode + if (s.devMode) { + _items.push_back({"Frequency", SettingType::INTEGER, + [&s]() { return (int)(s.loraFrequency / 1000); }, + [&s](int v) { s.loraFrequency = (uint32_t)v * 1000; }, + [](int v) -> String { + char buf[16]; snprintf(buf, sizeof(buf), "%d.%03d MHz", v / 1000, v % 1000); + return String(buf); + }, + 137000, 1020000, 125}); + idx++; + _items.push_back({"TX Power", SettingType::INTEGER, + [&s]() { return s.loraTxPower; }, [&s](int v) { s.loraTxPower = v; }, + [](int v) { return String(v) + " dBm"; }, -9, 22, 1}); + idx++; + _items.push_back({"Spread Factor", SettingType::INTEGER, + [&s]() { return s.loraSF; }, [&s](int v) { s.loraSF = v; }, + [](int v) { return String("SF") + String(v); }, 5, 12, 1}); + idx++; + _items.push_back({"Bandwidth", SettingType::ENUM_CHOICE, + [&s]() { + if (s.loraBW <= 62500) return 0; + if (s.loraBW <= 125000) return 1; + if (s.loraBW <= 250000) return 2; + return 3; + }, + [&s](int v) { + static const uint32_t bws[] = {62500, 125000, 250000, 500000}; + s.loraBW = bws[constrain(v, 0, 3)]; + }, + nullptr, 0, 3, 1, {"62.5k", "125k", "250k", "500k"}}); + idx++; + _items.push_back({"Coding Rate", SettingType::INTEGER, + [&s]() { return s.loraCR; }, [&s](int v) { s.loraCR = v; }, + [](int v) { return String("4/") + String(v); }, 5, 8, 1}); + idx++; + _items.push_back({"Preamble", SettingType::INTEGER, + [&s]() { return (int)s.loraPreamble; }, [&s](int v) { s.loraPreamble = v; }, + [](int v) { return String(v); }, 6, 65, 1}); + idx++; + } _categories.push_back({"Radio", radioStart, idx - radioStart, [this]() { int p = detectPreset(); return (p >= 0) ? String(LV_PRESETS[p].name) : String("Custom"); }}); @@ -590,6 +622,7 @@ void LvSettingsScreen::onEnter() { _editing = false; _textEditing = false; _confirmingReset = false; + _confirmingDevMode = false; rebuildCategoryList(); } @@ -901,6 +934,7 @@ void LvSettingsScreen::exitToCategories() { _editing = false; _textEditing = false; _confirmingReset = false; + _confirmingDevMode = false; rebuildCategoryList(); } diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index bd6ed74..8af9599 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -130,6 +130,7 @@ private: bool _textEditing = false; String _editText; bool _confirmingReset = false; + bool _confirmingDevMode = false; // WiFi picker std::vector _wifiResults;