mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-07-18 17:56:12 +00:00
- Restructure send_outgoing() to handle both Header1 wrapping and Header2 transport_id correction (for multi-hop edge case where Transport::outbound uses wrong _received_from as transport_id) - Add announce management improvements and LXMF delivery fixes - Add LoRa interface enhancements and nodes screen updates - Sync microReticulum hops > 1 revert in libdeps
1036 lines
40 KiB
C++
1036 lines
40 KiB
C++
// =============================================================================
|
|
// RatDeck v1.5 — Main Entry Point
|
|
// LilyGo T-Deck Plus: LovyanGFX Direct UI + microReticulum + LXMF Messaging
|
|
// =============================================================================
|
|
|
|
#include <Arduino.h>
|
|
#include <SPI.h>
|
|
#include <Wire.h>
|
|
#include <lvgl.h>
|
|
|
|
#include "config/BoardConfig.h"
|
|
#include "config/Config.h"
|
|
#include "hal/Display.h"
|
|
#include "hal/TouchInput.h"
|
|
#include "hal/Trackball.h"
|
|
#include "hal/Keyboard.h"
|
|
#include "hal/Power.h"
|
|
#include "radio/SX1262.h"
|
|
#include "input/InputManager.h"
|
|
#include "input/HotkeyManager.h"
|
|
#include "ui/UIManager.h"
|
|
#include "ui/LvTabBar.h"
|
|
#include "ui/LvInput.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/LvContactsScreen.h"
|
|
#include "ui/screens/LvSettingsScreen.h"
|
|
#include "ui/screens/LvHelpOverlay.h"
|
|
// Map screen removed
|
|
#include "ui/screens/LvNameInputScreen.h"
|
|
#include "ui/screens/LvDataCleanScreen.h"
|
|
#include "storage/FlashStore.h"
|
|
#include "storage/SDStore.h"
|
|
#include "storage/MessageStore.h"
|
|
#include "reticulum/ReticulumManager.h"
|
|
#include "reticulum/AnnounceManager.h"
|
|
#include "reticulum/LXMFManager.h"
|
|
#include "reticulum/IdentityManager.h"
|
|
#include "transport/LoRaInterface.h"
|
|
#include "transport/WiFiInterface.h"
|
|
#include "transport/TCPClientInterface.h"
|
|
#include "transport/BLEInterface.h"
|
|
#include "transport/BLESideband.h"
|
|
#include "config/UserConfig.h"
|
|
#include "audio/AudioNotify.h"
|
|
#include <ArduinoJson.h>
|
|
#include <Preferences.h>
|
|
#include <list>
|
|
#include <esp_system.h>
|
|
#include <freertos/task.h>
|
|
|
|
// --- Hardware ---
|
|
// Single shared SPI bus for display, LoRa, and SD card
|
|
// IMPORTANT: On ESP32-S3, Arduino FSPI=0 maps to SPI2 hardware.
|
|
// Do NOT use SPI2_HOST (IDF constant = 1) — Arduino treats index 1 as HSPI/SPI3!
|
|
SPIClass sharedSPI(FSPI);
|
|
|
|
SX1262 radio(&sharedSPI,
|
|
LORA_CS, SPI_SCK, SPI_MOSI, SPI_MISO,
|
|
LORA_RST, LORA_IRQ, LORA_BUSY, LORA_RXEN,
|
|
LORA_HAS_TCXO, LORA_DIO2_AS_RF_SWITCH);
|
|
|
|
Display display;
|
|
TouchInput touch;
|
|
Trackball trackball;
|
|
Keyboard keyboard;
|
|
|
|
// --- Subsystems ---
|
|
InputManager inputManager;
|
|
HotkeyManager hotkeys;
|
|
UIManager ui;
|
|
FlashStore flash;
|
|
SDStore sdStore;
|
|
MessageStore messageStore;
|
|
ReticulumManager rns;
|
|
AnnounceManager* announceManager = nullptr;
|
|
RNS::HAnnounceHandler announceHandler;
|
|
LXMFManager lxmf;
|
|
WiFiInterface* wifiImpl = nullptr;
|
|
RNS::Interface wifiIface({RNS::Type::NONE});
|
|
std::vector<TCPClientInterface*> tcpClients;
|
|
std::list<RNS::Interface> tcpIfaces; // Must persist — Transport stores references (list: no realloc)
|
|
BLEInterface bleInterface;
|
|
BLESideband bleSideband;
|
|
UserConfig userConfig;
|
|
Power powerMgr;
|
|
AudioNotify audio;
|
|
IdentityManager identityMgr;
|
|
|
|
// --- LVGL Screens ---
|
|
LvBootScreen lvBootScreen;
|
|
LvHomeScreen lvHomeScreen;
|
|
LvNodesScreen lvNodesScreen;
|
|
LvMessagesScreen lvMessagesScreen;
|
|
LvContactsScreen lvContactsScreen;
|
|
LvMessageView lvMessageView;
|
|
LvSettingsScreen lvSettingsScreen;
|
|
LvHelpOverlay lvHelpOverlay;
|
|
// LvMapScreen removed
|
|
LvNameInputScreen lvNameInputScreen;
|
|
LvDataCleanScreen lvDataCleanScreen;
|
|
|
|
// Tab-screen mapping (4 tabs) — LVGL versions
|
|
LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {};
|
|
|
|
// --- State ---
|
|
bool radioOnline = false;
|
|
bool bootComplete = false;
|
|
bool bootLoopRecovery = false;
|
|
bool wifiSTAStarted = false;
|
|
bool wifiSTAConnected = false;
|
|
unsigned long lastAutoAnnounce = 0;
|
|
unsigned long lastStatusUpdate = 0;
|
|
constexpr unsigned long ANNOUNCE_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
constexpr unsigned long STATUS_UPDATE_MS = 1000; // 1 Hz status bar update
|
|
unsigned long lastHeartbeat = 0;
|
|
constexpr unsigned long HEARTBEAT_INTERVAL_MS = 5000;
|
|
unsigned long loopCycleStart = 0;
|
|
unsigned long maxLoopTime = 0;
|
|
unsigned long lastLvglTime = 0;
|
|
constexpr unsigned long LVGL_INTERVAL_MS = 33; // ~30 FPS
|
|
constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 18; // Max cumulative TCP time per loop
|
|
bool wifiDeferredAnnounce = false;
|
|
unsigned long wifiConnectedAt = 0;
|
|
|
|
// =============================================================================
|
|
// Announce with display name (MessagePack-encoded app_data)
|
|
// =============================================================================
|
|
|
|
static RNS::Bytes encodeAnnounceName(const String& name) {
|
|
if (name.isEmpty()) return {};
|
|
size_t len = name.length();
|
|
if (len > 31) len = 31;
|
|
uint8_t buf[2 + 31];
|
|
buf[0] = 0x91; // msgpack fixarray(1)
|
|
buf[1] = 0xA0 | (uint8_t)len; // msgpack fixstr(len)
|
|
memcpy(buf + 2, name.c_str(), len);
|
|
return RNS::Bytes(buf, 2 + len);
|
|
}
|
|
|
|
static void announceWithName() {
|
|
Serial.println("[ANNOUNCE-TX] announceWithName() entry");
|
|
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
|
|
rns.announce(appData);
|
|
ui.statusBar().flashAnnounce();
|
|
ui.statusBar().showToast("Announce sent!");
|
|
ui.lvStatusBar().flashAnnounce();
|
|
ui.lvStatusBar().showToast("Announce sent!");
|
|
Serial.println("[ANNOUNCE-TX] announceWithName() exit");
|
|
}
|
|
|
|
// =============================================================================
|
|
// TCP client management — stop old clients, create new from config
|
|
// =============================================================================
|
|
|
|
static void reloadTCPClients() {
|
|
// Stop and deregister existing clients
|
|
for (auto* tcp : tcpClients) tcp->stop();
|
|
for (auto& iface : tcpIfaces) {
|
|
RNS::Transport::deregister_interface(iface);
|
|
}
|
|
tcpClients.clear();
|
|
tcpIfaces.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 (registered with Transport, mode=GATEWAY)\n", ep.host.c_str(), ep.port);
|
|
Serial.printf("[TCP] Total interfaces registered: %d\n", (int)RNS::Transport::get_interfaces().size());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tcpClients.empty()) {
|
|
Serial.println("[TCP] No active TCP connections");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hotkey callbacks
|
|
// =============================================================================
|
|
|
|
void onHotkeyHelp() {
|
|
lvHelpOverlay.toggle();
|
|
}
|
|
void onHotkeyMessages() {
|
|
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
|
ui.setLvScreen(&lvMessagesScreen);
|
|
}
|
|
void onHotkeyNewMsg() {
|
|
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
|
ui.setLvScreen(&lvMessagesScreen);
|
|
}
|
|
void onHotkeySettings() {
|
|
ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS);
|
|
ui.setLvScreen(&lvSettingsScreen);
|
|
}
|
|
void onHotkeyAnnounce() {
|
|
announceWithName();
|
|
}
|
|
void onHotkeyDiag() {
|
|
Serial.println("=== DIAGNOSTIC DUMP ===");
|
|
Serial.printf("Device: RatDeck T-Deck Plus\n");
|
|
Serial.printf("Identity: %s\n", rns.identityHash().c_str());
|
|
Serial.printf("Transport: %s\n", rns.isTransportActive() ? "ACTIVE" : "OFFLINE");
|
|
Serial.printf("Paths: %d Links: %d\n", (int)rns.pathCount(), (int)rns.linkCount());
|
|
Serial.printf("Radio: %s\n", radioOnline ? "ONLINE" : "OFFLINE");
|
|
if (radioOnline) {
|
|
Serial.printf("Freq: %lu Hz SF: %d BW: %lu CR: 4/%d TXP: %d dBm\n",
|
|
(unsigned long)radio.getFrequency(),
|
|
radio.getSpreadingFactor(),
|
|
(unsigned long)radio.getSignalBandwidth(),
|
|
radio.getCodingRate4(),
|
|
radio.getTxPower());
|
|
Serial.printf("Preamble: %ld symbols\n", radio.getPreambleLength());
|
|
uint16_t devErr = radio.getDeviceErrors();
|
|
uint8_t status = radio.getStatus();
|
|
Serial.printf("DevErrors: 0x%04X Status: 0x%02X (mode=%d cmd=%d)\n",
|
|
devErr, status, (status >> 4) & 0x07, (status >> 1) & 0x07);
|
|
if (devErr & 0x40) Serial.println(" *** PLL LOCK FAILED ***");
|
|
Serial.printf("Current RSSI: %d dBm\n", radio.currentRssi());
|
|
}
|
|
Serial.printf("Free heap: %lu bytes PSRAM: %lu bytes\n",
|
|
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getFreePsram());
|
|
Serial.printf("Uptime: %lu s\n", millis() / 1000);
|
|
Serial.println("=======================");
|
|
}
|
|
|
|
volatile bool rssiMonitorActive = false;
|
|
void onHotkeyRssiMonitor() {
|
|
if (!radioOnline) { Serial.println("[RSSI] Radio offline"); return; }
|
|
Serial.println("[RSSI] Sampling for 5 seconds...");
|
|
rssiMonitorActive = true;
|
|
int minRssi = 0, maxRssi = -200;
|
|
unsigned long start = millis();
|
|
int samples = 0;
|
|
while (millis() - start < 5000) {
|
|
int rssi = radio.currentRssi();
|
|
if (rssi < minRssi) minRssi = rssi;
|
|
if (rssi > maxRssi) maxRssi = rssi;
|
|
samples++;
|
|
Serial.printf("[RSSI] %d dBm\n", rssi);
|
|
delay(100);
|
|
}
|
|
rssiMonitorActive = false;
|
|
Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n", samples, minRssi, maxRssi);
|
|
}
|
|
|
|
void onHotkeyRadioTest() {
|
|
Serial.println("[TEST] Sending raw test packet...");
|
|
uint8_t header = 0xA0;
|
|
const char* testPayload = "RATDECK_TEST_1234567890";
|
|
radio.beginPacket();
|
|
radio.write(header);
|
|
radio.write((const uint8_t*)testPayload, strlen(testPayload));
|
|
bool ok = radio.endPacket();
|
|
Serial.printf("[TEST] TX %s (%d bytes)\n", ok ? "OK" : "FAILED", (int)(1 + strlen(testPayload)));
|
|
radio.receive();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper: render boot screen immediately
|
|
// =============================================================================
|
|
static void bootRender() {
|
|
// LVGL boot screen calls lv_timer_handler() internally via setProgress()
|
|
// Legacy render kept as fallback
|
|
}
|
|
|
|
// =============================================================================
|
|
// Setup — 26-step boot sequence
|
|
// =============================================================================
|
|
|
|
void setup() {
|
|
// Step 1: Power pin — CRITICAL: enables all T-Deck Plus peripherals
|
|
Power::enablePeripherals();
|
|
|
|
// Step 2: Serial
|
|
Serial.begin(SERIAL_BAUD);
|
|
delay(100);
|
|
Serial.println();
|
|
Serial.println("=================================");
|
|
Serial.printf(" RatDeck v%s\n", RATDECK_VERSION_STRING);
|
|
Serial.println(" LilyGo T-Deck Plus");
|
|
Serial.println("=================================");
|
|
|
|
esp_reset_reason_t reason = esp_reset_reason();
|
|
const char* reasonStr = "UNKNOWN";
|
|
switch (reason) {
|
|
case ESP_RST_POWERON: reasonStr = "POWER_ON"; break;
|
|
case ESP_RST_SW: reasonStr = "SOFTWARE"; break;
|
|
case ESP_RST_PANIC: reasonStr = "PANIC"; break;
|
|
case ESP_RST_INT_WDT: reasonStr = "INT_WDT"; break;
|
|
case ESP_RST_TASK_WDT: reasonStr = "TASK_WDT"; break;
|
|
case ESP_RST_WDT: reasonStr = "WDT"; break;
|
|
case ESP_RST_BROWNOUT: reasonStr = "BROWNOUT"; break;
|
|
case ESP_RST_DEEPSLEEP: reasonStr = "DEEP_SLEEP"; break;
|
|
default: break;
|
|
}
|
|
Serial.printf("[BOOT] Reset: %s (%d)\n", reasonStr, (int)reason);
|
|
Serial.printf("[BOOT] Heap: %lu PSRAM: %lu\n",
|
|
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getPsramSize());
|
|
|
|
// Step 3: Initialize I2C bus (shared by keyboard + touchscreen)
|
|
Wire.begin(I2C_SDA, I2C_SCL);
|
|
Wire.setClock(400000);
|
|
|
|
// Step 3.5: Initialize shared SPI bus
|
|
sharedSPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
|
|
// Deassert all slave CS pins to prevent bus contention
|
|
pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH);
|
|
pinMode(SD_CS, OUTPUT); digitalWrite(SD_CS, HIGH);
|
|
|
|
// Step 4: Radio + SD init BEFORE display
|
|
// Radio and SD must init while SPIClass exclusively owns SPI2_HOST.
|
|
// LovyanGFX's init() later joins the bus via spi_bus_add_device().
|
|
// This avoids any bus re-init dance that would invalidate device handles.
|
|
Serial.println("[BOOT] Initializing radio...");
|
|
if (radio.begin(LORA_DEFAULT_FREQ)) {
|
|
radio.setSpreadingFactor(LORA_DEFAULT_SF);
|
|
radio.setSignalBandwidth(LORA_DEFAULT_BW);
|
|
radio.setCodingRate4(LORA_DEFAULT_CR);
|
|
radio.setTxPower(LORA_DEFAULT_TX_POWER);
|
|
radio.setPreambleLength(LORA_DEFAULT_PREAMBLE);
|
|
radio.receive();
|
|
radioOnline = true;
|
|
Serial.println("[RADIO] SX1262 online at 915 MHz");
|
|
} else {
|
|
Serial.println("[RADIO] SX1262 not detected!");
|
|
}
|
|
|
|
// SD card init (shared SPI, right after radio)
|
|
digitalWrite(LORA_CS, HIGH);
|
|
delay(10);
|
|
if (sdStore.begin(&sharedSPI, SD_CS)) {
|
|
sdStore.formatForRatputer();
|
|
Serial.println("[SD] Card ready");
|
|
} else {
|
|
Serial.println("[SD] Not detected");
|
|
}
|
|
|
|
// Verify radio SPI still works after SD init
|
|
if (radioOnline) {
|
|
uint8_t sw_msb = radio.readRegister(0x0740);
|
|
uint8_t sw_lsb = radio.readRegister(0x0741);
|
|
Serial.printf("[BOOT] Radio SPI pre-display: syncword=0x%02X%02X %s\n",
|
|
sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK");
|
|
}
|
|
|
|
// Step 5: Display HAL — LovyanGFX + ST7789V
|
|
// LovyanGFX's Bus_SPI::init() calls spi_bus_initialize() which will
|
|
// return ESP_ERR_INVALID_STATE (bus already owned by SPIClass) and
|
|
// then spi_bus_add_device() to join the existing bus. Both LGFX and
|
|
// SPIClass get valid device handles on the same SPI2_HOST bus.
|
|
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);
|
|
uint8_t sw_lsb = radio.readRegister(0x0741);
|
|
Serial.printf("[BOOT] Radio SPI post-display: syncword=0x%02X%02X %s\n",
|
|
sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK");
|
|
}
|
|
|
|
// Step 6: UI manager (initializes both legacy and LVGL UI layers)
|
|
ui.begin(&display.gfx());
|
|
ui.setBootMode(true);
|
|
ui.setLvScreen(&lvBootScreen);
|
|
ui.statusBar().setLoRaOnline(radioOnline);
|
|
ui.lvStatusBar().setLoRaOnline(radioOnline);
|
|
lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED");
|
|
|
|
// Step 7: Touch HAL — GT911 I2C
|
|
touch.begin();
|
|
lvBootScreen.setProgress(0.50f, "Touch ready");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 8: Keyboard HAL — ESP32-C3 I2C
|
|
keyboard.begin();
|
|
lvBootScreen.setProgress(0.52f, "Keyboard ready");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 9: Trackball HAL — GPIO interrupts
|
|
trackball.begin();
|
|
lvBootScreen.setProgress(0.54f, "Trackball ready");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 10: Input manager
|
|
inputManager.begin(&keyboard, &trackball, &touch);
|
|
|
|
// 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);
|
|
hotkeys.registerHotkey('m', "Messages", onHotkeyMessages);
|
|
hotkeys.registerHotkey('n', "New Message", onHotkeyNewMsg);
|
|
hotkeys.registerHotkey('s', "Settings", onHotkeySettings);
|
|
hotkeys.registerHotkey('a', "Announce", onHotkeyAnnounce);
|
|
hotkeys.registerHotkey('d', "Diagnostics", onHotkeyDiag);
|
|
hotkeys.registerHotkey('t', "Radio Test", onHotkeyRadioTest);
|
|
hotkeys.registerHotkey('r', "RSSI Monitor", onHotkeyRssiMonitor);
|
|
hotkeys.setTabCycleCallback([](int dir) {
|
|
ui.lvTabBar().cycleTab(dir);
|
|
int tab = ui.lvTabBar().getActiveTab();
|
|
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
|
|
});
|
|
lvBootScreen.setProgress(0.58f, "Hotkeys registered");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 12: Mount LittleFS
|
|
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()) {
|
|
Serial.println("[BOOT] LittleFS formatted and mounted");
|
|
} else {
|
|
Serial.println("[BOOT] LittleFS format failed!");
|
|
}
|
|
} else {
|
|
Serial.println("[BOOT] LittleFS mounted OK");
|
|
}
|
|
|
|
// Step 13: Boot loop detection (NVS)
|
|
{
|
|
Preferences prefs;
|
|
if (prefs.begin("ratdeck", false)) {
|
|
int bc = prefs.getInt("bootc", 0);
|
|
prefs.putInt("bootc", bc + 1);
|
|
prefs.end();
|
|
if (bc >= 3) {
|
|
Serial.printf("[BOOT] Boot loop detected (%d failures)\n", bc);
|
|
bootLoopRecovery = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
lvBootScreen.setProgress(0.65f, "Starting Reticulum...");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
rns.setSDStore(&sdStore);
|
|
if (rns.begin(&radio, &flash)) {
|
|
Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str());
|
|
lvBootScreen.setProgress(0.72f, "Reticulum active");
|
|
} else {
|
|
Serial.println("[BOOT] Reticulum init failed!");
|
|
lvBootScreen.setProgress(0.72f, "RNS: FAILED");
|
|
}
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 15.5: Identity manager
|
|
identityMgr.begin(&flash, &sdStore);
|
|
|
|
// Step 16: Message store
|
|
lvBootScreen.setProgress(0.72f, "Starting messaging...");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
messageStore.begin(&flash, &sdStore);
|
|
|
|
// Step 17: LXMF init
|
|
lxmf.begin(&rns, &messageStore);
|
|
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();
|
|
});
|
|
// 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
|
|
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
|
|
lvBootScreen.setProgress(0.82f, "Loading config...");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
userConfig.load(sdStore, flash);
|
|
|
|
// Seed default Ratspeak TCP hub if no connections configured
|
|
if (userConfig.settings().tcpConnections.empty()) {
|
|
TCPEndpoint ep;
|
|
ep.host = "rns.ratspeak.org";
|
|
ep.port = 4242;
|
|
ep.autoConnect = true;
|
|
userConfig.settings().tcpConnections.push_back(ep);
|
|
Serial.println("[CONFIG] Default TCP hub: rns.ratspeak.org:4242");
|
|
}
|
|
|
|
// Sync display name between active identity slot and config.
|
|
// The identity slot is the source of truth for the name.
|
|
{
|
|
String slotName;
|
|
if (identityMgr.syncNameFromActive(slotName)) {
|
|
if (!slotName.isEmpty()) {
|
|
// Slot has a name — use it (overrides any stale config value)
|
|
if (userConfig.settings().displayName != slotName) {
|
|
Serial.printf("[BOOT] Name from identity slot: '%s'\n", slotName.c_str());
|
|
userConfig.settings().displayName = slotName;
|
|
userConfig.save(sdStore, flash);
|
|
}
|
|
} else if (!userConfig.settings().displayName.isEmpty()) {
|
|
// Slot has no name but config does — seed the slot (first boot migration)
|
|
identityMgr.setDisplayName(identityMgr.activeIndex(),
|
|
userConfig.settings().displayName);
|
|
Serial.printf("[BOOT] Seeded identity slot name: '%s'\n",
|
|
userConfig.settings().displayName.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 20: Boot loop recovery
|
|
if (bootLoopRecovery) {
|
|
userConfig.settings().wifiMode = RAT_WIFI_OFF;
|
|
Serial.println("[BOOT] WiFi forced OFF (boot loop recovery)");
|
|
}
|
|
lvBootScreen.setProgress(0.83f, "Config loaded");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
|
|
// Step 21: Apply radio config
|
|
if (radioOnline) {
|
|
auto& s = userConfig.settings();
|
|
radio.setFrequency(s.loraFrequency);
|
|
radio.setSpreadingFactor(s.loraSF);
|
|
radio.setSignalBandwidth(s.loraBW);
|
|
radio.setCodingRate4(s.loraCR);
|
|
radio.setTxPower(s.loraTxPower);
|
|
radio.setPreambleLength(s.loraPreamble);
|
|
radio.receive();
|
|
Serial.printf("[BOOT] Radio: %lu Hz, SF%d, BW%lu, CR4/%d, %d dBm, pre=%ld\n",
|
|
(unsigned long)s.loraFrequency, s.loraSF,
|
|
(unsigned long)s.loraBW, s.loraCR, s.loraTxPower, s.loraPreamble);
|
|
}
|
|
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) {
|
|
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(
|
|
userConfig.settings().wifiAPSSID.c_str(),
|
|
userConfig.settings().wifiAPPassword.c_str());
|
|
}
|
|
wifiIface = wifiImpl;
|
|
wifiIface.mode(RNS::Type::Interface::MODE_GATEWAY);
|
|
RNS::Transport::register_interface(wifiIface);
|
|
wifiImpl->start();
|
|
ui.statusBar().setWiFiActive(true);
|
|
ui.lvStatusBar().setWiFiActive(true);
|
|
} else if (wifiMode == RAT_WIFI_STA) {
|
|
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);
|
|
WiFi.begin(userConfig.settings().wifiSTASSID.c_str(),
|
|
userConfig.settings().wifiSTAPassword.c_str());
|
|
wifiSTAStarted = true;
|
|
Serial.printf("[WIFI] STA: %s\n", userConfig.settings().wifiSTASSID.c_str());
|
|
}
|
|
} else {
|
|
lvBootScreen.setProgress(0.87f, "WiFi disabled");
|
|
// (LVGL boot renders via lv_timer_handler in setProgress)
|
|
}
|
|
|
|
// Step 23: BLE start
|
|
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);
|
|
|
|
if (bleInterface.start()) {
|
|
static RNS::Interface bleIface(&bleInterface);
|
|
bleIface.mode(RNS::Type::Interface::MODE_GATEWAY);
|
|
RNS::Transport::register_interface(bleIface);
|
|
|
|
bleSideband.begin(bleInterface.getServer());
|
|
bleSideband.setPacketCallback([](const uint8_t* data, size_t len) {
|
|
RNS::Bytes pkt(data, len);
|
|
bleInterface.injectIncoming(pkt);
|
|
});
|
|
|
|
ui.statusBar().setBLEActive(true);
|
|
ui.lvStatusBar().setBLEActive(true);
|
|
Serial.println("[BLE] Transport + Sideband ready");
|
|
}
|
|
} else {
|
|
Serial.println("[BLE] Disabled by config");
|
|
}
|
|
|
|
// Step 24: Power manager
|
|
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
|
|
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);
|
|
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");
|
|
|
|
// 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.setLXMFManager(&lxmf);
|
|
lvHomeScreen.setAnnounceManager(announceManager);
|
|
lvHomeScreen.setRadioOnline(radioOnline);
|
|
lvHomeScreen.setTCPClients(&tcpClients);
|
|
lvHomeScreen.setAnnounceCallback([]() {
|
|
announceWithName();
|
|
Serial.println("[HOME] Announce triggered via Enter");
|
|
});
|
|
|
|
lvContactsScreen.setAnnounceManager(announceManager);
|
|
lvContactsScreen.setUIManager(&ui);
|
|
lvContactsScreen.setNodeSelectedCallback([](const std::string& peerHex) {
|
|
lvMessageView.setPeerHex(peerHex);
|
|
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
|
ui.setLvScreen(&lvMessageView);
|
|
});
|
|
|
|
lvNodesScreen.setAnnounceManager(announceManager);
|
|
lvNodesScreen.setUIManager(&ui);
|
|
lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) {
|
|
lvMessageView.setPeerHex(peerHex);
|
|
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
|
ui.setLvScreen(&lvMessageView);
|
|
});
|
|
|
|
lvMessagesScreen.setLXMFManager(&lxmf);
|
|
lvMessagesScreen.setAnnounceManager(announceManager);
|
|
lvMessagesScreen.setUIManager(&ui);
|
|
lvMessagesScreen.setOpenCallback([](const std::string& peerHex) {
|
|
lvMessageView.setPeerHex(peerHex);
|
|
ui.setLvScreen(&lvMessageView);
|
|
});
|
|
|
|
lvMessageView.setLXMFManager(&lxmf);
|
|
lvMessageView.setAnnounceManager(announceManager);
|
|
lvMessageView.setUIManager(&ui);
|
|
lvMessageView.setBackCallback([]() {
|
|
ui.setLvScreen(&lvMessagesScreen);
|
|
});
|
|
|
|
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.destinationHashStr());
|
|
lvSettingsScreen.setDestinationHash(rns.destinationHashHex());
|
|
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();
|
|
});
|
|
|
|
// LVGL help overlay
|
|
lvHelpOverlay.create();
|
|
|
|
// Tab bar callbacks — LVGL
|
|
lvTabScreens[LvTabBar::TAB_HOME] = &lvHomeScreen;
|
|
lvTabScreens[LvTabBar::TAB_CONTACTS] = &lvContactsScreen;
|
|
lvTabScreens[LvTabBar::TAB_MSGS] = &lvMessagesScreen;
|
|
lvTabScreens[LvTabBar::TAB_NODES] = &lvNodesScreen;
|
|
lvTabScreens[LvTabBar::TAB_SETTINGS] = &lvSettingsScreen;
|
|
|
|
ui.lvTabBar().setTabCallback([](int tab) {
|
|
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
|
|
});
|
|
|
|
// Data clean screen (first boot only — when SD has old data)
|
|
lvDataCleanScreen.setDoneCallback([](bool wipe) {
|
|
if (wipe) {
|
|
Serial.println("[BOOT] User chose to wipe old data");
|
|
lvDataCleanScreen.showStatus("Clearing old data...");
|
|
sdStore.wipeRatputer();
|
|
if (announceManager) announceManager->clearAll();
|
|
Serial.println("[BOOT] Old data cleared");
|
|
lvDataCleanScreen.showStatus("Done! Rebooting...");
|
|
delay(1500);
|
|
ESP.restart();
|
|
} else {
|
|
Serial.println("[BOOT] User chose to keep old data");
|
|
ui.setLvScreen(&lvNameInputScreen);
|
|
}
|
|
});
|
|
|
|
// Name input screen (first boot only — when no display name is set)
|
|
lvNameInputScreen.setDoneCallback([](const String& name) {
|
|
String finalName = name;
|
|
if (finalName.isEmpty()) {
|
|
// Auto-generate: Ratspeak.org-xxx (first 3 chars of LXMF dest hash)
|
|
String dh = rns.destinationHashHex();
|
|
finalName = "Ratspeak.org-" + dh.substring(0, 3);
|
|
}
|
|
userConfig.settings().displayName = finalName;
|
|
userConfig.save(sdStore, flash);
|
|
// Also save to active identity slot
|
|
if (identityMgr.activeIndex() >= 0) {
|
|
identityMgr.setDisplayName(identityMgr.activeIndex(), finalName);
|
|
}
|
|
Serial.printf("[BOOT] Display name set: '%s'\n", finalName.c_str());
|
|
|
|
// Transition to home screen
|
|
ui.setBootMode(false);
|
|
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()) {
|
|
// First boot — check if SD has old data that should be cleaned
|
|
if (sdStore.isReady() && sdStore.hasExistingData()) {
|
|
ui.setLvScreen(&lvDataCleanScreen);
|
|
Serial.println("[BOOT] Old SD data found, showing data clean screen");
|
|
} else {
|
|
ui.setLvScreen(&lvNameInputScreen);
|
|
Serial.println("[BOOT] Showing name input screen");
|
|
}
|
|
} else {
|
|
// Name already set — go straight to home
|
|
ui.setBootMode(false);
|
|
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();
|
|
Serial.println("[BOOT] Initial announce sent");
|
|
}
|
|
|
|
// Clear boot loop counter — we survived!
|
|
{
|
|
Preferences prefs;
|
|
if (prefs.begin("ratdeck", false)) {
|
|
prefs.putInt("bootc", 0);
|
|
prefs.end();
|
|
}
|
|
}
|
|
|
|
Serial.println("[BOOT] RatDeck ready");
|
|
Serial.printf("[BOOT] Summary: radio=%s flash=%s sd=%s\n",
|
|
radioOnline ? "ONLINE" : "OFFLINE",
|
|
flash.isReady() ? "OK" : "FAIL",
|
|
sdStore.isReady() ? "OK" : "FAIL");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Loop
|
|
// =============================================================================
|
|
|
|
void loop() {
|
|
// 1. Input polling
|
|
inputManager.update();
|
|
if (inputManager.hadStrongActivity()) {
|
|
powerMgr.activity(); // Keyboard/touch: wake from any state
|
|
} else if (inputManager.hadActivity()) {
|
|
powerMgr.weakActivity(); // Trackball: wake from dim only
|
|
}
|
|
|
|
// 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 (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);
|
|
|
|
// Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume)
|
|
if (!consumed && !evt.ctrl) {
|
|
bool tabLeft = (evt.character == ',') || evt.left;
|
|
bool tabRight = (evt.character == '/') || evt.right;
|
|
if (tabLeft) {
|
|
ui.lvTabBar().cycleTab(-1);
|
|
int tab = ui.lvTabBar().getActiveTab();
|
|
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
|
|
}
|
|
if (tabRight) {
|
|
ui.lvTabBar().cycleTab(1);
|
|
int tab = ui.lvTabBar().getActiveTab();
|
|
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. LVGL timer handler — throttled to ~30 FPS
|
|
{
|
|
unsigned long now = millis();
|
|
if (powerMgr.isScreenOn() && now - lastLvglTime >= LVGL_INTERVAL_MS) {
|
|
lastLvglTime = now;
|
|
lv_timer_handler();
|
|
}
|
|
}
|
|
|
|
// 4. Reticulum loop (radio RX via LoRaInterface) — throttle to ~100Hz
|
|
{
|
|
static unsigned long lastRNS = 0;
|
|
unsigned long now = millis();
|
|
if (now - lastRNS >= 10) {
|
|
lastRNS = now;
|
|
rns.loop();
|
|
}
|
|
}
|
|
|
|
// 5. Auto-announce every 5 minutes
|
|
if (bootComplete && millis() - lastAutoAnnounce >= ANNOUNCE_INTERVAL_MS) {
|
|
lastAutoAnnounce = millis();
|
|
if (rns.loraInterface() && rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) {
|
|
Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%");
|
|
} else {
|
|
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
|
|
rns.announce(appData);
|
|
ui.statusBar().flashAnnounce();
|
|
ui.lvStatusBar().flashAnnounce();
|
|
Serial.println("[AUTO] Periodic announce");
|
|
}
|
|
}
|
|
|
|
// 6. LXMF outgoing queue + announce manager deferred saves
|
|
lxmf.loop();
|
|
if (announceManager) announceManager->loop();
|
|
|
|
// 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());
|
|
|
|
// NTP time sync
|
|
configTzTime("UTC5", "pool.ntp.org", "time.nist.gov");
|
|
Serial.println("[NTP] Time sync started (UTC-5)");
|
|
|
|
// Recreate TCP clients on every WiFi connect (old clients may have stale sockets)
|
|
reloadTCPClients();
|
|
// Defer announce to let VPS register the connection (non-blocking)
|
|
wifiDeferredAnnounce = true;
|
|
wifiConnectedAt = millis();
|
|
} else if (!connected && wifiSTAConnected) {
|
|
wifiSTAConnected = false;
|
|
ui.statusBar().setWiFiActive(false);
|
|
ui.lvStatusBar().setWiFiActive(false);
|
|
ui.lvStatusBar().setTCPConnected(false);
|
|
// Stop and deregister TCP clients cleanly
|
|
for (auto* tcp : tcpClients) tcp->stop();
|
|
for (auto& iface : tcpIfaces) {
|
|
RNS::Transport::deregister_interface(iface);
|
|
}
|
|
tcpClients.clear();
|
|
tcpIfaces.clear();
|
|
Serial.println("[WIFI] STA disconnected, TCP interfaces deregistered");
|
|
}
|
|
}
|
|
|
|
// 7.5. Deferred WiFi announce (non-blocking replacement for delay(1500))
|
|
if (wifiDeferredAnnounce && millis() - wifiConnectedAt >= 1500) {
|
|
wifiDeferredAnnounce = false;
|
|
bool anyTcpConnected = false;
|
|
for (auto* tcp : tcpClients) {
|
|
if (tcp->isConnected()) { anyTcpConnected = true; break; }
|
|
}
|
|
if (anyTcpConnected) {
|
|
Serial.println("[TCP] Sending announce over new TCP connection...");
|
|
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
|
|
rns.announce(appData);
|
|
lastAutoAnnounce = millis();
|
|
} else {
|
|
Serial.println("[TCP] No TCP clients connected, skipping announce");
|
|
}
|
|
}
|
|
|
|
// 8. WiFi + TCP loops (with global budget)
|
|
if (wifiImpl) wifiImpl->loop();
|
|
{
|
|
unsigned long tcpBudgetStart = millis();
|
|
for (auto* tcp : tcpClients) {
|
|
if (millis() - tcpBudgetStart >= TCP_GLOBAL_BUDGET_MS) break;
|
|
tcp->loop();
|
|
yield();
|
|
}
|
|
}
|
|
|
|
// 9. BLE loops
|
|
bleInterface.loop();
|
|
bleSideband.loop();
|
|
|
|
// 10. Power management
|
|
powerMgr.loop();
|
|
|
|
// 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());
|
|
// Update TCP connection indicator
|
|
bool anyTcpUp = false;
|
|
for (auto* tcp : tcpClients) {
|
|
if (tcp && tcp->isConnected()) { anyTcpUp = true; break; }
|
|
}
|
|
ui.lvStatusBar().setTCPConnected(anyTcpUp);
|
|
ui.update();
|
|
}
|
|
}
|
|
|
|
// 12. Render any dirty regions
|
|
if (powerMgr.isScreenOn()) {
|
|
ui.render();
|
|
}
|
|
|
|
// 13. Heartbeat for crash diagnosis
|
|
{
|
|
unsigned long cycleTime = millis() - loopCycleStart;
|
|
if (cycleTime > maxLoopTime) maxLoopTime = cycleTime;
|
|
|
|
if (millis() - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
|
|
lastHeartbeat = millis();
|
|
Serial.printf("[HEART] heap=%lu psram=%lu min=%lu loop=%lums nodes=%d paths=%d links=%d lxmfQ=%d up=%lus radio=%s sd=%s flash=%s\n",
|
|
(unsigned long)ESP.getFreeHeap(),
|
|
(unsigned long)ESP.getFreePsram(),
|
|
(unsigned long)ESP.getMinFreeHeap(),
|
|
maxLoopTime,
|
|
announceManager ? announceManager->nodeCount() : 0,
|
|
(int)rns.pathCount(),
|
|
(int)rns.linkCount(),
|
|
lxmf.queuedCount(),
|
|
millis() / 1000,
|
|
radioOnline ? "ON" : "OFF",
|
|
sdStore.isReady() ? "OK" : "FAIL",
|
|
flash.isReady() ? "OK" : "FAIL");
|
|
maxLoopTime = 0;
|
|
}
|
|
}
|
|
loopCycleStart = millis();
|
|
|
|
yield();
|
|
}
|