From ff49c15d384c74df7d1343884ff8b7e36aaa6677 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Thu, 30 Jul 2026 20:34:12 -0400 Subject: [PATCH 1/2] platform: move network stack off the UI core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-threaded superloop shares loop() between LVGL render/input and rns.loop() (Reticulum crypto), so UI latency tracks loop cycle time and goes sluggish under network load. Add CoreSync: run the Reticulum/radio/network stack in a FreeRTOS task pinned to core 0 and keep LVGL + input on the Arduino loopTask (core 1). The two shared subsystems are serialized — a recursive spiBusMutex around the display / SX1262 / SD transactions on the common FSPI bus, and rnsMutex around the microReticulum / LXMF / AnnounceManager state, which the UI takes only via try-lock when drawing. Network-side UI feedback (toasts, TX indicator) goes through a snapshot bridge so LVGL is never touched from core 0. Gated on -DRSDECK_UI_CORE_SPLIT (1 in platformio.ini). At 0 every guard compiles to a no-op and behavior is the stock superloop, so the two modes can be A/B tested from one tree. Co-Authored-By: Claude Opus 5 --- docs/UI-CORE-SPLIT-NEXT-STEPS.md | 126 ++++++++++ platformio.ini | 5 + src/hal/Display.cpp | 5 + src/main.cpp | 369 +++++++++++++++++----------- src/platform/CoreSync.cpp | 64 +++++ src/platform/CoreSync.h | 131 ++++++++++ src/radio/SX1262.cpp | 6 + src/reticulum/AnnounceManager.cpp | 21 +- src/reticulum/AnnounceManager.h | 5 +- src/reticulum/ReticulumManager.cpp | 14 +- src/reticulum/ReticulumManager.h | 2 + src/storage/SDStore.cpp | 27 +- src/transport/LoRaInterface.cpp | 46 ++-- src/ui/screens/LvMessageView.cpp | 67 +++-- src/ui/screens/LvMessagesScreen.cpp | 3 + src/ui/screens/LvNodesScreen.cpp | 5 + src/ui/screens/LvSettingsScreen.cpp | 34 ++- 17 files changed, 710 insertions(+), 220 deletions(-) create mode 100644 docs/UI-CORE-SPLIT-NEXT-STEPS.md create mode 100644 src/platform/CoreSync.cpp create mode 100644 src/platform/CoreSync.h diff --git a/docs/UI-CORE-SPLIT-NEXT-STEPS.md b/docs/UI-CORE-SPLIT-NEXT-STEPS.md new file mode 100644 index 0000000..2a3c3e4 --- /dev/null +++ b/docs/UI-CORE-SPLIT-NEXT-STEPS.md @@ -0,0 +1,126 @@ +# rsDeck UI/Network Core Split — Handoff & Next Steps + +**Status:** Stages 0–2 complete and compile-verified. The tree now runs with split-mode +support in place: UI loop on `loopTask` (core 1), network task on core 0 when +`RSDECK_UI_CORE_SPLIT=1`, and stock superloop behavior when the flag is `0`. + +Build flag: `-DRSDECK_UI_CORE_SPLIT=1` in `platformio.ini`. Set it to `0` to get the +exact stock superloop back (every guard compiles to a no‑op). Flip this to A/B test. + +> Build/flash needs **esptool v5** (`merge-bin` hyphen syntax). Already installed +> (5.3.1) in the PlatformIO penv. If you hit `invalid choice: 'merge-bin'`, run +> `python3 -m pip install --upgrade 'esptool>=5'`. + +--- + +## Why + +The UI goes sluggish/unresponsive under network load. Root cause: single‑threaded +superloop — LVGL render + input share `loop()` with `rns.loop()` (Reticulum crypto), so +UI latency == loop cycle time, which grows with traffic. Confirmed with the user it's +**load‑dependent**, not an idle leak. + +**Fix:** move the Reticulum/radio/network stack to a FreeRTOS task pinned to **core 0**; +keep LVGL + input on the Arduino `loopTask` (**core 1**). Render never waits on crypto. + +## Shared subsystems that cross the two cores (must be serialized) + +1. **SPI bus** — display (LovyanGFX), SX1262 radio, and SD all share FSPI (SCK 40 / MOSI + 41 / MISO 38). Guarded by `CoreSync::spiBusMutex` (recursive). **DONE.** +2. **Reticulum/LXMF/AnnounceManager data + flash/SD persistence** — one "backend" lock, + `CoreSync::rnsMutex` (recursive). +3. **Network → UI status** — lock‑free `CoreSync::netStatus` snapshot + a deferred‑toast + bridge (`requestToast`/`takePendingToast`). **DONE (infra).** + +**Lock ordering (keep it consistent to stay deadlock‑free): always `rnsMutex → spiBusMutex`.** +The display flush takes `spiBusMutex` only (never `rnsMutex`), so there's no inversion. + +--- + +## What's DONE (committed to working tree, compiling) + +### New module +- `src/platform/CoreSync.h` / `.cpp` — mutexes (`spiBusMutex`, `rnsMutex`), RAII guards + (`SpiBusGuard`, `RnsGuard` blocking, `RnsTryGuard` try‑lock), the `NetStatus` atomic + snapshot, and the toast bridge. All no‑ops when the flag is 0. +- `CoreSync::begin()` is called first thing in `setup()` (main.cpp). + +### Stage 1 — SPI bus guarded (complete) +- `src/hal/Display.cpp` — `lvgl_flush_cb` wrapped in `SpiBusGuard`. +- `src/radio/SX1262.cpp` — all **5** SPI primitives wrapped: `singleTransfer`, + `executeOpcode`, `executeOpcodeRead`, `writeBuffer`, `readBuffer` (guard taken *after* + `waitOnBusy()`, around the transaction only). +- `src/storage/SDStore.cpp` — runtime methods guarded (recursive mutex handles the + nesting: `writeString→writeAtomic`, `ensureDir` recursion, etc.). `begin()` is + intentionally unguarded (boot, pre‑task). +- `src/ui/screens/LvSettingsScreen.cpp` — the identity‑import dir walk (the one runtime + `openDir`+iterate site) wrapped in a `SpiBusGuard` scope. + +### Stage 2 — complete +- `src/platform/CoreSync.*` — `NetStatus` + toast bridge added. +- `main.cpp` `announceWithName()` — refactored: guards `rns.announce` with `RnsGuard`, + delivers flash/toast via the snapshot bridge (safe from either core). +- `main.cpp` loop split landed: + - Added `networkLoopStep()` + `uiLoopStep()`. + - Added `networkTask()` pinned to core 0 under `RSDECK_UI_CORE_SPLIT`. + - `loop()` now dispatches split mode (`uiLoopStep()` only) vs stock mode + (`uiLoopStep()` + `networkLoopStep()`). + - Network-owned status updates now publish to `CoreSync::netStatus`. + - UI status tick reads `CoreSync::netStatus` and refreshes UI under `RnsTryGuard`. + - Added setup spawn after `bootComplete = true`: + `xTaskCreatePinnedToCore(networkTask, "netstack", 16384, ..., 0)`. + +--- + +## What REMAINS + +Stage 2 implementation work is complete. Remaining work is optional Stage 3 refinement +plus on-device soak validation under sustained traffic. + +### Stage 3 (optional refinement — removes choppy‑under‑flood) + +Make input **lock‑free** by moving the backend guard out of the coarse dispatch block and +into only the handlers that touch Reticulum/LXMF, so pure‑LVGL nav/typing/scroll never +take `rnsMutex`. Sites to guard individually (wrap the backend call in `CoreSync::RnsGuard`): +- `LvMessageView` — message send (`_btnSend` CLICKED), `markRead`, open‑chat + `getRecentMessages`. +- `LvMessagesScreen` — open conversation, `markRead`. +- `LvNodesScreen` — save/delete contact. +- `LvSettingsScreen` — `_rns->announce`, radio `set*`/`receive`/`sleep` apply, TCP reload. +- Home toggle callbacks — the `userConfig.save()` calls (SD/flash writes). +Then remove the blocking `RnsGuard` from the `uiLoopStep` dispatch block. Refresh reads +stay on the try‑lock. + +--- + +## Build / flash / on‑device test plan + +```bash +pio run # compile (flag on) +make flash PORT=/dev/tty... # or use the Ratspeak web flasher with rsdeck-merged.bin +``` + +Watch the serial `[HEART]` line — it already prints `loop=` (max loop time) and +`heap=/min=`. Test sequence: + +1. **Regression (flag on, before task spawn is even needed):** current tree — confirm it + boots, UI works, radio RX/TX works, SD reads/writes, no display corruption. This + validates Stage 1 (SPI guards) in isolation. +2. **After the split lands:** load the device (WiFi on + TCP hub + announce traffic). + - UI should stay smooth (scroll/type) while `[HEART] loop=` on the **UI core** stays + low. The heavy time now lives in the network task. + - Confirm status bar (wifi/tcp/ble/peers) still updates — it's snapshot‑driven now. + - Confirm announce (Enter on Home) still flashes the TX indicator + toasts, and that + auto/boot announces work (they run on the network core via the bridge). + - Send/receive an LXMF message; open a chat mid‑traffic. + - Watch for **stack overflow** panic (raise `netstack` stack if so) and for any + **`Interrupt wdt timeout`** (would indicate an SPI collision — check every radio/SD + path is guarded). +3. Set flag to 0, rebuild — must behave exactly like today (sanity). + +## Reference + +- Full running context is in Claude project memory: `ui-core-split.md`, + `esptool-v5-required.md`. +- LSP "file not found / Arduino.h" errors in the editor are noise (clangd lacks the + PlatformIO `-Isrc` include path); the real `pio run` is the source of truth. diff --git a/platformio.ini b/platformio.ini index e029fb2..03bd4dc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,6 +13,11 @@ build_flags = -fexceptions -O2 -DRSDECK=1 + ; UI/network core split: move the Reticulum/radio/network stack to its own + ; FreeRTOS task on core 0 so LVGL rendering + input (loopTask, core 1) never + ; stall behind crypto. Set to 0 for the stock single-threaded superloop + ; (all CoreSync guards compile to no-ops). See src/platform/CoreSync.h. + -DRSDECK_UI_CORE_SPLIT=1 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 ; Silence vfs_api log_e noise — it fires on every open/remove of a missing diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp index f254e88..31acfaf 100644 --- a/src/hal/Display.cpp +++ b/src/hal/Display.cpp @@ -1,5 +1,6 @@ #include "Display.h" #include +#include "../platform/CoreSync.h" // Double-buffered 10-line strips in PSRAM for DMA flush static lv_color_t* s_buf1 = nullptr; @@ -9,6 +10,10 @@ static LGFX_TDeck* s_gfx = nullptr; static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) { uint32_t w = area->x2 - area->x1 + 1; uint32_t h = area->y2 - area->y1 + 1; + // Hold the shared FSPI bus for the whole strip write. In the single-threaded + // build this is a no-op; under RSDECK_UI_CORE_SPLIT it keeps the flush from + // interleaving with an SX1262/SD transaction driven by the network core. + CoreSync::SpiBusGuard busGuard; s_gfx->startWrite(); s_gfx->setAddrWindow(area->x1, area->y1, w, h); // Blocking pushPixels prevents SPI bus contention with SX1262 radio on shared FSPI bus diff --git a/src/main.cpp b/src/main.cpp index 8a5b229..24d4008 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include "config/BoardConfig.h" #include "config/Config.h" #include "platform/RsDeckModeSwitch.h" +#include "platform/CoreSync.h" #include "hal/Display.h" #include "hal/TouchInput.h" #include "hal/Trackball.h" @@ -226,6 +227,10 @@ unsigned long lastAutoIfaceLinkCheck = 0; // LXMF diagnostic counters (reset each heartbeat) static uint32_t diagTcpSkipEvents = 0; +#if RSDECK_UI_CORE_SPLIT +static void networkTask(void*); +#endif + // ============================================================================= // Timezone helper — returns POSIX TZ string for current config // ============================================================================= @@ -275,8 +280,12 @@ static bool hasUsableAnnounceTransport() { } static bool announceWithName(bool silent = false) { + // Called from both cores (UI manual announce + network auto/boot announce). + // Guard the Reticulum access; deliver UI feedback through the snapshot bridge + // so we never touch LVGL from the network core. + CoreSync::RnsGuard backendGuard; if (!hasUsableAnnounceTransport()) { - if (!silent) ui.lvStatusBar().showToast("No active transport", 1500); + if (!silent) CoreSync::requestToast("No active transport", 1500); Serial.println("[ANNOUNCE-TX] skipped: no active transport"); return false; } @@ -286,8 +295,8 @@ static bool announceWithName(bool silent = false) { silent ? "yes" : "no"); rns.announce(appData); if (!silent) { - ui.lvStatusBar().flashAnnounce(); - ui.lvStatusBar().showToast("Announce sent!"); + CoreSync::netStatus.announceSeq.fetch_add(1); // UI flashes the TX indicator + CoreSync::requestToast("Announce sent!", 0); } return true; } @@ -1088,6 +1097,10 @@ void setup() { const unsigned long setupStartMs = millis(); bool flashMounted = false; + // Create cross-core mutexes before any SPI peripheral (display/radio/SD) or + // the network task can touch them. No-op in single-threaded builds. + CoreSync::begin(); + // Step 1: Power pin — CRITICAL: enables all T-Deck Plus peripherals Power::enablePeripherals(); @@ -1560,6 +1573,12 @@ void setup() { bootComplete = true; +#if RSDECK_UI_CORE_SPLIT + xTaskCreatePinnedToCore(networkTask, "netstack", 16384, nullptr, + 1 /* prio, same as loopTask */, nullptr, 0 /* core 0 */); + Serial.println("[CORE] Network task started on core 0"); +#endif + // Keep LVGL responsive during blocking radio operations (if screen is on) // Re-entrancy guard prevents nested lv_timer_handler() calls radio.setYieldCallback([]() { @@ -1587,6 +1606,7 @@ void setup() { lvHomeScreen.setAudioToggleCallback([]() { userConfig.settings().audioEnabled = !userConfig.settings().audioEnabled; audio.setEnabled(userConfig.settings().audioEnabled); + CoreSync::RnsGuard backendGuard; 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", @@ -1596,6 +1616,7 @@ void setup() { lvHomeScreen.setLoraToggleCallback([]() { auto& s = userConfig.settings(); s.loraEnabled = !s.loraEnabled; + CoreSync::RnsGuard backendGuard; bool ok = userConfig.save(sdStore, flash); ui.lvStatusBar().showToast( ok ? "LoRa saved; reboot to apply" : "Save failed", @@ -1626,6 +1647,7 @@ void setup() { ep.autoConnect = true; s.tcpConnections.push_back(ep); } + CoreSync::RnsGuard backendGuard; bool ok = userConfig.save(sdStore, flash); ui.lvStatusBar().showToast( ok ? "TCP server saved; reboot to apply" : "Save failed", @@ -1653,6 +1675,7 @@ void setup() { s.wifiRestoreMode = s.wifiMode; s.wifiMode = RAT_WIFI_OFF; } + CoreSync::RnsGuard backendGuard; bool ok = userConfig.save(sdStore, flash); ui.lvStatusBar().showToast( ok ? "WiFi saved; reboot to apply" : "Save failed", @@ -1665,6 +1688,7 @@ void setup() { auto& s = userConfig.settings(); bool oldTime = s.gpsTimeEnabled; s.gpsTimeEnabled = !s.gpsTimeEnabled; + CoreSync::RnsGuard backendGuard; bool ok = userConfig.save(sdStore, flash); if (!ok) { s.gpsTimeEnabled = oldTime; @@ -1742,6 +1766,7 @@ void setup() { lvSettingsScreen.setDestinationHash(rns.destinationHashHex()); lvSettingsScreen.setSaveCallback([]() -> bool { inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed); + CoreSync::RnsGuard backendGuard; bool ok = userConfig.save(sdStore, flash); Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED"); return ok; @@ -1942,84 +1967,8 @@ void setup() { // 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 +static void networkLoopStep() { + // Reticulum loop (radio RX via LoRaInterface) — throttle to ~100Hz unsigned long rnsDuration = 0; { static unsigned long lastRNS = 0; @@ -2032,11 +1981,6 @@ void loop() { } } - // 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)) { @@ -2054,12 +1998,14 @@ void loop() { } } - // 5. Auto-announce every 30-360 minutes from boot. Manual announces do + // 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 + const unsigned long announceInterval = + (unsigned long)userConfig.settings().announceInterval * 60000; if (bootComplete && millis() - lastAutoAnnounce >= announceInterval) { lastAutoAnnounce = millis(); - if (rns.loraInterface() && rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) { + if (rns.loraInterface() && + rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) { Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%"); } else { announceWithName(!powerMgr.isScreenOn()); @@ -2067,12 +2013,11 @@ void loop() { } } - // 6. LXMF outgoing queue + announce manager deferred saves + // LXMF outgoing queue + announce manager deferred saves lxmf.loop(); if (announceManager) announceManager->loop(); - audio.loop(); - // 7. WiFi STA connection handler + // WiFi STA connection handler if (wifiSTAStarted) { if (wifiNeedsReconnect.load() && WiFi.status() != WL_CONNECTED && (long)(millis() - wifiReconnectAt.load()) >= 0) { @@ -2088,7 +2033,7 @@ void loop() { bool connected = (WiFi.status() == WL_CONNECTED); if (connected && !wifiSTAConnected) { wifiSTAConnected = true; - ui.lvStatusBar().setWiFiActive(true); + CoreSync::netStatus.wifiActive.store(true); Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str()); // NTP time sync (DST-aware POSIX TZ string) @@ -2100,7 +2045,7 @@ void loop() { // 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 + // 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 @@ -2112,8 +2057,8 @@ void loop() { } } else if (!connected && wifiSTAConnected) { wifiSTAConnected = false; - ui.lvStatusBar().setWiFiActive(false); - ui.lvStatusBar().setTCPConnected(false); + CoreSync::netStatus.wifiActive.store(false); + CoreSync::netStatus.tcpConnected.store(false); // Stop and deregister TCP clients cleanly for (auto& iface : tcpIfaces) { RNS::Transport::deregister_interface(iface); @@ -2129,8 +2074,8 @@ void loop() { } } - // 7.6. AutoInterface deferred start — fire once SLAAC assigns a link-local - // IPv6 address. Arduino's IPv6Address::toString returns the expanded + // 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) { @@ -2149,15 +2094,13 @@ void loop() { scope); } else if (elapsed >= 10000) { autoIfaceDeferredStart = false; - Serial.println("[AUTOIFACE] SLAAC timeout — no link-local after 10s"); + 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. + // AutoInterface link-local rotation watch — covers SLAAC privacy address + // rotation while STA stays associated. if (autoIface.isOnline() && wifiSTAConnected && millis() - lastAutoIfaceLinkCheck >= 2000) { lastAutoIfaceLinkCheck = millis(); @@ -2170,7 +2113,7 @@ void loop() { } } - // 7.8. Deferred TCP reload from Settings. Avoid tearing down/recreating + // Deferred TCP reload from Settings. Avoid tearing down/recreating // Transport interfaces inside the LVGL key event path. if (tcpReloadRequested) { tcpReloadRequested = false; @@ -2179,7 +2122,8 @@ void loop() { if (announceManager) announceManager->clearTransientNodes(); } - // 8. WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded + // WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded. + bool anyTcpUp = false; { drainRetiredTCPClients(); bool skipTcp = (rnsDuration > 500); @@ -2190,9 +2134,18 @@ void loop() { for (auto* tcp : tcpClients) { if (millis() - tcpBudgetStart >= TCP_GLOBAL_BUDGET_MS) break; tcp->loop(); + if (tcp && tcp->isConnected()) anyTcpUp = true; yield(); } } + if (skipTcp) { + for (auto* tcp : tcpClients) { + if (tcp && tcp->isConnected()) { + anyTcpUp = true; + break; + } + } + } // 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 @@ -2200,53 +2153,13 @@ void loop() { autoIface.loop(); } - // 9. BLE loops + // 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()) { - // Update battery related paramt - ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent()); - ui.lvStatusBar().setCharging(powerMgr.isCharging()); - ui.lvStatusBar().setBatteryDisplay(userConfig.settings().batteryDisplay); // Switch between bar and percent - // 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) + // RSSI monitor (non-blocking, one sample per loop iteration) if (rssiMonitorActive && radioOnline) { unsigned long now = millis(); if (now - rssiMonitorStart >= 5000) { @@ -2263,7 +2176,20 @@ void loop() { } } - // 13. Heartbeat for crash diagnosis + // Publish network snapshot for the UI core. + CoreSync::netStatus.loraOnline.store(radioOnline); + CoreSync::netStatus.wifiEnabled.store(userConfig.settings().wifiMode != RAT_WIFI_OFF); + CoreSync::netStatus.bleEnabled.store(userConfig.settings().bleEnabled); + CoreSync::netStatus.wifiActive.store(wifiSTAConnected || (wifiImpl && wifiImpl->isAPActive())); + CoreSync::netStatus.tcpConnected.store(anyTcpUp); + CoreSync::netStatus.autoIfacePeers.store(autoIface.isOnline() ? (int)autoIface.peerCount() : -1); +#if HAS_BLE + CoreSync::netStatus.bleActive.store(bleInterface.isClientConnected()); +#else + CoreSync::netStatus.bleActive.store(false); +#endif + + // Heartbeat for crash diagnosis { unsigned long cycleTime = millis() - loopCycleStart; if (cycleTime > maxLoopTime) maxLoopTime = cycleTime; @@ -2283,7 +2209,6 @@ void loop() { 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; @@ -2316,6 +2241,152 @@ void loop() { } } loopCycleStart = millis(); +} + +static void uiLoopStep() { + // Render first, outside backend lock. + { + unsigned long now = millis(); + unsigned long lvglInterval = powerMgr.isDimmed() ? 200 : LVGL_INTERVAL_MS; + if (powerMgr.isScreenOn() && (inputManager.hadActivity() || now - lastLvglTime >= lvglInterval)) { + lastLvglTime = now; + lv_timer_handler(); + } + } + + // Input polling and power activity flags. + bool screenWasOn = powerMgr.isScreenOn(); + inputManager.update(); + bool wakeOnlyInput = !screenWasOn && inputManager.hadStrongActivity(); + if (inputManager.hadStrongActivity()) { + powerMgr.activity(); + } else if (inputManager.hadActivity()) { + powerMgr.weakActivity(); + } + + // Serial command handling can touch backend state; avoid blocking UI input + // if the network core is in a long crypto cycle. + { + CoreSync::RnsTryGuard serialGuard(0); + if (serialGuard.held()) { + handleSerialCommands(); + } + } + + if (inputManager.hadLongPress()) { + if (!ui.handleLongPress()) { + powerMgr.forceScreenOff(); + } + } + if (inputManager.hasKeyEvent() && !wakeOnlyInput) { + const KeyEvent& evt = inputManager.getKeyEvent(); + + if (lvHelpOverlay.isVisible()) { + lvHelpOverlay.handleKey(evt); + } else if (lvQrOverlay.isVisible()) { + lvQrOverlay.handleKey(evt); + } else { + bool consumed = ui.handleKey(evt); + if (!consumed) { + bool hotkeyAllowed = !ui.isBootMode() || (evt.ctrl && evt.character == 'h'); + bool hotkeyConsumed = false; + if (hotkeyAllowed) { + CoreSync::RnsTryGuard hotkeyGuard(0); + hotkeyConsumed = hotkeyGuard.held() && hotkeys.process(evt); + } + if (!hotkeyConsumed) { + LvInput::feedKey(evt); + + 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]); + } + } + } + } + } + } + + // UI-core peripherals. + audio.loop(); +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + gps.loop(); + } +#endif + powerMgr.loop(); + + // Periodic status bar update (1 Hz). + if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) { + lastStatusUpdate = millis(); + if (powerMgr.isScreenOn()) { + ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent()); + ui.lvStatusBar().setCharging(powerMgr.isCharging()); + ui.lvStatusBar().setBatteryDisplay(userConfig.settings().batteryDisplay); + + ui.lvStatusBar().setLoRaOnline(CoreSync::netStatus.loraOnline.load()); + ui.lvStatusBar().setWiFiActive(CoreSync::netStatus.wifiActive.load()); + ui.lvStatusBar().setTCPConnected(CoreSync::netStatus.tcpConnected.load()); + ui.lvStatusBar().setAutoIfacePeers(CoreSync::netStatus.autoIfacePeers.load()); + ui.lvStatusBar().setBLEActive(CoreSync::netStatus.bleActive.load()); +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + ui.lvStatusBar().setGPSFix(gps.hasTimeFix()); + } +#endif + ui.lvStatusBar().setUse24Hour(userConfig.settings().use24HourTime); + ui.lvStatusBar().updateTime(); + + CoreSync::RnsTryGuard refreshGuard(3); + if (refreshGuard.held()) { + ui.update(); + } + } + } + + // One-shot events from network core. + static uint32_t lastAnnounceSeqSeen = 0; + uint32_t announceSeq = CoreSync::netStatus.announceSeq.load(); + if (announceSeq != lastAnnounceSeqSeen) { + lastAnnounceSeqSeen = announceSeq; + ui.lvStatusBar().flashAnnounce(); + } + + char toastMsg[64] = {0}; + uint32_t toastDuration = CoreSync::takePendingToast(toastMsg, sizeof(toastMsg)); + if (toastDuration > 0 && toastMsg[0] != '\0') { + ui.lvStatusBar().showToast(toastMsg, toastDuration == 1 ? 0 : toastDuration); + } +} + +#if RSDECK_UI_CORE_SPLIT +static void networkTask(void*) { + for (;;) { + { + CoreSync::RnsGuard backendGuard; + networkLoopStep(); + } + vTaskDelay(1); + } +} +#endif + +void loop() { +#if RSDECK_UI_CORE_SPLIT + uiLoopStep(); +#else + uiLoopStep(); + networkLoopStep(); +#endif yield(); } diff --git a/src/platform/CoreSync.cpp b/src/platform/CoreSync.cpp new file mode 100644 index 0000000..e43a388 --- /dev/null +++ b/src/platform/CoreSync.cpp @@ -0,0 +1,64 @@ +#include "CoreSync.h" +#include + +namespace CoreSync { + +NetStatus netStatus; + +// Toast bridge: a single-slot mailbox. Producers (any core) write the message +// under a short critical section and bump toastSeq; the UI consumer reads it. +namespace { + char s_toastMsg[64] = {0}; + uint32_t s_toastDur = 0; +#if RSDECK_UI_CORE_SPLIT + portMUX_TYPE s_toastMux = portMUX_INITIALIZER_UNLOCKED; +#endif + uint32_t s_lastToastSeen = 0; +} + +void requestToast(const char* msg, uint32_t durationMs) { + if (!msg) return; +#if RSDECK_UI_CORE_SPLIT + portENTER_CRITICAL(&s_toastMux); +#endif + strlcpy(s_toastMsg, msg, sizeof(s_toastMsg)); + s_toastDur = durationMs; +#if RSDECK_UI_CORE_SPLIT + portEXIT_CRITICAL(&s_toastMux); +#endif + netStatus.toastSeq.fetch_add(1); +} + +uint32_t takePendingToast(char* out, uint32_t outSize) { + uint32_t seq = netStatus.toastSeq.load(); + if (seq == s_lastToastSeen) return 0; + s_lastToastSeen = seq; + uint32_t dur; +#if RSDECK_UI_CORE_SPLIT + portENTER_CRITICAL(&s_toastMux); +#endif + strlcpy(out, s_toastMsg, outSize); + dur = s_toastDur; +#if RSDECK_UI_CORE_SPLIT + portEXIT_CRITICAL(&s_toastMux); +#endif + return dur == 0 ? 1 : dur; // never return 0 for a real toast +} + +#if RSDECK_UI_CORE_SPLIT + +SemaphoreHandle_t spiBusMutex = nullptr; +SemaphoreHandle_t rnsMutex = nullptr; + +void begin() { + if (!spiBusMutex) spiBusMutex = xSemaphoreCreateRecursiveMutex(); + if (!rnsMutex) rnsMutex = xSemaphoreCreateRecursiveMutex(); +} + +#else + +void begin() {} + +#endif + +} // namespace CoreSync diff --git a/src/platform/CoreSync.h b/src/platform/CoreSync.h new file mode 100644 index 0000000..7fddbcf --- /dev/null +++ b/src/platform/CoreSync.h @@ -0,0 +1,131 @@ +#pragma once +// +// CoreSync — concurrency primitives for the optional UI/network core split. +// +// The T-Deck runs everything in a single Arduino loopTask by default. When +// RSDECK_UI_CORE_SPLIT is enabled, the Reticulum/radio/network stack is moved +// to its own FreeRTOS task pinned to core 0, leaving LVGL + input on the +// loopTask (core 1). Two subsystems are then shared across cores and must be +// serialized: +// +// * spiBus — the display (LovyanGFX), SX1262 radio, and SD card all sit on +// one physical FSPI bus. Every compound transaction takes +// spiBusMutex so a display flush can't interleave with a radio +// read on the wire. +// * rns — microReticulum / LXMF / AnnounceManager data structures are not +// thread-safe. The network task holds rnsMutex while processing; +// the UI takes it (briefly, try-lock) when reading state to draw. +// +// When RSDECK_UI_CORE_SPLIT is 0 everything is single-threaded and all of the +// helpers below compile down to no-ops — behavior is byte-for-byte the stock +// firmware. +// +#ifndef RSDECK_UI_CORE_SPLIT +#define RSDECK_UI_CORE_SPLIT 0 +#endif + +#if RSDECK_UI_CORE_SPLIT +#include +#include +#endif + +#include +#include + +namespace CoreSync { + +// Create the mutexes. Call once, early in setup(), before Display/radio init. +void begin(); + +#if RSDECK_UI_CORE_SPLIT + +extern SemaphoreHandle_t spiBusMutex; +extern SemaphoreHandle_t rnsMutex; + +inline void lockSpiBus() { xSemaphoreTakeRecursive(spiBusMutex, portMAX_DELAY); } +inline void unlockSpiBus() { xSemaphoreGiveRecursive(spiBusMutex); } + +// Block indefinitely for correctness-critical sections (network task processing). +inline void lockRns() { xSemaphoreTakeRecursive(rnsMutex, portMAX_DELAY); } +inline void unlockRns() { xSemaphoreGiveRecursive(rnsMutex); } +// Try-lock for the render/refresh path: never let the UI stall on crypto. A +// missed lock simply means "draw stale data this frame, refresh next tick". +inline bool tryLockRns(uint32_t ms) { + return xSemaphoreTakeRecursive(rnsMutex, pdMS_TO_TICKS(ms)) == pdTRUE; +} + +#else // single-threaded build — all no-ops + +inline void lockSpiBus() {} +inline void unlockSpiBus() {} +inline void lockRns() {} +inline void unlockRns() {} +inline bool tryLockRns(uint32_t) { return true; } + +#endif + +// RAII guard for the shared SPI bus. Scope it around one compound transaction +// (flush, radio opcode, or a File open→read→close sequence). +struct SpiBusGuard { + SpiBusGuard() { lockSpiBus(); } + ~SpiBusGuard() { unlockSpiBus(); } + SpiBusGuard(const SpiBusGuard&) = delete; + SpiBusGuard& operator=(const SpiBusGuard&) = delete; +}; + +// RAII guard for the RNS/LXMF data structures (blocking). Use in the network +// task and for UI-issued actions that must not be dropped. +struct RnsGuard { + RnsGuard() { lockRns(); } + ~RnsGuard() { unlockRns(); } + RnsGuard(const RnsGuard&) = delete; + RnsGuard& operator=(const RnsGuard&) = delete; +}; + +// RAII try-guard for the RNS data structures used from the render/refresh path. +// Check `held()` before touching shared state; if false, skip this cycle. +struct RnsTryGuard { + explicit RnsTryGuard(uint32_t ms) : _held(tryLockRns(ms)) {} + ~RnsTryGuard() { if (_held) unlockRns(); } + bool held() const { return _held; } + RnsTryGuard(const RnsTryGuard&) = delete; + RnsTryGuard& operator=(const RnsTryGuard&) = delete; +private: + bool _held; +}; + +// ------------------------------------------------------------------------- +// Network -> UI status snapshot. +// +// The network task (core 0) publishes connectivity state here as plain atomics; +// the UI loop (core 1) reads it once per status tick and pushes the values into +// the (change-gated) status bar. This replaces the network stack reaching into +// LVGL directly, which is not safe once the two run on different cores. +// +// Transient one-shot events (announce TX flash, a toast raised from network +// context) are delivered as monotonically-increasing sequence numbers: the UI +// remembers the last value it saw and acts when it changes. +// ------------------------------------------------------------------------- +struct NetStatus { + std::atomic loraOnline{false}; + std::atomic wifiEnabled{false}; + std::atomic wifiActive{false}; + std::atomic bleEnabled{false}; + std::atomic bleActive{false}; + std::atomic tcpConnected{false}; + std::atomic gpsFix{false}; + std::atomic autoIfacePeers{-1}; // -1 == AutoInterface offline + std::atomic announceSeq{0}; // bump on each announce TX -> UI flashes + std::atomic toastSeq{0}; // bump to raise a deferred toast +}; + +extern NetStatus netStatus; + +// Raise a toast from any core. The message is copied into a small internal +// buffer under a lightweight lock; the UI loop displays it when toastSeq moves. +void requestToast(const char* msg, uint32_t durationMs); +// Called by the UI loop: if a new toast is pending, copies it into `out` +// (size >= 64) and returns its duration; returns 0 when nothing is pending. +uint32_t takePendingToast(char* out, uint32_t outSize); + +} // namespace CoreSync diff --git a/src/radio/SX1262.cpp b/src/radio/SX1262.cpp index 7c78c40..8b8cd74 100644 --- a/src/radio/SX1262.cpp +++ b/src/radio/SX1262.cpp @@ -5,6 +5,7 @@ #include "SX1262.h" #include "config/BoardConfig.h" +#include "platform/CoreSync.h" SX1262* SX1262::_instance = nullptr; @@ -94,6 +95,7 @@ void SX1262::writeRegister(uint16_t address, uint8_t value) { uint8_t IRAM_ATTR SX1262::singleTransfer(uint8_t opcode, uint16_t address, uint8_t value) { waitOnBusy(); uint8_t response; + CoreSync::SpiBusGuard busGuard; // serialize against display/SD on shared FSPI bus _spiModem->beginTransaction(_spiSettings); digitalWrite(_ss, LOW); _spiModem->transfer(opcode); @@ -110,6 +112,7 @@ uint8_t IRAM_ATTR SX1262::singleTransfer(uint8_t opcode, uint16_t address, uint8 void SX1262::executeOpcode(uint8_t opcode, uint8_t* buffer, uint8_t size) { waitOnBusy(); + CoreSync::SpiBusGuard busGuard; _spiModem->beginTransaction(_spiSettings); digitalWrite(_ss, LOW); _spiModem->transfer(opcode); @@ -122,6 +125,7 @@ void SX1262::executeOpcode(uint8_t opcode, uint8_t* buffer, uint8_t size) { void SX1262::executeOpcodeRead(uint8_t opcode, uint8_t* buffer, uint8_t size) { waitOnBusy(); + CoreSync::SpiBusGuard busGuard; _spiModem->beginTransaction(_spiSettings); digitalWrite(_ss, LOW); _spiModem->transfer(opcode); @@ -135,6 +139,7 @@ void SX1262::executeOpcodeRead(uint8_t opcode, uint8_t* buffer, uint8_t size) { void SX1262::writeBuffer(const uint8_t* buffer, size_t size) { waitOnBusy(); + CoreSync::SpiBusGuard busGuard; _spiModem->beginTransaction(_spiSettings); digitalWrite(_ss, LOW); _spiModem->transfer(OP_FIFO_WRITE_6X); @@ -149,6 +154,7 @@ void SX1262::writeBuffer(const uint8_t* buffer, size_t size) { void SX1262::readBuffer(uint8_t* buffer, size_t size) { waitOnBusy(); + CoreSync::SpiBusGuard busGuard; _spiModem->beginTransaction(_spiSettings); digitalWrite(_ss, LOW); _spiModem->transfer(OP_FIFO_READ_6X); diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index c2cb935..eae76d7 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -169,14 +169,10 @@ void AnnounceManager::received_announce( if (nc == _nameCache.end() || nc->second != name) { _nameCache[destHex] = name; _nameCacheDirty = true; - saveNameCache(); - _nameCacheDirty = false; } } if (!idHex.empty()) { - persistKnownDestinationsAfterAnnounce( - identityChanged ? "identity update" : "repeat announce", - identityChanged); + _knownDestinationsDirty = true; } return; } @@ -201,8 +197,6 @@ void AnnounceManager::received_announce( } } } - saveNameCache(); - _nameCacheDirty = false; } } @@ -254,7 +248,7 @@ void AnnounceManager::received_announce( _hashIndex[key] = (int)_nodes.size(); _nodes.push_back(node); if (!idHex.empty()) { - persistKnownDestinationsAfterAnnounce("new peer", true); + _knownDestinationsDirty = true; } } @@ -270,13 +264,19 @@ void AnnounceManager::loop() { _nameCacheDirty = false; saveNameCache(); } + + if (_knownDestinationsDirty) { + if (persistKnownDestinationsAfterAnnounce("announce batch", false)) { + _knownDestinationsDirty = false; + } + } } -void AnnounceManager::persistKnownDestinationsAfterAnnounce(const char* reason, bool force) { +bool AnnounceManager::persistKnownDestinationsAfterAnnounce(const char* reason, bool force) { unsigned long now = millis(); if (!force && _lastKnownDestinationsPersist != 0 && now - _lastKnownDestinationsPersist < KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS) { - return; + return false; } _lastKnownDestinationsPersist = now; @@ -285,6 +285,7 @@ void AnnounceManager::persistKnownDestinationsAfterAnnounce(const char* reason, unsigned long elapsed = millis() - startMs; Serial.printf("[ANNOUNCE] Known destinations persisted after %s (force=%s in %lums)\n", reason ? reason : "announce", force ? "yes" : "no", elapsed); + return true; } int AnnounceManager::nodesOnlineSince(unsigned long maxAgeMs) const { diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index 637681b..834d95a 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -60,7 +60,7 @@ class AnnounceManager : public RNS::AnnounceHandler { private: void saveContact(const DiscoveredNode& node); void removeContact(const std::string& hexHash); - void persistKnownDestinationsAfterAnnounce(const char* reason, bool force); + bool persistKnownDestinationsAfterAnnounce(const char* reason, bool force); std::vector _nodes; SDStore* _sd = nullptr; @@ -69,6 +69,7 @@ class AnnounceManager : public RNS::AnnounceHandler { RNS::Bytes _localDestHash; bool _contactsDirty = false; bool _nameCacheDirty = false; + bool _knownDestinationsDirty = false; unsigned long _lastContactSave = 0; unsigned long _lastAnnounceProcessed = 0; std::map _nameCache; // hexHash → displayName @@ -78,7 +79,7 @@ class AnnounceManager : public RNS::AnnounceHandler { static constexpr int MAX_NODES = 100; static constexpr int MAX_NAME_CACHE = 300; static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000; - static constexpr unsigned long KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS = 5000; + static constexpr unsigned long KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS = 60000; static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing unsigned long _lastKnownDestinationsPersist = 0; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 37be62c..22fb5b2 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -416,18 +416,24 @@ void ReticulumManager::loop() { // node, so path tables are intentionally not persisted across boots. void ReticulumManager::persistData() { unsigned long start = millis(); + unsigned long now = millis(); unsigned long identityPersistMs = 0; unsigned long sdMirrorMs = 0; size_t sdMirrorBytes = 0; bool sdMirrorAttempted = false; bool sdMirrorOk = false; - const char* cycleName = "identity"; + const char* cycleName = "identity-skip"; switch (_persistCycle) { case 0: { - unsigned long phaseMs = millis(); - RNS::Identity::persist_data(); - identityPersistMs = millis() - phaseMs; + if (_lastIdentityPersist == 0 || + now - _lastIdentityPersist >= IDENTITY_PERSIST_MIN_INTERVAL_MS) { + cycleName = "identity"; + unsigned long phaseMs = millis(); + RNS::Identity::persist_data(); + identityPersistMs = millis() - phaseMs; + _lastIdentityPersist = now; + } break; } case 1: diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 9596c56..02ae627 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -71,7 +71,9 @@ class ReticulumManager { SDStore* _sd = nullptr; bool _transportActive = false; unsigned long _lastPersist = 0; + unsigned long _lastIdentityPersist = 0; unsigned long _lastAnnounceTime = 0; uint8_t _persistCycle = 0; // Rotating: 0=known destinations, 1=SD backup + static constexpr unsigned long IDENTITY_PERSIST_MIN_INTERVAL_MS = 15UL * 60UL * 1000UL; static uint32_t _announceFilterCount; }; diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp index 60ba20d..2e34b1b 100644 --- a/src/storage/SDStore.cpp +++ b/src/storage/SDStore.cpp @@ -1,6 +1,12 @@ #include "SDStore.h" #include "config/Config.h" #include "util/PerfTrace.h" +#include "platform/CoreSync.h" + +// Every runtime SD operation grabs the shared FSPI bus for its full duration. +// The mutex is recursive, so nested helpers (writeString -> writeAtomic, +// ensureDir recursion, formatForRsDeck -> ensureDir) lock safely. Boot-time +// setup (begin) runs before the network task exists, so it needs no guard. bool SDStore::begin(SPIClass* spi, int csPin) { if (!spi) return false; @@ -58,11 +64,12 @@ bool SDStore::begin(SPIClass* spi, int csPin) { void SDStore::end() { SD.end(); _ready = false; } -uint64_t SDStore::totalBytes() const { return _ready ? SD.totalBytes() : 0; } -uint64_t SDStore::usedBytes() const { return _ready ? SD.usedBytes() : 0; } +uint64_t SDStore::totalBytes() const { CoreSync::SpiBusGuard g; return _ready ? SD.totalBytes() : 0; } +uint64_t SDStore::usedBytes() const { CoreSync::SpiBusGuard g; return _ready ? SD.usedBytes() : 0; } bool SDStore::ensureDir(const char* path) { if (!_ready) return false; + CoreSync::SpiBusGuard busGuard; if (SD.exists(path)) return true; // Create parent directories recursively String pathStr = String(path); @@ -76,14 +83,17 @@ bool SDStore::ensureDir(const char* path) { return SD.mkdir(path); } -bool SDStore::exists(const char* path) { return _ready ? SD.exists(path) : false; } -bool SDStore::remove(const char* path) { return _ready ? SD.remove(path) : false; } -File SDStore::openDir(const char* path) { return _ready ? SD.open(path) : File(); } -bool SDStore::removeDir(const char* path) { return _ready ? SD.rmdir(path) : false; } +bool SDStore::exists(const char* path) { CoreSync::SpiBusGuard g; return _ready ? SD.exists(path) : false; } +bool SDStore::remove(const char* path) { CoreSync::SpiBusGuard g; return _ready ? SD.remove(path) : false; } +// NOTE: openDir only guards the open(); callers iterating the returned File must +// hold their own CoreSync::SpiBusGuard around the open->read->close sequence. +File SDStore::openDir(const char* path) { CoreSync::SpiBusGuard g; return _ready ? SD.open(path) : File(); } +bool SDStore::removeDir(const char* path) { CoreSync::SpiBusGuard g; return _ready ? SD.rmdir(path) : false; } bool SDStore::readFile(const char* path, uint8_t* buffer, size_t maxLen, size_t& bytesRead) { bytesRead = 0; if (!_ready) return false; + CoreSync::SpiBusGuard busGuard; File f = SD.open(path, FILE_READ); if (!f) return false; size_t size = f.size(); @@ -99,6 +109,7 @@ bool SDStore::writeAtomic(const char* path, const uint8_t* data, size_t len) { PerfTrace::write("sd", "atomic", path, len, startMs, false); return false; } + CoreSync::SpiBusGuard busGuard; String tmpPath = String(path) + ".tmp"; String bakPath = String(path) + ".bak"; @@ -154,6 +165,7 @@ bool SDStore::writeSimple(const char* path, const uint8_t* data, size_t len) { PerfTrace::write("sd", "simple", path, len, startMs, false); return false; } + CoreSync::SpiBusGuard busGuard; File f = SD.open(path, FILE_WRITE); if (!f) { @@ -182,6 +194,7 @@ bool SDStore::writeString(const char* path, const String& data) { String SDStore::readString(const char* path) { if (!_ready) return ""; + CoreSync::SpiBusGuard busGuard; File f = SD.open(path, FILE_READ); if (!f) { String bakPath = String(path) + ".bak"; @@ -195,6 +208,7 @@ String SDStore::readString(const char* path) { bool SDStore::wipeRsDeck() { if (!_ready) return false; + CoreSync::SpiBusGuard busGuard; // spans the whole recursive wipeDir walk Serial.println("[SD] Wiping rsDeck legacy /ratdeck/ data ..."); wipeDir("/ratdeck/messages"); wipeDir("/ratdeck/contacts"); @@ -225,6 +239,7 @@ void SDStore::wipeDir(const char* path) { bool SDStore::hasExistingData() { if (!_ready) return false; + CoreSync::SpiBusGuard busGuard; if (SD.exists(SD_PATH_USER_CONFIG)) return true; if (SD.exists(SD_PATH_IDENTITY)) return true; File dir = SD.open("/ratdeck/messages"); diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index 5715ed6..570f95b 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -3,6 +3,16 @@ #include #include +#ifndef RSDECK_LORA_IF_VERBOSE +#define RSDECK_LORA_IF_VERBOSE 0 +#endif + +#if RSDECK_LORA_IF_VERBOSE +#define LORA_IF_LOGF(...) Serial.printf(__VA_ARGS__) +#else +#define LORA_IF_LOGF(...) do {} while (0) +#endif + // RNode on-air framing constants (from RNode_Firmware Framing.h / Config.h) // Every LoRa packet has a 1-byte header: upper nibble = random sequence, lower nibble = flags #define RNODE_HEADER_L 1 @@ -60,12 +70,12 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) { if ((int)_txQueue.size() < TX_QUEUE_MAX) { _txQueue.push_back(data); if (_splitRxPending) { - Serial.printf("[LORA_IF] TX deferred (split RX pending, %d in queue)\n", (int)_txQueue.size()); + LORA_IF_LOGF("[LORA_IF] TX deferred (split RX pending, %d in queue)\n", (int)_txQueue.size()); } else { - Serial.printf("[LORA_IF] TX queued (%d in queue)\n", (int)_txQueue.size()); + LORA_IF_LOGF("[LORA_IF] TX queued (%d in queue)\n", (int)_txQueue.size()); } } else { - Serial.println("[LORA_IF] TX queue full, dropping oldest"); + LORA_IF_LOGF("[LORA_IF] TX queue full, dropping oldest\n"); _txQueue.pop_front(); _txQueue.push_back(data); } @@ -85,7 +95,7 @@ void LoRaInterface::transmitNow(const RNS::Bytes& data) { // First frame: header + first 254 bytes of payload size_t firstLen = RNODE_SINGLE_MTU; - Serial.printf("[LORA_IF] TX SPLIT: %d bytes in 2 frames (seq=0x%02X)\n", + LORA_IF_LOGF("[LORA_IF] TX SPLIT: %d bytes in 2 frames (seq=0x%02X)\n", (int)data.size(), header & RNODE_NIBBLE_SEQ); _radio->beginPacket(); @@ -98,7 +108,7 @@ void LoRaInterface::transmitNow(const RNS::Bytes& data) { _splitTxRemaining = RNS::Bytes(data.data() + firstLen, data.size() - firstLen); _splitTxHeader = header; - Serial.printf("[LORA_IF] TX SPLIT frame 1: %d+1 bytes (remaining: %d)\n", + LORA_IF_LOGF("[LORA_IF] TX SPLIT frame 1: %d+1 bytes (remaining: %d)\n", (int)firstLen, (int)_splitTxRemaining.size()); } else { // Single frame: fits in one LoRa packet @@ -107,7 +117,7 @@ void LoRaInterface::transmitNow(const RNS::Bytes& data) { _radio->write(data.data(), data.size()); _radio->endPacket(true); - Serial.printf("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", (int)data.size(), header); + LORA_IF_LOGF("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", (int)data.size(), header); } _txPending = true; @@ -145,7 +155,7 @@ void LoRaInterface::loop() { _splitTxPending = false; size_t frame2Size = _splitTxRemaining.size(); - Serial.printf("[LORA_IF] TX SPLIT frame 2: %d+1 bytes\n", (int)frame2Size); + LORA_IF_LOGF("[LORA_IF] TX SPLIT frame 2: %d+1 bytes\n", (int)frame2Size); _radio->beginPacket(); _radio->write(_splitTxHeader); @@ -177,7 +187,7 @@ void LoRaInterface::loop() { // Split RX timeout: discard stale partial packets and drain deferred TX if (_splitRxPending && (millis() - _splitRxTimestamp > _splitRxTimeoutMs)) { unsigned long age = millis() - _splitRxTimestamp; - Serial.printf("[LORA_IF] RX SPLIT timeout after %lums (limit=%lums frame=%.0fms), discarding partial\n", + LORA_IF_LOGF("[LORA_IF] RX SPLIT timeout after %lums (limit=%lums frame=%.0fms), discarding partial\n", age, _splitRxTimeoutMs, _singleFrameAirtimeMs); _splitRxPending = false; _splitRxBuffer = RNS::Bytes(); @@ -190,15 +200,17 @@ void LoRaInterface::loop() { } // Periodic RX debug +#if RSDECK_LORA_IF_VERBOSE static unsigned long lastRxDebug = 0; if (millis() - lastRxDebug > 30000) { lastRxDebug = millis(); int rssi = _radio->currentRssi(); uint8_t status = _radio->getStatus(); uint8_t chipMode = (status >> 4) & 0x07; - Serial.printf("[LORA_IF] RX: RSSI=%d dBm, status=0x%02X(mode=%d)\n", + LORA_IF_LOGF("[LORA_IF] RX: RSSI=%d dBm, status=0x%02X(mode=%d)\n", rssi, status, chipMode); } +#endif if (!_radio->packetAvailable) return; _radio->packetAvailable = false; @@ -206,7 +218,7 @@ void LoRaInterface::loop() { int packetSize = _radio->parsePacket(); if (packetSize <= RNODE_HEADER_L) { if (packetSize > 0) { - Serial.printf("[LORA_IF] RX runt packet (%d bytes), discarding\n", packetSize); + LORA_IF_LOGF("[LORA_IF] RX runt packet (%d bytes), discarding\n", packetSize); } _radio->receive(); return; @@ -233,20 +245,20 @@ void LoRaInterface::loop() { _splitRxBuffer = RNS::Bytes(raw + RNODE_HEADER_L, payloadSize); _splitRxTimestamp = millis(); - Serial.printf("[LORA_IF] RX SPLIT frame 1: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f, timeout=%lums\n", + LORA_IF_LOGF("[LORA_IF] RX SPLIT frame 1: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f, timeout=%lums\n", payloadSize, seq, _lastRxRssi, _lastRxSnr, _splitRxTimeoutMs); _radio->receive(); return; } else if (seq == _splitRxSeq) { // Second frame matches — reassemble - Serial.printf("[LORA_IF] RX SPLIT frame 2: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f, age=%lums\n", + LORA_IF_LOGF("[LORA_IF] RX SPLIT frame 2: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f, age=%lums\n", payloadSize, seq, _lastRxRssi, _lastRxSnr, millis() - _splitRxTimestamp); _splitRxBuffer.append(raw + RNODE_HEADER_L, payloadSize); int totalSize = _splitRxBuffer.size(); _splitRxPending = false; - Serial.printf("[LORA_IF] RX SPLIT reassembled: %d bytes total\n", totalSize); + LORA_IF_LOGF("[LORA_IF] RX SPLIT reassembled: %d bytes total\n", totalSize); InterfaceImpl::handle_incoming(_splitRxBuffer); _splitRxBuffer = RNS::Bytes(); @@ -263,7 +275,7 @@ void LoRaInterface::loop() { } else { // Different split packet's frame 1 arrived — the previous split is lost. // This happens when frame 2 was missed (radio was busy, collision, etc.) - Serial.printf("[LORA_IF] RX SPLIT new seq (had 0x%02X, got 0x%02X), previous frame 2 lost\n", + LORA_IF_LOGF("[LORA_IF] RX SPLIT new seq (had 0x%02X, got 0x%02X), previous frame 2 lost\n", _splitRxSeq, seq); _splitRxSeq = seq; _splitRxBuffer = RNS::Bytes(raw + RNODE_HEADER_L, payloadSize); @@ -277,10 +289,10 @@ void LoRaInterface::loop() { // Process the non-split packet normally but KEEP the split buffer. // Frame 2 may still arrive after this interleaving packet. if (_splitRxPending) { - Serial.printf("[LORA_IF] RX non-split %d bytes while awaiting split frame 2 (kept)\n", payloadSize); + LORA_IF_LOGF("[LORA_IF] RX non-split %d bytes while awaiting split frame 2 (kept)\n", payloadSize); } - Serial.printf("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", + LORA_IF_LOGF("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", packetSize, header, payloadSize, _lastRxRssi, _lastRxSnr); @@ -333,7 +345,7 @@ void LoRaInterface::refreshRadioTiming(bool forceLog) { _splitRxTimeoutMs = newTimeout; if (changed) { - Serial.printf("[LORA_IF] timing: bitrate=%lu bps frame=%.0fms split_timeout=%lums ldro=%s\n", + LORA_IF_LOGF("[LORA_IF] timing: bitrate=%lu bps frame=%.0fms split_timeout=%lums ldro=%s\n", (unsigned long)_bitrate, _singleFrameAirtimeMs, _splitRxTimeoutMs, diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index 89becc4..f18b73d 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -2,6 +2,7 @@ #include "ui/Theme.h" #include "ui/LvTheme.h" #include "ui/LvTabBar.h" +#include "platform/CoreSync.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" #include "util/PerfTrace.h" @@ -79,6 +80,8 @@ void makeTransparent(lv_obj_t* obj) { std::string LvMessageView::getPeerName() { if (_am) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return _peerHex.substr(0, 12); std::string name = _am->lookupName(_peerHex); if (!name.empty()) return name; } @@ -91,7 +94,11 @@ void LvMessageView::updateHeader() { std::string name = getPeerName(); lv_label_set_text(_lblHeader, name.c_str()); - const DiscoveredNode* node = _am ? _am->findNodeByHex(_peerHex) : nullptr; + const DiscoveredNode* node = nullptr; + CoreSync::RnsTryGuard backendGuard(0); + if (_am && backendGuard.held()) { + node = _am->findNodeByHex(_peerHex); + } const char* state = "UNKNOWN"; uint32_t stateColor = Theme::TEXT_MUTED; @@ -113,6 +120,9 @@ void LvMessageView::updateHeader() { void LvMessageView::markVisibleConversationRead() { if (!_markReadPending || !_lxmf) return; + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + unsigned long startMs = PerfTrace::nowMs(); _lxmf->markRead(_peerHex); _markReadPending = false; @@ -327,19 +337,22 @@ void LvMessageView::onEnter() { // Register status callback - partial update without full rebuild std::string peer = _peerHex; unsigned long phaseMs = millis(); - _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double ts, uint32_t savedCounter, LXMFStatus newStatus) { - if (peerHex != peer) return; - for (int i = (int)_cachedMsgs.size() - 1; i >= 0; i--) { - bool sameMessage = savedCounter > 0 - ? _cachedMsgs[i].savedCounter == savedCounter - : std::fabs(_cachedMsgs[i].timestamp - ts) < 1.0; - if (!_cachedMsgs[i].incoming && sameMessage) { - _cachedMsgs[i].status = newStatus; - updateMessageStatus(i, newStatus); - return; + { + CoreSync::RnsGuard backendGuard; + _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double ts, uint32_t savedCounter, LXMFStatus newStatus) { + if (peerHex != peer) return; + for (int i = (int)_cachedMsgs.size() - 1; i >= 0; i--) { + bool sameMessage = savedCounter > 0 + ? _cachedMsgs[i].savedCounter == savedCounter + : std::fabs(_cachedMsgs[i].timestamp - ts) < 1.0; + if (!_cachedMsgs[i].incoming && sameMessage) { + _cachedMsgs[i].status = newStatus; + updateMessageStatus(i, newStatus); + return; + } } - } - }); + }); + } callbackMs = millis() - phaseMs; } unsigned long phaseMs = millis(); @@ -371,7 +384,10 @@ void LvMessageView::onEnter() { } void LvMessageView::onExit() { - if (_lxmf) _lxmf->setStatusCallback(nullptr); + if (_lxmf) { + CoreSync::RnsGuard backendGuard; + _lxmf->setStatusCallback(nullptr); + } _markReadPending = false; hideSendModeMenu(); _inputText.clear(); @@ -390,6 +406,8 @@ void LvMessageView::refreshUI() { updateHeader(); // Only reload from disk when message count changes (new messages arrive) + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; auto* summary = _lxmf->getConversationSummary(_peerHex); int totalCount = summary ? summary->totalCount : -1; if (summary && totalCount == _knownTotalCount) return; @@ -541,14 +559,21 @@ void LvMessageView::rebuildMessages() { // Only load from disk if _cachedMsgs is empty (first call or after send) if (_cachedMsgs.empty()) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; unsigned long phaseMs = millis(); _cachedMsgs = _lxmf->getRecentMessages(_peerHex, CHAT_VIEW_MAX_MESSAGES); loadMs = millis() - phaseMs; loadedFromStore = true; } if (_lxmf) { - auto* summary = _lxmf->getConversationSummary(_peerHex); - _knownTotalCount = summary ? summary->totalCount : (int)_cachedMsgs.size(); + CoreSync::RnsTryGuard backendGuard(0); + if (backendGuard.held()) { + auto* summary = _lxmf->getConversationSummary(_peerHex); + _knownTotalCount = summary ? summary->totalCount : (int)_cachedMsgs.size(); + } else if (_knownTotalCount < 0) { + _knownTotalCount = (int)_cachedMsgs.size(); + } } _lastMsgCount = (int)_cachedMsgs.size(); _lastRefreshMs = millis(); @@ -701,9 +726,13 @@ void LvMessageView::sendCurrentMessage(bool viaLink) { destHash.assignHex(_peerHex.c_str()); hashMs = millis() - hashMs; unsigned long queueStartMs = millis(); - bool queued = viaLink - ? _lxmf->sendMessageViaLink(destHash, _inputText.c_str()) - : _lxmf->sendMessage(destHash, _inputText.c_str()); + bool queued; + { + CoreSync::RnsGuard backendGuard; + queued = viaLink + ? _lxmf->sendMessageViaLink(destHash, _inputText.c_str()) + : _lxmf->sendMessage(destHash, _inputText.c_str()); + } unsigned long queueMs = millis() - queueStartMs; if (!queued) { #if RSDECK_PERF_TRACE diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index 6fd80a6..15a2b0b 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -4,6 +4,7 @@ #include "ui/LvInput.h" #include "ui/UIManager.h" #include "ui/LxmFaceAvatar.h" +#include "platform/CoreSync.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" #include "storage/MessageStore.h" @@ -489,6 +490,7 @@ void LvMessagesScreen::showDeleteConfirm() { void LvMessagesScreen::addFocusedPeerToContacts() { if (!_am || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; + CoreSync::RnsGuard backendGuard; const auto& peerHex = _sortedPeers[_lpPeerIdx]; const DiscoveredNode* existing = _am->findNodeByHex(peerHex); if (existing && !existing->saved) { @@ -506,6 +508,7 @@ void LvMessagesScreen::addFocusedPeerToContacts() { void LvMessagesScreen::deleteFocusedConversation() { if (!_lxmf || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; + CoreSync::RnsGuard backendGuard; const auto& peerHex = _sortedPeers[_lpPeerIdx]; _lxmf->markRead(peerHex); _lxmf->deleteConversation(peerHex); diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index 4c6b248..49df2ae 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -3,6 +3,7 @@ #include "ui/LvTheme.h" #include "ui/LvInput.h" #include "ui/UIManager.h" +#include "platform/CoreSync.h" #include "reticulum/AnnounceManager.h" #include "config/UserConfig.h" #include @@ -508,6 +509,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { // --- Nickname input mode --- if (_actionState == NodeAction::NICKNAME_INPUT) { if (event.enter || event.character == '\n' || event.character == '\r') { + CoreSync::RnsGuard backendGuard; if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { auto& node = const_cast(_am->nodes()[_actionNodeIdx]); String finalName = _nicknameText; @@ -558,6 +560,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { break; case 1: if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size() && _onSelect) { + CoreSync::RnsGuard backendGuard; std::string hex = _am->nodes()[_actionNodeIdx].hash.toHex(); hideOverlay(); _onSelect(hex); @@ -578,6 +581,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { // --- Confirm delete mode --- if (_confirmDelete) { if (event.enter || event.character == '\n' || event.character == '\r') { + CoreSync::RnsGuard backendGuard; if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { _am->deleteContact(_actionNodeIdx); if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); @@ -595,6 +599,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { if (event.character == 's' || event.character == 'S') { int nodeIdx = getFocusedNodeIdx(); if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { + CoreSync::RnsGuard backendGuard; auto& node = const_cast(_am->nodes()[nodeIdx]); node.saved = !node.saved; if (node.saved) _am->saveContacts(); diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 7cd3036..cd6c78d 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -1,6 +1,7 @@ #include "LvSettingsScreen.h" #include "ui/Theme.h" #include "ui/LvTheme.h" +#include "platform/CoreSync.h" #include "ui/LvInput.h" #include "config/Config.h" #include "config/UserConfig.h" @@ -474,23 +475,28 @@ void LvSettingsScreen::buildItems() { return; } if (!_sd->exists(SD_PATH_IMPORT_IDENTITY) && !_sd->exists(SD_PATH_IMPORT_ID)) { - File dir = _sd->openDir(SD_PATH_IDENTITY_DIR); int identityFiles = 0; - while (dir) { - File entry = dir.openNextFile(); - if (!entry) break; - if (!entry.isDirectory()) { - String name = entry.name(); - name.toLowerCase(); - bool reservedIdentityFile = name == "identity.key" || name.endsWith("/identity.key") || - name == "identity.identity" || name.endsWith("/identity.identity"); - if (!reservedIdentityFile && (name.endsWith(".identity") || name.endsWith(".key"))) { - identityFiles++; + { + // Hold the shared SPI bus across the whole dir walk; openDir's + // own guard only covers the initial open, not this iteration. + CoreSync::SpiBusGuard busGuard; + File dir = _sd->openDir(SD_PATH_IDENTITY_DIR); + while (dir) { + File entry = dir.openNextFile(); + if (!entry) break; + if (!entry.isDirectory()) { + String name = entry.name(); + name.toLowerCase(); + bool reservedIdentityFile = name == "identity.key" || name.endsWith("/identity.key") || + name == "identity.identity" || name.endsWith("/identity.identity"); + if (!reservedIdentityFile && (name.endsWith(".identity") || name.endsWith(".key"))) { + identityFiles++; + } } + entry.close(); } - entry.close(); + dir.close(); } - dir.close(); if (identityFiles == 0) { if (_ui) _ui->lvStatusBar().showToast("Missing identity file", 1200); return; @@ -1075,6 +1081,7 @@ void LvSettingsScreen::buildItems() { announceItem.formatter = [](int) { return String("[Enter]"); }; announceItem.action = [this]() { if (_rns && _cfg) { + CoreSync::RnsGuard backendGuard; RNS::Bytes appData = encodeAnnounceName(_cfg->settings().displayName); _rns->announce(appData); if (_ui) { _ui->lvStatusBar().flashAnnounce(); _ui->lvStatusBar().showToast("Announce sent"); } @@ -2150,6 +2157,7 @@ String LvSettingsScreen::freqFormatWithCursor() const { void LvSettingsScreen::applyAndSave() { if (!_cfg) return; + CoreSync::RnsGuard backendGuard; auto& s = _cfg->settings(); // Theme switch applies live: palette globals -> shared styles -> shell. // Our own rows re-read Theme:: on the rebuild that follows every commit. From 21170eacfe075b71f9c577247533b634bae96c03 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Sun, 30 Aug 2026 15:05:21 -0400 Subject: [PATCH 2/2] Refinement --- docs/UI-CORE-SPLIT-NEXT-STEPS.md | 64 +++--- src/hal/Display.cpp | 2 + src/main.cpp | 337 ++++++++++++++++++---------- src/platform/CoreSync.cpp | 22 +- src/platform/CoreSync.h | 4 +- src/reticulum/AnnounceManager.cpp | 4 +- src/reticulum/AnnounceManager.h | 1 + src/reticulum/IdentityManager.cpp | 2 + src/reticulum/ReticulumManager.cpp | 9 +- src/storage/MessageStore.cpp | 8 + src/ui/screens/LvContactsScreen.cpp | 56 +++-- src/ui/screens/LvContactsScreen.h | 6 +- src/ui/screens/LvHomeScreen.cpp | 4 + src/ui/screens/LvMessageView.cpp | 26 +-- src/ui/screens/LvMessageView.h | 2 + src/ui/screens/LvMessagesScreen.cpp | 149 ++++++------ src/ui/screens/LvNodesScreen.cpp | 276 ++++++++++++++++++----- src/ui/screens/LvNodesScreen.h | 19 +- src/ui/screens/LvSettingsScreen.cpp | 130 ++++++++--- 19 files changed, 777 insertions(+), 344 deletions(-) diff --git a/docs/UI-CORE-SPLIT-NEXT-STEPS.md b/docs/UI-CORE-SPLIT-NEXT-STEPS.md index 2a3c3e4..8fc5d6b 100644 --- a/docs/UI-CORE-SPLIT-NEXT-STEPS.md +++ b/docs/UI-CORE-SPLIT-NEXT-STEPS.md @@ -1,11 +1,12 @@ # rsDeck UI/Network Core Split — Handoff & Next Steps -**Status:** Stages 0–2 complete and compile-verified. The tree now runs with split-mode -support in place: UI loop on `loopTask` (core 1), network task on core 0 when -`RSDECK_UI_CORE_SPLIT=1`, and stock superloop behavior when the flag is `0`. +**Status:** Stages 0–3 are implemented and pass static diagnostics. The tree now runs +with the UI loop on `loopTask` (core 1), the network task on core 0 when +`RSDECK_UI_CORE_SPLIT=1`, and a single-task compatibility path when the flag is `0`. +An on-device build and sustained-traffic soak test remain. Build flag: `-DRSDECK_UI_CORE_SPLIT=1` in `platformio.ini`. Set it to `0` to get the -exact stock superloop back (every guard compiles to a no‑op). Flip this to A/B test. +single-task superloop (every guard compiles to a no-op). Flip this to A/B test. > Build/flash needs **esptool v5** (`merge-bin` hyphen syntax). Already installed > (5.3.1) in the PlatformIO penv. If you hit `invalid choice: 'merge-bin'`, run @@ -28,7 +29,7 @@ keep LVGL + input on the Arduino `loopTask` (**core 1**). Render never waits on 1. **SPI bus** — display (LovyanGFX), SX1262 radio, and SD all share FSPI (SCK 40 / MOSI 41 / MISO 38). Guarded by `CoreSync::spiBusMutex` (recursive). **DONE.** 2. **Reticulum/LXMF/AnnounceManager data + flash/SD persistence** — one "backend" lock, - `CoreSync::rnsMutex` (recursive). + `CoreSync::rnsMutex` (recursive). **DONE.** 3. **Network → UI status** — lock‑free `CoreSync::netStatus` snapshot + a deferred‑toast bridge (`requestToast`/`takePendingToast`). **DONE (infra).** @@ -37,24 +38,27 @@ The display flush takes `spiBusMutex` only (never `rnsMutex`), so there's no inv --- -## What's DONE (committed to working tree, compiling) +## What's DONE ### New module - `src/platform/CoreSync.h` / `.cpp` — mutexes (`spiBusMutex`, `rnsMutex`), RAII guards (`SpiBusGuard`, `RnsGuard` blocking, `RnsTryGuard` try‑lock), the `NetStatus` atomic snapshot, and the toast bridge. All no‑ops when the flag is 0. -- `CoreSync::begin()` is called first thing in `setup()` (main.cpp). +- `CoreSync::begin()` is called first thing in `setup()` and fails explicitly if mutex + allocation is unsuccessful. ### Stage 1 — SPI bus guarded (complete) -- `src/hal/Display.cpp` — `lvgl_flush_cb` wrapped in `SpiBusGuard`. +- `src/hal/Display.cpp` — `lvgl_flush_cb`, panel sleep, and panel wake wrapped in + `SpiBusGuard`. - `src/radio/SX1262.cpp` — all **5** SPI primitives wrapped: `singleTransfer`, `executeOpcode`, `executeOpcodeRead`, `writeBuffer`, `readBuffer` (guard taken *after* `waitOnBusy()`, around the transaction only). - `src/storage/SDStore.cpp` — runtime methods guarded (recursive mutex handles the nesting: `writeString→writeAtomic`, `ensureDir` recursion, etc.). `begin()` is intentionally unguarded (boot, pre‑task). -- `src/ui/screens/LvSettingsScreen.cpp` — the identity‑import dir walk (the one runtime - `openDir`+iterate site) wrapped in a `SpiBusGuard` scope. +- Runtime `openDir`+iterate paths in `MessageStore`, `IdentityManager`, and Settings, + plus raw SD mirror copies in `ReticulumManager`, hold `SpiBusGuard` for the complete + `File` lifetime. ### Stage 2 — complete - `src/platform/CoreSync.*` — `NetStatus` + toast bridge added. @@ -67,29 +71,28 @@ The display flush takes `spiBusMutex` only (never `rnsMutex`), so there's no inv (`uiLoopStep()` + `networkLoopStep()`). - Network-owned status updates now publish to `CoreSync::netStatus`. - UI status tick reads `CoreSync::netStatus` and refreshes UI under `RnsTryGuard`. - - Added setup spawn after `bootComplete = true`: + - Added setup spawn after all callbacks, screens, and boot routing are initialized: `xTaskCreatePinnedToCore(networkTask, "netstack", 16384, ..., 0)`. + - Task allocation failure falls back to the single-task network loop and restores + the legacy radio-wait LVGL yield callback. + +### Stage 3 — fine-grained backend ownership (complete) +- Pure LVGL input/navigation runs without the backend lock. +- Backend actions use blocking `RnsGuard`; refresh paths use `RnsTryGuard` and keep the + previous frame when the backend is busy. +- Nodes and Contacts render from UI-owned snapshots and commit delayed actions by stable + destination hash instead of mutable vector index. +- Network-originated message/status events publish atomics; only the UI core touches + LVGL and audio. +- Deferred name-cache and known-destination persistence use independent throttle state. --- ## What REMAINS -Stage 2 implementation work is complete. Remaining work is optional Stage 3 refinement -plus on-device soak validation under sustained traffic. - -### Stage 3 (optional refinement — removes choppy‑under‑flood) - -Make input **lock‑free** by moving the backend guard out of the coarse dispatch block and -into only the handlers that touch Reticulum/LXMF, so pure‑LVGL nav/typing/scroll never -take `rnsMutex`. Sites to guard individually (wrap the backend call in `CoreSync::RnsGuard`): -- `LvMessageView` — message send (`_btnSend` CLICKED), `markRead`, open‑chat - `getRecentMessages`. -- `LvMessagesScreen` — open conversation, `markRead`. -- `LvNodesScreen` — save/delete contact. -- `LvSettingsScreen` — `_rns->announce`, radio `set*`/`receive`/`sleep` apply, TCP reload. -- Home toggle callbacks — the `userConfig.save()` calls (SD/flash writes). -Then remove the blocking `RnsGuard` from the `uiLoopStep` dispatch block. Refresh reads -stay on the try‑lock. +Build both flag values and run the on-device soak plan below. Static analysis cannot +validate RF timing, task stack headroom, display integrity, or watchdog behavior under +real sustained traffic. --- @@ -103,10 +106,9 @@ make flash PORT=/dev/tty... # or use the Ratspeak web flasher with rsdeck-merge Watch the serial `[HEART]` line — it already prints `loop=` (max loop time) and `heap=/min=`. Test sequence: -1. **Regression (flag on, before task spawn is even needed):** current tree — confirm it - boots, UI works, radio RX/TX works, SD reads/writes, no display corruption. This - validates Stage 1 (SPI guards) in isolation. -2. **After the split lands:** load the device (WiFi on + TCP hub + announce traffic). +1. **Regression (flag on):** confirm it boots, UI works, radio RX/TX works, SD + reads/writes, and no display corruption occurs. +2. Load the device (WiFi on + TCP hub + announce traffic). - UI should stay smooth (scroll/type) while `[HEART] loop=` on the **UI core** stays low. The heavy time now lives in the network task. - Confirm status bar (wifi/tcp/ble/peers) still updates — it's snapshot‑driven now. diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp index 31acfaf..1fb8828 100644 --- a/src/hal/Display.cpp +++ b/src/hal/Display.cpp @@ -73,9 +73,11 @@ void Display::setBrightness(uint8_t level) { } void Display::sleep() { + CoreSync::SpiBusGuard busGuard; _gfx.sleep(); } void Display::wakeup() { + CoreSync::SpiBusGuard busGuard; _gfx.wakeup(); } diff --git a/src/main.cpp b/src/main.cpp index 24d4008..b92a71e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -104,7 +104,7 @@ RNS::Interface wifiIface({RNS::Type::NONE}); std::vector tcpClients; std::list tcpIfaces; // Must persist — Transport stores references (list: no realloc) std::list retiredTcpClients; -bool tcpReloadRequested = false; +std::atomic tcpReloadRequested{false}; #if HAS_BLE BLEInterface bleInterface; BLESideband bleSideband; @@ -140,6 +140,10 @@ bool radioOnline = false; bool bootComplete = false; bool bootLoopRecovery = false; bool sdHadExistingData = false; +std::atomic uiScreenOn{true}; +#if RSDECK_UI_CORE_SPLIT +bool networkTaskStarted = false; +#endif bool wifiSTAStarted = false; WiFiMulti wifiMulti; bool wifiSTAConnected = false; @@ -1099,7 +1103,7 @@ void setup() { // Create cross-core mutexes before any SPI peripheral (display/radio/SD) or // the network task can touch them. No-op in single-threaded builds. - CoreSync::begin(); + bool coreSyncReady = CoreSync::begin(); // Step 1: Power pin — CRITICAL: enables all T-Deck Plus peripherals Power::enablePeripherals(); @@ -1113,6 +1117,11 @@ void setup() { Serial.println(" LilyGo T-Deck Plus"); Serial.println("================================="); + if (!coreSyncReady) { + Serial.println("[FATAL] Failed to allocate core synchronization mutexes"); + while (true) delay(1000); + } + esp_reset_reason_t reason = esp_reset_reason(); const char* reasonStr = "UNKNOWN"; switch (reason) { @@ -1361,11 +1370,11 @@ void setup() { 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(); + CoreSync::netStatus.unreadMessages.store(lxmf.unreadCount()); + CoreSync::netStatus.messageSeq.fetch_add(1); }); // Pre-cache unread counts so first tab switch to Messages is instant - lxmf.unreadCount(); + CoreSync::netStatus.unreadMessages.store(lxmf.unreadCount()); lvBootScreen.setProgress(0.75f, "LXMF ready"); // (LVGL boot renders via lv_timer_handler in setProgress) bootTraceStage("lxmf-begin"); @@ -1573,14 +1582,8 @@ void setup() { bootComplete = true; -#if RSDECK_UI_CORE_SPLIT - xTaskCreatePinnedToCore(networkTask, "netstack", 16384, nullptr, - 1 /* prio, same as loopTask */, nullptr, 0 /* core 0 */); - Serial.println("[CORE] Network task started on core 0"); -#endif - - // Keep LVGL responsive during blocking radio operations (if screen is on) - // Re-entrancy guard prevents nested lv_timer_handler() calls +#if !RSDECK_UI_CORE_SPLIT + // Keep LVGL responsive during blocking radio operations in single-core mode. radio.setYieldCallback([]() { static bool inYield = false; if (inYield) return; @@ -1590,6 +1593,7 @@ void setup() { } inYield = false; }); +#endif // Wire up LVGL screen dependencies lvHomeScreen.setReticulumManager(&rns); @@ -1604,51 +1608,67 @@ void setup() { Serial.println("[HOME] Announce triggered via Enter"); }); lvHomeScreen.setAudioToggleCallback([]() { - userConfig.settings().audioEnabled = !userConfig.settings().audioEnabled; - audio.setEnabled(userConfig.settings().audioEnabled); - CoreSync::RnsGuard backendGuard; - bool ok = userConfig.save(sdStore, flash); - ui.lvStatusBar().showToast(userConfig.settings().audioEnabled ? "Audio ON" : "Audio OFF", 1000); + bool enabled; + bool ok; + { + CoreSync::RnsGuard backendGuard; + enabled = !userConfig.settings().audioEnabled; + userConfig.settings().audioEnabled = enabled; + ok = userConfig.save(sdStore, flash); + } + audio.setEnabled(enabled); + ui.lvStatusBar().showToast(enabled ? "Audio ON" : "Audio OFF", 1000); Serial.printf("[AUDIO] Notifications %s (save %s)\n", - userConfig.settings().audioEnabled ? "ON" : "OFF", + enabled ? "ON" : "OFF", ok ? "OK" : "FAILED"); }); lvHomeScreen.setLoraToggleCallback([]() { - auto& s = userConfig.settings(); - s.loraEnabled = !s.loraEnabled; - CoreSync::RnsGuard backendGuard; - bool ok = userConfig.save(sdStore, flash); + bool enabled; + bool ok; + { + CoreSync::RnsGuard backendGuard; + auto& settings = userConfig.settings(); + settings.loraEnabled = !settings.loraEnabled; + enabled = settings.loraEnabled; + 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", + enabled ? "ON" : "OFF", ok ? "OK" : "FAILED"); }); lvHomeScreen.setTCPToggleCallback([]() { - auto& s = userConfig.settings(); bool enabled = false; - bool hasSavedTcpServer = false; - for (const auto& ep : s.tcpConnections) { - if (!ep.host.isEmpty()) hasSavedTcpServer = true; - if (!ep.host.isEmpty() && ep.autoConnect) { enabled = true; break; } - } - if (enabled) { - for (auto& ep : s.tcpConnections) ep.autoConnect = false; - } else if (hasSavedTcpServer) { - for (auto& ep : s.tcpConnections) { - if (!ep.host.isEmpty()) ep.autoConnect = true; + bool ok; + { + CoreSync::RnsGuard backendGuard; + auto& settings = userConfig.settings(); + bool hasSavedTcpServer = false; + for (const auto& endpoint : settings.tcpConnections) { + if (!endpoint.host.isEmpty()) hasSavedTcpServer = true; + if (!endpoint.host.isEmpty() && endpoint.autoConnect) { + enabled = true; + break; + } } - } else { - s.tcpConnections.clear(); - TCPEndpoint ep; - ep.host = "rns.ratspeak.org"; - ep.port = TCP_DEFAULT_PORT; - ep.autoConnect = true; - s.tcpConnections.push_back(ep); + if (enabled) { + for (auto& endpoint : settings.tcpConnections) endpoint.autoConnect = false; + } else if (hasSavedTcpServer) { + for (auto& endpoint : settings.tcpConnections) { + if (!endpoint.host.isEmpty()) endpoint.autoConnect = true; + } + } else { + settings.tcpConnections.clear(); + TCPEndpoint endpoint; + endpoint.host = "rns.ratspeak.org"; + endpoint.port = TCP_DEFAULT_PORT; + endpoint.autoConnect = true; + settings.tcpConnections.push_back(endpoint); + } + ok = userConfig.save(sdStore, flash); } - CoreSync::RnsGuard backendGuard; - bool ok = userConfig.save(sdStore, flash); ui.lvStatusBar().showToast( ok ? "TCP server saved; reboot to apply" : "Save failed", ok ? 3000 : 2000); @@ -1657,48 +1677,68 @@ void setup() { 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; + const char* validationError = nullptr; + bool ok = false; + int wifiMode = RAT_WIFI_OFF; + { + CoreSync::RnsGuard backendGuard; + auto& settings = userConfig.settings(); + if (settings.wifiMode == RAT_WIFI_OFF) { + RatWiFiMode restoreMode = settings.wifiRestoreMode == RAT_WIFI_OFF + ? RAT_WIFI_STA : settings.wifiRestoreMode; + if (restoreMode == RAT_WIFI_STA) { + size_t slot = settings.wifiSTASelected < settings.wifiSTANetworks.size() + ? settings.wifiSTASelected : 0; + if (slot >= settings.wifiSTANetworks.size() || + settings.wifiSTANetworks[slot].ssid.isEmpty()) { + validationError = "Add WiFi in Settings"; + } + } else if (restoreMode != RAT_WIFI_AP) { + validationError = "Add WiFi in Settings"; } - } else if (restoreMode != RAT_WIFI_AP) { - ui.lvStatusBar().showToast("Add WiFi in Settings", 2000); - return; + if (!validationError) settings.wifiMode = restoreMode; + } else { + settings.wifiRestoreMode = settings.wifiMode; + settings.wifiMode = RAT_WIFI_OFF; + } + if (!validationError) { + wifiMode = settings.wifiMode; + ok = userConfig.save(sdStore, flash); } - s.wifiMode = restoreMode; - } else { - s.wifiRestoreMode = s.wifiMode; - s.wifiMode = RAT_WIFI_OFF; } - CoreSync::RnsGuard backendGuard; - bool ok = userConfig.save(sdStore, flash); + if (validationError) { + ui.lvStatusBar().showToast(validationError, 2000); + return; + } 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"); + wifiMode, ok ? "OK" : "FAILED"); }); #if HAS_GPS lvHomeScreen.setGPSToggleCallback([]() { - auto& s = userConfig.settings(); - bool oldTime = s.gpsTimeEnabled; - s.gpsTimeEnabled = !s.gpsTimeEnabled; - CoreSync::RnsGuard backendGuard; - bool ok = userConfig.save(sdStore, flash); + bool enabled; + bool locationEnabled; + bool ok; + { + CoreSync::RnsGuard backendGuard; + auto& settings = userConfig.settings(); + bool oldTime = settings.gpsTimeEnabled; + settings.gpsTimeEnabled = !settings.gpsTimeEnabled; + ok = userConfig.save(sdStore, flash); + if (!ok) settings.gpsTimeEnabled = oldTime; + enabled = settings.gpsTimeEnabled; + locationEnabled = settings.gpsLocationEnabled; + } if (!ok) { - s.gpsTimeEnabled = oldTime; ui.lvStatusBar().showToast("Save failed", 2000); Serial.println("[GPS] Toggle save failed"); return; } - if (s.gpsTimeEnabled) { + if (enabled) { gps.setPosixTZ(currentPosixTZ()); - gps.setLocationEnabled(s.gpsLocationEnabled); + gps.setLocationEnabled(locationEnabled); gps.begin(); ui.lvStatusBar().showToast("GPS ON", 1000); Serial.println("[GPS] Enabled via Home"); @@ -1793,10 +1833,14 @@ void setup() { auto showQr = []() { // Encode `lxma://:` so Columba/Sideband // scanners get a full identity (no PENDING_IDENTITY round-trip). - String destHex = rns.destinationHashHex(); + String destHex; String pubHex; - if (auto identity = rns.destination().identity()) { - pubHex = String(identity.get_public_key().toHex().c_str()); + { + CoreSync::RnsGuard backendGuard; + destHex = rns.destinationHashHex(); + if (auto identity = rns.destination().identity()) { + pubHex = String(identity.get_public_key().toHex().c_str()); + } } lvQrOverlay.show(destHex, pubHex); }; @@ -1824,16 +1868,22 @@ void setup() { if (wipe) { Serial.println("[BOOT] User chose to wipe old data"); lvDataCleanScreen.showStatus("Clearing old data..."); - sdStore.wipeRsDeck(); - if (announceManager) announceManager->clearAll(); + { + CoreSync::RnsGuard backendGuard; + 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); + { + CoreSync::RnsGuard backendGuard; + userConfig.settings().sdStorageEnabled = true; + userConfig.save(sdStore, flash); + } lvDataCleanScreen.showStatus("SD storage enabled. Rebooting..."); delay(1500); ESP.restart(); @@ -1846,9 +1896,12 @@ void setup() { ui.setBootMode(false); ui.setScreen(&lvHomeScreen); ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); - bootAnnouncePending = true; - bootAnnounceAttempts = 0; - bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS; + { + CoreSync::RnsGuard backendGuard; + bootAnnouncePending = true; + bootAnnounceAttempts = 0; + bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS; + } Serial.println("[BOOT] Home ready; startup announce scheduled"); }; @@ -1865,9 +1918,17 @@ void setup() { // 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); + bool saved; + bool gpsTimeEnabled; + uint8_t radioRegion; + { + CoreSync::RnsGuard backendGuard; + userConfig.settings().timezoneIdx = (uint8_t)tzIdx; + userConfig.settings().timezoneSet = true; + saved = userConfig.save(sdStore, flash); + gpsTimeEnabled = userConfig.settings().gpsTimeEnabled; + radioRegion = userConfig.settings().radioRegion; + } if (!saved) { Serial.println("[BOOT] Timezone save failed; staying in setup"); ui.lvStatusBar().showToast("Save failed; storage unavailable", 3000); @@ -1880,18 +1941,18 @@ void setup() { setenv("TZ", tz, 1); tzset(); #if HAS_GPS - if (userConfig.settings().gpsTimeEnabled) { + if (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) { + if (tzRegion != 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]); + REGION_LABELS[tzRegion], REGION_LABELS[radioRegion]); } goHome(); }); @@ -1899,22 +1960,25 @@ void setup() { // 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); + bool saved; + { + CoreSync::RnsGuard backendGuard; + 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; + saved = userConfig.save(sdStore, flash); + if (saved && identityMgr.activeIndex() >= 0) { + identityMgr.setDisplayName(identityMgr.activeIndex(), finalName); + } } - 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) @@ -1961,13 +2025,31 @@ void setup() { flash.isReady() ? "OK" : "FAIL", sdStore.isReady() ? "OK" : "FAIL"); bootTraceStage("setup-complete"); + +#if RSDECK_UI_CORE_SPLIT + networkTaskStarted = xTaskCreatePinnedToCore( + networkTask, "netstack", 16384, nullptr, + 1 /* prio, same as loopTask */, nullptr, 0 /* core 0 */) == pdPASS; + if (!networkTaskStarted) { + radio.setYieldCallback([]() { + static bool inYield = false; + if (inYield) return; + inYield = true; + if (powerMgr.isScreenOn()) lv_timer_handler(); + inYield = false; + }); + } + Serial.println(networkTaskStarted + ? "[CORE] Network task started on core 0" + : "[CORE] Network task allocation failed; using loopTask fallback"); +#endif } // ============================================================================= // Main Loop // ============================================================================= -static void networkLoopStep() { +static void networkLoopStep(bool serviceLvgl) { // Reticulum loop (radio RX via LoRaInterface) — throttle to ~100Hz unsigned long rnsDuration = 0; { @@ -1981,6 +2063,10 @@ static void networkLoopStep() { } } + if (serviceLvgl && rnsDuration > LVGL_INTERVAL_MS && powerMgr.isScreenOn()) { + lv_timer_handler(); + } + if (bootComplete && bootAnnouncePending && (long)(millis() - bootAnnounceAt) >= 0) { bootAnnounceAttempts++; if (announceWithName(true)) { @@ -2008,7 +2094,7 @@ static void networkLoopStep() { rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) { Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%"); } else { - announceWithName(!powerMgr.isScreenOn()); + announceWithName(!uiScreenOn.load()); Serial.println("[AUTO] Periodic announce"); } } @@ -2115,8 +2201,7 @@ static void networkLoopStep() { // Deferred TCP reload from Settings. Avoid tearing down/recreating // Transport interfaces inside the LVGL key event path. - if (tcpReloadRequested) { - tcpReloadRequested = false; + if (tcpReloadRequested.exchange(false)) { Serial.println("[TCP] Applying deferred settings reload..."); reloadTCPClients(); if (announceManager) announceManager->clearTransientNodes(); @@ -2146,6 +2231,14 @@ static void networkLoopStep() { } } } + if (!anyTcpUp) { + for (auto* tcp : tcpClients) { + if (tcp && tcp->isConnected()) { + anyTcpUp = true; + break; + } + } + } // 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 @@ -2177,7 +2270,8 @@ static void networkLoopStep() { } // Publish network snapshot for the UI core. - CoreSync::netStatus.loraOnline.store(radioOnline); + CoreSync::netStatus.loraOnline.store( + radioOnline && userConfig.settings().loraEnabled); CoreSync::netStatus.wifiEnabled.store(userConfig.settings().wifiMode != RAT_WIFI_OFF); CoreSync::netStatus.bleEnabled.store(userConfig.settings().bleEnabled); CoreSync::netStatus.wifiActive.store(wifiSTAConnected || (wifiImpl && wifiImpl->isAPActive())); @@ -2227,16 +2321,6 @@ static void networkLoopStep() { (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; } } @@ -2322,9 +2406,20 @@ static void uiLoopStep() { #if HAS_GPS if (userConfig.settings().gpsTimeEnabled) { gps.loop(); + static unsigned long lastGpsDiag = 0; + if (millis() - lastGpsDiag >= HEARTBEAT_INTERVAL_MS) { + lastGpsDiag = millis(); + 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 powerMgr.loop(); + uiScreenOn.store(powerMgr.isScreenOn()); // Periodic status bar update (1 Hz). if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) { @@ -2362,6 +2457,18 @@ static void uiLoopStep() { ui.lvStatusBar().flashAnnounce(); } + static bool messageStatusInitialized = false; + static uint32_t lastMessageSeqSeen = 0; + uint32_t messageSeq = CoreSync::netStatus.messageSeq.load(); + if (!messageStatusInitialized || messageSeq != lastMessageSeqSeen) { + bool receivedNewMessage = messageSeq != lastMessageSeqSeen; + messageStatusInitialized = true; + lastMessageSeqSeen = messageSeq; + ui.lvTabBar().setUnreadCount( + LvTabBar::TAB_MSGS, CoreSync::netStatus.unreadMessages.load()); + if (receivedNewMessage) audio.requestMessage(); + } + char toastMsg[64] = {0}; uint32_t toastDuration = CoreSync::takePendingToast(toastMsg, sizeof(toastMsg)); if (toastDuration > 0 && toastMsg[0] != '\0') { @@ -2374,7 +2481,7 @@ static void networkTask(void*) { for (;;) { { CoreSync::RnsGuard backendGuard; - networkLoopStep(); + networkLoopStep(false); } vTaskDelay(1); } @@ -2384,9 +2491,13 @@ static void networkTask(void*) { void loop() { #if RSDECK_UI_CORE_SPLIT uiLoopStep(); + if (!networkTaskStarted) { + CoreSync::RnsGuard backendGuard; + networkLoopStep(true); + } #else uiLoopStep(); - networkLoopStep(); + networkLoopStep(true); #endif yield(); } diff --git a/src/platform/CoreSync.cpp b/src/platform/CoreSync.cpp index e43a388..c25bcc6 100644 --- a/src/platform/CoreSync.cpp +++ b/src/platform/CoreSync.cpp @@ -23,26 +23,27 @@ void requestToast(const char* msg, uint32_t durationMs) { #endif strlcpy(s_toastMsg, msg, sizeof(s_toastMsg)); s_toastDur = durationMs; + netStatus.toastSeq.fetch_add(1); #if RSDECK_UI_CORE_SPLIT portEXIT_CRITICAL(&s_toastMux); #endif - netStatus.toastSeq.fetch_add(1); } uint32_t takePendingToast(char* out, uint32_t outSize) { - uint32_t seq = netStatus.toastSeq.load(); - if (seq == s_lastToastSeen) return 0; - s_lastToastSeen = seq; - uint32_t dur; + uint32_t dur = 0; #if RSDECK_UI_CORE_SPLIT portENTER_CRITICAL(&s_toastMux); #endif - strlcpy(out, s_toastMsg, outSize); - dur = s_toastDur; + uint32_t seq = netStatus.toastSeq.load(); + if (seq != s_lastToastSeen) { + s_lastToastSeen = seq; + strlcpy(out, s_toastMsg, outSize); + dur = s_toastDur == 0 ? 1 : s_toastDur; + } #if RSDECK_UI_CORE_SPLIT portEXIT_CRITICAL(&s_toastMux); #endif - return dur == 0 ? 1 : dur; // never return 0 for a real toast + return dur; } #if RSDECK_UI_CORE_SPLIT @@ -50,14 +51,15 @@ uint32_t takePendingToast(char* out, uint32_t outSize) { SemaphoreHandle_t spiBusMutex = nullptr; SemaphoreHandle_t rnsMutex = nullptr; -void begin() { +bool begin() { if (!spiBusMutex) spiBusMutex = xSemaphoreCreateRecursiveMutex(); if (!rnsMutex) rnsMutex = xSemaphoreCreateRecursiveMutex(); + return spiBusMutex && rnsMutex; } #else -void begin() {} +bool begin() { return true; } #endif diff --git a/src/platform/CoreSync.h b/src/platform/CoreSync.h index 7fddbcf..f34ab43 100644 --- a/src/platform/CoreSync.h +++ b/src/platform/CoreSync.h @@ -35,7 +35,7 @@ namespace CoreSync { // Create the mutexes. Call once, early in setup(), before Display/radio init. -void begin(); +bool begin(); #if RSDECK_UI_CORE_SPLIT @@ -115,7 +115,9 @@ struct NetStatus { std::atomic tcpConnected{false}; std::atomic gpsFix{false}; std::atomic autoIfacePeers{-1}; // -1 == AutoInterface offline + std::atomic unreadMessages{0}; std::atomic announceSeq{0}; // bump on each announce TX -> UI flashes + std::atomic messageSeq{0}; // bump on each received message std::atomic toastSeq{0}; // bump to raise a deferred toast }; diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index eae76d7..87eddb2 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -153,7 +153,6 @@ void AnnounceManager::received_announce( auto& node = _nodes[it->second]; if (node.lastSeen != 0 && now >= node.lastSeen && now - node.lastSeen < ANNOUNCE_MIN_INTERVAL_MS) return; - bool identityChanged = !idHex.empty() && node.identityHex != idHex; // Saved contacts own their local alias. Incoming announce app_data is // still cached below, but must not reset the user-chosen contact name. if (!name.empty() && !node.saved) node.name = name; @@ -260,8 +259,9 @@ void AnnounceManager::loop() { saveContacts(); Serial.println("[ANNOUNCE] Deferred contact save complete"); } - if (_nameCacheDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) { + if (_nameCacheDirty && now - _lastNameCacheSave >= CONTACT_SAVE_INTERVAL_MS) { _nameCacheDirty = false; + _lastNameCacheSave = now; saveNameCache(); } diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index 834d95a..c593727 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -71,6 +71,7 @@ class AnnounceManager : public RNS::AnnounceHandler { bool _nameCacheDirty = false; bool _knownDestinationsDirty = false; unsigned long _lastContactSave = 0; + unsigned long _lastNameCacheSave = 0; unsigned long _lastAnnounceProcessed = 0; std::map _nameCache; // hexHash → displayName unsigned long _globalAnnounceWindowStart = 0; diff --git a/src/reticulum/IdentityManager.cpp b/src/reticulum/IdentityManager.cpp index 7a6ff53..8068c09 100644 --- a/src/reticulum/IdentityManager.cpp +++ b/src/reticulum/IdentityManager.cpp @@ -1,5 +1,6 @@ #include "IdentityManager.h" #include "config/Config.h" +#include "platform/CoreSync.h" #include #include #include @@ -66,6 +67,7 @@ String IdentityManager::slotKeyPath(int slotNum) const { String IdentityManager::importIdentityPath() const { if (!_sd || !_sd->isReady()) return ""; + CoreSync::SpiBusGuard busGuard; if (_sd->exists(SD_PATH_IMPORT_IDENTITY)) return String(SD_PATH_IMPORT_IDENTITY); if (_sd->exists(SD_PATH_IMPORT_ID)) return String(SD_PATH_IMPORT_ID); diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 22fb5b2..bbafe29 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -1,6 +1,7 @@ // Direct port from Ratputer — microReticulum integration #include "ReticulumManager.h" #include "config/Config.h" +#include "platform/CoreSync.h" #include "util/PerfTrace.h" #include #include @@ -50,6 +51,7 @@ namespace { constexpr size_t RNS_COPY_CHUNK = 512; bool copySDToFlash(const char* sdPath, const char* flashPath) { + CoreSync::SpiBusGuard busGuard; File in = SD.open(sdPath, FILE_READ); if (!in) return false; @@ -79,6 +81,7 @@ bool copySDToFlash(const char* sdPath, const char* flashPath) { bool copyFlashToSD(SDStore* sd, const char* flashPath, const char* sdPath) { if (!sd || !sd->isReady()) return false; + CoreSync::SpiBusGuard busGuard; File in = LittleFS.open(flashPath, "r"); if (!in || in.size() == 0) { if (in) in.close(); return false; } unsigned long startMs = PerfTrace::nowMs(); @@ -411,9 +414,9 @@ void ReticulumManager::loop() { } // Synchronous persist — one cycle per call to spread I/O across intervals. -// Runs on core 1 (main loop) to avoid data races with microReticulum's -// single-threaded transport state. rsDeck is an endpoint, not a transport -// node, so path tables are intentionally not persisted across boots. +// Runs inside the network task's RNS guard so microReticulum's single-threaded +// transport state cannot be accessed concurrently. rsDeck is an endpoint, not +// a transport node, so path tables are intentionally not persisted across boots. void ReticulumManager::persistData() { unsigned long start = millis(); unsigned long now = millis(); diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp index ca518ed..91dd52a 100644 --- a/src/storage/MessageStore.cpp +++ b/src/storage/MessageStore.cpp @@ -1,5 +1,6 @@ #include "MessageStore.h" #include "config/Config.h" +#include "platform/CoreSync.h" #include "util/PerfTrace.h" #include #include @@ -492,6 +493,7 @@ std::vector MessageStore::loadConversationTail(const std::string& p bool loadedFromSD = false; if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -573,6 +575,7 @@ std::vector MessageStore::loadRecentMessageIds(size_t maxIds) const int MessageStore::messageCount(const std::string& peerHex) const { if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -599,6 +602,7 @@ int MessageStore::messageCount(const std::string& peerHex) const { bool MessageStore::deleteConversation(const std::string& peerHex) { if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -704,6 +708,7 @@ void MessageStore::markConversationRead(const std::string& peerHex) { }; if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); markInDir([&](const char* p) { return _sd->openDir(p); }, [&](const char* p, const String& d) { _sd->writeString(p, d); return true; }, @@ -791,6 +796,7 @@ bool MessageStore::updateMessageStatus(const std::string& peerHex, double timest bool updated = false; if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); updated = updateInDir( [&](const char* p) { return _sd->openDir(p); }, @@ -882,6 +888,7 @@ ConversationSummary MessageStore::buildSummaryForPeer( bool loadedFromSD = false; if (_externalStorageEnabled && _sd && _sd->isReady()) { + CoreSync::SpiBusGuard busGuard; String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -1125,6 +1132,7 @@ void MessageStore::enforceFlashLimit(const std::string& peerHex) { void MessageStore::enforceSDLimit(const std::string& peerHex) { if (!_externalStorageEnabled || !_sd || !_sd->isReady()) return; + CoreSync::SpiBusGuard busGuard; String dir = sdConversationDir(peerHex); std::vector files; File d = _sd->openDir(dir.c_str()); diff --git a/src/ui/screens/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp index fa4724c..139e204 100644 --- a/src/ui/screens/LvContactsScreen.cpp +++ b/src/ui/screens/LvContactsScreen.cpp @@ -4,6 +4,7 @@ #include "ui/LvInput.h" #include "ui/LxmFaceAvatar.h" #include "ui/UIManager.h" +#include "platform/CoreSync.h" #include "reticulum/AnnounceManager.h" #include #include @@ -16,6 +17,13 @@ constexpr int kContactRowH = 38; constexpr int kContactAvatar = 32; constexpr int kContactTextX = 48; +int findNodeIndexByHex(const std::vector& nodes, const std::string& hex) { + for (int i = 0; i < (int)nodes.size(); i++) { + if (nodes[i].hash.toHex() == hex) return i; + } + return -1; +} + unsigned long nodeAgeMs(const DiscoveredNode& node, unsigned long now) { if (node.lastSeen == 0 || now < node.lastSeen) return ULONG_MAX; return now - node.lastSeen; @@ -128,7 +136,13 @@ void LvContactsScreen::refreshUI() { if (!_am) return; unsigned long now = millis(); int contacts = 0; - for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + for (const auto& node : _am->nodes()) { + if (node.saved) contacts++; + } + } if (contacts != _lastContactCount || now - _lastRebuild >= REBUILD_INTERVAL_MS) { rebuildList(); } @@ -136,13 +150,22 @@ void LvContactsScreen::refreshUI() { void LvContactsScreen::rebuildList() { if (!_am || !_list) return; + + std::vector snapshot; + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + snapshot = _am->nodes(); + } + _nodesSnapshot.swap(snapshot); + _lastRebuild = millis(); _contactIndices.clear(); lv_obj_clean(_list); _avatarBuffers.clear(); - const auto& nodes = _am->nodes(); + const auto& nodes = _nodesSnapshot; for (int i = 0; i < (int)nodes.size(); i++) { if (nodes[i].saved) _contactIndices.push_back(i); } @@ -228,9 +251,11 @@ void LvContactsScreen::rebuildList() { lv_obj_add_event_cb(row, [](lv_event_t* e) { auto* self = (LvContactsScreen*)lv_event_get_user_data(e); int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); - if (idx < (int)self->_contactIndices.size() && self->_onSelect) { + if (idx >= 0 && idx < (int)self->_contactIndices.size() && self->_onSelect) { int nodeIdx = self->_contactIndices[idx]; - self->_onSelect(self->_am->nodes()[nodeIdx].hash.toHex()); + if (nodeIdx >= 0 && nodeIdx < (int)self->_nodesSnapshot.size()) { + self->_onSelect(self->_nodesSnapshot[nodeIdx].hash.toHex()); + } } }, LV_EVENT_CLICKED, this); @@ -289,8 +314,11 @@ bool LvContactsScreen::handleLongPress() { if (!_focusActive) return false; lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (!focused) return false; - _deleteIdx = (int)(intptr_t)lv_obj_get_user_data(focused); - if (_deleteIdx < 0 || _deleteIdx >= (int)_contactIndices.size()) return false; + int deleteIdx = (int)(intptr_t)lv_obj_get_user_data(focused); + if (deleteIdx < 0 || deleteIdx >= (int)_contactIndices.size()) return false; + int nodeIdx = _contactIndices[deleteIdx]; + if (nodeIdx < 0 || nodeIdx >= (int)_nodesSnapshot.size()) return false; + _deleteNodeHex = _nodesSnapshot[nodeIdx].hash.toHex(); _confirmDelete = true; if (_ui) _ui->lvStatusBar().showToast("Remove? Enter=Remove Esc=Keep", 5000); return true; @@ -308,18 +336,20 @@ bool LvContactsScreen::handleKey(const KeyEvent& event) { if (_confirmDelete) { if (event.enter || event.character == '\n' || event.character == '\r') { - if (_deleteIdx >= 0 && _deleteIdx < (int)_contactIndices.size()) { - int nodeIdx = _contactIndices[_deleteIdx]; - if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { - _am->deleteContact(nodeIdx); - if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); - rebuildList(); - } + bool removed = false; + { + CoreSync::RnsGuard backendGuard; + int nodeIdx = findNodeIndexByHex(_am->nodes(), _deleteNodeHex); + removed = nodeIdx >= 0 && _am->deleteContact(nodeIdx); } + if (removed && _ui) _ui->lvStatusBar().showToast("Contact removed", 1200); _confirmDelete = false; + _deleteNodeHex.clear(); + if (removed) rebuildList(); return true; } _confirmDelete = false; + _deleteNodeHex.clear(); if (_ui) _ui->lvStatusBar().showToast("Kept contact", 800); return true; } diff --git a/src/ui/screens/LvContactsScreen.h b/src/ui/screens/LvContactsScreen.h index 1f0c305..4299a3f 100644 --- a/src/ui/screens/LvContactsScreen.h +++ b/src/ui/screens/LvContactsScreen.h @@ -1,12 +1,11 @@ #pragma once #include "ui/UIManager.h" +#include "reticulum/AnnounceManager.h" #include #include #include -class AnnounceManager; - class LvContactsScreen : public LvScreen { public: using NodeSelectedCallback = std::function; @@ -33,10 +32,11 @@ class LvContactsScreen : public LvScreen { std::function _showQrCb; bool _confirmDelete = false; bool _focusActive = false; - int _deleteIdx = -1; + std::string _deleteNodeHex; int _lastContactCount = -1; unsigned long _lastRebuild = 0; static constexpr unsigned long REBUILD_INTERVAL_MS = 30000; + std::vector _nodesSnapshot; std::vector _contactIndices; std::vector> _avatarBuffers; diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index 3442f24..dfd14c7 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -3,6 +3,7 @@ #include "ui/LvTheme.h" #include "ui/LvInput.h" #include "ui/LxmFaceAvatar.h" +#include "platform/CoreSync.h" #include "reticulum/ReticulumManager.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" @@ -274,6 +275,9 @@ void LvHomeScreen::onEnter() { void LvHomeScreen::refreshUI() { if (!_lblName) return; + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + unsigned long now = millis(); bool force = (_lastUptime == ULONG_MAX || _lastHeap == UINT32_MAX); if (!force && (now - _lastRefreshMs) < 1000) return; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index f18b73d..795f931 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -334,23 +334,15 @@ void LvMessageView::onEnter() { unsigned long rebuildMs = 0; if (_lxmf) { _markReadPending = true; - // Register status callback - partial update without full rebuild + _statusRefreshPending.store(false); + // The callback runs on the network core; defer all cache/LVGL work to + // refreshUI() on the UI core. std::string peer = _peerHex; unsigned long phaseMs = millis(); { CoreSync::RnsGuard backendGuard; - _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double ts, uint32_t savedCounter, LXMFStatus newStatus) { - if (peerHex != peer) return; - for (int i = (int)_cachedMsgs.size() - 1; i >= 0; i--) { - bool sameMessage = savedCounter > 0 - ? _cachedMsgs[i].savedCounter == savedCounter - : std::fabs(_cachedMsgs[i].timestamp - ts) < 1.0; - if (!_cachedMsgs[i].incoming && sameMessage) { - _cachedMsgs[i].status = newStatus; - updateMessageStatus(i, newStatus); - return; - } - } + _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, uint32_t, LXMFStatus) { + if (peerHex == peer) _statusRefreshPending.store(true); }); } callbackMs = millis() - phaseMs; @@ -389,6 +381,7 @@ void LvMessageView::onExit() { _lxmf->setStatusCallback(nullptr); } _markReadPending = false; + _statusRefreshPending.store(false); hideSendModeMenu(); _inputText.clear(); _cachedMsgs.clear(); @@ -408,14 +401,15 @@ void LvMessageView::refreshUI() { // Only reload from disk when message count changes (new messages arrive) CoreSync::RnsTryGuard backendGuard(0); if (!backendGuard.held()) return; + bool statusChanged = _statusRefreshPending.exchange(false); auto* summary = _lxmf->getConversationSummary(_peerHex); int totalCount = summary ? summary->totalCount : -1; - if (summary && totalCount == _knownTotalCount) return; + if (!statusChanged && summary && totalCount == _knownTotalCount) return; auto newMsgs = _lxmf->getRecentMessages(_peerHex, CHAT_VIEW_MAX_MESSAGES); int newKnownTotal = summary ? totalCount : (int)newMsgs.size(); - if (newKnownTotal != _knownTotalCount || newMsgs.size() != _cachedMsgs.size()) { - bool canAppend = !_cachedMsgs.empty() && + if (statusChanged || newKnownTotal != _knownTotalCount || newMsgs.size() != _cachedMsgs.size()) { + bool canAppend = !statusChanged && !_cachedMsgs.empty() && _cachedMsgs.size() < CHAT_VIEW_MAX_MESSAGES && newMsgs.size() > _cachedMsgs.size(); if (canAppend) { diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h index 1abb029..ff98c3b 100644 --- a/src/ui/screens/LvMessageView.h +++ b/src/ui/screens/LvMessageView.h @@ -2,6 +2,7 @@ #include "ui/UIManager.h" #include "reticulum/LXMFMessage.h" +#include #include #include #include @@ -55,6 +56,7 @@ class LvMessageView : public LvScreen { unsigned long _lastRefreshMs = 0; std::vector _cachedMsgs; bool _markReadPending = false; + std::atomic _statusRefreshPending{false}; void updateMessageStatus(int msgIdx, LXMFStatus status); static void applyStatusGlyph(lv_obj_t* lbl, LXMFStatus status); diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index 15a2b0b..c976822 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -164,28 +164,72 @@ void LvMessagesScreen::onExit() { void LvMessagesScreen::refreshUI() { if (!_lxmf) return; if (_lpState != LP_NONE) return; - int count = (int)_lxmf->conversations().size(); - int unread = _lxmf->unreadCount(); - int queued = _lxmf->queuedCount(); - uint32_t revision = _lxmf->storeRevision(); - if (count != _lastConvCount || unread != _lastUnreadTotal || - queued != _lastQueuedCount || revision != _lastStoreRevision) { - rebuildList(); + bool changed; + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + int count = (int)_lxmf->conversations().size(); + int unread = _lxmf->unreadCount(); + int queued = _lxmf->queuedCount(); + uint32_t revision = _lxmf->storeRevision(); + changed = count != _lastConvCount || unread != _lastUnreadTotal || + queued != _lastQueuedCount || revision != _lastStoreRevision; } + if (changed) rebuildList(); } void LvMessagesScreen::rebuildList() { if (!_lxmf || !_list) return; unsigned long startMs = millis(); + int count = 0; + + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + + const auto& convs = _lxmf->conversations(); + count = (int)convs.size(); + _lastConvCount = count; + _lastUnreadTotal = _lxmf->unreadCount(); + _lastQueuedCount = _lxmf->queuedCount(); + _lastStoreRevision = _lxmf->storeRevision(); + _sortedPeers.clear(); + _sortedConvs.clear(); + _sortedConvs.reserve(count); + + for (int i = 0; i < count; i++) { + ConvInfo ci; + ci.peerHex = convs[i]; + auto* summary = _lxmf->getConversationSummary(ci.peerHex); + if (summary) { + ci.lastTs = summary->lastTimestamp; + ci.preview = chatPreviewText(summary->lastPreview, 56); + ci.lastIncoming = summary->lastIncoming; + ci.unreadCount = summary->unreadCount; + ci.totalCount = summary->totalCount; + ci.hasUnread = ci.unreadCount > 0; + ci.hasOutgoing = summary->hasOutgoing; + ci.hasPending = summary->hasPending; + ci.hasFailed = summary->hasFailed; + ci.lastOutgoingStatus = summary->lastOutgoingStatus; + } + ci.displayName = displayNameForPeer(_am, ci.peerHex); + + if (_am) { + const DiscoveredNode* node = _am->findNodeByHex(ci.peerHex); + if (node) { + ci.knownNode = true; + ci.savedNode = node->saved; + ci.rssi = node->rssi; + ci.snr = node->snr; + ci.hops = node->hops; + ci.lastSeen = node->lastSeen; + } + } - const auto& convs = _lxmf->conversations(); - int count = (int)convs.size(); - _lastConvCount = count; - _lastUnreadTotal = _lxmf->unreadCount(); - _lastQueuedCount = _lxmf->queuedCount(); - _lastStoreRevision = _lxmf->storeRevision(); - _sortedPeers.clear(); - _sortedConvs.clear(); + _sortedConvs.push_back(ci); + } + } lv_obj_clean(_list); _avatarBuffers.clear(); @@ -199,41 +243,7 @@ void LvMessagesScreen::rebuildList() { lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); - // Build sorted conversation info - _sortedConvs.reserve(count); _avatarBuffers.reserve(count); - for (int i = 0; i < count; i++) { - ConvInfo ci; - ci.peerHex = convs[i]; - auto* s = _lxmf->getConversationSummary(ci.peerHex); - if (s) { - ci.lastTs = s->lastTimestamp; - ci.preview = chatPreviewText(s->lastPreview, 56); - ci.lastIncoming = s->lastIncoming; - ci.unreadCount = s->unreadCount; - ci.totalCount = s->totalCount; - ci.hasUnread = ci.unreadCount > 0; - ci.hasOutgoing = s->hasOutgoing; - ci.hasPending = s->hasPending; - ci.hasFailed = s->hasFailed; - ci.lastOutgoingStatus = s->lastOutgoingStatus; - } - ci.displayName = displayNameForPeer(_am, ci.peerHex); - - if (_am) { - const DiscoveredNode* node = _am->findNodeByHex(ci.peerHex); - if (node) { - ci.knownNode = true; - ci.savedNode = node->saved; - ci.rssi = node->rssi; - ci.snr = node->snr; - ci.hops = node->hops; - ci.lastSeen = node->lastSeen; - } - } - - _sortedConvs.push_back(ci); - } std::sort(_sortedConvs.begin(), _sortedConvs.end(), [](const ConvInfo& a, const ConvInfo& b) { return a.lastTs > b.lastTs; @@ -490,31 +500,44 @@ void LvMessagesScreen::showDeleteConfirm() { void LvMessagesScreen::addFocusedPeerToContacts() { if (!_am || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; - CoreSync::RnsGuard backendGuard; const auto& peerHex = _sortedPeers[_lpPeerIdx]; - const DiscoveredNode* existing = _am->findNodeByHex(peerHex); - if (existing && !existing->saved) { - auto& node = const_cast(*existing); - node.saved = true; - _am->saveContacts(); - if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200); - } else if (!existing) { - _am->addManualContact(peerHex, ""); + bool added = false; + bool alreadySaved = false; + { + CoreSync::RnsGuard backendGuard; + const DiscoveredNode* existing = _am->findNodeByHex(peerHex); + if (existing && !existing->saved) { + auto& node = const_cast(*existing); + node.saved = true; + _am->saveContacts(); + added = true; + } else if (!existing) { + _am->addManualContact(peerHex, ""); + added = true; + } else { + alreadySaved = true; + } + } + if (added) { if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200); - } else if (_ui) { + } else if (alreadySaved && _ui) { _ui->lvStatusBar().showToast("Already a friend", 1200); } } void LvMessagesScreen::deleteFocusedConversation() { if (!_lxmf || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; - CoreSync::RnsGuard backendGuard; const auto& peerHex = _sortedPeers[_lpPeerIdx]; - _lxmf->markRead(peerHex); - _lxmf->deleteConversation(peerHex); + int unread = 0; + { + CoreSync::RnsGuard backendGuard; + _lxmf->markRead(peerHex); + _lxmf->deleteConversation(peerHex); + unread = _lxmf->unreadCount(); + } if (_ui) { _ui->lvStatusBar().showToast("Chat deleted", 1200); - _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); + _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, unread); } _lastConvCount = -1; rebuildList(); diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index 49df2ae..c070272 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -15,6 +15,16 @@ namespace { constexpr int kPeerRowH = 36; constexpr int kPeerHeaderH = 20; +constexpr int kFilterBarH = 22; +constexpr int kFilterMaxLen = 24; + +int findNodeIndexByHex(const std::vector& nodes, const std::string& hex) { + for (int i = 0; i < (int)nodes.size(); i++) { + if (nodes[i].hash.toHex() == hex) return i; + } + return -1; +} + unsigned long nodeAgeMs(const DiscoveredNode& node, unsigned long now) { if (node.lastSeen == 0 || now < node.lastSeen) return ULONG_MAX; return now - node.lastSeen; @@ -59,7 +69,7 @@ std::string peerMetaFor(const DiscoveredNode& node, unsigned long ageMs, bool de return meta; } -lv_obj_t* createEmptyState(lv_obj_t* parent) { +lv_obj_t* createEmptyState(lv_obj_t* parent, lv_obj_t** outTitle, lv_obj_t** outHint) { lv_obj_t* box = lv_obj_create(parent); lv_obj_set_size(box, 252, 94); lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0); @@ -92,6 +102,8 @@ lv_obj_t* createEmptyState(lv_obj_t* parent) { lv_label_set_text(hint, "Listening for announces"); lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 55); + if (outTitle) *outTitle = title; + if (outHint) *outHint = hint; return box; } @@ -102,10 +114,48 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); lv_obj_set_style_pad_all(parent, 0, 0); - _emptyState = createEmptyState(parent); + _emptyState = createEmptyState(parent, &_emptyTitle, &_emptyHint); + + // --- Filter input: fixed above the list so it stays visible while scrolling. + // Kept outside _list because rebuildList() cleans that container on every + // keystroke; as the first object added to the focus group it is also the + // natural "up from the top row" target. + _filterBar = lv_obj_create(parent); + lv_obj_set_size(_filterBar, Theme::CONTENT_W, kFilterBarH); + lv_obj_set_pos(_filterBar, 0, 0); + lv_obj_add_style(_filterBar, LvTheme::styleTextarea(), 0); + lv_obj_add_style(_filterBar, LvTheme::styleTextareaFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_radius(_filterBar, 0, 0); + lv_obj_set_style_pad_all(_filterBar, 0, 0); + lv_obj_set_style_border_side(_filterBar, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_width(_filterBar, 1, 0); + lv_obj_clear_flag(_filterBar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_filterBar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_user_data(_filterBar, (void*)(intptr_t)-1); + + _filterLbl = lv_label_create(_filterBar); + lv_obj_set_style_text_font(_filterLbl, &lv_font_rsdeck_12, 0); + lv_label_set_long_mode(_filterLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(_filterLbl, Theme::CONTENT_W - 16); + lv_obj_set_pos(_filterLbl, 8, 3); + + lv_group_add_obj(LvInput::group(), _filterBar); + lv_obj_add_event_cb(_filterBar, [](lv_event_t* e) { + auto* self = (LvNodesScreen*)lv_event_get_user_data(e); + self->_focusActive = true; + lv_group_focus_obj(lv_event_get_target(e)); + }, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(_filterBar, [](lv_event_t* e) { + ((LvNodesScreen*)lv_event_get_user_data(e))->updateFilterDisplay(); + }, LV_EVENT_FOCUSED, this); + lv_obj_add_event_cb(_filterBar, [](lv_event_t* e) { + ((LvNodesScreen*)lv_event_get_user_data(e))->updateFilterDisplay(); + }, LV_EVENT_DEFOCUSED, this); + updateFilterDisplay(); _list = lv_obj_create(parent); - lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); + lv_obj_set_size(_list, lv_pct(100), Theme::CONTENT_H - kFilterBarH); + lv_obj_set_pos(_list, 0, kFilterBarH); lv_obj_add_style(_list, LvTheme::styleList(), 0); lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); @@ -212,6 +262,9 @@ void LvNodesScreen::destroyUI() { _overlayTitle = nullptr; _overlayMeta = nullptr; _overlayReach = nullptr; _nicknameBox = nullptr; _nicknameLbl = nullptr; _nicknameHint = nullptr; _list = nullptr; _emptyState = nullptr; + _emptyTitle = nullptr; _emptyHint = nullptr; + _filterBar = nullptr; _filterLbl = nullptr; + _nodesSnapshot.clear(); LvScreen::destroyUI(); } @@ -220,17 +273,60 @@ void LvNodesScreen::onEnter() { _lastContactCount = -1; _focusActive = false; _confirmDelete = false; + // Start unfiltered: this is a live discovery view, and a filter carried over + // from a previous visit would silently hide newly announced peers. + _filterText = ""; + updateFilterDisplay(); hideOverlay(); rebuildList(); } +bool LvNodesScreen::isFilterFocused() const { + return _filterBar && lv_group_get_focused(LvInput::group()) == _filterBar; +} + +void LvNodesScreen::updateFilterDisplay() { + if (!_filterLbl) return; + bool focused = isFilterFocused(); + if (_filterText.isEmpty()) { + lv_obj_set_style_text_color(_filterLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(_filterLbl, focused ? "Filter: _" : "Filter peers"); + } else { + String shown = "Filter: " + _filterText; + if (focused) shown += "_"; + lv_obj_set_style_text_color(_filterLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_filterLbl, shown.c_str()); + } +} + +bool LvNodesScreen::matchesFilter(const DiscoveredNode& node) const { + if (_filterText.isEmpty()) return true; + String needle = _filterText; + needle.toLowerCase(); + String name = displayNameFor(node).c_str(); + name.toLowerCase(); + if (name.indexOf(needle) >= 0) return true; + String hex = node.hash.toHex().c_str(); + hex.toLowerCase(); + return hex.indexOf(needle) >= 0; +} + void LvNodesScreen::refreshUI() { if (!_am) return; unsigned long now = millis(); if (now - _lastRebuild < REBUILD_INTERVAL_MS) return; int contacts = 0; - for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } - int countDelta = abs(_am->nodeCount() - _lastNodeCount); + int nodeCount = 0; + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + const auto& nodes = _am->nodes(); + nodeCount = (int)nodes.size(); + for (const auto& node : nodes) { + if (node.saved) contacts++; + } + } + int countDelta = abs(nodeCount - _lastNodeCount); int contactDelta = abs(contacts - _lastContactCount); bool ageRefresh = now - _lastRebuild >= AGE_REBUILD_INTERVAL_MS; if (countDelta > 0 || contactDelta > 0 || ageRefresh) { @@ -241,6 +337,15 @@ void LvNodesScreen::refreshUI() { void LvNodesScreen::rebuildList() { if (!_am || !_list) return; + + std::vector snapshot; + { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return; + snapshot = _am->nodes(); + } + _nodesSnapshot.swap(snapshot); + _lastRebuild = millis(); // Preserve scroll position across rebuilds lv_coord_t scrollY = lv_obj_get_scroll_y(_list); @@ -248,16 +353,21 @@ void LvNodesScreen::rebuildList() { _sortedContactIndices.clear(); _sortedOnlineIndices.clear(); - const auto& nodes = _am->nodes(); + const auto& nodes = _nodesSnapshot; int count = (int)nodes.size(); _lastNodeCount = count; unsigned long now = millis(); + int savedTotal = 0; for (int i = 0; i < count; i++) { + if (nodes[i].saved) savedTotal++; + if (!matchesFilter(nodes[i])) continue; if (nodes[i].saved) _sortedContactIndices.push_back(i); else _sortedOnlineIndices.push_back(i); } - _lastContactCount = (int)_sortedContactIndices.size(); + // Track the unfiltered saved count so refreshUI() compares like with like — + // otherwise an active filter reads as a contact-count delta on every tick. + _lastContactCount = savedTotal; std::sort(_sortedContactIndices.begin(), _sortedContactIndices.end(), [&nodes, now](int a, int b) { unsigned long ageA = nodeAgeMs(nodes[a], now); @@ -274,6 +384,10 @@ void LvNodesScreen::rebuildList() { }); if (_sortedContactIndices.empty() && _sortedOnlineIndices.empty()) { + bool filtered = !_filterText.isEmpty(); + if (_emptyTitle) lv_label_set_text(_emptyTitle, filtered ? "No peers match" : "No peers heard"); + if (_emptyHint) lv_label_set_text(_emptyHint, filtered ? "Esc clears the filter" + : "Listening for announces"); if (_emptyState) lv_obj_clear_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); return; @@ -317,7 +431,7 @@ void LvNodesScreen::rebuildList() { lv_obj_add_event_cb(row, [](lv_event_t* e) { auto* self = (LvNodesScreen*)lv_event_get_user_data(e); int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); - if (idx >= 0 && idx < (int)self->_am->nodes().size()) { + if (idx >= 0 && idx < (int)self->_nodesSnapshot.size()) { self->showActionMenu(idx); } }, LV_EVENT_CLICKED, this); @@ -387,7 +501,7 @@ void LvNodesScreen::rebuildList() { int LvNodesScreen::getFocusedNodeIdx() const { lv_obj_t* focused = lv_group_get_focused(LvInput::group()); - if (!focused) return -1; + if (!focused || focused == _filterBar) return -1; return (int)(intptr_t)lv_obj_get_user_data(focused); } @@ -396,14 +510,15 @@ int LvNodesScreen::getFocusedNodeIdx() const { void LvNodesScreen::updateOverlayDetails(const char* title) { if (!_overlayTitle || !_overlayMeta || !_overlayReach) return; - if (!_am || _actionNodeIdx < 0 || _actionNodeIdx >= (int)_am->nodes().size()) { + int nodeIdx = findNodeIndexByHex(_nodesSnapshot, _actionNodeHex); + if (nodeIdx < 0) { lv_label_set_text(_overlayTitle, title ? title : "Peer"); lv_label_set_text(_overlayMeta, "ID: unavailable"); lv_label_set_text(_overlayReach, "No route data"); return; } - const auto& node = _am->nodes()[_actionNodeIdx]; + const auto& node = _nodesSnapshot[nodeIdx]; unsigned long age = nodeAgeMs(node, millis()); bool devMode = _cfg && _cfg->settings().devMode; @@ -420,36 +535,35 @@ void LvNodesScreen::updateOverlayDetails(const char* title) { } void LvNodesScreen::showActionMenu(int nodeIdx) { - _actionNodeIdx = nodeIdx; + if (!_overlay || nodeIdx < 0 || nodeIdx >= (int)_nodesSnapshot.size()) return; + + const auto& node = _nodesSnapshot[nodeIdx]; + _actionNodeHex = node.hash.toHex(); _menuIdx = 0; _actionState = NodeAction::ACTION_MENU; _nicknameText = ""; - if (_overlay) { - if (_am && nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { - bool isSaved = _am->nodes()[nodeIdx].saved; - lv_label_set_text(_menuLabels[0], isSaved ? "Edit Name" : "Save Contact"); - lv_label_set_text(_menuLabels[1], "Message"); - lv_label_set_text(_menuLabels[2], "Close"); - } - updateOverlayDetails(nullptr); - for (int i = 0; i < 3; i++) lv_obj_clear_flag(_menuBtns[i], LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN); - updateMenuSelection(); - } + lv_label_set_text(_menuLabels[0], node.saved ? "Edit Name" : "Save Contact"); + lv_label_set_text(_menuLabels[1], "Message"); + lv_label_set_text(_menuLabels[2], "Close"); + updateOverlayDetails(nullptr); + for (int i = 0; i < 3; i++) lv_obj_clear_flag(_menuBtns[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN); + updateMenuSelection(); } void LvNodesScreen::hideOverlay() { _actionState = NodeAction::BROWSE; - _actionNodeIdx = -1; + _actionNodeHex.clear(); _nicknameText = ""; if (_overlay) lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); } void LvNodesScreen::showNicknameInput() { _actionState = NodeAction::NICKNAME_INPUT; - if (_am && _actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { - _nicknameText = String(_am->nodes()[_actionNodeIdx].name.c_str()); + int nodeIdx = findNodeIndexByHex(_nodesSnapshot, _actionNodeHex); + if (nodeIdx >= 0) { + _nicknameText = String(_nodesSnapshot[nodeIdx].name.c_str()); } updateOverlayDetails("Set contact name"); for (int i = 0; i < 3; i++) lv_obj_add_flag(_menuBtns[i], LV_OBJ_FLAG_HIDDEN); @@ -482,11 +596,11 @@ bool LvNodesScreen::handleLongPress() { // the list. Otherwise let main.cpp blank the screen. if (!_focusActive) return false; int nodeIdx = getFocusedNodeIdx(); - if (nodeIdx < 0 || nodeIdx >= (int)_am->nodes().size()) return false; - const auto& node = _am->nodes()[nodeIdx]; + if (nodeIdx < 0 || nodeIdx >= (int)_nodesSnapshot.size()) return false; + const auto& node = _nodesSnapshot[nodeIdx]; if (node.saved) { _confirmDelete = true; - _actionNodeIdx = nodeIdx; + _actionNodeHex = node.hash.toHex(); if (_ui) _ui->lvStatusBar().showToast("Remove? Enter=Remove Esc=Keep", 5000); } else { showActionMenu(nodeIdx); @@ -509,18 +623,25 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { // --- Nickname input mode --- if (_actionState == NodeAction::NICKNAME_INPUT) { if (event.enter || event.character == '\n' || event.character == '\r') { - CoreSync::RnsGuard backendGuard; - if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { - auto& node = const_cast(_am->nodes()[_actionNodeIdx]); - String finalName = _nicknameText; - finalName.trim(); - if (finalName.isEmpty()) { - if (!node.name.empty()) finalName = String(node.name.c_str()); - else finalName = String(node.hash.toHex().substr(0, 12).c_str()); + bool saved = false; + { + CoreSync::RnsGuard backendGuard; + int nodeIdx = findNodeIndexByHex(_am->nodes(), _actionNodeHex); + if (nodeIdx >= 0) { + auto& node = const_cast(_am->nodes()[nodeIdx]); + String finalName = _nicknameText; + finalName.trim(); + if (finalName.isEmpty()) { + if (!node.name.empty()) finalName = String(node.name.c_str()); + else finalName = String(node.hash.toHex().substr(0, 12).c_str()); + } + node.name = finalName.c_str(); + node.saved = true; + _am->saveContacts(); + saved = true; } - node.name = finalName.c_str(); - node.saved = true; - _am->saveContacts(); + } + if (saved) { if (_ui) _ui->lvStatusBar().showToast("Contact saved", 1200); hideOverlay(); rebuildList(); @@ -559,9 +680,8 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { showNicknameInput(); break; case 1: - if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size() && _onSelect) { - CoreSync::RnsGuard backendGuard; - std::string hex = _am->nodes()[_actionNodeIdx].hash.toHex(); + if (!_actionNodeHex.empty() && _onSelect) { + std::string hex = _actionNodeHex; hideOverlay(); _onSelect(hex); } else { @@ -581,29 +701,77 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { // --- Confirm delete mode --- if (_confirmDelete) { if (event.enter || event.character == '\n' || event.character == '\r') { - CoreSync::RnsGuard backendGuard; - if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { - _am->deleteContact(_actionNodeIdx); + bool removed = false; + { + CoreSync::RnsGuard backendGuard; + int nodeIdx = findNodeIndexByHex(_am->nodes(), _actionNodeHex); + removed = nodeIdx >= 0 && _am->deleteContact(nodeIdx); + } + if (removed) { if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); rebuildList(); } _confirmDelete = false; + _actionNodeHex.clear(); return true; } _confirmDelete = false; + _actionNodeHex.clear(); if (_ui) _ui->lvStatusBar().showToast("Kept contact", 800); return true; } + // --- Filter input mode (filter bar holds focus) --- + // Sits ahead of the 's' shortcut so letters typed here reach the filter + // instead of toggling a contact. Up/down fall through to the focus group. + if (isFilterFocused()) { + if (event.enter || event.character == '\n' || event.character == '\r') { + lv_group_focus_next(LvInput::group()); // jump into the filtered list + return true; + } + if (event.character == 0x1B) { + if (_filterText.isEmpty()) return false; // let Esc bubble up + _filterText = ""; + updateFilterDisplay(); + rebuildList(); + return true; + } + if (event.character == '\b' || event.character == 0x7F) { + if (_filterText.length() > 0) { + _filterText.remove(_filterText.length() - 1); + updateFilterDisplay(); + rebuildList(); + } + return true; + } + if (event.character >= 0x20 && event.character <= 0x7E) { + if ((int)_filterText.length() < kFilterMaxLen) { + _filterText += (char)event.character; + updateFilterDisplay(); + rebuildList(); + } + return true; + } + return false; + } + // 's' or 'S' to save/unsave contact if (event.character == 's' || event.character == 'S') { int nodeIdx = getFocusedNodeIdx(); - if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { - CoreSync::RnsGuard backendGuard; - auto& node = const_cast(_am->nodes()[nodeIdx]); - node.saved = !node.saved; - if (node.saved) _am->saveContacts(); - rebuildList(); + if (nodeIdx >= 0 && nodeIdx < (int)_nodesSnapshot.size()) { + std::string nodeHex = _nodesSnapshot[nodeIdx].hash.toHex(); + bool changed = false; + { + CoreSync::RnsGuard backendGuard; + int liveIdx = findNodeIndexByHex(_am->nodes(), nodeHex); + if (liveIdx >= 0) { + auto& node = const_cast(_am->nodes()[liveIdx]); + node.saved = !node.saved; + if (node.saved) _am->saveContacts(); + changed = true; + } + } + if (changed) rebuildList(); } return true; } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index b2453f3..7fdd252 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -1,11 +1,11 @@ #pragma once #include "ui/UIManager.h" +#include "reticulum/AnnounceManager.h" #include #include #include -class AnnounceManager; class UserConfig; class LvNodesScreen : public LvScreen { @@ -30,6 +30,11 @@ class LvNodesScreen : public LvScreen { void rebuildList(); int getFocusedNodeIdx() const; + // Filter input helpers + bool isFilterFocused() const; + void updateFilterDisplay(); + bool matchesFilter(const DiscoveredNode& node) const; + // Action modal helpers enum class NodeAction { BROWSE, ACTION_MENU, NICKNAME_INPUT }; void showActionMenu(int nodeIdx); @@ -49,7 +54,7 @@ class LvNodesScreen : public LvScreen { // Action modal state NodeAction _actionState = NodeAction::BROWSE; int _menuIdx = 0; - int _actionNodeIdx = -1; + std::string _actionNodeHex; String _nicknameText; // Overlay widgets @@ -65,7 +70,8 @@ class LvNodesScreen : public LvScreen { int _lastNodeCount = -1; int _lastContactCount = -1; - // Sorted index vectors (into _am->nodes()) + // UI-owned snapshot and sorted indices into it. + std::vector _nodesSnapshot; std::vector _sortedContactIndices; std::vector _sortedOnlineIndices; @@ -75,4 +81,11 @@ class LvNodesScreen : public LvScreen { lv_obj_t* _list = nullptr; lv_obj_t* _emptyState = nullptr; + lv_obj_t* _emptyTitle = nullptr; + lv_obj_t* _emptyHint = nullptr; + + // Filter bar — fixed above the list, first object in the focus group + lv_obj_t* _filterBar = nullptr; + lv_obj_t* _filterLbl = nullptr; + String _filterText; }; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index cd6c78d..eb04c92 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -256,7 +256,11 @@ void LvSettingsScreen::runFormatSD() { return; } if (_ui) _ui->lvStatusBar().showToast("Formatting SD card...", 2000); - bool ok = _sd->formatForRsDeck(); + bool ok; + { + CoreSync::RnsGuard backendGuard; + ok = _sd->formatForRsDeck(); + } if (_ui) _ui->lvStatusBar().showToast(ok ? "SD card formatted" : "SD format failed", 1500); rebuildItemList(); } @@ -269,7 +273,11 @@ void LvSettingsScreen::runWipeSD() { return; } if (_ui) _ui->lvStatusBar().showToast("Erasing rsDeck SD data...", 2000); - bool ok = _sd->wipeRsDeck(); + bool ok; + { + CoreSync::RnsGuard backendGuard; + ok = _sd->wipeRsDeck(); + } if (_ui) _ui->lvStatusBar().showToast(ok ? "SD data erased" : "SD erase failed", 1500); rebuildItemList(); } @@ -277,11 +285,14 @@ void LvSettingsScreen::runWipeSD() { void LvSettingsScreen::runFactoryReset() { _confirmingReset = false; if (_ui) _ui->lvStatusBar().showToast("Erasing device...", 3000); - if (_sd && _sd->isReady()) _sd->wipeRsDeck(); - if (_flash) _flash->format(); - nvs_flash_erase(); - delay(1500); // Long enough for key state to clear before reboot - ESP.restart(); + { + CoreSync::RnsGuard backendGuard; + if (_sd && _sd->isReady()) _sd->wipeRsDeck(); + if (_flash) _flash->format(); + nvs_flash_erase(); + delay(1500); // Long enough for key state to clear before reboot + ESP.restart(); + } } void LvSettingsScreen::runEnableDevMode() { @@ -290,8 +301,11 @@ void LvSettingsScreen::runEnableDevMode() { rebuildItemList(); return; } - _cfg->settings().devMode = true; - applyAndSave(); + { + CoreSync::RnsGuard backendGuard; + _cfg->settings().devMode = true; + applyAndSave(); + } buildItems(); enterCategory(_categoryIdx); if (_ui) _ui->lvStatusBar().showToast("Developer radio controls unlocked", 1500); @@ -453,8 +467,14 @@ void LvSettingsScreen::buildItems() { newId.formatter = [](int) { return String("[Enter]"); }; newId.action = [this, &s]() { if (!_idMgr) { if (_ui) _ui->lvStatusBar().showToast("Not available", 1200); return; } - if (_idMgr->count() >= 8) { if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200); return; } - int idx = _idMgr->createIdentity(s.displayName); + bool slotsFull; + int idx = -1; + { + CoreSync::RnsGuard backendGuard; + slotsFull = _idMgr->count() >= 8; + if (!slotsFull) idx = _idMgr->createIdentity(s.displayName); + } + if (slotsFull) { if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200); return; } if (idx >= 0) { if (_ui) _ui->lvStatusBar().showToast("Identity created", 1200); buildItems(); @@ -510,11 +530,17 @@ void LvSettingsScreen::buildItems() { if (_ui) _ui->lvStatusBar().showToast("Not available", 1200); return; } - if (_idMgr->count() >= 8) { + bool slotsFull; + int idx = -1; + { + CoreSync::RnsGuard backendGuard; + slotsFull = _idMgr->count() >= 8; + if (!slotsFull) idx = _idMgr->importIdentity(s.displayName); + } + if (slotsFull) { if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200); return; } - int idx = _idMgr->importIdentity(s.displayName); if (idx >= 0) { if (_ui) _ui->lvStatusBar().showToast("Identity imported", 1200); buildItems(); @@ -693,10 +719,13 @@ void LvSettingsScreen::buildItems() { }; devModeItem.action = [this, &s]() { if (s.devMode) { - s.devMode = false; + { + CoreSync::RnsGuard backendGuard; + s.devMode = false; + applyAndSave(); + } _confirmingDevMode = false; clearConfirmations(); - applyAndSave(); buildItems(); enterCategory(_categoryIdx); if (_ui) _ui->lvStatusBar().showToast("Developer radio controls locked", 1500); @@ -857,14 +886,21 @@ void LvSettingsScreen::buildItems() { return ssid.isEmpty() ? String("Empty") : String("[Enter]"); }; forgetItem.action = [this, &s]() { - size_t slot = selectedWiFiSlot(s); - if (slot >= s.wifiSTANetworks.size() || s.wifiSTANetworks[slot].ssid.isEmpty()) { + bool cleared = false; + { + CoreSync::RnsGuard backendGuard; + size_t slot = selectedWiFiSlot(s); + if (slot < s.wifiSTANetworks.size() && !s.wifiSTANetworks[slot].ssid.isEmpty()) { + s.wifiSTANetworks[slot].ssid = ""; + s.wifiSTANetworks[slot].password = ""; + applyAndSave(); + cleared = true; + } + } + if (!cleared) { if (_ui) _ui->lvStatusBar().showToast("WiFi profile already empty", 1200); return; } - s.wifiSTANetworks[slot].ssid = ""; - s.wifiSTANetworks[slot].password = ""; - applyAndSave(); if (_ui) _ui->lvStatusBar().showToast("WiFi profile cleared", 1200); }; _items.push_back(forgetItem); @@ -1001,16 +1037,30 @@ void LvSettingsScreen::buildItems() { // Diagnostics int infoStart = idx; _items.push_back({"RNS Transport", SettingType::READONLY, nullptr, nullptr, - [this](int) { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }}); + [this](int) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return String("Busy"); + return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); + }}); idx++; _items.push_back({"Known Paths", SettingType::READONLY, nullptr, nullptr, - [this](int) { return _rns ? String((int)_rns->pathCount()) : String("0"); }}); + [this](int) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return String("Busy"); + return _rns ? String((int)_rns->pathCount()) : String("0"); + }}); idx++; _items.push_back({"Live Links", SettingType::READONLY, nullptr, nullptr, - [this](int) { return _rns ? String((int)_rns->linkCount()) : String("0"); }}); + [this](int) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return String("Busy"); + return _rns ? String((int)_rns->linkCount()) : String("0"); + }}); idx++; _items.push_back({"LoRa Driver", SettingType::READONLY, nullptr, nullptr, [this](int) { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return String("Busy"); if (_radio && _radio->isRadioOnline()) { char buf[32]; snprintf(buf, sizeof(buf), "SF%d BW%luk %ddBm", @@ -1041,6 +1091,8 @@ void LvSettingsScreen::buildItems() { idx++; _categories.push_back({"Diagnostics", infoStart, idx - infoStart, [this]() -> String { + CoreSync::RnsTryGuard backendGuard(0); + if (!backendGuard.held()) return String("Busy"); if (!_rns || !_rns->isTransportActive()) return String("Transport offline"); String summary = String("Paths ") + String((int)_rns->pathCount()); summary += " / Links "; @@ -1609,6 +1661,7 @@ void LvSettingsScreen::rebuildItemList() { void LvSettingsScreen::selectWifiResult(int resultIdx) { if (!_cfg || resultIdx < 0 || resultIdx >= (int)_wifiResults.size()) return; + CoreSync::RnsGuard backendGuard; auto& net = _wifiResults[resultIdx]; auto& nets = _cfg->settings().wifiSTANetworks; while (nets.size() <= _wifiTargetSlot && nets.size() < WIFI_STA_MAX_NETWORKS) { @@ -1827,10 +1880,13 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { } }; if (event.enter || event.character == '\n' || event.character == '\r') { - if (item.textSetter) item.textSetter(_editText); + { + CoreSync::RnsGuard backendGuard; + if (item.textSetter) item.textSetter(_editText); + applyAndSave(); + } _textEditing = false; _editValueLbl = nullptr; - applyAndSave(); rebuildItemList(); return true; } @@ -1871,10 +1927,14 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { if (event.enter || event.character == '\n' || event.character == '\r') { auto& item = _items[_selectedIdx]; _editValue = freqRecompose(); - if (item.setter) item.setter(_editValue); + { + CoreSync::RnsGuard backendGuard; + if (item.setter) item.setter(_editValue); + applyAndSave(); + } _freqEditing = false; _editing = false; _editValueLbl = nullptr; - applyAndSave(); rebuildItemList(); return true; + rebuildItemList(); return true; } if (event.del || event.character == 8) { if (_freqCursor > 0) _freqCursor--; @@ -1918,10 +1978,13 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { } if (event.enter || event.character == '\n' || event.character == '\r') { if (_editValue < item.minVal) _editValue = item.minVal; - if (item.setter) item.setter(_editValue); + { + CoreSync::RnsGuard backendGuard; + if (item.setter) item.setter(_editValue); + applyAndSave(); + } _editing = false; _numericTyping = false; - applyAndSave(); rebuildItemList(); return true; } if (event.del || event.character == 8) { @@ -1972,9 +2035,12 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { rebuildItemList(); } else if (item.type == SettingType::TOGGLE) { clearConfirmations(); - int val = item.getter ? item.getter() : 0; - if (item.setter) item.setter(val ? 0 : 1); - applyAndSave(); + { + CoreSync::RnsGuard backendGuard; + int val = item.getter ? item.getter() : 0; + if (item.setter) item.setter(val ? 0 : 1); + applyAndSave(); + } rebuildItemList(); } else if (strcmp(item.label, "Frequency") == 0 && item.type == SettingType::INTEGER) { clearConfirmations();