v1.4.0: LVGL UI, async radio TX, live TCP management, input fixes

- Migrate all screens to LVGL v8.4 widget system
- Non-blocking radio TX (async endPacket via LoRaInterface)
- Live TCP server switching with transient node cleanup
- Fix UI freeze during radio transmit
- Trackball long-press delete, deferred click with debounce
- Pin microReticulum to 392363c, fix list_directory API
- Fix CI build: portable include path, remove hardcoded local path
This commit is contained in:
DeFiDude
2026-03-07 13:00:59 -07:00
parent 9b7980665c
commit 07025bfa23
51 changed files with 3895 additions and 309 deletions
+20 -5
View File
@@ -6,6 +6,10 @@
// Color depth: 16-bit RGB565
#define LV_COLOR_DEPTH 16
// CRITICAL: Byte-swap for LovyanGFX + ST7789V
// Without this, colors appear pixelated/glitchy
#define LV_COLOR_16_SWAP 1
// Memory: use stdlib malloc (PSRAM-aware on ESP32-S3)
#define LV_MEM_CUSTOM 1
#define LV_MEM_CUSTOM_INCLUDE <stdlib.h>
@@ -23,16 +27,22 @@
#define LV_VER_RES_MAX 240
#define LV_DPI_DEF 130
// Logging
#define LV_USE_LOG 0
// Logging (enabled temporarily for debug)
#define LV_USE_LOG 1
#define LV_LOG_LEVEL LV_LOG_LEVEL_WARN
#define LV_LOG_PRINTF 1
// Theme
#define LV_USE_THEME_DEFAULT 1
#define LV_THEME_DEFAULT_DARK 1
// Fonts - built-in
#define LV_FONT_MONTSERRAT_8 1
#define LV_FONT_MONTSERRAT_10 1
#define LV_FONT_MONTSERRAT_12 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_16 1
#define LV_FONT_UNSCII_8 1
#define LV_FONT_DEFAULT &lv_font_montserrat_12
#define LV_FONT_DEFAULT &lv_font_montserrat_14
// Widgets
#define LV_USE_LABEL 1
@@ -53,11 +63,16 @@
#define LV_USE_SPINNER 1
#define LV_USE_MSGBOX 1
#define LV_USE_KEYBOARD 1
#define LV_USE_CHECKBOX 1
#define LV_USE_CANVAS 0
// Scroll
// Layouts
#define LV_USE_FLEX 1
#define LV_USE_GRID 1
// Animations
#define LV_USE_ANIMIMG 0
// OS
#define LV_USE_OS LV_OS_NONE
+4 -1
View File
@@ -21,17 +21,20 @@ build_flags =
-mfix-esp32-psram-cache-issue
-DDISPLAY_WIDTH=320
-DDISPLAY_HEIGHT=240
-DLV_CONF_INCLUDE_SIMPLE
"-I${PROJECT_DIR}"
build_unflags =
-fno-exceptions
-std=gnu++11
lib_deps =
https://github.com/attermann/microReticulum.git
https://github.com/attermann/microReticulum.git#392363c
https://github.com/attermann/Crypto.git
bblanchon/ArduinoJson@^7.4.2
lovyan03/LovyanGFX@^1.1.16
h2zero/NimBLE-Arduino@^2.1
lvgl/lvgl@^8.3.4
lib_archive = false
+4 -4
View File
@@ -5,9 +5,9 @@
// =============================================================================
#define RATDECK_VERSION_MAJOR 1
#define RATDECK_VERSION_MINOR 3
#define RATDECK_VERSION_MINOR 4
#define RATDECK_VERSION_PATCH 0
#define RATDECK_VERSION_STRING "1.3.0"
#define RATDECK_VERSION_STRING "1.4.0"
// --- Feature Flags ---
#define HAS_DISPLAY true
@@ -43,8 +43,8 @@
// --- TCP Client ---
#define MAX_TCP_CONNECTIONS 4
#define TCP_DEFAULT_PORT 4242
#define TCP_RECONNECT_INTERVAL_MS 10000
#define TCP_CONNECT_TIMEOUT_MS 5000
#define TCP_RECONNECT_INTERVAL_MS 15000
#define TCP_CONNECT_TIMEOUT_MS 500
// --- Limits ---
#define RATDECK_MAX_NODES 200 // PSRAM allows more
+4 -1
View File
@@ -46,7 +46,10 @@ bool UserConfig::parseJson(const String& json) {
_settings.screenDimTimeout = doc["screen_dim"] | 30;
_settings.screenOffTimeout = doc["screen_off"] | 60;
_settings.brightness = doc["brightness"] | 255;
// Brightness: stored as 1-100%. Migrate old 0-255 values.
int rawBri = doc["brightness"] | 100;
if (rawBri > 100) rawBri = rawBri * 100 / 255; // Migrate from PWM to percentage
_settings.brightness = constrain(rawBri, 1, 100);
_settings.denseFontMode = doc["dense_font"] | false;
_settings.trackballSpeed = doc["trackball_speed"] | 3;
_settings.touchSensitivity = doc["touch_sens"] | 3;
+1 -1
View File
@@ -38,7 +38,7 @@ struct UserSettings {
// Display
uint16_t screenDimTimeout = 30; // seconds
uint16_t screenOffTimeout = 60; // seconds
uint8_t brightness = 255;
uint8_t brightness = 100; // Percentage 1-100
bool denseFontMode = false; // T-Deck Plus: adaptive font toggle
// Trackball
+49
View File
@@ -1,4 +1,20 @@
#include "Display.h"
#include <lvgl.h>
// Double-buffered 10-line strips in PSRAM for DMA flush
static lv_color_t* s_buf1 = nullptr;
static lv_color_t* s_buf2 = nullptr;
static LGFX_TDeck* s_gfx = nullptr;
static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
s_gfx->startWrite();
s_gfx->setAddrWindow(area->x1, area->y1, w, h);
s_gfx->pushPixelsDMA((lgfx::swap565_t*)&color_p->full, w * h);
s_gfx->endWrite();
lv_disp_flush_ready(drv);
}
bool Display::begin() {
_gfx.init();
@@ -12,6 +28,39 @@ bool Display::begin() {
return true;
}
void Display::beginLVGL() {
s_gfx = &_gfx;
lv_init();
// Allocate double-buffered 10-line strips in PSRAM
const uint32_t bufSize = 320 * 10;
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);
// Fall back to PSRAM if DMA-capable memory not available
if (!s_buf1) s_buf1 = (lv_color_t*)ps_malloc(bufSize * sizeof(lv_color_t));
if (!s_buf2) s_buf2 = (lv_color_t*)ps_malloc(bufSize * sizeof(lv_color_t));
if (!s_buf1 || !s_buf2) {
Serial.println("[LVGL] FATAL: buffer allocation failed!");
return;
}
static lv_disp_draw_buf_t draw_buf;
lv_disp_draw_buf_init(&draw_buf, s_buf1, s_buf2, bufSize);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
disp_drv.flush_cb = lvgl_flush_cb;
disp_drv.draw_buf = &draw_buf;
lv_disp_drv_register(&disp_drv);
Serial.println("[LVGL] Display driver registered (320x240, double-buffered 10-line DMA)");
}
void Display::setBrightness(uint8_t level) {
_gfx.setBrightness(level);
}
+3
View File
@@ -57,6 +57,9 @@ class Display {
public:
bool begin();
// Initialize LVGL display driver with double-buffered DMA flush
void beginLVGL();
// Backlight
void setBrightness(uint8_t level);
void sleep();
+24 -6
View File
@@ -37,6 +37,13 @@ int Power::batteryPercent() const {
return (int)((v - 3.0f) / 1.2f * 100.0f);
}
uint8_t Power::percentToPWM(uint8_t pct) const {
if (pct == 0) return 0;
if (pct >= 100) return 255;
// Map 1-100 to ~6-255 (minimum visible PWM ~6)
return (uint8_t)(6 + (uint16_t)(pct - 1) * 249 / 99);
}
void Power::activity() {
_lastActivity = millis();
if (_state != ACTIVE) {
@@ -44,10 +51,18 @@ void Power::activity() {
}
}
void Power::setBrightness(uint8_t brightness) {
_fullBrightness = brightness;
void Power::weakActivity() {
_lastActivity = millis();
// Trackball wakes from DIM but not from SCREEN_OFF
if (_state == DIMMED) {
setState(ACTIVE);
}
}
void Power::setBrightness(uint8_t percent) {
_brightnessPct = constrain(percent, 1, 100);
if (_state == ACTIVE) {
display.setBrightness(_fullBrightness);
display.setBrightness(percentToPWM(_brightnessPct));
}
}
@@ -76,15 +91,18 @@ void Power::loop() {
void Power::setState(State newState) {
if (newState == _state) return;
State oldState = _state;
_state = newState;
switch (_state) {
case ACTIVE:
display.wakeup();
display.setBrightness(_fullBrightness);
if (oldState == SCREEN_OFF) {
display.wakeup();
}
display.setBrightness(percentToPWM(_brightnessPct));
break;
case DIMMED:
display.setBrightness(DIM_BRIGHTNESS);
display.setBrightness(DIM_PWM);
break;
case SCREEN_OFF:
display.setBrightness(0);
+8 -5
View File
@@ -11,15 +11,17 @@ public:
void begin();
void loop();
// Call on any user activity (keypress, touch, trackball)
// Strong activity = keyboard/touch — wakes from any state
void activity();
// Weak activity = trackball — wakes from DIM only, not SCREEN_OFF
void weakActivity();
// Battery
float batteryVoltage() const;
int batteryPercent() const;
// Backlight
void setBrightness(uint8_t brightness);
// Backlight — accepts percentage 1-100
void setBrightness(uint8_t percent);
void setDimTimeout(uint16_t seconds) { _dimTimeout = seconds * 1000UL; }
void setOffTimeout(uint16_t seconds) { _offTimeout = seconds * 1000UL; }
@@ -29,11 +31,12 @@ public:
private:
void setState(State newState);
uint8_t percentToPWM(uint8_t pct) const;
State _state = ACTIVE;
unsigned long _lastActivity = 0;
unsigned long _dimTimeout = 30000;
unsigned long _offTimeout = 60000;
uint8_t _fullBrightness = 255;
static constexpr uint8_t DIM_BRIGHTNESS = 64;
uint8_t _brightnessPct = 100; // User brightness as 1-100%
static constexpr uint8_t DIM_PWM = 40; // ~15% PWM when dimmed
};
+78 -33
View File
@@ -1,4 +1,5 @@
#include "InputManager.h"
#include "config/BoardConfig.h"
void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) {
_kb = kb;
@@ -9,6 +10,8 @@ void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) {
void InputManager::update() {
_hasKey = false;
_activity = false;
_strongActivity = false;
_longPress = false;
// Poll keyboard
if (_kb) {
@@ -17,17 +20,18 @@ void InputManager::update() {
_keyEvent = _kb->getEvent();
_hasKey = true;
_activity = true;
_strongActivity = true;
}
}
// Poll trackball — convert deltas to nav KeyEvents
if (_tb) {
_tb->update();
if (_tb->hadMovement() || _tb->wasClicked()) {
_activity = true;
if (_tb->hadMovement()) {
_activity = true; // Movement is weak — only wakes from dim
}
// Generate nav events from trackball when no keyboard key was pressed
// Generate nav events from trackball movement (click handled below via GPIO)
if (!_hasKey) {
unsigned long now = millis();
@@ -40,46 +44,87 @@ void InputManager::update() {
if (_tbAccumY < -20) _tbAccumY = -20;
if (now - _lastTbNavTime >= TB_NAV_RATE_MS) {
// Click → enter
if (_tb->wasClicked()) {
int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX;
int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY;
bool yDominant = absY >= absX;
if (yDominant && absY >= TB_NAV_THRESHOLD) {
_keyEvent = {};
_keyEvent.enter = true;
if (_tbAccumY < 0) _keyEvent.up = true;
else _keyEvent.down = true;
_hasKey = true;
_activity = true;
_strongActivity = true;
_lastTbNavTime = now;
_tbAccumX = 0;
_tbAccumY = 0;
}
// Pick dominant axis — whichever accumulated more wins
else {
int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX;
int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY;
bool yDominant = absY >= absX;
if (yDominant && absY >= TB_NAV_THRESHOLD) {
_keyEvent = {};
if (_tbAccumY < 0) _keyEvent.up = true;
else _keyEvent.down = true;
_hasKey = true;
_lastTbNavTime = now;
_tbAccumX = 0;
_tbAccumY = 0;
}
else if (!yDominant && absX >= TB_NAV_THRESHOLD) {
_keyEvent = {};
if (_tbAccumX < 0) _keyEvent.left = true;
else _keyEvent.right = true;
_hasKey = true;
_lastTbNavTime = now;
_tbAccumX = 0;
_tbAccumY = 0;
}
else if (!yDominant && absX >= TB_NAV_THRESHOLD) {
_keyEvent = {};
if (_tbAccumX < 0) _keyEvent.left = true;
else _keyEvent.right = true;
_hasKey = true;
_activity = true;
_strongActivity = true;
_lastTbNavTime = now;
_tbAccumX = 0;
_tbAccumY = 0;
}
}
}
// Click / long-press detection via GPIO (deferred click with debounce)
// Short click fires on button RELEASE; long press fires after hold threshold
// Debounce: require GPIO HIGH for CLICK_DEBOUNCE_MS before accepting release
bool clickDown = (digitalRead(TBALL_CLICK) == LOW);
if (clickDown) {
_lastClickDownMs = millis(); // Track last time GPIO was LOW
if (!_clickPending) {
// Button just went down — start tracking, don't fire yet
_clickPending = true;
_longPressFired = false;
_clickStartMs = millis();
_activity = true;
_strongActivity = true; // Click wakes from screen off
} else if (!_longPressFired && millis() - _clickStartMs >= LONG_PRESS_MS) {
// Long press threshold reached
_longPress = true;
_longPressFired = true;
_hasKey = false; // Suppress any concurrent events
_activity = true;
_strongActivity = true;
}
} else if (_clickPending) {
// GPIO is HIGH — only accept release after debounce period
if (millis() - _lastClickDownMs >= CLICK_DEBOUNCE_MS) {
_clickPending = false;
if (!_longPressFired && !_hasKey) {
// Short click — generate deferred enter event
_keyEvent = {};
_keyEvent.enter = true;
_hasKey = true;
_activity = true;
_strongActivity = true;
_lastTbNavTime = millis();
_tbAccumX = 0;
_tbAccumY = 0;
}
_longPressFired = false;
}
// If GPIO was LOW too recently, ignore — likely bounce
}
}
// Touch is polled by LVGL indev read callback — just check for activity
if (_touch && _touch->isTouched()) {
_activity = true;
// Touch activity check — throttled to ~50Hz
if (_touch) {
unsigned long now = millis();
if (now - _lastTouchPoll >= TOUCH_POLL_MS) {
_lastTouchPoll = now;
if (_touch->isTouched()) {
_activity = true;
_strongActivity = true;
}
}
}
}
+18
View File
@@ -15,6 +15,12 @@ public:
// Any activity (for power wake)
bool hadActivity() const { return _activity; }
// Strong activity = keyboard/touch (wakes from screen off)
// Weak activity = trackball only (wakes from dim, NOT from screen off)
bool hadStrongActivity() const { return _strongActivity; }
// Long-press: trackball click held for >= threshold
bool hadLongPress() const { return _longPress; }
private:
Keyboard* _kb = nullptr;
@@ -24,6 +30,14 @@ private:
bool _hasKey = false;
KeyEvent _keyEvent;
bool _activity = false;
bool _strongActivity = false;
bool _longPress = false;
bool _clickPending = false;
bool _longPressFired = false;
unsigned long _clickStartMs = 0;
unsigned long _lastClickDownMs = 0;
static constexpr unsigned long LONG_PRESS_MS = 1200;
static constexpr unsigned long CLICK_DEBOUNCE_MS = 80;
// Trackball navigation state
int8_t _tbAccumX = 0;
@@ -31,4 +45,8 @@ private:
unsigned long _lastTbNavTime = 0;
static constexpr int8_t TB_NAV_THRESHOLD = 3;
static constexpr unsigned long TB_NAV_RATE_MS = 200;
// Touch polling throttle
unsigned long _lastTouchPoll = 0;
static constexpr unsigned long TOUCH_POLL_MS = 20; // ~50Hz
};
+2 -64
View File
@@ -1,64 +1,2 @@
#ifndef LV_CONF_H
#define LV_CONF_H
#include <stdint.h>
// Color depth: 16-bit RGB565
#define LV_COLOR_DEPTH 16
// Memory: use stdlib malloc (PSRAM-aware on ESP32-S3)
#define LV_MEM_CUSTOM 1
#define LV_MEM_CUSTOM_INCLUDE <stdlib.h>
#define LV_MEM_CUSTOM_ALLOC malloc
#define LV_MEM_CUSTOM_FREE free
#define LV_MEM_CUSTOM_REALLOC realloc
// Tick: custom (provided by main loop)
#define LV_TICK_CUSTOM 1
#define LV_TICK_CUSTOM_INCLUDE "Arduino.h"
#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis())
// Display
#define LV_HOR_RES_MAX 320
#define LV_VER_RES_MAX 240
#define LV_DPI_DEF 130
// Logging
#define LV_USE_LOG 0
// Fonts - built-in
#define LV_FONT_MONTSERRAT_8 1
#define LV_FONT_MONTSERRAT_10 1
#define LV_FONT_MONTSERRAT_12 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_UNSCII_8 1
#define LV_FONT_DEFAULT &lv_font_montserrat_12
// Widgets
#define LV_USE_LABEL 1
#define LV_USE_BTN 1
#define LV_USE_BTNMATRIX 1
#define LV_USE_TEXTAREA 1
#define LV_USE_LIST 1
#define LV_USE_BAR 1
#define LV_USE_SLIDER 1
#define LV_USE_SWITCH 1
#define LV_USE_DROPDOWN 1
#define LV_USE_ROLLER 1
#define LV_USE_TABLE 1
#define LV_USE_TABVIEW 1
#define LV_USE_IMG 1
#define LV_USE_LINE 1
#define LV_USE_ARC 1
#define LV_USE_SPINNER 1
#define LV_USE_MSGBOX 1
#define LV_USE_KEYBOARD 1
// Scroll
#define LV_USE_FLEX 1
#define LV_USE_GRID 1
// OS
#define LV_USE_OS LV_OS_NONE
#endif // LV_CONF_H
// Redirect to root lv_conf.h — LVGL expects it at project root
#include "../lv_conf.h"
+261 -149
View File
@@ -6,6 +6,7 @@
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <lvgl.h>
#include "config/BoardConfig.h"
#include "config/Config.h"
@@ -18,6 +19,8 @@
#include "input/InputManager.h"
#include "input/HotkeyManager.h"
#include "ui/UIManager.h"
#include "ui/LvTabBar.h"
#include "ui/LvInput.h"
#include "ui/screens/BootScreen.h"
#include "ui/screens/HomeScreen.h"
#include "ui/screens/NodesScreen.h"
@@ -25,8 +28,17 @@
#include "ui/screens/MessageView.h"
#include "ui/screens/SettingsScreen.h"
#include "ui/screens/HelpOverlay.h"
#include "ui/screens/MapScreen.h"
// MapScreen removed
#include "ui/screens/NameInputScreen.h"
#include "ui/screens/LvBootScreen.h"
#include "ui/screens/LvHomeScreen.h"
#include "ui/screens/LvNodesScreen.h"
#include "ui/screens/LvMessagesScreen.h"
#include "ui/screens/LvMessageView.h"
#include "ui/screens/LvSettingsScreen.h"
#include "ui/screens/LvHelpOverlay.h"
// Map screen removed
#include "ui/screens/LvNameInputScreen.h"
#include "storage/FlashStore.h"
#include "storage/SDStore.h"
#include "storage/MessageStore.h"
@@ -85,7 +97,7 @@ Power powerMgr;
AudioNotify audio;
IdentityManager identityMgr;
// --- Screens ---
// --- Legacy Screens (kept for fallback during migration) ---
BootScreen bootScreen;
HomeScreen homeScreen;
NodesScreen nodesScreen;
@@ -93,11 +105,25 @@ MessagesScreen messagesScreen;
MessageView messageView;
SettingsScreen settingsScreen;
HelpOverlay helpOverlay;
MapScreen mapScreen;
// MapScreen removed
NameInputScreen nameInputScreen;
// Tab-screen mapping (5 tabs)
Screen* tabScreens[5] = {nullptr, nullptr, nullptr, nullptr, nullptr};
// --- LVGL Screens ---
LvBootScreen lvBootScreen;
LvHomeScreen lvHomeScreen;
LvNodesScreen lvNodesScreen;
LvMessagesScreen lvMessagesScreen;
LvMessageView lvMessageView;
LvSettingsScreen lvSettingsScreen;
LvHelpOverlay lvHelpOverlay;
// LvMapScreen removed
LvNameInputScreen lvNameInputScreen;
// Tab-screen mapping (4 tabs) — LVGL versions
LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {};
// Legacy tab mapping (kept for reference)
Screen* tabScreens[LvTabBar::TAB_COUNT] = {};
// --- State ---
bool radioOnline = false;
@@ -105,7 +131,6 @@ bool bootComplete = false;
bool bootLoopRecovery = false;
bool wifiSTAStarted = false;
bool wifiSTAConnected = false;
bool tcpClientsCreated = false;
unsigned long lastAutoAnnounce = 0;
unsigned long lastStatusUpdate = 0;
constexpr unsigned long ANNOUNCE_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -135,28 +160,60 @@ static void announceWithName() {
rns.announce(appData);
ui.statusBar().flashAnnounce();
ui.statusBar().showToast("Announce sent!");
ui.lvStatusBar().flashAnnounce();
ui.lvStatusBar().showToast("Announce sent!");
Serial.println("[ANNOUNCE] Sent with display name");
}
// =============================================================================
// TCP client management — stop old clients, create new from config
// =============================================================================
static void reloadTCPClients() {
// Stop existing clients (don't delete — Transport holds interface references)
for (auto* tcp : tcpClients) tcp->stop();
tcpClients.clear();
// Create new clients from current config
if (WiFi.status() == WL_CONNECTED) {
for (auto& ep : userConfig.settings().tcpConnections) {
if (ep.autoConnect && !ep.host.isEmpty()) {
char name[32];
snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str());
auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name);
tcpIfaces.emplace_back(tcp);
tcpIfaces.back().mode(RNS::Type::Interface::MODE_GATEWAY);
RNS::Transport::register_interface(tcpIfaces.back());
tcp->start();
tcpClients.push_back(tcp);
Serial.printf("[TCP] Created client: %s:%d\n", ep.host.c_str(), ep.port);
}
}
}
if (tcpClients.empty()) {
Serial.println("[TCP] No active TCP connections");
}
}
// =============================================================================
// Hotkey callbacks
// =============================================================================
void onHotkeyHelp() {
helpOverlay.toggle();
ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr);
lvHelpOverlay.toggle();
}
void onHotkeyMessages() {
ui.tabBar().setActiveTab(TabBar::TAB_MSGS);
ui.setScreen(&messagesScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
ui.setLvScreen(&lvMessagesScreen);
}
void onHotkeyNewMsg() {
ui.tabBar().setActiveTab(TabBar::TAB_MSGS);
ui.setScreen(&messagesScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
ui.setLvScreen(&lvMessagesScreen);
}
void onHotkeySettings() {
ui.tabBar().setActiveTab(TabBar::TAB_SETUP);
ui.setScreen(&settingsScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETUP);
ui.setLvScreen(&lvSettingsScreen);
}
void onHotkeyAnnounce() {
announceWithName();
@@ -225,7 +282,8 @@ void onHotkeyRadioTest() {
// Helper: render boot screen immediately
// =============================================================================
static void bootRender() {
ui.render();
// LVGL boot screen calls lv_timer_handler() internally via setProgress()
// Legacy render kept as fallback
}
// =============================================================================
@@ -320,6 +378,10 @@ void setup() {
display.begin();
Serial.println("[BOOT] Display initialized (LovyanGFX direct)");
// Step 5.5: Initialize LVGL display driver
display.beginLVGL();
Serial.println("[BOOT] LVGL initialized");
// Verify radio SPI survives display init
if (radioOnline) {
uint8_t sw_msb = radio.readRegister(0x0740);
@@ -328,33 +390,37 @@ void setup() {
sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK");
}
// Step 6: UI manager
// Step 6: UI manager (initializes both legacy and LVGL UI layers)
ui.begin(&display.gfx());
ui.setBootMode(true);
ui.setScreen(&bootScreen);
ui.setLvScreen(&lvBootScreen);
ui.statusBar().setLoRaOnline(radioOnline);
bootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED");
bootRender();
ui.lvStatusBar().setLoRaOnline(radioOnline);
lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED");
// Step 7: Touch HAL — GT911 I2C
touch.begin();
bootScreen.setProgress(0.50f, "Touch ready");
bootRender();
lvBootScreen.setProgress(0.50f, "Touch ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 8: Keyboard HAL — ESP32-C3 I2C
keyboard.begin();
bootScreen.setProgress(0.52f, "Keyboard ready");
bootRender();
lvBootScreen.setProgress(0.52f, "Keyboard ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 9: Trackball HAL — GPIO interrupts
trackball.begin();
bootScreen.setProgress(0.54f, "Trackball ready");
bootRender();
lvBootScreen.setProgress(0.54f, "Trackball ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 10: Input manager
inputManager.begin(&keyboard, &trackball, &touch);
bootScreen.setProgress(0.55f, "Input ready");
bootRender();
// Step 10.5: LVGL input drivers
LvInput::init(&keyboard, &trackball, &touch);
lvBootScreen.setProgress(0.55f, "Input ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 11: Register hotkeys
hotkeys.registerHotkey('h', "Help", onHotkeyHelp);
@@ -366,16 +432,16 @@ void setup() {
hotkeys.registerHotkey('t', "Radio Test", onHotkeyRadioTest);
hotkeys.registerHotkey('r', "RSSI Monitor", onHotkeyRssiMonitor);
hotkeys.setTabCycleCallback([](int dir) {
ui.tabBar().cycleTab(dir);
int tab = ui.tabBar().getActiveTab();
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);
ui.lvTabBar().cycleTab(dir);
int tab = ui.lvTabBar().getActiveTab();
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
});
bootScreen.setProgress(0.58f, "Hotkeys registered");
bootRender();
lvBootScreen.setProgress(0.58f, "Hotkeys registered");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 12: Mount LittleFS
bootScreen.setProgress(0.60f, "Mounting flash...");
bootRender();
lvBootScreen.setProgress(0.60f, "Mounting flash...");
// (LVGL boot renders via lv_timer_handler in setProgress)
if (!flash.begin()) {
Serial.println("[BOOT] Flash init failed, formatting...");
if (flash.format()) {
@@ -401,8 +467,8 @@ void setup() {
}
}
bootScreen.setProgress(0.65f, "Starting Reticulum...");
bootRender();
lvBootScreen.setProgress(0.65f, "Starting Reticulum...");
// (LVGL boot renders via lv_timer_handler in setProgress)
rns.setSDStore(&sdStore);
// Transport mode is loaded later from config; default is endpoint (no rebroadcast)
// We load a quick peek at the config to get the transport setting before RNS init
@@ -420,19 +486,19 @@ void setup() {
}
if (rns.begin(&radio, &flash)) {
Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str());
bootScreen.setProgress(0.72f, "Reticulum active");
lvBootScreen.setProgress(0.72f, "Reticulum active");
} else {
Serial.println("[BOOT] Reticulum init failed!");
bootScreen.setProgress(0.72f, "RNS: FAILED");
lvBootScreen.setProgress(0.72f, "RNS: FAILED");
}
bootRender();
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 15.5: Identity manager
identityMgr.begin(&flash, &sdStore);
// Step 16: Message store
bootScreen.setProgress(0.72f, "Starting messaging...");
bootRender();
lvBootScreen.setProgress(0.72f, "Starting messaging...");
// (LVGL boot renders via lv_timer_handler in setProgress)
messageStore.begin(&flash, &sdStore);
// Step 17: LXMF init
@@ -440,24 +506,28 @@ void setup() {
lxmf.setMessageCallback([](const LXMFMessage& msg) {
Serial.printf("[LXMF] Message from %s\n", msg.sourceHash.toHex().substr(0, 8).c_str());
ui.tabBar().setUnreadCount(TabBar::TAB_MSGS, lxmf.unreadCount());
ui.lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, lxmf.unreadCount());
audio.playMessage();
});
bootScreen.setProgress(0.75f, "LXMF ready");
bootRender();
// Pre-cache unread counts so first tab switch to Messages is instant
lxmf.unreadCount();
lvBootScreen.setProgress(0.75f, "LXMF ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 18: Announce manager
bootScreen.setProgress(0.78f, "Loading contacts...");
bootRender();
lvBootScreen.setProgress(0.78f, "Loading contacts...");
// (LVGL boot renders via lv_timer_handler in setProgress)
announceManager = new AnnounceManager();
announceManager->setStorage(&sdStore, &flash);
announceManager->setLocalDestHash(rns.destination().hash());
announceManager->loadContacts();
announceManager->loadNameCache();
announceHandler = RNS::HAnnounceHandler(announceManager);
RNS::Transport::register_announce_handler(announceHandler);
// Step 19: User config load
bootScreen.setProgress(0.82f, "Loading config...");
bootRender();
lvBootScreen.setProgress(0.82f, "Loading config...");
// (LVGL boot renders via lv_timer_handler in setProgress)
userConfig.load(sdStore, flash);
// Step 20: Boot loop recovery
@@ -465,8 +535,8 @@ void setup() {
userConfig.settings().wifiMode = RAT_WIFI_OFF;
Serial.println("[BOOT] WiFi forced OFF (boot loop recovery)");
}
bootScreen.setProgress(0.83f, "Config loaded");
bootRender();
lvBootScreen.setProgress(0.83f, "Config loaded");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 21: Apply radio config
if (radioOnline) {
@@ -482,14 +552,15 @@ void setup() {
(unsigned long)s.loraFrequency, s.loraSF,
(unsigned long)s.loraBW, s.loraCR, s.loraTxPower, s.loraPreamble);
}
bootScreen.setProgress(0.84f, "Radio configured");
bootRender();
lvBootScreen.setProgress(0.84f, "Radio configured");
// (LVGL boot renders via lv_timer_handler in setProgress)
// Step 22: WiFi start
RatWiFiMode wifiMode = userConfig.settings().wifiMode;
ui.lvStatusBar().setWiFiEnabled(wifiMode != RAT_WIFI_OFF);
if (wifiMode == RAT_WIFI_AP) {
bootScreen.setProgress(0.87f, "Starting WiFi AP...");
bootRender();
lvBootScreen.setProgress(0.87f, "Starting WiFi AP...");
// (LVGL boot renders via lv_timer_handler in setProgress)
wifiImpl = new WiFiInterface("WiFi.AP");
if (!userConfig.settings().wifiAPSSID.isEmpty()) {
wifiImpl->setAPCredentials(
@@ -501,9 +572,10 @@ void setup() {
RNS::Transport::register_interface(wifiIface);
wifiImpl->start();
ui.statusBar().setWiFiActive(true);
ui.lvStatusBar().setWiFiActive(true);
} else if (wifiMode == RAT_WIFI_STA) {
bootScreen.setProgress(0.87f, "WiFi STA starting...");
bootRender();
lvBootScreen.setProgress(0.87f, "WiFi STA starting...");
// WiFi is enabled but not yet connected — indicator will be yellow
if (!userConfig.settings().wifiSTASSID.isEmpty()) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
@@ -513,13 +585,14 @@ void setup() {
Serial.printf("[WIFI] STA: %s\n", userConfig.settings().wifiSTASSID.c_str());
}
} else {
bootScreen.setProgress(0.87f, "WiFi disabled");
bootRender();
lvBootScreen.setProgress(0.87f, "WiFi disabled");
// (LVGL boot renders via lv_timer_handler in setProgress)
}
// Step 23: BLE start
bootScreen.setProgress(0.90f, "BLE...");
bootRender();
lvBootScreen.setProgress(0.90f, "BLE...");
// (LVGL boot renders via lv_timer_handler in setProgress)
ui.lvStatusBar().setBLEEnabled(userConfig.settings().bleEnabled);
if (userConfig.settings().bleEnabled) {
bleInterface.setSideband(&bleSideband);
@@ -535,6 +608,7 @@ void setup() {
});
ui.statusBar().setBLEActive(true);
ui.lvStatusBar().setBLEActive(true);
Serial.println("[BLE] Transport + Sideband ready");
}
} else {
@@ -542,115 +616,139 @@ void setup() {
}
// Step 24: Power manager
bootScreen.setProgress(0.92f, "Power manager...");
bootRender();
lvBootScreen.setProgress(0.92f, "Power manager...");
// (LVGL boot renders via lv_timer_handler in setProgress)
powerMgr.begin();
powerMgr.setDimTimeout(userConfig.settings().screenDimTimeout);
powerMgr.setOffTimeout(userConfig.settings().screenOffTimeout);
powerMgr.setBrightness(userConfig.settings().brightness);
// Step 25: Audio init
bootScreen.setProgress(0.94f, "Audio...");
bootRender();
lvBootScreen.setProgress(0.94f, "Audio...");
// (LVGL boot renders via lv_timer_handler in setProgress)
audio.setEnabled(userConfig.settings().audioEnabled);
audio.setVolume(userConfig.settings().audioVolume);
audio.begin();
// Boot complete — transition to Home screen
delay(200);
bootScreen.setProgress(1.0f, "Ready");
bootRender();
lvBootScreen.setProgress(1.0f, "Ready");
// (LVGL boot renders via lv_timer_handler in setProgress)
audio.playBoot();
delay(400);
bootComplete = true;
ui.statusBar().setTransportMode("Ratdeck");
ui.lvStatusBar().setTransportMode("Ratdeck");
// Wire up screen dependencies
homeScreen.setReticulumManager(&rns);
homeScreen.setRadio(&radio);
homeScreen.setUserConfig(&userConfig);
homeScreen.setAnnounceCallback([]() {
// Keep UI alive during blocking radio TX (endPacket wait loop)
// Re-entrancy guard prevents nested lv_timer_handler() calls
radio.setYieldCallback([]() {
static bool inYield = false;
if (inYield) return;
inYield = true;
powerMgr.activity(); // Keep screen alive during TX
if (powerMgr.isScreenOn()) {
lv_timer_handler();
}
inYield = false;
});
// Wire up LVGL screen dependencies
lvHomeScreen.setReticulumManager(&rns);
lvHomeScreen.setRadio(&radio);
lvHomeScreen.setUserConfig(&userConfig);
lvHomeScreen.setAnnounceCallback([]() {
announceWithName();
});
nodesScreen.setAnnounceManager(announceManager);
nodesScreen.setNodeSelectedCallback([](const std::string& peerHex) {
messageView.setPeerHex(peerHex);
ui.tabBar().setActiveTab(TabBar::TAB_MSGS);
ui.setScreen(&messageView);
lvNodesScreen.setAnnounceManager(announceManager);
lvNodesScreen.setUIManager(&ui);
lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) {
lvMessageView.setPeerHex(peerHex);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
ui.setLvScreen(&lvMessageView);
});
messagesScreen.setLXMFManager(&lxmf);
messagesScreen.setAnnounceManager(announceManager);
messagesScreen.setOpenCallback([](const std::string& peerHex) {
messageView.setPeerHex(peerHex);
ui.setScreen(&messageView);
lvMessagesScreen.setLXMFManager(&lxmf);
lvMessagesScreen.setAnnounceManager(announceManager);
lvMessagesScreen.setUIManager(&ui);
lvMessagesScreen.setOpenCallback([](const std::string& peerHex) {
lvMessageView.setPeerHex(peerHex);
ui.setLvScreen(&lvMessageView);
});
messageView.setLXMFManager(&lxmf);
messageView.setAnnounceManager(announceManager);
messageView.setBackCallback([]() {
ui.setScreen(&messagesScreen);
lvMessageView.setLXMFManager(&lxmf);
lvMessageView.setAnnounceManager(announceManager);
lvMessageView.setBackCallback([]() {
ui.setLvScreen(&lvMessagesScreen);
});
settingsScreen.setUserConfig(&userConfig);
settingsScreen.setFlashStore(&flash);
settingsScreen.setSDStore(&sdStore);
settingsScreen.setRadio(&radio);
settingsScreen.setAudio(&audio);
settingsScreen.setPower(&powerMgr);
settingsScreen.setWiFi(wifiImpl);
settingsScreen.setTCPClients(&tcpClients);
settingsScreen.setRNS(&rns);
settingsScreen.setIdentityManager(&identityMgr);
settingsScreen.setUIManager(&ui);
settingsScreen.setIdentityHash(rns.identityHash());
settingsScreen.setSaveCallback([]() -> bool {
lvSettingsScreen.setUserConfig(&userConfig);
lvSettingsScreen.setFlashStore(&flash);
lvSettingsScreen.setSDStore(&sdStore);
lvSettingsScreen.setRadio(&radio);
lvSettingsScreen.setAudio(&audio);
lvSettingsScreen.setPower(&powerMgr);
lvSettingsScreen.setWiFi(wifiImpl);
lvSettingsScreen.setTCPClients(&tcpClients);
lvSettingsScreen.setRNS(&rns);
lvSettingsScreen.setIdentityManager(&identityMgr);
lvSettingsScreen.setUIManager(&ui);
lvSettingsScreen.setIdentityHash(rns.identityHash());
lvSettingsScreen.setSaveCallback([]() -> bool {
bool ok = userConfig.save(sdStore, flash);
Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED");
return ok;
});
lvSettingsScreen.setTCPChangeCallback([]() {
Serial.println("[TCP] Settings changed, reloading...");
reloadTCPClients();
if (announceManager) announceManager->clearTransientNodes();
});
// Tab bar callbacks
tabScreens[TabBar::TAB_HOME] = &homeScreen;
tabScreens[TabBar::TAB_MSGS] = &messagesScreen;
tabScreens[TabBar::TAB_NODES] = &nodesScreen;
tabScreens[TabBar::TAB_MAP] = &mapScreen;
tabScreens[TabBar::TAB_SETUP] = &settingsScreen;
// LVGL help overlay
lvHelpOverlay.create();
ui.tabBar().setTabCallback([](int tab) {
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);
// Tab bar callbacks — LVGL
lvTabScreens[LvTabBar::TAB_HOME] = &lvHomeScreen;
lvTabScreens[LvTabBar::TAB_MSGS] = &lvMessagesScreen;
lvTabScreens[LvTabBar::TAB_NODES] = &lvNodesScreen;
lvTabScreens[LvTabBar::TAB_SETUP] = &lvSettingsScreen;
ui.lvTabBar().setTabCallback([](int tab) {
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
});
// Name input screen (first boot only — when no display name is set)
nameInputScreen.setDoneCallback([](const String& name) {
lvNameInputScreen.setDoneCallback([](const String& name) {
userConfig.settings().displayName = name;
userConfig.save(sdStore, flash);
Serial.printf("[BOOT] Display name set: '%s'\n", name.c_str());
// Transition to home screen
ui.setBootMode(false);
ui.setScreen(&homeScreen);
ui.tabBar().setActiveTab(TabBar::TAB_HOME);
ui.setLvScreen(&lvHomeScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
// Initial announce with name
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
rns.announce(appData);
lastAutoAnnounce = millis();
ui.statusBar().flashAnnounce();
ui.lvStatusBar().flashAnnounce();
Serial.println("[BOOT] Initial announce sent");
});
if (userConfig.settings().displayName.isEmpty()) {
// Show name input screen (boot mode keeps status/tab bars hidden)
ui.setScreen(&nameInputScreen);
ui.setLvScreen(&lvNameInputScreen);
Serial.println("[BOOT] Showing name input screen");
} else {
// Name already set — go straight to home
ui.setBootMode(false);
ui.setScreen(&homeScreen);
ui.tabBar().setActiveTab(TabBar::TAB_HOME);
ui.setLvScreen(&lvHomeScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
// Initial announce with name
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
@@ -682,21 +780,30 @@ void setup() {
void loop() {
// 1. Input polling
inputManager.update();
if (inputManager.hadActivity()) {
powerMgr.activity();
if (inputManager.hadStrongActivity()) {
powerMgr.activity(); // Keyboard/touch: wake from any state
} else if (inputManager.hadActivity()) {
powerMgr.weakActivity(); // Trackball: wake from dim only
}
// 2. Key event dispatch
// 2. Long-press dispatch
if (inputManager.hadLongPress()) {
ui.handleLongPress();
}
// 3. Key event dispatch
if (inputManager.hasKeyEvent()) {
const KeyEvent& evt = inputManager.getKeyEvent();
// Help overlay intercepts all keys when visible
if (helpOverlay.isVisible()) {
helpOverlay.handleKey(evt);
ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr);
if (lvHelpOverlay.isVisible()) {
lvHelpOverlay.handleKey(evt);
}
// Ctrl+hotkeys first
else if (!hotkeys.process(evt)) {
// Feed key to LVGL input system
LvInput::feedKey(evt);
// Screen gets the key next
bool consumed = ui.handleKey(evt);
@@ -705,93 +812,99 @@ void loop() {
bool tabLeft = (evt.character == ',') || evt.left;
bool tabRight = (evt.character == '/') || evt.right;
if (tabLeft) {
ui.tabBar().cycleTab(-1);
int tab = ui.tabBar().getActiveTab();
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);
ui.lvTabBar().cycleTab(-1);
int tab = ui.lvTabBar().getActiveTab();
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
}
if (tabRight) {
ui.tabBar().cycleTab(1);
int tab = ui.tabBar().getActiveTab();
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);
ui.lvTabBar().cycleTab(1);
int tab = ui.lvTabBar().getActiveTab();
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
}
}
}
}
// 3. Reticulum loop (radio RX via LoRaInterface)
rns.loop();
// 3. LVGL timer handler — run FIRST after input for responsive UI
if (powerMgr.isScreenOn()) {
lv_timer_handler();
}
// 4. Auto-announce every 5 minutes
// 4. Reticulum loop (radio RX via LoRaInterface) — throttle to ~200Hz
{
static unsigned long lastRNS = 0;
unsigned long now = millis();
if (now - lastRNS >= 5) {
lastRNS = now;
rns.loop();
}
}
// 5. Auto-announce every 5 minutes
if (bootComplete && millis() - lastAutoAnnounce >= ANNOUNCE_INTERVAL_MS) {
lastAutoAnnounce = millis();
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
rns.announce(appData);
ui.statusBar().flashAnnounce();
ui.lvStatusBar().flashAnnounce();
Serial.println("[AUTO] Periodic announce");
}
// 5. LXMF outgoing queue
// 6. LXMF outgoing queue + announce manager deferred saves
lxmf.loop();
if (announceManager) announceManager->loop();
// 6. WiFi STA connection handler
// 7. WiFi STA connection handler
if (wifiSTAStarted) {
bool connected = (WiFi.status() == WL_CONNECTED);
if (connected && !wifiSTAConnected) {
wifiSTAConnected = true;
ui.statusBar().setWiFiActive(true);
ui.lvStatusBar().setWiFiActive(true);
Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str());
if (!tcpClientsCreated) {
tcpClientsCreated = true;
for (auto& ep : userConfig.settings().tcpConnections) {
if (ep.autoConnect) {
char name[32];
snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str());
auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name);
tcpIfaces.emplace_back(tcp);
tcpIfaces.back().mode(RNS::Type::Interface::MODE_GATEWAY);
RNS::Transport::register_interface(tcpIfaces.back());
tcp->start();
tcpClients.push_back(tcp);
}
}
// Create TCP clients (safe to call multiple times)
if (tcpClients.empty()) {
reloadTCPClients();
}
} else if (!connected && wifiSTAConnected) {
wifiSTAConnected = false;
ui.statusBar().setWiFiActive(false);
ui.lvStatusBar().setWiFiActive(false);
Serial.println("[WIFI] STA disconnected");
}
}
// 7. WiFi + TCP loops
// 8. WiFi + TCP loops
if (wifiImpl) wifiImpl->loop();
for (auto* tcp : tcpClients) {
tcp->loop();
yield();
}
// 8. BLE loops
// 9. BLE loops
bleInterface.loop();
bleSideband.loop();
// 9. Power management
// 10. Power management
powerMgr.loop();
// 10. Periodic status bar update (1 Hz) + render
// 11. Periodic status bar update (1 Hz) + render
if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) {
lastStatusUpdate = millis();
if (powerMgr.isScreenOn()) {
ui.statusBar().setBatteryPercent(powerMgr.batteryPercent());
ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent());
ui.update();
}
}
// 11. Render any dirty regions
// 12. Render any dirty regions
if (powerMgr.isScreenOn()) {
ui.render();
}
// 12. Heartbeat for crash diagnosis
// 13. Heartbeat for crash diagnosis
{
unsigned long cycleTime = millis() - loopCycleStart;
if (cycleTime > maxLoopTime) maxLoopTime = cycleTime;
@@ -817,5 +930,4 @@ void loop() {
loopCycleStart = millis();
yield();
delay(5);
}
+44 -7
View File
@@ -144,6 +144,7 @@ void SX1262::waitOnBusy() {
if (_busy != -1) {
while (digitalRead(_busy) == HIGH) {
if (millis() >= (t + 100)) break;
if (_yieldCb) _yieldCb();
}
}
}
@@ -293,25 +294,33 @@ int SX1262::beginPacket(int implicitHeader) {
return 1;
}
int SX1262::endPacket() {
int SX1262::endPacket(bool async) {
setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode);
uint8_t timeout[3] = {0};
uint32_t txStart = millis();
_txStartMs = millis();
_txTimeoutMs = _txStartMs + (uint32_t)(getAirtime(_payloadLength) * MODEM_TIMEOUT_MULT) + 2000;
executeOpcode(OP_TX_6X, timeout, 3);
if (async) {
_txActive = true;
Serial.printf("[SX1262] TX ASYNC: payload=%d calc=%.0fms\n",
_payloadLength, getAirtime(_payloadLength));
return 1;
}
// Blocking mode: wait for TX completion
uint8_t buf[2] = {0};
executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2);
bool timed_out = false;
uint32_t w_timeout = txStart + (uint32_t)(getAirtime(_payloadLength) * MODEM_TIMEOUT_MULT) + 2000;
while ((millis() < w_timeout) && ((buf[1] & IRQ_TX_DONE_MASK_6X) == 0)) {
while ((millis() < _txTimeoutMs) && ((buf[1] & IRQ_TX_DONE_MASK_6X) == 0)) {
buf[0] = 0x00; buf[1] = 0x00;
executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2);
yield();
if (_yieldCb) _yieldCb();
}
uint32_t txActual = millis() - txStart;
if (millis() > w_timeout) { timed_out = true; }
uint32_t txActual = millis() - _txStartMs;
bool timed_out = millis() > _txTimeoutMs;
if (timed_out) {
Serial.printf("[SX1262] TX TIMEOUT: payload=%d actual=%dms calc=%.0fms\n",
@@ -326,6 +335,34 @@ int SX1262::endPacket() {
return !timed_out;
}
bool SX1262::isTxBusy() {
if (!_txActive) return false;
// Check for timeout
if (millis() > _txTimeoutMs) {
Serial.printf("[SX1262] TX ASYNC TIMEOUT after %dms\n",
(int)(millis() - _txStartMs));
uint8_t mask[2] = {0x00, IRQ_TX_DONE_MASK_6X};
executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2);
_txActive = false;
return false;
}
// Poll IRQ status
uint8_t buf[2] = {0};
executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2);
if (buf[1] & IRQ_TX_DONE_MASK_6X) {
Serial.printf("[SX1262] TX ASYNC OK: %dms\n",
(int)(millis() - _txStartMs));
uint8_t mask[2] = {0x00, IRQ_TX_DONE_MASK_6X};
executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2);
_txActive = false;
return false;
}
return true; // Still transmitting
}
size_t SX1262::write(uint8_t byte) { return write(&byte, 1); }
size_t SX1262::write(const uint8_t* buffer, size_t size) {
+10 -1
View File
@@ -22,7 +22,8 @@ public:
// --- TX ---
int beginPacket(int implicitHeader = 0);
int endPacket();
int endPacket(bool async = false);
bool isTxBusy();
size_t write(uint8_t byte);
size_t write(const uint8_t* buffer, size_t size);
@@ -68,6 +69,10 @@ public:
// --- Interrupt-driven RX ---
void onReceive(void(*callback)(int));
// --- Yield callback (called during blocking TX wait) ---
using YieldCallback = void(*)();
void setYieldCallback(YieldCallback cb) { _yieldCb = cb; }
// --- Power ---
void standby();
void sleep();
@@ -127,9 +132,13 @@ private:
bool _radioOnline = false;
bool _tcxo = false;
bool _dio2_as_rf_switch = false;
bool _txActive = false;
uint32_t _txStartMs = 0;
uint32_t _txTimeoutMs = 0;
uint8_t _packet[MAX_PACKET_SIZE] = {};
void (*_onReceive)(int) = nullptr;
YieldCallback _yieldCb = nullptr;
unsigned long _preambleDetectedAt = 0;
long _loraPreambleTimeMs = 0;
+86 -4
View File
@@ -58,17 +58,30 @@ void AnnounceManager::received_announce(
// Filter out own announces
if (_localDestHash.size() > 0 && destination_hash == _localDestHash) return;
Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destination_hash.toHex().c_str(), name.c_str());
std::string destHex = destination_hash.toHex();
Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destHex.c_str(), name.c_str());
// Update name cache for persistence across reboots
if (!name.empty()) {
auto it = _nameCache.find(destHex);
if (it == _nameCache.end() || it->second != name) {
_nameCache[destHex] = name;
_nameCacheDirty = true;
}
}
std::string idHex = announced_identity.hexhash();
unsigned long now = millis();
for (auto& node : _nodes) {
if (node.hash == destination_hash) {
// Rate-limit updates for existing nodes
if (now - node.lastSeen < ANNOUNCE_MIN_INTERVAL_MS) return;
if (!name.empty()) node.name = name;
if (!idHex.empty()) node.identityHex = idHex;
node.lastSeen = millis();
node.lastSeen = now;
node.hops = RNS::Transport::hops_to(destination_hash);
if (node.saved) saveContact(node);
if (node.saved) _contactsDirty = true;
return;
}
}
@@ -98,13 +111,29 @@ void AnnounceManager::received_announce(
_nodes.push_back(node);
}
void AnnounceManager::loop() {
unsigned long now = millis();
if (_contactsDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) {
_contactsDirty = false;
_lastContactSave = now;
saveContacts();
Serial.println("[ANNOUNCE] Deferred contact save complete");
}
if (_nameCacheDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) {
_nameCacheDirty = false;
saveNameCache();
}
}
const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const {
for (const auto& n : _nodes) { if (n.hash == hash) return &n; }
return nullptr;
}
const DiscoveredNode* AnnounceManager::findNodeByHex(const std::string& hexHash) const {
for (const auto& n : _nodes) { if (n.hash.toHex() == hexHash) return &n; }
RNS::Bytes target;
target.assignHex(hexHash.c_str());
for (const auto& n : _nodes) { if (n.hash == target) return &n; }
return nullptr;
}
@@ -137,6 +166,16 @@ void AnnounceManager::evictStale(unsigned long maxAgeMs) {
}), _nodes.end());
}
void AnnounceManager::clearTransientNodes() {
int before = _nodes.size();
_nodes.erase(std::remove_if(_nodes.begin(), _nodes.end(),
[](const DiscoveredNode& n) { return !n.saved; }), _nodes.end());
int removed = before - (int)_nodes.size();
if (removed > 0) {
Serial.printf("[ANNOUNCE] Cleared %d transient nodes\n", removed);
}
}
void AnnounceManager::saveContact(const DiscoveredNode& node) {
std::string hexHash = node.hash.toHex();
JsonDocument doc;
@@ -202,3 +241,46 @@ void AnnounceManager::loadContacts() {
void AnnounceManager::saveContacts() {
for (const auto& n : _nodes) { if (n.saved) saveContact(n); }
}
std::string AnnounceManager::lookupName(const std::string& hexHash) const {
// Check live nodes first
const DiscoveredNode* node = findNodeByHex(hexHash);
if (node && !node->name.empty()) return node->name;
// Fall back to cached names
auto it = _nameCache.find(hexHash);
if (it != _nameCache.end()) return it->second;
return "";
}
void AnnounceManager::saveNameCache() {
JsonDocument doc;
for (auto& kv : _nameCache) {
doc[kv.first] = kv.second;
}
String json;
serializeJson(doc, json);
if (_sd && _sd->isReady()) {
_sd->writeString("/ratputer/config/names.json", json);
}
if (_flash) {
_flash->writeString("/config/names.json", json);
}
Serial.printf("[ANNOUNCE] Name cache saved (%d entries)\n", (int)_nameCache.size());
}
void AnnounceManager::loadNameCache() {
String json;
if (_sd && _sd->isReady()) {
json = _sd->readString("/ratputer/config/names.json");
}
if (json.isEmpty() && _flash) {
json = _flash->readString("/config/names.json");
}
if (json.isEmpty()) return;
JsonDocument doc;
if (deserializeJson(doc, json)) return;
for (JsonPair kv : doc.as<JsonObject>()) {
_nameCache[kv.key().c_str()] = kv.value().as<std::string>();
}
Serial.printf("[ANNOUNCE] Name cache loaded (%d entries)\n", (int)_nameCache.size());
}
+16 -1
View File
@@ -5,6 +5,7 @@
#include <Bytes.h>
#include <vector>
#include <string>
#include <map>
class SDStore;
class FlashStore;
@@ -34,6 +35,12 @@ public:
void setLocalDestHash(const RNS::Bytes& hash) { _localDestHash = hash; }
void saveContacts();
void loadContacts();
void loop(); // Call from main loop — handles deferred saves
// Name cache: persists hash→name mappings so names survive reboots
std::string lookupName(const std::string& hexHash) const;
void saveNameCache();
void loadNameCache();
const std::vector<DiscoveredNode>& nodes() const { return _nodes; }
int nodeCount() const { return _nodes.size(); }
@@ -41,6 +48,7 @@ public:
const DiscoveredNode* findNodeByHex(const std::string& hexHash) const;
void addManualContact(const std::string& hexHash, const std::string& name);
void evictStale(unsigned long maxAgeMs = 3600000);
void clearTransientNodes();
private:
void saveContact(const DiscoveredNode& node);
@@ -50,5 +58,12 @@ private:
SDStore* _sd = nullptr;
FlashStore* _flash = nullptr;
RNS::Bytes _localDestHash;
static constexpr int MAX_NODES = 200; // PSRAM allows more
bool _contactsDirty = false;
bool _nameCacheDirty = false;
unsigned long _lastContactSave = 0;
unsigned long _lastAnnounceProcessed = 0;
std::map<std::string, std::string> _nameCache; // hexHash → displayName
static constexpr int MAX_NODES = 30;
static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000;
static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing
};
+7 -2
View File
@@ -38,12 +38,17 @@ bool LittleFSFileSystem::directory_exists(const char* p) { return LittleFS.exist
bool LittleFSFileSystem::create_directory(const char* p) { return LittleFS.mkdir(p); }
bool LittleFSFileSystem::remove_directory(const char* p) { return LittleFS.rmdir(p); }
std::list<std::string> LittleFSFileSystem::list_directory(const char* p) {
std::list<std::string> LittleFSFileSystem::list_directory(const char* p, Callbacks::DirectoryListing callback) {
std::list<std::string> entries;
File dir = LittleFS.open(p);
if (!dir || !dir.isDirectory()) return entries;
File f = dir.openNextFile();
while (f) { entries.push_back(f.name()); f = dir.openNextFile(); }
while (f) {
const char* name = f.name();
entries.push_back(name);
if (callback) callback(name);
f = dir.openNextFile();
}
return entries;
}
+1 -1
View File
@@ -24,7 +24,7 @@ public:
virtual bool directory_exists(const char* directory_path) override;
virtual bool create_directory(const char* directory_path) override;
virtual bool remove_directory(const char* directory_path) override;
virtual std::list<std::string> list_directory(const char* directory_path) override;
virtual std::list<std::string> list_directory(const char* directory_path, Callbacks::DirectoryListing callback = nullptr) override;
virtual size_t storage_size() override;
virtual size_t storage_available() override;
};
+20 -10
View File
@@ -40,6 +40,11 @@ void LoRaInterface::stop() {
void LoRaInterface::send_outgoing(const RNS::Bytes& data) {
if (!_online || !_radio) return;
if (_txPending) {
Serial.println("[LORA_IF] TX busy, dropping packet");
return;
}
// Build RNode-compatible 1-byte header:
// Upper nibble: random sequence number (for split-packet tracking)
// Lower nibble: flags (FLAG_SPLIT=0x01 if packet won't fit in single frame)
@@ -57,22 +62,27 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) {
_radio->beginPacket();
_radio->write(header); // 1-byte RNode header
_radio->write(data.data(), data.size()); // Reticulum packet payload
bool sent = _radio->endPacket();
_radio->endPacket(true); // Async: start TX and return immediately
if (sent) {
Serial.printf("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", data.size(), header);
InterfaceImpl::handle_outgoing(data);
} else {
Serial.println("[LORA_IF] TX failed (timeout)");
}
// Return to RX mode
_radio->receive();
_txPending = true;
_txData = data;
InterfaceImpl::handle_outgoing(data);
Serial.printf("[LORA_IF] TX %d+1 bytes queued (hdr=0x%02X)\n", data.size(), header);
}
void LoRaInterface::loop() {
if (!_online || !_radio) return;
// Handle async TX completion
if (_txPending) {
if (!_radio->isTxBusy()) {
_txPending = false;
_txData = RNS::Bytes();
_radio->receive();
}
return; // Don't process RX while TX is active
}
// Periodic RX debug: dump RSSI + chip status every 30 seconds
static unsigned long lastRxDebug = 0;
if (millis() - lastRxDebug > 30000) {
+2
View File
@@ -21,4 +21,6 @@ protected:
private:
SX1262* _radio;
bool _txPending = false;
RNS::Bytes _txData;
};
+2 -1
View File
@@ -42,8 +42,9 @@ void TCPClientInterface::tryConnect() {
void TCPClientInterface::loop() {
if (!_online) return;
// Auto-reconnect
// Auto-reconnect (only if WiFi is connected)
if (!_client.connected()) {
if (WiFi.status() != WL_CONNECTED) return;
if (millis() - _lastAttempt >= TCP_RECONNECT_INTERVAL_MS) {
tryConnect();
}
+73
View File
@@ -0,0 +1,73 @@
#include "LvInput.h"
#include "hal/Keyboard.h"
#include "hal/Trackball.h"
#include "hal/TouchInput.h"
namespace LvInput {
static Keyboard* s_kb = nullptr;
static Trackball* s_tb = nullptr;
static TouchInput* s_touch = nullptr;
static lv_group_t* s_group = nullptr;
// Keypad indev state
static uint32_t s_lastKey = 0;
static lv_indev_state_t s_keyState = LV_INDEV_STATE_RELEASED;
static bool s_keyReady = false;
// Touch disabled — GT911 coordinate mapping needs calibration
static void keypad_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) {
if (s_keyReady) {
data->key = s_lastKey;
data->state = s_keyState;
s_keyReady = false;
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
void init(Keyboard* kb, Trackball* tb, TouchInput* touch) {
s_kb = kb;
s_tb = tb;
s_touch = touch;
// Create input group
s_group = lv_group_create();
lv_group_set_default(s_group);
// Register keypad indev
static lv_indev_drv_t keyDrv;
lv_indev_drv_init(&keyDrv);
keyDrv.type = LV_INDEV_TYPE_KEYPAD;
keyDrv.read_cb = keypad_read_cb;
lv_indev_t* keyIndev = lv_indev_drv_register(&keyDrv);
lv_indev_set_group(keyIndev, s_group);
// Touch indev disabled — GT911 coordinate mapping needs calibration
Serial.println("[LVGL] Input drivers registered (touch disabled)");
}
void feedKey(const KeyEvent& evt) {
uint32_t key = 0;
if (evt.enter) key = LV_KEY_ENTER;
else if (evt.up) key = LV_KEY_UP;
else if (evt.down) key = LV_KEY_DOWN;
else if (evt.left) key = LV_KEY_LEFT;
else if (evt.right) key = LV_KEY_RIGHT;
else if (evt.del) key = LV_KEY_BACKSPACE;
else if (evt.tab) key = LV_KEY_NEXT;
else if (evt.character == 0x1B) key = LV_KEY_ESC;
else return; // Don't feed printable chars — screens handle them via handleKey()
s_lastKey = key;
s_keyState = LV_INDEV_STATE_PRESSED;
s_keyReady = true;
}
lv_group_t* group() {
return s_group;
}
} // namespace LvInput
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <lvgl.h>
class Keyboard;
class Trackball;
class TouchInput;
struct KeyEvent;
// LVGL input device drivers for T-Deck Plus hardware
namespace LvInput {
void init(Keyboard* kb, Trackball* tb, TouchInput* touch);
// Feed a KeyEvent into the LVGL keypad indev (called from main loop)
void feedKey(const KeyEvent& evt);
// Get the LVGL input group (for focusing widgets)
lv_group_t* group();
} // namespace LvInput
+157
View File
@@ -0,0 +1,157 @@
#include "LvStatusBar.h"
#include "Theme.h"
#include <Arduino.h>
void LvStatusBar::create(lv_obj_t* parent) {
_bar = lv_obj_create(parent);
lv_obj_set_size(_bar, Theme::SCREEN_W, Theme::STATUS_BAR_H);
lv_obj_align(_bar, LV_ALIGN_TOP_LEFT, 0, 0);
lv_obj_set_style_bg_color(_bar, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(_bar, 1, 0);
lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(_bar, 0, 0);
lv_obj_set_style_radius(_bar, 0, 0);
lv_obj_clear_flag(_bar, LV_OBJ_FLAG_SCROLLABLE);
const lv_font_t* font = &lv_font_montserrat_12;
// Left side: LoRa BLE WiFi indicators
_lblLora = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblLora, font, 0);
lv_label_set_text(_lblLora, "LoRa");
lv_obj_align(_lblLora, LV_ALIGN_LEFT_MID, 4, 0);
_lblBle = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblBle, font, 0);
lv_label_set_text(_lblBle, "BLE");
lv_obj_align(_lblBle, LV_ALIGN_LEFT_MID, 50, 0);
_lblWifi = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblWifi, font, 0);
lv_label_set_text(_lblWifi, "WiFi");
lv_obj_align(_lblWifi, LV_ALIGN_LEFT_MID, 84, 0);
// Center: "Ratspeak"
_lblBrand = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblBrand, font, 0);
lv_obj_set_style_text_color(_lblBrand, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(_lblBrand, "Ratspeak");
lv_obj_align(_lblBrand, LV_ALIGN_CENTER, 0, 0);
// Right: Battery %
_lblBatt = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblBatt, font, 0);
lv_label_set_text(_lblBatt, "");
lv_obj_align(_lblBatt, LV_ALIGN_RIGHT_MID, -4, 0);
// Toast overlay (hidden by default)
_toast = lv_obj_create(parent);
lv_obj_set_size(_toast, Theme::SCREEN_W, Theme::STATUS_BAR_H);
lv_obj_align(_toast, LV_ALIGN_TOP_LEFT, 0, 0);
lv_obj_set_style_bg_color(_toast, lv_color_hex(Theme::ACCENT), 0);
lv_obj_set_style_bg_opa(_toast, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(_toast, 0, 0);
lv_obj_set_style_radius(_toast, 0, 0);
lv_obj_clear_flag(_toast, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
_lblToast = lv_label_create(_toast);
lv_obj_set_style_text_font(_lblToast, font, 0);
lv_obj_set_style_text_color(_lblToast, lv_color_hex(Theme::BG), 0);
lv_obj_center(_lblToast);
lv_label_set_text(_lblToast, "");
// Set initial indicator colors
refreshIndicators();
}
void LvStatusBar::update() {
// Handle announce flash timeout
if (_announceFlashEnd > 0 && millis() >= _announceFlashEnd) {
_announceFlashEnd = 0;
refreshIndicators();
}
// Handle toast timeout
if (_toastEnd > 0 && millis() >= _toastEnd) {
_toastEnd = 0;
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
}
}
void LvStatusBar::setLoRaOnline(bool online) {
_loraOnline = online;
refreshIndicators();
}
void LvStatusBar::setBLEActive(bool active) {
_bleActive = active;
refreshIndicators();
}
void LvStatusBar::setWiFiActive(bool active) {
_wifiActive = active;
refreshIndicators();
}
void LvStatusBar::setBatteryPercent(int pct) {
if (_battPct == pct) return;
_battPct = pct;
if (pct >= 0) {
char buf[8];
snprintf(buf, sizeof(buf), "%d%%", pct);
lv_label_set_text(_lblBatt, buf);
uint32_t col = Theme::PRIMARY;
if (pct <= 15) col = Theme::ERROR_CLR;
else if (pct <= 30) col = Theme::WARNING_CLR;
lv_obj_set_style_text_color(_lblBatt, lv_color_hex(col), 0);
}
}
void LvStatusBar::setTransportMode(const char* mode) {
(void)mode;
}
void LvStatusBar::flashAnnounce() {
_announceFlashEnd = millis() + 1000;
refreshIndicators();
}
void LvStatusBar::showToast(const char* msg, uint32_t durationMs) {
lv_label_set_text(_lblToast, msg);
_toastEnd = millis() + durationMs;
lv_obj_clear_flag(_toast, LV_OBJ_FLAG_HIDDEN);
}
void LvStatusBar::refreshIndicators() {
bool flashing = _announceFlashEnd > 0 && millis() < _announceFlashEnd;
// LoRa: green=online, red=offline, cyan if TX flash
if (flashing) {
lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ACCENT), 0);
} else if (_loraOnline) {
lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0);
} else {
lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0);
}
// BLE: green=active, yellow=enabled-not-connected, red=disabled
if (_bleActive) {
lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::PRIMARY), 0);
} else if (_bleEnabled) {
lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::WARNING_CLR), 0);
} else {
lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::ERROR_CLR), 0);
}
// WiFi: green=connected, yellow=enabled-not-connected, red=disabled
if (_wifiActive) {
lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::PRIMARY), 0);
} else if (_wifiEnabled) {
lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::WARNING_CLR), 0);
} else {
lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::ERROR_CLR), 0);
}
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <lvgl.h>
#include <string>
class LvStatusBar {
public:
void create(lv_obj_t* parent);
void update();
// Status indicators: green=active, yellow=enabled-but-unconfigured, red=off
void setLoRaOnline(bool online);
void setBLEActive(bool active);
void setBLEEnabled(bool enabled) { _bleEnabled = enabled; refreshIndicators(); }
void setWiFiActive(bool active);
void setWiFiEnabled(bool enabled) { _wifiEnabled = enabled; refreshIndicators(); }
void setBatteryPercent(int pct);
void setTransportMode(const char* mode);
void flashAnnounce();
void showToast(const char* msg, uint32_t durationMs = 1500);
lv_obj_t* obj() { return _bar; }
private:
void refreshIndicators();
lv_obj_t* _bar = nullptr;
lv_obj_t* _lblLora = nullptr;
lv_obj_t* _lblBle = nullptr;
lv_obj_t* _lblWifi = nullptr;
lv_obj_t* _lblBrand = nullptr;
lv_obj_t* _lblBatt = nullptr;
lv_obj_t* _toast = nullptr;
lv_obj_t* _lblToast = nullptr;
bool _loraOnline = false;
bool _bleActive = false;
bool _bleEnabled = false;
bool _wifiActive = false;
bool _wifiEnabled = false;
int _battPct = -1;
unsigned long _announceFlashEnd = 0;
unsigned long _toastEnd = 0;
};
+81
View File
@@ -0,0 +1,81 @@
#include "LvTabBar.h"
#include "Theme.h"
#include <cstdio>
constexpr const char* LvTabBar::TAB_NAMES[TAB_COUNT];
static void tab_click_cb(lv_event_t* e) {
LvTabBar* bar = (LvTabBar*)lv_event_get_user_data(e);
lv_obj_t* target = lv_event_get_target(e);
for (int i = 0; i < LvTabBar::TAB_COUNT; i++) {
lv_obj_t* parent = lv_obj_get_parent(target);
if (target == lv_obj_get_child(bar->obj(), i) ||
(parent && parent == lv_obj_get_child(bar->obj(), i))) {
bar->setActiveTab(i);
break;
}
}
}
void LvTabBar::create(lv_obj_t* parent) {
_bar = lv_obj_create(parent);
lv_obj_set_size(_bar, Theme::SCREEN_W, Theme::TAB_BAR_H);
lv_obj_align(_bar, LV_ALIGN_BOTTOM_LEFT, 0, 0);
lv_obj_set_style_bg_color(_bar, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(_bar, 1, 0);
lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_pad_all(_bar, 0, 0);
lv_obj_set_style_radius(_bar, 0, 0);
lv_obj_clear_flag(_bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_layout(_bar, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
const lv_font_t* font = &lv_font_montserrat_12;
for (int i = 0; i < TAB_COUNT; i++) {
_tabs[i] = lv_label_create(_bar);
lv_obj_set_style_text_font(_tabs[i], font, 0);
lv_label_set_text(_tabs[i], TAB_NAMES[i]);
lv_obj_add_flag(_tabs[i], LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(_tabs[i], tab_click_cb, LV_EVENT_CLICKED, this);
}
refreshTabs();
}
void LvTabBar::setActiveTab(int tab) {
if (tab < 0 || tab >= TAB_COUNT) return;
_activeTab = tab;
refreshTabs();
if (_tabCb) _tabCb(tab);
}
void LvTabBar::cycleTab(int direction) {
int next = (_activeTab + direction + TAB_COUNT) % TAB_COUNT;
setActiveTab(next);
}
void LvTabBar::setUnreadCount(int tab, int count) {
if (tab < 0 || tab >= TAB_COUNT) return;
_unread[tab] = count;
refreshTabs();
}
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);
}
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <lvgl.h>
class LvTabBar {
public:
enum Tab { TAB_HOME = 0, TAB_MSGS, TAB_NODES, TAB_SETUP, TAB_COUNT = 4 };
void create(lv_obj_t* parent);
void setActiveTab(int tab);
int getActiveTab() const { return _activeTab; }
void cycleTab(int direction);
void setUnreadCount(int tab, int count);
using TabCallback = void(*)(int tab);
void setTabCallback(TabCallback cb) { _tabCb = cb; }
lv_obj_t* obj() { return _bar; }
private:
void refreshTabs();
lv_obj_t* _bar = nullptr;
lv_obj_t* _tabs[TAB_COUNT] = {};
int _activeTab = TAB_HOME;
int _unread[TAB_COUNT] = {};
TabCallback _tabCb = nullptr;
static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Msgs", "Nodes", "Setup"};
};
+147
View File
@@ -0,0 +1,147 @@
#include "LvTheme.h"
#include "Theme.h"
#include <Arduino.h>
namespace LvTheme {
static lv_style_t s_screen;
static lv_style_t s_label;
static lv_style_t s_labelMuted;
static lv_style_t s_labelAccent;
static lv_style_t s_btn;
static lv_style_t s_btnPressed;
static lv_style_t s_bar;
static lv_style_t s_barIndicator;
static lv_style_t s_switch;
static lv_style_t s_switchChecked;
static lv_style_t s_textarea;
static lv_style_t s_list;
static lv_style_t s_listBtn;
static lv_style_t s_listBtnFocused;
static lv_style_t s_dropdown;
static lv_style_t s_slider;
void init(lv_disp_t* disp) {
// Screen background
lv_style_init(&s_screen);
lv_style_set_bg_color(&s_screen, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_screen, LV_OPA_COVER);
lv_style_set_text_color(&s_screen, lv_color_hex(Theme::PRIMARY));
lv_style_set_text_font(&s_screen, &lv_font_montserrat_14);
// Labels
lv_style_init(&s_label);
lv_style_set_text_color(&s_label, lv_color_hex(Theme::PRIMARY));
lv_style_init(&s_labelMuted);
lv_style_set_text_color(&s_labelMuted, lv_color_hex(Theme::MUTED));
lv_style_init(&s_labelAccent);
lv_style_set_text_color(&s_labelAccent, lv_color_hex(Theme::ACCENT));
// Buttons
lv_style_init(&s_btn);
lv_style_set_bg_color(&s_btn, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_btn, LV_OPA_COVER);
lv_style_set_border_color(&s_btn, lv_color_hex(Theme::BORDER));
lv_style_set_border_width(&s_btn, 1);
lv_style_set_text_color(&s_btn, lv_color_hex(Theme::PRIMARY));
lv_style_set_radius(&s_btn, 3);
lv_style_set_pad_all(&s_btn, 6);
lv_style_init(&s_btnPressed);
lv_style_set_bg_color(&s_btnPressed, lv_color_hex(Theme::SELECTION_BG));
lv_style_set_border_color(&s_btnPressed, lv_color_hex(Theme::PRIMARY));
// Bar
lv_style_init(&s_bar);
lv_style_set_bg_color(&s_bar, lv_color_hex(Theme::BORDER));
lv_style_set_bg_opa(&s_bar, LV_OPA_COVER);
lv_style_set_radius(&s_bar, 2);
lv_style_init(&s_barIndicator);
lv_style_set_bg_color(&s_barIndicator, lv_color_hex(Theme::PRIMARY));
lv_style_set_bg_opa(&s_barIndicator, LV_OPA_COVER);
lv_style_set_radius(&s_barIndicator, 2);
// Switch
lv_style_init(&s_switch);
lv_style_set_bg_color(&s_switch, lv_color_hex(Theme::MUTED));
lv_style_set_bg_opa(&s_switch, LV_OPA_COVER);
lv_style_set_radius(&s_switch, LV_RADIUS_CIRCLE);
lv_style_init(&s_switchChecked);
lv_style_set_bg_color(&s_switchChecked, lv_color_hex(Theme::PRIMARY));
// Textarea
lv_style_init(&s_textarea);
lv_style_set_bg_color(&s_textarea, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_textarea, LV_OPA_COVER);
lv_style_set_border_color(&s_textarea, lv_color_hex(Theme::BORDER));
lv_style_set_border_width(&s_textarea, 1);
lv_style_set_text_color(&s_textarea, lv_color_hex(Theme::PRIMARY));
lv_style_set_radius(&s_textarea, 2);
lv_style_set_pad_all(&s_textarea, 6);
// List
lv_style_init(&s_list);
lv_style_set_bg_color(&s_list, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_list, LV_OPA_COVER);
lv_style_set_pad_all(&s_list, 0);
lv_style_set_pad_row(&s_list, 0);
lv_style_set_border_width(&s_list, 0);
lv_style_init(&s_listBtn);
lv_style_set_bg_color(&s_listBtn, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_listBtn, LV_OPA_COVER);
lv_style_set_text_color(&s_listBtn, lv_color_hex(Theme::PRIMARY));
lv_style_set_border_color(&s_listBtn, lv_color_hex(Theme::BORDER));
lv_style_set_border_width(&s_listBtn, 0);
lv_style_set_border_side(&s_listBtn, LV_BORDER_SIDE_BOTTOM);
lv_style_set_pad_all(&s_listBtn, 5);
lv_style_set_radius(&s_listBtn, 0);
lv_style_init(&s_listBtnFocused);
lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::SELECTION_BG));
lv_style_set_border_color(&s_listBtnFocused, lv_color_hex(Theme::PRIMARY));
lv_style_set_border_width(&s_listBtnFocused, 1);
// Dropdown
lv_style_init(&s_dropdown);
lv_style_set_bg_color(&s_dropdown, lv_color_hex(Theme::BG));
lv_style_set_bg_opa(&s_dropdown, LV_OPA_COVER);
lv_style_set_border_color(&s_dropdown, lv_color_hex(Theme::BORDER));
lv_style_set_border_width(&s_dropdown, 1);
lv_style_set_text_color(&s_dropdown, lv_color_hex(Theme::PRIMARY));
lv_style_set_radius(&s_dropdown, 2);
lv_style_set_pad_all(&s_dropdown, 4);
// Slider
lv_style_init(&s_slider);
lv_style_set_bg_color(&s_slider, lv_color_hex(Theme::BORDER));
lv_style_set_bg_opa(&s_slider, LV_OPA_COVER);
// Apply screen style to default theme
lv_obj_add_style(lv_scr_act(), &s_screen, 0);
Serial.println("[LVGL] Ratspeak theme initialized");
}
lv_style_t* styleScreen() { return &s_screen; }
lv_style_t* styleLabel() { return &s_label; }
lv_style_t* styleLabelMuted() { return &s_labelMuted; }
lv_style_t* styleLabelAccent() { return &s_labelAccent; }
lv_style_t* styleBtn() { return &s_btn; }
lv_style_t* styleBtnPressed() { return &s_btnPressed; }
lv_style_t* styleBar() { return &s_bar; }
lv_style_t* styleBarIndicator() { return &s_barIndicator; }
lv_style_t* styleSwitch() { return &s_switch; }
lv_style_t* styleSwitchChecked() { return &s_switchChecked; }
lv_style_t* styleTextarea() { return &s_textarea; }
lv_style_t* styleList() { return &s_list; }
lv_style_t* styleListBtn() { return &s_listBtn; }
lv_style_t* styleListBtnFocused() { return &s_listBtnFocused; }
lv_style_t* styleDropdown() { return &s_dropdown; }
lv_style_t* styleSlider() { return &s_slider; }
} // namespace LvTheme
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <lvgl.h>
// Ratspeak LVGL theme — matrix green on black cyberpunk aesthetic
namespace LvTheme {
void init(lv_disp_t* disp);
// Style accessors for common elements
lv_style_t* styleScreen();
lv_style_t* styleLabel();
lv_style_t* styleLabelMuted();
lv_style_t* styleLabelAccent();
lv_style_t* styleBtn();
lv_style_t* styleBtnPressed();
lv_style_t* styleBar();
lv_style_t* styleBarIndicator();
lv_style_t* styleSwitch();
lv_style_t* styleSwitchChecked();
lv_style_t* styleTextarea();
lv_style_t* styleList();
lv_style_t* styleListBtn();
lv_style_t* styleListBtnFocused();
lv_style_t* styleDropdown();
lv_style_t* styleSlider();
} // namespace LvTheme
+14 -8
View File
@@ -3,7 +3,7 @@
#include <cstdint>
// =============================================================================
// Ratdeck — Cyberpunk Theme Constants (LovyanGFX direct drawing)
// Ratdeck — Cyberpunk Theme Constants
// =============================================================================
namespace Theme {
@@ -11,24 +11,24 @@ namespace Theme {
// --- Colors (RGB888 for LovyanGFX) ---
constexpr uint32_t BG = 0x000000; // Pure black
constexpr uint32_t PRIMARY = 0x00FF41; // Matrix green
constexpr uint32_t SECONDARY = 0x00CC33; // Dimmed green
constexpr uint32_t SECONDARY = 0x00DD44; // Dimmed green
constexpr uint32_t ACCENT = 0x00FFFF; // Cyan
constexpr uint32_t MUTED = 0x336633; // Dark green
constexpr uint32_t MUTED = 0x559955; // Readable muted green
constexpr uint32_t ERROR_CLR = 0xFF3333; // Red
constexpr uint32_t WARNING_CLR = 0xFFFF00; // Yellow
constexpr uint32_t BORDER = 0x004400; // Subtle green
constexpr uint32_t SELECTION_BG = 0x003300; // Highlight
constexpr uint32_t BORDER = 0x007700; // Visible green border
constexpr uint32_t SELECTION_BG = 0x004400; // Highlight
constexpr uint32_t MSG_OUT_BG = 0x002200; // Outgoing bubble
constexpr uint32_t MSG_IN_BG = 0x1A1A2E; // Incoming bubble
constexpr uint32_t TAB_ACTIVE = 0x00FF41;
constexpr uint32_t TAB_INACTIVE = 0x336633;
constexpr uint32_t TAB_INACTIVE = 0x559955;
constexpr uint32_t BADGE_BG = 0xFF3333;
// --- Layout Metrics ---
constexpr int SCREEN_W = 320;
constexpr int SCREEN_H = 240;
constexpr int STATUS_BAR_H = 14;
constexpr int TAB_BAR_H = 14;
constexpr int STATUS_BAR_H = 20;
constexpr int TAB_BAR_H = 20;
constexpr int CONTENT_Y = STATUS_BAR_H;
constexpr int CONTENT_H = SCREEN_H - STATUS_BAR_H - TAB_BAR_H;
constexpr int CONTENT_W = SCREEN_W;
@@ -38,3 +38,9 @@ constexpr int TAB_COUNT = 5;
constexpr int TAB_W = SCREEN_W / TAB_COUNT;
} // namespace Theme
// LVGL color helper — available when LVGL is included
#ifdef LV_CONF_H
#include <lvgl.h>
inline lv_color_t lvColor(uint32_t rgb888) { return lv_color_hex(rgb888); }
#endif
+113 -1
View File
@@ -1,14 +1,59 @@
#include "UIManager.h"
#include "Theme.h"
#include "LvTheme.h"
#include "hal/Display.h"
// --- LvScreen base ---
void LvScreen::destroyUI() {
// Content is owned by UIManager's _lvContent — just clear our pointers.
// The UIManager calls lv_obj_clean(_lvContent) before creating the next screen.
_screen = nullptr;
}
// --- UIManager ---
void UIManager::begin(LGFX_TDeck* gfx) {
_gfx = gfx;
_statusBar.setGfx(gfx);
_tabBar.setGfx(gfx);
// Initialize LVGL theme and create persistent UI structure
lv_obj_t* scr = lv_scr_act();
LvTheme::init(lv_disp_get_default());
// Apply screen background style
lv_obj_add_style(scr, LvTheme::styleScreen(), 0);
// Create LVGL status bar (top)
_lvStatusBar.create(scr);
// Create LVGL tab bar (bottom)
_lvTabBar.create(scr);
// Create content area between status bar and tab bar
_lvContent = lv_obj_create(scr);
lv_obj_set_pos(_lvContent, 0, Theme::STATUS_BAR_H);
lv_obj_set_size(_lvContent, Theme::CONTENT_W, Theme::CONTENT_H);
lv_obj_set_style_bg_color(_lvContent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_lvContent, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_lvContent, 0, 0);
lv_obj_set_style_pad_all(_lvContent, 0, 0);
lv_obj_set_style_radius(_lvContent, 0, 0);
_lvglActive = true;
Serial.println("[UI] LVGL UI structure created");
}
void UIManager::setScreen(Screen* screen) {
// When setting a legacy screen, hide LVGL content and use legacy rendering
if (_currentLvScreen) {
_currentLvScreen->onExit();
_currentLvScreen->destroyUI();
_currentLvScreen = nullptr;
}
if (_currentScreen) {
_currentScreen->onExit();
}
@@ -20,9 +65,48 @@ void UIManager::setScreen(Screen* screen) {
_currentScreen->markDirty();
}
// Hide LVGL layers for legacy rendering
if (_lvglActive) {
lv_obj_add_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_lvContent, LV_OBJ_FLAG_HIDDEN);
}
forceRedraw();
}
void UIManager::setLvScreen(LvScreen* screen) {
// Transition from legacy screen
if (_currentScreen) {
_currentScreen->onExit();
_currentScreen = nullptr;
}
// Transition from previous LVGL screen
if (_currentLvScreen) {
_currentLvScreen->onExit();
_currentLvScreen->destroyUI();
}
_currentLvScreen = screen;
// Show LVGL layers
if (_lvglActive) {
if (!_bootMode) {
lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN);
}
lv_obj_clear_flag(_lvContent, LV_OBJ_FLAG_HIDDEN);
}
if (_currentLvScreen) {
// Clean content area
lv_obj_clean(_lvContent);
_currentLvScreen->createUI(_lvContent);
_currentLvScreen->onEnter();
}
}
void UIManager::setOverlay(Screen* overlay) {
_overlay = overlay;
if (_overlay) {
@@ -33,17 +117,36 @@ void UIManager::setOverlay(Screen* overlay) {
void UIManager::setBootMode(bool boot) {
_bootMode = boot;
if (_lvglActive) {
if (boot) {
lv_obj_add_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN);
// In boot mode, content area is full screen
lv_obj_set_pos(_lvContent, 0, 0);
lv_obj_set_size(_lvContent, Theme::SCREEN_W, Theme::SCREEN_H);
} else {
lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN);
lv_obj_set_pos(_lvContent, 0, Theme::STATUS_BAR_H);
lv_obj_set_size(_lvContent, Theme::CONTENT_W, Theme::CONTENT_H);
}
}
forceRedraw();
}
void UIManager::update() {
_statusBar.update();
_lvStatusBar.update();
if (_currentScreen) _currentScreen->update();
if (_currentLvScreen) _currentLvScreen->refreshUI();
}
void UIManager::render() {
if (!_gfx) return;
// If an LVGL screen is active, don't do legacy rendering
if (_currentLvScreen) return;
bool needStatusRedraw = _statusBar.isDirty();
bool needTabRedraw = _tabBar.isDirty();
bool needContentRedraw = (_currentScreen && _currentScreen->isDirty());
@@ -59,7 +162,6 @@ void UIManager::render() {
if (needContentRedraw) {
if (_bootMode) {
// In boot mode, content area is full screen
_gfx->setClipRect(0, 0, Theme::SCREEN_W, Theme::SCREEN_H);
} else {
_gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H);
@@ -96,8 +198,18 @@ bool UIManager::handleKey(const KeyEvent& event) {
if (_overlay) {
return _overlay->handleKey(event);
}
if (_currentLvScreen) {
return _currentLvScreen->handleKey(event);
}
if (_currentScreen) {
return _currentScreen->handleKey(event);
}
return false;
}
bool UIManager::handleLongPress() {
if (_currentLvScreen) {
return _currentLvScreen->handleLongPress();
}
return false;
}
+49 -4
View File
@@ -1,11 +1,15 @@
#pragma once
#include <lvgl.h>
#include "LvStatusBar.h"
#include "LvTabBar.h"
#include "StatusBar.h"
#include "TabBar.h"
#include "hal/Keyboard.h"
class LGFX_TDeck;
// Legacy screen base class (LovyanGFX direct drawing)
class Screen {
public:
virtual ~Screen() = default;
@@ -24,22 +28,49 @@ protected:
bool _dirty = true;
};
// LVGL screen base class
class LvScreen {
public:
virtual ~LvScreen() = default;
virtual void createUI(lv_obj_t* parent) = 0;
virtual void destroyUI();
virtual void refreshUI() {}
virtual void onEnter() {}
virtual void onExit() {}
virtual bool handleKey(const KeyEvent& event) { return false; }
virtual bool handleLongPress() { return false; }
virtual const char* title() const = 0;
lv_obj_t* screen() const { return _screen; }
protected:
lv_obj_t* _screen = nullptr;
};
class UIManager {
public:
void begin(LGFX_TDeck* gfx);
// Screen management
// Legacy screen management (LovyanGFX)
void setScreen(Screen* screen);
Screen* getScreen() { return _currentScreen; }
// Component access
// LVGL screen management
void setLvScreen(LvScreen* screen);
LvScreen* getLvScreen() { return _currentLvScreen; }
// Component access — legacy (for old code compatibility)
StatusBar& statusBar() { return _statusBar; }
TabBar& tabBar() { return _tabBar; }
// Component access — LVGL
LvStatusBar& lvStatusBar() { return _lvStatusBar; }
LvTabBar& lvTabBar() { return _lvTabBar; }
// Update data (called periodically)
void update();
// Render if dirty (called from loop)
// Render if dirty (called from loop — legacy screens only)
void render();
// Force full redraw
@@ -47,19 +78,33 @@ public:
// Handle key event — routes to current screen
bool handleKey(const KeyEvent& event);
bool handleLongPress();
// Boot mode — hides status bar and tab bar
void setBootMode(bool boot);
bool isBootMode() const { return _bootMode; }
// Overlay support
// Overlay support (legacy)
void setOverlay(Screen* overlay);
// LVGL content area parent (between status bar and tab bar)
lv_obj_t* contentParent() { return _lvContent; }
private:
LGFX_TDeck* _gfx = nullptr;
// Legacy components
StatusBar _statusBar;
TabBar _tabBar;
Screen* _currentScreen = nullptr;
Screen* _overlay = nullptr;
// LVGL components
LvStatusBar _lvStatusBar;
LvTabBar _lvTabBar;
LvScreen* _currentLvScreen = nullptr;
lv_obj_t* _lvContent = nullptr;
bool _bootMode = false;
bool _lvglActive = false;
};
+54
View File
@@ -0,0 +1,54 @@
#include "LvBootScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "config/Config.h"
void LvBootScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
// Title: "RATDECK"
_lblTitle = lv_label_create(parent);
lv_obj_set_style_text_font(_lblTitle, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(_lblTitle, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(_lblTitle, "RATDECK");
lv_obj_align(_lblTitle, LV_ALIGN_TOP_MID, 0, 60);
// Version
_lblVersion = lv_label_create(parent);
lv_obj_set_style_text_font(_lblVersion, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(_lblVersion, lv_color_hex(Theme::SECONDARY), 0);
char ver[32];
snprintf(ver, sizeof(ver), "v%s", RATDECK_VERSION_STRING);
lv_label_set_text(_lblVersion, ver);
lv_obj_align(_lblVersion, LV_ALIGN_TOP_MID, 0, 82);
// Progress bar
_bar = lv_bar_create(parent);
lv_obj_set_size(_bar, 200, 10);
lv_obj_align(_bar, LV_ALIGN_TOP_MID, 0, 105);
lv_bar_set_range(_bar, 0, 100);
lv_bar_set_value(_bar, 0, LV_ANIM_OFF);
lv_obj_add_style(_bar, LvTheme::styleBar(), LV_PART_MAIN);
lv_obj_add_style(_bar, LvTheme::styleBarIndicator(), LV_PART_INDICATOR);
// Status text
_lblStatus = lv_label_create(parent);
lv_obj_set_style_text_font(_lblStatus, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(_lblStatus, "Starting...");
lv_obj_align(_lblStatus, LV_ALIGN_TOP_MID, 0, 128);
}
void LvBootScreen::setProgress(float progress, const char* status) {
if (_bar) {
lv_bar_set_value(_bar, (int)(progress * 100), LV_ANIM_OFF);
}
if (_lblStatus) {
lv_label_set_text(_lblStatus, status);
}
// Force LVGL to flush during boot (before main loop)
lv_timer_handler();
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "ui/UIManager.h"
class LvBootScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
const char* title() const override { return "Boot"; }
void setProgress(float progress, const char* status);
private:
lv_obj_t* _lblTitle = nullptr;
lv_obj_t* _lblVersion = nullptr;
lv_obj_t* _bar = nullptr;
lv_obj_t* _lblStatus = nullptr;
};
+66
View File
@@ -0,0 +1,66 @@
#include "LvHelpOverlay.h"
#include "ui/Theme.h"
#include <lvgl.h>
void LvHelpOverlay::create() {
_overlay = lv_obj_create(lv_layer_top());
lv_obj_set_size(_overlay, Theme::CONTENT_W - 40, Theme::CONTENT_H - 20);
lv_obj_center(_overlay);
lv_obj_set_style_bg_color(_overlay, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_overlay, 240, 0);
lv_obj_set_style_border_color(_overlay, lv_color_hex(Theme::PRIMARY), 0);
lv_obj_set_style_border_width(_overlay, 1, 0);
lv_obj_set_style_radius(_overlay, 4, 0);
lv_obj_set_style_pad_all(_overlay, 10, 0);
lv_obj_set_style_pad_row(_overlay, 3, 0);
lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN);
const lv_font_t* font = &lv_font_montserrat_12;
auto mkLabel = [&](const char* text, uint32_t color) {
lv_obj_t* lbl = lv_label_create(_overlay);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0);
lv_label_set_text(lbl, text);
};
mkLabel("HOTKEYS", Theme::ACCENT);
mkLabel("Ctrl+H Help", Theme::PRIMARY);
mkLabel("Ctrl+M Messages", Theme::PRIMARY);
mkLabel("Ctrl+N New Message", Theme::PRIMARY);
mkLabel("Ctrl+S Settings", Theme::PRIMARY);
mkLabel("Ctrl+A Announce", Theme::PRIMARY);
mkLabel("Ctrl+D Diagnostics", Theme::PRIMARY);
mkLabel("Ctrl+T Radio Test", Theme::PRIMARY);
mkLabel("Ctrl+R RSSI Monitor", Theme::PRIMARY);
mkLabel(", / Prev/Next Tab", Theme::PRIMARY);
mkLabel("Esc Back", Theme::PRIMARY);
mkLabel("", Theme::MUTED);
mkLabel("Any key to close", Theme::MUTED);
lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN);
}
void LvHelpOverlay::show() {
if (!_overlay) create();
lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN);
_visible = true;
}
void LvHelpOverlay::hide() {
if (_overlay) lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN);
_visible = false;
}
void LvHelpOverlay::toggle() {
if (_visible) hide(); else show();
}
bool LvHelpOverlay::handleKey(const KeyEvent& event) {
if (_visible) {
hide();
return true;
}
return false;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "ui/UIManager.h"
class LvHelpOverlay {
public:
void create();
void show();
void hide();
bool isVisible() const { return _visible; }
void toggle();
bool handleKey(const KeyEvent& event);
private:
lv_obj_t* _overlay = nullptr;
bool _visible = false;
};
+97
View File
@@ -0,0 +1,97 @@
#include "LvHomeScreen.h"
#include "ui/Theme.h"
#include "reticulum/ReticulumManager.h"
#include "radio/SX1262.h"
#include "config/UserConfig.h"
#include <Arduino.h>
#include <esp_system.h>
void LvHomeScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_set_layout(parent, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 6, 0);
lv_obj_set_style_pad_row(parent, 4, 0);
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
const lv_font_t* font = &lv_font_montserrat_14;
auto mkLabel = [&](const char* initial) -> lv_obj_t* {
lv_obj_t* lbl = lv_label_create(parent);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(lbl, initial);
return lbl;
};
_lblId = mkLabel("Identity: ...");
_lblTransport = mkLabel("Transport: ...");
_lblPaths = mkLabel("Paths: ...");
_lblLora = mkLabel("Radio: ...");
_lblHeap = mkLabel("Heap: ...");
_lblPsram = mkLabel("PSRAM: ...");
_lblUptime = mkLabel("Uptime: 0m");
// Force refresh on new UI — invalidate cache
_lastUptime = ULONG_MAX;
_lastHeap = UINT32_MAX;
refreshUI();
}
void LvHomeScreen::onEnter() {
// Invalidate cache so refreshUI() always updates after screen transition
_lastUptime = ULONG_MAX;
_lastHeap = UINT32_MAX;
refreshUI();
}
void LvHomeScreen::refreshUI() {
if (!_lblId) return;
unsigned long upMins = millis() / 60000;
uint32_t heap = ESP.getFreeHeap() / 1024;
if (upMins == _lastUptime && heap == _lastHeap) return;
_lastUptime = upMins;
_lastHeap = heap;
if (_rns) {
lv_label_set_text_fmt(_lblId, "ID: %s", _rns->identityHash().c_str());
lv_label_set_text_fmt(_lblTransport, "Transport: %s",
_rns->isTransportActive() ? "ACTIVE" : "OFFLINE");
lv_label_set_text_fmt(_lblPaths, "Paths: %d Links: %d",
(int)_rns->pathCount(), (int)_rns->linkCount());
} else {
lv_label_set_text(_lblId, "Identity: ---");
lv_label_set_text(_lblTransport, "Transport: OFFLINE");
lv_label_set_text(_lblPaths, "Paths: 0 Links: 0");
}
if (_radio && _radio->isRadioOnline()) {
lv_label_set_text_fmt(_lblLora, "LoRa: SF%d BW%luk %ddBm",
_radio->getSpreadingFactor(),
(unsigned long)(_radio->getSignalBandwidth() / 1000),
_radio->getTxPower());
lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0);
} else {
lv_label_set_text(_lblLora, "Radio: OFFLINE");
lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0);
}
lv_label_set_text_fmt(_lblHeap, "Heap: %lukB free",
(unsigned long)(ESP.getFreeHeap() / 1024));
lv_label_set_text_fmt(_lblPsram, "PSRAM: %lukB free",
(unsigned long)(ESP.getFreePsram() / 1024));
if (upMins >= 60) {
lv_label_set_text_fmt(_lblUptime, "Uptime: %luh %lum", upMins / 60, upMins % 60);
} else {
lv_label_set_text_fmt(_lblUptime, "Uptime: %lum", upMins);
}
}
bool LvHomeScreen::handleKey(const KeyEvent& event) {
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_announceCb) _announceCb();
return true;
}
return false;
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
class ReticulumManager;
class SX1262;
class UserConfig;
class LvHomeScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
void refreshUI() override;
void onEnter() override;
bool handleKey(const KeyEvent& event) override;
void setReticulumManager(ReticulumManager* rns) { _rns = rns; }
void setRadio(SX1262* radio) { _radio = radio; }
void setUserConfig(UserConfig* cfg) { _cfg = cfg; }
void setAnnounceCallback(std::function<void()> cb) { _announceCb = cb; }
const char* title() const override { return "Home"; }
private:
ReticulumManager* _rns = nullptr;
SX1262* _radio = nullptr;
UserConfig* _cfg = nullptr;
std::function<void()> _announceCb;
unsigned long _lastUptime = 0;
uint32_t _lastHeap = 0;
lv_obj_t* _lblId = nullptr;
lv_obj_t* _lblTransport = nullptr;
lv_obj_t* _lblPaths = nullptr;
lv_obj_t* _lblLora = nullptr;
lv_obj_t* _lblHeap = nullptr;
lv_obj_t* _lblPsram = nullptr;
lv_obj_t* _lblUptime = nullptr;
};
+14
View File
@@ -0,0 +1,14 @@
#include "LvMapScreen.h"
#include "ui/Theme.h"
void LvMapScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* lbl = lv_label_create(parent);
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(lbl, "Map\n\nComing soon\n\nNode topology view\nwill appear here");
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(lbl);
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "ui/UIManager.h"
class LvMapScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
const char* title() const override { return "Map"; }
};
+283
View File
@@ -0,0 +1,283 @@
#include "LvMessageView.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/AnnounceManager.h"
#include <Arduino.h>
#include <time.h>
std::string LvMessageView::getPeerName() {
if (_am) {
std::string name = _am->lookupName(_peerHex);
if (!name.empty()) return name;
}
return _peerHex.substr(0, 12);
}
void LvMessageView::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_pad_all(parent, 0, 0);
// Use flex column layout: header, messages (grows), input
lv_obj_set_layout(parent, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, 0);
const lv_font_t* font = &lv_font_montserrat_12;
int headerH = 22;
int inputH = 28;
// Header bar (top)
_header = lv_obj_create(parent);
lv_obj_set_size(_header, lv_pct(100), headerH);
lv_obj_set_style_bg_color(_header, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_header, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(_header, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(_header, 1, 0);
lv_obj_set_style_border_side(_header, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(_header, 0, 0);
lv_obj_set_style_radius(_header, 0, 0);
lv_obj_clear_flag(_header, LV_OBJ_FLAG_SCROLLABLE);
_lblHeader = lv_label_create(_header);
lv_obj_set_style_text_font(_lblHeader, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(_lblHeader, lv_color_hex(Theme::ACCENT), 0);
lv_obj_align(_lblHeader, LV_ALIGN_LEFT_MID, 4, 0);
// Message scroll area (middle, grows to fill)
_msgScroll = lv_obj_create(parent);
lv_obj_set_width(_msgScroll, lv_pct(100));
lv_obj_set_flex_grow(_msgScroll, 1);
lv_obj_set_style_bg_color(_msgScroll, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_msgScroll, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_msgScroll, 0, 0);
lv_obj_set_style_pad_all(_msgScroll, 4, 0);
lv_obj_set_style_pad_row(_msgScroll, 6, 0);
lv_obj_set_style_radius(_msgScroll, 0, 0);
lv_obj_set_layout(_msgScroll, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_msgScroll, LV_FLEX_FLOW_COLUMN);
// Input row (bottom, just above tab bar)
_inputRow = lv_obj_create(parent);
lv_obj_set_size(_inputRow, lv_pct(100), inputH);
lv_obj_set_style_bg_color(_inputRow, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_inputRow, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(_inputRow, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(_inputRow, 1, 0);
lv_obj_set_style_border_side(_inputRow, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_pad_all(_inputRow, 3, 0);
lv_obj_set_style_radius(_inputRow, 0, 0);
lv_obj_clear_flag(_inputRow, LV_OBJ_FLAG_SCROLLABLE);
_textarea = lv_textarea_create(_inputRow);
lv_obj_set_size(_textarea, Theme::CONTENT_W - 50, 22);
lv_obj_align(_textarea, LV_ALIGN_LEFT_MID, 0, 0);
lv_textarea_set_one_line(_textarea, true);
lv_textarea_set_placeholder_text(_textarea, "Type message...");
lv_obj_set_style_bg_color(_textarea, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_border_width(_textarea, 0, 0);
lv_obj_set_style_text_color(_textarea, lv_color_hex(Theme::PRIMARY), 0);
lv_obj_set_style_text_font(_textarea, font, 0);
lv_obj_set_style_pad_all(_textarea, 2, 0);
_btnSend = lv_btn_create(_inputRow);
lv_obj_set_size(_btnSend, 40, 22);
lv_obj_align(_btnSend, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_bg_color(_btnSend, lv_color_hex(Theme::SELECTION_BG), 0);
lv_obj_set_style_radius(_btnSend, 3, 0);
lv_obj_set_style_pad_all(_btnSend, 0, 0);
lv_obj_t* sendLbl = lv_label_create(_btnSend);
lv_obj_set_style_text_font(sendLbl, &lv_font_montserrat_10, 0);
lv_obj_set_style_text_color(sendLbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(sendLbl, "Send");
lv_obj_center(sendLbl);
}
void LvMessageView::destroyUI() {
_header = nullptr;
_lblHeader = nullptr;
_msgScroll = nullptr;
_inputRow = nullptr;
_textarea = nullptr;
_btnSend = nullptr;
LvScreen::destroyUI();
}
void LvMessageView::onEnter() {
if (_lxmf) _lxmf->markRead(_peerHex);
_lastMsgCount = -1;
_lastRefreshMs = 0;
_inputText.clear();
if (_lblHeader) {
char header[48];
snprintf(header, sizeof(header), "< %s", getPeerName().c_str());
lv_label_set_text(_lblHeader, header);
}
if (_textarea) {
lv_textarea_set_text(_textarea, "");
}
rebuildMessages();
}
void LvMessageView::onExit() {
_inputText.clear();
_cachedMsgs.clear();
}
void LvMessageView::refreshUI() {
if (!_lxmf) return;
unsigned long now = millis();
if (now - _lastRefreshMs >= REFRESH_INTERVAL_MS) {
int oldCount = (int)_cachedMsgs.size();
_cachedMsgs = _lxmf->getMessages(_peerHex);
_lastRefreshMs = now;
if ((int)_cachedMsgs.size() != oldCount) {
_lastMsgCount = (int)_cachedMsgs.size();
rebuildMessages();
}
}
}
void LvMessageView::rebuildMessages() {
if (!_lxmf || !_msgScroll) return;
_cachedMsgs = _lxmf->getMessages(_peerHex);
_lastRefreshMs = millis();
lv_obj_clean(_msgScroll);
const lv_font_t* font = &lv_font_montserrat_12;
int maxBubbleW = Theme::CONTENT_W * 3 / 4;
for (const auto& msg : _cachedMsgs) {
// Bubble container
lv_obj_t* bubble = lv_obj_create(_msgScroll);
lv_obj_set_width(bubble, Theme::CONTENT_W - 12);
lv_obj_set_style_pad_all(bubble, 0, 0);
lv_obj_set_style_bg_opa(bubble, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(bubble, 0, 0);
lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_height(bubble, LV_SIZE_CONTENT);
// Message text in a rounded box
lv_obj_t* box = lv_obj_create(bubble);
lv_obj_set_style_radius(box, 4, 0);
lv_obj_set_style_pad_all(box, 5, 0);
lv_obj_set_style_border_width(box, 0, 0);
lv_obj_set_width(box, LV_SIZE_CONTENT);
lv_obj_set_height(box, LV_SIZE_CONTENT);
lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
if (msg.incoming) {
lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_IN_BG), 0);
lv_obj_align(box, LV_ALIGN_LEFT_MID, 0, 0);
} else {
lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_OUT_BG), 0);
lv_obj_align(box, LV_ALIGN_RIGHT_MID, 0, 0);
}
lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0);
// Message label with word wrap
lv_obj_t* lbl = lv_label_create(box);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(
msg.incoming ? Theme::ACCENT : Theme::PRIMARY), 0);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lbl, maxBubbleW - 14);
lv_label_set_text(lbl, msg.content.c_str());
// Status indicator for outgoing
if (!msg.incoming) {
const char* ind = "~";
uint32_t indColor = Theme::MUTED;
if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) {
ind = "*"; indColor = Theme::ACCENT;
} else if (msg.status == LXMFStatus::FAILED) {
ind = "!"; indColor = Theme::ERROR_CLR;
}
lv_obj_t* statusLbl = lv_label_create(box);
lv_obj_set_style_text_font(statusLbl, &lv_font_montserrat_10, 0);
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);
}
// Timestamp below bubble
if (msg.timestamp > 1000000) {
time_t t = (time_t)msg.timestamp;
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(bubble);
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);
if (msg.incoming) {
lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_LEFT, 2, 1);
} else {
lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_RIGHT, -2, 1);
}
}
}
}
// Auto-scroll to bottom
lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF);
}
void LvMessageView::sendCurrentMessage() {
if (!_lxmf || _peerHex.empty() || _inputText.empty()) return;
RNS::Bytes destHash;
destHash.assignHex(_peerHex.c_str());
_lxmf->sendMessage(destHash, _inputText.c_str());
_inputText.clear();
if (_textarea) lv_textarea_set_text(_textarea, "");
rebuildMessages();
}
bool LvMessageView::handleKey(const KeyEvent& event) {
if (event.character == 0x1B) {
if (_onBack) _onBack();
return true;
}
if (event.del || event.character == 0x08) {
if (!_inputText.empty()) {
_inputText.pop_back();
if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str());
} else {
if (_onBack) _onBack();
}
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
sendCurrentMessage();
return true;
}
// Scroll
if (event.up) {
if (_msgScroll) lv_obj_scroll_to_y(_msgScroll,
lv_obj_get_scroll_y(_msgScroll) - 30, LV_ANIM_OFF);
return true;
}
if (event.down) {
if (_msgScroll) lv_obj_scroll_to_y(_msgScroll,
lv_obj_get_scroll_y(_msgScroll) + 30, LV_ANIM_OFF);
return true;
}
if (event.character >= 0x20 && event.character < 0x7F) {
_inputText += (char)event.character;
if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str());
return true;
}
return false;
}
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include "ui/UIManager.h"
#include "reticulum/LXMFMessage.h"
#include <functional>
#include <string>
#include <vector>
class LXMFManager;
class AnnounceManager;
class LvMessageView : public LvScreen {
public:
using BackCallback = std::function<void()>;
void createUI(lv_obj_t* parent) override;
void destroyUI() override;
void refreshUI() override;
void onEnter() override;
void onExit() override;
bool handleKey(const KeyEvent& event) override;
void setPeerHex(const std::string& hex) { _peerHex = hex; }
void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; }
void setAnnounceManager(AnnounceManager* am) { _am = am; }
void setBackCallback(BackCallback cb) { _onBack = cb; }
const char* title() const override { return "Chat"; }
private:
void sendCurrentMessage();
void rebuildMessages();
std::string getPeerName();
LXMFManager* _lxmf = nullptr;
AnnounceManager* _am = nullptr;
BackCallback _onBack;
std::string _peerHex;
std::string _inputText;
int _lastMsgCount = -1;
unsigned long _lastRefreshMs = 0;
std::vector<LXMFMessage> _cachedMsgs;
// LVGL widgets
lv_obj_t* _header = nullptr;
lv_obj_t* _lblHeader = nullptr;
lv_obj_t* _msgScroll = nullptr;
lv_obj_t* _inputRow = nullptr;
lv_obj_t* _textarea = nullptr;
lv_obj_t* _btnSend = nullptr;
static constexpr unsigned long REFRESH_INTERVAL_MS = 2000; // Check for new messages every 2s
};
+188
View File
@@ -0,0 +1,188 @@
#include "LvMessagesScreen.h"
#include "ui/Theme.h"
#include "ui/UIManager.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/AnnounceManager.h"
#include "storage/MessageStore.h"
#include <Arduino.h>
void LvMessagesScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_pad_all(parent, 0, 0);
_lblEmpty = lv_label_create(parent);
lv_obj_set_style_text_font(_lblEmpty, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(_lblEmpty, "No conversations");
lv_obj_center(_lblEmpty);
_list = lv_obj_create(parent);
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_list, 0, 0);
lv_obj_set_flex_grow(_list, 1);
lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_list, 0, 0);
lv_obj_set_style_pad_all(_list, 0, 0);
lv_obj_set_style_pad_row(_list, 0, 0);
lv_obj_set_style_radius(_list, 0, 0);
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
_lastConvCount = -1;
rebuildList();
}
void LvMessagesScreen::onEnter() {
_lastConvCount = -1;
_selectedIdx = 0;
rebuildList();
}
void LvMessagesScreen::refreshUI() {
if (!_lxmf) return;
int count = (int)_lxmf->conversations().size();
if (count != _lastConvCount) {
rebuildList();
}
}
// Update only the selection highlight without rebuilding widgets
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);
}
}
void LvMessagesScreen::rebuildList() {
if (!_lxmf || !_list) return;
int count = (int)_lxmf->conversations().size();
_lastConvCount = count;
_rows.clear();
lv_obj_clean(_list);
if (count == 0) {
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN);
if (_selectedIdx >= count) _selectedIdx = count - 1;
if (_selectedIdx < 0) _selectedIdx = 0;
const auto& convs = _lxmf->conversations();
const lv_font_t* font = &lv_font_montserrat_14;
for (int i = 0; i < count; i++) {
const auto& peerHex = convs[i];
int unread = _lxmf->unreadCount(peerHex);
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(
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);
// Peer name — check name cache (survives reboots)
std::string displayName;
if (_am) displayName = _am->lookupName(peerHex);
if (displayName.empty()) displayName = peerHex.substr(0, 16);
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::PRIMARY), 0);
lv_label_set_text(lbl, displayName.c_str());
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0);
// Unread badge
if (unread > 0) {
char badge[8];
snprintf(badge, sizeof(badge), "(%d)", unread);
lv_obj_t* badgeLbl = lv_label_create(row);
lv_obj_set_style_text_font(badgeLbl, font, 0);
lv_obj_set_style_text_color(badgeLbl, lv_color_hex(Theme::BADGE_BG), 0);
lv_label_set_text(badgeLbl, badge);
lv_obj_align(badgeLbl, LV_ALIGN_RIGHT_MID, -4, 0);
}
_rows.push_back(row);
}
}
bool LvMessagesScreen::handleLongPress() {
if (!_lxmf) return false;
int count = (int)_lxmf->conversations().size();
if (count == 0 || _selectedIdx >= count) return false;
_confirmDelete = true;
if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000);
return true;
}
bool LvMessagesScreen::handleKey(const KeyEvent& event) {
if (!_lxmf) return false;
// Confirm delete mode
if (_confirmDelete) {
if (event.enter || event.character == '\n' || event.character == '\r') {
int count = (int)_lxmf->conversations().size();
if (_selectedIdx < count) {
const auto& peerHex = _lxmf->conversations()[_selectedIdx];
_lxmf->markRead(peerHex); // Clear unread
// Delete via MessageStore
extern MessageStore messageStore;
messageStore.deleteConversation(peerHex);
messageStore.refreshConversations();
if (_ui) _ui->lvStatusBar().showToast("Chat deleted", 1200);
_selectedIdx = 0;
_lastConvCount = -1;
rebuildList();
}
_confirmDelete = false;
return true;
}
_confirmDelete = false;
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
return true;
}
int count = (int)_lxmf->conversations().size();
if (count == 0) return false;
if (event.up) {
if (_selectedIdx > 0) {
int prev = _selectedIdx;
_selectedIdx--;
updateSelection(prev, _selectedIdx);
}
return true;
}
if (event.down) {
if (_selectedIdx < count - 1) {
int prev = _selectedIdx;
_selectedIdx++;
updateSelection(prev, _selectedIdx);
}
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx < count && _onOpen) {
_onOpen(_lxmf->conversations()[_selectedIdx]);
}
return true;
}
return false;
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
#include <string>
#include <vector>
class LXMFManager;
class AnnounceManager;
class LvMessagesScreen : public LvScreen {
public:
using OpenCallback = std::function<void(const std::string& peerHex)>;
void createUI(lv_obj_t* parent) override;
void refreshUI() override;
void onEnter() override;
bool handleKey(const KeyEvent& event) override;
void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; }
void setAnnounceManager(AnnounceManager* am) { _am = am; }
void setOpenCallback(OpenCallback cb) { _onOpen = cb; }
void setUIManager(class UIManager* ui) { _ui = ui; }
bool handleLongPress() override;
const char* title() const override { return "Messages"; }
private:
void rebuildList();
void updateSelection(int oldIdx, int newIdx);
LXMFManager* _lxmf = nullptr;
AnnounceManager* _am = nullptr;
class UIManager* _ui = nullptr;
OpenCallback _onOpen;
int _lastConvCount = -1;
int _selectedIdx = 0;
bool _confirmDelete = false;
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
std::vector<lv_obj_t*> _rows;
};
+79
View File
@@ -0,0 +1,79 @@
#include "LvNameInputScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "config/Config.h"
void LvNameInputScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
// Title: "RATSPEAK"
lv_obj_t* title = lv_label_create(parent);
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(title, "RATSPEAK");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
// Subtitle
lv_obj_t* sub = lv_label_create(parent);
lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(sub, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(sub, "ratspeak.org");
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 42);
// Prompt
lv_obj_t* prompt = lv_label_create(parent);
lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(prompt, "Enter your display name:");
lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 70);
// Text area
_textarea = lv_textarea_create(parent);
lv_obj_set_size(_textarea, 220, 36);
lv_obj_align(_textarea, LV_ALIGN_TOP_MID, 0, 95);
lv_textarea_set_max_length(_textarea, MAX_NAME_LEN);
lv_textarea_set_one_line(_textarea, true);
lv_textarea_set_placeholder_text(_textarea, "Name");
lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0);
lv_obj_set_style_text_font(_textarea, &lv_font_montserrat_14, 0);
// Hint
lv_obj_t* hint = lv_label_create(parent);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(hint, "[Enter] OK");
lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 140);
// Version
lv_obj_t* ver = lv_label_create(parent);
lv_obj_set_style_text_font(ver, &lv_font_montserrat_10, 0);
lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0);
char verBuf[32];
snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING);
lv_label_set_text(ver, verBuf);
lv_obj_align(ver, LV_ALIGN_BOTTOM_MID, 0, -10);
}
bool LvNameInputScreen::handleKey(const KeyEvent& event) {
if (!_textarea) return false;
if (event.enter || event.character == '\n' || event.character == '\r') {
const char* text = lv_textarea_get_text(_textarea);
if (text && strlen(text) > 0 && _doneCb) {
_doneCb(String(text));
}
return true;
}
if (event.del || event.character == 8) {
lv_textarea_del_char(_textarea);
return true;
}
if (event.character >= 0x20 && event.character <= 0x7E) {
char buf[2] = {event.character, 0};
lv_textarea_add_text(_textarea, buf);
return true;
}
return true; // Consume all keys
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
class LvNameInputScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
bool handleKey(const KeyEvent& event) override;
const char* title() const override { return "Setup"; }
void setDoneCallback(std::function<void(const String&)> cb) { _doneCb = cb; }
static constexpr int MAX_NAME_LEN = 16;
private:
lv_obj_t* _textarea = nullptr;
std::function<void(const String&)> _doneCb;
};
+290
View File
@@ -0,0 +1,290 @@
#include "LvNodesScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/UIManager.h"
#include "reticulum/AnnounceManager.h"
#include <Arduino.h>
#include <algorithm>
void LvNodesScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_pad_all(parent, 0, 0);
// Empty state label
_lblEmpty = lv_label_create(parent);
lv_obj_set_style_text_font(_lblEmpty, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(_lblEmpty, "No nodes discovered");
lv_obj_center(_lblEmpty);
// Scrollable list container
_list = lv_obj_create(parent);
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_list, 0, 0);
lv_obj_set_flex_grow(_list, 1);
lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_list, 0, 0);
lv_obj_set_style_pad_all(_list, 0, 0);
lv_obj_set_style_pad_row(_list, 0, 0);
lv_obj_set_style_radius(_list, 0, 0);
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
_lastNodeCount = -1;
_lastContactCount = -1;
rebuildList();
}
void LvNodesScreen::onEnter() {
_lastNodeCount = -1;
_lastContactCount = -1;
_selectedIdx = 0;
rebuildList();
}
void LvNodesScreen::refreshUI() {
if (!_am) return;
int contacts = 0;
for (const auto& n : _am->nodes()) { if (n.saved) contacts++; }
if (_am->nodeCount() != _lastNodeCount || contacts != _lastContactCount) {
rebuildList();
}
}
// Update only the selection highlight without rebuilding widgets
void LvNodesScreen::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);
}
scrollToSelected();
}
void LvNodesScreen::rebuildList() {
if (!_am || !_list) return;
int count = _am->nodeCount();
_lastNodeCount = count;
_rows.clear();
_rowToNodeIdx.clear();
lv_obj_clean(_list);
// Separate contacts and online nodes
std::vector<int> contactIndices;
std::vector<int> onlineIndices;
const auto& nodes = _am->nodes();
for (int i = 0; i < count; i++) {
if (nodes[i].saved) contactIndices.push_back(i);
else onlineIndices.push_back(i);
}
_lastContactCount = contactIndices.size();
// Sort online nodes: most recently seen first
std::sort(onlineIndices.begin(), onlineIndices.end(), [&nodes](int a, int b) {
return nodes[a].lastSeen > nodes[b].lastSeen;
});
if (count == 0) {
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
_totalRows = 0;
return;
}
lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN);
const lv_font_t* font = &lv_font_montserrat_14;
const lv_font_t* smallFont = &lv_font_montserrat_10;
int rowIdx = 0;
// Helper to create a section header
auto makeSectionHeader = [&](const char* title, int itemCount) {
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 22);
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);
char buf[32];
snprintf(buf, sizeof(buf), "%s (%d)", title, itemCount);
lv_obj_t* lbl = lv_label_create(row);
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(lbl, buf);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0);
_rows.push_back(row);
_rowToNodeIdx.push_back(-1); // header, not selectable
return rowIdx++;
};
// Helper to create a node row
auto makeNodeRow = [&](int nodeIdx) {
const auto& node = nodes[nodeIdx];
bool selected = (rowIdx == _selectedIdx);
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 24);
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_width(row, 0, 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);
// Name + hash
std::string displayHash;
if (!node.identityHex.empty() && node.identityHex.size() >= 12) {
displayHash = node.identityHex.substr(0, 4) + ":" +
node.identityHex.substr(4, 4) + ":" +
node.identityHex.substr(8, 4);
} else {
displayHash = node.hash.toHex().substr(0, 8);
}
char buf[64];
snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), displayHash.c_str());
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(
node.saved ? Theme::ACCENT : Theme::PRIMARY), 0);
lv_label_set_text(lbl, buf);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0);
// Hops + age
unsigned long ageSec = (millis() - node.lastSeen) / 1000;
char infoBuf[24];
if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec);
else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60);
lv_obj_t* info = lv_label_create(row);
lv_obj_set_style_text_font(info, smallFont, 0);
lv_obj_set_style_text_color(info, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(info, infoBuf);
lv_obj_align(info, LV_ALIGN_RIGHT_MID, -4, 0);
_rows.push_back(row);
_rowToNodeIdx.push_back(nodeIdx);
rowIdx++;
};
// Contacts section (if any)
if (!contactIndices.empty()) {
_contactHeaderIdx = makeSectionHeader("Contacts", contactIndices.size());
for (int ni : contactIndices) makeNodeRow(ni);
} else {
_contactHeaderIdx = -1;
}
// Online section
_onlineHeaderIdx = makeSectionHeader("Online", onlineIndices.size());
for (int ni : onlineIndices) makeNodeRow(ni);
_totalRows = rowIdx;
// Clamp selection to valid selectable row
if (_selectedIdx >= _totalRows) _selectedIdx = _totalRows - 1;
if (_selectedIdx < 0) _selectedIdx = 0;
// Skip headers
while (_selectedIdx < _totalRows && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx++;
if (_selectedIdx >= _totalRows) _selectedIdx = _totalRows - 1;
scrollToSelected();
}
void LvNodesScreen::scrollToSelected() {
if (_selectedIdx >= 0 && _selectedIdx < (int)_rows.size()) {
lv_obj_scroll_to_view(_rows[_selectedIdx], LV_ANIM_OFF);
}
}
bool LvNodesScreen::handleLongPress() {
if (!_am || _totalRows == 0) return false;
if (_selectedIdx < 0 || _selectedIdx >= (int)_rowToNodeIdx.size()) return false;
int nodeIdx = _rowToNodeIdx[_selectedIdx];
if (nodeIdx < 0 || nodeIdx >= (int)_am->nodes().size()) return false;
const auto& node = _am->nodes()[nodeIdx];
if (!node.saved) return false; // Only contacts can be deleted
_confirmDelete = true;
if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000);
return true;
}
bool LvNodesScreen::handleKey(const KeyEvent& event) {
if (!_am || _totalRows == 0) return false;
// Confirm delete mode
if (_confirmDelete) {
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) {
int nodeIdx = _rowToNodeIdx[_selectedIdx];
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
auto& nodes = const_cast<std::vector<DiscoveredNode>&>(_am->nodes());
nodes.erase(nodes.begin() + nodeIdx);
_am->saveContacts();
if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200);
_selectedIdx = 0;
rebuildList();
}
}
_confirmDelete = false;
return true;
}
_confirmDelete = false;
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
return true;
}
if (event.up) {
int prev = _selectedIdx;
_selectedIdx--;
while (_selectedIdx >= 0 && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx--;
if (_selectedIdx < 0) _selectedIdx = prev;
if (_selectedIdx != prev) updateSelection(prev, _selectedIdx);
return true;
}
if (event.down) {
int prev = _selectedIdx;
_selectedIdx++;
while (_selectedIdx < _totalRows && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx++;
if (_selectedIdx >= _totalRows) _selectedIdx = prev;
if (_selectedIdx != prev) updateSelection(prev, _selectedIdx);
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) {
int nodeIdx = _rowToNodeIdx[_selectedIdx];
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size() && _onSelect) {
_onSelect(_am->nodes()[nodeIdx].hash.toHex());
}
}
return true;
}
// 's' or 'S' to save/unsave contact
if (event.character == 's' || event.character == 'S') {
if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) {
int nodeIdx = _rowToNodeIdx[_selectedIdx];
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
auto& node = const_cast<DiscoveredNode&>(_am->nodes()[nodeIdx]);
node.saved = !node.saved;
if (node.saved) {
_am->saveContacts();
}
rebuildList();
}
}
return true;
}
return false;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
#include <string>
#include <vector>
class AnnounceManager;
class LvNodesScreen : public LvScreen {
public:
using NodeSelectedCallback = std::function<void(const std::string& peerHex)>;
void createUI(lv_obj_t* parent) override;
void refreshUI() override;
void onEnter() override;
bool handleKey(const KeyEvent& event) override;
void setAnnounceManager(AnnounceManager* am) { _am = am; }
void setNodeSelectedCallback(NodeSelectedCallback cb) { _onSelect = cb; }
void setUIManager(class UIManager* ui) { _ui = ui; }
bool handleLongPress() override;
const char* title() const override { return "Nodes"; }
private:
void rebuildList();
void updateSelection(int oldIdx, int newIdx);
void scrollToSelected();
AnnounceManager* _am = nullptr;
class UIManager* _ui = nullptr;
NodeSelectedCallback _onSelect;
bool _confirmDelete = false;
int _lastNodeCount = -1;
int _lastContactCount = -1;
int _selectedIdx = 0;
int _totalRows = 0;
// Section tracking
bool _contactsCollapsed = false;
int _contactHeaderIdx = -1; // Row index of "Contacts" header
int _onlineHeaderIdx = -1; // Row index of "Online" header
std::vector<int> _rowToNodeIdx; // Maps row index -> node index in _am->nodes(), -1 for headers
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
std::vector<lv_obj_t*> _rows;
};
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
#pragma once
#include "ui/UIManager.h"
#include "transport/WiFiInterface.h"
#include "config/UserConfig.h"
#include <string>
#include <vector>
#include <functional>
class FlashStore;
class SDStore;
class SX1262;
class AudioNotify;
class Power;
class WiFiInterface;
class TCPClientInterface;
class ReticulumManager;
class IdentityManager;
// Reuse existing SettingType, SettingItem, SettingsCategory from SettingsScreen.h
#include "SettingsScreen.h"
class LvSettingsScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
void onEnter() override;
bool handleKey(const KeyEvent& event) override;
void setUserConfig(UserConfig* cfg) { _cfg = cfg; }
void setFlashStore(FlashStore* fs) { _flash = fs; }
void setSDStore(SDStore* sd) { _sd = sd; }
void setRadio(SX1262* radio) { _radio = radio; }
void setAudio(AudioNotify* audio) { _audio = audio; }
void setPower(Power* power) { _power = power; }
void setWiFi(WiFiInterface* wifi) { _wifi = wifi; }
void setTCPClients(std::vector<TCPClientInterface*>* tcp) { _tcp = tcp; }
void setRNS(ReticulumManager* rns) { _rns = rns; }
void setIdentityManager(IdentityManager* idm) { _idMgr = idm; }
void setUIManager(UIManager* ui) { _ui = ui; }
void setIdentityHash(const String& hash) { _identityHash = hash; }
void setSaveCallback(std::function<bool()> cb) { _saveCallback = cb; }
void setTCPChangeCallback(std::function<void()> cb) { _tcpChangeCb = cb; }
const char* title() const override { return "Settings"; }
private:
void buildItems();
void applyAndSave();
void applyPreset(int presetIdx);
int detectPreset() const;
bool isEditable(int idx) const;
void skipToNextEditable(int dir);
void showCategoryList();
void showItemList(int catIdx);
void showWifiPicker();
void rebuildCategoryList();
void rebuildItemList();
void rebuildWifiList();
void enterCategory(int catIdx);
void exitToCategories();
void updateCategorySelection(int oldIdx, int newIdx);
void updateItemSelection(int oldIdx, int newIdx);
void updateWifiSelection(int oldIdx, int newIdx);
UserConfig* _cfg = nullptr;
FlashStore* _flash = nullptr;
SDStore* _sd = nullptr;
SX1262* _radio = nullptr;
AudioNotify* _audio = nullptr;
Power* _power = nullptr;
WiFiInterface* _wifi = nullptr;
std::vector<TCPClientInterface*>* _tcp = nullptr;
ReticulumManager* _rns = nullptr;
IdentityManager* _idMgr = nullptr;
UIManager* _ui = nullptr;
String _identityHash;
std::function<bool()> _saveCallback;
std::function<void()> _tcpChangeCb;
SettingsView _view = SettingsView::CATEGORY_LIST;
std::vector<SettingsCategory> _categories;
std::vector<SettingItem> _items;
int _categoryIdx = 0;
int _selectedIdx = 0;
int _catRangeStart = 0;
int _catRangeEnd = 0;
// Edit state
bool _editing = false;
int _editValue = 0;
bool _textEditing = false;
String _editText;
bool _confirmingReset = false;
// WiFi picker
std::vector<WiFiInterface::ScanResult> _wifiResults;
int _wifiPickerIdx = 0;
// Reboot-required tracking
bool _rebootNeeded = false;
struct RebootSnapshot {
RatWiFiMode wifiMode;
String wifiSTASSID;
String wifiSTAPassword;
bool bleEnabled;
bool transportEnabled;
};
RebootSnapshot _rebootSnap;
void snapshotRebootSettings();
bool rebootSettingsChanged() const;
// TCP change detection
String _tcpSnapHost;
uint16_t _tcpSnapPort = 0;
void snapshotTCPSettings();
bool tcpSettingsChanged() const;
// LVGL widgets
lv_obj_t* _scrollContainer = nullptr;
std::vector<lv_obj_t*> _rowObjs;
};