mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-04-26 10:57:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -56,6 +56,9 @@ struct UserSettings {
|
||||
|
||||
// Identity
|
||||
String displayName;
|
||||
|
||||
// Developer mode — unlocks custom radio parameters
|
||||
bool devMode = false;
|
||||
};
|
||||
|
||||
class UserConfig {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
62
src/main.cpp
62
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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public:
|
||||
|
||||
private:
|
||||
void refreshTabs();
|
||||
void refreshTab(int idx);
|
||||
|
||||
lv_obj_t* _bar = nullptr;
|
||||
lv_obj_t* _tabs[TAB_COUNT] = {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] = {};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ private:
|
||||
bool _textEditing = false;
|
||||
String _editText;
|
||||
bool _confirmingReset = false;
|
||||
bool _confirmingDevMode = false;
|
||||
|
||||
// WiFi picker
|
||||
std::vector<WiFiInterface::ScanResult> _wifiResults;
|
||||
|
||||
Reference in New Issue
Block a user