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
This commit is contained in:
DeFiDude
2026-03-15 12:25:29 -06:00
parent 02d7dedcce
commit fc91f8214e
30 changed files with 604 additions and 238 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -56,6 +56,9 @@ struct UserSettings {
// Identity
String displayName;
// Developer mode — unlocks custom radio parameters
bool devMode = false;
};
class UserConfig {

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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;
};

View File

@@ -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;
}
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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<std::vector<uint8_t>> 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);
}
}
}
}

View File

@@ -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<std::vector<uint8_t>> _incomingFrames;
SemaphoreHandle_t _framesMutex = nullptr;
class BLESideband* _sideband = nullptr;

View File

@@ -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

View File

@@ -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;

View File

@@ -3,6 +3,7 @@
#include <Interface.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <vector>
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<RNS::Bytes> _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; }

View File

@@ -192,6 +192,45 @@ std::vector<WiFiInterface::ScanResult> 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::ScanResult> WiFiInterface::getScanResults(int maxResults) {
std::vector<ScanResult> 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);

View File

@@ -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<ScanResult> scanNetworks(int maxResults = 15);
static void startAsyncScan();
static bool isScanComplete();
static std::vector<ScanResult> getScanResults(int maxResults = 15);
protected:
virtual void send_outgoing(const RNS::Bytes& data) override;

View File

@@ -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);
}
}

View File

@@ -21,6 +21,7 @@ public:
private:
void refreshTabs();
void refreshTab(int idx);
lv_obj_t* _bar = nullptr;
lv_obj_t* _tabs[TAB_COUNT] = {};

View File

@@ -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;
}

View File

@@ -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<int> _contactIndices; // Maps row -> node index in _am->nodes()
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
std::vector<lv_obj_t*> _rows;
// Object pool
static constexpr int ROW_POOL_SIZE = 10;
lv_obj_t* _poolRows[ROW_POOL_SIZE] = {};
lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {};
};

View File

@@ -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;

View File

@@ -43,6 +43,8 @@ private:
unsigned long _lastRefreshMs = 0;
std::vector<LXMFMessage> _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<lv_obj_t*> _statusLabels;
std::vector<lv_obj_t*> _textLabels;
static constexpr unsigned long REFRESH_INTERVAL_MS = 2000; // Check for new messages every 2s
};

View File

@@ -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<ConvInfo> 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;
}

View File

@@ -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<std::string> _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<lv_obj_t*> _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<ConvInfo> _sortedConvs;
};

View File

@@ -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();
}

View File

@@ -130,6 +130,7 @@ private:
bool _textEditing = false;
String _editText;
bool _confirmingReset = false;
bool _confirmingDevMode = false;
// WiFi picker
std::vector<WiFiInterface::ScanResult> _wifiResults;