mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-07-18 09:46:15 +00:00
Gate rsDeck opportunistic LXMF sends by the final packed Reticulum raw size when the next hop is LoRa, instead of using the pre-encryption LXMF payload size as the 254-byte limit. Keep TCP paths on the normal Reticulum MDU, route LoRa packets that would exceed one RNode RF frame into link delivery, and log payload/raw/lora_raw sizing for field diagnosis. Tested with rsDeck/Ratdeck T-Deck and Heltec V3 RNode attached over USB to a MacBook Pro running Ratspeak, using the medium-fast LoRa preset. Short 1-5 character opportunistic sends worked bidirectionally. Direct link delivery worked from T-Deck to Ratspeak/Heltec; Ratspeak-to-T-Deck link delivery later queued during link establishment while opportunistic still worked.
2242 lines
86 KiB
C++
2242 lines
86 KiB
C++
// =============================================================================
|
||
// rsDeck — Main Entry Point
|
||
// LilyGo T-Deck Plus: LovyanGFX Direct UI + microReticulum + LXMF Messaging
|
||
// =============================================================================
|
||
|
||
#include <Arduino.h>
|
||
#include <SPI.h>
|
||
#include <Wire.h>
|
||
#include <esp_netif.h>
|
||
#include <lvgl.h>
|
||
|
||
#include "config/BoardConfig.h"
|
||
#include "config/Config.h"
|
||
#include "platform/RsDeckModeSwitch.h"
|
||
#include "hal/Display.h"
|
||
#include "hal/TouchInput.h"
|
||
#include "hal/Trackball.h"
|
||
#include "hal/Keyboard.h"
|
||
#include "hal/Power.h"
|
||
#if HAS_GPS
|
||
#include "hal/GPSManager.h"
|
||
#endif
|
||
#include "radio/SX1262.h"
|
||
#include "input/InputManager.h"
|
||
#include "input/HotkeyManager.h"
|
||
#include "ui/UIManager.h"
|
||
#include "ui/Theme.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"
|
||
#include "ui/screens/LvQrOverlay.h"
|
||
// Map screen removed
|
||
#include "ui/screens/LvNameInputScreen.h"
|
||
#include "ui/screens/LvTimezoneScreen.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 <WiFiMulti.h>
|
||
#include "transport/TCPClientInterface.h"
|
||
#include "transport/AutoInterfaceWrapper.h"
|
||
#if HAS_BLE
|
||
#include "transport/BLEInterface.h"
|
||
#include "transport/BLESideband.h"
|
||
#endif
|
||
#include "config/UserConfig.h"
|
||
#include "audio/AudioNotify.h"
|
||
#include <ArduinoJson.h>
|
||
#include <Preferences.h>
|
||
#include <atomic>
|
||
#include <cctype>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <list>
|
||
#include <string>
|
||
#include <esp_system.h>
|
||
#include <esp_heap_caps.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)
|
||
std::list<TCPClientInterface*> retiredTcpClients;
|
||
bool tcpReloadRequested = false;
|
||
#if HAS_BLE
|
||
BLEInterface bleInterface;
|
||
BLESideband bleSideband;
|
||
#endif
|
||
UserConfig userConfig;
|
||
Power powerMgr;
|
||
AudioNotify audio;
|
||
IdentityManager identityMgr;
|
||
#if HAS_GPS
|
||
GPSManager gps;
|
||
#endif
|
||
|
||
// --- LVGL Screens ---
|
||
LvBootScreen lvBootScreen;
|
||
LvHomeScreen lvHomeScreen;
|
||
LvNodesScreen lvNodesScreen;
|
||
LvMessagesScreen lvMessagesScreen;
|
||
LvContactsScreen lvContactsScreen;
|
||
LvMessageView lvMessageView;
|
||
LvSettingsScreen lvSettingsScreen;
|
||
LvHelpOverlay lvHelpOverlay;
|
||
LvQrOverlay lvQrOverlay;
|
||
// LvMapScreen removed
|
||
LvNameInputScreen lvNameInputScreen;
|
||
LvTimezoneScreen lvTimezoneScreen;
|
||
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 sdHadExistingData = false;
|
||
bool wifiSTAStarted = false;
|
||
WiFiMulti wifiMulti;
|
||
bool wifiSTAConnected = false;
|
||
unsigned long lastAutoAnnounce = 0;
|
||
bool bootAnnouncePending = false;
|
||
uint8_t bootAnnounceAttempts = 0;
|
||
unsigned long bootAnnounceAt = 0;
|
||
constexpr unsigned long BOOT_ANNOUNCE_DELAY_MS = 5000;
|
||
constexpr uint8_t BOOT_ANNOUNCE_MAX_ATTEMPTS = 3;
|
||
|
||
static void applyRadioSettingsToHardware(const UserSettings& s, const char* context) {
|
||
if (!radioOnline) return;
|
||
|
||
if (!s.loraEnabled) {
|
||
radio.sleep();
|
||
Serial.printf("[%s] LoRa disabled by config\n", context);
|
||
return;
|
||
}
|
||
|
||
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("[%s] Radio: %lu Hz, SF%d, BW%lu, CR4/%d, %d dBm, pre=%ld\n",
|
||
context,
|
||
(unsigned long)s.loraFrequency, s.loraSF,
|
||
(unsigned long)s.loraBW, s.loraCR, s.loraTxPower,
|
||
s.loraPreamble);
|
||
}
|
||
|
||
// STA reconnects are scheduled from WiFi events and fired from loop().
|
||
std::atomic<bool> wifiNeedsReconnect{false};
|
||
std::atomic<unsigned long> wifiReconnectAt{0};
|
||
std::atomic<uint8_t> wifiReconnectAttempt{0};
|
||
constexpr unsigned long WIFI_BACKOFF_MS[4] = {5000, 15000, 60000, 300000};
|
||
constexpr unsigned long WIFI_NETIF_SETTLE_MS = 1500;
|
||
|
||
static void scheduleWiFiReconnect() {
|
||
uint8_t attempt = wifiReconnectAttempt.load();
|
||
uint8_t idx = attempt < 4 ? attempt : 3;
|
||
unsigned long backoff = WIFI_BACKOFF_MS[idx];
|
||
if (backoff < WIFI_NETIF_SETTLE_MS) backoff = WIFI_NETIF_SETTLE_MS;
|
||
wifiReconnectAt.store(millis() + backoff);
|
||
wifiNeedsReconnect.store(true);
|
||
if (attempt < 4) wifiReconnectAttempt.store(attempt + 1);
|
||
}
|
||
|
||
static void onWiFiEvent(WiFiEvent_t event) {
|
||
switch (event) {
|
||
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
||
// Our own disconnect() below can emit another disconnect event.
|
||
if (wifiNeedsReconnect.load()) break;
|
||
scheduleWiFiReconnect();
|
||
// Drop the netif and clear stale AP info before the next connect.
|
||
WiFi.disconnect(false, true);
|
||
break;
|
||
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
|
||
case ARDUINO_EVENT_WIFI_STA_GOT_IP6:
|
||
wifiNeedsReconnect.store(false);
|
||
wifiReconnectAttempt.store(0);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
unsigned long lastStatusUpdate = 0;
|
||
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 = 35; // Max cumulative TCP time per loop
|
||
|
||
AutoInterfaceWrapper autoIface;
|
||
bool autoIfaceDeferredStart = false;
|
||
unsigned long autoIfaceDeferredAt = 0;
|
||
unsigned long lastAutoIfaceLinkCheck = 0;
|
||
|
||
// LXMF diagnostic counters (reset each heartbeat)
|
||
static uint32_t diagTcpSkipEvents = 0;
|
||
|
||
// =============================================================================
|
||
// Timezone helper — returns POSIX TZ string for current config
|
||
// =============================================================================
|
||
|
||
static const char* currentPosixTZ() {
|
||
uint8_t idx = userConfig.settings().timezoneIdx;
|
||
if (idx < TIMEZONE_COUNT) return TIMEZONE_TABLE[idx].posixTZ;
|
||
return "EST5EDT,M3.2.0,M11.1.0"; // Fallback
|
||
}
|
||
|
||
// =============================================================================
|
||
// Announce with display name (MessagePack-encoded app_data)
|
||
// =============================================================================
|
||
|
||
// LXMF announce app_data:
|
||
// [display_name(bin), stamp_cost(nil|uint), supported_functionality(array)]
|
||
// Always emit fixarray(3) so Python LXMF doesn't default auto_compress=True for
|
||
// our destinations. stamp_cost=nil means no inbound stamp is required. Empty
|
||
// supported_functionality list = we do NOT support SF_COMPRESSION (bz2).
|
||
RNS::Bytes encodeAnnounceName(const String& name) {
|
||
size_t nameLen = name.length();
|
||
if (nameLen > 31) nameLen = 31;
|
||
uint8_t buf[5 + 31];
|
||
size_t i = 0;
|
||
buf[i++] = 0x93; // fixarray(3)
|
||
buf[i++] = 0xC4; // bin 8
|
||
buf[i++] = (uint8_t)nameLen;
|
||
if (nameLen) { memcpy(buf + i, name.c_str(), nameLen); i += nameLen; }
|
||
buf[i++] = 0xC0; // stamp_cost = nil (no stamp required)
|
||
buf[i++] = 0x90; // empty fixarray (no SF_* supported)
|
||
return RNS::Bytes(buf, i);
|
||
}
|
||
|
||
static bool hasUsableAnnounceTransport() {
|
||
if (!rns.isTransportActive()) return false;
|
||
auto* loraIf = rns.loraInterface();
|
||
if (radioOnline && loraIf && loraIf->isOnline()) return true;
|
||
if (wifiImpl && wifiImpl->isAPActive() && wifiImpl->getClientCount() > 0) return true;
|
||
for (auto* tcp : tcpClients) {
|
||
if (tcp && tcp->isConnected()) return true;
|
||
}
|
||
#if HAS_BLE
|
||
if (bleInterface.isClientConnected()) return true;
|
||
#endif
|
||
if (autoIface.isOnline() && autoIface.peerCount() > 0) return true;
|
||
return false;
|
||
}
|
||
|
||
static bool announceWithName(bool silent = false) {
|
||
if (!hasUsableAnnounceTransport()) {
|
||
if (!silent) ui.lvStatusBar().showToast("No active transport", 1500);
|
||
Serial.println("[ANNOUNCE-TX] skipped: no active transport");
|
||
return false;
|
||
}
|
||
RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
|
||
Serial.printf("[ANNOUNCE-TX] name=\"%s\" appData=%d bytes silent=%s\n",
|
||
userConfig.settings().displayName.c_str(), (int)appData.size(),
|
||
silent ? "yes" : "no");
|
||
rns.announce(appData);
|
||
if (!silent) {
|
||
ui.lvStatusBar().flashAnnounce();
|
||
ui.lvStatusBar().showToast("Announce sent!");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void manualAnnounce() {
|
||
if (announceWithName()) Serial.println("[ANNOUNCE] Manual announce sent");
|
||
}
|
||
|
||
// =============================================================================
|
||
// TCP client management — stop old clients, create new from config
|
||
// =============================================================================
|
||
|
||
static void drainRetiredTCPClients() {
|
||
for (auto it = retiredTcpClients.begin(); it != retiredTcpClients.end(); ) {
|
||
TCPClientInterface* tcp = *it;
|
||
if (!tcp || tcp->canDestroy()) {
|
||
if (tcp) delete tcp;
|
||
it = retiredTcpClients.erase(it);
|
||
} else {
|
||
++it;
|
||
}
|
||
}
|
||
}
|
||
|
||
static void retireTCPClient(TCPClientInterface* tcp) {
|
||
if (!tcp) return;
|
||
tcp->stop();
|
||
if (tcp->canDestroy()) {
|
||
delete tcp;
|
||
} else {
|
||
retiredTcpClients.push_back(tcp);
|
||
}
|
||
}
|
||
|
||
static void reloadTCPClients() {
|
||
// Stop and deregister existing clients
|
||
for (auto& iface : tcpIfaces) {
|
||
RNS::Transport::deregister_interface(iface);
|
||
}
|
||
for (auto* tcp : tcpClients) {
|
||
retireTCPClient(tcp);
|
||
}
|
||
tcpClients.clear();
|
||
tcpIfaces.clear();
|
||
drainRetiredTCPClients();
|
||
|
||
// 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_FULL);
|
||
RNS::Transport::register_interface(tcpIfaces.back());
|
||
tcp->start();
|
||
tcpClients.push_back(tcp);
|
||
Serial.printf("[TCP] Created client: %s:%d (registered with Transport, mode=FULL)\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");
|
||
}
|
||
}
|
||
|
||
static void requestTCPClientsReload() {
|
||
tcpReloadRequested = true;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Hotkey callbacks
|
||
// =============================================================================
|
||
|
||
void onHotkeyHelp() {
|
||
lvHelpOverlay.toggle();
|
||
}
|
||
void onHotkeyMessages() {
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
||
ui.setScreen(&lvMessagesScreen);
|
||
}
|
||
void onHotkeyNewMsg() {
|
||
bool hasContacts = false;
|
||
if (announceManager) {
|
||
for (const auto& node : announceManager->nodes()) {
|
||
if (node.saved) { hasContacts = true; break; }
|
||
}
|
||
}
|
||
if (hasContacts) {
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_CONTACTS);
|
||
ui.setScreen(&lvContactsScreen);
|
||
} else {
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_NODES);
|
||
ui.setScreen(&lvNodesScreen);
|
||
ui.lvStatusBar().showToast("Pick a peer to message", 1200);
|
||
}
|
||
}
|
||
void onHotkeySettings() {
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS);
|
||
ui.setScreen(&lvSettingsScreen);
|
||
}
|
||
void onHotkeyAnnounce() {
|
||
manualAnnounce();
|
||
}
|
||
void onHotkeyAutoIface() {
|
||
Serial.println("=== AUTOIFACE DUMP ===");
|
||
Serial.printf("Enabled in settings : %s\n",
|
||
userConfig.settings().autoIfaceEnabled ? "YES" : "no");
|
||
Serial.printf("Online : %s\n", autoIface.isOnline() ? "YES" : "no");
|
||
if (autoIface.isOnline()) {
|
||
Serial.printf("Multicast address : %s\n", autoIface.multicastAddress().c_str());
|
||
Serial.printf("Link-local : %s\n", WiFi.localIPv6().toString().c_str());
|
||
Serial.printf("Peers : %u\n", (unsigned)autoIface.peerCount());
|
||
}
|
||
Serial.printf("Deferred-start armed: %s (elapsed=%lums)\n",
|
||
autoIfaceDeferredStart ? "YES" : "no",
|
||
autoIfaceDeferredStart ? (millis() - autoIfaceDeferredAt) : 0UL);
|
||
Serial.println("======================");
|
||
}
|
||
void onHotkeyDiag() {
|
||
Serial.println("=== DIAGNOSTIC DUMP ===");
|
||
Serial.printf("Device: rsDeck 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("Regulator: %s\n", LORA_USE_DCDC_REGULATOR ? "DC-DC" : "LDO");
|
||
Serial.printf("Preamble: %ld symbols\n", radio.getPreambleLength());
|
||
Serial.printf("Bitrate: %lu bps LDRO: %s frame255: %.0f ms\n",
|
||
(unsigned long)radio.getBitrate(),
|
||
radio.lowDataRateEnabled() ? "ON" : "off",
|
||
radio.getAirtime(MAX_PACKET_SIZE));
|
||
if (auto* loraIf = rns.loraInterface()) {
|
||
Serial.printf("LoRaIF: bitrate=%lu bps split_timeout=%lu ms frame=%.0f ms airtime=%.2f%%\n",
|
||
(unsigned long)loraIf->bitrate(),
|
||
loraIf->splitRxTimeoutMs(),
|
||
loraIf->singleFrameAirtimeMs(),
|
||
loraIf->airtimeUtilization() * 100.0f);
|
||
}
|
||
Serial.printf("IQ invert: %s\n", radio.getInvertIQ() ? "ON" : "off");
|
||
Serial.printf("SyncWord regs: 0x%02X%02X\n",
|
||
radio.readRegister(REG_SYNC_WORD_MSB_6X),
|
||
radio.readRegister(REG_SYNC_WORD_LSB_6X));
|
||
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("IRQ flags: 0x%04X\n", radio.getIrqFlags());
|
||
Serial.printf("Current RSSI: %d dBm\n", radio.currentRssi());
|
||
uint8_t packetType = radio.getPacketType();
|
||
const char* packetTypeName =
|
||
(packetType == 0x00) ? "GFSK" :
|
||
(packetType == 0x01) ? "LoRa" :
|
||
(packetType == 0x02) ? "LR-FHSS" : "unknown";
|
||
Serial.printf("Packet type: 0x%02X (%s)%s\n",
|
||
packetType, packetTypeName,
|
||
packetType == 0x01 ? "" : " *** NOT LoRa ***");
|
||
}
|
||
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("=======================");
|
||
}
|
||
|
||
static void printIrqFlags(uint16_t flags) {
|
||
Serial.printf("0x%04X", flags);
|
||
if (flags & 0x0001) Serial.print(" TX_DONE");
|
||
if (flags & 0x0002) Serial.print(" RX_DONE");
|
||
if (flags & 0x0004) Serial.print(" PREAMBLE");
|
||
if (flags & 0x0008) Serial.print(" SYNC");
|
||
if (flags & 0x0010) Serial.print(" HEADER_VALID");
|
||
if (flags & 0x0020) Serial.print(" HEADER_ERR");
|
||
if (flags & 0x0040) Serial.print(" CRC_ERR");
|
||
if (flags & 0x0080) Serial.print(" CAD_DONE");
|
||
if (flags & 0x0100) Serial.print(" CAD_DET");
|
||
if (flags & 0x0200) Serial.print(" TIMEOUT");
|
||
}
|
||
|
||
void onHotkeyIrqMonitor() {
|
||
if (!radioOnline) { Serial.println("[IRQ] Radio offline"); return; }
|
||
|
||
radio.receive();
|
||
Serial.println("[IRQ] Sampling IRQ/RSSI for 5 seconds...");
|
||
uint16_t lastFlags = 0xFFFF;
|
||
unsigned long start = millis();
|
||
unsigned long lastLine = 0;
|
||
while (millis() - start < 5000) {
|
||
uint16_t flags = radio.getIrqFlags();
|
||
unsigned long now = millis();
|
||
if (flags != lastFlags || now - lastLine >= 500) {
|
||
Serial.printf("[IRQ] t=%lums rssi=%d flags=",
|
||
now - start, radio.currentRssi());
|
||
printIrqFlags(flags);
|
||
Serial.println();
|
||
lastFlags = flags;
|
||
lastLine = now;
|
||
}
|
||
delay(50);
|
||
}
|
||
radio.receive();
|
||
Serial.println("[IRQ] Done");
|
||
}
|
||
|
||
// RSSI monitor — non-blocking state machine (sampled in main loop)
|
||
volatile bool rssiMonitorActive = false;
|
||
unsigned long rssiMonitorStart = 0;
|
||
unsigned long rssiLastSample = 0;
|
||
int rssiMinVal = 0, rssiMaxVal = -200, rssiSampleCount = 0;
|
||
|
||
void onHotkeyRssiMonitor() {
|
||
if (!radioOnline) { Serial.println("[RSSI] Radio offline"); return; }
|
||
if (rssiMonitorActive) {
|
||
// Already running — cancel
|
||
rssiMonitorActive = false;
|
||
Serial.printf("[RSSI] Stopped: %d samples, min=%d max=%d dBm\n",
|
||
rssiSampleCount, rssiMinVal, rssiMaxVal);
|
||
return;
|
||
}
|
||
Serial.println("[RSSI] Sampling for 5 seconds (non-blocking)...");
|
||
rssiMonitorActive = true;
|
||
rssiMonitorStart = millis();
|
||
rssiLastSample = 0;
|
||
rssiMinVal = 0;
|
||
rssiMaxVal = -200;
|
||
rssiSampleCount = 0;
|
||
}
|
||
|
||
void onHotkeyRadioTest() {
|
||
Serial.println("[TEST] Sending raw test packet...");
|
||
uint8_t header = 0xA0;
|
||
const char* testPayload = "RSDECK_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();
|
||
}
|
||
|
||
static void cycleDiagnosticTxPower() {
|
||
static constexpr int8_t kPowers[] = {-9, -3, 0, 2, 6, 10, 14, 17, 22};
|
||
int current = radio.getTxPower();
|
||
size_t next = 0;
|
||
for (size_t i = 0; i < sizeof(kPowers) / sizeof(kPowers[0]); i++) {
|
||
if (current == kPowers[i]) {
|
||
next = (i + 1) % (sizeof(kPowers) / sizeof(kPowers[0]));
|
||
break;
|
||
}
|
||
}
|
||
|
||
radio.setTxPower(kPowers[next]);
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] transient TX power set to %d dBm\n", (int)kPowers[next]);
|
||
}
|
||
|
||
static void setDiagnosticMinTxPower() {
|
||
radio.setTxPower(-9);
|
||
radio.receive();
|
||
Serial.println("[SERIAL] transient TX power set to -9 dBm");
|
||
}
|
||
|
||
static bool setDiagnosticTxPower(int powerDbm) {
|
||
static constexpr int kMaxDiagnosticTxPower = 22;
|
||
if (powerDbm < -9 || powerDbm > kMaxDiagnosticTxPower) {
|
||
Serial.printf("[SERIAL] TX power out of range: %d dBm (allowed -9..%d)\n",
|
||
powerDbm, kMaxDiagnosticTxPower);
|
||
return false;
|
||
}
|
||
radio.setTxPower((int8_t)powerDbm);
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] transient TX power set to %d dBm\n", powerDbm);
|
||
return true;
|
||
}
|
||
|
||
static void toggleDiagnosticInvertIQ() {
|
||
radio.setInvertIQ(!radio.getInvertIQ());
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] IQ inversion %s\n", radio.getInvertIQ() ? "ON" : "off");
|
||
}
|
||
|
||
static bool setDiagnosticFrequency(uint32_t frequencyHz) {
|
||
if (frequencyHz < 150000000UL || frequencyHz > 960000000UL) {
|
||
Serial.printf("[SERIAL] frequency out of range: %lu Hz (allowed 150000000..960000000)\n",
|
||
(unsigned long)frequencyHz);
|
||
return false;
|
||
}
|
||
radio.setFrequency(frequencyHz);
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] transient frequency set to %lu Hz\n", (unsigned long)frequencyHz);
|
||
return true;
|
||
}
|
||
|
||
static void nudgeDiagnosticFrequency(int32_t deltaHz) {
|
||
uint32_t next = radio.getFrequency() + deltaHz;
|
||
radio.setFrequency(next);
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] transient frequency set to %lu Hz\n", (unsigned long)next);
|
||
}
|
||
|
||
static const char* skipSerialSeparators(const char* p) {
|
||
while (p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '=' || *p == ',')) {
|
||
++p;
|
||
}
|
||
return p;
|
||
}
|
||
|
||
static bool hasSerialArgument(const char* p) {
|
||
p = skipSerialSeparators(p);
|
||
return p && *p != '\0';
|
||
}
|
||
|
||
static bool parseSerialLong(const char* p, long& value, const char** rest = nullptr) {
|
||
p = skipSerialSeparators(p);
|
||
if (!p || *p == '\0') return false;
|
||
char* end = nullptr;
|
||
value = std::strtol(p, &end, 10);
|
||
if (end == p) return false;
|
||
if (rest) *rest = end;
|
||
return true;
|
||
}
|
||
|
||
static bool parseSerialDestinationHash(const char* p, RNS::Bytes& hash) {
|
||
p = skipSerialSeparators(p);
|
||
if (!p || *p == '\0') return false;
|
||
|
||
char hex[33] = {0};
|
||
size_t len = 0;
|
||
while (*p && len < 32) {
|
||
unsigned char ch = (unsigned char)*p;
|
||
if (std::isxdigit(ch)) {
|
||
hex[len++] = (char)*p;
|
||
} else if (*p != ' ' && *p != '\t' && *p != ':' && *p != '=' && *p != ',' && *p != '-') {
|
||
return false;
|
||
}
|
||
++p;
|
||
}
|
||
|
||
if (len != 32) return false;
|
||
hash.assignHex(hex);
|
||
return hash.size() == 16;
|
||
}
|
||
|
||
static bool selectDiagnosticPeer(const char* explicitArg, RNS::Bytes& destHash, std::string& label) {
|
||
if (hasSerialArgument(explicitArg)) {
|
||
if (!parseSerialDestinationHash(explicitArg, destHash)) {
|
||
Serial.println("[SERIAL] invalid LXMF destination hash; expected 32 hex characters");
|
||
return false;
|
||
}
|
||
label = destHash.toHex();
|
||
return true;
|
||
}
|
||
|
||
if (!announceManager) {
|
||
Serial.println("[SERIAL] LXMF test failed: announce manager is not ready");
|
||
return false;
|
||
}
|
||
|
||
const std::string localHex = rns.destination().hash().toHex();
|
||
for (const auto& node : announceManager->nodes()) {
|
||
if (node.hash.size() != 16) continue;
|
||
const std::string nodeHex = node.hash.toHex();
|
||
if (nodeHex == localHex) continue;
|
||
destHash = node.hash;
|
||
label = node.name.empty() ? nodeHex : (node.name + " " + nodeHex);
|
||
return true;
|
||
}
|
||
|
||
Serial.println("[SERIAL] LXMF test failed: no peer known; send/receive announces first or pass a hash");
|
||
return false;
|
||
}
|
||
|
||
static std::string makeDiagnosticLxmfPayload(size_t length) {
|
||
static constexpr char kPrefix[] = "RSDECK-LXMF-TEST:";
|
||
static constexpr char kPattern[] =
|
||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||
|
||
std::string out;
|
||
out.reserve(length);
|
||
for (size_t i = 0; kPrefix[i] && out.size() < length; ++i) {
|
||
out.push_back(kPrefix[i]);
|
||
}
|
||
for (size_t i = 0; out.size() < length; ++i) {
|
||
out.push_back(kPattern[i % (sizeof(kPattern) - 1)]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
static bool sendDiagnosticLxmf(size_t length, const char* explicitDest) {
|
||
static constexpr size_t kMaxDiagnosticLxmfChars = 512;
|
||
if (length == 0 || length > kMaxDiagnosticLxmfChars) {
|
||
Serial.printf("[SERIAL] LXMF test length out of range: %u (allowed 1..%u)\n",
|
||
(unsigned)length, (unsigned)kMaxDiagnosticLxmfChars);
|
||
return false;
|
||
}
|
||
|
||
RNS::Bytes destHash;
|
||
std::string peerLabel;
|
||
if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;
|
||
|
||
std::string payload = makeDiagnosticLxmfPayload(length);
|
||
bool ok = lxmf.sendMessage(destHash, payload);
|
||
Serial.printf("[SERIAL] LXMF test %s: len=%u dest=%s queue=%d\n",
|
||
ok ? "queued" : "rejected",
|
||
(unsigned)payload.size(),
|
||
peerLabel.c_str(),
|
||
lxmf.queuedCount());
|
||
return ok;
|
||
}
|
||
|
||
static constexpr uint8_t LITE_TRANSPORT_ID[16] = {
|
||
'r', 's', 'l', 'i', 't', 'e', '-', 'h',
|
||
'e', 'l', 't', 'e', 'c', '-', 'v', '3'
|
||
};
|
||
static constexpr size_t RNODE_DIAG_SINGLE_MTU = RSDECK_RNODE_SINGLE_FRAME_RAW_MAX;
|
||
static RNS::Bytes diagnosticLiteLinkId;
|
||
|
||
static bool sendDiagnosticRawReticulum(const RNS::Bytes& raw, const char* label) {
|
||
if (!radioOnline || !radio.isRadioOnline()) {
|
||
Serial.println("[SERIAL] lite diag failed: radio offline");
|
||
return false;
|
||
}
|
||
if (raw.empty() || raw.size() > RNODE_DIAG_SINGLE_MTU) {
|
||
Serial.printf("[SERIAL] lite diag %s rejected: raw len=%u (allowed 1..%u)\n",
|
||
label ? label : "packet",
|
||
(unsigned)raw.size(),
|
||
(unsigned)RNODE_DIAG_SINGLE_MTU);
|
||
return false;
|
||
}
|
||
|
||
uint8_t rnodeHeader = (uint8_t)(random(256)) & 0xF0;
|
||
radio.beginPacket();
|
||
radio.write(rnodeHeader);
|
||
radio.write(raw.data(), raw.size());
|
||
bool ok = radio.endPacket();
|
||
radio.receive();
|
||
Serial.printf("[SERIAL] lite diag %s TX %s raw=%u air=%u rnode=0x%02X\n",
|
||
label ? label : "packet",
|
||
ok ? "OK" : "FAILED",
|
||
(unsigned)raw.size(),
|
||
(unsigned)(raw.size() + 1),
|
||
rnodeHeader);
|
||
return ok;
|
||
}
|
||
|
||
static RNS::Bytes diagnosticHeader2LinkId(const RNS::Bytes& raw) {
|
||
RNS::Bytes material;
|
||
if (raw.empty()) return material;
|
||
|
||
material.append((uint8_t)(raw[0] & 0x0F));
|
||
if (raw.size() > 18) {
|
||
size_t headerEnd = raw.size() < 35 ? raw.size() : 35;
|
||
material.append(raw.data() + 18, headerEnd - 18);
|
||
}
|
||
if (raw.size() > 35) {
|
||
size_t payloadLen = raw.size() - 35;
|
||
if (payloadLen > 64) payloadLen = 64;
|
||
material.append(raw.data() + 35, payloadLen);
|
||
}
|
||
|
||
return RNS::Identity::full_hash(material).left(16);
|
||
}
|
||
|
||
static bool buildDiagnosticHeader2(uint8_t packetType, uint8_t context, const RNS::Bytes& destHash,
|
||
const uint8_t* payload, size_t payloadLen, RNS::Bytes& out) {
|
||
if (destHash.size() != 16) return false;
|
||
out.clear();
|
||
out.append((uint8_t)(0x40 | 0x10 | (packetType & 0x03))); // Header2 + Transport + Single
|
||
out.append((uint8_t)0x00); // hops
|
||
out.append(LITE_TRANSPORT_ID, sizeof(LITE_TRANSPORT_ID));
|
||
out.append(destHash.data(), destHash.size());
|
||
out.append(context);
|
||
out.append(payload, payloadLen);
|
||
return out.size() <= RNODE_DIAG_SINGLE_MTU;
|
||
}
|
||
|
||
static bool buildDiagnosticLinkPacket(uint8_t packetType, uint8_t context, const uint8_t* payload,
|
||
size_t payloadLen, RNS::Bytes& out) {
|
||
if (diagnosticLiteLinkId.size() != 16) {
|
||
Serial.println("[SERIAL] lite link diag failed: send J [dest_hash] first");
|
||
return false;
|
||
}
|
||
out.clear();
|
||
out.append((uint8_t)(0x0C | (packetType & 0x03))); // Header1 + Broadcast + Link
|
||
out.append((uint8_t)0x00); // hops
|
||
out.append(diagnosticLiteLinkId.data(), diagnosticLiteLinkId.size());
|
||
out.append(context);
|
||
out.append(payload, payloadLen);
|
||
return out.size() <= RNODE_DIAG_SINGLE_MTU;
|
||
}
|
||
|
||
static bool parseSerialContextByte(const char* p, uint8_t defaultContext, uint8_t& context) {
|
||
p = skipSerialSeparators(p);
|
||
if (!p || *p == '\0') {
|
||
context = defaultContext;
|
||
return true;
|
||
}
|
||
|
||
char* end = nullptr;
|
||
long parsed = std::strtol(p, &end, 16);
|
||
if (end == p || parsed < 0 || parsed > 0xFF) {
|
||
Serial.println("[SERIAL] invalid context; expected one hex byte, for example K0E or YFD");
|
||
return false;
|
||
}
|
||
context = (uint8_t)parsed;
|
||
return true;
|
||
}
|
||
|
||
static void fillDiagnosticPayload(uint8_t* payload, size_t len, uint8_t seed) {
|
||
for (size_t i = 0; i < len; i++) {
|
||
payload[i] = (uint8_t)(seed + i);
|
||
}
|
||
}
|
||
|
||
static bool sendDiagnosticLiteHeader2Data(size_t length, const char* explicitDest) {
|
||
static constexpr size_t kMaxDiagnosticTransportPayload = 160;
|
||
if (length == 0 || length > kMaxDiagnosticTransportPayload) {
|
||
Serial.printf("[SERIAL] usage: H<len> [dest_hash], length 1..%u\n",
|
||
(unsigned)kMaxDiagnosticTransportPayload);
|
||
return false;
|
||
}
|
||
|
||
RNS::Bytes destHash;
|
||
std::string peerLabel;
|
||
if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;
|
||
|
||
uint8_t payload[kMaxDiagnosticTransportPayload];
|
||
fillDiagnosticPayload(payload, length, 0x48);
|
||
|
||
RNS::Bytes raw;
|
||
if (!buildDiagnosticHeader2(0x00, 0x00, destHash, payload, length, raw)) {
|
||
Serial.println("[SERIAL] lite Header2 data build failed");
|
||
return false;
|
||
}
|
||
|
||
Serial.printf("[SERIAL] lite Header2 DATA to Heltec transport, dest=%s payload=%u\n",
|
||
peerLabel.c_str(), (unsigned)length);
|
||
return sendDiagnosticRawReticulum(raw, "H2-DATA");
|
||
}
|
||
|
||
static bool sendDiagnosticLiteLinkRequest(const char* explicitDest) {
|
||
RNS::Bytes destHash;
|
||
std::string peerLabel;
|
||
if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;
|
||
|
||
uint8_t payload[64];
|
||
fillDiagnosticPayload(payload, sizeof(payload), 0xA5);
|
||
|
||
RNS::Bytes raw;
|
||
if (!buildDiagnosticHeader2(0x02, 0x00, destHash, payload, sizeof(payload), raw)) {
|
||
Serial.println("[SERIAL] lite link request build failed");
|
||
return false;
|
||
}
|
||
|
||
diagnosticLiteLinkId = diagnosticHeader2LinkId(raw);
|
||
Serial.printf("[SERIAL] lite LINKREQUEST to Heltec transport, dest=%s link=%s\n",
|
||
peerLabel.c_str(), diagnosticLiteLinkId.toHex().c_str());
|
||
return sendDiagnosticRawReticulum(raw, "LINKREQUEST");
|
||
}
|
||
|
||
static bool sendDiagnosticLiteLinkData(const char* contextArg) {
|
||
uint8_t context = 0x0E; // Channel
|
||
if (!parseSerialContextByte(contextArg, context, context)) return false;
|
||
|
||
uint8_t payload[24];
|
||
fillDiagnosticPayload(payload, sizeof(payload), context);
|
||
|
||
RNS::Bytes raw;
|
||
if (!buildDiagnosticLinkPacket(0x00, context, payload, sizeof(payload), raw)) {
|
||
Serial.println("[SERIAL] lite link data build failed");
|
||
return false;
|
||
}
|
||
|
||
Serial.printf("[SERIAL] lite LINK DATA context=0x%02X link=%s\n",
|
||
context, diagnosticLiteLinkId.toHex().c_str());
|
||
return sendDiagnosticRawReticulum(raw, "LINK-DATA");
|
||
}
|
||
|
||
static bool sendDiagnosticLiteLinkProof(const char* contextArg) {
|
||
uint8_t context = 0xFD; // LinkProof
|
||
if (!parseSerialContextByte(contextArg, context, context)) return false;
|
||
|
||
uint8_t payload[64];
|
||
fillDiagnosticPayload(payload, sizeof(payload), 0x7A);
|
||
|
||
RNS::Bytes raw;
|
||
if (!buildDiagnosticLinkPacket(0x03, context, payload, sizeof(payload), raw)) {
|
||
Serial.println("[SERIAL] lite link proof build failed");
|
||
return false;
|
||
}
|
||
|
||
Serial.printf("[SERIAL] lite LINK PROOF context=0x%02X link=%s\n",
|
||
context, diagnosticLiteLinkId.toHex().c_str());
|
||
return sendDiagnosticRawReticulum(raw, "LINK-PROOF");
|
||
}
|
||
|
||
static void handleSerialLineCommand(const char* line) {
|
||
if (!line || !*line) return;
|
||
|
||
switch ((char)std::toupper((unsigned char)line[0])) {
|
||
case 'F': {
|
||
long value = 0;
|
||
if (!parseSerialLong(line + 1, value) || value < 0) {
|
||
Serial.println("[SERIAL] usage: F<frequency_hz>, for example F915000000");
|
||
return;
|
||
}
|
||
setDiagnosticFrequency((uint32_t)value);
|
||
break;
|
||
}
|
||
case 'P': {
|
||
long value = 0;
|
||
if (!parseSerialLong(line + 1, value)) {
|
||
Serial.println("[SERIAL] usage: P<tx_power_dbm>, for example P1 or P5");
|
||
return;
|
||
}
|
||
setDiagnosticTxPower((int)value);
|
||
break;
|
||
}
|
||
case 'L': {
|
||
long length = 0;
|
||
const char* rest = nullptr;
|
||
if (!parseSerialLong(line + 1, length, &rest) || length <= 0) {
|
||
Serial.println("[SERIAL] usage: L<payload_chars> [dest_hash], for example L120");
|
||
return;
|
||
}
|
||
sendDiagnosticLxmf((size_t)length, rest);
|
||
break;
|
||
}
|
||
case 'H': {
|
||
long length = 0;
|
||
const char* rest = nullptr;
|
||
if (!parseSerialLong(line + 1, length, &rest) || length <= 0) {
|
||
Serial.println("[SERIAL] usage: H<len> [dest_hash], for example H32 2db8...");
|
||
return;
|
||
}
|
||
sendDiagnosticLiteHeader2Data((size_t)length, rest);
|
||
break;
|
||
}
|
||
case 'J': {
|
||
sendDiagnosticLiteLinkRequest(line + 1);
|
||
break;
|
||
}
|
||
case 'K': {
|
||
sendDiagnosticLiteLinkData(line + 1);
|
||
break;
|
||
}
|
||
case 'Y': {
|
||
sendDiagnosticLiteLinkProof(line + 1);
|
||
break;
|
||
}
|
||
default:
|
||
Serial.printf("[SERIAL] unknown line command '%c'\n", line[0]);
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void printSerialHelp() {
|
||
Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | +/- freq");
|
||
Serial.println("[SERIAL] line commands: F<hz> exact-frequency | P<dBm> exact-tx-power | L<len> [dest_hash] LXMF test");
|
||
Serial.println("[SERIAL] lite relay diag: H<len> [dest] Header2 data | J [dest] linkreq | K<ctx_hex> link-data | Y<ctx_hex> link-proof");
|
||
}
|
||
|
||
static void handleSerialCommands() {
|
||
static char line[96];
|
||
static size_t lineLen = 0;
|
||
static bool lineActive = false;
|
||
|
||
while (Serial.available() > 0) {
|
||
char c = (char)Serial.read();
|
||
if (lineActive) {
|
||
if (c == '\r' || c == '\n') {
|
||
line[lineLen] = '\0';
|
||
handleSerialLineCommand(line);
|
||
lineLen = 0;
|
||
lineActive = false;
|
||
continue;
|
||
}
|
||
if (lineLen + 1 >= sizeof(line)) {
|
||
Serial.println("[SERIAL] line command too long; discarded");
|
||
lineLen = 0;
|
||
lineActive = false;
|
||
continue;
|
||
}
|
||
line[lineLen++] = c;
|
||
continue;
|
||
}
|
||
|
||
if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
|
||
if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y') {
|
||
lineActive = true;
|
||
lineLen = 0;
|
||
line[lineLen++] = c;
|
||
continue;
|
||
}
|
||
|
||
switch (c) {
|
||
case '?':
|
||
printSerialHelp();
|
||
break;
|
||
case 'a':
|
||
case 'A':
|
||
onHotkeyAnnounce();
|
||
break;
|
||
case 't':
|
||
case 'T':
|
||
onHotkeyRadioTest();
|
||
break;
|
||
case 'd':
|
||
case 'D':
|
||
onHotkeyDiag();
|
||
break;
|
||
case 'r':
|
||
case 'R':
|
||
onHotkeyRssiMonitor();
|
||
break;
|
||
case 'i':
|
||
case 'I':
|
||
onHotkeyIrqMonitor();
|
||
break;
|
||
case 'p':
|
||
cycleDiagnosticTxPower();
|
||
break;
|
||
case 'm':
|
||
case 'M':
|
||
setDiagnosticMinTxPower();
|
||
break;
|
||
case 'q':
|
||
case 'Q':
|
||
toggleDiagnosticInvertIQ();
|
||
break;
|
||
case '+':
|
||
case '=':
|
||
nudgeDiagnosticFrequency(1000);
|
||
break;
|
||
case '-':
|
||
case '_':
|
||
nudgeDiagnosticFrequency(-1000);
|
||
break;
|
||
default:
|
||
Serial.printf("[SERIAL] unknown command '%c'\n", c);
|
||
printSerialHelp();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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() {
|
||
bool flashMounted = false;
|
||
|
||
// 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(" rsDeck v%s\n", RSDECK_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());
|
||
|
||
// Dual-boot layout: re-arm the launcher so the next reset shows the chooser.
|
||
auto launcherBoot = rs_deck::returnToLauncherNextBoot();
|
||
if (!launcherBoot.ok) {
|
||
Serial.printf("[BOOT] Launcher return unavailable: %s\n", launcherBoot.message);
|
||
}
|
||
if (!psramFound() || heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < 1024 * 1024) {
|
||
Serial.printf("[BOOT] FATAL: PSRAM unavailable or too fragmented (largest=%lu)\n",
|
||
(unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM));
|
||
while (true) delay(1000);
|
||
}
|
||
|
||
// Step 3: Initialize I2C bus (shared by keyboard + touchscreen)
|
||
Wire.begin(I2C_SDA, I2C_SCL);
|
||
Wire.setClock(400000);
|
||
Wire.setTimeOut(20);
|
||
|
||
// 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);
|
||
|
||
// Mount flash before radio bring-up so persisted RF settings are used from
|
||
// the first SX1262 init, instead of always booting at the US default first.
|
||
Serial.println("[BOOT] Mounting flash for early config...");
|
||
if (flash.begin()) {
|
||
flashMounted = true;
|
||
userConfig.load(flash);
|
||
} else {
|
||
Serial.println("[BOOT] Early flash mount failed; using default radio config");
|
||
}
|
||
// Select palette before any LVGL styles are built
|
||
Theme::setScheme(userConfig.settings().themeLight ? Theme::Scheme::LIGHT : Theme::Scheme::DARK);
|
||
|
||
// 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(userConfig.settings().loraFrequency)) {
|
||
radioOnline = true;
|
||
applyRadioSettingsToHardware(userConfig.settings(), "RADIO");
|
||
Serial.printf("[RADIO] SX1262 online at %lu Hz\n",
|
||
(unsigned long)userConfig.settings().loraFrequency);
|
||
} 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)) {
|
||
sdHadExistingData = sdStore.hasExistingData();
|
||
sdStore.formatForRsDeck();
|
||
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
|
||
if (!display.beginLVGL()) {
|
||
display.gfx().fillScreen(TFT_BLACK);
|
||
display.gfx().setTextColor(TFT_RED, TFT_BLACK);
|
||
display.gfx().drawString("LVGL/PSRAM failed", 24, 106);
|
||
display.setBrightness(160);
|
||
while (true) delay(1000);
|
||
}
|
||
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();
|
||
ui.setBootMode(true);
|
||
ui.setScreen(&lvBootScreen);
|
||
ui.lvStatusBar().setLoRaOnline(radioOnline);
|
||
lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED");
|
||
|
||
// Display::begin() left the backlight at 0 to hide an unpainted
|
||
// framebuffer; the setProgress() above has now flushed the boot screen.
|
||
// powerMgr at step 24 overrides with the user's configured value.
|
||
display.setBrightness(128);
|
||
|
||
// 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);
|
||
inputManager.setPowerMgr(&powerMgr);
|
||
|
||
// 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('i', "AutoIface dump", onHotkeyAutoIface);
|
||
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.setScreen(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 (flashMounted) {
|
||
Serial.println("[BOOT] LittleFS already mounted OK");
|
||
} else if (!flash.begin()) {
|
||
Serial.println("[BOOT] Flash init failed; automatic formatting disabled");
|
||
lvBootScreen.setProgress(0.62f, "Flash mount failed");
|
||
} else {
|
||
flashMounted = true;
|
||
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.64f, "Loading config...");
|
||
userConfig.load(sdStore, flash);
|
||
// SD config may override the early flash-only load; re-sync palette
|
||
{
|
||
Theme::Scheme want = userConfig.settings().themeLight ? Theme::Scheme::LIGHT : Theme::Scheme::DARK;
|
||
if (want != Theme::scheme()) { Theme::setScheme(want); ui.applyTheme(); }
|
||
}
|
||
inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed);
|
||
applyRadioSettingsToHardware(userConfig.settings(), "BOOT PRE-RNS");
|
||
|
||
lvBootScreen.setProgress(0.65f, "Starting Reticulum...");
|
||
// (LVGL boot renders via lv_timer_handler in setProgress)
|
||
rns.setSDStore(&sdStore);
|
||
if (rns.begin(&radio, &flash, userConfig.settings().loraEnabled)) {
|
||
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, userConfig.settings().sdStorageEnabled);
|
||
|
||
// 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.lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, lxmf.unreadCount());
|
||
audio.requestMessage();
|
||
});
|
||
// 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)
|
||
// Filter to lxmf.delivery so we don't capture every aspect (lxmf.propagation,
|
||
// nomadnetwork.node, etc.) from the same peer as separate "doubled" entries.
|
||
announceManager = new AnnounceManager("lxmf.delivery");
|
||
announceManager->setStorage(&sdStore, &flash);
|
||
announceManager->setLocalDestHash(rns.destination().hash());
|
||
if (rns.loraInterface()) announceManager->setLoRaInterface(rns.loraInterface());
|
||
announceManager->loadContacts();
|
||
announceManager->loadNameCache();
|
||
announceHandler = RNS::HAnnounceHandler(announceManager);
|
||
RNS::Transport::register_announce_handler(announceHandler);
|
||
|
||
// No default TCP hub. Users opt in via Settings → TCP Server →
|
||
// "Ratspeak Hub" (seeds rns.ratspeak.org) or "Custom" (host/port).
|
||
|
||
// 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 && userConfig.settings().loraEnabled) {
|
||
applyRadioSettingsToHardware(userConfig.settings(), "BOOT");
|
||
ui.lvStatusBar().setLoRaOnline(true);
|
||
} else if (radioOnline) {
|
||
radio.sleep();
|
||
ui.lvStatusBar().setLoRaOnline(false);
|
||
Serial.println("[BOOT] LoRa disabled by config");
|
||
}
|
||
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.lvStatusBar().setWiFiActive(true);
|
||
} else if (wifiMode == RAT_WIFI_STA) {
|
||
lvBootScreen.setProgress(0.87f, "WiFi STA starting...");
|
||
auto& settings = userConfig.settings();
|
||
auto& nets = settings.wifiSTANetworks;
|
||
size_t selectedSlot = settings.wifiSTASelected < WIFI_STA_MAX_NETWORKS ? settings.wifiSTASelected : 0;
|
||
int registered = 0;
|
||
if (selectedSlot < nets.size() && !nets[selectedSlot].ssid.isEmpty()) {
|
||
const auto& n = nets[selectedSlot];
|
||
wifiMulti.addAP(n.ssid.c_str(), n.password.c_str());
|
||
registered++;
|
||
Serial.printf("[WIFI] STA: using profile %u (%s)\n",
|
||
(unsigned)(selectedSlot + 1), n.ssid.c_str());
|
||
} else {
|
||
Serial.printf("[WIFI] STA: selected profile %u is empty\n",
|
||
(unsigned)(selectedSlot + 1));
|
||
}
|
||
// WiFi is enabled but not yet connected — indicator will be yellow
|
||
if (registered > 0) {
|
||
WiFi.mode(WIFI_STA);
|
||
WiFi.onEvent(onWiFiEvent);
|
||
// AutoInterface needs an IPv6 link-local address. Must be enabled
|
||
// BEFORE WiFi.begin() so SLAAC starts on STA association.
|
||
if (userConfig.settings().autoIfaceEnabled) {
|
||
WiFi.enableIpV6();
|
||
Serial.println("[WIFI] IPv6 enabled (AutoInterface ON)");
|
||
}
|
||
uint8_t initialStatus = wifiMulti.run(5000);
|
||
wifiSTAStarted = true;
|
||
if (initialStatus != WL_CONNECTED && WiFi.status() != WL_CONNECTED &&
|
||
!wifiNeedsReconnect.load()) {
|
||
scheduleWiFiReconnect();
|
||
}
|
||
Serial.printf("[WIFI] STA: %d selected profile registered\n", registered);
|
||
}
|
||
} else {
|
||
lvBootScreen.setProgress(0.87f, "WiFi disabled");
|
||
// (LVGL boot renders via lv_timer_handler in setProgress)
|
||
}
|
||
|
||
// Step 23: BLE stays disabled in default builds.
|
||
lvBootScreen.setProgress(0.90f, "Links ready");
|
||
// (LVGL boot renders via lv_timer_handler in setProgress)
|
||
#if HAS_BLE
|
||
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.lvStatusBar().setBLEActive(true);
|
||
Serial.println("[BLE] Transport + Sideband ready");
|
||
}
|
||
} else {
|
||
Serial.println("[BLE] Disabled by config");
|
||
}
|
||
#else
|
||
ui.lvStatusBar().setBLEEnabled(false);
|
||
ui.lvStatusBar().setBLEActive(false);
|
||
Serial.println("[BLE] Disabled in default firmware build");
|
||
#endif
|
||
|
||
// 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);
|
||
powerMgr.setKbBrightness(userConfig.settings().keyboardBrightness);
|
||
powerMgr.setKbAutoOn(userConfig.settings().keyboardAutoOn);
|
||
powerMgr.setKbAutoOff(userConfig.settings().keyboardAutoOff);
|
||
|
||
// Step 24.5: GPS init
|
||
#if HAS_GPS
|
||
if (userConfig.settings().gpsTimeEnabled) {
|
||
lvBootScreen.setProgress(0.93f, "Starting GPS...");
|
||
gps.setPosixTZ(currentPosixTZ());
|
||
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
|
||
gps.begin();
|
||
Serial.println("[BOOT] GPS UART started (MIA-M10Q)");
|
||
}
|
||
#endif
|
||
|
||
// 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
|
||
// Yield to LVGL instead of blocking delay
|
||
lvBootScreen.setProgress(0.98f, "Ready");
|
||
for (int i = 0; i < 6; i++) { lv_timer_handler(); delay(1); }
|
||
lvBootScreen.setProgress(1.0f, "Ready");
|
||
audio.playBoot();
|
||
|
||
bootComplete = true;
|
||
|
||
// Keep LVGL responsive during blocking radio operations (if screen is on)
|
||
// Re-entrancy guard prevents nested lv_timer_handler() calls
|
||
radio.setYieldCallback([]() {
|
||
static bool inYield = false;
|
||
if (inYield) return;
|
||
inYield = true;
|
||
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([]() {
|
||
manualAnnounce();
|
||
Serial.println("[HOME] Announce triggered via Enter");
|
||
});
|
||
lvHomeScreen.setAudioToggleCallback([]() {
|
||
userConfig.settings().audioEnabled = !userConfig.settings().audioEnabled;
|
||
audio.setEnabled(userConfig.settings().audioEnabled);
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
ui.lvStatusBar().showToast(userConfig.settings().audioEnabled ? "Audio ON" : "Audio OFF", 1000);
|
||
Serial.printf("[AUDIO] Notifications %s (save %s)\n",
|
||
userConfig.settings().audioEnabled ? "ON" : "OFF",
|
||
ok ? "OK" : "FAILED");
|
||
});
|
||
lvHomeScreen.setLoraToggleCallback([]() {
|
||
auto& s = userConfig.settings();
|
||
s.loraEnabled = !s.loraEnabled;
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
ui.lvStatusBar().showToast(
|
||
ok ? "LoRa saved; reboot to apply" : "Save failed",
|
||
ok ? 3000 : 2000);
|
||
Serial.printf("[LORA] Saved %s (save %s, reboot required)\n",
|
||
s.loraEnabled ? "ON" : "OFF",
|
||
ok ? "OK" : "FAILED");
|
||
});
|
||
lvHomeScreen.setTCPToggleCallback([]() {
|
||
auto& s = userConfig.settings();
|
||
bool enabled = false;
|
||
bool hasSavedRelay = false;
|
||
for (const auto& ep : s.tcpConnections) {
|
||
if (!ep.host.isEmpty()) hasSavedRelay = true;
|
||
if (!ep.host.isEmpty() && ep.autoConnect) { enabled = true; break; }
|
||
}
|
||
if (enabled) {
|
||
for (auto& ep : s.tcpConnections) ep.autoConnect = false;
|
||
} else if (hasSavedRelay) {
|
||
for (auto& ep : s.tcpConnections) {
|
||
if (!ep.host.isEmpty()) ep.autoConnect = true;
|
||
}
|
||
} else {
|
||
s.tcpConnections.clear();
|
||
TCPEndpoint ep;
|
||
ep.host = "rns.ratspeak.org";
|
||
ep.port = TCP_DEFAULT_PORT;
|
||
ep.autoConnect = true;
|
||
s.tcpConnections.push_back(ep);
|
||
}
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
ui.lvStatusBar().showToast(
|
||
ok ? "TCP relay saved; reboot to apply" : "Save failed",
|
||
ok ? 3000 : 2000);
|
||
Serial.printf("[TCP] Saved relay %s (save %s, reboot required)\n",
|
||
enabled ? "OFF" : "ON",
|
||
ok ? "OK" : "FAILED");
|
||
});
|
||
lvHomeScreen.setWiFiToggleCallback([]() {
|
||
auto& s = userConfig.settings();
|
||
if (s.wifiMode == RAT_WIFI_OFF) {
|
||
RatWiFiMode restoreMode = s.wifiRestoreMode == RAT_WIFI_OFF ? RAT_WIFI_STA : s.wifiRestoreMode;
|
||
if (restoreMode == RAT_WIFI_STA) {
|
||
size_t slot = s.wifiSTASelected < s.wifiSTANetworks.size() ? s.wifiSTASelected : 0;
|
||
if (slot >= s.wifiSTANetworks.size() || s.wifiSTANetworks[slot].ssid.isEmpty()) {
|
||
ui.lvStatusBar().showToast("Add WiFi in Settings", 2000);
|
||
return;
|
||
}
|
||
} else if (restoreMode != RAT_WIFI_AP) {
|
||
ui.lvStatusBar().showToast("Add WiFi in Settings", 2000);
|
||
return;
|
||
}
|
||
s.wifiMode = restoreMode;
|
||
} else {
|
||
s.wifiRestoreMode = s.wifiMode;
|
||
s.wifiMode = RAT_WIFI_OFF;
|
||
}
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
ui.lvStatusBar().showToast(
|
||
ok ? "WiFi saved; reboot to apply" : "Save failed",
|
||
ok ? 3000 : 2000);
|
||
Serial.printf("[WIFI] Saved mode %d (save %s, reboot required)\n",
|
||
(int)s.wifiMode, ok ? "OK" : "FAILED");
|
||
});
|
||
#if HAS_GPS
|
||
lvHomeScreen.setGPSToggleCallback([]() {
|
||
auto& s = userConfig.settings();
|
||
bool oldTime = s.gpsTimeEnabled;
|
||
s.gpsTimeEnabled = !s.gpsTimeEnabled;
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
if (!ok) {
|
||
s.gpsTimeEnabled = oldTime;
|
||
ui.lvStatusBar().showToast("Save failed", 2000);
|
||
Serial.println("[GPS] Toggle save failed");
|
||
return;
|
||
}
|
||
if (s.gpsTimeEnabled) {
|
||
gps.setPosixTZ(currentPosixTZ());
|
||
gps.setLocationEnabled(s.gpsLocationEnabled);
|
||
gps.begin();
|
||
ui.lvStatusBar().showToast("GPS ON", 1000);
|
||
Serial.println("[GPS] Enabled via Home");
|
||
} else {
|
||
gps.stop();
|
||
ui.lvStatusBar().setGPSFix(false);
|
||
ui.lvStatusBar().showToast("GPS OFF", 1000);
|
||
Serial.println("[GPS] Disabled via Home");
|
||
}
|
||
});
|
||
#else
|
||
lvHomeScreen.setGPSToggleCallback([]() {
|
||
ui.lvStatusBar().showToast("GPS unavailable", 1500);
|
||
});
|
||
#endif
|
||
lvHomeScreen.setPeersCallback([]() {
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_NODES);
|
||
ui.setScreen(&lvNodesScreen);
|
||
});
|
||
|
||
lvContactsScreen.setAnnounceManager(announceManager);
|
||
lvContactsScreen.setUIManager(&ui);
|
||
lvContactsScreen.setNodeSelectedCallback([](const std::string& peerHex) {
|
||
lvMessageView.setPeerHex(peerHex);
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
||
ui.setScreen(&lvMessageView);
|
||
});
|
||
|
||
lvNodesScreen.setAnnounceManager(announceManager);
|
||
lvNodesScreen.setUIManager(&ui);
|
||
lvNodesScreen.setUserConfig(&userConfig);
|
||
lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) {
|
||
lvMessageView.setPeerHex(peerHex);
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
|
||
ui.setScreen(&lvMessageView);
|
||
});
|
||
|
||
lvMessagesScreen.setLXMFManager(&lxmf);
|
||
lvMessagesScreen.setAnnounceManager(announceManager);
|
||
lvMessagesScreen.setUIManager(&ui);
|
||
lvMessagesScreen.setOpenCallback([](const std::string& peerHex) {
|
||
lvMessageView.setPeerHex(peerHex);
|
||
ui.setScreen(&lvMessageView);
|
||
});
|
||
|
||
lvMessageView.setLXMFManager(&lxmf);
|
||
lvMessageView.setAnnounceManager(announceManager);
|
||
lvMessageView.setUIManager(&ui);
|
||
lvMessageView.setBackCallback([]() {
|
||
ui.setScreen(&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 {
|
||
inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed);
|
||
bool ok = userConfig.save(sdStore, flash);
|
||
Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED");
|
||
return ok;
|
||
});
|
||
lvSettingsScreen.setTCPChangeCallback([]() {
|
||
Serial.println("[TCP] Settings changed, scheduling reload...");
|
||
requestTCPClientsReload();
|
||
});
|
||
#if HAS_GPS
|
||
lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) {
|
||
if (timeEnabled) {
|
||
gps.setPosixTZ(currentPosixTZ());
|
||
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
|
||
gps.begin();
|
||
Serial.println("[GPS] Time enabled via settings");
|
||
} else {
|
||
gps.stop();
|
||
ui.lvStatusBar().setGPSFix(false);
|
||
Serial.println("[GPS] Disabled via settings");
|
||
}
|
||
});
|
||
#endif
|
||
|
||
auto showQr = []() {
|
||
// Encode `lxma://<destHash>:<publicKey>` so Columba/Sideband
|
||
// scanners get a full identity (no PENDING_IDENTITY round-trip).
|
||
String destHex = rns.destinationHashHex();
|
||
String pubHex;
|
||
if (auto identity = rns.destination().identity()) {
|
||
pubHex = String(identity.get_public_key().toHex().c_str());
|
||
}
|
||
lvQrOverlay.show(destHex, pubHex);
|
||
};
|
||
lvSettingsScreen.setShowQrCallback(showQr);
|
||
lvContactsScreen.setShowQrCallback(showQr);
|
||
|
||
// LVGL help overlay
|
||
lvHelpOverlay.create();
|
||
lvQrOverlay.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.setScreen(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.wipeRsDeck();
|
||
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");
|
||
userConfig.settings().sdStorageEnabled = true;
|
||
userConfig.save(sdStore, flash);
|
||
lvDataCleanScreen.showStatus("SD storage enabled. Rebooting...");
|
||
delay(1500);
|
||
ESP.restart();
|
||
}
|
||
});
|
||
|
||
// --- Boot flow helpers ---
|
||
// Transition to home screen (shared by name input, timezone, and normal boot)
|
||
auto goHome = []() {
|
||
ui.setBootMode(false);
|
||
ui.setScreen(&lvHomeScreen);
|
||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
|
||
bootAnnouncePending = true;
|
||
bootAnnounceAttempts = 0;
|
||
bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS;
|
||
Serial.println("[BOOT] Home ready; startup announce scheduled");
|
||
};
|
||
|
||
// Show timezone screen, then go home
|
||
auto showTimezone = [goHome]() {
|
||
if (!userConfig.settings().timezoneSet) {
|
||
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
|
||
ui.setScreen(&lvTimezoneScreen);
|
||
Serial.println("[BOOT] Showing timezone selection");
|
||
} else {
|
||
goHome();
|
||
}
|
||
};
|
||
|
||
// Timezone screen done callback
|
||
lvTimezoneScreen.setDoneCallback([goHome](int tzIdx) {
|
||
userConfig.settings().timezoneIdx = (uint8_t)tzIdx;
|
||
userConfig.settings().timezoneSet = true;
|
||
bool saved = userConfig.save(sdStore, flash);
|
||
if (!saved) {
|
||
Serial.println("[BOOT] Timezone save failed; staying in setup");
|
||
ui.lvStatusBar().showToast("Save failed; storage unavailable", 3000);
|
||
return;
|
||
}
|
||
Serial.printf("[BOOT] Timezone set: %s (%s)\n",
|
||
TIMEZONE_TABLE[tzIdx].label, TIMEZONE_TABLE[tzIdx].posixTZ);
|
||
// Apply timezone immediately
|
||
const char* tz = TIMEZONE_TABLE[tzIdx].posixTZ;
|
||
setenv("TZ", tz, 1);
|
||
tzset();
|
||
#if HAS_GPS
|
||
if (userConfig.settings().gpsTimeEnabled) {
|
||
gps.setPosixTZ(tz);
|
||
}
|
||
#endif
|
||
// Warn if timezone suggests a different radio region
|
||
uint8_t tzRegion = TIMEZONE_TABLE[tzIdx].radioRegion;
|
||
if (tzRegion != userConfig.settings().radioRegion) {
|
||
char msg[64];
|
||
snprintf(msg, sizeof(msg), "TZ suggests %s region", REGION_LABELS[tzRegion]);
|
||
ui.lvStatusBar().showToast(msg, 3000);
|
||
Serial.printf("[REGION] Timezone suggests %s, current is %s\n",
|
||
REGION_LABELS[tzRegion], REGION_LABELS[userConfig.settings().radioRegion]);
|
||
}
|
||
goHome();
|
||
});
|
||
|
||
// Name input screen (first boot only — when no display name is set)
|
||
lvNameInputScreen.setDoneCallback([showTimezone](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;
|
||
bool saved = userConfig.save(sdStore, flash);
|
||
if (!saved) {
|
||
Serial.println("[BOOT] Display name save failed; staying in setup");
|
||
ui.lvStatusBar().showToast("Save failed; storage unavailable", 3000);
|
||
return;
|
||
}
|
||
// 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());
|
||
|
||
// Next step: timezone selection (or home if already set)
|
||
showTimezone();
|
||
});
|
||
|
||
if (sdHadExistingData && !userConfig.settings().sdStorageEnabled) {
|
||
ui.setScreen(&lvDataCleanScreen);
|
||
Serial.println("[BOOT] Existing SD data found; waiting for user choice");
|
||
} else if (userConfig.settings().displayName.isEmpty()) {
|
||
// First boot — go to name input
|
||
ui.setScreen(&lvNameInputScreen);
|
||
Serial.println("[BOOT] Showing name input screen");
|
||
} else if (!userConfig.settings().timezoneSet) {
|
||
// Name set but timezone not — show timezone picker
|
||
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
|
||
ui.setScreen(&lvTimezoneScreen);
|
||
Serial.println("[BOOT] Showing timezone selection (name already set)");
|
||
} else {
|
||
// Everything configured — go straight to home
|
||
goHome();
|
||
}
|
||
|
||
// Clear boot loop counter — we survived!
|
||
{
|
||
Preferences prefs;
|
||
if (prefs.begin("ratdeck", false)) {
|
||
prefs.putInt("bootc", 0);
|
||
prefs.end();
|
||
}
|
||
}
|
||
|
||
if (userConfig.settings().keyboardAutoOn) {
|
||
// We are in ACTIVE power state here, switch keyboard backlight ON
|
||
keyboard.backlightOn();
|
||
}
|
||
|
||
Serial.println("[BOOT] rsDeck 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() {
|
||
handleSerialCommands();
|
||
|
||
// 1. Input polling
|
||
bool screenWasOn = powerMgr.isScreenOn();
|
||
inputManager.update();
|
||
bool wakeOnlyInput = !screenWasOn && inputManager.hadStrongActivity();
|
||
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 — screen blanking is the default if no screen consumes it
|
||
if (inputManager.hadLongPress()) {
|
||
if (!ui.handleLongPress()) {
|
||
powerMgr.forceScreenOff();
|
||
}
|
||
}
|
||
|
||
// 3. Key event dispatch
|
||
if (inputManager.hasKeyEvent() && !wakeOnlyInput) {
|
||
const KeyEvent& evt = inputManager.getKeyEvent();
|
||
|
||
// Help overlay intercepts all keys when visible
|
||
if (lvHelpOverlay.isVisible()) {
|
||
lvHelpOverlay.handleKey(evt);
|
||
}
|
||
// QR overlay also dismisses on any keypress while visible
|
||
else if (lvQrOverlay.isVisible()) {
|
||
lvQrOverlay.handleKey(evt);
|
||
}
|
||
else {
|
||
// Screen-local input owns the keyboard. This keeps message and
|
||
// settings text entry from being preempted by global shortcuts.
|
||
bool consumed = ui.handleKey(evt);
|
||
if (!consumed) {
|
||
bool hotkeyAllowed = !ui.isBootMode() || (evt.ctrl && evt.character == 'h');
|
||
bool hotkeyConsumed = hotkeyAllowed && hotkeys.process(evt);
|
||
if (!hotkeyConsumed) {
|
||
|
||
// Feed to LVGL input system only if the screen didn't consume it
|
||
LvInput::feedKey(evt);
|
||
|
||
// Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume)
|
||
if (!evt.ctrl && !ui.isBootMode()) {
|
||
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.setScreen(lvTabScreens[tab]);
|
||
}
|
||
if (tabRight) {
|
||
ui.lvTabBar().cycleTab(1);
|
||
int tab = ui.lvTabBar().getActiveTab();
|
||
if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. LVGL timer handler — 30 FPS active, 5 FPS dimmed.
|
||
// Bypass the throttle on input activity so a keypress/scroll renders this
|
||
// iteration instead of waiting up to a full frame interval.
|
||
{
|
||
unsigned long now = millis();
|
||
unsigned long lvglInterval = powerMgr.isDimmed() ? 200 : LVGL_INTERVAL_MS;
|
||
bool inputBurst = inputManager.hadActivity();
|
||
if (powerMgr.isScreenOn() && (inputBurst || now - lastLvglTime >= lvglInterval)) {
|
||
lastLvglTime = now;
|
||
lv_timer_handler();
|
||
}
|
||
}
|
||
|
||
// 4. Reticulum loop (radio RX via LoRaInterface) — throttle to ~100Hz
|
||
unsigned long rnsDuration = 0;
|
||
{
|
||
static unsigned long lastRNS = 0;
|
||
unsigned long now = millis();
|
||
if (now - lastRNS >= 10) {
|
||
lastRNS = now;
|
||
unsigned long rnsStart = millis();
|
||
rns.loop();
|
||
rnsDuration = millis() - rnsStart;
|
||
}
|
||
}
|
||
|
||
// 4.5 Keep LVGL responsive after heavy RNS processing (announce floods)
|
||
if (rnsDuration > LVGL_INTERVAL_MS && powerMgr.isScreenOn()) {
|
||
lv_timer_handler();
|
||
}
|
||
|
||
if (bootComplete && bootAnnouncePending && (long)(millis() - bootAnnounceAt) >= 0) {
|
||
bootAnnounceAttempts++;
|
||
if (announceWithName(true)) {
|
||
bootAnnouncePending = false;
|
||
lastAutoAnnounce = millis();
|
||
Serial.println("[BOOT] Startup announce sent");
|
||
} else if (bootAnnounceAttempts < BOOT_ANNOUNCE_MAX_ATTEMPTS) {
|
||
bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS;
|
||
Serial.printf("[BOOT] Startup announce retry scheduled (%u/%u)\n",
|
||
(unsigned)bootAnnounceAttempts,
|
||
(unsigned)BOOT_ANNOUNCE_MAX_ATTEMPTS);
|
||
} else {
|
||
bootAnnouncePending = false;
|
||
Serial.println("[BOOT] Startup announce skipped after retries");
|
||
}
|
||
}
|
||
|
||
// 5. Auto-announce every 30-360 minutes from boot. Manual announces do
|
||
// not reset this schedule.
|
||
const unsigned long announceInterval = (unsigned long)userConfig.settings().announceInterval * 60000; // m -> ms
|
||
if (bootComplete && millis() - lastAutoAnnounce >= announceInterval) {
|
||
lastAutoAnnounce = millis();
|
||
if (rns.loraInterface() && rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) {
|
||
Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%");
|
||
} else {
|
||
announceWithName(!powerMgr.isScreenOn());
|
||
Serial.println("[AUTO] Periodic announce");
|
||
}
|
||
}
|
||
|
||
// 6. LXMF outgoing queue + announce manager deferred saves
|
||
lxmf.loop();
|
||
if (announceManager) announceManager->loop();
|
||
audio.loop();
|
||
|
||
// 7. WiFi STA connection handler
|
||
if (wifiSTAStarted) {
|
||
if (wifiNeedsReconnect.load() && WiFi.status() != WL_CONNECTED &&
|
||
(long)(millis() - wifiReconnectAt.load()) >= 0) {
|
||
wifiNeedsReconnect.store(false);
|
||
uint8_t attempt = wifiReconnectAttempt.load();
|
||
Serial.printf("[WIFI] Reconnect attempt #%u\n", (unsigned)attempt);
|
||
uint8_t result = wifiMulti.run(2000);
|
||
if (result != WL_CONNECTED && WiFi.status() != WL_CONNECTED &&
|
||
!wifiNeedsReconnect.load()) {
|
||
scheduleWiFiReconnect();
|
||
}
|
||
}
|
||
bool connected = (WiFi.status() == WL_CONNECTED);
|
||
if (connected && !wifiSTAConnected) {
|
||
wifiSTAConnected = true;
|
||
ui.lvStatusBar().setWiFiActive(true);
|
||
Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str());
|
||
|
||
// NTP time sync (DST-aware POSIX TZ string)
|
||
{
|
||
const char* tz = currentPosixTZ();
|
||
configTzTime(tz, "pool.ntp.org", "time.nist.gov");
|
||
Serial.printf("[NTP] Time sync started (TZ=%s)\n", tz);
|
||
}
|
||
|
||
// Recreate TCP clients on every WiFi connect (old clients may have stale sockets)
|
||
reloadTCPClients();
|
||
// Arm AutoInterface deferred-start; SLAAC needs ~1.5–10s to assign
|
||
// a link-local IPv6 address, so we don't start the interface here.
|
||
// Trigger link-local creation AFTER association (calling
|
||
// esp_netif_create_ip6_linklocal pre-association is a no-op on
|
||
// some Arduino-ESP32 versions).
|
||
if (userConfig.settings().autoIfaceEnabled) {
|
||
WiFi.enableIpV6();
|
||
autoIfaceDeferredStart = true;
|
||
autoIfaceDeferredAt = millis();
|
||
}
|
||
} else if (!connected && wifiSTAConnected) {
|
||
wifiSTAConnected = false;
|
||
ui.lvStatusBar().setWiFiActive(false);
|
||
ui.lvStatusBar().setTCPConnected(false);
|
||
// Stop and deregister TCP clients cleanly
|
||
for (auto& iface : tcpIfaces) {
|
||
RNS::Transport::deregister_interface(iface);
|
||
}
|
||
for (auto* tcp : tcpClients) {
|
||
retireTCPClient(tcp);
|
||
}
|
||
tcpClients.clear();
|
||
tcpIfaces.clear();
|
||
Serial.println("[WIFI] STA disconnected, TCP interfaces deregistered");
|
||
autoIface.stop();
|
||
autoIfaceDeferredStart = false;
|
||
}
|
||
}
|
||
|
||
// 7.6. AutoInterface deferred start — fire once SLAAC assigns a link-local
|
||
// IPv6 address. Arduino's IPv6Address::toString returns the expanded
|
||
// form ("0000:0000:..." for unset; "fe80:0000:..." once SLAAC completes),
|
||
// so check the prefix bytes directly: link-local is fe80::/10.
|
||
if (autoIfaceDeferredStart) {
|
||
unsigned long elapsed = millis() - autoIfaceDeferredAt;
|
||
if (elapsed >= 1500) {
|
||
IPv6Address ll = WiFi.localIPv6();
|
||
bool isLinkLocal = (ll[0] == 0xfe) && ((ll[1] & 0xc0) == 0x80);
|
||
if (isLinkLocal) {
|
||
autoIfaceDeferredStart = false;
|
||
esp_netif_t* sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||
uint32_t scope = sta ? esp_netif_get_netif_impl_index(sta) : 1;
|
||
autoIface.start(
|
||
userConfig.settings().autoIfaceGroupId.c_str(),
|
||
userConfig.settings().autoIfaceMaxPeers,
|
||
ll.toString(),
|
||
scope);
|
||
} else if (elapsed >= 10000) {
|
||
autoIfaceDeferredStart = false;
|
||
Serial.println("[AUTOIFACE] SLAAC timeout — no link-local after 10s");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7.7. AutoInterface link-local rotation watch — covers SLAAC privacy
|
||
// address rotation while STA stays associated. notify_link_change()
|
||
// is idempotent in the library, so polling here is cheap (string
|
||
// compare, no socket churn) and only does real work on actual change.
|
||
if (autoIface.isOnline() && wifiSTAConnected &&
|
||
millis() - lastAutoIfaceLinkCheck >= 2000) {
|
||
lastAutoIfaceLinkCheck = millis();
|
||
IPv6Address ll = WiFi.localIPv6();
|
||
bool isLinkLocal = (ll[0] == 0xfe) && ((ll[1] & 0xc0) == 0x80);
|
||
if (isLinkLocal) {
|
||
esp_netif_t* sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||
uint32_t scope = sta ? esp_netif_get_netif_impl_index(sta) : 1;
|
||
autoIface.notifyLinkChange(ll.toString(), scope);
|
||
}
|
||
}
|
||
|
||
// 7.8. Deferred TCP reload from Settings. Avoid tearing down/recreating
|
||
// Transport interfaces inside the LVGL key event path.
|
||
if (tcpReloadRequested) {
|
||
tcpReloadRequested = false;
|
||
Serial.println("[TCP] Applying deferred settings reload...");
|
||
reloadTCPClients();
|
||
if (announceManager) announceManager->clearTransientNodes();
|
||
}
|
||
|
||
// 8. WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded
|
||
{
|
||
drainRetiredTCPClients();
|
||
bool skipTcp = (rnsDuration > 500);
|
||
if (skipTcp) diagTcpSkipEvents++;
|
||
if (!skipTcp && wifiImpl) wifiImpl->loop();
|
||
if (!skipTcp) {
|
||
unsigned long tcpBudgetStart = millis();
|
||
for (auto* tcp : tcpClients) {
|
||
if (millis() - tcpBudgetStart >= TCP_GLOBAL_BUDGET_MS) break;
|
||
tcp->loop();
|
||
yield();
|
||
}
|
||
}
|
||
// AutoInterface always runs — its loop is non-blocking, capped at 4
|
||
// packets per socket per call, time-gated for announces/peer-jobs.
|
||
// Skipping it under TCP load causes peers to time out (22 s silence
|
||
// window) when a TCP flood holds the loop above the skip threshold.
|
||
autoIface.loop();
|
||
}
|
||
|
||
// 9. BLE loops
|
||
#if HAS_BLE
|
||
bleInterface.loop();
|
||
bleSideband.loop();
|
||
#endif
|
||
|
||
// 9.5. GPS poll (non-blocking, reads available UART bytes)
|
||
#if HAS_GPS
|
||
if (userConfig.settings().gpsTimeEnabled) {
|
||
gps.loop();
|
||
}
|
||
#endif
|
||
|
||
// 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.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.lvStatusBar().setAutoIfacePeers(
|
||
autoIface.isOnline() ? (int)autoIface.peerCount() : -1);
|
||
#if HAS_GPS
|
||
if (userConfig.settings().gpsTimeEnabled) {
|
||
ui.lvStatusBar().setGPSFix(gps.hasTimeFix());
|
||
}
|
||
#endif
|
||
// Update clock display (shows time from any valid source: GPS, NTP, etc.)
|
||
ui.lvStatusBar().setUse24Hour(userConfig.settings().use24HourTime);
|
||
ui.lvStatusBar().updateTime();
|
||
ui.update();
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// 12.5. RSSI monitor (non-blocking, one sample per loop iteration)
|
||
if (rssiMonitorActive && radioOnline) {
|
||
unsigned long now = millis();
|
||
if (now - rssiMonitorStart >= 5000) {
|
||
rssiMonitorActive = false;
|
||
Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n",
|
||
rssiSampleCount, rssiMinVal, rssiMaxVal);
|
||
} else if (now - rssiLastSample >= 100) {
|
||
rssiLastSample = now;
|
||
int rssi = radio.currentRssi();
|
||
if (rssi < rssiMinVal) rssiMinVal = rssi;
|
||
if (rssi > rssiMaxVal) rssiMaxVal = rssi;
|
||
rssiSampleCount++;
|
||
Serial.printf("[RSSI] %d dBm\n", rssi);
|
||
}
|
||
}
|
||
|
||
// 13. Heartbeat for crash diagnosis
|
||
{
|
||
unsigned long cycleTime = millis() - loopCycleStart;
|
||
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");
|
||
// Diagnostic: show registered transport interfaces and TCP connection status
|
||
{
|
||
auto& ifaces = RNS::Transport::get_interfaces();
|
||
int tcpUp = 0;
|
||
int tcpRx = 0;
|
||
for (auto* tcp : tcpClients) {
|
||
if (tcp && tcp->isConnected()) tcpUp++;
|
||
if (tcp) tcpRx += tcp->hubRxCount();
|
||
}
|
||
Serial.printf("[HEART-DIAG] ifaces=%d tcp=%d/%d wifi=%s autoiface=%s peers=%u\n",
|
||
(int)ifaces.size(), tcpUp, (int)tcpClients.size(),
|
||
wifiSTAConnected ? "STA" : (wifiImpl ? "AP" : "OFF"),
|
||
autoIface.isOnline() ? "ON" : "off",
|
||
(unsigned)autoIface.peerCount());
|
||
Serial.printf("[LXMF-DIAG] tcp_rx=%d tcp_skip=%lu ann_filt=%lu\n",
|
||
tcpRx, (unsigned long)diagTcpSkipEvents,
|
||
(unsigned long)rns.announceFilterCount());
|
||
diagTcpSkipEvents = 0;
|
||
}
|
||
#if HAS_GPS
|
||
if (userConfig.settings().gpsTimeEnabled) {
|
||
Serial.printf("[GPS] sats=%d timeFix=%s locFix=%s syncs=%lu chars=%lu\n",
|
||
gps.satellites(),
|
||
gps.hasTimeFix() ? "YES" : "NO",
|
||
gps.hasLocationFix() ? "YES" : "NO",
|
||
(unsigned long)gps.timeSyncCount(),
|
||
(unsigned long)gps.charsProcessed());
|
||
}
|
||
#endif
|
||
maxLoopTime = 0;
|
||
}
|
||
}
|
||
loopCycleStart = millis();
|
||
|
||
yield();
|
||
}
|