#include "UITask.h" #include "TouchSleep.h" #include "../MyMesh.h" #include "device_caps.h" // CAP_* capability flags (replaces device-name #ifs) // Port the browser-facing web UI (VNC mirror / remote / terminal viewer page) listens on. // The T-Display P4's ESP-AT stack allows ONE listening port, so the web UI shares the // companion TCP port behind a first-byte router (MultiTransportCompanionInterface). #if defined(HAS_TDISPLAY_P4) #define WEB_UI_PORT_STR "5000" #else #define WEB_UI_PORT_STR "8765" #endif #include #include #include #include #include // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO) #if CAP_LUA_AUDIO static void* wadaMp3Scratch(); #define MINIMP3_ONLY_MP3 #define MINIMP3_NO_SIMD #define MINIMP3_SCRATCH ((mp3dec_scratch_t*)wadaMp3Scratch()) #define MINIMP3_IMPLEMENTATION #include "../../lib/minimp3/minimp3.h" #undef MINIMP3_IMPLEMENTATION #undef MINIMP3_SCRATCH #undef MINIMP3_NO_SIMD #undef MINIMP3_ONLY_MP3 static void* s_wada_mp3_scratch = nullptr; static void* wadaMp3Scratch() { return s_wada_mp3_scratch; } #endif #if defined(ESP32) #include #include // Dedicated LittleFS instance for the map tile pack (separate // partition — see variants/heltec_v4/partitions_tft_touch.csv). Keeps // tiles out of the SPIFFS partition where /new_prefs + /contacts3 + // chat history live, so refreshing the tile pack no longer wipes the // operator's Profile / Radio settings. #include #include #include #include // esp_deep_sleep_start / ext0 wakeup for the power-off menu #include // rtc_gpio_pullup_en — hold the wake pin's level in deep sleep #include "assets/lockscreen_placeholder_jpg.h" // seeded to SPIFFS /lock/placeholder.jpg on first boot (PNG decode is broken on this board) #if CAP_LOCK_SCREEN #include "assets/lockscreen_wallpaper_rgb565.h" // crisp pre-dithered default lock-screen wallpaper (no JPEG banding) #if defined(TLORA_PAGER) #include "assets/lockscreen_wallpaper_pager_rgb565.h" // native 480x222 crop/layout for this board's wide/short panel #endif #endif #include #include #include // nvs_get_stats() for the About-tab NVS usage line #include #include // A/B slot info + reboot-to-recovery (esp_ota_get_running_partition) #include // find/erase otadata to fall back to the factory(recovery) slot #if !defined(HAS_TANMATSU) #include // spi_flash_cache2phys — verify the OTA "running" slot matches reality (beta_21 coredump) #endif #if !defined(HAS_TANMATSU) #include // detect/read/erase the panic coredump for the crash-report export #include #include // xTaskGetCurrentTaskHandleForCPU / pcTaskGetName — Task-WDT crash self-record #include #else // Tanmatsu's 16M.csv has no coredump partition yet — stub so the crash-export compiles + links. #include static inline esp_err_t esp_core_dump_image_check() { return ESP_FAIL; } static inline esp_err_t esp_core_dump_image_get(size_t* a, size_t* s) { (void)a; (void)s; return ESP_FAIL; } static inline esp_err_t esp_core_dump_image_erase() { return ESP_FAIL; } #endif #endif #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) #include // microSD — T-Deck/M9 on the LoRa SPI, V4-R8 on the TFT SPI #include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards) #include "sd_diskio.h" // internal Arduino-SD drive helpers (sdcard_init / sd_*_raw) extern SPIClass* tdeckSharedSPI(); // FatFs mkfs (the prebuilt ESP-IDF compiles f_setlabel OUT — FF_USE_LABEL=0 — // so the "MESHCOMOD" volume label is written by hand via sd_*_raw, below). extern "C" int f_mkfs(const char* path, uint8_t opt, uint32_t au, void* work, unsigned len); #ifndef MC_FM_FAT32 #define MC_FM_FAT32 0x02 // FatFs f_mkfs option: force FAT32 #endif #ifndef PIN_SD_CS #define PIN_SD_CS 39 // T-Deck microSD chip-select (M9 sets its own via build flags) #endif #endif #if defined(HAS_THINKNODE_M9) extern SPIClass* m9SharedSPI(); #endif #if defined(HAS_TDECK_GT911) #include // T-Deck MAX98357A speaker amp (notification tones) // T-Deck I2S audio amp pins (MAX98357A, no MCLK). Overridable via build flags. #ifndef PIN_I2S_BCK #define PIN_I2S_BCK 7 #endif #ifndef PIN_I2S_WS #define PIN_I2S_WS 5 #endif #ifndef PIN_I2S_DOUT #define PIN_I2S_DOUT 6 #endif #elif defined(TLORA_PAGER) #include // microSD (CS=21) on the shared radio/display SPI bus -- // storage + file manager/WAV access. No sd_diskio.h/f_mkfs // here: formatting is deliberately off on this board (the // reasons are on the format-helper guard in the file manager). #define PIN_SD_CS PAGER_PIN_SD_CS // from TLoraPagerBoard.h, already visible via // MyMesh.h -> target.h -> TLoraPagerBoard.h above #include // pager ES8311 codec (notification tones + WAV playback) #include "Es8311Codec.h" // PIN_I2S_MCLK/BCK/WS/DOUT/SDIN come from platformio.ini build flags #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) #include // internal FAT partition (Tanmatsu 'locfd' / P4 'storage') #include // microSD on the P4-class boards' SDMMC slot 0; slot 1 = C6 radio extern bool g_fs_ok; // set in main.cpp once the internal FAT is mounted #endif #include #include // PSRAM-preferred alloc helpers for the map tile cache #include "SnakeGame.h" // Apps → Snake (self-contained game module) #include "LuaHost.h" // Lua app host (LUA_APPS.md; Phase 0 spike gate) #include "LuaAppHost.h" // sandboxed Lua apps (LUA_APPS.md Phase 1; self-gated on CAP_LUA_APPS) #include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) #include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution #if defined(HAS_TOUCH_UI) #include #include #if CAP_TRACKBALL #include #endif #if defined(HAS_TDECK_KEYBOARD) #include "../helpers/input/TDeckKeyboard.h" #elif defined(HAS_M9_KEYBOARD) #include #endif #if defined(HAS_M9_COMPASS) #include // wada.sys.compass() source (luaHostCompass) #endif #if defined(HAS_M9_IMU) #include // wada.sys.accel() source (luaHostAccel) #endif #if defined(HAS_PAGER_KEYBOARD) #include "../helpers/input/PagerKeyboard.h" #endif #if defined(HAS_PAGER_ENCODER) #include #endif #if defined(HAS_ATTAKY_MESH_KEYBOARD) #include #endif #if defined(ATTAKY_MESH_SERIES) #include #endif #include "KeyboardLayouts.h" #include "i18n.h" #include "emoji_data.h" // baked Noto colour-emoji glyphs (emojiGlyphLookup) #include "qr_icon.h" // baked recolour-able QR glyph (qr_icon_dsc) for the Chats Share button #if defined(HAS_TANMATSU) #include // badge-bsp-backed DisplayDriver (P4) #elif defined(TLORA_PAGER) #include #elif defined(HAS_RAK_TAP_V2) #include // LovyanGFX FSPI on RAK Tap V2 #elif defined(HAS_TDISPLAY_P4) #if defined(HAS_TDP4_LCD) #include // HI8561 TFT-LCD (LCD SKU) on the T-Display P4 #else #include // RM69A10 MIPI-DSI AMOLED (default SKU) on the T-Display P4 #endif #else #include #endif #include #include #include #if defined(ESP32) #include #include // NVS-or-SD prefs backend (Launcher-safe) #include "helpers/esp32/WdtHeavyGuard.h" // shared ref-counted core-0 WDT suspend (history/backup saves + core saveContacts) // QUOTED on purpose: the vendored core lib ships a STALE copy of this header in // its include path, so an angle include picks that up and misses accessors we // add here (e.g. the signal-probe prefs). Quotes force the local src/ copy. #include "../helpers/esp32/TouchPrefsStore.h" #include "../helpers/esp32/WebMirror.h" // web UI mirror (framebuffer + pointer bridge) #include "../helpers/ClockFloorRTC.h" extern ClockFloorRTC rtc_clock; // the board clock (variants/*/target.cpp); its send-timestamp floor is seeded/persisted from here (#89) #if defined(MULTI_TRANSPORT_COMPANION) #include #include #include // on-device HTTPS for the Reader (text browser); setInsecure(), no cert store #if CAP_OTA #include // Arduino OTA writer (native dual-OTA boards; Tanmatsu is IDF/AppFS, no OTA) #endif #include "../helpers/esp32/WifiRuntimeStore.h" // QUOTED: this tree's copy (wifiScan*Active), not the lib's stale one #include "helpers/esp32/MqttBridge.h" #if defined(HAS_TDISPLAY_P4) // T-Display P4: the C6 runs ESP-AT, so Arduino's real WiFi object must NEVER be driven (its // mode()/begin() re-init esp_hosted and panic). These facades rebind every WiFi.* below to the // c6_at AT-over-SDIO driver — scans/joins become real, status reads come from a cache — and // every WiFiClient/WiFiClientSecure to AT+CIP sockets (HTTPClient dispatches virtually, so // tiles/version-check/OTA/reader fetch through the C6; TLS runs ON the C6). MUST stay the // LAST includes of this block. P4-only: the headers only exist in the P4 build. #include #include #define WiFiClient C6Client #define WiFiClientSecure C6ClientSecure #endif #endif #endif #if defined(HAS_TANMATSU) extern TanmatsuDisplay display; #elif defined(TLORA_PAGER) extern ST7796LCDDisplay display; #elif defined(HAS_RAK_TAP_V2) || defined(HELTEC_LORA_V4_R8) extern LGFXDisplay display; #elif defined(HAS_TDISPLAY_P4) extern DISPLAY_CLASS display; // RM69A10Display (AMOLED) or HI8561Display (LCD) — set in CMakeLists #else extern ST7789LCDDisplay display; #endif #endif constexpr unsigned long UI_REFRESH_MS = 250; constexpr int UI_SORT_SCRATCH = UITask::MAX_UI_THREADS; UIEventType g_last_event = UIEventType::none; #if defined(ESP32) namespace { constexpr const char* k_ui_history_path = "/ui_chat_history_v1.bin"; constexpr uint32_t k_ui_history_magic = 0x55494348; // "UICH" // Bump when the on-disk record layout changes. From v5 on, the header // self-describes its record sizes (see loadHistoryFromStorage), so you can // APPEND fields to the END of UiHistoryThread/UiHistoryMsg and OLD blobs still // load — the appended tail reads back zero instead of the whole history being // discarded. v4 added meta_flags/path_len/snr_q4/rssi per message. constexpr uint16_t k_ui_history_version = 6; // v6: MAX_MSG_TEXT 96 -> 160 (record size changed) // Oldest on-disk version we still load. v4's record layout matches the current // build, so it reads fine via the sizeof() fallback below; anything older // predates that layout and is discarded. constexpr uint16_t k_ui_history_min_version = 6; // v4/v5 used 96-char records; reject (don't mis-read) // Split-format file paths. Thread metadata and the message ring are written // to separate files so unread counts (small, ~4 KB) can flush at 200 ms while // the ring (larger, scales with MAX_UI_MESSAGES) flushes lazily every 2 s, // reducing flash write pressure when the ring is large. constexpr const char* k_ui_threads_path = "/ui_threads_v1.bin"; // Threads-index writes go tmp+rename (same discipline as the segment compacts): // a truncate-in-place "w" rewrite interrupted by a hard power-cut left a short // file the next boot quarantine-DELETED — chat list, unread counts and DM // entries gone (M9 power slider = rail cut; any board on a brownout). constexpr const char* k_ui_threads_tmp_path = "/ui_threads_v1.bin.tmp"; constexpr const char* k_ui_msgs_path = "/ui_msgs_v1.bin"; constexpr const char* k_ui_msgs_tmp_path = "/ui_msgs_v1.bin.tmp"; // Separate temp for the SYNCHRONOUS (shutdown/reboot/no-PSRAM) writer. The // core-0 worker and the loop task can overlap when a stalled worker outlives // uiHistWaitWorkerIdle's 9 s cap — two truncating opens of ONE tmp interleave // into garbage, and whichever rename lands last installs a corrupt file that // the next boot quarantines (total history loss). Distinct tmp names make the // overlap last-rename-wins with each candidate internally consistent. constexpr const char* k_ui_msgs_tmp2_path = "/ui_msgs_v1.bin.tm2"; constexpr uint32_t k_ui_threads_magic = 0x55495448; // "UITH" constexpr uint32_t k_ui_msgs_magic = 0x55494D53; // "UIMS" #if defined(HAS_EXPANSION_KIT) // PR #29's GPS-nav/waypoint/track-recorder bundle is intentionally NOT ported // here; the Local-Sensors / Expansion detail text still reads these four GPS // fields, so they are declared at no-data defaults (course = -1 = unknown) and // stay at those defaults — guarded reads compile and render nothing extra. static unsigned long s_gps_last_fix_ms = 0; static unsigned long s_gps_last_loss_ms = 0; static float s_gps_speed_kmh = 0.0f; static float s_gps_course_deg = -1.0f; #endif struct __attribute__((packed)) UiHistoryHeader { uint32_t magic; uint16_t version; uint16_t reserved; uint16_t ui_msg_count; uint16_t ui_msg_head; uint32_t msgcount; int16_t active_thread_idx; uint8_t active_thread_is_channel; // v5+: on-disk record sizes at write time, so the loader can read a blob // whose records were SHORTER (fields appended since) and zero-fill the new // tail. Zero in a v4 blob -> loader falls back to sizeof(). Carved out of the // old _pad, so the header size is unchanged from v4. uint16_t thread_rec_size; uint16_t msg_rec_size; uint8_t _pad[1]; }; struct __attribute__((packed)) UiHistoryThread { uint8_t used; uint8_t channel; uint16_t unread; uint32_t last_ts; int16_t mesh_contact_idx; uint8_t mesh_contact_pub[32]; uint8_t mesh_contact_key6[6]; int16_t mesh_channel_slot; char name[UITask::MAX_THREAD_NAME + 1]; }; struct __attribute__((packed)) UiHistoryMsg { uint32_t ts; uint8_t channel; uint8_t outgoing; // v4 additions — RX metadata for the per-bubble Info popup. All zero when // the message was either outgoing or loaded from a pre-v4 blob (which // gets discarded outright on the version-mismatch check, so the latter // shouldn't happen in practice, but the zero defaults keep the Info // popup honest if it ever does). uint8_t meta_flags; uint8_t path_len; int8_t snr_q4; int8_t rssi; char thread[UITask::MAX_THREAD_NAME + 1]; char sender[UITask::MAX_SENDER_NAME + 1]; char text[UITask::MAX_MSG_TEXT + 1]; // Append any FUTURE fields HERE (after text) and bump k_ui_history_version. // The v5+ loader zero-fills appended fields for older blobs, so appending // never wipes chat history. Inserting in the middle (as v4 did) breaks that // and must instead raise k_ui_history_min_version. }; // Header for the split message-ring file (/ui_msgs_v1.bin). The thread file // (/ui_threads_v1.bin) reuses UiHistoryHeader (it holds active_thread_idx and // thread_rec_size). This smaller header holds only the ring-specific fields. struct __attribute__((packed)) UiMsgFileHeader { uint32_t magic; uint16_t version; uint16_t reserved; uint16_t ui_msg_count; uint16_t ui_msg_head; uint32_t msgcount; uint16_t msg_rec_size; uint8_t _pad[2]; }; // ---- Segmented message store (generation 3) ------------------------------ // The ring is persisted as fixed-capacity SEGMENT files under /msgs: // seg_.bin, k_ui_seg_records records each. New messages APPEND one // record to the newest ("active") segment — a ~240 B write instead of the old // full-ring rewrite (515 KB+ at 2300 messages), which shrinks the hard-cut // loss window, the SPI bus-collision window, and flash wear all at once. // Deletes tombstone in RAM and mark the owning segment dirty; compaction // rewrites JUST that segment from the RAM ring (disk holds exactly the ring's // record set, so no read pass is ever needed). Corruption quarantines one // segment instead of the whole history. On FAT backends /msgs is a real // subdirectory (single child under /meshcomod — stays inside the factory // wipe's 24-entry cap); on SPIFFS the same name is a legal FLAT file name // (31-char limit: "/msgs/seg_4294967295.bin" = 24 chars), so the layout is // identical everywhere and no mkdir is required there. constexpr const char* k_ui_seg_dir = "/msgs"; // Commit marker. Written (last) only once a migration has been fully verified, // so its ABSENCE means "the segment set on disk is a partial migration" — the // loader then wipes those segments and falls back to the still-intact // old-format file. Without it, a power cut between chunk writes left a valid // oldest-first PREFIX that shadowed the old file forever: the UI came up // showing only the oldest few hundred messages, permanently. constexpr const char* k_ui_seg_ok = "/msgs/store.ok"; constexpr uint32_t k_ui_seg_magic = 0x55495347; // "UISG" constexpr uint16_t k_ui_seg_version = 1; constexpr int k_ui_seg_records = 256; // records per segment (~60 KB at v1 size) constexpr int k_ui_seg_max = 24; // table cap: 5000/256 = 20 live segments + slack struct __attribute__((packed)) UiSegHeader { uint32_t magic; // k_ui_seg_magic uint16_t version; // k_ui_seg_version uint16_t msg_rec_size; // sizeof(UiSegMsg) at write time — self-describing so // future fields append at the END (same rule as v5+) uint32_t first_seq; // seq of this segment's first-ever record (== filename key) uint8_t _pad[4]; }; // On-disk segment record: the v6 record layout + the monotonic seq appended // (append-at-end evolution — a reader of either size copies min(rec_size) and // zero-fills the rest, so seq reads 0 from a hypothetical older record). struct __attribute__((packed)) UiSegMsg { UiHistoryMsg m; uint32_t seq; }; } // namespace #endif // ---- Shared by all companion_radio + ui-new builds (thread/mesh sync, prefs bits, loop counters) ---- constexpr uint8_t AUTO_ADD_OVERWRITE_OLDEST = (1u << 0); constexpr uint8_t AUTO_ADD_CHAT = (1u << 1); constexpr uint8_t AUTO_ADD_REPEATER = (1u << 2); constexpr uint8_t AUTO_ADD_ROOM_SERVER = (1u << 3); constexpr uint8_t AUTO_ADD_SENSOR = (1u << 4); static bool hasContactPub(const uint8_t pub[32]) { static const uint8_t z[32] = {0}; return memcmp(pub, z, sizeof(z)) != 0; } static bool hasContactKey6(const uint8_t key6[6]) { static const uint8_t z[6] = {0, 0, 0, 0, 0, 0}; return memcmp(key6, z, sizeof(z)) != 0; } static uint32_t s_live_diag_loops = 0; #if !defined(HAS_TOUCH_UI) static void pushDiagLine(const char* message) { (void)message; } #else static void pushDiagLine(const char* message); // real def near the diag log #endif // ============================================================ // LVGL TOUCH UI — Heltec V4 TFT Cap-Touch only // ============================================================ #if defined(HAS_TOUCH_UI) // ---- colour palette: TACTICAL (sober) ---- // Aimed at how *actual* fielded military comms equipment looks — Harris // PRC handhelds, Codan terminals, Nett Warrior / MFOCS dashboards — // rather than the Hollywood "amber + OD-green camo" look. Real military // UIs are deliberately boring: dark neutral gray, clean white text, and // colour reserved strictly for FUNCTIONAL status (amber = caution, green // = OK, red = danger). The base palette here is all desaturated grays // and one cool slate accent; the warm/saturated colours (the status // amber + OD green + alert red) live in the status-color constants // below and are used only where state really matters. constexpr uint32_t COLOR_BG = 0x000000; // pure black, OLED-style constexpr uint32_t COLOR_PANEL = 0x040506; // essentially black panel // Earlier accent was 0x4E5C66 — RGB (78,92,102), cool blue-leaning. Even // at low opacity that tinted every chip and settings row blue. Switched // to a true neutral medium gray (very slight warm) so chip fills read as // "darker gray" rather than "blue". // Runtime-themeable accent (Settings -> Theme colour). NOT constexpr: the picker // rewrites these live and they're reloaded from the saved pref at boot. Every // accent site reads them through lv_color_hex(), so one write re-themes the UI. uint32_t COLOR_ACCENT = 0x15B6A6; // WADAMESH brand teal (default; the logo dots) uint32_t COLOR_ACCENT_PRESS = 0x0D766B; // darker brand teal (default) // Perceived luminance 0..255 (keep the accent dark enough for off-white text). static inline uint32_t accentLuma(uint32_t rgb) { return (299u*((rgb>>16)&0xFF) + 587u*((rgb>>8)&0xFF) + 114u*(rgb&0xFF)) / 1000u; } // Scale brightness to `pct` percent, preserving hue. static inline uint32_t accentDarken(uint32_t rgb, int pct) { uint32_t r=((rgb>>16)&0xFF)*pct/100, g=((rgb>>8)&0xFF)*pct/100, b=(rgb&0xFF)*pct/100; return (r<<16)|(g<<8)|b; } // Clamp a picked accent dark enough that off-white button text stays readable. static inline uint32_t accentClampReadable(uint32_t rgb) { const uint32_t kMaxLuma = 140; uint32_t L = accentLuma(rgb); if (L > kMaxLuma) return accentDarken(rgb, (int)(kMaxLuma * 100 / L)); return rgb & 0xFFFFFFu; } constexpr uint32_t COLOR_TEXT = 0xE0E3E6; // clean off-white constexpr uint32_t COLOR_SUB = 0x828891; // medium neutral gray // Chat-bubble palette: kept near-monochrome (military comms terminals // don't colour-code direction). Slight luminance + hue lean keeps L/R // readable but neither side gets a "fun" tint. constexpr uint32_t COLOR_SENT_BG = 0x1D2226; // very subtle steel constexpr uint32_t COLOR_RECV_BG = 0x1B1D1F; // warmer neutral gray constexpr uint32_t COLOR_MENTION = 0x4FA3FF; // blue — @mentions of me constexpr uint32_t COLOR_MENTION_BG = 0x16324F; // blue-tinted bubble for a message that @mentions me // Functional status colours — use sparingly. These are the ONLY warm/ // saturated colours in the palette, so when they appear the operator // instantly reads them as state, not decoration. constexpr uint32_t COLOR_STATUS_OK = 0x4A8E4A; // muted go-green constexpr uint32_t COLOR_STATUS_WARN = 0xC8A030; // dim amber constexpr uint32_t COLOR_STATUS_DANGER= 0xA04040; // muted red // LVGL 8.3 / Montserrat doesn't ship a STAR glyph. We carry a small custom // font subset (one glyph: U+2605 BLACK STAR at size 28) in // star_font_28.c — see &star_font_28 below. Pair the BIG macro with that // font; the small ASCII macro stays as a plain '*' for places that still // render in Montserrat (e.g. the action-sheet menu rows at font 12). #define TOUCH_SYM_STAR "*" /* render in Montserrat at any size */ #define TOUCH_SYM_STAR_BIG "\xE2\x98\x85" /* render ONLY with star_font_28 */ extern "C" const lv_font_t star_font_28; extern "C" const lv_font_t star_font_14; // FontAwesome glyphs (monochrome, bpp4) carried by person_font: U+F007 "user", // U+F519 "tower-broadcast", U+F0C0 "users" (group). Spliced into the g_font_16 // and g_font_14 fallback chains in initTouchFontFallbacks, so these PUA codepoints // render an icon that follows the label's text colour. Used for the Contacts tab // icon (person), the Contacts-list type icons (person = peer, antenna = repeater), // and the Chats list (person = DM, group = channel). extern "C" const lv_font_t person_font; // 16 px — for g_font_16 contexts extern "C" const lv_font_t person_font14; // 14 px — for g_font_14 (chat list rows) #define TOUCH_SYM_PERSON "\xEF\x80\x87" /* U+F007 user */ #define TOUCH_SYM_ANTENNA "\xEF\x94\x99" /* U+F519 tower-broadcast */ #define TOUCH_SYM_GROUP "\xEF\x83\x80" /* U+F0C0 users (group) */ // FontAwesome "magnifying-glass" (U+F002), 16 px — for the map zoom button. Its // own one-glyph font, spliced into the g_font_16 fallback chain like person_font. extern "C" const lv_font_t zoom_font; #define TOUCH_SYM_ZOOM "\xEF\x80\x82" /* U+F002 magnifying-glass */ // FontAwesome sun (U+F185) + moon (U+F186) for sleep-state markers. Spliced into // the g_font_12, g_font_14, and g_font_16 chains in initTouchFontFallbacks so the // glyphs render at any of those sizes (the status-bar sleep icon uses g_font_12; // the battery-chart sleep markers will use g_font_14). extern "C" const lv_font_t sleepicons_font; #define TOUCH_SYM_MOON "\xEF\x86\x86" /* U+F186 moon */ // FontAwesome lock (U+F023) + bell (U+F0F3) + bell-slash (U+F1F6), 16 px — for the // control-center chips (real lock instead of an eye; a dynamic bell that gains a slash // when notifications are silenced). Own font (scripts-generated from fa-solid-900), // spliced into the g_font_16 fallback chain like person_font/zoom_font. extern "C" const lv_font_t cc_icons_16; #define TOUCH_SYM_LOCK "\xEF\x80\xA3" /* U+F023 lock */ #define TOUCH_SYM_BELL "\xEF\x83\xB3" /* U+F0F3 bell (notifications on) */ #define TOUCH_SYM_BELL_SLASH "\xEF\x87\xB6" /* U+F1F6 bell-slash (silenced) */ // Extras fallback fonts — em-dash (U+2014), ellipsis (U+2026), middle dot // (U+00B7). LVGL's stock Montserrat subset doesn't include these, so any // string carrying them renders missing-glyph rectangles (visible in the // settings page, toasts, etc.). We carry tiny custom subsets at the three // label sizes we use (12, 14, 16) and chain them as fallback fonts via // initTouchFontFallbacks() — runs once at the top of UITask::begin after // lv_init. Anywhere the touch UI references a Montserrat font, swap to // &g_font_NN to pick up the fallback chain. extern "C" const lv_font_t extras_12; extern "C" const lv_font_t extras_14; extern "C" const lv_font_t extras_16; // extras_lat_28 used to be compiled HAS_TANMATSU-only (Large/Huge UI-scale Latin- // accent fallback, issue #129); it's now compiled on every board (extras_lat_28.c) // for the "at a glance" notification's message body (atGlanceEnsureFont()) -- // montserrat_28 was picked over 24 because only 12/14/16/28 are actually built // into this project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't enabled). extern "C" const lv_font_t extras_lat_28; // extras_lat_20 was HAS_TANMATSU-only too (Large/Huge UI-scale fallback); the // T-Deck now also builds it, for its 20px at-glance body experiment (see // atGlanceEnsureFont()) -- extras_lat_20.c's own gate was widened to match. #if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911) extern "C" const lv_font_t extras_lat_20; #endif #if defined(TLORA_PAGER) // Full multilingual fallbacks at the Pager's accessible text sizes. These keep // accented Latin, Greek, Cyrillic, Arabic, punctuation, and units aligned with // the enlarged Montserrat primary instead of dropping back to 12/14/16 px. extern "C" const lv_font_t extras_20; extern "C" const lv_font_t extras_24; #endif #if defined(HAS_TANMATSU) // Compressed Latin-accent fonts at the scaled sizes so umlauts etc. match their // neighbours at Large/Huge UI scale (issue #129). Room for these was made by // storing the extras fonts compressed (LV_USE_FONT_COMPRESSED) — nothing lost, // net binary SMALLER, so the P4 app-load ceiling is respected. extern "C" const lv_font_t extras_lat_24; #endif static lv_font_t g_font_12; static lv_font_t g_font_14; static lv_font_t g_font_16; // Swap a label's RESOLVED raw Montserrat for its chained twin (g_font_NN), so // accents, Greek, Cyrillic and Arabic reach the fallback fonts instead of // rendering as tofu boxes. LVGL's theme puts a font on each widget, so a label // that sets none of its own lands on the stock LV_FONT_MONTSERRAT_* — which has // no fallback chain. That is the Hungarian "Lek□rdez□s (perc)" class of bug. // // Reads what the label actually resolves to rather than assuming 14, so a label // inheriting a larger font from its parent keeps that size. Anything already // chained, or using a deliberately different font, is left alone — so this is // safe to call after any lv_label_create. static void useChainedFont(lv_obj_t* label) { if (!label) return; const lv_font_t* f = lv_obj_get_style_text_font(label, LV_PART_MAIN); const lv_font_t* repl = nullptr; if (f == &lv_font_montserrat_12) repl = &g_font_12; else if (f == &lv_font_montserrat_14) repl = &g_font_14; else if (f == &lv_font_montserrat_16) repl = &g_font_16; if (repl) lv_obj_set_style_text_font(label, repl, LV_PART_MAIN); } // Auto-shrink a single-line label's font one baked step (16 -> 14 -> 12) until its // text fits within max_w, so a longer translation (French/German/Russian/…) fits a // fixed-width button or cell instead of clipping. No-op if it already fits or is // already at 12 px. Cheap (one lv_txt_get_size per candidate) — call once, right // after setting the label's text + font. Fonts scale on the Tanmatsu, so the ladder // stays proportional there too. Wrapping/marquee is intentionally NOT used here — // this keeps one-line button labels one line. static void uiFitLabelWidth(lv_obj_t* lbl, lv_coord_t max_w) { if (!lbl || max_w <= 6) return; const char* txt = lv_label_get_text(lbl); if (!txt || !txt[0]) return; const lv_font_t* cur = lv_obj_get_style_text_font(lbl, LV_PART_MAIN); lv_point_t sz; // Already fits at the current font? Do nothing. Measuring the CURRENT font first // (not assuming it's one of ours) means this never shrinks a label that fits — so // it's safe to call on any label, whatever font it inherited. lv_txt_get_size(&sz, txt, cur, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); if (sz.x <= max_w) return; // Overflows: pick the LARGEST baked font (16 -> 14 -> 12) that fits. const lv_font_t* ladder[3] = { &g_font_16, &g_font_14, &g_font_12 }; for (int i = 0; i < 3; ++i) { lv_txt_get_size(&sz, txt, ladder[i], 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); if (sz.x <= max_w) { lv_obj_set_style_text_font(lbl, ladder[i], LV_PART_MAIN); return; } } lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); // still wide: 12 px beats clipping a bigger font } #if LV_USE_IMGFONT // lv_imgfont path callback: hand back the baked colour image for an emoji // codepoint (copied into the imgfont's scratch buffer as an lv_img_dsc_t), or // false so LVGL keeps walking the fallback chain for everything else. static bool emojiImgfontPathCb(const lv_font_t* /*font*/, void* img_src, uint16_t /*len*/, uint32_t unicode, uint32_t /*unicode_next*/) { const lv_img_dsc_t* d = emojiGlyphLookup(unicode); if (!d) return false; lv_memcpy(img_src, d, sizeof(lv_img_dsc_t)); // The imgfont reuses ONE scratch buffer as the image source for every glyph, so // LVGL's image cache (keyed on the src pointer) hands back the first-decoded // emoji for all of them — every emoji renders identical. Drop the stale entry so // the just-copied dsc is decoded fresh. (True-colour "decode" is a no-op copy, so // this is cheap, and other images — map tiles — use distinct src ptrs, untouched.) lv_img_cache_invalidate_src(img_src); return true; } static lv_font_t* s_emoji_font[3] = { nullptr, nullptr, nullptr }; // one per text size #endif // UI size (font-based, at native resolution so it stays crisp) is set in // initTouchFontFallbacks() from the saved preset. SC() scales geometry where a board supports it. The bottom // tab bar is deliberately NOT scaled (g_font_tab stays 16 px + TABBAR_H is fixed) — by request it // stays the Normal size at every level. static int s_ui_fscale = 100; static inline lv_coord_t SC(int px) { return (lv_coord_t)((px * s_ui_fscale + 50) / 100); } // Popup-card dimension scaler: the 800×480 Tanmatsu panel dwarfs dialogs sized for the // 320px T-Deck/V4 — scale their fixed card/menu W/H up so they don't look lost in the middle. // No-op (plain SC) on the smaller boards, so their popups stay byte-for-byte unchanged. // EXCLUDES the round P4: it's CAP_LARGE_SCREEN (for font scaling) but only 284 px wide, so // the 1.7× would blow fixed-width cards (e.g. PSC(232)=394) far past the screen and clip the // controls off-side. Plain SC keeps those cards ≤232 px — a comfortable fit on the 284 px panel. #if CAP_LARGE_SCREEN && !CAP_ROUND_CORNERS static inline lv_coord_t PSC(int px) { return (lv_coord_t)((SC(px) * 17) / 10); } // ~1.7× — wide Tanmatsu only #else static inline lv_coord_t PSC(int px) { return SC(px); } #endif // Popup CARD WIDTH — PSC-scaled but clamped so a fixed-width dialog can never exceed the screen. // On the narrow round P4 (284 px logical) a Large/Huge font scale pushes PSC(232) to ~325-394 px, // which clipped settings cards off BOTH edges. The clamp keeps a small margin either side; it's a // no-op on the wide boards (their PSC widths already fit). Height is unaffected (tall screen). static inline lv_coord_t PCW(int px) { lv_coord_t w = PSC(px); lv_coord_t cap = lv_disp_get_hor_res(nullptr) - SC(12); return (cap > 0 && w > cap) ? cap : w; } #if CAP_UI_SIZE static lv_font_t g_font_tab; // fixed 16 px tab-bar icon font (montserrat_16 + person glyph) #endif // Compact UI chrome (tab icons, popup close affordances, overview actions) must // not grow with the semantic text preset. On the Pager's 222-px-tall panel a // Large-preset 24-px glyph inside fixed 28/32-px controls crowds neighbouring // content even though the control itself has not grown. Match the deliberately // fixed bottom toolbar wherever UI-size presets exist. static inline const lv_font_t* uiChromeFont() { #if CAP_UI_SIZE return &g_font_tab; #else return &g_font_16; #endif } // Jumbo is deliberately the Large UI with only conversation text taken to // the Pager's 24-px role. Keeping this separate avoids enlarging status chrome, // settings, timestamps, sender labels, and the composer on the Pager's short // display. Compact-chat rows use the same role because the message and metadata // share a single LVGL label there. static inline const lv_font_t* chatMessageFont() { #if defined(TLORA_PAGER) return touchPrefsGetUiScale() == 3 ? &g_font_16 : &g_font_12; #else return &g_font_12; #endif } static void initTouchFontFallbacks() { #if defined(TLORA_PAGER) // The Pager is wide but only 222 px tall. Grow the semantic text roles while // leaving SC() at 100%; globally scaling every row/card made content // unreachable. Layouts that need more room are bounded individually below. s_ui_fscale = 100; switch (touchPrefsGetUiScale()) { case 1: g_font_12 = lv_font_montserrat_16; g_font_14 = lv_font_montserrat_18; g_font_16 = lv_font_montserrat_20; break; case 2: case 3: // Jumbo keeps Large chrome; chat message text is bumped separately. g_font_12 = lv_font_montserrat_18; g_font_14 = lv_font_montserrat_20; g_font_16 = lv_font_montserrat_24; break; default: g_font_12 = lv_font_montserrat_12; g_font_14 = lv_font_montserrat_14; g_font_16 = lv_font_montserrat_16; break; } g_font_tab = lv_font_montserrat_16; #elif CAP_LARGE_SCREEN // Crisp "UI size": render bigger by swapping in larger built-in Montserrat fonts (NOT by // upscaling a low-res frame). g_font_12/14/16 are what the whole UI draws with, so this scales // every screen at once. The colour-emoji + non-Latin fallbacks stay their baked sizes (they don't // grow), which is fine for Latin text. g_font_tab is pinned to 16 px so the bottom bar never grows. // UI scale from the saved pref (Normal/Large/Huge -> 100/140/170%). The P4 honours it like the // Tanmatsu — its "UI size" dropdown is shown (CAP_LARGE_SCREEN), so pinning 100% here made that // setting a dead no-op (reported: "changing text size doesn't work"). At Large/Huge some P4 chrome // that still uses unscaled dims (status bar, home grid, list rows) can be tight; that is the lesser // problem and a per-screen SC() follow-up, not a reason to disable scaling outright. switch (touchPrefsGetUiScale()) { case 1: s_ui_fscale = 140; break; case 2: s_ui_fscale = 170; break; default: s_ui_fscale = 100; break; } switch (s_ui_fscale) { case 140: g_font_12 = lv_font_montserrat_16; g_font_14 = lv_font_montserrat_20; g_font_16 = lv_font_montserrat_24; break; // Large ~1.4x case 170: g_font_12 = lv_font_montserrat_20; g_font_14 = lv_font_montserrat_24; g_font_16 = lv_font_montserrat_28; break; // Huge ~1.7x default: g_font_12 = lv_font_montserrat_12; g_font_14 = lv_font_montserrat_14; g_font_16 = lv_font_montserrat_16; break; // Normal } g_font_tab = lv_font_montserrat_16; #else g_font_12 = lv_font_montserrat_12; g_font_14 = lv_font_montserrat_14; g_font_16 = lv_font_montserrat_16; #endif #if LV_USE_IMGFONT // Insert the colour-emoji image font as the tail of each chain: // g_font_NN (montserrat, Latin) -> emoji (colour images) -> extras_NN // (Cyrillic/Greek/Arabic). The emoji font returns false for non-emoji // codepoints, so they fall straight through to the size-matched extras. const lv_font_t* extras[3] = { &extras_12, &extras_14, &extras_16 }; #if defined(TLORA_PAGER) switch (touchPrefsGetUiScale()) { case 1: extras[0] = &extras_16; extras[1] = &extras_20; extras[2] = &extras_20; break; case 2: case 3: extras[0] = &extras_20; extras[1] = &extras_20; extras[2] = &extras_24; break; default: break; } #endif #if defined(HAS_TANMATSU) // At Large/Huge scale the primaries become Montserrat 20/24/28 (ASCII-only), // so accents dropped to the 16 px fallback and looked tiny. Splice the // size-matched Latin-accent font per slot; tail is extras_16 so non-Latin // still resolves. g_font sizes: Large(140) 16/20/24, Huge(170) 20/24/28. static lv_font_t s_acc_scaled[3]; const lv_font_t* acc[3] = { nullptr, nullptr, nullptr }; if (s_ui_fscale == 140) { acc[1] = &extras_lat_20; acc[2] = &extras_lat_24; } else if (s_ui_fscale == 170) { acc[0] = &extras_lat_20; acc[1] = &extras_lat_24; acc[2] = &extras_lat_28; } for (int i = 0; i < 3; ++i) { if (!acc[i]) continue; s_acc_scaled[i] = *acc[i]; s_acc_scaled[i].fallback = &extras_16; extras[i] = &s_acc_scaled[i]; } #endif lv_font_t* prim[3] = { &g_font_12, &g_font_14, &g_font_16 }; #if defined(TLORA_PAGER) // LVGL lays out every glyph in a fallback chain using the primary font's // line box. The generated OFL fallbacks need a taller box than Montserrat at // the Pager's accessible sizes, so carry those metrics onto the primary // copies. Without this, non-Latin and accented glyphs can clip and multiline // input grows by too little even though the fallback bitmap itself is the // requested size. if (touchPrefsGetUiScale() != 0) { for (int i = 0; i < 3; ++i) { if (extras[i]->line_height > prim[i]->line_height) { prim[i]->line_height = extras[i]->line_height; prim[i]->base_line = extras[i]->base_line; } } } #endif for (int i = 0; i < 3; ++i) { s_emoji_font[i] = lv_imgfont_create(16, emojiImgfontPathCb); // 16 px baked glyphs (~15% larger; sit on the text baseline) if (s_emoji_font[i]) { s_emoji_font[i]->fallback = extras[i]; prim[i]->fallback = s_emoji_font[i]; } else { prim[i]->fallback = extras[i]; } // OOM: plain chain } #else g_font_12.fallback = &extras_12; g_font_14.fallback = &extras_14; g_font_16.fallback = &extras_16; #endif // Contacts-tab person glyph: splice onto the HEAD of g_font_16's chain — the // tab bar's btnmatrix renders its icons in g_font_16 (g_font_16 -> person -> // [emoji ->] extras). One PUA codepoint (U+F007); it follows the tab's // active/inactive text colour like the other monochrome tab symbols. static lv_font_t s_person_font; s_person_font = person_font; s_person_font.fallback = g_font_16.fallback; g_font_16.fallback = &s_person_font; // Control-center glyphs (lock / bell / bell-slash): splice onto the HEAD of // g_font_16's chain too, so the CC chips (which render their icon in g_font_16) // resolve U+F023/F0F3/F1F6. Chain: montserrat_16 -> cc_icons -> person -> [emoji] -> extras. static lv_font_t s_cc_icons_font; s_cc_icons_font = cc_icons_16; s_cc_icons_font.fallback = g_font_16.fallback; g_font_16.fallback = &s_cc_icons_font; #if CAP_UI_SIZE // The fixed-size tab bar font needs the person glyph too (Contacts tab icon, U+F007) at 16 px. static lv_font_t s_tab_person; s_tab_person = person_font; s_tab_person.fallback = nullptr; g_font_tab.fallback = &s_tab_person; #endif // Map zoom magnifier (U+F002) — head of the g_font_16 chain (overlay buttons // render in g_font_16). One PUA codepoint; misses on it are free for plain text. static lv_font_t s_zoom_font; s_zoom_font = zoom_font; s_zoom_font.fallback = g_font_16.fallback; g_font_16.fallback = &s_zoom_font; // Sleep-state icons: sun (U+F185) + moon (U+F186). Spliced into all three // chains — g_font_12 for the status-bar sleep indicator, g_font_14 for the // battery-chart sleep markers, g_font_16 for any larger context. A single font // object can only sit in one chain at a time; use distinct static copies. static lv_font_t s_sleep_font_12; s_sleep_font_12 = sleepicons_font; s_sleep_font_12.fallback = g_font_12.fallback; g_font_12.fallback = &s_sleep_font_12; static lv_font_t s_sleep_font_14; s_sleep_font_14 = sleepicons_font; s_sleep_font_14.fallback = g_font_14.fallback; g_font_14.fallback = &s_sleep_font_14; static lv_font_t s_sleep_font_16; s_sleep_font_16 = sleepicons_font; s_sleep_font_16.fallback = g_font_16.fallback; g_font_16.fallback = &s_sleep_font_16; // Also reach the person/antenna/group glyphs from g_font_14 — the contact // action sheet title and the Chats-list rows render the icon inline with 14 px // text. Use the 14 px-rendered person_font14 (NOT the 16 px person_font) so the // glyph matches the line height instead of overshooting + clipping at the top. // fallback is only consulted on a glyph miss, so normal 14 px text pays nothing. static lv_font_t s_person_font14; s_person_font14 = person_font14; s_person_font14.fallback = g_font_14.fallback; g_font_14.fallback = &s_person_font14; } #if defined(MULTI_TRANSPORT_COMPANION) // Cross-core queued + in-flight tile count. This drives progress/memory gates; // backend ownership is tracked separately beside s_tile_fs so a queued backlog // does not unnecessarily delay a safe cache handoff. volatile uint16_t s_tile_fetch_pending = 0; static inline uint16_t tileFetchPendingLoad() { return __atomic_load_n(&s_tile_fetch_pending, __ATOMIC_ACQUIRE); } static inline void tileFetchPendingInc() { __atomic_fetch_add(&s_tile_fetch_pending, (uint16_t)1, __ATOMIC_RELEASE); } static inline void tileFetchPendingDec() { const uint16_t pending = tileFetchPendingLoad(); if (pending > 0) __atomic_fetch_sub(&s_tile_fetch_pending, (uint16_t)1, __ATOMIC_RELEASE); } #endif // ---- I2S notification sound (T-Deck MAX98357A amp / pager ES8311 codec) ---- // Synthesized beeps and small WAV playback for UI feedback (message arrived, // etc), both driven over I2S. The legacy genericBuzzer (RTTTL on a digital // pin) doesn't apply here — that's for boards with a piezo on a GPIO (the // Heltec V4), not an I2S speaker. WAV parsing and per-slot dispatch state // below are shared; T-Deck and the pager each get their own I2S install/ // tone/WAV functions, since the pager additionally drives an ES8311 codec // over I2C that the T-Deck's plain MAX98357A DAC doesn't have. #if CAP_AUDIO_STREAM static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); } static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); } static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) { char tag[4]; if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false; const uint32_t riff_size = wavRd32(f); const uint64_t riff_end = 8u + (uint64_t)riff_size; if (riff_size < 4 || riff_end > f.size() || riff_end > 0xFFFFFFFFu) return false; if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false; uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false; while ((uint64_t)f.position() + 8u <= riff_end) { if (f.read((uint8_t*)tag,4)!=4) break; uint32_t csz = wavRd32(f); if (!memcmp(tag,"fmt ",4)) { if (csz < 16) return false; fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f); const uint64_t skip = (uint64_t)(csz - 16) + (csz & 1u); const uint64_t next = (uint64_t)f.position() + skip; if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false; hf = true; } else if (!memcmp(tag,"data",4)) { if ((uint64_t)f.position() + csz > riff_end) return false; dlen = csz; hd = true; break; } else { const uint64_t skip = (uint64_t)csz + (csz & 1u); const uint64_t next = (uint64_t)f.position() + skip; if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false; } } if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000) return false; if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen; return true; } #endif #if CAP_LUA_AUDIO static volatile bool s_lua_audio_active = false; static volatile uint32_t s_lua_audio_storage_pending = 0; static volatile bool s_lua_audio_storage_active = false; static inline bool luaAudioActive() { return __atomic_load_n(&s_lua_audio_active, __ATOMIC_ACQUIRE); } static inline bool luaAudioStorageBusy() { return __atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0 || __atomic_load_n(&s_lua_audio_storage_active, __ATOMIC_ACQUIRE); } #endif #if defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9) static bool fmSdTryMount(); // V4-R8/M9 microSD — fwd decl (defined in the mount-helper block below; sdRestoreRun needs it) #endif #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) static constexpr int kI2sSampleRate = 16000; static constexpr i2s_port_t kI2sPort = I2S_NUM_0; // The tile fetcher's in-flight counter (defined later in the file). We skip // beeping while tiles are downloading — the I2S DMA buffers + Wi-Fi RX DMA + a // tile decode all contend for the scarce internal DMA RAM, and this build is // already tight enough that tile downloads can OOM-reboot on their own. #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) static bool fmSdTryMount(); // defined far below (microSD mount) #endif #if defined(HAS_TDECK_GT911) // I2S is installed ON DEMAND for the duration of a tone and uninstalled after. // Holding the driver resident permanently kept ~2 KB of internal DMA RAM, which // shrank the margin the tile-fetch worker relies on and made tile downloads // OOM-reboot. Transient install keeps steady-state internal RAM untouched. static bool tdeckAudioInstallRate(int rate) { // Heap pre-flight. i2s_driver_install ESP_ERROR_CHECKs its internal DMA + timer // allocations and abort()s the firmware on NO_MEM — this is the "esp_timer_create // ESP_ERR_NO_MEM" crash testers hit toggling sound while BLE + Wi-Fi are both up // and internal DRAM is exhausted. Skip the chime silently when it's too tight // rather than crash. (Buffer sizing is separate; this is just a guard.) if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) < 16 * 1024) return false; i2s_config_t cfg = {}; cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); cfg.sample_rate = rate; cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; cfg.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; // MAX98357A is mono cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; cfg.intr_alloc_flags = 0; cfg.dma_buf_count = 4; cfg.dma_buf_len = 256; cfg.use_apll = false; cfg.tx_desc_auto_clear = true; if (i2s_driver_install(kI2sPort, &cfg, 0, nullptr) != ESP_OK) return false; i2s_pin_config_t pins = {}; pins.mck_io_num = I2S_PIN_NO_CHANGE; pins.bck_io_num = PIN_I2S_BCK; pins.ws_io_num = PIN_I2S_WS; pins.data_out_num = PIN_I2S_DOUT; pins.data_in_num = I2S_PIN_NO_CHANGE; if (i2s_set_pin(kI2sPort, &pins) != ESP_OK) { i2s_driver_uninstall(kI2sPort); return false; } return true; } static bool tdeckAudioInstall() { return tdeckAudioInstallRate(kI2sSampleRate); } // Render `freq` Hz for `ms` ms into the already-installed I2S as a 16-bit sine // with a short fade-in/out so it doesn't click. static void tdeckPlayToneRaw(int freq, int ms, int vol = 9000) { const int total = (kI2sSampleRate * ms) / 1000; const int fade = total / 8 > 0 ? total / 8 : 1; int16_t buf[128]; int written_total = 0; double phase = 0.0; const double step = 2.0 * M_PI * (double)freq / (double)kI2sSampleRate; while (written_total < total) { int n = 0; for (; n < 128 && written_total < total; ++n, ++written_total) { double amp = (double)vol; if (written_total < fade) amp *= (double)written_total / fade; else if (written_total > total-fade) amp *= (double)(total - written_total)/fade; buf[n] = (int16_t)(sin(phase) * amp); phase += step; if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI; } size_t bw = 0; i2s_write(kI2sPort, buf, n * sizeof(int16_t), &bw, pdMS_TO_TICKS(200)); } i2s_zero_dma_buffer(kI2sPort); } #endif // HAS_TDECK_GT911 #if defined(TLORA_PAGER) // Same on-demand-install rationale as the T-Deck (above), plus this board's // ES8311 codec: I2S clocks (incl. MCLK) must already be toggling before the // codec's PLL will lock, so codec register writes happen after i2s_set_pin. // The codec chip itself stays powered across chimes (its own begin() runs // once, lazily, on first use) -- only the I2S driver and the codec's DAC // power/format state (start()/suspend()) are cycled per playback, matching // the amp's AMP_EN toggle in TLoraPagerBoard (see pagerNotifyTaskFn below). static Es8311Codec s_pager_codec; static bool s_pager_codec_begun = false; static bool pagerAudioInstallRate(int rate) { if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) < 16 * 1024) return false; i2s_config_t cfg = {}; cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); cfg.sample_rate = rate; cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; cfg.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; // mono synth/WAV buffer cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; cfg.intr_alloc_flags = 0; cfg.dma_buf_count = 4; cfg.dma_buf_len = 256; cfg.use_apll = false; cfg.tx_desc_auto_clear = true; if (i2s_driver_install(kI2sPort, &cfg, 0, nullptr) != ESP_OK) return false; i2s_pin_config_t pins = {}; pins.mck_io_num = PIN_I2S_MCLK; pins.bck_io_num = PIN_I2S_BCK; pins.ws_io_num = PIN_I2S_WS; pins.data_out_num = PIN_I2S_DOUT; pins.data_in_num = I2S_PIN_NO_CHANGE; if (i2s_set_pin(kI2sPort, &pins) != ESP_OK) { i2s_driver_uninstall(kI2sPort); return false; } if (!s_pager_codec_begun) { s_pager_codec_begun = s_pager_codec.begin(Wire, 0x18); if (!s_pager_codec_begun) { i2s_driver_uninstall(kI2sPort); return false; } } if (!s_pager_codec.start((uint32_t)rate)) { i2s_driver_uninstall(kI2sPort); return false; } return true; } static bool pagerAudioInstall() { return pagerAudioInstallRate(kI2sSampleRate); } static void pagerAudioUninstall() { s_pager_codec.setMute(true); s_pager_codec.suspend(); i2s_zero_dma_buffer(kI2sPort); i2s_driver_uninstall(kI2sPort); } // Render `freq` Hz for `ms` ms into the already-installed I2S as a 16-bit // sine with a short fade-in/out so it doesn't click. Unlike the T-Deck's // tdeckPlayToneRaw, loudness comes from the codec's hardware volume register // (set by the caller before this runs), not software sample scaling, so // there's no `vol` parameter here -- always render at a fixed safe level. static void pagerPlayToneRaw(int freq, int ms) { const int total = (kI2sSampleRate * ms) / 1000; const int fade = total / 8 > 0 ? total / 8 : 1; int16_t buf[128]; int written_total = 0; double phase = 0.0; const double step = 2.0 * M_PI * (double)freq / (double)kI2sSampleRate; const double amp0 = 22000.0; while (written_total < total) { int n = 0; for (; n < 128 && written_total < total; ++n, ++written_total) { double amp = amp0; if (written_total < fade) amp *= (double)written_total / fade; else if (written_total > total-fade) amp *= (double)(total - written_total)/fade; buf[n] = (int16_t)(sin(phase) * amp); phase += step; if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI; } size_t bw = 0; i2s_write(kI2sPort, buf, n * sizeof(int16_t), &bw, pdMS_TO_TICKS(200)); } i2s_zero_dma_buffer(kI2sPort); } #endif // TLORA_PAGER // ---- Custom WAV notification playback (T-Deck I2S) ------------------------- // Stream a small PCM WAV (internal SPIFFS or "sd:"-prefixed SD) straight to the // amp. Supports PCM 16-bit, mono or stereo (downmixed to the mono amp), sample // rate read from the header; capped to ~6 s so a stray big file can't hold the // notify task. Any problem -> false, and the caller falls back to the chime. static bool wavOpen(const char* prefpath, File& f) { if (!prefpath || !prefpath[0]) return false; fs::FS* fsp = &SPIFFS; const char* fp = prefpath; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) // only the T-Deck/pager sound picker ever writes an "sd:"-prefixed pref // No fmSdTryMount() here: this runs on the throwaway notify task, and changing // the SD VFS lifecycle from a second task races the loop task's own SD use. // If the card // isn't mounted the open below fails fast and the chime falls back; mounting // is owned by boot adoption / sdHealthTick's reinsert watch / the FM paths. if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; } #endif f = fsp->open(fp, FILE_READ); if (!f || f.isDirectory()) { if (f) f.close(); return false; } return true; } static bool wavIsSupported(const char* prefpath) { File f; if (!wavOpen(prefpath, f)) return false; bool ok = wavParse(f, nullptr, nullptr, nullptr); f.close(); return ok; } // ---- Shared notification-task state (T-Deck + pager) ----------------------- static volatile bool s_notify_playing = false; static volatile int s_notify_slot = TOUCH_SND_MSG; // which per-event sound to play this round static char s_notify_path[TOUCH_SOUND_PATH_MAXLEN] = {0}; // caller-resolved WAV path (avoid NVS in the task) static volatile int s_notify_vol = 9000; // meaning is board-specific: T-Deck = software amplitude, pager = 0-100 hw volume pct #if defined(HAS_TDECK_GT911) static bool tdeckPlayWavFile(const char* prefpath, int vol) { File f; if (!wavOpen(prefpath, f)) return false; uint16_t ch=0; uint32_t rate=0, dlen=0; if (!wavParse(f, &ch, &rate, &dlen)) { f.close(); return false; } const uint32_t frameBytes = (uint32_t)ch * 2u; const uint32_t maxBytes = rate * frameBytes * 6u; // ~6 s cap if (dlen > maxBytes) dlen = maxBytes; if (!tdeckAudioInstallRate((int)rate)) { f.close(); return false; } const float gain = (float)vol / 13000.0f; int16_t in[256], out[256]; uint32_t remaining = dlen; while (remaining >= frameBytes) { size_t want = sizeof(in); if (want > remaining) want = remaining - (remaining % frameBytes); int got = f.read((uint8_t*)in, want); if (got <= 0) break; int frames = got / (int)frameBytes; for (int i = 0; i < frames; ++i) { int32_t smp = (ch == 2) ? (((int32_t)in[2*i] + in[2*i+1]) / 2) : in[i]; smp = (int32_t)(smp * gain); if (smp > 32767) smp = 32767; else if (smp < -32768) smp = -32768; out[i] = (int16_t)smp; } size_t bw = 0; i2s_write(kI2sPort, out, (size_t)frames * sizeof(int16_t), &bw, pdMS_TO_TICKS(300)); remaining -= (uint32_t)got; } i2s_zero_dma_buffer(kI2sPort); i2s_driver_uninstall(kI2sPort); f.close(); return true; } // The chime body: install I2S, play the notes, uninstall. ~300 ms of blocking // i2s_write + driver setup/teardown — run on its own throwaway task (below). static void tdeckNotifyTaskFn(void* arg) { (void)arg; const int v = s_notify_vol; // Custom WAV for this slot (if set + valid) — installs/uninstalls I2S itself. // Path was resolved on the caller thread (s_notify_path) to keep NVS access // off this short-lived task. bool played = (s_notify_path[0] && tdeckPlayWavFile(s_notify_path, v)); if (!played && tdeckAudioInstall()) { // built-in chime fallback (distinct per slot) if (s_notify_slot == TOUCH_SND_MEN) { // @-mention: bright 3-note rising arpeggio tdeckPlayToneRaw(1318, 70, v); // E6 tdeckPlayToneRaw(1760, 70, v); // A6 tdeckPlayToneRaw(2349, 130, v); // D7 } else if (s_notify_slot == TOUCH_SND_DM) { // direct message: distinct rising fifth tdeckPlayToneRaw(1047, 90, v); // C6 tdeckPlayToneRaw(1568, 120, v); // G6 } else { // message: the original two-note chime tdeckPlayToneRaw(880, 90, v); // A5 tdeckPlayToneRaw(1318, 110, v); // E6 } i2s_driver_uninstall(kI2sPort); } s_notify_playing = false; vTaskDelete(nullptr); } // Notification chime. Spawned on a short-lived task so the ~300 ms of I2S work // does NOT freeze the UI — newMsgImpl (the caller) runs on the UI/mesh thread, so // playing synchronously locked the screen for the whole chime. Skips while tiles // download (DMA-RAM contention → reboot) and won't stack itself. Caller checks the // sound pref. static void tdeckPlayNotifySlot(int slot) { if (tileFetchPendingLoad() > 0) return; // don't fight the Wi-Fi/tile DMA buffers if (s_notify_playing) return; // already chiming — don't stack tasks/I2S s_notify_slot = slot; s_notify_vol = (int)touchPrefsGetSoundVolume() * 130; // 0..100 -> 0..13000 amplitude touchPrefsGetSoundFile(slot, s_notify_path, sizeof s_notify_path); // resolve here, not in the task s_notify_playing = true; // bigger stack than the tone-only chime: WAV path uses a File handle + buffers. if (xTaskCreate(tdeckNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) { s_notify_playing = false; // couldn't spawn (low DRAM) — skip the chime } } // Preview an arbitrary WAV (not yet saved to a slot) on the notify task. static void tdeckPreviewWavFile(const char* prefpath) { if (tileFetchPendingLoad() > 0 || s_notify_playing) return; strncpy(s_notify_path, prefpath, sizeof s_notify_path - 1); s_notify_path[sizeof s_notify_path - 1] = '\0'; s_notify_slot = TOUCH_SND_MSG; // if the file fails, the task plays the msg chime s_notify_vol = (int)touchPrefsGetSoundVolume() * 130; s_notify_playing = true; if (xTaskCreate(tdeckNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) s_notify_playing = false; } #endif // HAS_TDECK_GT911 #if defined(TLORA_PAGER) static bool pagerPlayWavFile(const char* prefpath, int volPct) { File f; if (!wavOpen(prefpath, f)) return false; uint16_t ch=0; uint32_t rate=0, dlen=0; if (!wavParse(f, &ch, &rate, &dlen)) { f.close(); return false; } const uint32_t frameBytes = (uint32_t)ch * 2u; const uint32_t maxBytes = rate * frameBytes * 6u; // ~6 s cap, matches the T-Deck path if (dlen > maxBytes) dlen = maxBytes; if (!pagerAudioInstallRate((int)rate)) { f.close(); return false; } s_pager_codec.setVolumePercent((uint8_t)volPct); s_pager_codec.setMute(false); int16_t in[256], out[256]; uint32_t remaining = dlen; while (remaining >= frameBytes) { size_t want = sizeof(in); if (want > remaining) want = remaining - (remaining % frameBytes); int got = f.read((uint8_t*)in, want); if (got <= 0) break; int frames = got / (int)frameBytes; for (int i = 0; i < frames; ++i) { // Downmix to mono -- no software gain (unlike the T-Deck path): the // codec's hardware volume register, already set above, is doing that. out[i] = (ch == 2) ? (int16_t)(((int32_t)in[2*i] + in[2*i+1]) / 2) : in[i]; } size_t bw = 0; i2s_write(kI2sPort, out, (size_t)frames * sizeof(int16_t), &bw, pdMS_TO_TICKS(300)); remaining -= (uint32_t)got; } i2s_zero_dma_buffer(kI2sPort); pagerAudioUninstall(); f.close(); return true; } // Mirrors tdeckNotifyTaskFn, bracketed with the amp's AMP_EN toggle (muted/ // off at idle to save battery -- see TLoraPagerBoard::setAmpEnabled()). static void pagerNotifyTaskFn(void* arg) { (void)arg; const int volPct = s_notify_vol; board.setAmpEnabled(true); bool played = (s_notify_path[0] && pagerPlayWavFile(s_notify_path, volPct)); if (!played && pagerAudioInstall()) { s_pager_codec.setVolumePercent((uint8_t)volPct); s_pager_codec.setMute(false); if (s_notify_slot == TOUCH_SND_MEN) { // @-mention: bright 3-note rising arpeggio pagerPlayToneRaw(1318, 70); // E6 pagerPlayToneRaw(1760, 70); // A6 pagerPlayToneRaw(2349, 130); // D7 } else if (s_notify_slot == TOUCH_SND_DM) { // direct message: distinct rising fifth pagerPlayToneRaw(1047, 90); // C6 pagerPlayToneRaw(1568, 120); // G6 } else { // message: the original two-note chime pagerPlayToneRaw(880, 90); // A5 pagerPlayToneRaw(1318, 110); // E6 } pagerAudioUninstall(); } board.setAmpEnabled(false); s_notify_playing = false; vTaskDelete(nullptr); } static void pagerPlayNotifySlot(int slot) { if (tileFetchPendingLoad() > 0) return; if (s_notify_playing) return; s_notify_slot = slot; s_notify_vol = (int)touchPrefsGetSoundVolume(); // 0..100 -> the codec's hw volume register touchPrefsGetSoundFile(slot, s_notify_path, sizeof s_notify_path); s_notify_playing = true; if (xTaskCreate(pagerNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) { s_notify_playing = false; } } static void pagerPreviewWavFile(const char* prefpath) { if (tileFetchPendingLoad() > 0 || s_notify_playing) return; strncpy(s_notify_path, prefpath, sizeof s_notify_path - 1); s_notify_path[sizeof s_notify_path - 1] = '\0'; s_notify_slot = TOUCH_SND_MSG; s_notify_vol = (int)touchPrefsGetSoundVolume(); s_notify_playing = true; if (xTaskCreate(pagerNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) s_notify_playing = false; } #endif // TLORA_PAGER #endif // HAS_TDECK_GT911 || TLORA_PAGER // ---- Unified UI notification sound (T-Deck I2S / pager codec / Heltec V4 + Elecrow M9 // piezo / T-Display P4 ES8311 codec) ---- #if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(TLORA_PAGER) || defined(THINKNODE_M9_BUZZER_PIN) || defined(HAS_TDISPLAY_P4) #define HAS_UI_SOUND 1 #endif #if defined(HAS_TDISPLAY_P4) // T-Display P4: ES8311 codec + NS4150B speaker (P4Audio.cpp in the variant). Same three // chime patterns as the T-Deck, rendered on a throwaway task so the ~300 ms of blocking // I2S writes never stalls the UI/mesh loop. Loudness = amplitude from the volume pref // (the codec DAC volume stays fixed). #include static volatile bool s_p4_snd_playing = false; static volatile int s_p4_snd_slot = TOUCH_SND_MSG; static volatile int s_p4_snd_vol = 9000; static void p4NotifyTaskFn(void*) { const int v = s_p4_snd_vol; if (s_p4_snd_slot == TOUCH_SND_MEN) { // @-mention: bright 3-note rising arpeggio p4AudioTone(1318, 70, v); // E6 p4AudioTone(1760, 70, v); // A6 p4AudioTone(2349, 130, v); // D7 } else if (s_p4_snd_slot == TOUCH_SND_DM) { // direct message: distinct rising fifth p4AudioTone(1047, 90, v); // C6 p4AudioTone(1568, 120, v); // G6 } else { // message: the original two-note chime p4AudioTone(880, 90, v); // A5 p4AudioTone(1318, 110, v); // E6 } s_p4_snd_playing = false; vTaskDelete(nullptr); } static void p4PlayNotifySlot(int slot) { if (s_p4_snd_playing) return; // already chiming — don't stack tasks const int pct = (int)touchPrefsGetSoundVolume(); if (pct <= 0) return; s_p4_snd_slot = slot; s_p4_snd_vol = pct * 130; // 0..100 -> 0..13000 amplitude s_p4_snd_playing = true; if (xTaskCreate(p4NotifyTaskFn, "notify", 4096, nullptr, 3, nullptr) != pdPASS) { s_p4_snd_playing = false; // couldn't spawn — skip the chime } } #endif #if defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) #if defined(HELTEC_V4_BUZZER_PIN) #define UI_BUZZER_PIN HELTEC_V4_BUZZER_PIN #else #define UI_BUZZER_PIN THINKNODE_M9_BUZZER_PIN #endif // Simple piezo buzzer, GPIO-driven via Arduino tone()/noTone() (Heltec V4 expansion kit, // or the ThinkNode M9's onboard buzzer/BUZZER_EN). Plays a short two-note chime via LEDC // on a throwaway task so the ~210 ms doesn't stall the UI thread. static volatile bool s_v4_beep_playing = false; static volatile bool s_v4_mention = false; static void v4BeepTaskFn(void* arg) { (void)arg; if (s_v4_mention) { tone(UI_BUZZER_PIN, 1500); vTaskDelay(pdMS_TO_TICKS(110)); tone(UI_BUZZER_PIN, 2000); vTaskDelay(pdMS_TO_TICKS(110)); tone(UI_BUZZER_PIN, 2600); vTaskDelay(pdMS_TO_TICKS(170)); } else { tone(UI_BUZZER_PIN, 1000); vTaskDelay(pdMS_TO_TICKS(160)); tone(UI_BUZZER_PIN, 1500); vTaskDelay(pdMS_TO_TICKS(160)); tone(UI_BUZZER_PIN, 2000); vTaskDelay(pdMS_TO_TICKS(200)); } noTone(UI_BUZZER_PIN); pinMode(UI_BUZZER_PIN, INPUT); // high-Z → no idle current / no buzz s_v4_beep_playing = false; vTaskDelete(nullptr); } static void v4BuzzerBeep(bool mention) { if (s_v4_beep_playing) return; s_v4_mention = mention; s_v4_beep_playing = true; if (xTaskCreate(v4BeepTaskFn, "v4beep", 2048, nullptr, 3, nullptr) != pdPASS) s_v4_beep_playing = false; } #endif #if defined(HAS_TANMATSU) static void tanBeep(); // I2S notification tick; defined far below (with the CC volume slider) #endif // Play the platform's notification chime. Caller checks the buzzer/sound pref. static inline void uiPlaySlot(int slot) { #if CAP_LUA_AUDIO if (luaAudioActive()) return; #endif #if defined(HAS_TDECK_GT911) tdeckPlayNotifySlot(slot); #elif defined(HAS_TDISPLAY_P4) p4PlayNotifySlot(slot); // ES8311 codec chimes (same patterns as the T-Deck) #elif defined(TLORA_PAGER) pagerPlayNotifySlot(slot); #elif defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) v4BuzzerBeep(slot == TOUCH_SND_MEN); // mention = higher trill; msg/DM = the lower chime #elif defined(HAS_TANMATSU) tanBeep(); // single codec tick on these boards (no per-slot sounds) #endif (void)slot; } // Notification chimes. uiPlayNotify = generic message slot; uiPlayMention = @-mention slot. static inline void uiPlayNotify() { uiPlaySlot(TOUCH_SND_MSG); } static inline void uiPlayMention() { uiPlaySlot(TOUCH_SND_MEN); } // Preview an arbitrary WAV file (not yet saved to a slot) -- used by the // sound picker's "play" button before the user commits to a choice. static inline void uiPreviewWavFile(const char* path) { #if CAP_LUA_AUDIO if (luaAudioActive()) return; #endif #if defined(HAS_TDECK_GT911) tdeckPreviewWavFile(path); #elif defined(TLORA_PAGER) pagerPreviewWavFile(path); #endif (void)path; } #if defined(HAS_TANMATSU) // Defined in the HAS_TANMATSU apply block far below; forward-declared so the // Sound control-center chip (ccSoundCb) can chime through the I2S codec. static void tanBeep(); // Live codec volume (ES8156). Defined far below with the CC volume slider; forward- // declared so the Sound settings page (buildDeviceSettings, above it) can apply the // volume live as the user adjusts it. static void applyVolume(uint8_t pct); #elif defined(HAS_TDISPLAY_P4) // Same forward-declaration for the P4 (defined in its apply block far below): the // Sound settings volume buttons keep the CC slider state in sync through it. static void applyVolume(uint8_t pct); #endif // ---- misc UI constants ---- constexpr int SWIPE_SCROLL_STEP = 90; // ---- Global status bar (lv_layer_sys → drawn above everything) ---- // Always-visible 240×22 strip across the top of the display. Left zone is // dynamic (MESHCOMOD on Home tab, mail-icon + total-unread count on the // other tabs). Right zone is a fixed stack: connection icon (WiFi or // BLE, whichever is up), clock, battery percent, battery icon. The bar // sits on lv_layer_sys so modals on lv_layer_top can't cover it, and the // tabview / chat overlay are explicitly sized to leave the top 22 px // clear. // Runtime (not constexpr) so the UI-scale can grow it to fit bigger status-bar text — set to SC(22) // once at boot in begin(), before the UI is built. Stays 22 on the non-scaled boards. static lv_coord_t STATUSBAR_H = 22; #if CAP_ROUND_CORNERS // Two-row status bar for the round-cornered phone panel: row 1 (top) holds the app name + // clock, row 2 (bottom) holds the wifi/ble/sd/signal/battery cluster. A top pad clears the // corner arc; a horizontal inset keeps the end glyphs out of the left/right arcs. STATUSBAR_H // is set to (top pad + two rows) at boot so every page reserves the taller bar, and the bar // stays this fixed height in ALL states (no *2 doubling — the tall personalities reuse the // two rows). Text tops (ROW1_Y / ROW2_Y) are tuned so the small g_font glyphs sit centred. static constexpr lv_coord_t SB_ROW = 22; // one status-bar row static constexpr lv_coord_t SB_TOP_PAD = 8; // clear the top corner arc static constexpr lv_coord_t SB_INSET_X = 16; // left/right corner safe-area static constexpr lv_coord_t SB_ROW1_Y = SB_TOP_PAD + 2; // ~10 text top, row 1 (clock) static constexpr lv_coord_t SB_ROW2_Y = SB_TOP_PAD + SB_ROW + 3; // ~33 text top, row 2 (name + status) static void statusBarLayoutTwoRow(int slide); // defined just after buildGlobalStatusBar #endif // True while the Reader/Web page is collapsed and showing its URL in the status bar's // title zone — updateGlobalStatusBar then hides the clock to make room for the URL. static bool s_reader_bar_url = false; // True whenever the Reader/Web page is open — suppresses the tall status-bar glass // fade so the address reads on a solid bar (no gradient behind it). static bool s_reader_page_open = false; struct GlobalStatusBar { lv_obj_t* root; lv_obj_t* left_label; lv_obj_t* conn_icon; // Wi-Fi glyph lv_obj_t* ble_icon; // Bluetooth glyph (separate from Wi-Fi) lv_obj_t* sleep_icon; // idle light-sleep readiness indicator (T-Deck only) lv_obj_t* dnd_icon; // Do Not Disturb active glyph (moon, all boards) lv_obj_t* sd_icon; // microSD read/write activity LED (left of Wi-Fi) lv_obj_t* async_icon; // async mesh-request spinner glyph (centre, transient) lv_obj_t* clock; lv_obj_t* batt_pct; lv_obj_t* batt_icon; lv_obj_t* layout_label; // EN / BG indicator, right side lv_obj_t* chan_gear; // channel-settings gear, left of the thread name (channels only) lv_obj_t* sig_bars[4]; // mesh signal-strength bars (lit count = last-heard SNR) lv_obj_t* sig_box; // container holding sig_bars (slides over when charging hides the %) lv_obj_t* inbox_add; // chat/channel-overview actions: [+ add | ✓ read | QR] lv_obj_t* inbox_mark; lv_obj_t* inbox_qr; lv_obj_t* chat_back; // "‹" affordance shown while in a chat (tap the bar = close) lv_obj_t* fade; // glass backdrop for the lower row in a chat/overview: top-row solid, // fading to translucent at the bottom so the chat shows through it lv_obj_t* dim; // full-bar scrim shown while a modal popup is up, so the bar dims with // the rest of the screen (the popup's own backdrop starts below it) }; static GlobalStatusBar g_statusbar = {}; static void updateGlobalStatusBar(); // fwd decl, called from refresh tick // Settings detail pages render the global status bar at DOUBLE height: the bar's // back chevron + page title then read like a tall title bar (the whole bar still // taps to go Back — see statusBarTapCb), and the extra room is free for future // per-page header content. updateGlobalStatusBar enlarges the back label while tall. static bool s_statusbar_tall = false; // Generic "app page" hook so full-screen tool pages (RF Monitor, Spectrum) get the SAME // tall status-bar + back-chevron treatment as settings detail pages — NOT a second header // bar. When s_apppage_title is set, updateGlobalStatusBar goes tall and paints "‹ " // centred in the bar; a tap on the bar calls s_apppage_close (see statusBarTapCb). static const char* s_apppage_title = nullptr; static void (*s_apppage_close)() = nullptr; // Slim variant: the page keeps the "‹ title" + tap-to-close bar behavior but the // bar stays ONE line — no double-height glass row (the Lua Store's tab bar sits // right at the top of its content, which the glass row used to sit over). static bool s_apppage_slim = false; #if CAP_ROUND_CORNERS // The round-panel bar is already two rows tall in every state — the tall personalities // (settings title, inbox actions, open chat) reuse the two rows rather than doubling it. static inline lv_coord_t statusBarCurH() { return STATUSBAR_H; } #else static inline lv_coord_t statusBarCurH() { return s_statusbar_tall ? (lv_coord_t)(STATUSBAR_H * 2) : STATUSBAR_H; } #endif static void statusBarSetTall(bool tall) { s_statusbar_tall = tall; if (g_statusbar.root) lv_obj_set_height(g_statusbar.root, statusBarCurH()); // (updateGlobalStatusBar drives this every tick + refreshes the left zone; it must // NOT be called from here — it calls back into statusBarSetTall = recursion.) } void reserveTileFetchStack(); // fwd: claim the worker stack before Wi-Fi eats the heap // ---- AppPage: the shared full-screen app-page chrome (see AppPage.h) -------------- // Thin wrappers over the machinery just above, exported so the self-contained app // modules (SnakeGame) build the same page as the in-file tool // pages instead of hand-rolling it against a hardcoded bar height. lv_coord_t appPageContentTop() { return STATUSBAR_H; } lv_coord_t appPageContentH() { return (lv_coord_t)(lv_disp_get_ver_res(nullptr) - STATUSBAR_H); } lv_obj_t* appPageCreateRoot(uint32_t bg_color) { lv_obj_t* root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(root); lv_obj_set_size(root, lv_disp_get_hor_res(nullptr), appPageContentH()); lv_obj_set_pos(root, 0, appPageContentTop()); lv_obj_set_style_bg_color(root, lv_color_hex(bg_color), LV_PART_MAIN); lv_obj_set_style_bg_opa(root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); // CLICKABLE is what makes the page modal: every press lands here instead of falling // through to the tab bar / list underneath. lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); return root; } void appPageBegin(const char* title, void (*close_fn)()) { s_apppage_title = title; s_apppage_close = close_fn; s_apppage_slim = false; statusBarSetTall(true); updateGlobalStatusBar(); } void appPageBeginSlim(const char* title, void (*close_fn)()) { s_apppage_title = title; s_apppage_close = close_fn; s_apppage_slim = true; statusBarSetTall(false); updateGlobalStatusBar(); } void appPageEnd(void (*close_fn)()) { if (s_apppage_close != close_fn) return; // a different page owns the bar now — leave it s_apppage_title = nullptr; s_apppage_close = nullptr; s_apppage_slim = false; statusBarSetTall(false); updateGlobalStatusBar(); } void appPageDeleteRootAsync(lv_obj_t* root) { if (root) lv_obj_del_async(root); } static const char* tsBlockReason(); // fwd decl (defined near idle-sleep hooks) static const char* tsWakeReasonStr(touchSleep::WakeReason r); // fwd decl (defined near idle-sleep hooks) static void batteryTapCb(lv_event_t* e); // fwd decl (defined near the battery chart) — Settings "Battery" button reuses it // microSD read/write activity. markSdIo() stamps the time of the last SD access // (SD-backed tile cache, SD tile packs, file manager, mount). UITask::loop lights // a small LED in the status bar for a short window after each access. Cost on the // hot path is one 32-bit timestamp store; the loop toggle is edge-triggered so it // only touches LVGL when the lit/dark state actually flips. Written from both // cores (core-0 fetch task + core-1 UI) — an aligned word store is atomic on the // S3, and a stale read only ever mistimes the LED by a frame, which is harmless. static volatile uint32_t g_sd_io_ms = 0; static inline void markSdIo() { const uint32_t t = millis(); g_sd_io_ms = t ? t : 1; } // Async mesh-request activity: a small spinner glyph in the status bar while a // request is sending or awaiting a reply. markMeshRequest() stamps a send; the // indicator also stays lit while a UI reply is pending (s_ui_ping_deadline_ms, // telemetry/ping) or the sightline worker is busy (s_los_busy). static volatile uint32_t g_mesh_req_ms = 0; static inline void markMeshRequest() { const uint32_t t = millis(); g_mesh_req_ms = t ? t : 1; } // How long the LED stays lit after the last SD access (ms). Long enough that a // single quick read is visible, short enough to read as "activity" not "on". static constexpr uint32_t k_sd_io_led_ms = 180; // Active chat thread name, surfaced in the status bar's left zone. The in-chat // header bar (back button + name) was removed to give the conversation the full // height — like the Terminal/Files views, the chat name lives in the status bar // and a small floating HOME button handles exit. Empty = no chat open. static char s_chat_title[40] = {0}; static void setChatStatusTitle(const char* sanitized) { // already glyph-sanitized name, or nullptr to clear if (sanitized && sanitized[0]) { strncpy(s_chat_title, sanitized, sizeof(s_chat_title) - 1); s_chat_title[sizeof(s_chat_title) - 1] = '\0'; } else { s_chat_title[0] = '\0'; } updateGlobalStatusBar(); } /** Bottom tab indices. Left→right: Chats(0), Contacts(1), Home(2), Map(3)[, Sensors][, Settings]. */ // Bottom-bar order, left→right: Chats, Contacts, Home (middle), Map[, Sensors], Settings. // On Heltec V4 (HAS_EXPANSION_KIT) a 6th "Sensors" tab is inserted before Settings, // shifting Settings from index 4 → 5; other touch boards keep the 5-tab layout. constexpr int CHAT_INBOX_TAB_INDEX = 0; constexpr int CONTACTS_TAB_INDEX = 1; constexpr int HOME_TAB_INDEX = 2; constexpr int MAP_TAB_INDEX = 3; #if defined(HAS_EXPANSION_KIT) // Runtime (not constexpr): the Sensors tab is only inserted when an environment // sensor is present AND the user pref is on (see sensorsUiWanted()), so its // index — and Settings' index after it — are decided in buildUiTree. Defaults // here are the "no Sensors tab" layout (Settings at 4); buildUiTree promotes // them to 4/5 when the Sensors tab is added. SENSORS_TAB_INDEX = -1 means // "absent", so every `== SENSORS_TAB_INDEX` refresh check simply never matches. // These are only ever read as plain ints (comparisons / nav args), so mutable // is fine; CHAT/CONTACTS/HOME/MAP stay constexpr. static int SENSORS_TAB_INDEX = -1; static int SETTINGS_TAB_INDEX = 4; static int TAB_LAST = 4; #else constexpr int SETTINGS_TAB_INDEX = 4; constexpr int TAB_LAST = 4; #endif // Objects flagged thus are clickable but must NOT be a keyboard-nav focus target — // navCollect skips them. Used for the full-area map touch/pan catcher, which would // otherwise get focused and painted solid by the focus highlight (white over the map). #define NAV_SKIP_FLAG LV_OBJ_FLAG_USER_1 // A "horizontal-only" secondary keyboard-nav target (e.g. the per-row settings gear in the chat // list): UP/DOWN skip it so vertical nav walks the primary rows, RIGHT/LEFT reach it, and — the // key part — it does NOT suppress its parent row from being harvested. Without this, navCollect's // leaf rule (a clickable with a clickable child is treated as a container) picks the gear over the // row, leaving the row un-focusable so keyboard users could only open thread-settings, never the chat. #define NAV_HMOVE_FLAG LV_OBJ_FLAG_USER_2 // An object whose keyboard-nav focus highlight should be an ACCENT-coloured // tint (matching its own touch-press feedback) instead of navFocusCb's // default white reverse-video fill. Used by app-drawer tiles (addAppTile): // a solid white block over a small rounded icon chip reads as a glitch, not // a highlight — while on the pager (no touch, so this IS the only feedback // a user ever sees on these tiles) it needs to look intentional. Reuses the // exact same COLOR_ACCENT tint the tile's own LV_STATE_PRESSED style already // defines, so a keyboard/encoder-focused tile matches what a touch/trackball // press already looks like on every board. #define NAV_ACCENTFOCUS_FLAG LV_OBJ_FLAG_USER_3 // ---- Chat overlay layout ---- constexpr int CHAT_HDR_H = 0; // in-chat header bar removed; thread name shows in the status bar #if CAP_LARGE_SCREEN constexpr int CHAT_COMP_H = 64; // big screen: ~2× the typing box (60px) + chrome #else constexpr int CHAT_COMP_H = 34; // composer row, single line (slimmed 50 → 40 → 34; hugs the 30px textbox) #endif constexpr int CHAT_COMP_MAX_LINES = 4; // composer grows up to this many wrapped lines, then scrolls vertically static inline lv_coord_t chatComposerBaseH() { #if defined(TLORA_PAGER) // Preserve the 34-px Small composer, then add enough chrome around the live // font line for Medium/Large without globally scaling the short viewport. const lv_coord_t need = lv_font_get_line_height(&g_font_14) + 18; return need > CHAT_COMP_H ? need : CHAT_COMP_H; #else return CHAT_COMP_H; #endif } // Current composer-row height. The composer wraps long text to multiple lines // and grows UPWARD (its bottom stays pinned, the message list above shrinks) // instead of horizontally scrolling a single line. The chat layout helpers // below derive the message-area height + composer Y from this, so a taller // composer automatically reclaims space from the message list. Reset to // CHAT_COMP_H on each chat-panel (re)build; updated by chatComposerAutoGrow(). static lv_coord_t s_comp_h = CHAT_COMP_H; constexpr int CHAT_KB_H = 130; // on-screen keyboard (portrait) // Bottom tab-bar height (matches lv_tabview_create in buildUiTree). A tab // page's usable content area is the screen minus the status bar and tab bar — // queried live so it tracks the current rotation (240×260 portrait / // 320×180 landscape). #if CAP_LARGE_SCREEN constexpr int TABBAR_H = 46; // taller on the big 800×480 panel — room for the coloured F-key shapes #elif defined(HAS_THINKNODE_M9) // No tab bar on this board: it is tap-only chrome (navMaybeRebuild deliberately // never adds it to the focus group), and the M9 has no touch — the dedicated // HOME/MESSAGE/MAP keys and the app drawer's Chats/Contacts/Map/Settings tiles // cover every tab. Reclaims the row for content (user request). constexpr int TABBAR_H = 0; #else constexpr int TABBAR_H = 30; // bottom nav bar (trimmed from 38; icons stay g_font_16) #endif static inline lv_coord_t tabContentW() { return lv_disp_get_hor_res(nullptr); } static inline lv_coord_t tabContentH() { return lv_disp_get_ver_res(nullptr) - STATUSBAR_H - TABBAR_H; } // Usable area for a centered modal below the global status bar (small margin). // Popups were sized for the 320-tall portrait screen; these let them shrink to // fit the 240-tall landscape one. Backdrops should use the full screen so the // dark overlay covers everything and the card centers on-screen. static inline lv_coord_t modalAvailW() { return lv_disp_get_hor_res(nullptr) - 12; } static inline lv_coord_t modalAvailH() { return lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12; } // Chat-detail geometry — runtime so the conversation view fills the screen and // tracks rotation. The keyboard is half the screen tall in landscape (matches // kbApplyLayoutForRotation), full CHAT_KB_H in portrait. static inline bool chatLandscape() { return lv_disp_get_hor_res(nullptr) > lv_disp_get_ver_res(nullptr); } static inline lv_coord_t chatScreenW() { return lv_disp_get_hor_res(nullptr); } static inline lv_coord_t chatComposerChipSz() { #if CAP_LARGE_SCREEN return 56; #elif defined(TLORA_PAGER) return chatComposerBaseH() - 4; #else return 30; #endif } static inline lv_coord_t chatComposerSendSz() { #if defined(TLORA_PAGER) return chatComposerChipSz(); #else return 34; #endif } static inline lv_coord_t chatComposerTaW() { const lv_coord_t chip = chatComposerChipSz(); return chatScreenW() - (2 * chip + 12) - chatComposerSendSz() - 14; } // Most boards use a DOUBLE-height status bar in a chat (thread name on the lower // row + a centred cog). The round panel already has two physical rows, while the // short Pager keeps its back/cog/title in the regular single row. chatBarH() is // the full visual height used by channel/blocked sheets. #if CAP_ROUND_CORNERS || defined(TLORA_PAGER) static inline lv_coord_t chatBarH() { return STATUSBAR_H; } #else static inline lv_coord_t chatBarH() { return (lv_coord_t)(STATUSBAR_H * 2); } #endif static inline lv_coord_t chatScreenH() { return lv_disp_get_ver_res(nullptr) - STATUSBAR_H; } static inline lv_coord_t chatKbH() { return chatLandscape() ? (lv_disp_get_ver_res(nullptr) / 2) : CHAT_KB_H; } // The message list spans the FULL height under the header down to the screen bottom // (or the keyboard top) — the composer FLOATS over its lower edge with a transparent // row, and p.msgs carries a bottom inset == s_comp_h so the newest bubble rests just // above the composer while older bubbles scroll UNDER it. (s_comp_h tracks the // composer's current, possibly grown, height; chatComposerAutoGrow keeps the inset.) static inline lv_coord_t chatMsgHOpen() { return chatScreenH() - CHAT_HDR_H; } static inline lv_coord_t chatCompYOpen() { return chatScreenH() - s_comp_h; } // with keyboard shown: static inline lv_coord_t chatMsgHKb() { return chatScreenH() - CHAT_HDR_H - chatKbH(); } static inline lv_coord_t chatCompYKb() { return chatScreenH() - s_comp_h - chatKbH(); } // ---- Thread list button context ---- struct LvThreadButtonCtx { int idx; bool channel; }; // ---- Per-panel state (DM chats and channels share this shape) ---- struct LvChatPanel { lv_obj_t* list_cont; // full-page lv_list in the tab (thread chooser) lv_obj_t* overlay; // full-screen overlay on lv_scr_act (chat detail) lv_obj_t* header_name; // label: thread name in the overlay header lv_obj_t* msgs; // read-only textarea: message history lv_obj_t* jump_btn; // floating "jump to latest" button (Discord-style) lv_obj_t* jump_oldest_btn; // floating "jump to oldest" button lv_obj_t* composer_row; // container: input + send button lv_obj_t* composer_ta; // textarea: user types here LvThreadButtonCtx ctx_store[UITask::MAX_UI_THREADS]; bool channel_mode; /** When true, `list_cont` shows channels + DMs with history (Chats tab only). */ bool inbox_combined; bool detail_open; uint32_t list_sig = 0; // change-signature of the rendered thread list (skip no-op rebuilds) }; struct LvContactButtonCtx { uint32_t mesh_idx; bool is_repeater; bool is_fav; // favorites can't be multi-select-deleted (unfavorite first) uint8_t key6[6]; // stable identity for the multi-select set (pub_key prefix) lv_obj_t* age_lbl; // the row's Heard label — updated in place on the 60s age tick (#82) }; static void* psAlloc(size_t n); // defined below — PSRAM-first, zero-init #define CONTACTS_CTX_MAX 128 static LvContactButtonCtx* s_contacts_ctx = (LvContactButtonCtx*)psAlloc(sizeof(LvContactButtonCtx) * CONTACTS_CTX_MAX); // PSRAM (2 KB off internal .bss) // ---- Contact list sort modes (cycled via short-press on the active filter) ---- enum ContactsSortMode : uint8_t { CONTACTS_SORT_AZ = 0, // alphabetical CONTACTS_SORT_LAST_HEARD = 1, // most recent advert first (default) CONTACTS_SORT_LAST_MSG = 2, // most recent message first (falls back to last-heard) CONTACTS_SORT_DISTANCE = 3, // nearest first; rows with no shared location are excluded CONTACTS_SORT_COUNT = 4, }; // Default to most-recently-heard at the top — operators care who's on the // air now far more than alphabetical order. Favorites still pin above all // (see the qsort comparator in refreshContactsList). static uint8_t g_contacts_sort = CONTACTS_SORT_LAST_HEARD; // Reverse the active sort's natural order (the asc/desc toggle). Natural order is // A→Z / newest-heard / newest-message / nearest; `desc` flips just the primary key // (favourites stay pinned on top, "?"-heard rows stay sunk). Reset when the mode // changes; re-tapping the active mode in the Sort sheet flips it. static bool g_contacts_sort_desc = false; static const char* contactsSortLabel(uint8_t m) { switch (m) { case CONTACTS_SORT_AZ: return "A-Z"; case CONTACTS_SORT_LAST_HEARD: return "RECENT"; case CONTACTS_SORT_LAST_MSG: return "MSG"; case CONTACTS_SORT_DISTANCE: return "DIST"; } return "?"; } // Long form for the Sort sheet rows. static const char* contactsSortOptName(uint8_t m) { switch (m) { case CONTACTS_SORT_AZ: return "Name (A-Z)"; case CONTACTS_SORT_LAST_HEARD: return "Recently heard"; case CONTACTS_SORT_LAST_MSG: return "Recent message"; case CONTACTS_SORT_DISTANCE: return "Nearest first"; } return "?"; } /** Last tab index before `tabChangedCb`; used to close chat overlays only when leaving Chats. */ static int s_lv_tab_prev = HOME_TAB_INDEX; // ---- Top-level LVGL state ---- struct LvUiState { bool ready; bool touch_inited; bool dirty_threads; bool dirty_timeline; bool defer_heavy_refresh; unsigned long heavy_refresh_at_ms; uint32_t lvgl_tick_prev_us; lv_disp_draw_buf_t draw_buf; lv_disp_drv_t disp_drv; lv_indev_drv_t indev_drv; lv_obj_t* tabview; lv_obj_t* home_state; lv_obj_t* home_unread; // clickable "envelope + Unread N" line -> Chats inbox lv_obj_t* home_apps; // "Apps" launcher btn — keyboard-nav default focus on the Home tab lv_obj_t* home_stats; #if defined(HAS_EXPANSION_KIT) lv_obj_t* home_env; // Expansion-Kit local-env summary label on Home lv_obj_t* home_env_chart; // tiny batt/temp history chart on Home #endif // Duty-cycle meter (label + bar) on the Home tab. Surfaces the live TX // budget remaining so the operator notices regulatory throttling before // a ten-message-burst stalls. Created in makeHome iff the user pref is on. lv_obj_t* home_dc_label; lv_obj_t* home_dc_bar; lv_obj_t* settings_status; lv_obj_t* diag_id_label; // pinned ID/freq/sf line above the diag ring lv_obj_t* diag_label; lv_obj_t* keyboard; // shared LVGL on-screen keyboard LvChatPanel dm; // Chats tab list + DM overlay LvChatPanel ch; // channel thread overlay only (no tab list) lv_obj_t* contacts_list; uint8_t contacts_filter; // 0=all 1=repeaters 2=peers (non-repeater) 3=favorites // Per-session text-search filter applied on top of the category filter. // Empty = no text filter. Set/cleared by the magnifier sheet. char contacts_search[24]; lv_obj_t* contacts_search_indicator; // tiny chip shown when search is active UITask* task; }; // ---- Diagnostics ring buffer ---- constexpr int DIAG_LINES = 16; // bumped from 7 so dispatcher TX traces survive the UI flow constexpr int DIAG_COLS = 44; static char (*s_diag_ring)[DIAG_COLS] = (char(*)[DIAG_COLS])psAlloc((size_t)DIAG_LINES * DIAG_COLS); // PSRAM (0.7 KB off internal .bss) static int s_diag_line = 0; // Sticky copy of the latest "ID …" identity/radio diag line. MyMesh emits // this during begin() — before the Diag tab's labels exist — so we cache // the most recent value and back-fill the pinned label when the tab is // constructed. static char s_diag_id_pinned[DIAG_COLS] = { 0 }; constexpr int RXLOG_LINES = 28; constexpr int RXLOG_COLS = 80; // Monitor RX/RAW rings — lazy PSRAM (allocated in begin(), internal fallback): // keeps 2×28×80 = 4.4 KB off internal DRAM. Null-guarded in the helpers below. static char (*s_rxlog_ring)[RXLOG_COLS] = nullptr; static int s_rxlog_line = 0; static char (*s_rawlog_ring)[RXLOG_COLS] = nullptr; static int s_rawlog_line = 0; static lv_obj_t* s_live_diag_label = nullptr; static uint32_t s_live_diag_reads = 0; static uint32_t s_live_diag_pressed = 0; static uint32_t s_live_diag_tap_edges = 0; static unsigned long s_live_diag_next_ms = 0; // File-scope so tsNextWakeForcingDueMs can read it; initialised at first loop tick (~4 s). static unsigned long s_sig_probe_at = 4000; /** Keep live diag overlay available but hidden by default; flip true for field debugging. */ constexpr bool k_show_live_diag_overlay = false; #if CAP_TRACKBALL // ---- T-Deck trackball cursor ---- // A soft pointer the trackball drives; auto-hides after inactivity, and the // centre click injects a touch press at the cursor (see lvglTouchRead / loop). static lv_obj_t* s_tb_cursor = nullptr; static int s_tb_cursor_x = 160; // rendered position (landscape 320x240) static int s_tb_cursor_y = 120; static bool s_tb_reverse = false; // invert trackball direction (cached pref; refreshed at init + on toggle) static bool s_tb_edge_scroll = false; // push past screen edge to scroll content under cursor static float s_tb_target_x = 160.0f, s_tb_target_y = 120.0f; // full-sensitivity target static float s_tb_render_x = 160.0f, s_tb_render_y = 120.0f; // eased render position static unsigned long s_tb_prev_ms = 0; static unsigned long s_tb_last_active_ms = 0; static bool s_tb_click_press = false; // centre button held this frame // Keyboard ESDFX navigation: when on, the physical-keyboard ESDFX cluster moves // focus through s_nav_group (the same focus group the Tanmatsu uses) whenever no // text field is focused — E up, X down, S left, F right, D select, Q back. The // trackball ALWAYS stays a soft mouse cursor. Cached from touchPrefsGetKbdNav() so // the per-tick poll never hits NVS; s_nav_keypad_drv is the keypad LVGL indev that // drains navFifo (fed from handleHwKey instead of the trackball). static bool s_kbd_nav = false; // Trackball drives the focus-group D-pad nav (up/down/left/right move the selection, centre // click selects) instead of the soft mouse cursor. Default ON (cached from touchPrefsGetTbNav // at init + on toggle). Independent of s_kbd_nav (the keyboard ESDFX nav). static bool s_tb_nav = true; static lv_indev_drv_t s_nav_keypad_drv; constexpr int kTbCursorDiameter = 16; // px constexpr int kTbCursorStepPx = 12; // px per encoder step constexpr float kTbCursorSmoothMs = 60.0f; // ease time-constant (smaller = snappier) constexpr unsigned long kTbCursorHideMs = 800; // auto-hide after idle // True while the trackball cursor is visible AND screen-Y is within the bottom // tab bar. Used to swallow stray FINGER touches on the tab bar while the user is // driving the cursor — a trackball CLICK on a tab still works (it arrives via // the cursor path, not as a raw touch). static bool tbFingerTouchOnTabBarBlocked(uint16_t y) { if (!s_tb_cursor) return false; if ((millis() - s_tb_last_active_ms) >= kTbCursorHideMs) return false; // cursor hidden return (int)y >= (lv_disp_get_ver_res(nullptr) - TABBAR_H); } #endif #if defined(HAS_TANMATSU) // Keyboard-only device: nav is always on. (The trackball #if above declares this for the T-Deck.) static bool s_kbd_nav = true; #endif #if defined(HAS_THINKNODE_M9) // Keyboard-only device (no touch, no trackball): nav is always on, same as Tanmatsu above. // Unlike Tanmatsu (which registers its KEYPAD indev as the PRIMARY one, driven by navPump() // reading bsp-input), the M9 takes the CAP_KEYPAD_NAV "secondary indev" path the T-Deck's // trackball block above uses — fed by handleHwKey() instead — so it needs the SAME three // symbols that block declares for the T-Deck (s_kbd_nav/s_tb_nav/s_nav_keypad_drv), just // without any of the actual trackball-cursor state above (no trackball on this board). static bool s_kbd_nav = true; static bool s_tb_nav = false; // no trackball — read by the shared nav-rebuild gate, never set static lv_indev_drv_t s_nav_keypad_drv; #endif // ---- Panel currently linked to the keyboard (or nullptr) ---- static LvChatPanel* s_kb_panel = nullptr; /** Full-width strip above the on-screen keyboard: mirrors the focused textarea so edits stay visible. */ static lv_obj_t* s_kb_mirror_root = nullptr; static lv_obj_t* s_kb_mirror_ta = nullptr; /** Real textarea whose text is synced with `s_kb_mirror_ta` while the keyboard is open. */ static lv_obj_t* s_kb_bind_ta = nullptr; // The T-Deck has a physical keyboard and never shows the on-screen keyboard or // the (hidden) mirror strip, so its keys bind STRAIGHT to the visible field — // the mirror indirection only exists for boards with an on-screen keyboard. // Routing the T-Deck through the mirror made every edit act at the mirror's // end-cursor, so backspace deleted the last character no matter where the caret // was. When this returns false, kbMirrorBind binds the field directly and the // mirror sync / redirects below are skipped. #if defined(HAS_ATTAKY_MESH_KEYBOARD) // Set while the module's '#' has summoned the OSK for this editing session; hideKb clears it. static bool s_osk_forced = false; #endif static inline bool kbMirrorActive() { #if CAP_KEYBOARD return false; // physical keyboard: bind keys straight to the field, never show the on-screen kb #elif defined(HAS_ATTAKY_MESH_KEYBOARD) // Keyboard is a detachable module, so decide at runtime, not via CAP_KEYBOARD: // suppress the on-screen keys while the module answers on I2C, unless '#' has // summoned them back for this field. No module: behave like stock upstream. return !(attakyKeyboardPresent() && !s_osk_forced); #else return true; #endif } // ---- Chats "+" add-channel modal pointers (see lower in file for impl) ---- static lv_obj_t* s_addch_sheet = nullptr; static lv_obj_t* s_addch_name_ta = nullptr; static lv_obj_t* s_addch_secret_ta = nullptr; static lv_obj_t* s_addch_hashtag_ta = nullptr; static lv_obj_t* s_addch_error_l = nullptr; // ---- Contacts → "Add" manual contact modal pointers ---- static lv_obj_t* s_addct_pub_ta = nullptr; static lv_obj_t* s_addct_name_ta = nullptr; static lv_obj_t* s_addct_error_l = nullptr; // ---- Keyboard rotation (landscape typing) ---- // Stored as the lv_disp_rot_t value: 0 = portrait, 1 = ROT_90, 3 = ROT_270. // Applied to the whole display while the keyboard is shown; reverts to // portrait on hideKb(). Persisted in NVS under "kbrot" so the user's choice // survives reboots. static uint8_t s_kb_rotation = 0; static lv_obj_t* s_kb_rot_left_btn = nullptr; static lv_obj_t* s_kb_rot_right_btn = nullptr; static lv_obj_t* s_kb_lang_btn = nullptr; // on-screen language-cycle key (boards w/o a physical keyboard) static lv_obj_t* s_kb_lang_lbl = nullptr; // its label — the active layout's 2-letter code static lv_obj_t* s_kb_alt_btn = nullptr; // on-screen "Alt" key: cycles the last letter's accent (issue #22) // Accent-popup picker on/off (default on). Loaded from NVS at boot; gates // accentBoxMaybeShow() so the tap-to-pick accent box can be turned off in // the keyboard settings (applies to the on-screen + physical keyboards). static bool s_accent_popups = true; // ---- Global UI orientation (persistent) ---- // The base orientation of the whole UI, loaded from NVS ("uirot") and applied // at boot BEFORE the screens are built so the layout reflows to the rotated // resolution. The keyboard-landscape toggle above is transient and layers on // top of this; hideKb() reverts to s_ui_rotation (not hard portrait). // Stored as the lv_disp_rot_t value: 0 = portrait, 1 = ROT_90, 3 = ROT_270. static uint8_t s_ui_rotation = 0; // The rotation the keyboard should use when shown: if the UI is already in a // landscape base orientation, keep it; otherwise honour the transient // keyboard-landscape toggle. Lets the rotate arrows still work in portrait. static inline uint8_t effectiveKbRotation() { const bool ui_landscape = (s_ui_rotation == LV_DISP_ROT_90 || s_ui_rotation == LV_DISP_ROT_270); return ui_landscape ? s_ui_rotation : s_kb_rotation; } // ---- Discovered adverts ring buffer ---- // Filled by UITask::discoveredContact() each time an advert comes in for a // node that's not in contacts[]. Browsable + addable via the Discovered // modal opened from the Contacts tab "Found" button. constexpr int DISCOVERED_MAX = 48; // ring of recently-heard nodes not in contacts; persisted in chunks struct LvDiscoveredEntry { bool used; bool in_contacts; uint8_t path_len; uint32_t last_advert_ts; uint32_t recv_ms; ContactInfo ci; }; // PSRAM-first allocator (falls back to internal RAM) for moving large static UI buffers off // the scarce internal SRAM. Safe as a static initializer: PSRAM is up before C++ ctors run, // and the fallback keeps the original internal behaviour if PSRAM is ever unavailable. static void* psAlloc(size_t n) { void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM); if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT); // Zero-init: these buffers replace zero-initialized static UI arrays, and // their callers rely on that — e.g. s_discovered[].used is the validity flag // for the "Found" list. Without this, fresh-boot PSRAM garbage makes random // entries look "used" and the Found list shows corrupt contacts. if (p) memset(p, 0, n); return p; } static LvDiscoveredEntry* s_discovered = (LvDiscoveredEntry*)psAlloc(sizeof(LvDiscoveredEntry) * DISCOVERED_MAX); static uint32_t s_discovered_seq = 0; // Count of discovered adverts NOT yet in contacts (the "Discovered" badge). static int discoveredCount() { int n = 0; for (int i = 0; i < DISCOVERED_MAX; ++i) if (s_discovered && s_discovered[i].used && !s_discovered[i].in_contacts) ++n; return n; } // Drop every discovered entry (the Purge action on the Discovered list). static void purgeDiscovered() { if (!s_discovered) return; for (int i = 0; i < DISCOVERED_MAX; ++i) s_discovered[i].used = false; } // ---- Persist the discovered ring across reboots --------------------------- // High-churn discovery data has its own namespace so an advert burst never // rewrites the critical touch settings snapshot. static const char* KDISC = "disc"; // chunk 0 (older firmware wrote ≤24 records here) static const char* DISC_NS = "discovered"; static const char* DISC_MARKER = "ver"; static bool s_disc_dirty = false; static unsigned long s_disc_save_at = 0; // Compact on-disk record (84 B/entry). SdNvsPrefs caps one value at 2048 B, so // we cap each stored blob at DISC_PER_CHUNK records // (2 + 24*84 = 2018 B) and split the ring across as many chunk keys ("disc", // "disc1", …) as DISCOVERED_MAX needs. We persist only what the Discovered list // redraws — the bulky parts of ContactInfo (out_path[64], shared_secret, …) are // re-derived live. static const uint8_t DISC_BLOB_VER = 1; static constexpr size_t DISC_REC_SZ = PUB_KEY_SIZE + 32 + 4 + 16; // pubkey + name + 4×u8 + 4×u32 static constexpr int DISC_PER_CHUNK = 24; // 2 + 24*84 = 2018 B < 2048 cap static constexpr int DISC_CHUNKS = (DISCOVERED_MAX + DISC_PER_CHUNK - 1) / DISC_PER_CHUNK; static constexpr size_t DISC_BLOB_SZ = 2 + DISC_PER_CHUNK * DISC_REC_SZ; static uint8_t* s_disc_blob = nullptr; // Discovered-ring persistence scratch — lazy PSRAM (internal fallback): ~2 KB off internal DRAM static uint8_t* discBlob() { // alloc on first save/load; returns null only on (unlikely) OOM if (!s_disc_blob) { s_disc_blob = (uint8_t*)heap_caps_malloc(DISC_BLOB_SZ, MALLOC_CAP_SPIRAM); if (!s_disc_blob) s_disc_blob = (uint8_t*)heap_caps_malloc(DISC_BLOB_SZ, MALLOC_CAP_8BIT); } return s_disc_blob; } static inline void discPutU32(uint8_t*& p, uint32_t v) { p[0]=(uint8_t)v; p[1]=(uint8_t)(v>>8); p[2]=(uint8_t)(v>>16); p[3]=(uint8_t)(v>>24); p+=4; } static inline uint32_t discGetU32(const uint8_t*& p) { uint32_t v=(uint32_t)p[0]|((uint32_t)p[1]<<8)|((uint32_t)p[2]<<16)|((uint32_t)p[3]<<24); p+=4; return v; } static void discChunkKey(int chunk, char* out, size_t cap) { if (chunk == 0) { strncpy(out, KDISC, cap - 1); out[cap - 1] = '\0'; } else { snprintf(out, cap, "disc%d", chunk); } } static void saveDiscovered() { if (!s_discovered || !discBlob()) return; SdNvsPrefs prefs; if (!prefs.begin(DISC_NS, false)) return; prefs.putUChar(DISC_MARKER, DISC_BLOB_VER); int src = 0; // walk the ring once, emitting used entries into successive chunk blobs for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { uint8_t* p = s_disc_blob; *p++ = DISC_BLOB_VER; uint8_t* pcount = p++; // back-patched with the entry count uint8_t count = 0; while (src < DISCOVERED_MAX && count < DISC_PER_CHUNK) { const LvDiscoveredEntry& e = s_discovered[src++]; if (!e.used) continue; memcpy(p, e.ci.id.pub_key, PUB_KEY_SIZE); p += PUB_KEY_SIZE; memcpy(p, e.ci.name, 32); p += 32; *p++ = e.ci.type; *p++ = e.ci.flags; *p++ = e.path_len; *p++ = e.in_contacts ? 1 : 0; discPutU32(p, e.last_advert_ts); discPutU32(p, e.recv_ms); discPutU32(p, (uint32_t)e.ci.gps_lat); discPutU32(p, (uint32_t)e.ci.gps_lon); ++count; } *pcount = count; char key[8]; discChunkKey(chunk, key, sizeof key); if (count > 0) prefs.putBytes(key, s_disc_blob, (size_t)(p - s_disc_blob)); else prefs.remove(key); // clear an empty trailing chunk } prefs.end(); s_disc_dirty = false; } static void loadDiscovered() { if (!s_discovered || !discBlob()) return; memset(s_discovered, 0, sizeof(LvDiscoveredEntry) * DISCOVERED_MAX); SdNvsPrefs prefs; const bool prefs_open = prefs.begin(DISC_NS, true); const bool have_new = prefs_open && prefs.isKey(DISC_MARKER); int dst = 0; for (int chunk = 0; chunk < DISC_CHUNKS && dst < DISCOVERED_MAX; ++chunk) { char key[8]; discChunkKey(chunk, key, sizeof key); size_t got = have_new ? prefs.getBytes(key, s_disc_blob, DISC_BLOB_SZ) : touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ); if (got < 2 || s_disc_blob[0] != DISC_BLOB_VER) continue; // missing / garbage chunk -> skip uint8_t count = s_disc_blob[1]; const uint8_t* p = s_disc_blob + 2; const uint8_t* end = s_disc_blob + got; for (uint8_t i = 0; i < count && dst < DISCOVERED_MAX && (size_t)(end - p) >= DISC_REC_SZ; ++i) { LvDiscoveredEntry& e = s_discovered[dst++]; e.used = true; memcpy(e.ci.id.pub_key, p, PUB_KEY_SIZE); p += PUB_KEY_SIZE; memcpy(e.ci.name, p, 32); p += 32; e.ci.name[31] = '\0'; e.ci.type = *p++; e.ci.flags = *p++; e.path_len = *p++; e.ci.out_path_len = e.path_len; e.in_contacts = (*p++ != 0); e.last_advert_ts = discGetU32(p); e.ci.last_advert_timestamp = e.last_advert_ts; e.recv_ms = discGetU32(p); e.ci.gps_lat = (int32_t)discGetU32(p); e.ci.gps_lon = (int32_t)discGetU32(p); e.ci.shared_secret_valid = false; // force secret recompute on first use } } prefs.end(); if (!have_new) { // First boot after the split: queue the new namespace but leave legacy // chunks intact until a later boot proves the new snapshot is durable. saveDiscovered(); } else { // The separate snapshot survived a reboot, so the old copies are now safe // to retire from the critical touch namespace. for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { char key[8]; discChunkKey(chunk, key, sizeof key); if (touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ) > 0) touchPrefsSetBlob(key, nullptr, 0); } } } // Coalesce a burst of adverts into one write ~20 s later (flash-churn guard). static void markDiscoveredDirty() { s_disc_dirty = true; s_disc_save_at = millis() + 20000; } static void discoveredFlushIfDue(unsigned long now) { if (s_disc_dirty && (long)(now - s_disc_save_at) >= 0) saveDiscovered(); } // Synchronous flush for the deliberate reboot / power-off / download-mode paths // (mirrors the chat-history flush) so a manual reboot never drops recent finds. static void discoveredFlushNow() { if (s_disc_dirty) saveDiscovered(); } // Remove discovered entries heard via more hops than the configured limit (the // "auto-delete above N hops" setting; 0 = off). Returns true if anything went. static bool discoveredSweepHops() { if (!s_discovered) return false; #if defined(ESP32) const uint8_t maxhops = touchPrefsGetDiscoveredMaxHops(); if (maxhops == 0) return false; bool changed = false; for (int i = 0; i < DISCOVERED_MAX; ++i) if (s_discovered[i].used && s_discovered[i].path_len > maxhops) { s_discovered[i].used = false; changed = true; } if (changed) markDiscoveredDirty(); return changed; #else return false; #endif } // ---- Touch-init deferred flag ---- static bool g_cap_touch_hw_started = false; // ---- LVGL draw buffer ---- // 240x24 RGB565 = 11,520 bytes. The T-Pager keeps this in PSRAM so Wi-Fi + // NimBLE connection/security allocations retain enough contiguous internal // DRAM. Its ST7796 flush is synchronous TFT_eSPI::pushColors (no DMA), so the // external-RAM source is valid. Other boards retain the faster internal-DMA // allocation below unless it fails. // // 1.5x the original 240x16 size — modest bump to reduce setAddrWindow // round-trips without giving LVGL more headroom to over-invalidate. // Double-buffering and 240x40 were tried and starved the CHSC6X poll cadence // (touch I2C is read from inside lv_timer_handler at LV_INDEV_DEF_READ_PERIOD // = 30 ms, and bigger render windows pushed effective poll rate to ~11 Hz). // 48 lines × 240 × 2 B = 23 KB. Lives in internal DMA-capable DRAM (see // allocator below) which reads ~3× faster than PSRAM during SPI push. // Doubled from 24 to amortize the per-flush setAddrWindow + startWrite/ // endWrite overhead. Tried double-buffering (two 23 KB internal blocks) // — bootlooped after the top bar showed, because the second 23 KB block // starved Bluedroid's DMA-DRAM pool at init time. Stayed single-buffer. // Was 48 — dropped to 24 to claw back ~12 KB of internal DMA-DRAM for // the Wi-Fi tile-fetch task stack (which has to live in internal RAM // because mbedTLS/lwIP crash on PSRAM stacks under this Arduino-ESP32 // build). 24 × 240 × 2 B = 11.5 KB. Flush time is bound by SPI clock // (80 MHz), not memcpy speed, so the throughput delta is small — // LVGL just calls flush_cb more often. static constexpr int LV_DRAW_BUF_LINES = 24; static lv_color_t* g_draw_buffer = nullptr; // Operating clock of the LAST successful SD.begin() on SPI-SD boards (0 = none). Recorded at // every mount/remount site (main.cpp boot adoption + the two UITask mounts) because SD.begin's // clock is the session clock; Settings -> About reports it on the R8 (no serial console there). uint32_t g_sd_operating_hz = 0; static uint32_t g_draw_buf_px = 240 * LV_DRAW_BUF_LINES; // actual buffer size in px; shrinks if the full alloc fails at boot #if CAP_LARGE_SCREEN // UI resolution scaling (Tanmatsu, no touchscreen). LVGL renders at s_lv_pw x s_lv_ph (PHYSICAL // portrait) and lvglFlush upscales each already-rotated band to the 480x800 panel. s_lv_pw == the // panel width (480) means 100% / native (no upscale). Set once at boot from touchPrefsGetUiScale(). static constexpr int TAN_PANEL_PW = 480, TAN_PANEL_PH = 800; // physical panel (portrait) static constexpr size_t TAN_SCALE_BUF_PX = 86400; // one upscaled band (<= 19200 px * 4) — PSRAM static int s_lv_pw = TAN_PANEL_PW, s_lv_ph = TAN_PANEL_PH; // LVGL physical resolution static uint16_t* s_scale_buf = nullptr; // upscale scratch (lazy PSRAM) #endif // ---- Global UI state instance ---- LvUiState g_lv = {}; /** If `cp` has no glyph in `font`, show '*' instead of LVGL's missing-glyph box. Preserves \\n \\r \\t. */ static bool uiFontHasGlyph(const lv_font_t* font, uint32_t cp) { if (!font) return true; if (cp == '\n' || cp == '\r' || cp == '\t') return true; if (cp < 0x20u) return true; lv_font_glyph_dsc_t dsc{}; return lv_font_get_glyph_dsc(font, &dsc, cp, 0); } /** * Copy UTF-8 `in` → `out` (NUL-terminated, capped). Codepoints not in `font` become ASCII '*'. * Invalid UTF-8 bytes become '*'. */ static void copyUtf8ReplacingMissingGlyphs(const lv_font_t* font, char* out, size_t out_cap, const char* in) { if (!out || out_cap == 0) return; out[0] = '\0'; if (!in) return; size_t w = 0; const char* p = in; const char* end = in + strlen(in); while (*p && w + 1 < out_cap) { const char* seq_beg = p; uint32_t cp = 0; bool ok = false; const unsigned char c0 = static_cast<unsigned char>(*p); if (c0 < 0x80u) { cp = c0; p += 1; ok = true; } else if ((c0 & 0xE0u) == 0xC0u && end - p >= 2) { const unsigned char c1 = static_cast<unsigned char>(p[1]); if ((c1 & 0xC0u) == 0x80u) { cp = (static_cast<uint32_t>(c0 & 0x1Fu) << 6) | (c1 & 0x3Fu); if (cp >= 0x80u) { p += 2; ok = true; } } } else if ((c0 & 0xF0u) == 0xE0u && end - p >= 3) { const unsigned char c1 = static_cast<unsigned char>(p[1]); const unsigned char c2 = static_cast<unsigned char>(p[2]); if ((c1 & 0xC0u) == 0x80u && (c2 & 0xC0u) == 0x80u) { cp = (static_cast<uint32_t>(c0 & 0x0Fu) << 12) | (static_cast<uint32_t>(c1 & 0x3Fu) << 6) | (c2 & 0x3Fu); if (cp >= 0x800u && !(cp >= 0xD800u && cp <= 0xDFFFu)) { p += 3; ok = true; } } } else if ((c0 & 0xF8u) == 0xF0u && end - p >= 4) { const unsigned char c1 = static_cast<unsigned char>(p[1]); const unsigned char c2 = static_cast<unsigned char>(p[2]); const unsigned char c3 = static_cast<unsigned char>(p[3]); if ((c1 & 0xC0u) == 0x80u && (c2 & 0xC0u) == 0x80u && (c3 & 0xC0u) == 0x80u) { cp = (static_cast<uint32_t>(c0 & 0x07u) << 18) | (static_cast<uint32_t>(c1 & 0x3Fu) << 12) | (static_cast<uint32_t>(c2 & 0x3Fu) << 6) | (c3 & 0x3Fu); if (cp >= 0x10000u && cp <= 0x10FFFFu) { p += 4; ok = true; } } } if (!ok) { out[w++] = '*'; p = seq_beg + 1; continue; } if (uiFontHasGlyph(font, cp)) { // Whole sequence or nothing: a glyph split at the cap leaves invalid UTF-8. if (w + (size_t)(p - seq_beg) + 1 > out_cap) break; for (const char* q = seq_beg; q < p; ++q) out[w++] = *q; } else { out[w++] = '*'; } } out[w] = '\0'; } enum class SettingsModalKind : uint8_t { None = 0, Profile, Radio, AutoAdd, Experimental, Bluetooth, // was Transport — replaced with a dedicated BLE page Device, Wifi, Log, Discovered, // discovered adverts list (manual-add view) Advert, // "send advert" picker (flood vs zero-hop) ChCreatePrv, // Chats "+": create a private channel (name + secret) ChJoinPrv, // Chats "+": join a private channel by secret ChJoinTag, // Chats "+": join a hashtag channel by name SystemInfo, // Read-only system / firmware diagnostic page AddContact, // Manual add-contact (pubkey + name) modal QuickReply, // Edit the 6 quick-reply preset macros Mqtt, // MQTT bridge config }; struct SettingsModalState { lv_obj_t* root; SettingsModalKind kind; lv_obj_t* name_ta; lv_obj_t* lat_ta; lv_obj_t* lon_ta; lv_obj_t* freq_ta; lv_obj_t* bw_ta; lv_obj_t* sf_ta; lv_obj_t* cr_ta; lv_obj_t* tx_ta; lv_obj_t* airtime_ta; lv_obj_t* region_ta; lv_obj_t* max_hops_ta; lv_obj_t* auto_chat_sw; lv_obj_t* auto_rep_sw; lv_obj_t* auto_room_sw; lv_obj_t* auto_sensor_sw; lv_obj_t* auto_overwrite_sw; lv_obj_t* manual_add_sw; lv_obj_t* share_loc_sw; lv_obj_t* path_mode_dd; lv_obj_t* exp_multi_sw; lv_obj_t* exp_repeat_sw; lv_obj_t* exp_boost_sw; lv_obj_t* exp_dc_sw; lv_obj_t* wifi_sw; lv_obj_t* glance_en_sw; lv_obj_t* glance_locked_sw; lv_obj_t* wifi_ssid_ta; lv_obj_t* wifi_pwd_ta; /** Transports modal: live STA line (IP when connected, else Arduino WiFi status). */ lv_obj_t* wifi_sta_status_l; lv_obj_t* radio_preset_dd; lv_obj_t* log_view_ta; lv_obj_t* log_rx_btn; lv_obj_t* log_raw_btn; uint8_t log_mode; // 0 = rx summary, 1 = raw /** Device modal: screen-timeout (seconds, 0=never) textarea. */ lv_obj_t* screen_to_ta; /** Device modal (Keyboard): backlight-brightness slider (issue #84). */ lv_obj_t* kbbl_slider; /** Device modal: live GPS fix-status line under the GPS toggle. */ lv_obj_t* gps_status; /** Quick replies modal: 6 textareas, one per slot. Empty save clears * the slot (so the picker shows "(empty)" and a save with text repopulates). * Allocated only while the modal is open. */ lv_obj_t* qr_tas[TOUCH_QUICK_REPLY_COUNT]; lv_obj_t* mqtt_en_sw; lv_obj_t* mqtt_host_ta; lv_obj_t* mqtt_port_ta; lv_obj_t* mqtt_user_ta; lv_obj_t* mqtt_pwd_ta; lv_obj_t* mqtt_ch_sw; lv_obj_t* mqtt_dm_sw; lv_obj_t* mqtt_psk_ta; lv_obj_t* mqtt_consent_cb; }; static SettingsModalState g_set_modal = {}; /** Mirrors `Meshcomod-client/shared/radio_presets.ts` (community presets); keep in sync manually. */ struct MeshRadioPreset { const char* label; float freq_mhz; float bw_khz; uint8_t sf; uint8_t cr; int8_t tx_dbm; uint8_t airtime_limit_pct; // web % → NodePrefs.airtime_factor = pct / 100 }; static constexpr size_t k_mesh_radio_preset_count = 21; static const MeshRadioPreset k_mesh_radio_presets[k_mesh_radio_preset_count] = { {"Australia", 915.8f, 250.f, 10, 5, 22, 10}, {"Australia (Mid)", 915.075f, 125.f, 9, 5, 22, 10}, // NSW/Canberra migration target (issue #121) {"Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22, 10}, {"Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22, 10}, {"Australia: QLD", 923.125f, 62.5f, 8, 5, 22, 10}, {"Australia: NSW (SF11)", 915.8f, 250.f, 11, 5, 22, 10}, {"New Zealand", 917.375f, 250.f, 11, 5, 22, 10}, {"New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22, 10}, {"EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 22, 10}, {"EU/UK (Deprecated)", 869.525f, 250.f, 11, 5, 22, 10}, {"Switzerland (Narrow)", 869.618f, 62.5f, 8, 8, 22, 10}, {"Czech Republic (Narrow)", 869.432f, 62.5f, 7, 5, 22, 10}, {"Slovakia (Narrow)", 869.618f, 62.5f, 8, 8, 22, 10}, {"Portugal (Narrow)", 869.618f, 62.5f, 7, 6, 22, 10}, // community PT preset (issue #74) {"EU 433MHz (Long Range)", 433.65f, 250.f, 11, 5, 22, 10}, {"US/Canada", 910.525f, 62.5f, 7, 5, 22, 10}, {"USA: SoCal (Community)", 927.875f, 62.5f, 7, 5, 22, 10}, {"USA: San Francisco (Community)", 910.525f, 62.5f, 7, 5, 22, 10}, {"USA: Sacramento (Community)", 909.875f, 62.5f, 9, 5, 22, 10}, {"USA: Pacific NW (Community)", 910.525f, 62.5f, 7, 5, 22, 10}, {"Brazil", 923.125f, 62.5f, 8, 8, 22, 10}, // official MeshCore BR preset — same params as Australia: SA, WA (issue #69) }; static bool g_radio_preset_cb_silent = false; static void kbMirrorSyncToReal(); // keyboard mirror strip; defined below // #161: duty% -> the core's airtime_factor (Dispatcher: duty = 1/(1+af)), clamped to the // core's 0..9 range (af 9 = 10% duty, the regulatory floor the UI offers). static float meshPresetAirtimeFactor(uint8_t pct) { if (pct == 0 || pct >= 100) return 0.0f; // unlimited float af = (100.0f / (float)pct) - 1.0f; return af > 9.0f ? 9.0f : af; } static int findMatchingMeshRadioPreset(const NodePrefs* prefs) { if (!prefs) return -1; for (size_t i = 0; i < k_mesh_radio_preset_count; ++i) { const MeshRadioPreset& p = k_mesh_radio_presets[i]; if (std::fabs(prefs->freq - p.freq_mhz) > 0.002) continue; if (std::fabs(prefs->bw - p.bw_khz) > 0.02) continue; if (prefs->sf != p.sf) continue; if (prefs->cr != p.cr) continue; if (prefs->tx_power_dbm != p.tx_dbm) continue; const double exp_af = (double)meshPresetAirtimeFactor(p.airtime_limit_pct); const double old_af = static_cast<double>(p.airtime_limit_pct) / 100.0; // pre-#161 buggy write const double cur = static_cast<double>(prefs->airtime_factor); if (std::fabs(cur - exp_af) > 0.05 && std::fabs(cur - old_af) > 0.05) continue; return static_cast<int>(i); } return -1; } // #161 boot heal: devices configured by the OLD preset code carry af = pct/100 (~91% duty on a // 10% preset). If the radio params match a preset exactly and the stored factor matches the OLD // buggy value (and NOT a deliberate user choice near it -- the exact-preset match is the guard), // rewrite it to the correct factor once and persist. static void healPresetAirtimeFactor() { NodePrefs* prefs = the_mesh.getNodePrefs(); if (!prefs) return; const int m = findMatchingMeshRadioPreset(prefs); if (m < 0) return; const MeshRadioPreset& p = k_mesh_radio_presets[m]; const float good = meshPresetAirtimeFactor(p.airtime_limit_pct); const float bad = (float)p.airtime_limit_pct / 100.0f; if (std::fabs(prefs->airtime_factor - bad) <= 0.05f && std::fabs(prefs->airtime_factor - good) > 0.05f) { prefs->airtime_factor = good; the_mesh.savePrefs(); } } static void applyMeshRadioPresetFields(unsigned preset_idx) { if (preset_idx >= k_mesh_radio_preset_count || !g_set_modal.freq_ta) return; kbMirrorSyncToReal(); const MeshRadioPreset& p = k_mesh_radio_presets[preset_idx]; char buf[28]; snprintf(buf, sizeof(buf), "%.3f", static_cast<double>(p.freq_mhz)); lv_textarea_set_text(g_set_modal.freq_ta, buf); snprintf(buf, sizeof(buf), "%.1f", static_cast<double>(p.bw_khz)); lv_textarea_set_text(g_set_modal.bw_ta, buf); snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(p.sf)); lv_textarea_set_text(g_set_modal.sf_ta, buf); snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(p.cr)); lv_textarea_set_text(g_set_modal.cr_ta, buf); snprintf(buf, sizeof(buf), "%d", static_cast<int>(p.tx_dbm)); lv_textarea_set_text(g_set_modal.tx_ta, buf); // #161: MeshCore's airtime_factor is a TX pacing multiplier -- the core's Dispatcher runs at // duty = 1/(1+af). A 10% duty limit therefore needs af = 9, NOT 0.10: the old pct/100 write // configured every preset user's radio to ~91% duty (and the Home meter honestly said so). const float af = meshPresetAirtimeFactor(p.airtime_limit_pct); snprintf(buf, sizeof(buf), "%.2f", static_cast<double>(af)); lv_textarea_set_text(g_set_modal.airtime_ta, buf); } static void radioPresetChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || g_radio_preset_cb_silent) return; if (!g_set_modal.radio_preset_dd || !g_set_modal.freq_ta) return; const uint16_t sel = lv_dropdown_get_selected(g_set_modal.radio_preset_dd); if (sel == 0) return; const unsigned idx = static_cast<unsigned>(sel - 1u); if (idx >= k_mesh_radio_preset_count) return; applyMeshRadioPresetFields(idx); } /** Subtitle labels on Set tab section rows (titles are static; see `refreshSettingsSectionSubtitles`). */ enum : int { SEC_PROFILE = 0, SEC_RADIO, SEC_AUTOADD, SEC_BLUETOOTH, // was SEC_TRANSPORT — replaced with a dedicated BLE page SEC_DEVICE, SEC_EXPERIMENTAL, SEC_WIFI, SEC_LOG, SEC_SYSTEM, SEC_QUICKREPLY, // user-editable composer macro presets SEC_COUNT }; static lv_obj_t* g_set_sec_sub[SEC_COUNT]; /** Settings list rows: full width inside tab padding (240 − 16). */ static constexpr lv_coord_t k_settings_row_w = 224; static constexpr lv_coord_t k_settings_row_h = 54; static constexpr lv_coord_t k_settings_row_gap = 6; static bool settingsModalIsOpen() { return g_set_modal.root != nullptr; } // Compact one-line GPS status for the Device settings panel + control center. // TR("GPS: off") / "GPS: searching · N sats" / "GPS: fix · N sats <lat>, <lon>". // Stamped when acquisition starts so the page can show how long it has been searching; a stuck // receiver is then obvious from the elapsed time alone. Cleared on fix and when GPS is off. static uint32_t s_gps_acq_since = 0; // compact = the top-bar dropdown, which must stay ONE line (it shares this string with the GPS // settings page, and a two-line version pushed the whole dropdown taller). The settings page asks // for the full text, where there is room to explain what is happening. static const char* gpsStatusStr(bool compact = false) { static char s[200]; if (!g_lv.task || !g_lv.task->getGPSState()) { s_gps_acq_since = 0; snprintf(s, sizeof s, TR("GPS: off")); return s; } // satellitesCount() is satellites USED IN THE FIX, so it reads 0 for the whole cold acquisition // and a bare "0 sats" looks like a fault. All the other reachable fields (lat/lon/alt/valid) are // likewise empty until a fix lands, so from up here a receiver that is happily tracking // satellites and one that is not powered look EXACTLY the same. // // That is a real limitation, not an oversight: the core's MicroNMEALocationProvider keeps its // MicroNMEA object private and LocationProvider exposes no satellites-in-view, no SNR and no // HDOP. Deducing "is it alive" from the decoded GPS time does NOT work either — MicroNMEA parses // RMC left to right and bails out at the EMPTY latitude field of an unfixed sentence, so it // never reaches parseDate and the time stays unset until there is already a fix. So do not claim // to know whether data is arriving; report the honest thing (how long it has been trying) and // point at the actual remedy. The serial boot probe prints the real sky view meanwhile. if (!g_lv.task->getGpsFix()) { const uint32_t now = millis(); if (!s_gps_acq_since) s_gps_acq_since = now; const uint32_t secs = (now - s_gps_acq_since) / 1000u; int n = snprintf(s, sizeof s, TR("GPS: searching")); if (n < (int)sizeof s) { if (secs < 600) n += snprintf(s + n, sizeof s - n, " %lum%02lus", (unsigned long)(secs / 60), (unsigned long)(secs % 60)); else n += snprintf(s + n, sizeof s - n, " %lum", (unsigned long)(secs / 60)); } if (!compact && n < (int)sizeof s) snprintf(s + n, sizeof s - n, "\n%s", TR("No position yet. A cold start needs a clear view of the sky and can take several minutes.")); return s; } s_gps_acq_since = 0; const int sats = g_lv.task->getGpsSats(); const int alt = g_lv.task->getGpsAltitude(); int n = snprintf(s, sizeof s, TR("GPS: fix")); if (sats > 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d sats", sats); if (!compact && alt && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d m", alt); if (n < (int)sizeof s) snprintf(s + n, sizeof s - n, "%s%.5f, %.5f", compact ? " " : "\n", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); return s; } #if defined(HAS_EXPANSION_KIT) // ---- Heltec V4 Expansion Kit: local environment sensors (ch1 batt rail, // ch2 BME280, ch3 GXHTV3/SHT4X) + GPS module summary. Board-gated; no other // touch board compiles this. The pressure-drop "weather alarm" half of PR #29 // is intentionally NOT ported (no touchPrefsGetWeatherAlarm / no pressure-check // statics), so only the descriptive/history pieces live here. static const char* localEnvStatusStr() { static char s[192]; s[0] = '\0'; if (!g_lv.task || !g_lv.task->getLocalEnvSummary(s, sizeof s)) return ""; return s; } static constexpr int kHomeEnvHistoryPoints = 24; // 24 * 15 s = 6 min static constexpr unsigned long kHomeEnvSampleMs = 15000; static uint16_t s_home_env_hist_batt_mv[kHomeEnvHistoryPoints] = {}; static int16_t s_home_env_hist_temp_t10[kHomeEnvHistoryPoints] = {}; static int16_t s_home_env_hist_hum[kHomeEnvHistoryPoints] = {}; static int16_t s_home_env_hist_press_hpa10[kHomeEnvHistoryPoints] = {}; static int16_t s_home_env_hist_alt_m[kHomeEnvHistoryPoints] = {}; static bool s_home_env_hist_seeded = false; static unsigned long s_home_env_last_sample_ms = 0; static bool localEnvHasAnySensors(const UITask::LocalEnvSnapshot& snap) { return snap.have_batt || snap.have_bme_temp || snap.have_bme_hum || snap.have_bme_pressure || snap.have_bme_alt || snap.have_gxhtv3_temp || snap.have_gxhtv3_hum || snap.gps_present; } // True only when an actual ENVIRONMENT module (BME280 ch2 / GXHTV3 ch3) is // detected. Deliberately excludes the battery rail + GPS, which are present on a // bare V4 — those make localEnvHasAnySensors() always true, so it can't gate the // Sensors UI. Used to auto-hide the Sensors tab + Home env widget when no // environment sensors are attached. static bool localEnvHasEnvSensors(const UITask::LocalEnvSnapshot& snap) { return snap.have_bme_temp || snap.have_bme_hum || snap.have_bme_pressure || snap.have_bme_alt || snap.have_gxhtv3_temp || snap.have_gxhtv3_hum; } // Cached once at the top of buildUiTree (a sensor probe is too heavy to repeat // per-frame). sensorsUiWanted() = this AND the user pref. Both default to a // "show" state so behaviour is unchanged when an env sensor is connected. static bool s_env_sensors_present = false; static inline bool sensorsUiWanted() { return s_env_sensors_present && touchPrefsGetShowSensorsTab(); } static const char* localEnvModuleState(bool detected, bool query_ok) { if (detected) return "ok"; return query_ok ? "not detected" : "telemetry unavailable"; } static size_t localEnvAppendLine(char* out, size_t cap, size_t p, const char* fmt, ...) { if (!out || cap == 0 || p >= cap) return p; va_list ap; va_start(ap, fmt); const int wrote = vsnprintf(out + p, cap - p, fmt, ap); va_end(ap); if (wrote <= 0) return p; size_t np = p + (size_t)wrote; return np < cap ? np : cap - 1; } static void localEnvAppendHistorySpark(char* out, size_t cap, const char* label, const int16_t* vals, int n, int16_t none_sentinel) { if (!out || cap == 0 || !label || !vals || n <= 0) return; size_t p = strlen(out); if (p >= cap - 1) return; if (p > 0) p += snprintf(out + p, cap - p, "\n"); p += snprintf(out + p, cap - p, "%s ", label); for (int i = 0; i < n && p < cap - 1; ++i) { if (vals[i] == none_sentinel) out[p++] = '.'; else if (i > 0 && vals[i] > vals[i - 1]) out[p++] = '/'; else if (i > 0 && vals[i] < vals[i - 1]) out[p++] = '\\'; else out[p++] = '-'; } out[p] = '\0'; } static void localEnvAppendHistorySparkU16(char* out, size_t cap, const char* label, const uint16_t* vals, int n, uint16_t none_sentinel) { if (!out || cap == 0 || !label || !vals || n <= 0) return; size_t p = strlen(out); if (p >= cap - 1) return; if (p > 0) p += snprintf(out + p, cap - p, "\n"); p += snprintf(out + p, cap - p, "%s ", label); for (int i = 0; i < n && p < cap - 1; ++i) { if (vals[i] == none_sentinel) out[p++] = '.'; else if (i > 0 && vals[i] > vals[i - 1]) out[p++] = '/'; else if (i > 0 && vals[i] < vals[i - 1]) out[p++] = '\\'; else out[p++] = '-'; } out[p] = '\0'; } // Small compass-cardinal helper. PR #29 defines this in its (skipped) waypoint // section; we copy the standalone helper here since the local-env detail text // references it for the GPS course readout. static const char* bearingCardinal(double deg) { static const char* k_dirs[8] = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; const int idx = (int)floor((deg + 22.5) / 45.0) & 7; return k_dirs[idx]; } static void buildLocalEnvDetailText(const UITask::LocalEnvSnapshot& snap, char* out, size_t cap) { if (!out || cap == 0) return; out[0] = '\0'; size_t p = 0; const bool have_bme = snap.have_bme_temp || snap.have_bme_hum || snap.have_bme_pressure || snap.have_bme_alt; const bool have_gx = snap.have_gxhtv3_temp || snap.have_gxhtv3_hum; p = localEnvAppendLine(out, cap, p, "Detected modules"); p = localEnvAppendLine(out, cap, p, "\nBattery rail (ch1): %s", localEnvModuleState(snap.have_batt, true)); if (snap.have_batt) p = localEnvAppendLine(out, cap, p, " %.2fV", (double)snap.batt_v); p = localEnvAppendLine(out, cap, p, "\nBME280 (ch2): %s", localEnvModuleState(have_bme, snap.query_ok)); if (snap.have_bme_temp) p = localEnvAppendLine(out, cap, p, "\n Temp %.1fC", (double)snap.bme_temp_c); if (snap.have_bme_hum) p = localEnvAppendLine(out, cap, p, "\n Hum %.0f%%RH", (double)snap.bme_hum_pct); if (snap.have_bme_pressure) p = localEnvAppendLine(out, cap, p, "\n Press %.0fhPa", (double)snap.bme_pressure_hpa); if (snap.have_bme_alt) p = localEnvAppendLine(out, cap, p, "\n Alt %dm", (int)snap.bme_alt_m); p = localEnvAppendLine(out, cap, p, "\nGXHTV3/SHT4X (ch3): %s", localEnvModuleState(have_gx, snap.query_ok)); if (snap.have_gxhtv3_temp) p = localEnvAppendLine(out, cap, p, "\n Temp %.1fC", (double)snap.gxhtv3_temp_c); if (snap.have_gxhtv3_hum) p = localEnvAppendLine(out, cap, p, "\n Hum %.0f%%RH", (double)snap.gxhtv3_hum_pct); p = localEnvAppendLine(out, cap, p, "\nGPS module: %s", snap.gps_present ? "available" : "not detected"); if (snap.gps_present) { p = localEnvAppendLine(out, cap, p, "\n State %s", snap.gps_enabled ? "enabled" : "disabled"); if (snap.gps_sats >= 0) p = localEnvAppendLine(out, cap, p, " Sats %d", snap.gps_sats); p = localEnvAppendLine(out, cap, p, " Fix %s", snap.gps_fix ? "yes" : "no"); if (s_gps_speed_kmh > 0.1f) p = localEnvAppendLine(out, cap, p, "\n Speed %.1f km/h", (double)s_gps_speed_kmh); if (s_gps_course_deg >= 0.0f) p = localEnvAppendLine(out, cap, p, " Course %.0f\xC2\xB0 %s", (double)s_gps_course_deg, bearingCardinal(s_gps_course_deg)); p = localEnvAppendLine(out, cap, p, "\n HDOP unavailable"); if (g_lv.task) { p = localEnvAppendLine(out, cap, p, " Seen this boot %s", g_lv.task->getGpsHadFix() ? "yes" : "no"); if (s_gps_last_fix_ms != 0) p = localEnvAppendLine(out, cap, p, "\n Last fix age %lus", (unsigned long)((millis() - s_gps_last_fix_ms) / 1000UL)); if (!snap.gps_fix && s_gps_last_loss_ms != 0) p = localEnvAppendLine(out, cap, p, " Lost %lus ago", (unsigned long)((millis() - s_gps_last_loss_ms) / 1000UL)); if (snap.gps_fix) { p = localEnvAppendLine(out, cap, p, "\n Live %.5f, %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); } else if (g_lv.task->getNodeLat() != 0.0 || g_lv.task->getNodeLon() != 0.0) { p = localEnvAppendLine(out, cap, p, "\n Saved %.5f, %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); } } } p = localEnvAppendLine(out, cap, p, "\nBuzzer module: %s", snap.buzzer_available ? "available" : "not detected"); if (snap.buzzer_available) p = localEnvAppendLine(out, cap, p, " %s", snap.buzzer_quiet ? "quiet" : "on"); p = localEnvAppendLine(out, cap, p, "\n\nTelemetry probe: %s", snap.query_ok ? "ok" : "failed"); p = localEnvAppendLine(out, cap, p, "\nReasoning: ch1 local rail, ch2 BME280, ch3 GXHTV3/SHT4X"); } static void buildHomeEnvSummary(char* out, size_t cap) { if (!out || cap == 0) return; out[0] = '\0'; if (!g_lv.task) return; if (!g_lv.task->getLocalEnvSummary(out, cap)) return; localEnvAppendHistorySparkU16(out, cap, "Batt", s_home_env_hist_batt_mv, kHomeEnvHistoryPoints, 0); localEnvAppendHistorySpark(out, cap, "Temp", s_home_env_hist_temp_t10, kHomeEnvHistoryPoints, INT16_MIN); } static void buildExpansionInfoText(char* out, size_t cap) { if (!out || cap == 0) return; out[0] = '\0'; if (!g_lv.task) return; UITask::LocalEnvSnapshot snap; const bool have_env = g_lv.task->getLocalEnvSnapshot(snap); char env[384]; buildLocalEnvDetailText(snap, env, sizeof env); snprintf(out, cap, "%s", have_env ? env : TR("No local expansion data")); } #endif // HAS_EXPANSION_KIT // Control-center popup state — declared up here so the periodic settings refresh // (which is defined above the control-center code) can update the live GPS line. static lv_obj_t* s_cc_root = nullptr; static lv_obj_t* s_cc_gps_label = nullptr; #if defined(HAS_EXPANSION_KIT) static lv_obj_t* s_cc_env_label = nullptr; // Expansion-Kit local-env line in the control center #endif static lv_obj_t* s_cc_sys_label = nullptr; // CPU/RAM/PSRAM/IP line; refreshed live while CC open static void ccBuildSysInfo(char* buf, size_t n); // fwd-decl; defined with the CC helpers below static void closeControlCenter(); // defined in the control-center section below static void openControlCenter(); // defined in the control-center section below (early decl for the M9 CTRL key) static void takeScreenshotToSd(); // shared by the status-bar hold and M9 Control Center action static lv_obj_t* s_power_menu = nullptr; // power off / reboot menu (control center) static void closePowerMenu(); // defined in the control-center section below static void openPowerMenu(); // hold the red ✕ (F1) on the Tanmatsu → power off / reboot #if defined(HAS_TOUCH_UI) static void homeTerminalCb(lv_event_t* e); // mesh console launcher; defined far below #endif #if defined(HAS_EXPANSION_KIT) static lv_obj_t* s_expansion_root = nullptr; static lv_obj_t* s_local_sensors_root = nullptr; static void closeExpansionCard(); static void closeLocalSensorsPage(); static void openLocalSensorsPage(); static void relayoutHomeCharts(); static void makeSensorsTab(lv_obj_t* tab); static void refreshSensorsTab(); static void refreshSensorsHistoryCharts(); #endif #if CAP_KEYBOARD // Keyboard backlight: mode 0=off, 1=on, 2=auto (on while typing, off after idle). static uint8_t s_kb_bl_mode = 2; static unsigned long s_kb_last_key_ms = 0; constexpr unsigned long kKbBacklightIdleMs = 3000; // auto: off 3 s after last key // Register input activity (keypress / field focus / tap) for the auto backlight. static inline void noteKbActivity() { s_kb_last_key_ms = millis(); } #else static inline void noteKbActivity() {} #endif /** List-style settings launcher (not theme-default `lv_btn` chrome). */ static void styleSettingsRow(lv_obj_t* row) { // Matches the same low-opacity neutral-slate look as styleButton so the // settings rows don't read as a different visual language than the // chips / sheet buttons elsewhere. Earlier hardcoded blue-slate hexes // (0x141C27 / 0x2A3848) predated the tactical palette overhaul. lv_obj_remove_style_all(row); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_0, LV_PART_MAIN); // fully transparent at rest lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_ACCENT_PRESS), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(row, LV_OPA_30, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(row, 6, LV_PART_MAIN); // sharper than 14 lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); lv_obj_set_style_border_color(row, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(row, LV_OPA_30, LV_PART_MAIN); lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN); lv_obj_set_style_shadow_width(row, 0, LV_PART_MAIN); lv_obj_set_style_shadow_ofs_y(row, 0, LV_PART_MAIN); } // ============================================================ // Style helpers // ============================================================ static void styleSurface(lv_obj_t* obj, uint32_t bg, lv_coord_t radius = 10) { lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(obj, lv_color_hex(bg), LV_PART_MAIN); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN); lv_obj_set_style_radius(obj, radius, LV_PART_MAIN); } static void styleCard(lv_obj_t* obj) { styleSurface(obj, COLOR_PANEL, 10); lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN); lv_obj_set_style_border_color(obj, lv_color_hex(0x18191A), LV_PART_MAIN); } // LVGL draws a text area's PLACEHOLDER from LV_PART_TEXTAREA_PLACEHOLDER, and that // part does NOT inherit the LV_PART_MAIN font the creation sites set. So the hint // text fell back to the theme's plain Montserrat and every accented character in it // rendered as a box -- "H(box)l(box)zat n(box)v" for "Halozat nev" -- while text the // user typed into the same field was fine. 27 placeholders, and the part was styled // in none of them. The beta_62 sweep fixed ~79 LABELS; this is the other LVGL part // it never touched. // // Resolve the chained face from whatever MAIN already carries, so this cannot change // how any field looks -- only which glyphs it can draw. Placeholders are often set // BEFORE the site assigns MAIN's font, so an unrecognised font falls back to // g_font_14 (the size every such site ends up using anyway). static void taSetPlaceholder(lv_obj_t* ta, const char* txt) { if (!ta) return; lv_textarea_set_placeholder_text(ta, txt); const lv_font_t* f = lv_obj_get_style_text_font(ta, LV_PART_MAIN); if (f == &lv_font_montserrat_12) f = &g_font_12; else if (f == &lv_font_montserrat_14) f = &g_font_14; else if (f == &lv_font_montserrat_16) f = &g_font_16; else if (f != &g_font_12 && f != &g_font_14 && f != &g_font_16) f = &g_font_14; lv_obj_set_style_text_font(ta, f, LV_PART_TEXTAREA_PLACEHOLDER); } static void styleButton(lv_obj_t* obj) { // Subdued slate chip. Background sits at the panel colour with a low // overall opacity so the pure-black BG bleeds through — chips read as // "etched out of the bezel" rather than glowing. Border is a faint // 1-px line of COLOR_ACCENT at low opacity, text is clean white-ish. // Press state flashes a brighter slate fill so taps still register. // Primary action buttons (Send / Save / Login / Apply / Add) override // the bg to COLOR_STATUS_OK so they remain visually distinct. lv_obj_set_style_bg_color(obj, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(obj, LV_OPA_10, LV_PART_MAIN); lv_obj_set_style_bg_color(obj, lv_color_hex(COLOR_ACCENT_PRESS), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(obj, LV_OPA_50, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_border_color(obj, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN); lv_obj_set_style_border_opa(obj, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_text_color(obj, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // ...and the font, for the same reason as the text colour. A button carries a // font from the LVGL theme which its child label inherits, and that one has no // fallback chain — so accents inside button labels came out as tofu boxes // ("Eszk□z □jraind□t□sa") even after the screen-level default was fixed, because // the button sits between the screen and the label. g_font_14 is the same face // and metrics plus the accent/Greek/Cyrillic/Arabic fallbacks. Labels that set // their own font are unaffected — a style on the label beats one on the button. lv_obj_set_style_text_font(obj, &g_font_14, LV_PART_MAIN); lv_obj_set_style_radius(obj, 4, LV_PART_MAIN); lv_obj_set_style_shadow_width(obj, 0, LV_PART_MAIN); } // "X" close affordance for popup cards. A bare 16-px glyph with an // Compact 24×24 visual target with a 32×32 extended hit area, so it reads as // just a symbol but remains forgiving to hit. Sits inside the card's top-right // corner — no border / bg, so it doesn't compete visually with the card's // own buttons. // IMPORTANT: each caller is responsible for keeping the top-right ~32×32 // of the card content-free (or for placing only short titles there) so // the X doesn't sit on top of a real button. // Tanmatsu: tint a close/cancel ✕ glyph red (matches the F1 / Power button). No-op on other boards. static inline void tanCloseRed(lv_obj_t* lbl) { #if defined(HAS_TANMATSU) lv_obj_set_style_text_color(lbl, lv_color_hex(0xE05544), LV_PART_MAIN); #else (void)lbl; #endif } static lv_obj_t* addCloseXBadge(lv_obj_t* card, lv_event_cb_t cb, void* user_data = nullptr) { lv_obj_t* x = lv_obj_create(card); lv_obj_remove_style_all(x); lv_obj_set_size(x, 24, 24); // Keep the visible focus tint tight around the centred glyph. ext_click_area // restores the previous forgiving 32-px touch target without painting it. lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -2, 2); lv_obj_set_ext_click_area(x, 4); // No fill / border — bare glyph. Pressed state nudges a faint dim so // there's *some* visual feedback on tap. lv_obj_set_style_bg_opa(x, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_bg_color(x, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(x, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(x, 12, LV_PART_MAIN); lv_obj_set_style_border_width(x, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(x, 0, LV_PART_MAIN); lv_obj_add_flag(x, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(x, LV_OBJ_FLAG_FLOATING); lv_obj_add_flag(x, LV_OBJ_FLAG_IGNORE_LAYOUT); // Keep the normal reverse-video navigation focus: a bright 24-px disk with // the child X inverted to black. The larger 32-px hit target stays invisible. lv_obj_clear_flag(x, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(x); lv_obj_add_event_cb(x, cb, LV_EVENT_CLICKED, user_data); lv_obj_t* lbl = lv_label_create(x); lv_label_set_text(lbl, LV_SYMBOL_CLOSE); lv_obj_set_style_text_font(lbl, uiChromeFont(), LV_PART_MAIN); #if defined(HAS_TANMATSU) lv_obj_set_style_text_color(lbl, lv_color_hex(0xE05544), LV_PART_MAIN); // red ✕ — matches the F1 / Power button #else lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); #endif lv_obj_center(lbl); return x; } // ============================================================ // Display / input drivers // ============================================================ // Screenshot capture: when g_shot_buf is set, lvglFlush mirrors every flushed // area into this full-screen RGB565 buffer (in addition to the panel write), so a // forced full redraw fills it with the composited screen. Only live during a shot. static lv_color_t* g_shot_buf = nullptr; static int g_shot_w = 0; static int g_shot_h = 0; // REMOTE mode: render the UI to an off-screen 480x800 display for the web only (the // physical panel drops to a placeholder). Set from the pref at boot; drives lvglFlush // (skip the panel) + the display-init resolution. Ungated so non-mirror code reads it // (always false on Tanmatsu / when off). static bool s_remote_mode = false; static bool s_remote_landscape = false; // remote orientation: true=800x480 landscape (desktop), false=480x800 portrait // Bootloop guard for remote mode: RTC memory persists across a soft reset (crash/reboot) // but is garbage on a cold power-on. Armed before entering remote mode, cleared a few // seconds into a healthy run; a still-armed flag on the next boot means the last remote // boot didn't complete -> auto-disable (see UITask::begin + loop). Never gets stuck. #define RMT_GUARD_MAGIC 0x52454D54u // 'REMT' RTC_NOINIT_ATTR static uint32_t s_rmt_boot_guard; #if !defined(HAS_TANMATSU) #include "../wadamesh_mark_rgb.h" // anti-aliased mesh mark (RGB565), same artwork as the boot splash // Physical-panel placeholder shown while remote mode renders the UI off-screen. Drawn // on the first loop pass (IP sentinel) then refreshed when the IP appears (Wi-Fi up). // Uses the DisplayDriver text API (same path as the boot splash), not LVGL. static uint32_t s_remote_ph_ip = 0xFFFFFFFFu; static uint32_t s_remote_exit_armed = 0; // time of the first of two SPACE presses to exit static void drawRemotePlaceholder(bool exit_armed = false) { const int W = display.width(), H = display.height(); const bool up = (WiFi.status() == WL_CONNECTED) && ((uint32_t)WiFi.localIP() != 0); display.startFrame((ColorVal)0x0000); // explicit dark (core palettes vary -- see main.cpp boot splash) #if defined(TLORA_PAGER) // The Pager panel is only 222 px tall in landscape. Give it a purpose-built // two-column status screen instead of squeezing the tall touch-board layout // underneath a 98 px logo. The outlined control is actionable with the // encoder knob; SPACE twice remains the keyboard fallback. if (W >= H) { display.writePixelsRGB565(18, (H - WADAMESH_MARK_H) / 2, WADAMESH_MARK_W, WADAMESH_MARK_H, WADAMESH_MARK_RGB565); const int panel_x = 190; const int panel_w = W - panel_x - 14; display.setColor((ColorVal)0xFFFF); display.setTextSize(2); display.drawTextCentered(panel_x + panel_w / 2, 14, "REMOTE UI"); display.setTextSize(1); if (up) { char url[48]; snprintf(url, sizeof url, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); display.setColor((ColorVal)0xFFFF); display.drawTextCentered(panel_x + panel_w / 2, 48, "Open in browser:"); display.setColor((ColorVal)0x07E0); display.drawTextCentered(panel_x + panel_w / 2, 66, url); } else { display.setColor((ColorVal)0xFD20); display.drawTextCentered(panel_x + panel_w / 2, 58, "Connecting to Wi-Fi..."); } display.setColor(exit_armed ? (ColorVal)0x0320 : (ColorVal)0x3000); display.fillRect(panel_x, 98, panel_w, 60); display.setColor(exit_armed ? (ColorVal)0x07E0 : (ColorVal)0xFBEF); display.drawRect(panel_x, 98, panel_w, 60); display.setTextSize(2); display.drawTextCentered(panel_x + panel_w / 2, 106, "EXIT REMOTE"); display.setTextSize(1); display.drawTextCentered(panel_x + panel_w / 2, 136, exit_armed ? "press SPACE again" : "press encoder knob"); display.setColor((ColorVal)0xFFE0); display.drawTextCentered(panel_x + panel_w / 2, 180, "or press SPACE twice within 3s"); } else { display.writePixelsRGB565((W - WADAMESH_MARK_W) / 2, 18, WADAMESH_MARK_W, WADAMESH_MARK_H, WADAMESH_MARK_RGB565); display.setColor((ColorVal)0xFFFF); display.setTextSize(2); display.drawTextCentered(W / 2, 130, "REMOTE UI"); display.setTextSize(1); if (up) { char url[48]; snprintf(url, sizeof url, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); display.drawTextCentered(W / 2, 166, "Open in browser:"); display.setColor((ColorVal)0x07E0); display.drawTextCentered(W / 2, 184, url); } else { display.setColor((ColorVal)0xFD20); display.drawTextCentered(W / 2, 176, "Connecting to Wi-Fi..."); } display.setColor(exit_armed ? (ColorVal)0x0320 : (ColorVal)0x3000); display.fillRect(16, 220, W - 32, 62); display.setColor(exit_armed ? (ColorVal)0x07E0 : (ColorVal)0xFBEF); display.drawRect(16, 220, W - 32, 62); display.setTextSize(2); display.drawTextCentered(W / 2, 228, "EXIT REMOTE"); display.setTextSize(1); display.drawTextCentered(W / 2, 258, exit_armed ? "press SPACE again" : "press encoder knob"); display.setColor((ColorVal)0xFFE0); display.drawTextCentered(W / 2, 304, "or press SPACE twice within 3s"); } #else // wadamesh mesh mark up top (same white-on-black artwork as the boot splash). The // touch DisplayDriver runs at scale 1.0, so writePixelsRGB565 shares text coords. const int ly = (H >= 288) ? 46 : 14; // portrait panels have room to breathe; landscape sits high display.writePixelsRGB565((W - WADAMESH_MARK_W) / 2, ly, WADAMESH_MARK_W, WADAMESH_MARK_H, WADAMESH_MARK_RGB565); int y = ly + WADAMESH_MARK_H + 18; // Prominent title (the logo already carries the brand — no redundant "wadamesh" line). display.setColor((ColorVal)0xFFFF); display.setTextSize(2); display.drawTextCentered(W / 2, y, "REMOTE MODE"); y += 34; display.setTextSize(1); if (up) { char u[48]; snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); display.setColor((ColorVal)0xFFFF); display.drawTextCentered(W / 2, y, "Open this address in a browser"); display.setColor((ColorVal)0x07E0); display.drawTextCentered(W / 2, y + 20, u); } else { display.setColor((ColorVal)0xFD20); display.drawTextCentered(W / 2, y, "Connecting to Wi-Fi..."); } // Footer: how to leave remote mode — the physical method, plus the browser fallback. #if CAP_KEYBOARD const char* how = exit_armed ? "Press SPACE again to exit" : "Press SPACE twice to exit"; #elif CAP_TOUCH const char* how = exit_armed ? "Keep holding to exit..." : "Touch and hold 3s to exit"; #else const char* how = "Tap Exit in the browser to leave"; #endif display.setColor(exit_armed ? (ColorVal)0x07E0 : (ColorVal)0xFFE0); display.drawTextCentered(W / 2, H - 30, how); #if CAP_KEYBOARD || CAP_TOUCH display.setColor((ColorVal)0xFFFF); display.drawTextCentered(W / 2, H - 14, "or tap Exit in the browser"); #endif #endif display.endFrame(); s_remote_ph_ip = up ? (uint32_t)WiFi.localIP() : 0; } static void exitRemoteModeNow() { touchPrefsSetRemoteMode(false); if (g_lv.task) g_lv.task->rebootDevice(); } // In remote mode the panel shows only the placeholder, so the physical keyboard is the // way out: two SPACE presses within 3 s exit remote mode (reboot back to normal). static void remotePhysicalKey(int key) { if (key != ' ') return; uint32_t nowm = millis(); if (s_remote_exit_armed && (nowm - s_remote_exit_armed) < 3000) { exitRemoteModeNow(); // saves chat history + prefs, then reboots to normal mode } else { s_remote_exit_armed = nowm; drawRemotePlaceholder(true); } } #if CAP_TOUCH // Keyboard-less touch boards (Heltec V4, RAK Tap) have no SPACE key to exit remote mode, // so a 3 s touch-and-hold on the physical panel leaves it. The touch poll runs even in // remote mode (the LVGL indev is skipped but the driver still polls), so the live-touch // state is available here. Called each loop from the remote block. static uint32_t s_remote_touch_start = 0; static void remoteTouchTick() { uint16_t tx, ty; const bool pressed = heltecV4CapTouchGetLive(&tx, &ty); const uint32_t nowm = millis(); if (pressed) { if (!s_remote_touch_start) { s_remote_touch_start = nowm; drawRemotePlaceholder(true); } // "Keep holding..." else if (nowm - s_remote_touch_start >= 3000) { // held long enough -> leave exitRemoteModeNow(); } } else if (s_remote_touch_start) { s_remote_touch_start = 0; drawRemotePlaceholder(false); // lifted before 3 s -> revert the hint } } #endif #endif #if !defined(HAS_TANMATSU) // ===== Web UI mirror: coalesced, rate-capped, backpressure-gated streaming ===== // The flush hook copies each drawn band into a full-screen shadow buffer and grows // a dirty bounding box (cheap, no network). webMirrorTick() then RLE-encodes just // the dirty box and queues it — but ONLY after the previous frame has fully drained // (adaptive to the real Wi-Fi throughput) and no faster than a rate cap. So the link // never backs up (no lag pileup, no wedged-client disconnects), while the browser // still gets the *current* pixels of everything that changed since the last send. static uint8_t* s_web_rle_buf = nullptr; // RLE output (<=32 KB band) static uint8_t* s_web_band_buf = nullptr; // contiguous band gathered from the shadow static uint16_t* s_web_fb = nullptr; // full-screen RGB565 shadow (PSRAM) static int s_web_fb_w = 0, s_web_fb_h = 0; static bool s_web_dirty = false; static int s_web_dx1 = 0, s_web_dy1 = 0, s_web_dx2 = 0, s_web_dy2 = 0; // dirty bbox (inclusive) static uint32_t s_web_last_send_ms = 0; // RLE-encode RGB565 pixels as [count(1..255), lo, hi] runs. Returns bytes, or 0 if // it would not beat raw (bails at cap). UI is mostly flat -> ~5-15x; noisy content // (map tiles) bails to raw. cap must be <= the out[] buffer size. static size_t webMirrorRle(const uint16_t* src, int n, uint8_t* out, size_t cap) { size_t o = 0; int i = 0; while (i < n) { uint16_t v = src[i]; int run = 1; while (i + run < n && src[i + run] == v && run < 255) run++; if (o + 3 > cap) return 0; out[o++] = (uint8_t)run; out[o++] = (uint8_t)(v & 0xFF); out[o++] = (uint8_t)(v >> 8); i += run; } return o; } static bool webMirrorEnsureBufs() { if (s_web_fb && s_web_band_buf && s_web_rle_buf) return true; if (s_web_fb_w <= 0 || s_web_fb_h <= 0) return false; if (!s_web_fb) s_web_fb = (uint16_t*)heap_caps_malloc((size_t)s_web_fb_w * s_web_fb_h * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_web_band_buf) s_web_band_buf = (uint8_t*)heap_caps_malloc(33000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_web_rle_buf) s_web_rle_buf = (uint8_t*)heap_caps_malloc(33000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); return s_web_fb && s_web_band_buf && s_web_rle_buf; } // Flush hook: copy a just-drawn band into the shadow buffer + grow the dirty bbox. // Runs on the LVGL thread only when a mirror client is connected. static void webMirrorOnFlush(int x, int y, int w, int h, const uint16_t* px) { if (w <= 0 || h <= 0 || !webMirrorEnsureBufs()) return; for (int row = 0; row < h; row++) { int dy = y + row; if (dy < 0 || dy >= s_web_fb_h) continue; int x0 = x, cw = w, sxoff = 0; if (x0 < 0) { cw += x0; sxoff = -x0; x0 = 0; } if (x0 + cw > s_web_fb_w) cw = s_web_fb_w - x0; if (cw > 0) memcpy(s_web_fb + (size_t)dy * s_web_fb_w + x0, px + (size_t)row * w + sxoff, (size_t)cw * 2); } int bx1 = x < 0 ? 0 : x, by1 = y < 0 ? 0 : y; int bx2 = x + w - 1, by2 = y + h - 1; if (bx2 >= s_web_fb_w) bx2 = s_web_fb_w - 1; if (by2 >= s_web_fb_h) by2 = s_web_fb_h - 1; if (bx2 < bx1 || by2 < by1) return; if (!s_web_dirty) { s_web_dx1 = bx1; s_web_dy1 = by1; s_web_dx2 = bx2; s_web_dy2 = by2; s_web_dirty = true; } else { if (bx1 < s_web_dx1) s_web_dx1 = bx1; if (by1 < s_web_dy1) s_web_dy1 = by1; if (bx2 > s_web_dx2) s_web_dx2 = bx2; if (by2 > s_web_dy2) s_web_dy2 = by2; } } // Encode + queue a rect region from the shadow buffer at full resolution. Split into // SMALL (<=4 KB) bands so each WS frame fits the lwIP socket send buffer (~5.7 KB) and // flushes in ~one non-blocking pass, well within the write timeout even on congested // Wi-Fi. Bigger bands span several send-buffer refills over multiple RTTs and can stall // past the timeout mid-write -> a truncated WS frame -> the browser drops the socket // (the "disconnects often" bug, worst on the large 480x800 remote frames). Header // (10 B) = [0x01, flags(bit0=rle), x, y, w, h LE16]. RGB565 native little-endian. static void webMirrorQueueRegion(int rx, int ry, int rw, int rh) { if (rw <= 0 || rh <= 0 || !s_web_fb || !s_web_band_buf) return; int rows = 4096 / (rw * 2); if (rows < 1) rows = 1; uint16_t* band = (uint16_t*)s_web_band_buf; for (int yy = 0; yy < rh; yy += rows) { int bh = (yy + rows <= rh) ? rows : (rh - yy); for (int r = 0; r < bh; r++) memcpy(band + (size_t)r * rw, s_web_fb + (size_t)(ry + yy + r) * s_web_fb_w + rx, (size_t)rw * 2); const size_t raw_bytes = (size_t)rw * bh * 2; uint8_t flags = 0; const uint8_t* body = s_web_band_buf; size_t body_len = raw_bytes; if (s_web_rle_buf) { size_t rl = webMirrorRle(band, rw * bh, s_web_rle_buf, raw_bytes); if (rl > 0 && rl < raw_bytes) { flags = 0x01; body = s_web_rle_buf; body_len = rl; } } int by = ry + yy; uint8_t hdr[10] = { 0x01, flags, (uint8_t)(rx & 0xFF), (uint8_t)((rx >> 8) & 0xFF), (uint8_t)(by & 0xFF), (uint8_t)((by >> 8) & 0xFF), (uint8_t)(rw & 0xFF), (uint8_t)((rw >> 8) & 0xFF), (uint8_t)(bh & 0xFF), (uint8_t)((bh >> 8) & 0xFF) }; g_web_mirror.pushFrame(hdr, 10, body, body_len); } } // Rate-capped, backpressure-gated send of the accumulated dirty bbox at full res. // Called each UI loop AFTER lv_timer_handler (so this frame's flushes are already in // the shadow). Only sends once the previous frame fully drained, so it self-paces to // the link speed (never backs up -> no disconnects) while staying crisp. static void webMirrorTick() { if (!g_web_mirror.active() || !s_web_dirty || !s_web_fb) return; if (!g_web_mirror.empty()) return; // previous frame still draining -> adapt to link speed uint32_t now = millis(); if (now - s_web_last_send_ms < 50) return; // cap the outgoing frame rate (~20 fps ceiling) int rx = s_web_dx1, ry = s_web_dy1; int rw = s_web_dx2 - s_web_dx1 + 1, rh = s_web_dy2 - s_web_dy1 + 1; s_web_dirty = false; s_web_last_send_ms = now; webMirrorQueueRegion(rx, ry, rw, rh); } #endif static void lvglFlush(lv_disp_drv_t* disp_drv, const lv_area_t* area, lv_color_t* color_p) { (void)disp_drv; if (!color_p) { lv_disp_flush_ready(disp_drv); return; } // never deref a NULL draw buffer (OOM at boot) int32_t w = area->x2 - area->x1 + 1; int32_t h = area->y2 - area->y1 + 1; #if !defined(HAS_TANMATSU) // REMOTE mode: the UI renders to an off-screen web display — never write the // physical panel (wrong resolution). Only capture + stream the band. if (s_remote_mode) { if (g_web_mirror.active()) webMirrorOnFlush(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t*>(color_p)); lv_disp_flush_ready(disp_drv); return; } #endif #if CAP_LARGE_SCREEN // UI scaling: LVGL rendered at a smaller physical resolution; nearest-neighbour upscale this // (already sw-rotated) band to the panel before blitting. 100% (s_lv_pw==panel) skips this. if (s_lv_pw != TAN_PANEL_PW && s_scale_buf) { const int sx1 = area->x1, sy1 = area->y1, sw = w, sh = h; const int px1 = sx1 * TAN_PANEL_PW / s_lv_pw; const int py1 = sy1 * TAN_PANEL_PH / s_lv_ph; const int px2 = (area->x2 + 1) * TAN_PANEL_PW / s_lv_pw; // exclusive — tiles seamlessly with the next band const int py2 = (area->y2 + 1) * TAN_PANEL_PH / s_lv_ph; const int pw = px2 - px1, ph = py2 - py1; if (pw > 0 && ph > 0 && (size_t)pw * (size_t)ph <= TAN_SCALE_BUF_PX) { const uint16_t* src = reinterpret_cast<uint16_t*>(color_p); for (int oy = 0; oy < ph; oy++) { int srcy = (py1 + oy) * s_lv_ph / TAN_PANEL_PH - sy1; if (srcy < 0) srcy = 0; else if (srcy >= sh) srcy = sh - 1; uint16_t* d = s_scale_buf + (size_t)oy * pw; const uint16_t* srow = src + (size_t)srcy * sw; for (int ox = 0; ox < pw; ox++) { int srcx = (px1 + ox) * s_lv_pw / TAN_PANEL_PW - sx1; if (srcx < 0) srcx = 0; else if (srcx >= sw) srcx = sw - 1; d[ox] = srow[srcx]; } } display.writePixelsRGB565(px1, py1, pw, ph, s_scale_buf); } lv_disp_flush_ready(disp_drv); return; } #endif #if defined(HELTEC_LORA_V4_R8) // Async DMA band flush: returns as soon as the swapped copy is handed to the SPI DMA, so // LVGL renders the next band while this one drains; the last band of the refresh closes // the frame transaction. The screenshot/web-mirror copies below read color_p, which the // driver has already copied out — unaffected. (LGFXDisplay::flushBandRGB565) display.flushBandRGB565(area->x1, area->y1, w, h, reinterpret_cast<uint16_t*>(color_p), lv_disp_flush_is_last(disp_drv)); #else display.writePixelsRGB565(area->x1, area->y1, w, h, reinterpret_cast<uint16_t*>(color_p)); #endif if (g_shot_buf) { // mirror this area into the screenshot buffer for (int32_t row = 0; row < h; ++row) { const int32_t dy = area->y1 + row; if (dy < 0 || dy >= g_shot_h) continue; int32_t x0 = area->x1, cw = w; if (x0 < 0) { cw += x0; x0 = 0; } if (x0 + cw > g_shot_w) cw = g_shot_w - x0; if (cw > 0) memcpy(g_shot_buf + (size_t)dy * g_shot_w + x0, color_p + (size_t)row * w + (x0 - area->x1), (size_t)cw * sizeof(lv_color_t)); } } #if !defined(HAS_TANMATSU) // Web UI mirror: copy this flushed band into the shadow buffer (one bool when off). if (g_web_mirror.active()) webMirrorOnFlush(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t*>(color_p)); #endif lv_disp_flush_ready(disp_drv); } // Rotate the ST7789 panel in hardware for the landscape orientations. Uses the // global `display` (ST7789LCDDisplay) — a free function so it isn't shadowed by // UITask::begin's DisplayDriver* parameter, also called `display`. ROT_90 maps // to panel rotation 1, ROT_270 to 3. Portrait leaves the panel as inited. static void applyHardwarePanelRotation(uint8_t lvgl_rot) { if (lvgl_rot == LV_DISP_ROT_90) display.setDisplayRotation(1); else if (lvgl_rot == LV_DISP_ROT_270) display.setDisplayRotation(3); } // In-chat action openers, forward-declared here UNCONDITIONALLY — their click-callbacks // compile on every board — so both those callbacks and the Tanmatsu F-key dispatch can // reach them before their definitions further down. (openEmojiPickerForComposer is only // used by the Tanmatsu F-keys, so it stays gated.) static void openQuickReplyPicker(LvChatPanel* p); // △ quick replies static void openActiveChatSettings(); // ○ channel settings #if defined(HAS_TANMATSU) static void openEmojiPickerForComposer(lv_obj_t* ta); // □ emoji picker (Tanmatsu F-key only) #endif // Settings detail-sheet state — declared OUTSIDE the CAP_KEYPAD_NAV gate (compiles on every touch // board) but ABOVE the nav code so navMaybeRebuild can collect the sheet's controls for trackball // nav. The settings code far below uses these in full; here they're just shared state. static lv_obj_t* s_settings_sheet = nullptr; // open detail sheet; null = on the landing static int s_settings_open_cat = -1; // which category is open (for About-label teardown) static bool s_settings_from_cc = false; // settings sheet opened via a control-center long-press // The Wi-Fi join / details / hidden sheet floats on the BASE layer (lv_scr_act) ABOVE the // settings category page — SAME layering as s_settings_sheet, and SAME reason it's declared up // here: navMaybeRebuild must recognise it as the focus root, else keypad/trackball nav collects // the tab content behind it and its SSID/password fields can't be selected (Tanmatsu #). Defined // (with its ta/sw siblings) far below; only the container pointer is needed up here. static lv_obj_t* s_wifi_sheet = nullptr; // join / details / hidden overlay (one at a time) #if CAP_KEYPAD_NAV // =========================================================================== // Keypad / D-pad focus navigation // --------------------------------------------------------------------------- // The Tanmatsu has NO touchscreen, so the whole (tap-designed) touch UI is // driven by the keys: a badge-bsp input queue -> an LVGL KEYPAD indev + a focus // group. Arrows + WASD move focus, Enter/○ activate, Esc/✕ go back; text fields // type while LVGL is in "edit mode". Each visible screen's clickable widgets are // collected into the group so focus-based navigation reaches the existing // tap handlers (LVGL sends CLICKED to the focused widget on Enter). // // The SAME focus group + keypad indev + navMaybeRebuild()/navCollect() are reused // on the T-Deck when the trackball is in D-pad mode (Settings -> trackball): there // the FIFO is fed from the trackball poll (updateTrackball) instead of the // badge-bsp queue, so navPump() + the bsp include stay Tanmatsu-only below. // =========================================================================== #if defined(HAS_TANMATSU) extern "C" { #include "bsp/input.h" #include "bsp/display.h" // screen backlight brightness #include "bsp/audio.h" // ES8156 codec volume + speaker amplifier #include "driver/i2s_std.h" // i2s_channel_write — the volume-slider feedback beep #include "bsp/tanmatsu.h" // bsp_tanmatsu_coprocessor_get_handle (pulls tanmatsu_coprocessor.h) } #endif static lv_group_t* s_nav_group = nullptr; #if defined(HAS_TANMATSU) static QueueHandle_t s_nav_queue = nullptr; #endif static bool s_nav_debug = false; // set true to log decoded key events + group rebuilds (printf->console) // FIFO of (LVGL key, pressed?) transitions decoded from bsp events. The keypad // read_cb drains one per call (continue_reading) so a press+release pair becomes // a single click / focus step. struct NavKeyTr { uint32_t key; bool pressed; }; static NavKeyTr s_nav_fifo[64]; static uint8_t s_nav_fifo_head = 0, s_nav_fifo_tail = 0; static uint32_t s_nav_last_key = 0; static inline void navFifoPush(uint32_t key, bool pressed) { uint8_t nxt = (uint8_t)((s_nav_fifo_head + 1) % 64); if (nxt == s_nav_fifo_tail) return; // full → drop s_nav_fifo[s_nav_fifo_head] = { key, pressed }; s_nav_fifo_head = nxt; } static inline bool navFifoPop(NavKeyTr* out) { if (s_nav_fifo_tail == s_nav_fifo_head) return false; *out = s_nav_fifo[s_nav_fifo_tail]; s_nav_fifo_tail = (uint8_t)((s_nav_fifo_tail + 1) % 64); return true; } static inline void navPushTap(uint32_t key) { navFifoPush(key, true); navFifoPush(key, false); } static lv_obj_t* s_nav_first = nullptr; // first/last focusable collected each rebuild — the ←/→ static lv_obj_t* s_nav_last = nullptr; // arrows jump to these (usually top item / primary action) static lv_obj_t* s_nav_entered_obj = nullptr; // widget Enter was just sent to; if it's a selection // that didn't navigate away, focus jumps to s_nav_last // Where the just-activated element sat, captured at Enter-time (before its cb can destroy it). // If Enter rebuilds the screen with FRESH objects (e.g. a control-center chip that recreates the // panel), navMaybeRebuild re-focuses the new element at the same spot — keeping the selection on // the button you pressed instead of dropping to the top. static int s_nav_resel_x = 0, s_nav_resel_y = 0; static bool s_nav_resel_pending = false; static inline void navMarkEntered(lv_obj_t* obj) { s_nav_entered_obj = obj; if (obj && lv_obj_is_valid(obj)) { lv_area_t a; lv_obj_get_coords(obj, &a); s_nav_resel_x = (a.x1 + a.x2) / 2; s_nav_resel_y = (a.y1 + a.y2) / 2; s_nav_resel_pending = true; } } static int s_nav_count = 0; // # focusable widgets collected this rebuild // Collected focusable widgets this rebuild, in tree order — used for 2D spatial // navigation (W/A/Z/D move to the nearest element in that physical direction, not // just the next/prev in the linear list). static const int kNavMax = 160; // spatial-nav mirror capacity — MUST exceed the largest focusable // grid or its tail is keypad-unreachable on the Tanmatsu: focusables // past this cap land in the LVGL group but not s_nav_objs[], so // navMoveDir() never reaches them (the 122-cell emoji picker — GH #141). static lv_obj_t* s_nav_objs[kNavMax] = { nullptr }; static lv_obj_t* s_nav_tabbar = nullptr; // the bottom tab bar (btnmatrix), added last to the group static bool s_nav_want_tabbar = false; // after switching tabs from the bar, refocus the bar static lv_obj_t* s_nav_styled = nullptr; // obj currently wearing the focus highlight #if defined(HAS_TANMATSU) || defined(TLORA_PAGER) static bool s_nav_show = true; // keyboard-only device: focus highlight is always visible #else static bool s_nav_show = false; // T-Deck: focus-visible — paint only while keyboard-navigating (hidden after a touch/click) #endif // While navMaybeRebuild() tears down + recollects the focus group, the FIRST object re-added to the // empty group gets auto-focused by LVGL. On a keyboard-only device (s_nav_show always true) that // transient focus would scroll the list/chat to that first item (e.g. yank an open chat to the top // bubble right after it correctly landed at the bottom). This flag makes navFocusCb skip the // scroll-into-view during a rebuild; direct arrow-nav (focus set OUTSIDE a rebuild) still scrolls. static bool s_nav_suppress_scroll = false; static lv_obj_t* s_nav_focus_hint = nullptr; // one-shot: focus this object on the next rebuild (#45) static void goToTab(int idx); // (defined far below) tab switch + refresh static int getActiveTab(); // (defined below) current tabview index static void mapNudge(int dir); // (defined far below) map pan — 0=up 1=down 2=left 3=right static bool hwKeyDismissTopPopup(); // (defined far below) close the topmost modal/sheet static bool anyPopupOpen(); // (defined below) is any modal/sheet currently up static void closeChatPanel(LvChatPanel* p); // (defined far below) close an open chat/channel detail static void toggleControlCenter(); // (defined far below) the top-bar "control center" dropdown static void homeKeyActivate(); // (defined far below) green ○ tap: Home, or toggle drawer if already Home static uint32_t s_f4_down_ms = 0; // green ○ press time — tap = Home, hold = control center static uint32_t s_f1_down_ms = 0; // red ✕ press time — tap = close, hold = power menu static uint32_t s_nav_rep_key = 0; // arrow key currently held for auto-repeat (0 = none) static uint32_t s_nav_rep_t0 = 0; // when it was pressed static uint32_t s_nav_rep_last = 0; // last auto-repeat fire time static bool s_f1_fired = false; // power menu already opened during this F1 hold static bool s_f4_fired = false; // control center already opened during this F4 hold #if defined(HAS_TANMATSU) // The Tanmatsu Vol- (bottom side button) emits NOISY bursts of press/release events per physical // press (not one clean edge, and a single tap streams for >0.6 s), so we can't tell a hold from a // tap — we just act ONCE per burst, on a "fresh" event after a quiet gap. Single-tap toggle. static uint32_t s_vol_last_ev = 0; // last Vol- event time (any press/release) // Vol+ (middle side button): a single tap toggles the top-bar dropdown (fired INSTANTLY on the fresh // event); a long hold toggles the sound master switch. A tap streams events for a while so we can't // defer it — the tap fires immediately, and a detected long-press UNDOES it (toggles the dropdown // back) before flipping the sound. Net: a hold leaves the dropdown as it was and just flips sound. static uint32_t s_volup_start = 0; // 0 = no active burst; else the burst's first-event time static uint32_t s_volup_last_ev = 0; // last Vol+ event time (burst coalescing) static bool s_volup_long_fired = false; // long-press (sound) already fired this burst static constexpr uint32_t VOLUP_LONG_MS = 1500; // hold this long (ms) to convert the tap into a sound toggle // ---- Message-notification LED (the discrete envelope-icon RGB LED on the CH32 coprocessor) -------- // Hardware (I2C register 146) is on/off per channel for colour A + colour B, plus a `fade` bit that // makes the coprocessor autonomously breathe between A and B. There is NO brightness/PWM, so we map: // • unread present -> breathe OFF<->green (a soft "glow") byte 0x28 = green_b | fade // • new message -> solid white, briefly (a bright flash) byte 0x07 = red | green | blue (colour A) // • idle / disabled -> dark byte 0x00 // Bit layout: b0 R(A) b1 G(A) b2 B(A) b3 fade b4 R(B) b5 G(B) b6 B(B) b7 fade_hold. static uint8_t s_msgled_last = 0xFF; // last byte written (0xFF = unknown -> force the first write) static uint32_t s_msgled_flash_until = 0; // solid-flash one-shot deadline (0 = not flashing) static void msgLedWriteRaw(uint8_t byte) { if (byte == s_msgled_last) return; // only hit the I2C bus on a real change tanmatsu_coprocessor_handle_t cph = nullptr; if (bsp_tanmatsu_coprocessor_get_handle(&cph) != ESP_OK || !cph) return; if (tanmatsu_coprocessor_set_message(cph, byte & 0x01, byte & 0x02, byte & 0x04, byte & 0x10, byte & 0x20, byte & 0x40, byte & 0x08, byte & 0x80) == ESP_OK) { s_msgled_last = byte; } } // Steady state from (feature on?, any unread?). A pending flash overrides until its deadline passes. static void msgLedRefresh(bool unread_present) { if (!touchPrefsGetMsgLed()) { msgLedWriteRaw(0x00); return; } // feature off -> dark if (s_msgled_flash_until) { if ((int32_t)(millis() - s_msgled_flash_until) < 0) { msgLedWriteRaw(0x07); return; } // still flashing s_msgled_flash_until = 0; // flash done } msgLedWriteRaw(unread_present ? 0x28 : 0x00); // breathe green when unread, else dark } // Kick a one-shot bright flash (a new message just arrived); the steady glow/off resumes after. static void msgLedFlash() { if (!touchPrefsGetMsgLed()) return; s_msgled_flash_until = millis() + 220; msgLedWriteRaw(0x07); } static void msgLedToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetMsgLed(on); if (on) msgLedFlash(); // brief confirmation blink else msgLedWriteRaw(0x00); // and go dark immediately when turned off if (g_lv.task) g_lv.task->showAlert(on ? TR("Message LED on") : TR("Message LED off"), 900); } #endif // The open chat/channel detail is a full-screen overlay on lv_scr_act (above the tabview), so the // keypad nav must treat IT as the focus container — else arrows hit the tab bar behind it (switching // screens), the composer never gets focus, and Esc has nothing to close. Returns the open panel. static LvChatPanel* navOpenChatPanel() { if (g_lv.dm.detail_open && g_lv.dm.overlay && !lv_obj_has_flag(g_lv.dm.overlay, LV_OBJ_FLAG_HIDDEN)) return &g_lv.dm; if (g_lv.ch.detail_open && g_lv.ch.overlay && !lv_obj_has_flag(g_lv.ch.overlay, LV_OBJ_FLAG_HIDDEN)) return &g_lv.ch; return nullptr; } // True when the bottom tab bar is the focused item (so ←/→/Enter switch screens there). static bool navOnTabBar() { return s_nav_tabbar && s_nav_group && lv_group_get_focused(s_nav_group) == s_nav_tabbar; } // Switch tabs by ±1 (wraps) and keep focus on the bar so you can keep flipping through screens. static void navSwitchTab(int dir) { if (!g_lv.tabview) return; lv_obj_t* tvc = lv_tabview_get_content(g_lv.tabview); int n = tvc ? (int)lv_obj_get_child_cnt(tvc) : 0; if (n <= 0) return; s_nav_want_tabbar = true; goToTab(((getActiveTab() + dir) % n + n) % n); } // Jump straight to a main tab (the coloured F-keys). Close any open modal/sheet first so the jump // lands on the bare page. static void navGoToMainTab(int tab) { for (int i = 0; i < 8 && anyPopupOpen(); i++) hwKeyDismissTopPopup(); goToTab(tab); } // Visible focus ring (the LVGL default theme's focus outline is invisible on this dark UI). Driven // by the group's focus-changed callback. Reverse-video ("negative") highlight: the // focused element is FILLED with the text colour and its text flipped to the // background colour — like a text selection — instead of a coloured ring. Applied as // LOCAL styles so it overrides each element's own styling, and restored exactly via // local-style introspection so app styling survives the unfocus. The bottom tab bar // (a btnmatrix) is the exception: a full fill there reads as a messy bar, so it keeps // a bright outline. static struct { bool bg_c, bg_o, txt; lv_style_value_t v_bg_c, v_bg_o, v_txt; } s_nav_sv; // Descendant labels that carry their OWN (local) text colour don't inherit the // container's flipped colour, so they'd stay light on the light fill -> unreadable. // Track + flip them to dark on focus, restore exactly on unfocus. static struct { lv_obj_t* o; lv_style_value_t v; } s_nav_txt[32]; static int s_nav_txt_n = 0; static void navInvertText(lv_obj_t* o) { lv_style_value_t v; if (lv_obj_get_local_style_prop(o, LV_STYLE_TEXT_COLOR, &v, LV_PART_MAIN) == LV_STYLE_RES_FOUND && s_nav_txt_n < (int)(sizeof(s_nav_txt) / sizeof(s_nav_txt[0]))) { s_nav_txt[s_nav_txt_n].o = o; s_nav_txt[s_nav_txt_n].v = v; s_nav_txt_n++; lv_obj_set_style_text_color(o, lv_color_hex(COLOR_BG), LV_PART_MAIN); } uint32_t n = lv_obj_get_child_cnt(o); for (uint32_t i = 0; i < n; i++) navInvertText(lv_obj_get_child(o, i)); } static void navRestoreText() { for (int i = 0; i < s_nav_txt_n; i++) if (s_nav_txt[i].o && lv_obj_is_valid(s_nav_txt[i].o)) lv_obj_set_local_style_prop(s_nav_txt[i].o, LV_STYLE_TEXT_COLOR, s_nav_txt[i].v, LV_PART_MAIN); s_nav_txt_n = 0; } static void navUnstyle(lv_obj_t* o) { navRestoreText(); // un-flip descendant text first (each is_valid-guarded) if (!o || !lv_obj_is_valid(o)) return; if (o == s_nav_tabbar) { // tab bar: clear the inverted active-tab cell lv_obj_remove_local_style_prop(o, LV_STYLE_BG_COLOR, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_remove_local_style_prop(o, LV_STYLE_BG_OPA, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_remove_local_style_prop(o, LV_STYLE_TEXT_COLOR, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_invalidate(o); return; } lv_obj_set_style_outline_width(o, 0, LV_PART_MAIN); lv_obj_set_style_shadow_width(o, 0, LV_PART_MAIN); // drop the switch/slider focus glow (navFocusCb adds it) if (s_nav_sv.bg_c) lv_obj_set_local_style_prop(o, LV_STYLE_BG_COLOR, s_nav_sv.v_bg_c, LV_PART_MAIN); else lv_obj_remove_local_style_prop(o, LV_STYLE_BG_COLOR, LV_PART_MAIN); if (s_nav_sv.bg_o) lv_obj_set_local_style_prop(o, LV_STYLE_BG_OPA, s_nav_sv.v_bg_o, LV_PART_MAIN); else lv_obj_remove_local_style_prop(o, LV_STYLE_BG_OPA, LV_PART_MAIN); if (s_nav_sv.txt) lv_obj_set_local_style_prop(o, LV_STYLE_TEXT_COLOR, s_nav_sv.v_txt, LV_PART_MAIN); else lv_obj_remove_local_style_prop(o, LV_STYLE_TEXT_COLOR, LV_PART_MAIN); lv_obj_invalidate(o); } // Keyboard-nav edit mode for text fields: when focus lands on a field it is NOT editable // yet — the letter-nav keys (W/A/S/D/X) keep navigating. Select/Enter starts typing; any // focus move (arrows / trackball / advance) drops back to navigate. Without this, landing // on a field trapped every letter key as a typed character — you couldn't move away. static bool s_nav_ta_editing = false; static void navFocusCb(lv_group_t* g) { lv_obj_t* f = lv_group_get_focused(g); // Edit mode — decided only on a GENUINE move to a different field. The chat composer // auto-edits (you opened the chat to type), and a field focused by a TOUCH tap edits // immediately too (you pointed right at it). Every OTHER field (reached with the letter-nav // keys) starts in navigate mode so those keys keep working — press select/Enter to edit it. // Crucially, a navMaybeRebuild() that re-focuses the SAME field (e.g. while the accent box is // up) must NOT reset edit mode — otherwise it kicks you out of typing mid-word. static lv_obj_t* s_nav_focus_prev = nullptr; if (f && f != s_nav_focus_prev) { // ignore the transient f==null a rebuild's remove-all emits lv_indev_t* act = lv_indev_get_act(); // the indev driving this focus change (null if programmatic) const bool by_touch = act && lv_indev_get_type(act) == LV_INDEV_TYPE_POINTER; #if defined(HAS_TANMATSU) (void)by_touch; // no touch; the dedicated arrow keys navigate between fields. // The chat composer auto-edits whenever it gains focus (you focused it to type), so // backspace works immediately — including after navigating to another element and // back to the field (the bug: it used to land back in navigate mode, where backspace // reads as "back" and won't delete your earlier text). Every OTHER field starts in // NAVIGATE mode so the letter-nav keys keep working until you press select/Enter. s_nav_ta_editing = (f == g_lv.ch.composer_ta || f == g_lv.dm.composer_ta); #else s_nav_ta_editing = (f != nullptr && (by_touch || f == g_lv.ch.composer_ta || f == g_lv.dm.composer_ta)); #endif s_nav_focus_prev = f; } lv_obj_t* nav_was_styled = s_nav_styled; if (s_nav_styled && s_nav_styled != f) navUnstyle(s_nav_styled); s_nav_styled = nullptr; // old highlight (if any) is now restored; nothing styled yet if (!f || !s_nav_show) return; // focus-visible: paint only while actively keyboard-navigating s_nav_styled = f; // we ARE styling f now — only now does it own s_nav_sv / s_nav_txt if (f == nav_was_styled) return; // re-focused the SAME already-styled obj: leave its saved style intact. // Re-applying would capture the reverse-video AS the "original" so a later // navUnstyle restores the highlight → the obj stays stuck highlighted. if (f == s_nav_tabbar) { // Tab bar is a btnmatrix — invert just the active (checked) tab cell, which tracks // the selection as you press left/right, instead of boxing the whole bar. lv_obj_set_style_bg_color(f, lv_color_hex(COLOR_TEXT), LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_bg_opa(f, LV_OPA_COVER, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_text_color(f, lv_color_hex(COLOR_BG), LV_PART_ITEMS | LV_STATE_CHECKED); } else if (lv_obj_check_type(f, &lv_switch_class) || lv_obj_check_type(f, &lv_slider_class) || lv_obj_check_type(f, &lv_textarea_class)) { // A switch/slider's look is its knob/indicator, not the MAIN bg, so a reverse-video // fill doesn't read as focused — give it a bright outline PLUS an accent glow instead. s_nav_sv.bg_c = s_nav_sv.bg_o = s_nav_sv.txt = false; // nothing to restore but the outline/shadow // Bright white ring tight to the widget… lv_obj_set_style_outline_color(f, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_outline_width(f, 3, LV_PART_MAIN); lv_obj_set_style_outline_pad(f, 3, LV_PART_MAIN); lv_obj_set_style_outline_opa(f, LV_OPA_COVER, LV_PART_MAIN); // …backed by a big soft ACCENT glow so a focused switch/slider really pops out // from its neighbours (cleared in navUnstyle alongside the outline). lv_obj_set_style_shadow_color(f, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_shadow_width(f, 16, LV_PART_MAIN); lv_obj_set_style_shadow_spread(f, 2, LV_PART_MAIN); lv_obj_set_style_shadow_opa(f, LV_OPA_COVER, LV_PART_MAIN); } else if (lv_obj_has_flag(f, NAV_ACCENTFOCUS_FLAG)) { // App-drawer tile: a solid white reverse-fill over a small rounded icon // chip reads as a glitch, not a highlight — use the SAME accent tint the // tile's own LV_STATE_PRESSED style already applies on a touch/trackball // press, so keyboard/encoder focus looks identical on every board instead // of white-on-pager vs. accent-on-touch. s_nav_sv.bg_c = s_nav_sv.bg_o = s_nav_sv.txt = false; // nothing to restore; navUnstyle just clears these lv_obj_set_style_bg_color(f, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(f, LV_OPA_20, LV_PART_MAIN); } else { // Save the element's current local props, then apply the negative fill. s_nav_sv.bg_c = lv_obj_get_local_style_prop(f, LV_STYLE_BG_COLOR, &s_nav_sv.v_bg_c, LV_PART_MAIN) == LV_STYLE_RES_FOUND; s_nav_sv.bg_o = lv_obj_get_local_style_prop(f, LV_STYLE_BG_OPA, &s_nav_sv.v_bg_o, LV_PART_MAIN) == LV_STYLE_RES_FOUND; s_nav_sv.txt = lv_obj_get_local_style_prop(f, LV_STYLE_TEXT_COLOR, &s_nav_sv.v_txt, LV_PART_MAIN) == LV_STYLE_RES_FOUND; lv_obj_set_style_bg_color(f, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_opa(f, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_text_color(f, lv_color_hex(COLOR_BG), LV_PART_MAIN); // dark text on the light fill (inheritors) lv_obj_set_style_outline_width(f, 0, LV_PART_MAIN); // no amber ring lv_obj_set_style_outline_width(f, 0, LV_PART_MAIN | LV_STATE_FOCUS_KEY); // no stock-theme blue ring // …and flip any descendant labels that carry their own colour, so all text stays readable. uint32_t nc = lv_obj_get_child_cnt(f); for (uint32_t i = 0; i < nc; i++) navInvertText(lv_obj_get_child(f, i)); } if (!s_nav_suppress_scroll) lv_obj_scroll_to_view_recursive(f, LV_ANIM_OFF); } #if CAP_KEYPAD_NAV // Hide the keyboard-nav focus highlight — called when the user touches the screen or // clicks the trackball (pointer input takes over). The group keeps its internal focus, // so the next ESDFX key resumes from the same place and re-reveals the highlight. static void navHideFocus() { if (s_nav_styled) { navUnstyle(s_nav_styled); s_nav_styled = nullptr; } s_nav_show = false; } // ---- Keyboard-nav tab hotkeys (programmable; default E/R/T/U/I) ---- static uint8_t s_nav_keys[5] = { 'e','r','t','u','i' }; // per main tab [chat,contacts,home,map,settings]; loaded from prefs at boot static uint8_t s_dir_keys[8] = #if defined(HAS_TANMATSU) { 'w','x','a','d','s', 0, 'f','v' }; // Tanmatsu control keys: up,down,left,right,select,(no back — Esc/F-key),scroll-up,scroll-down #else { 'w','z','a','d','s','q','f','c' }; // control keys: up,down,left,right,select,back,scroll-up,scroll-down; loaded from prefs at boot #endif static int s_navkey_capture = -1; // binding idx awaiting a new key from the settings remap: 0-4 tab, 5-12 dir (-1 = idle) static lv_obj_t* s_navkey_row_val[13] = { nullptr }; // the key labels in the settings rows (0-4 tabs, 5-12 dirs); refreshed on remap static lv_obj_t* s_navkey_hint[5] = { nullptr }; // small key labels over the menubar icons (tab hotkeys only) static bool s_nav_mbar_keys = false; // show those menubar letter hints? pref, default false = hidden static const char* const kNavTabNames[5] = { "Messages", "Contacts", "Home", "Map", "Settings" }; static const char* const kNavDirNames[8] = { "Up", "Down", "Left", "Right", "Select", "Back", "Scroll up", "Scroll down" }; // 2D spatial focus move: jump to the nearest focusable element in the given physical // direction (not just next/prev in the linear list). Scores candidates in that // half-plane by primary-axis distance + a cross-axis penalty (prefers aligned + close). enum NavDir { NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT }; static void navMoveDir(int dir) { if (!s_nav_group) return; const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; if (n <= 0) return; lv_obj_t* cur = lv_group_get_focused(s_nav_group); if (!cur || !lv_obj_is_valid(cur)) { // s_nav_objs is a raw mirror that only refreshes on the next navMaybeRebuild // tick; with chat bubbles now recycled on every scroll-end reflow, slot 0 can // be a just-deleted bubble for one tick — focusing it unguarded dereferences // freed memory inside lv_group_focus_obj. s_nav_show = true; if (s_nav_objs[0] && lv_obj_is_valid(s_nav_objs[0])) lv_group_focus_obj(s_nav_objs[0]); return; } // A focused slider captures LEFT/RIGHT to adjust its VALUE instead of moving focus — // so horizontal tunes the slider and you leave it by going UP/DOWN. (Same idea as the // open-dropdown capture in navPump.) Fire VALUE_CHANGED (live preview) then RELEASED // (commit) so the slider's callbacks behave exactly like a drag+release: brightness // applies + persists, map zoom re-renders + persists, etc. if ((dir == NAV_LEFT || dir == NAV_RIGHT) && lv_obj_check_type(cur, &lv_slider_class)) { const int32_t mn = lv_slider_get_min_value(cur), mx = lv_slider_get_max_value(cur); int32_t step = (mx - mn) / 20; if (step < 1) step = 1; // ~20 presses end-to-end, min 1 int32_t v = lv_slider_get_value(cur) + (dir == NAV_RIGHT ? step : -step); if (v < mn) v = mn; else if (v > mx) v = mx; lv_slider_set_value(cur, v, LV_ANIM_OFF); lv_event_send(cur, LV_EVENT_VALUE_CHANGED, nullptr); lv_event_send(cur, LV_EVENT_RELEASED, nullptr); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return; } lv_area_t a; lv_obj_get_coords(cur, &a); const int cx = (a.x1 + a.x2) / 2, cy = (a.y1 + a.y2) / 2; lv_obj_t* best = nullptr; long bestScore = 0x7FFFFFFFL; for (int i = 0; i < n; i++) { lv_obj_t* o = s_nav_objs[i]; if (!o || o == cur || !lv_obj_is_valid(o) || lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) continue; // Horizontal-only secondary targets (chat-row gears) are reachable by LEFT/RIGHT only, so // UP/DOWN walk the primary rows instead of hopping onto a gear. if ((dir == NAV_UP || dir == NAV_DOWN) && lv_obj_has_flag(o, NAV_HMOVE_FLAG)) continue; lv_area_t b; lv_obj_get_coords(o, &b); const int dx = (b.x1 + b.x2) / 2 - cx, dy = (b.y1 + b.y2) / 2 - cy; // Cross-axis distance is the GAP between the rects (0 when they overlap on that // axis) — so a full-width row below a right-aligned switch still counts as // "straight down" and isn't skipped for a far but column-aligned element. long hgap = b.x1 - a.x2; if (a.x1 - b.x2 > hgap) hgap = a.x1 - b.x2; if (hgap < 0) hgap = 0; long vgap = b.y1 - a.y2; if (a.y1 - b.y2 > vgap) vgap = a.y1 - b.y2; if (vgap < 0) vgap = 0; // Direction test uses the CENTRE delta (which side is the candidate on). Ranking uses // GAPS, not centre distance: `primary` = the gap along the pressed axis (how far past the // current edge the candidate sits) so the element IMMEDIATELY in that direction wins — a // tall, far button is no longer out-scored by a sideways box whose centre happens to be // vertically close. `cross` = the off-axis gap (0 when the rects line up on that axis). bool ok; long primary, cross; switch (dir) { case NAV_UP: ok = dy < 0; primary = vgap; cross = hgap; break; case NAV_DOWN: ok = dy > 0; primary = vgap; cross = hgap; break; case NAV_LEFT: ok = dx < 0; primary = hgap; cross = vgap; break; default: ok = dx > 0; primary = hgap; cross = vgap; break; // NAV_RIGHT } if (!ok) continue; // Column/row alignment dominates: a candidate directly in line (cross == 0) beats a // nearer-but-sideways one, so DOWN from a right-column button lands on the button below // it, not a box off to the left. Among in-line candidates the closest (smallest gap) wins. const long score = primary + 8 * cross; if (score < bestScore) { bestScore = score; best = o; } } if (best) { s_nav_show = true; lv_group_focus_obj(best); if (g_lv.task) g_lv.task->noteUserInput(); } } static inline int navKeyLower(int k) { return (k >= 'A' && k <= 'Z') ? k - 'A' + 'a' : k; } // Which binding currently uses key `lk` (0-4 = tab hotkey, 5-10 = up/down/left/right/ // select/back), excluding binding `except`, or -1. Used to reject duplicate assignments. static int navKeyUsedBy(int lk, int except) { for (int t = 0; t < 5; t++) if (t != except && s_nav_keys[t] && navKeyLower(s_nav_keys[t]) == lk) return t; for (int d = 0; d < 8; d++) if ((d + 5) != except && s_dir_keys[d] && navKeyLower(s_dir_keys[d]) == lk) return d + 5; return -1; } // The main tab a key jumps to while keyboard nav is on, or -1. static int navTabForHotkey(int key) { const int lk = navKeyLower(key); for (int t = 0; t < 5; t++) if (s_nav_keys[t] && navKeyLower(s_nav_keys[t]) == lk) return t; return -1; } // The control action a key triggers (0..5 move/select/back, 6/7 scroll up/down), or -1. static int navDirForKey(int key) { const int lk = navKeyLower(key); for (int d = 0; d < 8; d++) if (s_dir_keys[d] && navKeyLower(s_dir_keys[d]) == lk) return d; return -1; } // Scroll the focused element's nearest scrollable ancestor (a list/page) without // moving the focus — for the scroll-up/down keys. // Scroll one "page" (~two-thirds of the view) in the given direction. static void navScrollBy(lv_obj_t* p, bool up) { const lv_coord_t room = up ? lv_obj_get_scroll_top(p) : lv_obj_get_scroll_bottom(p); lv_coord_t step = lv_obj_get_height(p) * 2 / 3; if (step > room) step = room; lv_obj_scroll_by(p, 0, up ? step : -step, LV_ANIM_ON); } // Find the largest scrollable container under `o` that still has room to scroll the // requested way (the main page scroller usually wins on area). static void navFindScrollableRec(lv_obj_t* o, bool up, lv_obj_t** best, long* bestArea) { if (!o || lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) return; if (lv_obj_has_flag(o, LV_OBJ_FLAG_SCROLLABLE)) { const lv_coord_t room = up ? lv_obj_get_scroll_top(o) : lv_obj_get_scroll_bottom(o); if (room > 0) { const long area = (long)lv_obj_get_width(o) * (long)lv_obj_get_height(o); if (area > *bestArea) { *bestArea = area; *best = o; } } } const uint32_t n = lv_obj_get_child_cnt(o); for (uint32_t i = 0; i < n; i++) navFindScrollableRec(lv_obj_get_child(o, i), up, best, bestArea); } // Scroll the focused element's nearest scrollable ancestor; if nothing focusable can // scroll (or the focus isn't inside a scroll area), fall back to the biggest scrollable // page on the active screen — so the scroll keys ALWAYS move the page. static bool navMapZoomIfActive(bool zoom_in); // defined with the map zoom code below // Returns the container it scrolled (or null if the map-zoom shortcut fired / nothing // scrollable was found) — callers that need to relocate focus afterward use this instead // of re-deriving the container themselves (see navRefocusFirstVisible()'s callers). static lv_obj_t* navScrollFocused(bool up) { // On the Map tab the scroll keys zoom instead (F/C on the T-Deck, F/V on the // Tanmatsu — scroll-up = zoom in, like a mouse wheel on any map). The map has // nothing to scroll, so the keys were dead weight there (wyvern.red feedback). if (navMapZoomIfActive(up)) return nullptr; lv_obj_t* o = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; for (lv_obj_t* p = o; p; p = lv_obj_get_parent(p)) { if (!lv_obj_has_flag(p, LV_OBJ_FLAG_SCROLLABLE)) continue; const lv_coord_t room = up ? lv_obj_get_scroll_top(p) : lv_obj_get_scroll_bottom(p); if (room <= 0) continue; // nothing to scroll that way here — try the next ancestor navScrollBy(p, up); return p; } lv_obj_t* best = nullptr; long bestArea = 0; // fallback: scroll the page regardless of focus navFindScrollableRec(lv_scr_act(), up, &best, &bestArea); if (best) navScrollBy(best, up); return best; } // Simple ancestor walk with no "room to scroll" gate — used only to relocate a container // for a post-scroll refocus, where direction no longer matters. static lv_obj_t* navNearestScrollableAncestor(lv_obj_t* start) { for (lv_obj_t* p = start; p; p = lv_obj_get_parent(p)) if (lv_obj_has_flag(p, LV_OBJ_FLAG_SCROLLABLE)) return p; return nullptr; } // After a free-scroll with no explicit focus target, snap focus to whatever's nearest the // top of the now-visible viewport — mirrors how a touch drag leaves focus alone until you // tap something, but this device has no touch fallback, so plain NEXT/PREV needs a sane // resume point instead of staying stuck on a focus that scrolled off-glass. static void navRefocusFirstVisible(lv_obj_t* p) { if (!p || !s_nav_group) return; lv_area_t pa; lv_obj_get_coords(p, &pa); const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; lv_obj_t* best = nullptr; int32_t bestY = 0x7FFFFFFF; // lv_coord_t is 16-bit; would overflow this sentinel for (int i = 0; i < n; i++) { lv_obj_t* o = s_nav_objs[i]; if (!o || !lv_obj_is_valid(o) || lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) continue; lv_area_t oa; lv_obj_get_coords(o, &oa); if (oa.y1 < pa.y1 || oa.y1 > pa.y2) continue; // not at/below the container's own top edge if (oa.y1 < bestY) { bestY = oa.y1; best = o; } } if (best) { s_nav_show = true; lv_group_focus_obj(best); } } // Small key hints over each menubar icon — shown only while keyboard nav is on. static void navMenubarKeysSync() { #if defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) // Tanmatsu menubar uses the coloured F-key shapes, not letter hotkeys. The // pager prints each fixed mnemonic beside its icon directly in the tab label, // so neither target needs this optional overlay. The M9 has no tab bar at all. A plain `return` isn't enough // since the body below still needs s_kbd_nav to exist at compile time; exclude it. #else if (!g_lv.tabview) return; lv_obj_t* bar = lv_tabview_get_tab_btns(g_lv.tabview); if (!bar) return; const int bw = lv_obj_get_width(bar); const int cw = bw > 0 ? bw / 5 : 0; for (int i = 0; i < 5; i++) { if (!s_kbd_nav || !s_nav_mbar_keys) { if (s_navkey_hint[i]) lv_obj_add_flag(s_navkey_hint[i], LV_OBJ_FLAG_HIDDEN); continue; } if (!s_navkey_hint[i]) { lv_obj_t* l = lv_label_create(bar); lv_obj_remove_style_all(l); lv_obj_add_flag(l, LV_OBJ_FLAG_FLOATING | LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(l, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); s_navkey_hint[i] = l; } const int lk = navKeyLower(s_nav_keys[i]); char b[2] = { (char)(lk >= 'a' && lk <= 'z' ? lk - 'a' + 'A' : lk), 0 }; lv_label_set_text(s_navkey_hint[i], b); lv_obj_clear_flag(s_navkey_hint[i], LV_OBJ_FLAG_HIDDEN); if (cw > 0) lv_obj_align(s_navkey_hint[i], LV_ALIGN_LEFT_MID, cw * i + cw / 2 - 15, 8); // bottom-left of the icon } #endif } // Apply a captured key to the tab being remapped (Settings → Keyboard). static void navKeyCaptureApply(int key) { const int t = s_navkey_capture; // 0-4 = tab hotkey, 5-12 = up/down/left/right/select/back/scroll-up/scroll-down s_navkey_capture = -1; if (t < 0 || t >= 13) return; if (key == 0x1B || key == 0x08 || key == 0x7F) { if (g_lv.task) g_lv.task->showAlert(TR("Cancelled"), 700); return; } if (!((key>='a'&&key<='z') || (key>='A'&&key<='Z'))) { if (g_lv.task) g_lv.task->showAlert(TR("Letters only"), 900); return; } const int lk = navKeyLower(key); if (navKeyUsedBy(lk, t) >= 0) { if (g_lv.task) g_lv.task->showAlert(TR("Key already in use"), 1100); return; } if (t < 5) { s_nav_keys[t] = (uint8_t)lk; #if defined(ESP32) touchPrefsSetNavKey(t, (uint8_t)lk); #endif navMenubarKeysSync(); // tab hotkey changed → refresh the menubar hint } else { s_dir_keys[t - 5] = (uint8_t)lk; #if defined(ESP32) touchPrefsSetNavDirKey(t - 5, (uint8_t)lk); #endif } if (s_navkey_row_val[t] && lv_obj_is_valid(s_navkey_row_val[t])) { char b[2] = { (char)(lk - 'a' + 'A'), 0 }; lv_label_set_text(s_navkey_row_val[t], b); } if (g_lv.task) g_lv.task->showAlert(TR("Hotkey saved"), 800); } static void navKeyCaptureStartCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = (int)(intptr_t)lv_event_get_user_data(e); if (t < 0 || t >= 13) return; // 0-4 = tab hotkeys, 5-12 = dir/select/back/scroll s_navkey_capture = t; if (g_lv.task) g_lv.task->showAlert(TR("Press a key…"), 4000); } // Reposition the menubar key hints when the tab bar is (re)sized — first layout + rotation. static void navMenubarSizeCb(lv_event_t* /*e*/) { navMenubarKeysSync(); } #endif // The widget currently focused in the nav group, iff it's an editable text field. static lv_obj_t* navFocusedTextarea() { if (!s_nav_group) return nullptr; lv_obj_t* f = lv_group_get_focused(s_nav_group); return (f && lv_obj_check_type(f, &lv_textarea_class)) ? f : nullptr; } // The text cursor is the reliable "you can type now" cue: show it only while editing, hide it // while a focused field is merely the keyboard-nav target. Synced each nav tick — the per-field // LV_PART_CURSOR opacity override is a no-op when unchanged, so it's cheap; the previous field's // cursor is restored when focus moves off it (so it reads normally outside keyboard-nav). static lv_obj_t* s_nav_cursor_ta = nullptr; static bool s_nav_cursor_shown = true; static void navSyncCursor() { lv_obj_t* tf = navFocusedTextarea(); const bool want = (tf != nullptr) && s_nav_ta_editing; if (tf == s_nav_cursor_ta && want == s_nav_cursor_shown) return; // unchanged → nothing to do if (s_nav_cursor_ta && s_nav_cursor_ta != tf && lv_obj_is_valid(s_nav_cursor_ta)) lv_obj_set_style_opa(s_nav_cursor_ta, LV_OPA_COVER, LV_PART_CURSOR); // restore on leave if (tf) lv_obj_set_style_opa(tf, want ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_CURSOR); s_nav_cursor_ta = tf; s_nav_cursor_shown = want; } // Drain badge-bsp key events (called from UITask::loop). Focus-movement / activation // is queued for the LVGL keypad indev; TEXT is applied DIRECTLY to the focused field // so the physical keyboard types straight in (T-Deck-style) — no on-screen keyboard, // no LVGL edit-mode toggle. When a field is focused: printable keys type, backspace // deletes, ←/→ move the caret, ↑/↓/Tab/Enter move focus to the next/prev widget. // While a dropdown is open the focus group must NOT rebuild/refocus (that closes it), and arrow // keys must reach it as LV_KEY_UP/DOWN — lv_dropdown ignores PREV/NEXT, which the group instead // consumes to move focus away (closing the dropdown). Returns the focused obj iff it's an open // dropdown. static lv_obj_t* navOpenDropdown() { if (!s_nav_group) return nullptr; lv_obj_t* f = lv_group_get_focused(s_nav_group); if (f && lv_obj_check_type(f, &lv_dropdown_class) && lv_dropdown_is_open(f)) return f; return nullptr; } #if defined(HAS_TANMATSU) || defined(TLORA_PAGER) // Enter on a focused chat bubble = the same per-message action menu the T-Deck opens on a // long-press (Copy / Info / …). Bubbles are the focusable leaves inside the chat's msgs // container, so identify one by its parent. Returns true if it handled the Enter. Shared by // Tanmatsu's navPump() (below) and the pager's handleHwKey() Enter branch — board-agnostic, // only touches s_nav_group/navOpenChatPanel/plain lv_obj calls. static bool navEnterBubble() { lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; LvChatPanel* cp = navOpenChatPanel(); if (cp && cp->msgs && foc && lv_obj_is_valid(foc) && lv_obj_get_parent(foc) == cp->msgs) { lv_event_send(foc, LV_EVENT_LONG_PRESSED, nullptr); return true; } return false; } #endif #if defined(HAS_TANMATSU) // bsp-input driven; on the T-Deck navFifo is fed from the trackball instead // ALT-accent picker (issue #129) — functions defined with the accent machinery // further down (they reuse the accent popup + kAccentSets); called from navPump. // State lives HERE so navPump can consult it: while a pick is open the focused- // textarea lookup can come back null (focus wanders when overlays appear), so // the hook falls back to s_altacc_ta instead of dropping the cycle keypress. static bool s_altacc_active = false; static char s_altacc_key = 0; // letter driving the open picker static lv_obj_t* s_altacc_ta = nullptr; // field the pick inserts into static bool tanAltAccentHandleKey(char c, lv_obj_t* ta); static void tanAltAccentAltReleased(); static bool mentionNavActive(); static void mentionNavMove(int delta); static void mentionNavConfirm(); static void mentionBoxHide(); // The UP/DOWN/LEFT/RIGHT action, factored out so a HELD arrow can auto-repeat it (navPump's // per-frame tick re-fires this). Recomputes the focused field each call so repeat stays correct. static void navArrowAction(uint32_t key) { lv_obj_t* ta_focused = navFocusedTextarea(); lv_obj_t* ta = (ta_focused && (!s_kbd_nav || s_nav_ta_editing)) ? ta_focused : nullptr; switch (key) { case BSP_INPUT_NAVIGATION_KEY_UP: navMoveDir(NAV_UP); break; // 2D spatial: element above case BSP_INPUT_NAVIGATION_KEY_DOWN: navMoveDir(NAV_DOWN); break; // …below case BSP_INPUT_NAVIGATION_KEY_LEFT: if (ta) lv_textarea_cursor_left(ta); // field: move caret else if (navOnTabBar()) navSwitchTab(-1); // tab bar: prev screen else navMoveDir(NAV_LEFT); // else: element to the left break; case BSP_INPUT_NAVIGATION_KEY_RIGHT: if (ta) lv_textarea_cursor_right(ta); else if (navOnTabBar()) navSwitchTab(+1); else navMoveDir(NAV_RIGHT); break; default: break; } } static void navPump() { if (!s_nav_queue && (bsp_input_get_queue(&s_nav_queue) != ESP_OK || !s_nav_queue)) return; // Fire-on-hold: trigger the F1/F4 long-press action the MOMENT the threshold passes while the // key is still held down (not on release), and only once per hold. if (s_f1_down_ms && !s_f1_fired && (millis() - s_f1_down_ms) >= 450) { s_f1_fired = true; openPowerMenu(); } if (s_f4_down_ms && !s_f4_fired && (millis() - s_f4_down_ms) >= 450) { s_f4_fired = true; toggleControlCenter(); } #if defined(HAS_TANMATSU) // Vol+ long-press: once the burst has streamed past the threshold, undo the dropdown the tap opened // and flip the sound master switch instead (fires once mid-hold, like F1/F4 above). if (s_volup_start && !s_volup_long_fired && (millis() - s_volup_start) >= VOLUP_LONG_MS) { s_volup_long_fired = true; toggleControlCenter(); // undo the tap's instant dropdown toggle if (g_lv.task) { g_lv.task->toggleBuzzer(); const bool quiet = g_lv.task->isBuzzerQuiet(); if (!quiet) tanBeep(); // confirmation tick when (re)enabling sound g_lv.task->showAlert(quiet ? TR("Sound off") : TR("Sound on"), 900); } } // Vol+ burst ended (quiet gap since the last event) — close out the press. if (s_volup_start && (millis() - s_volup_last_ev) >= 450) s_volup_start = 0; #endif // Auto-repeat a held arrow key: after a short initial delay, re-fire it at a steady rate // (like a PC keyboard) until release — so holding scrolls a list / ramps a slider. if (s_nav_rep_key && (millis() - s_nav_rep_t0) >= 380 && (millis() - s_nav_rep_last) >= 95) { s_nav_rep_last = millis(); navArrowAction(s_nav_rep_key); } bsp_input_event_t ev; int budget = 48; while (budget-- > 0 && xQueueReceive(s_nav_queue, &ev, 0) == pdTRUE) { #if defined(HAS_TANMATSU) // Vol- (bottom side button): SHORT click = screen sleep/wake (like the V4 user button); LONG press // = lock/unlock (fired mid-hold at the top of navPump). Handle it BEFORE the wake-on-input path so // the press timing isn't swallowed and it never leaks to the UI. // The Tanmatsu BSP emits a redundant SCANCODE event (type 4) — and ACTION (3) for the power // button — alongside every real NAVIGATION/KEYBOARD key. The app only consumes navigation + // keyboard; the duplicate scancode was hitting noteUserInput() and re-waking the screen the // instant a Vol- press slept it. Drop everything that isn't navigation/keyboard. // Scancode events are otherwise unused — the ONE we care about is the ALT // key's own release, which commits an open ALT-accent pick (issue #129). if (ev.type == INPUT_EVENT_TYPE_SCANCODE) { const uint32_t sc = (uint32_t)ev.args_scancode.scancode; const uint32_t code = sc & ~(uint32_t)BSP_INPUT_SCANCODE_RELEASE_MODIFIER; const bool released = (sc & BSP_INPUT_SCANCODE_RELEASE_MODIFIER) != 0; if (released && (code == BSP_INPUT_SCANCODE_LEFTALT || code == BSP_INPUT_SCANCODE_ESCAPED_RALT)) tanAltAccentAltReleased(); continue; } if (ev.type != INPUT_EVENT_TYPE_NAVIGATION && ev.type != INPUT_EVENT_TYPE_KEYBOARD) continue; if (ev.type == INPUT_EVENT_TYPE_NAVIGATION && ev.args_navigation.key == BSP_INPUT_NAVIGATION_KEY_VOLUME_DOWN) { // The button emits a noisy burst per press; act ONCE per burst — on a "fresh" event (>=450 ms // since the last) — and absorb the rest. One tap = one action, instant + reliable. The tap: // locked -> unlock; asleep -> wake; awake -> lock (if "Lock when screen off" is on) else sleep. uint32_t nm = millis(); bool fresh = (uint32_t)(nm - s_vol_last_ev) >= 450; s_vol_last_ev = nm; if (fresh && g_lv.task) { if (g_lv.task->isManualLocked()) { g_lv.task->unlockScreen(); } else if (g_lv.task->isScreenOff()) { g_lv.task->wakeScreen(); } else if (touchPrefsGetLockOnScreenOff()) { g_lv.task->lockScreen(); } else { g_lv.task->sleepScreen(); } } continue; } // Vol+ (middle side button): single tap toggles the top-bar dropdown NOW; a long hold converts // that into a sound-master toggle (handled at the top of navPump). Coalesce the noisy burst into // one "tap" on the first fresh event. Screen off -> just wake (Vol- owns wake); locked -> ignore. if (ev.type == INPUT_EVENT_TYPE_NAVIGATION && ev.args_navigation.key == BSP_INPUT_NAVIGATION_KEY_VOLUME_UP) { uint32_t nm = millis(); bool fresh = (uint32_t)(nm - s_volup_last_ev) >= 450; s_volup_last_ev = nm; if (fresh && g_lv.task) { if (g_lv.task->isManualLocked()) { s_volup_start = 0; } // locked: Vol- unlocks else if (g_lv.task->isScreenOff()) { s_volup_start = 0; g_lv.task->wakeScreen(); } // off: wake, no gesture else { s_volup_start = nm; s_volup_long_fired = false; toggleControlCenter(); } // awake: toggle dropdown now } continue; } #endif // Any key counts as activity: reset the idle timer, and if the screen idled // off, wake it and swallow this key (no touch on the Tanmatsu to wake it). if (g_lv.task) { bool was_off = g_lv.task->isScreenOff(); g_lv.task->noteUserInput(); noteKbActivity(); // physical keypress lights the "auto" keyboard backlight if (was_off) continue; } #if defined(HAS_TANMATSU) // Hard screen lock: the overlay absorbs everything else (Vol- was already handled above). if (g_lv.task && g_lv.task->isManualLocked()) continue; #endif // Mention rows are intentionally outside the LVGL focus group. Route the // Tanmatsu's dedicated navigation events into their private selection state // before ordinary field/caret navigation sees them. Printable keyboard input // still falls through and narrows the list. if (mentionNavActive()) { if (ev.type == INPUT_EVENT_TYPE_NAVIGATION) { const bool down = ev.args_navigation.state; switch (ev.args_navigation.key) { case BSP_INPUT_NAVIGATION_KEY_UP: if (down) mentionNavMove(-1); continue; case BSP_INPUT_NAVIGATION_KEY_DOWN: if (down) mentionNavMove(+1); continue; case BSP_INPUT_NAVIGATION_KEY_RETURN: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_A: case BSP_INPUT_NAVIGATION_KEY_JOYSTICK_PRESS: if (down) mentionNavConfirm(); continue; case BSP_INPUT_NAVIGATION_KEY_F1: case BSP_INPUT_NAVIGATION_KEY_ESC: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_B: if (down) mentionBoxHide(); continue; case BSP_INPUT_NAVIGATION_KEY_LEFT: case BSP_INPUT_NAVIGATION_KEY_RIGHT: if (down) mentionBoxHide(); break; // dismiss, then let normal caret navigation handle it default: break; } } else if (ev.type == INPUT_EVENT_TYPE_KEYBOARD) { const char c = ev.args_keyboard.ascii; if (c == '\r' || c == '\n') { mentionNavConfirm(); continue; } if (c == 8 || c == 127) { mentionBoxHide(); continue; } } } // An open dropdown captures input: arrows move the highlight (LV_KEY_UP/DOWN, NOT prev/next), // Enter selects + closes, Esc/✕ closes. navMaybeRebuild() leaves the group alone while it's open. if (navOpenDropdown()) { if (ev.type == INPUT_EVENT_TYPE_NAVIGATION) { const bool down = ev.args_navigation.state; switch (ev.args_navigation.key) { case BSP_INPUT_NAVIGATION_KEY_UP: navFifoPush(LV_KEY_UP, down); break; case BSP_INPUT_NAVIGATION_KEY_DOWN: navFifoPush(LV_KEY_DOWN, down); break; case BSP_INPUT_NAVIGATION_KEY_RETURN: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_A: case BSP_INPUT_NAVIGATION_KEY_JOYSTICK_PRESS: navFifoPush(LV_KEY_ENTER, down); break; case BSP_INPUT_NAVIGATION_KEY_F1: case BSP_INPUT_NAVIGATION_KEY_ESC: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_B: navFifoPush(LV_KEY_ESC, down); break; default: break; } } else if (ev.type == INPUT_EVENT_TYPE_KEYBOARD) { const char dc = ev.args_keyboard.ascii; const int da = navDirForKey(dc); // programmable: up/down move the highlight; select confirms; back cancels if (dc == '\r' || dc == '\n' || dc == ' ' || da == 4) navPushTap(LV_KEY_ENTER); else if (dc == 8 || dc == 127 || da == 5) navPushTap(LV_KEY_ESC); else if (da == 0) navPushTap(LV_KEY_UP); else if (da == 1) navPushTap(LV_KEY_DOWN); } continue; } lv_obj_t* ta_focused = navFocusedTextarea(); // a text field is focused lv_obj_t* ta = (ta_focused && (!s_kbd_nav || s_nav_ta_editing)) ? ta_focused : nullptr; // editing target (kbd-nav off = type directly) if (ev.type == INPUT_EVENT_TYPE_NAVIGATION) { const bool down = ev.args_navigation.state; const uint32_t mod = ev.args_navigation.modifiers; if (s_nav_debug && down) printf("[NAV] navkey=%d mod=%lu ta=%d\n", (int)ev.args_navigation.key, (unsigned long)mod, ta ? 1 : 0); switch (ev.args_navigation.key) { case BSP_INPUT_NAVIGATION_KEY_UP: case BSP_INPUT_NAVIGATION_KEY_DOWN: case BSP_INPUT_NAVIGATION_KEY_LEFT: case BSP_INPUT_NAVIGATION_KEY_RIGHT: // Ctrl+Arrow is an EXPLICIT gesture (not 2D focus nav): // • On the Map tab → pan the map one step in that direction. // • Off the map → Ctrl+Up/Down page-scroll the focused list/page // (Ctrl+Left/Right fall through to normal nav). // No auto-repeat for these (no s_nav_rep_key) — one press = one step/page. if (down && (mod & BSP_INPUT_MODIFIER_CTRL)) { if (getActiveTab() == MAP_TAB_INDEX) { switch (ev.args_navigation.key) { case BSP_INPUT_NAVIGATION_KEY_UP: mapNudge(0); break; // north case BSP_INPUT_NAVIGATION_KEY_DOWN: mapNudge(1); break; // south case BSP_INPUT_NAVIGATION_KEY_LEFT: mapNudge(2); break; // west case BSP_INPUT_NAVIGATION_KEY_RIGHT: mapNudge(3); break; // east default: break; } break; // map pan handled — never moves focus / sets repeat } if (ev.args_navigation.key == BSP_INPUT_NAVIGATION_KEY_UP) { navScrollFocused(true); break; } if (ev.args_navigation.key == BSP_INPUT_NAVIGATION_KEY_DOWN) { navScrollFocused(false); break; } // Ctrl+Left/Right off the map: fall through to plain 2D nav below. } // Caret / tab-switch / 2D-spatial focus — all in navArrowAction so a held key // auto-repeats (the repeat tick at the top of navPump re-fires the held key). if (down) { s_nav_rep_key = ev.args_navigation.key; s_nav_rep_t0 = s_nav_rep_last = millis(); navArrowAction(ev.args_navigation.key); } else if (s_nav_rep_key == ev.args_navigation.key) { s_nav_rep_key = 0; // released → stop repeating } break; case BSP_INPUT_NAVIGATION_KEY_TAB: navFifoPush((mod & BSP_INPUT_MODIFIER_SHIFT) ? LV_KEY_PREV : LV_KEY_NEXT, down); break; case BSP_INPUT_NAVIGATION_KEY_RETURN: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_A: case BSP_INPUT_NAVIGATION_KEY_JOYSTICK_PRESS: if (navOnTabBar()) { if (down) navSwitchTab(+1); break; } // Enter on the tab bar = next screen if (ta_focused && !ta) { if (down) s_nav_ta_editing = true; break; } // 1st Enter on a focused field = start typing // Enter on an EMPTY composer drops back to navigate mode (cursor off); a non-empty composer falls through. if (down && ta && (ta == g_lv.ch.composer_ta || ta == g_lv.dm.composer_ta) && !lv_textarea_get_text(ta)[0]) { s_nav_ta_editing = false; break; } if (s_nav_debug && down) printf("[NAV] ENTER focus=%p unread=%p first=%p last=%p tabbar=%p\n", (void*)lv_group_get_focused(s_nav_group), (void*)g_lv.home_unread, (void*)s_nav_first, (void*)s_nav_last, (void*)s_nav_tabbar); if (!ta && down) navMarkEntered(lv_group_get_focused(s_nav_group)); if (!ta && down && navEnterBubble()) break; // Enter on a chat bubble = the long-press menu navFifoPush(ta ? LV_KEY_NEXT : LV_KEY_ENTER, down); break; // on a field, Enter advances case BSP_INPUT_NAVIGATION_KEY_F1: // red ✕ — tap = close topmost; HOLD = power menu (opens mid-hold, see navPump top) if (down) { if (!s_f1_down_ms) { s_f1_down_ms = millis(); s_f1_fired = false; } break; } { const bool fired = s_f1_fired; s_f1_down_ms = 0; s_f1_fired = false; if (fired) break; // power menu already opened while held if (s_nav_ta_editing) { s_nav_ta_editing = false; break; } // editing a field: ✕ stops typing first (cursor off → navigate) if (anyPopupOpen()) { hwKeyDismissTopPopup(); break; } // a popup/sheet ON TOP (emoji, quick replies, …) closes FIRST… LvChatPanel* cp = navOpenChatPanel(); if (cp) { closeChatPanel(cp); break; } // …then an open chat/channel… navPushTap(LV_KEY_ESC); } // …or plain Esc break; case BSP_INPUT_NAVIGATION_KEY_ESC: case BSP_INPUT_NAVIGATION_KEY_GAMEPAD_B: if (down && s_nav_ta_editing) { s_nav_ta_editing = false; break; } // editing a field: Esc stops typing first (cursor off → navigate) if (anyPopupOpen()) { if (down) hwKeyDismissTopPopup(); break; } // a popup/sheet ON TOP (emoji, quick replies, …) closes FIRST — must precede the chat-close below if (down) { LvChatPanel* cp = navOpenChatPanel(); if (cp) { closeChatPanel(cp); break; } } // then an open chat/channel navFifoPush(LV_KEY_ESC, down); break; // Coloured shape keys (left→right F2..F6 = △ □ ○ ♣ ◇). On a MAIN page they jump // to that tab; INSIDE a chat/channel they become the in-conversation controls // that match the on-screen coloured-shape chips (see makeChatDetail). case BSP_INPUT_NAVIGATION_KEY_F2: // orange △ — chat: quick replies; else Messages if (down) { LvChatPanel* cp = navOpenChatPanel(); if (cp) openQuickReplyPicker(cp); else navGoToMainTab(CHAT_INBOX_TAB_INDEX); } break; case BSP_INPUT_NAVIGATION_KEY_F3: // yellow □ — chat: emoji picker; else Contacts if (down) { LvChatPanel* cp = navOpenChatPanel(); if (cp) openEmojiPickerForComposer(cp->composer_ta); else navGoToMainTab(CONTACTS_TAB_INDEX); } break; case BSP_INPUT_NAVIGATION_KEY_F4: { // green ○ — channel chat: settings; else Home/control center LvChatPanel* cp = navOpenChatPanel(); if (cp && cp->channel_mode) { if (down) openActiveChatSettings(); break; } if (down) { if (!s_f4_down_ms) { s_f4_down_ms = millis(); s_f4_fired = false; } } else { const bool fired = s_f4_fired; s_f4_down_ms = 0; s_f4_fired = false; if (!fired) homeKeyActivate(); // tap: Home / toggle app drawer (control center already opened mid-hold if held) } break; } case BSP_INPUT_NAVIGATION_KEY_F5: if (down) navGoToMainTab(MAP_TAB_INDEX); break; // blue ♣ = Map case BSP_INPUT_NAVIGATION_KEY_F6: // purple ◇ — chat: jump to latest; else Settings if (down) { LvChatPanel* cp = navOpenChatPanel(); if (cp) { if (cp->msgs) lv_obj_scroll_to_y(cp->msgs, LV_COORD_MAX, LV_ANIM_ON); } else navGoToMainTab(SETTINGS_TAB_INDEX); } break; default: break; } } else if (ev.type == INPUT_EVENT_TYPE_KEYBOARD) { char c = ev.args_keyboard.ascii; if (s_nav_debug) printf("[NAV] kbd ascii=%d '%c' ta=%d\n", (int)(uint8_t)c, (c >= 32 && c < 127) ? c : '?', ta ? 1 : 0); if (s_navkey_capture >= 0) { navKeyCaptureApply((uint8_t)c); continue; } // Settings remap: this key is the new binding // ALT + letter over a text field = accent picker (issue #129): opens the // accent popup, the letter cycles the highlight while ALT stays down, // releasing ALT (scancode handler above) inserts the highlighted variant. if ((ev.args_keyboard.modifiers & BSP_INPUT_MODIFIER_ALT) && (uint8_t)c >= 32 && (ta || ta_focused || s_altacc_active)) { if (!ta && ta_focused) { s_nav_ta_editing = true; ta = ta_focused; } // start editing, like plain typing does if (tanAltAccentHandleKey(c, ta ? ta : s_altacc_ta)) continue; // fall back to the pick's own field } if (ta) { // type straight into the focused field if (c == 8 || c == 127) { // Backspace in an EMPTY field = leave edit mode (matches Enter-on-empty below; // consistent with backspace-as-back everywhere else on this board). if (!lv_textarea_get_text(ta)[0]) s_nav_ta_editing = false; else lv_textarea_del_char(ta); } else if (c == '\r' || c == '\n') { // Enter on an EMPTY composer drops back to navigate mode (cursor off) so the letter-nav // keys work again; anywhere else it advances to the next widget. if ((ta == g_lv.ch.composer_ta || ta == g_lv.dm.composer_ta) && !lv_textarea_get_text(ta)[0]) s_nav_ta_editing = false; else navPushTap(LV_KEY_NEXT); } else if ((uint8_t)c >= 32) { // Double-tap SPACE within 250 ms switches to the configured secondary // keyboard language — the same shortcut the T-Deck's physical keyboard // has in handleHwKey, which Tanmatsu typing bypasses (it types here). static unsigned long s_tan_space_ms = 0; bool cycled = false; if (c == ' ') { unsigned long snow = millis(); if ((snow - s_tan_space_ms) < 250 && keyboardLayoutsAnySecondary()) { lv_textarea_del_char(ta); // remove the first space KeyboardLayoutId next = keyboardLayoutsCycle(g_lv.keyboard); touchPrefsSetKeyboardLayout(static_cast<uint8_t>(next)); if (g_lv.task) g_lv.task->showAlert(keyboardLayoutName(next), 800); cycled = true; } s_tan_space_ms = snow; } if (!cycled) lv_textarea_add_char(ta, (uint32_t)(uint8_t)c); } #if defined(HAS_TANMATSU) } else if (ta_focused && (uint8_t)c >= 32) { // Tanmatsu: a printable keypress on a focused (not-yet-editing) field starts editing AND types // that character — no Enter first. The dedicated arrow keys still navigate between fields until // you type, and ←/→ then move the caret (navArrowAction) once editing. s_nav_ta_editing = true; lv_textarea_add_char(ta_focused, (uint32_t)(uint8_t)c); #endif } else if (c == '\r' || c == '\n' || c == ' ') { // Enter / space = select — ALWAYS works (independent of the letter binds) if (ta_focused) s_nav_ta_editing = true; // on a focused field, select starts typing (letters navigate until then) else if (navOnTabBar()) navSwitchTab(+1); else { navMarkEntered(lv_group_get_focused(s_nav_group)); if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); } } else if (c == 8 || c == 127) { // Backspace = back — ALWAYS works if (anyPopupOpen()) hwKeyDismissTopPopup(); else navPushTap(LV_KEY_ESC); } else switch (navDirForKey(c)) { // programmable letter nav — Tanmatsu defaults W up/A left/X down/D right/S select, F/V scroll case 0: navMoveDir(NAV_UP); break; // up — 2D spatial case 1: navMoveDir(NAV_DOWN); break; // down case 2: if (navOnTabBar()) navSwitchTab(-1); else navMoveDir(NAV_LEFT); break; // left → element to the left case 3: if (navOnTabBar()) navSwitchTab(+1); else navMoveDir(NAV_RIGHT); break; // right → element to the right case 4: if (navOnTabBar()) navSwitchTab(+1); // select else if (ta_focused) s_nav_ta_editing = true; // focused field: start typing else { navMarkEntered(lv_group_get_focused(s_nav_group)); if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); } break; case 5: if (anyPopupOpen()) hwKeyDismissTopPopup(); else navPushTap(LV_KEY_ESC); break; // back (unbound by default on Tanmatsu) case 6: navScrollFocused(true); break; // scroll up case 7: navScrollFocused(false); break; // scroll down default: break; // not a nav key — ignore } } } } #endif // HAS_TANMATSU (navPump) static void tanmatsuKeypadRead(lv_indev_drv_t* drv, lv_indev_data_t* data) { (void)drv; NavKeyTr tr; if (navFifoPop(&tr)) { data->key = tr.key; data->state = tr.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; s_nav_last_key = tr.key; } else { data->key = s_nav_last_key; data->state = LV_INDEV_STATE_RELEASED; } data->continue_reading = (s_nav_fifo_tail != s_nav_fifo_head); } // Collect focus targets into the nav group. In LVGL 8 even base containers are // CLICKABLE by default, so naively adding every clickable object would make focus // step through scaffolding. Instead add only LEAF clickables — a clickable object // with no clickable descendant (buttons, list rows, switches, dropdowns…) — which // are the real tap targets. Returns true if the subtree contains any clickable. static bool navCollect(lv_obj_t* obj) { if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return false; // skip hidden subtrees // NAV_SKIP takes a whole passive subtree out of nav — skip it AND its children, not just the // node. Without this, navCollect recursed past a NAV_SKIP container (the status-bar root, the // accent box, …) and still collected its clickable children, so popups highlighted the // topbar's buttons and the bar's status glyphs leaked into the focus ring. if (lv_obj_has_flag(obj, NAV_SKIP_FLAG)) return false; bool descHasClickable = false; uint32_t n = lv_obj_get_child_cnt(obj); for (uint32_t i = 0; i < n; i++) { lv_obj_t* c = lv_obj_get_child(obj, i); // A horizontal-only secondary target (e.g. a chat row's gear) is collected on its own but must // NOT count as its parent's clickable descendant — else the leaf rule harvests the gear and // drops the row, making the row un-focusable (you could only open settings, never the chat). if (c && navCollect(c) && !lv_obj_has_flag(c, NAV_HMOVE_FLAG)) descHasClickable = true; } bool meClickable = lv_obj_has_flag(obj, LV_OBJ_FLAG_CLICKABLE) && !lv_obj_has_flag(obj, NAV_SKIP_FLAG) // skip explicit non-nav targets (e.g. the map catcher) && !lv_obj_has_flag(obj, NAV_PASSTHRU_FLAG); // container that only exists to catch touches (Lua app body) if (meClickable && !descHasClickable) { // LVGL's btn/switch/checkbox widgets ship with LV_OBJ_FLAG_SCROLL_ON_FOCUS, // and lv_obj's own FOCUSED handler scrolls them into view — bypassing our // s_nav_suppress_scroll entirely. That's what yanked a fresh page down on // open when its first focusable sat below the fold (Tanmatsu About page). // navFocusCb already does the controlled scroll-into-view for live keyboard // navigation, so strip the built-in duplicate from everything we manage. lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLL_ON_FOCUS); lv_group_add_obj(s_nav_group, obj); if (!s_nav_first) s_nav_first = obj; s_nav_last = obj; if (s_nav_count < kNavMax) s_nav_objs[s_nav_count] = obj; // for 2D spatial nav s_nav_count++; } return meClickable || descHasClickable; } static bool s_nav_dirty = true; static lv_obj_t* s_nav_prev_scr = nullptr; static uint32_t s_nav_prev_sig = 0xFFFFFFFFu; static void navMarkDirty() { s_nav_dirty = true; } // A virtualized chat rebuild destroys the currently-materialized row objects. // Detach the navigation group while every object (and every descendant label // remembered by navInvertText) is still alive. Letting lv_obj_del() remove the // focused row itself is unsafe: LVGL has already torn down that row's styles and // animations when lv_group_remove_obj() auto-focuses a survivor and calls // navFocusCb(), which then tries to restore styles on the half-destructed row. // The caller must rebuild the group after recreating the object tree. static bool navDetachBeforeTreeMutation() { if (!s_nav_group) return false; if (s_nav_styled) { navUnstyle(s_nav_styled); s_nav_styled = nullptr; } else { navRestoreText(); } lv_group_remove_all_objs(s_nav_group); s_nav_first = s_nav_last = nullptr; s_nav_count = 0; for (int i = 0; i < kNavMax; ++i) s_nav_objs[i] = nullptr; navMarkDirty(); return true; } // Repopulate the focus group when the visible screen actually changes (screen // swap, modal open/close, or a within-screen content rebuild). A 2-level // child-count signature avoids reshuffling focus during steady-state navigation. // A cheap fingerprint of the visible widget tree: mixes in each non-hidden object's // address + structure, so it changes whenever the screen's content changes — including // wizard steps that swap content without changing the top-level child count (a plain // child-count signature missed those, leaving the focus group empty after a step swap). static uint32_t navTreeSig(lv_obj_t* obj, int depth) { // Skip NAV_SKIP_FLAG overlays (accent box, @-mention picker): they're not nav targets, so // their appearing/disappearing must NOT change the signature and trigger a rebuild — that // rebuild's remove-all + re-focus churn would reset edit mode and break typing mid-word. if (!obj || depth > 7 || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) || lv_obj_has_flag(obj, NAV_SKIP_FLAG)) return 0; uint32_t h = (uint32_t)(uintptr_t)obj * 2654435761u + (uint32_t)depth + 1u; uint32_t n = lv_obj_get_child_cnt(obj); for (uint32_t i = 0; i < n; i++) { lv_obj_t* c = lv_obj_get_child(obj, i); // Skip the child ENTIRELY (don't even count it) when it's hidden or a passive NAV_SKIP // overlay — returning 0 from the recursion wasn't enough, because merely iterating over an // added/removed child still moves `h`. The accent box + @-mention picker are created and // deleted on every keystroke, so counting them flips the signature → rebuild → edit-mode // reset → typing breaks mid-word (and Esc then reads as "navigate back", exiting the screen). if (!c || lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) || lv_obj_has_flag(c, NAV_SKIP_FLAG)) continue; h = h * 2654435761u + navTreeSig(c, depth + 1); } return h; } // The top layer permanently holds HIDDEN overlays (on-screen keyboard, keyboard mirror) on every // screen, so "has any child" is true even on the plain home screen — which made us collect the top // layer (0 visible targets) instead of the active screen's UI. Only treat the top layer as the focus // container when it actually SHOWS something (a modal / the app drawer). static bool navTopHasVisibleChild(lv_obj_t* top) { uint32_t n = lv_obj_get_child_cnt(top); for (uint32_t i = 0; i < n; i++) { lv_obj_t* c = lv_obj_get_child(top, i); // NAV_SKIP_FLAG overlays (e.g. the tap-to-pick accent box) are passive hints, not // focus targets — they must NOT make nav re-root onto the top layer and steal focus // off the field you're typing in. if (c && !lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_flag(c, NAV_SKIP_FLAG)) return true; } return false; } // True only at the top level — no modal/popup on lv_layer_top(), no settings detail // sheet, no open chat/channel. Scopes the pager's Alt+encoder gesture (updatePagerEncoder): // tab-switching only makes sense here; inside a nested scrollable screen the same // gesture instead free-scrolls the page. static bool navOnMainPage() { if (navTopHasVisibleChild(lv_layer_top())) return false; if (s_settings_sheet && lv_obj_is_valid(s_settings_sheet)) return false; if (navOpenChatPanel()) return false; return true; } // Some fullscreen views (Terminal, File manager) build their OWN popups (command // picker, prompts, action sheets, …) as separate lv_layer_top() siblings rather // than nesting them inside the view — so with two visible top-layer children at // once, collecting the whole top layer pulled in BOTH the view underneath and the // popup on top of it, forcing nav to walk the entire hidden screen's controls // before ever reaching the popup (reported: opening the terminal's command picker // left focus on the terminal behind it). LVGL has no z-index — child order IS // stacking order — so the last visible, non-skip child is the one actually on top; // collect ONLY that subtree. Single-popup screens are unaffected (same one child). static lv_obj_t* navTopFrontmostChild(lv_obj_t* top) { int32_t n = (int32_t)lv_obj_get_child_cnt(top); for (int32_t i = n - 1; i >= 0; i--) { lv_obj_t* c = lv_obj_get_child(top, (uint32_t)i); if (c && !lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_flag(c, NAV_SKIP_FLAG)) return c; } return top; // shouldn't happen (caller only calls this when navTopHasVisibleChild(top) is true) } static LvChatPanel* s_nav_prev_chat = nullptr; // last chat panel we focused (so we only auto-focus on open) // The global status bar has NAV_SKIP_FLAG so its passive glyphs (time / signal / battery) // never become keyboard/trackball-nav targets — but that also skipped its ACTION buttons. // Re-add the VISIBLE, clickable ones by hand after a rebuild so nav can reach them: the // chat-overview [ + ✓ QR ] and (in an open channel) the settings gear. (Trackball-nav // couldn't reach the + / ✓ / QR on the Chats tab.) static void navAddStatusBarActions() { if (!s_nav_group) return; auto add = [](lv_obj_t* o) { if (!o || !lv_obj_is_valid(o) || lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) return; if (!lv_obj_has_flag(o, LV_OBJ_FLAG_CLICKABLE)) return; lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLL_ON_FOCUS); // same as navCollect: our navFocusCb owns focus-scroll lv_group_add_obj(s_nav_group, o); if (!s_nav_first) s_nav_first = o; s_nav_last = o; if (s_nav_count < kNavMax) s_nav_objs[s_nav_count] = o; s_nav_count++; }; add(g_statusbar.inbox_add); add(g_statusbar.inbox_mark); add(g_statusbar.inbox_qr); add(g_statusbar.chan_gear); // On a settings detail / tool page (Monitor, Spectrum) the bar's left_label is the // "‹ Title" Back affordance — make it reachable so nav can go Back from a double-topbar page. if (s_settings_open_cat >= 0 || s_apppage_title) add(g_statusbar.left_label); } static void navMaybeRebuild() { if (!s_nav_group) return; navSyncCursor(); // keep the text cursor showing only in edit mode (reliable "ready to type" cue) if (navOpenDropdown()) return; // an open dropdown owns the group — rebuilding would close it // Issue #45: when an overlay (settings sheet, modal) or a chat closes, the element that had focus // lived inside it and is now gone, so focus would fall to the first list item and the underlying // list would snap to the top. Stash the page focus on the way out, restore it on the way back. static lv_obj_t* s_nav_page_focus = nullptr; static bool s_nav_prev_on_page = true; static bool s_nav_prev_useTop = false; // track the container kind so reselect only fires on a same-screen rebuild lv_obj_t* scr = lv_scr_act(); lv_obj_t* top = lv_layer_top(); bool useTop = navTopHasVisibleChild(top); // The Wi-Fi join / details / hidden sheet floats on the BASE layer ABOVE the settings category // page (itself above the tabview) — same base-layer trick as s_settings_sheet. It's created LAST, // so when it's open it's the frontmost thing and must own the focus group; otherwise nav collects // the settings page (or tab content) behind it and its SSID/password fields + Join button can't be // selected at all on a no-touch board (Tanmatsu). Takes priority over the settings sheet below it. bool on_wifi_sheet = (!useTop && s_wifi_sheet != nullptr && lv_obj_is_valid(s_wifi_sheet)); // A settings detail sheet (openSettingsCategory) floats on the BASE layer ABOVE the tabview — // it's not a top-layer child nor a chat, so without this the tabview branch below would collect // the tab content BEHIND it and trackball nav couldn't reach any setting. Collect ITS controls. bool on_settings = (!useTop && !on_wifi_sheet && s_settings_sheet != nullptr && lv_obj_is_valid(s_settings_sheet)); // An open chat/channel detail overlay (above the tabview) takes focus priority over the tab content: // collect ITS controls + NO tab bar, so arrows stay inside the chat (don't reach the bar and switch // screens) and the composer can be focused for typing. LvChatPanel* chat = (useTop || on_wifi_sheet || on_settings) ? nullptr : navOpenChatPanel(); lv_obj_t* root = useTop ? navTopFrontmostChild(top) : on_wifi_sheet ? s_wifi_sheet : on_settings ? s_settings_sheet : (chat ? chat->overlay : scr); const bool on_page = !useTop && !on_wifi_sheet && !on_settings && !chat; // #45: true = main tab content (the list that scrolls) lv_obj_t* tabbar = nullptr; if (!useTop && !on_wifi_sheet && !on_settings && !chat && g_lv.tabview) { // Main app: collect ONLY the active tab's content (inactive tabs are scrolled off-screen, not // hidden, so the whole screen would focus off-screen widgets). The tab bar is added separately // as the LAST focus stop so you navigate content first, then reach the bar to switch screens. lv_obj_t* tvc = lv_tabview_get_content(g_lv.tabview); uint32_t at = (uint32_t)getActiveTab(); if (tvc && at < lv_obj_get_child_cnt(tvc)) root = lv_obj_get_child(tvc, at); tabbar = lv_tabview_get_tab_btns(g_lv.tabview); } uint32_t sig = navTreeSig(root, 0) ^ (useTop ? 0xA5A5A5A5u : on_wifi_sheet ? 0x9F1CB0EDu : on_settings ? (0x5E771465u ^ ((uint32_t)(s_settings_open_cat + 1) * 2654435761u)) : chat ? 0xC4A7C4A7u : ((uint32_t)getActiveTab() + 1) * 2654435761u); // navTreeSig only walks the page `root`; the top-bar action buttons live in the // NAV_SKIP'd status bar (added separately by navAddStatusBarActions). Fold their // visibility into the sig so a rebuild fires when they appear/disappear — otherwise // they could stay unreachable until the next unrelated content change. if (g_statusbar.inbox_add && !lv_obj_has_flag(g_statusbar.inbox_add, LV_OBJ_FLAG_HIDDEN)) sig ^= 0x5BD1E995u; if (g_statusbar.chan_gear && !lv_obj_has_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_HIDDEN)) sig ^= 0x1B873593u; if (!s_nav_dirty && scr == s_nav_prev_scr && sig == s_nav_prev_sig) return; s_nav_dirty = false; s_nav_prev_scr = scr; s_nav_prev_sig = sig; lv_obj_t* keep = lv_group_get_focused(s_nav_group); // preserve focus across a same-page rebuild (toggle/click) if (!on_page && s_nav_prev_on_page) s_nav_page_focus = keep; // #45: leaving the page → remember where we were s_nav_suppress_scroll = true; // don't let the transient first-object focus during recollect scroll the page/chat lv_group_remove_all_objs(s_nav_group); s_nav_first = s_nav_last = nullptr; s_nav_count = 0; if (root) navCollect(root); // content items (focus starts here) // Only expose the top-bar action buttons when NOT inside a modal/popup (useTop). A modal on the top // layer must trap keyboard nav to its own controls — otherwise up/down/right would jump out to the // status bar (+ ✓ QR / gear) sitting "under" the popup. Pages + chat keep their top-bar actions. if (!useTop) navAddStatusBarActions(); // The menu/tab bar is intentionally NOT a focus stop: the directional keys (WASDZ // / A-D) navigate screen CONTENT only. Tabs are switched by the dedicated hotkeys // — the coloured F-keys on the Tanmatsu, the programmable E/R/T/U/I on the T-Deck — // never by the directional keys. (navGoToMainTab handles those, independent of the // focus group.) (void)tabbar; s_nav_tabbar = nullptr; s_nav_want_tabbar = false; // When a chat just opened, drop focus on its composer so typing goes straight in (issue: textfield // not auto-selected). Only on the open transition, so new messages don't steal focus mid-typing. bool focus_set = false; // true once we deliberately focus a COLLECTED obj (else LVGL defaults to the first item) if (s_nav_focus_hint && lv_obj_is_valid(s_nav_focus_hint)) { // One-shot explicit focus hint — e.g. turning keyboard-nav ON keeps the highlight on the switch you // just toggled instead of snapping the settings list to the top (issue #45). const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; for (int i = 0; i < n; i++) if (s_nav_objs[i] == s_nav_focus_hint) { lv_group_focus_obj(s_nav_focus_hint); focus_set = true; break; } s_nav_focus_hint = nullptr; // consume regardless (one-shot) } else if (chat && chat != s_nav_prev_chat && chat->composer_ta && lv_obj_is_valid(chat->composer_ta)) { lv_group_focus_obj(chat->composer_ta); // chat just opened → focus the composer focus_set = true; } else if (on_page && !s_nav_prev_on_page && s_nav_page_focus && lv_obj_is_valid(s_nav_page_focus)) { // #45: just returned to the page from an overlay/chat — restore the item we left from so the // list stays put instead of snapping to the top. const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; for (int i = 0; i < n; i++) if (s_nav_objs[i] == s_nav_page_focus) { lv_group_focus_obj(s_nav_page_focus); focus_set = true; break; } } else if (keep && lv_obj_is_valid(keep)) { // A toggle/click triggers a rebuild; if the previously-focused element survived, // keep focus on it instead of jumping back to the top of the page. const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; for (int i = 0; i < n; i++) if (s_nav_objs[i] == keep) { lv_group_focus_obj(keep); focus_set = true; break; } } else if (s_nav_resel_pending && useTop == s_nav_prev_useTop && chat == s_nav_prev_chat && on_page == s_nav_prev_on_page) { // The activated element was rebuilt as a FRESH object on the same screen (e.g. a control- // center chip that recreates the whole panel) — re-focus whichever new element sits where // the old one was, so the selection stays on the button you just pressed. long best = 0x7FFFFFFFL; lv_obj_t* bo = nullptr; const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; for (int i = 0; i < n; i++) { lv_obj_t* o = s_nav_objs[i]; if (!o) continue; lv_area_t b; lv_obj_get_coords(o, &b); long dx = (long)((b.x1 + b.x2) / 2) - s_nav_resel_x, dy = (long)((b.y1 + b.y2) / 2) - s_nav_resel_y; long d = dx * dx + dy * dy; if (d < best) { best = d; bo = o; } } if (bo && best <= 40 * 40) { s_nav_show = true; lv_group_focus_obj(bo); focus_set = true; } // only a genuine overlap (recreated) } // Home tab, FRESH focus (none of the keep/restore/reselect branches actually set focus): default // to the "Apps" button instead of letting LVGL fall to the first collected item (the unread line). // Guarded so returning from a sub-page (which restores the prior selection above) is NOT overridden. if (!focus_set && on_page && getActiveTab() == HOME_TAB_INDEX && g_lv.home_apps && lv_obj_is_valid(g_lv.home_apps)) { const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; for (int i = 0; i < n; i++) if (s_nav_objs[i] == g_lv.home_apps) { lv_group_focus_obj(g_lv.home_apps); break; } } // Chat list only: it carries horizontal-only gears. If no branch above set focus, land on the // first PRIMARY row, not a gear (navCollect collects a row's gear before the row itself). Guarded // to pages that actually have HMOVE targets so home/settings default-focus behaviour is untouched. if (!focus_set) { const int n2 = s_nav_count < kNavMax ? s_nav_count : kNavMax; bool any_hmove = false; for (int i = 0; i < n2; i++) { lv_obj_t* o = s_nav_objs[i]; if (o && lv_obj_has_flag(o, NAV_HMOVE_FLAG)) { any_hmove = true; break; } } if (any_hmove) { for (int i = 0; i < n2; i++) { lv_obj_t* o = s_nav_objs[i]; if (o && lv_obj_is_valid(o) && !lv_obj_has_flag(o, NAV_HMOVE_FLAG)) { lv_group_focus_obj(o); break; } } } } s_nav_suppress_scroll = false; // real focus is set — re-enable focus-scroll for live arrow navigation s_nav_resel_pending = false; // consumed this rebuild s_nav_prev_chat = chat; s_nav_prev_on_page = on_page; // #45 s_nav_prev_useTop = useTop; // for the same-screen reselect guard above if (s_nav_debug) printf("[NAV] rebuilt count=%d %s tab=%d\n", s_nav_count, useTop ? "top" : chat ? "chat" : "tab", getActiveTab()); } // Re-skin the bottom tab bar so each tab is its physical F-key's coloured OUTLINE shape with the // page icon inside, same colour: △ Messages · □ Contacts · ○ Home · ♣ Map · ◇ Settings. static lv_obj_t* s_tabhint_cv[5] = { nullptr }; static lv_obj_t* s_tabhint_ic[5] = { nullptr }; // All tabs stay full-bright; the active page is shown only by its cell backing (set below). Kept as // a hook in case we want a per-tab active treatment later. static void navUpdateTabHilite(int) {} static void navBuildTabKeyHints() { static bool built = false; if (built || !g_lv.tabview) return; lv_obj_t* bar = lv_tabview_get_tab_btns(g_lv.tabview); if (!bar) return; built = true; lv_obj_set_style_text_opa(bar, LV_OPA_TRANSP, LV_PART_ITEMS); // hide the default symbols lv_obj_set_style_pad_all(bar, 0, LV_PART_ITEMS); // The active page is shown by a clearly-lit rounded "pill" behind its cell (no dimming of the // others) — a lighter fill than the dark bar so the current tab stands out on its own. lv_obj_set_style_bg_color(bar, lv_color_hex(0x4A555F), LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_radius(bar, 8, LV_PART_ITEMS | LV_STATE_CHECKED); const int SZ = 34; const lv_coord_t cell = lv_disp_get_hor_res(nullptr) / 5; const uint32_t cols[5] = { 0xF5A623, 0xFFD400, 0x2ECC40, 0x2E9BFF, 0xC724B1 }; const char* icons[5] = { LV_SYMBOL_ENVELOPE, TOUCH_SYM_PERSON, LV_SYMBOL_HOME, LV_SYMBOL_GPS, LV_SYMBOL_SETTINGS }; for (int i = 0; i < 5; i++) { lv_color_t col = lv_color_hex(cols[i]); lv_obj_t* cv = lv_canvas_create(bar); lv_obj_clear_flag(cv, LV_OBJ_FLAG_CLICKABLE); uint8_t* buf = (uint8_t*)heap_caps_malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR_ALPHA(34, 34), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) buf = (uint8_t*)malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR_ALPHA(34, 34)); lv_canvas_set_buffer(cv, buf, SZ, SZ, LV_IMG_CF_TRUE_COLOR_ALPHA); lv_canvas_fill_bg(cv, lv_color_black(), LV_OPA_TRANSP); lv_draw_line_dsc_t ld; lv_draw_line_dsc_init(&ld); ld.color = col; ld.width = 2; ld.round_start = ld.round_end = 1; lv_draw_rect_dsc_t rd; lv_draw_rect_dsc_init(&rd); rd.bg_opa = LV_OPA_TRANSP; rd.border_color = col; rd.border_width = 2; rd.border_opa = LV_OPA_COVER; switch (i) { case 0: { lv_point_t p[4] = {{SZ/2,1},{1,SZ-2},{SZ-2,SZ-2},{SZ/2,1}}; lv_canvas_draw_line(cv, p, 4, &ld); } break; // △ case 1: rd.radius = 4; lv_canvas_draw_rect(cv, 1, 1, SZ-2, SZ-2, &rd); break; // □ case 2: rd.radius = LV_RADIUS_CIRCLE; lv_canvas_draw_rect(cv, 1, 1, SZ-2, SZ-2, &rd); break; // ○ case 3: { rd.radius = LV_RADIUS_CIRCLE; const int r = 18; // ♣ three lobes lv_canvas_draw_rect(cv, (SZ-r)/2, 0, r, r, &rd); lv_canvas_draw_rect(cv, 0, SZ-r, r, r, &rd); lv_canvas_draw_rect(cv, SZ-r, SZ-r, r, r, &rd); } break; default: { lv_point_t p[5] = {{SZ/2,1},{SZ-2,SZ/2},{SZ/2,SZ-2},{1,SZ/2},{SZ/2,1}}; lv_canvas_draw_line(cv, p, 5, &ld); } break; // ◇ } lv_obj_align(cv, LV_ALIGN_LEFT_MID, i * cell + (cell - SZ) / 2, 0); s_tabhint_cv[i] = cv; lv_obj_t* ic = lv_label_create(bar); lv_label_set_text(ic, icons[i]); lv_obj_set_style_text_color(ic, col, LV_PART_MAIN); #if CAP_LARGE_SCREEN lv_obj_set_style_text_font(ic, &g_font_tab, LV_PART_MAIN); // fixed 16 px — tab bar never scales #else lv_obj_set_style_text_font(ic, &g_font_16, LV_PART_MAIN); #endif lv_obj_clear_flag(ic, LV_OBJ_FLAG_CLICKABLE); lv_obj_align_to(ic, cv, LV_ALIGN_CENTER, 0, (i == 0) ? 5 : 0); // nudge the icon into the triangle's body s_tabhint_ic[i] = ic; } navUpdateTabHilite(getActiveTab()); } #endif // HAS_TANMATSU // Touch-only boards (Heltec V4) compile out the keypad/trackball focus-group nav // above, so navMarkDirty() has no group to flag dirty. Provide a no-op fallback // so the shared "rebuild the focus group" hints sprinkled through base-scope code // (Wi-Fi list rebuild, etc.) still compile everywhere. #if !CAP_KEYPAD_NAV static inline void navMarkDirty() {} #endif static unsigned long s_slider_touch_ms = 0; // last time a slider (volume, etc.) was dragged static bool s_wake_swallow = false; // swallow the whole touch that wakes the screen (issue #4) static bool s_lock_on_screen_off = false; // idle screen-off also engages the manual lock (cached pref) #if !defined(HAS_TANMATSU) // Web UI mirror: a virtual pointer indev fed by a phone browser's taps over the // WebSocket server (WebMirror). Drives the SAME LVGL UI as the physical touch; // idle -> released so it never fights the real touchscreen. static lv_indev_drv_t s_web_indev_drv; static void webPointerRead(lv_indev_drv_t* drv, lv_indev_data_t* data) { (void)drv; int16_t x = 0, y = 0; bool pr = false; if (g_web_mirror.readPointer(&x, &y, &pr)) { data->point.x = x; data->point.y = y; data->state = pr ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; } else { data->state = LV_INDEV_STATE_RELEASED; } } #endif static void lvglTouchRead(lv_indev_drv_t* indev, lv_indev_data_t* data) { (void)indev; static lv_point_t p = {0, 0}; ++s_live_diag_reads; // When the background polling task is running it is the sole owner of the // chsc6x driver's state machine — calling heltecV4CapTouchCheck() from here // too would race the state machine and double-dispatch tap/swipe events. if (!heltecV4CapTouchIsAsyncPolling()) { (void)heltecV4CapTouchCheck(); } // If the gesture has turned into a swipe, tell LVGL to abandon the press // it started on the originally-touched widget. Without this, swiping // sideways across a settings row both switches tabs (our applySwipeGesture) // and clicks the row (LVGL's default press-release-to-click). // // EXCEPTION: on the Map tab, pan IS the primary gesture. The swipe abort // was cutting off horizontal drags after the 36-px swipe threshold, so // the user could only move the map a tiny amount before LVGL released // the finger. Skip the abort there and keep delivering live touch. if (heltecV4CapTouchIsSwiping()) { const bool on_map = g_lv.tabview && (int)lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX; // Chat detail overlay needs full touch too — its composer / send / // back / bubble interactions are all taps, not swipes. Bypass the // abort there as well, otherwise opening a chat from a map marker // (which leaves the swipe-detector latched briefly) freezes the // detail until the next manual tab change. const bool detail_open = g_lv.dm.detail_open || g_lv.ch.detail_open; // Control center open: its brightness slider needs the full horizontal drag, // so don't abort the press there either. // A slider being dragged (e.g. the volume slider) needs the full horizontal // drag too — exactly like the CC brightness slider — so don't abort its // press. s_slider_touch_ms is stamped by the slider's own press/drag events. const bool on_slider = (s_slider_touch_ms && (millis() - s_slider_touch_ms) < 300); if (!on_map && !detail_open && !s_cc_root && !on_slider) { lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); data->state = LV_INDEV_STATE_RELEASED; data->point = p; return; } } uint16_t x = 0, y = 0; bool raw_press = false; if (heltecV4CapTouchGetLive(&x, &y)) { ++s_live_diag_pressed; raw_press = true; } else if (heltecV4CapTouchPopTap(&x, &y)) { // Fallback: if live state is missed, emit one press edge from finalized tap. ++s_live_diag_tap_edges; raw_press = true; } if (!raw_press) s_wake_swallow = false; // finger lifted -> the next touch acts normally if (raw_press #if CAP_TRACKBALL // Ignore a stray finger on the tab bar while the cursor is up. && !tbFingerTouchOnTabBarBlocked(y) #endif ) { p.x = static_cast<lv_coord_t>(x); p.y = static_cast<lv_coord_t>(y); data->point = p; // A finger press must NOT reach the hidden/locked UI. When merely // idle-dimmed, noteUserInput() wakes it; on a manual lock the touch is // inert (only a trackball *hold* unlocks), whether the lock screen is dark // or lit. Either way the press is absorbed, not delivered — and we latch // s_wake_swallow so the REST of this gesture is swallowed too. A tap spans // several polls, so once the backlight is back on a still-down finger would // otherwise press the UI underneath and trigger the action before you see // it (issue #4). Applies to both boards (V4 + T-Deck). if (g_lv.task && (g_lv.task->isScreenOff() || g_lv.task->isManualLock())) { if (!g_lv.task->isManualLock()) g_lv.task->noteUserInput(); s_wake_swallow = true; data->state = LV_INDEV_STATE_RELEASED; return; } if (s_wake_swallow) { // same finger that just woke the screen — keep swallowing data->state = LV_INDEV_STATE_RELEASED; return; } #if CAP_TRACKBALL navHideFocus(); // a real finger press = pointer input; drop the keyboard-nav focus highlight #endif data->state = LV_INDEV_STATE_PRESSED; if (g_lv.task) g_lv.task->noteUserInput(); return; } #if CAP_TRACKBALL // Trackball centre click acts as a touch at the cursor (lower priority than a // real finger). Holding it = a held press, so taps, long-press and drag all // work; releasing fires the click on whatever is under the cursor. In D-pad mode // (s_tb_nav) the click is a "select" handled in updateTrackball, not a cursor tap. if (s_tb_click_press && !s_tb_nav) { p.x = static_cast<lv_coord_t>(s_tb_cursor_x); p.y = static_cast<lv_coord_t>(s_tb_cursor_y); data->point = p; data->state = LV_INDEV_STATE_PRESSED; if (g_lv.task) g_lv.task->noteUserInput(); return; } #endif data->point = p; data->state = LV_INDEV_STATE_RELEASED; } // ============================================================ // Forward declarations // ============================================================ static void refreshChatDetail(LvChatPanel& p); static void refreshChatDetailAsync(LvChatPanel& p); static void chatVirtReset(LvChatPanel* p); static void chatVirtJumpToOldest(LvChatPanel* p); static void chatVirtJumpToLatest(LvChatPanel* p); static void chatVirtScheduleRender(LvChatPanel* p); static void chatVirtApplyPendingScroll(LvChatPanel* p); static void refreshChatList(LvChatPanel& p); static void applyAccent(uint32_t rgb); // theme accent (Settings -> Theme colour) static void openAccentPicker(); static void openAccentPickerCb(lv_event_t* e); static void openChannelScopeModal(int slot, const char* name); // per-channel region scope static void channelGearCb(lv_event_t* e); static bool chanScopeIsOpen(); // fwd: the status-bar back chevron closes the channel-settings sheet static void chanScopeClose(); // fwd static bool blockedModalIsOpen();// fwd: the bar's Back chevron also closes the blocked-users sheet static void blockedModalClose(); // fwd static void openBlockedUsersModal(); // ignore-list manager (unblock) static void openRegionsModal(); // known-regions manager (#271) static bool overlayBlocksTabSwipe(); // theme/channel-scope pickers swallow tab swipes static bool drawerPopupOpen(); // popups floating over the app drawer (signal/mentions/power/files) static void refreshContactsList(); static void contactsListForceRefresh(); // refresh past the no-change cache (e.g. fav toggle, where the count is unchanged) static void refreshThreadLists(); static void refreshStatusLabels(); static void refreshLiveDiag(unsigned long now); static void refreshSettingsSectionSubtitles(); static void refreshLogModalView(); static void hideKb(); static void accentExit(); // long-press accent picker (issue #22, dead on touch) static void accentBoxHide(); // tap-to-pick accent box (issue #22) static void mentionBoxHide(); // tap-to-pick @-mention contact picker (issue #42) static void txtMenuHide(); // cut/copy/paste/select-all edit menu static void showKb(LvChatPanel* p); static void closeSettingsModal(); static void contactSelectCb(lv_event_t* e); static double contactDistanceKm(double lat1, double lon1, double lat2, double lon2); static void openLogModalCb(lv_event_t* e); static void logModeRxCb(lv_event_t* e); static void logModeRawCb(lv_event_t* e); static void showConfirm(const char* msg, const char* ok_label, void (*on_confirm)()); static void clipboardSet(const char* text, const char* tag); static void copyLabelLongPressCb(lv_event_t* e); static void taClearSelection(lv_obj_t* ta); // clear composer text-selection before an insert static lv_obj_t* createSettingsModal(const char* title, SettingsModalKind kind); static void chatDeleteApply(); static void shareMyContactBtnCb(lv_event_t* e); static void openShareMyContactPopup(); static void refreshMapInfoLabel(); static void renderMapTiles(); static void renderMapMarkers(); static void freeMapTiles(); static void mapNoteStorageChanged(); // tile storage appeared/vanished: adopt, invalidate, re-render // Single entry point used by tabChangedCb. Recenters on self GPS and // rebuilds the tile grid. Defined alongside the map state below. static void onMapTabActivated(); static void clearRouteReplay(); // drop the message-route overlay (defined with the map code) static void applyMapChrome(bool on); // map-tab immersive chrome (transparent bars); defined near makeMapTab static void formatAgeBadge(char* buf, size_t cap, uint32_t age_secs); // defined with the contacts list static void formatDistanceBadge(char* out, size_t out_cap, double self_lat, double self_lon, int32_t c_lat_e6, int32_t c_lon_e6); // defined with the contacts list static void openChannelLongPressActionSheet(int thread_idx, const char* name); static void usernameBubbleColors(const char* name, lv_color_t* bubble_bg, lv_color_t* name_col); // Room-server login recovery (#267). Both live with the admin-login code far below, // but the room thread sheet up here drives them. static void roomReloginBegin(const uint8_t pub[32]); // blank re-login + no-reply watchdog static void openRoomJoinPrompt(const uint8_t pub[32], bool after_relogin_failed); // passworded Join // ============================================================ // Helpers // ============================================================ static const char* onOff(bool v) { return v ? "On" : "Off"; } static void scheduleHeavyRefresh(unsigned long delay_ms) { g_lv.defer_heavy_refresh = true; g_lv.heavy_refresh_at_ms = millis() + delay_ms; } static bool hasChatDetailOpen() { return (g_lv.dm.detail_open && g_lv.dm.overlay) || (g_lv.ch.detail_open && g_lv.ch.overlay); } static void closeChatPanel(LvChatPanel* p); // fwd: HOME button + swipe-back both close an open chat static void resetSettingsModalState() { g_set_modal = {}; g_set_modal.kind = SettingsModalKind::None; } static bool parseFloatField(lv_obj_t* ta, float& out) { if (!ta) return false; const char* txt = lv_textarea_get_text(ta); if (!txt || !txt[0]) return false; // Tolerate a European decimal comma ("50,8466") and any trailing whitespace — // strict strtof stops at the comma and rejected such coordinates as invalid. char buf[40]; size_t j = 0; for (const char* p = txt; *p && j < sizeof(buf) - 1; ++p) buf[j++] = (*p == ',') ? '.' : *p; buf[j] = '\0'; char* endptr = nullptr; float v = strtof(buf, &endptr); if (!endptr || endptr == buf) return false; while (*endptr == ' ' || *endptr == '\t' || *endptr == '\r' || *endptr == '\n') ++endptr; if (*endptr != '\0') return false; out = v; return true; } static bool parseIntField(lv_obj_t* ta, int& out) { if (!ta) return false; const char* txt = lv_textarea_get_text(ta); if (!txt || !txt[0]) return false; char* endptr = nullptr; long v = strtol(txt, &endptr, 10); if (!endptr || endptr == txt || *endptr != '\0') return false; out = static_cast<int>(v); return true; } // ============================================================ // Tab / swipe navigation // ============================================================ static void goToTab(int idx); // fwd-declared; defined after tabChangedCb static int getActiveTab() { return g_lv.tabview ? static_cast<int>(lv_tabview_get_tab_act(g_lv.tabview)) : 0; } static lv_obj_t* getActiveTabPage() { if (!g_lv.tabview) return nullptr; lv_obj_t* content = lv_tabview_get_content(g_lv.tabview); if (!content) return nullptr; return lv_obj_get_child(content, getActiveTab()); } // Settings is a category landing (a scrollable list in portrait, a 2-column grid // in landscape) that opens a focused detail "sheet" per category — replacing the // old nested sub-tabview. Stage 1 maps the categories onto the existing inline // builders; Stage 2 will split the "Device" catch-all further. enum { CAT_PROFILE = 0, // identity / name / advert location / QR / export-import CAT_RADIO, // radio params + experimental CAT_AUTOADD, // auto-add contacts (own page; also linked from the Contacts tab) CAT_WIFI, // Wi-Fi config + saved slots CAT_BLUETOOTH, // BLE enable + pairing code CAT_GPS, // GPS toggle + fix status + serial baud (hardware) CAT_CLOCK, // time zone, UTC offset, sync clock, 12-hour clock CAT_BATTERY, // battery history, calibrate, battery saver CAT_SENSORS, // expansion kit + Show-Sensors-tab toggle (V4-with-kit) CAT_DISPLAY, // screen timeout, UI size, bubbles, theme, orientation CAT_KEYBOARD, // secondary layouts + accent popups CAT_SOUND, // notification sound CAT_QUICKREPLIES, // quick-reply macros CAT_LOCK, // lock-screen wallpaper + text colour (T-Deck only) CAT_GENERAL, // reboot, run setup, storage/SD, misc device prefs (was "System") CAT_BACKUPS, // list/delete .json backups + factory reset CAT_LANGUAGE, // UI language picker CAT_MQTT, // MQTT bridge — broker host/port/credentials CAT_APPPERMS, // what each Lua app is allowed to do, and taking it back CAT_ABOUT, // firmware / update / system info / diagnostics CAT_COUNT }; struct SettingsCatDef { const char* label; const char* icon; }; #if CAP_LUA_APPS // Built-in tile hide bits (app_hide pref). Order is FROZEN — append only. enum { APPHIDE_SNAKE = 1u << 0, APPHIDE_AIRTIME = 1u << 1, APPHIDE_MONITOR = 1u << 2, APPHIDE_SPECTRUM = 1u << 3, APPHIDE_DISCOVER = 1u << 4, APPHIDE_VNC = 1u << 5, APPHIDE_REMOTE = 1u << 6, APPHIDE_READER = 1u << 7, APPHIDE_TERMINAL = 1u << 8, APPHIDE_FILES = 1u << 9, APPHIDE_SIGNAL = 1u << 10, APPHIDE_MENTIONS = 1u << 11, APPHIDE_MQTT = 1u << 12, // Settings -> MQTT bridge (hidden by default: experimental + privacy) }; #endif static const SettingsCatDef kSettingsCats[CAT_COUNT] = { { "Profile", LV_SYMBOL_EDIT }, { "Radio & Mesh", LV_SYMBOL_SHUFFLE }, { "Auto-add", LV_SYMBOL_DOWNLOAD }, { "Wi-Fi", LV_SYMBOL_WIFI }, { "Bluetooth", LV_SYMBOL_BLUETOOTH }, { "GPS", LV_SYMBOL_GPS }, { "Clock & time", LV_SYMBOL_REFRESH }, { "Battery", LV_SYMBOL_CHARGE }, { "Sensors", LV_SYMBOL_EYE_OPEN }, { "Display", LV_SYMBOL_IMAGE }, { "Keyboard", LV_SYMBOL_KEYBOARD }, { "Sound", LV_SYMBOL_AUDIO }, { "Quick replies", LV_SYMBOL_ENVELOPE }, { "Lock screen", LV_SYMBOL_EYE_CLOSE }, { "General", LV_SYMBOL_SETTINGS }, { "Backups", LV_SYMBOL_SAVE }, { "Language", LV_SYMBOL_BARS }, { "MQTT bridge", LV_SYMBOL_UPLOAD }, { "App permissions", LV_SYMBOL_WARNING }, { "About", LV_SYMBOL_LIST }, }; // Which slice of the (formerly monolithic) Device settings a builder emits. One // detail page per section; buildDeviceSettings(sec) emits only that section's // blocks (the skipped blocks don't advance the y-cursor, so each page lays out // from the top). GPS/CLOCK/BATTERY/SENSORS/GENERAL were carved out of the old // "System" page (DSEC_SYSTEM retired). enum { DSEC_DISPLAY = 0, DSEC_KEYBOARD, DSEC_SOUND, DSEC_LOCK, DSEC_GPS, DSEC_CLOCK, DSEC_BATTERY, DSEC_SENSORS, DSEC_GENERAL }; static lv_obj_t* s_settings_landing = nullptr; // the category landing container static lv_obj_t* s_settings_cat_card[CAT_COUNT] = { nullptr }; // one card per category (for live hide/show) // s_settings_sheet / s_settings_open_cat / s_settings_from_cc are declared ABOVE the nav // functions (navMaybeRebuild needs them to collect the settings detail sheet for trackball nav). static void closeSettingsCategory(); // fwd: tab-change + key-dismiss close the sheet static void openSettingsCategory(int cat); // fwd: the Contacts overflow links to CAT_AUTOADD #if CAP_LUA_SDK_EXT static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw); // fwd: defined with the perm helpers #endif // ---- Firmware update check (red badge on the Settings gear + About-tab line) ---- // Compares our embedded release tag against the latest pre-alpha_N published to // ALLFATHER-BV/meshcomod (the same listing the web flasher reads), fetched over // plain HTTP via the meshcomod api-github proxy. FIRMWARE_RELEASE_TAG is set at // release time; an empty/non-release tag disables the check (dev build). #ifndef FIRMWARE_RELEASE_TAG #define FIRMWARE_RELEASE_TAG "" #endif static lv_obj_t* s_update_badge = nullptr; // red "!" over the bottom-bar gear static lv_obj_t* s_chat_unread_badge = nullptr; // red unread-count badge over the bottom-bar Chats icon static lv_obj_t* s_tab_indicator = nullptr; // thin rounded accent glow bar under the active tab static lv_obj_t* s_update_subtab_badge = nullptr;// red dot over the "About" sub-tab button static lv_obj_t* s_update_about_lbl = nullptr; // status line on the About sub-tab static lv_obj_t* s_sysinfo_lbl = nullptr; // System-info LIVE tier (uptime/heap, 1 Hz on the About tab) static lv_obj_t* s_sysinfo_rest_lbl = nullptr; // System-info slow tier (re-set only when its text changes) #if defined(HAS_TDECK_GT911) static lv_obj_t* s_sleep_diag_lbl = nullptr; // Idle-sleep instrumentation label (Lock settings, line 1) static lv_obj_t* s_sleep_diag_lbl2 = nullptr; // Idle-sleep instrumentation label (Lock settings, line 2) #endif static bool s_update_available = false; static bool s_verchk_ran = false; // a check has completed (ok or failed) static volatile bool s_verchk_request = false; // UI -> core-0 worker: please check static volatile bool s_verchk_done = false; // worker -> UI: result ready static bool s_verchk_recheck = false; // force a fresh check now (e.g. update channel changed) // microSD size/free for the About page. usedBytes() scans the FAT (slow, and contends // with the worker's own SD writes) — doing it on the UI thread froze the About sheet and // dropped the Back tap. Off-load it to the core-0 worker; sysInfoText reads these. static volatile bool s_sdinfo_request = false; // UI -> worker: rescan SD usage static volatile bool s_sdinfo_busy = false; // worker is inside the FAT scan // UI -> worker: write the chat-history snapshot to storage. The full-file write // can stall for multiple seconds in SPIFFS garbage collection on SD-less boards // (Heltec V4); on the loop thread that froze the whole UI, incl. touch wake // (the "ui:hist 6140ms" field stall). The loop thread snapshots the ring, the // worker writes the snapshot; shutdown/reboot still write synchronously. static volatile bool s_hist_flush_req = false; // job armed, waiting for the worker static volatile bool s_hist_flush_busy = false; // worker owns the job snapshot + its segment file static volatile bool s_hist_flush_ok = true; // last worker write result (retry on false) // Message-ring write health. A failing ui_msgs write used to be 100% silent — // the user only found out on the next reboot as "messages from the last N // minutes vanished" (the thread list still showed fresh times because the // small threads file kept writing while the big ring write failed). Updated // inside the segment writers from EITHER task (volatile stores only); read by // the About page + the repeated-failure toast in flushHistoryIfDue. static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write // Wall-clock epochs for the same two events, so the readouts can show WHEN a // save happened instead of how long ago. 0 = never, or the system clock wasn't // set yet at the time (a relative age would be meaningless anyway). static volatile uint32_t s_msgs_write_ok_epoch = 0; static volatile uint32_t s_msgs_write_fail_epoch = 0; static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success static volatile uint8_t s_msgs_write_stage = 0; // where the last failure hit: 'o' open, 'h' header, 'b' body, 'r' rename static volatile int s_msgs_write_errno = 0; // errno at that failure (ENFILE/EMFILE = VFS file table full, ENOSPC = card full, EIO = card error) // Human-readable diagnosis of the last chat-store write failure — shown on the // About panel and in the failure alerts, so a tester doesn't need an errno // table. Diagnostic vocabulary stays English on purpose (it's what ends up in // bug reports verbatim). static void chatSaveFailText(char* out, size_t cap) { const char* stage; switch ((char)s_msgs_write_stage) { case 'o': stage = "open"; break; case 'h': stage = "header"; break; case 'b': stage = "write"; break; case 'r': stage = "rename"; break; case 'a': stage = "append"; break; // segmented store: active-segment append case 'c': stage = "compact"; break; // segmented store: one-segment rewrite case 'd': stage = "mkdir"; break; // segmented store: data-dir create case 's': stage = "scan"; break; // segmented store: segment discovery case 'm': stage = "commit"; break; // segmented store: migration commit marker default: stage = "save"; break; } const int e = s_msgs_write_errno; const char* why; switch (e) { case ENFILE: case EMFILE: why = "too many open files"; break; case ENOSPC: why = "card full"; break; case EIO: why = "card I/O error"; break; case ENOENT: why = "folder missing"; break; case EROFS: why = "write-protected"; break; case EACCES: why = "access denied"; break; case 0: why = "no errno"; break; default: why = strerror(e); break; // newlib carries the full table } snprintf(out, cap, "%s failed: %s (e%d)", stage, why, e); } static void fmtClockHM(char* buf, size_t cap, const struct tm* t); // fwd: 12/24h-aware HH:MM // "When did the chat store last save" as a CLOCK TIME rather than an age: // HH:MM (honouring the 12/24h pref), with a "-<N>D" suffix once it is a day or // more old, so a stale save is obvious at a glance instead of having to read a // growing seconds counter. "--:--" when the event never happened, or happened // before the system clock was set (no meaningful timestamp exists). static void chatSaveStamp(char* out, size_t cap, uint32_t epoch) { if (!epoch) { snprintf(out, cap, "--:--"); return; } const time_t t = (time_t)epoch; struct tm tmv; if (!localtime_r(&t, &tmv)) { snprintf(out, cap, "--:--"); return; } char hm[12]; fmtClockHM(hm, sizeof hm, &tmv); const time_t now_t = time(nullptr); const uint32_t days = (now_t > (time_t)epoch) ? (uint32_t)((now_t - (time_t)epoch) / 86400) : 0; if (days) snprintf(out, cap, "%s-%uD", hm, (unsigned)days); else snprintf(out, cap, "%s", hm); } static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size // ---- Segmented store: runtime segment table (loop-task-owned) -------------- // Oldest-first view of the on-disk segment set. Rebuilt by the boot loader, // consulted and updated by the flush scheduler. The hist_flush worker never // touches it — it only executes fully-described jobs and reports ok/fail. struct UiSegInfo { uint32_t first_seq; // filename key + first record's seq uint32_t last_seq; // newest seq physically in the FILE uint32_t bytes; // file size (header + records) — arithmetic, never stat() uint16_t disk_recs; // records physically in the file uint16_t live_recs; // of those, how many are still live in the RAM ring uint8_t compact_dirty; // RAM tombstones/drops within this segment -> rewrite due // The FILE's content is untrusted: it may be missing, partially written (a // failed append), or stale (post-resync). Repair = a full rewrite from the // ring that also ABSORBS the unflushed tail, so no append may target it // until that lands. Set on append failure and by segRetableFromRing. uint8_t rewrite_open; }; static UiSegInfo s_seg[k_ui_seg_max]; static int s_seg_count = 0; static volatile uint32_t s_seg_total_bytes = 0; // writer-maintained; About page reads it static volatile bool s_seg_resync = false; // card swapped/remounted: rewrite everything from RAM // Set by segRetableFromRing: on-disk segment files that are NOT part of the // rebuilt table must be unlinked — but only once the re-land has landed, so a // same-card recovery keeps its durable history readable the whole time // (deleting up front turned a recovery into a multi-minute window where a // power cut lost everything that had been safe on disk). static bool s_seg_stale_purge = false; // False until the boot loader read segments OR the one-time migration landed. // While false the flush scheduler must not append segments (they would shadow // the still-authoritative old-format file on the next boot) — it retries the // migration instead. static bool s_seg_store_ready = false; // Durability watermark: highest seq that has landed in a segment file. Ring // records with seq above it are the pending-append backlog. Loop-task-owned; // advanced only when a write is COMMITTED (worker job observed ok, or a sync // write returned true). static uint32_t s_seg_flushed_seq = 0; // One flush job in flight at a time (the existing busy/req protocol). Built // and committed on the loop task; the hist_flush worker (or the sync path) // only executes it. The snapshot buffer holds at most one segment's records // (~80 KB PSRAM) — it replaces the old whole-ring snapshot (1.3 MB). enum : uint8_t { SEGJOB_NONE = 0, SEGJOB_APPEND, SEGJOB_COMPACT }; struct SegJob { uint8_t kind = SEGJOB_NONE; uint32_t first_seq = 0; // target segment (filename key) uint32_t last_seq = 0; // newest seq covered by the job bool create = false; // APPEND: file doesn't exist yet (write header) bool repair = false; // COMPACT of an untrusted file: also absorbs the unflushed tail int n = 0; }; // The WORKER's armed job. Written only while !(busy || req); the sync drain // uses its own local SegJob + its own buffer so a worker stalled past the 9 s // idle-wait cap can never observe a rebuilt descriptor or a reused buffer. static uint8_t s_segjob_kind = SEGJOB_NONE; static uint32_t s_segjob_first_seq = 0; static uint32_t s_segjob_last_seq = 0; static bool s_segjob_create = false; static int s_segjob_n = 0; static UITask::UIMessage* s_segjob_buf = nullptr; // worker jobs only static UITask::UIMessage* s_segsync_buf = nullptr; // sync-drain jobs only // A delete landed inside the segment a COMPACT job is currently rewriting — // its snapshot predates the tombstone, so the segment must stay dirty when // the job commits (a second compaction picks the deletion up). static bool s_segjob_redirty = false; // fwd decls — scheduler helpers live with the storage code below. static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, UITask::UIMessage* buf, SegJob* out); static void segCommitJob(const SegJob& job); static bool segMoreWorkPending(uint32_t newest_seq); static bool uiSegRunArmedJob(); static void segMarkSeqDirty(uint32_t seq); static void segNoteEvicted(uint32_t seq); static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq); static void segPurgeStaleFiles(); static bool uiMsgsWriteFail(char stage); // fwd — failure stage/errno bookkeeping (writer block below) static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out); static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot); // fwd decls — the ops live with the storage code far below. static void uiDataEnsureDirs(); static bool uiHistWaitWorkerIdle(); static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps); static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out); static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out); static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer); static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n); static void uiSegRemoveFile(uint32_t first_seq); static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists static volatile bool s_sdinfo_ok = false; // card present + sizes valid static uint64_t s_sdinfo_tot = 0; static uint64_t s_sdinfo_free = 0; static volatile int s_verchk_latest_n = -1; // highest published pre-alpha_N (-1 = failed) #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // Wi-Fi OTA self-update — native dual-OTA boards only (V4 + standalone T-Deck). The button is // gated on touchHasOtaUpdateSlot() (a spare A/B slot exists), so Launcher single-slot installs and // the Tanmatsu (AppFS) never reach this — they update out-of-band. Arduino Update.begin() does the // slot select + the image-fits-the-slot check, so a too-small legacy slot fails cleanly. static volatile bool s_ota_request = false; // UI -> worker: download + flash the latest bin static volatile int s_ota_state = 0; // 0 idle, 1 running, 2 success, 3 error static volatile int s_ota_pct = 0; // download/write progress 0..100 static volatile int s_ota_target_n = -1; // beta_N the worker should fetch (latest, or a chosen older one) static char s_ota_msg[80] = {0}; // error detail surfaced to the UI static bool touchHasOtaUpdateSlot(); // fwd: spare-A/B-slot probe, defined further below #endif // ---- Wi-Fi scan (serviced by the core-0 fetch worker; results drawn in the // Network tab + the setup wizard) ---- static constexpr int kWifiScanMax = 14; static char s_wifiscan_ssids[kWifiScanMax][WIFI_CONFIG_SSID_MAX]; static volatile int s_wifiscan_count = 0; static volatile bool s_wifiscan_request = false; // UI -> worker: scan now static volatile bool s_wifiscan_done = false; // worker -> UI: results ready #if defined(TLORA_PAGER) static bool s_wifiscan_wait_for_radio = false; // main task must cold-start STA first static uint32_t s_wifiscan_pager_guard_ms = 0; // recover if worker never services request #endif // Scan-while-connected handshake (S3): the radio can't sweep reliably while // associated, so the MAIN task briefly drops the link before the worker scans and // rejoins after. All WiFi state changes happen on the main task (never the worker). static volatile bool s_wifiscan_drop_req = false; // UI -> main: drop link, then scan static bool s_wifiscan_reconnect_after = false; // main: rejoin once results land static uint32_t s_wifiscan_drop_ms = 0; // main: disassoc-settle timer static uint32_t s_wifiscan_guard_ms = 0; // main: cancel only an unclaimed scan // DEBUG: last wifiScanWatchdogSafe outcome (so the main-task list rebuild can log it). static volatile int16_t s_swd_kick = -9; static volatile int16_t s_swd_st = -9; static volatile uint32_t s_swd_dur = 0; static volatile uint8_t s_swd_status = 0; static lv_obj_t* s_wifi_scan_list = nullptr; // results list (inside the scan popup) static lv_obj_t* s_wifi_scan_popup = nullptr; // full-screen scan overlay (covers bar + sub-tabs) static bool ensureTileFetchTaskRunning(); // fwd: respawn the core-0 worker if it idled out // ---- First-boot setup wizard (welcome -> name -> region -> Wi-Fi). Shown once // on a fresh flash; gated by touchPrefsGetSetupDone(). s_setup_root is declared // here (not with the wizard functions further down) so the hardware-key handler // can swallow stray keystrokes while the wizard owns the screen. ---- static lv_obj_t* s_setup_root = nullptr; // full-screen overlay; non-null = wizard active static int s_setup_step = 0; // 0 welcome / 1 name / 2 region / 3 Wi-Fi static lv_obj_t* s_setup_name_ta = nullptr; static lv_obj_t* s_setup_region_list = nullptr; static int s_setup_region_sel = -1; // selected preset index, -1 = keep default static lv_obj_t* s_setup_region_next_btn = nullptr; // so a keypad-nav region pick can jump focus straight to it static lv_obj_t* s_setup_ssid_ta = nullptr; // (legacy) Wi-Fi fields — the wizard's Wi-Fi step is now an info screen static lv_obj_t* s_setup_pwd_ta = nullptr; static void setupWizardOpen(); // fwd: re-trigger the flow (Device settings button) static void setupRerunCb(lv_event_t* e); // fwd: "Run setup again" button callback // Our release number (the N in "beta_N"); -1 if this isn't a tagged build. static int firmwareReleaseN() { const char* t = FIRMWARE_RELEASE_TAG; const char* pfx = "beta_"; const size_t pl = strlen(pfx); if (strncmp(t, pfx, pl) != 0) return -1; if (t[pl] < '0' || t[pl] > '9') return -1; return atoi(t + pl); } // Reflect the latest check result in the gear badge + the About status line. static void otaButtonRefreshState(); // defined below; greys the Install button when current static void versionCheckUpdateUi() { // Two badges: the bottom-bar gear (get the user into Settings) and the About // sub-tab (the actual update info lives there). lv_obj_t* badges[2] = { s_update_badge, s_update_subtab_badge }; for (lv_obj_t* bd : badges) { if (!bd) continue; if (s_update_available) lv_obj_clear_flag(bd, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(bd, LV_OBJ_FLAG_HIDDEN); } otaButtonRefreshState(); // enable/grey the OTA button to match the result if (!s_update_about_lbl) return; const int my_n = firmwareReleaseN(); char b[140]; if (my_n < 0) { snprintf(b, sizeof b, "Firmware %s\nDevelopment build — update check off", FIRMWARE_VERSION); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } else if (s_update_available && s_verchk_latest_n >= 0) { snprintf(b, sizeof b, LV_SYMBOL_DOWNLOAD " Update available: beta_%d\nYou have beta_%d — update manually at flasher.wadamesh.com", s_verchk_latest_n, my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(0xE2A23A), LV_PART_MAIN); } else if (s_verchk_latest_n >= 0) { snprintf(b, sizeof b, LV_SYMBOL_OK " Up to date (beta_%d)", my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(0x6FCF6F), LV_PART_MAIN); } else if (s_verchk_ran) { snprintf(b, sizeof b, TR("Firmware beta_%d\nCouldn't reach the update server"), my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } else { snprintf(b, sizeof b, TR("Firmware beta_%d\nChecking for updates over Wi-Fi…"), my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } lv_label_set_text(s_update_about_lbl, b); } // ---- OTA: install the latest published release over the air ---- // The download goes through the meshcomod flasher proxy over PLAIN HTTP (the // device can't do HTTPS after Wi-Fi associates — see the tile/version-check // notes), which startHttpOtaFromUrl + its allow-list already support. The // proxy maps /firmware-download/* → ALLFATHER-BV/meshcomod main/* on GitHub, // so the app-only .bin under prebuilt/releases/TOUCH/<tag>/<env>.bin is what we // fetch. Requires the dual-slot partition table (app1 ≥ image size). #ifndef FIRMWARE_OTA_ENV #define FIRMWARE_OTA_ENV "" #endif static lv_obj_t* s_ota_status_lbl = nullptr; // live OTA status line (About page) static lv_obj_t* s_ota_btn = nullptr; // "Install update" button (greyed when up to date) static lv_obj_t* s_ota_btn_lbl = nullptr; // its caption (recoloured when disabled) static lv_obj_t* s_ota_prev_btn = nullptr; // single "Install a previous version" button (opens the version-picker popup) // Enable the OTA button only when an update is actually available — or when the // check hasn't completed yet (offline / still checking), so the user isn't // blocked by a stale "up to date". Grey + non-clickable when current. static void otaButtonRefreshState() { if (!s_ota_btn) return; // Known-current = a successful check (latest_n >= 0) that isn't newer than us. const bool known_current = (s_verchk_latest_n >= 0) && !s_update_available; const bool enabled = !known_current; if (enabled) { lv_obj_add_flag(s_ota_btn, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_state(s_ota_btn, LV_STATE_DISABLED); lv_obj_set_style_bg_color(s_ota_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ota_btn, LV_OPA_COVER, LV_PART_MAIN); if (s_ota_btn_lbl) lv_obj_set_style_text_color(s_ota_btn_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); } else { lv_obj_clear_flag(s_ota_btn, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_state(s_ota_btn, LV_STATE_DISABLED); lv_obj_set_style_bg_color(s_ota_btn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ota_btn, LV_OPA_20, LV_PART_MAIN); if (s_ota_btn_lbl) lv_obj_set_style_text_color(s_ota_btn_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } // "Install a previous version" button: only meaningful once the version check // yields a latest_n with at least one earlier beta below it (latest_n >= 2). // Hidden until then; the popup it opens lists the actual betas. if (s_ota_prev_btn) { if (s_verchk_latest_n >= 2) lv_obj_clear_flag(s_ota_prev_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(s_ota_prev_btn, LV_OBJ_FLAG_HIDDEN); } } #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // ---- Save-update-to-SD (Launcher installs) --------------------------------- // Launcher-managed T-Decks have no spare A/B slot (touchHasOtaUpdateSlot() is // false), so Wi-Fi OTA can't work there — but the Launcher itself can flash an // app-only bin straight from the SD card. This downloads the latest bin for // the ACTIVE update channel (stable, or beta when "Get test builds" is on) // into /BINS/wadamesh-beta_<N>-<stable|beta>.bin on the SD card. The download // streams on the tile worker; this UI side mirrors the OTA poll pattern. // Wording is board-conditional and the two sets are SEPARATE TR() keys on purpose. // Only the T-Deck has bmorcelli's Launcher; now that this feature is gated on // CAP_SD it also compiles for the Pager, the ThinkNode M9 and the Heltec V4-R8, // where "flash it from the Launcher" names something the user does not have. The // T-Deck strings must stay byte-identical: they are translated in all 13 .lang // files, and editing the English key orphans every translation of it (audit with // scripts/build/audit-lang.py). #if defined(HAS_TDECK_GT911) #define SDFW_SAVED_FMT TR("Saved: %s\nFlash it from the Launcher.") #define SDFW_HINT_TEXT TR("For Launcher installs: saves the latest firmware of the selected channel to the SD card (BINS folder).") #else #define SDFW_SAVED_FMT TR("Saved: %s\nReady for offline flashing.") #define SDFW_HINT_TEXT TR("Saves the latest firmware of the selected channel to the SD card (BINS folder).") #endif static volatile bool s_sdfw_request = false; // UI -> worker static volatile int s_sdfw_state = 0; // 0 idle, 1 running, 2 ok, 3 error static volatile int s_sdfw_pct = 0; static int s_sdfw_target_n = -1; static char s_sdfw_msg[64]; // success: saved path / error text static lv_obj_t* s_sdfw_status_lbl = nullptr; static lv_timer_t* s_sdfw_poll_timer = nullptr; static void sdFwPollTimerCb(lv_timer_t* t) { (void)t; const int st = s_sdfw_state; if (st == 1) { if (s_sdfw_status_lbl) { char b[48]; snprintf(b, sizeof b, TR("Saving to SD… %d%%"), s_sdfw_pct); lv_label_set_text(s_sdfw_status_lbl, b); } return; } if (s_sdfw_poll_timer) { lv_timer_del(s_sdfw_poll_timer); s_sdfw_poll_timer = nullptr; } if (st == 2) { if (s_sdfw_status_lbl) { char b[112]; snprintf(b, sizeof b, SDFW_SAVED_FMT, s_sdfw_msg); lv_label_set_text(s_sdfw_status_lbl, b); } if (g_lv.task) g_lv.task->showAlert(TR("Update bin saved to SD"), 3000); } else if (st == 3) { if (s_sdfw_status_lbl) { char b[96]; snprintf(b, sizeof b, TR("SD download failed: %s"), s_sdfw_msg); lv_label_set_text(s_sdfw_status_lbl, b); } } s_sdfw_state = 0; } static void sdFwSaveToSdCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_sdfw_state == 1 || s_ota_state == 1) return; // a download is already running if (s_verchk_latest_n < 0) { if (s_sdfw_status_lbl) lv_label_set_text(s_sdfw_status_lbl, TR("No version known yet. Wi-Fi and a completed update check are needed first.")); return; } s_sdfw_msg[0] = 0; s_sdfw_pct = 0; s_sdfw_target_n = s_verchk_latest_n; // latest of the ACTIVE channel s_sdfw_state = 1; s_sdfw_request = true; // hand off to the worker if (s_sdfw_status_lbl) lv_label_set_text(s_sdfw_status_lbl, TR("Saving to SD… 0%")); if (!s_sdfw_poll_timer) s_sdfw_poll_timer = lv_timer_create(sdFwPollTimerCb, 400, nullptr); } #endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA static lv_timer_t* s_ota_poll_timer = nullptr; // UI-thread poll of the worker's OTA progress (the download/flash runs on the tile worker). static void otaPollTimerCb(lv_timer_t* t) { (void)t; const int st = s_ota_state; if (st == 1) { // still downloading / writing if (s_ota_status_lbl) { char b[64]; snprintf(b, sizeof b, "Updating %d%%\nDo not power off.", s_ota_pct); lv_label_set_text(s_ota_status_lbl, b); } return; } if (s_ota_poll_timer) { lv_timer_del(s_ota_poll_timer); s_ota_poll_timer = nullptr; } if (st == 2) { // success -> boot the freshly-written slot (rebootDevice saves chat history first) if (s_ota_status_lbl) lv_label_set_text(s_ota_status_lbl, TR("Update complete.\nRebooting...")); lv_refr_now(NULL); if (g_lv.task) g_lv.task->rebootDevice(); } else { // error -> show why, leave the button armed to retry, fall back to manual if (s_ota_status_lbl) { char b[112]; snprintf(b, sizeof b, "Update failed: %s\nUse flasher.wadamesh.com instead.", s_ota_msg[0] ? s_ota_msg : "unknown"); lv_label_set_text(s_ota_status_lbl, b); } if (s_ota_btn) { lv_obj_clear_state(s_ota_btn, LV_STATE_DISABLED); lv_obj_add_flag(s_ota_btn, LV_OBJ_FLAG_CLICKABLE); } s_ota_state = 0; } } // Shared OTA kick-off: re-validates (slot present, Wi-Fi up, not already running), pins the // target beta_N for the worker, then hands off. Used by both the "Install update" button // (target = latest) and the "reinstall an earlier version" buttons (target = a chosen older N). static void otaStartInstall(int target_n) { if (!touchHasOtaUpdateSlot()) { // Launcher / no spare slot — the button shouldn't even be here if (g_lv.task) g_lv.task->showAlert(TR("Update via the Launcher / flasher.wadamesh.com"), 3000); return; } if (WiFi.status() != WL_CONNECTED) { if (s_ota_status_lbl) { lv_label_set_text(s_ota_status_lbl, TR("Connect to Wi-Fi first, then try again.")); lv_obj_set_style_text_color(s_ota_status_lbl, lv_color_hex(0xE2A23A), LV_PART_MAIN); } if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi not connected"), 2000); return; } if (target_n < 1) return; // no valid release to fetch if (s_ota_state == 1) return; // already running s_ota_msg[0] = 0; s_ota_pct = 0; s_ota_target_n = target_n; s_ota_state = 1; s_ota_request = true; // hand off to the worker if (s_ota_btn) { lv_obj_add_state(s_ota_btn, LV_STATE_DISABLED); lv_obj_clear_flag(s_ota_btn, LV_OBJ_FLAG_CLICKABLE); } if (s_ota_status_lbl) { char sb[48]; snprintf(sb, sizeof sb, "Installing beta_%d...\nDo not power off.", target_n); lv_label_set_text(s_ota_status_lbl, sb); lv_obj_set_style_text_color(s_ota_status_lbl, lv_color_hex(0xE2A23A), LV_PART_MAIN); } if (!s_ota_poll_timer) s_ota_poll_timer = lv_timer_create(otaPollTimerCb, 400, nullptr); } // ---- Previous-version flow: one button -> version-picker popup -> "are you sure?" -> install ---- // The chosen beta_N is stashed module-static between the picker tap and the // confirm callback (SimpleCb is no-arg), mirroring the chatDelete pattern. static void popupClose(lv_obj_t** root); // THE popup closer (defined below); every modal teardown routes through it static int s_ota_pending_n = -1; // beta_N awaiting confirmation static lv_obj_t* s_ota_prev_modal = nullptr; // the version-picker overlay (backdrop root) // Async-delete the picker overlay (NEVER sync-del an overlay that may be mid-gesture — UAF). static void otaPrevModalClose() { if (s_ota_prev_modal) { popupClose(&s_ota_prev_modal); } } static void otaPrevModalCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; otaPrevModalClose(); } // Confirm "Install beta_N?" was accepted -> kick off the (downgrade) install. static void otaConfirmInstallApply() { otaStartInstall(s_ota_pending_n); } // A version row in the picker was tapped: stash the target, close the picker, // then raise the are-you-sure confirm before anything is downloaded. static void otaPrevPickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; int v = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); if (v < 1) return; s_ota_pending_n = v; otaPrevModalClose(); char m[96]; snprintf(m, sizeof m, TR("Install beta_%d?\nThis downgrades the firmware and reboots."), v); showConfirm(m, TR("Install"), otaConfirmInstallApply); } // "Install a previous version" button -> build the version-picker popup listing // the up-to-3 betas just below the latest. Tap-outside / X dismisses it. static void otaPrevVersionsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_verchk_latest_n < 2) { // no earlier release exists yet if (g_lv.task) g_lv.task->showAlert(TR("No earlier versions yet"), 1500); return; } otaPrevModalClose(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); // Backdrop: full-screen tap-catcher (tap outside the card = dismiss). lv_obj_t* bg = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(bg); lv_obj_set_size(bg, sw, lv_disp_get_ver_res(nullptr)); lv_obj_set_pos(bg, 0, 0); lv_obj_set_style_bg_color(bg, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(bg, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(bg, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(bg, otaPrevModalCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_move_foreground(bg); s_ota_prev_modal = bg; // Card: centered, content-height, holds the title + version rows. NOT clickable, // so taps on the card itself don't bubble to the backdrop and close it. lv_obj_t* card = lv_obj_create(bg); lv_obj_remove_style_all(card); lv_obj_set_width(card, sw - SC(40)); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 10); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(card, LV_OBJ_FLAG_CLICKABLE); addCloseXBadge(card, otaPrevModalCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Install an earlier version")); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); for (int k = 1; k <= 3; k++) { int v = s_verchk_latest_n - k; if (v < 1) break; lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, lv_pct(90), 38); styleButton(b); lv_obj_set_user_data(b, (void*)(intptr_t)v); lv_obj_add_event_cb(b, otaPrevPickCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); char bt[16]; snprintf(bt, sizeof bt, "beta_%d", v); lv_label_set_text(l, bt); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); } } #endif static void otaInstallLatestCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA otaStartInstall(s_verchk_latest_n); #else // Launcher / Tanmatsu: no spare OTA slot to write into — update out-of-band. if (s_ota_status_lbl) { lv_label_set_text(s_ota_status_lbl, TR("Update via the Launcher / flasher.wadamesh.com.")); lv_obj_set_style_text_color(s_ota_status_lbl, lv_color_hex(0xE2A23A), LV_PART_MAIN); } if (g_lv.task) g_lv.task->showAlert(TR("Update via the Launcher"), 3000); #endif } // Trigger a check once Wi-Fi is up (then every 6 h); apply the result when ready. static void versionCheckService(unsigned long now) { #if defined(MULTI_TRANSPORT_COMPANION) if (firmwareReleaseN() < 0) return; // dev build: nothing to compare against static bool started = false; static unsigned long next_ms = 0; static uint8_t fail_streak = 0; // Associated is NOT reachable: on the esp-hosted P4 (and a slow AP anywhere) // WL_CONNECTED lands before DHCP finishes, and a check fired in that window // fails on the very first boot-time attempt. Require an actual IP. const bool wifi_ready = (WiFi.status() == WL_CONNECTED) && (uint32_t)WiFi.localIP() != 0; if (wifi_ready && !s_verchk_request && !s_verchk_done && (s_verchk_recheck || !started || (long)(now - next_ms) >= 0)) { started = true; s_verchk_recheck = false; // consume the forced-recheck request next_ms = now + 6UL * 60UL * 60UL * 1000UL; // re-check every 6 h ensureTileFetchTaskRunning(); // worker idles out — make sure it's up s_verchk_request = true; } if (s_verchk_done) { s_verchk_done = false; s_verchk_ran = true; s_update_available = (s_verchk_latest_n > firmwareReleaseN()); if (s_verchk_latest_n < 0) { // Fetch failed (DNS/socket/parse): retry with backoff — 1 min for the first // few tries, then every 5 min — instead of showing "Couldn't reach the // update server" for the full 6 h because one boot-time attempt raced DHCP. if (fail_streak < 10) fail_streak++; next_ms = now + ((fail_streak <= 3) ? 60UL * 1000UL : 5UL * 60UL * 1000UL); } else { fail_streak = 0; } versionCheckUpdateUi(); } #else (void)now; #endif } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // "Get test builds (beta)" toggle (About page) — switch the OTA update channel // between stable (releases/TOUCH) and the opt-in test/beta channel (releases/BETA). // Persists the choice, then forces a fresh version check so the About status line // AND the Install-update button reflect the newly-selected channel right away. static void betaUpdatesToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetBetaUpdates(on); s_verchk_ran = false; // drop the cached result... s_update_available = false; s_verchk_latest_n = -1; s_verchk_recheck = true; // ...and re-query the newly-selected channel versionCheckUpdateUi(); // status line falls back to "Checking…" (also greys the button) if (g_lv.task) g_lv.task->showAlert(on ? TR("Test builds: on") : TR("Test builds: off"), 2000); } #endif static void applySwipeGesture(int8_t swipe_x, int8_t swipe_y) { // The Snake game owns the whole screen while it's open: steer with the swipe // and return, so it can never reach the tab switcher / map pan / control // centre behind the overlay. This reuses the per-board hardware swipe detector // that drives tab switching (reliable on both the V4 cap-touch and the T-Deck // GT911), since LVGL's own LV_EVENT_GESTURE doesn't fire dependably on the // cap-touch — so this, not SnakeGame's gestureCb, is what makes touch steering // actually work. swipe_x<0 == left, swipe_y<0 == up (see the control-center // note below for the sign convention). if (luaAppIsOpen()) { // Lua apps get the same reliable hardware swipes as Snake did if (swipe_x != 0) luaAppSteer(swipe_x < 0 ? -1 : 1, 0); else if (swipe_y != 0) luaAppSteer(0, swipe_y < 0 ? -1 : 1); return; } if (SnakeGame::isOpen()) { if (swipe_x != 0) SnakeGame::steer(swipe_x < 0 ? -1 : 1, 0); else if (swipe_y != 0) SnakeGame::steer(0, swipe_y < 0 ? -1 : 1); return; } // Every OTHER full-screen app page (Airtime, Spectrum, RF Monitor, Discover, VNC, Remote, // Reader) also owns the screen, so swallow the swipe instead of letting it reach the tab // switcher behind the page. Making the page root opaque + CLICKABLE only stops CLICKS: // this detector reads the per-board touch hardware directly and never consults LVGL // hit-testing, which is why a swipe inside Airtime still jumped to the Map tab. Settings // sub-sheets borrow s_apppage_title for their back chevron too, but they always open from // inside a category and have their own rightward swipe-back further down, so exclude them. // (Vertical swipes are a no-op at the end of this function, so this only kills tab swipes.) if (s_apppage_title && s_settings_open_cat < 0) return; // A slider was just being dragged — its horizontal drag must NOT be read as a // tab/back swipe (e.g. raising the volume slider rightward kept triggering the // settings "swipe right = back"). Ignore swipes briefly after any slider touch. if (s_slider_touch_ms && (millis() - s_slider_touch_ms) < 500) return; // An open chat/channel: a left→right (rightward) swipe closes it (iOS-style // back). Any other swipe is swallowed so the conversation keeps the screen. if (hasChatDetailOpen()) { if (swipe_x > 0) { if (g_lv.dm.detail_open) closeChatPanel(&g_lv.dm); else if (g_lv.ch.detail_open) closeChatPanel(&g_lv.ch); } return; } if (s_wifi_scan_popup) return; // scan popup is modal; don't switch tabs underneath if (overlayBlocksTabSwipe()) return; // theme / channel-scope pickers: don't swipe the menu beneath // The control center drops down from the top bar, so an up-swipe dismisses // it (natural "push it back up" gesture). Any other swipe is swallowed so // that e.g. dragging its brightness slider can't also switch the tab // underneath the card. swipe_y < 0 == upward (see TDeckTouch swipe sign). if (s_cc_root) { if (swipe_y < 0) closeControlCenter(); return; } // Popups floating over the app drawer (or any tab): swallow the swipe so it // can't switch tabs / close the drawer underneath them. if (drawerPopupOpen()) return; if (!g_lv.tabview) return; if (swipe_x != 0) { // An open settings detail sheet: a left→right (rightward) swipe goes Back, // like iOS edge-back. Any other swipe is swallowed so the tab underneath the // sheet never switches. if (s_settings_sheet) { if (swipe_x > 0) closeSettingsCategory(); return; } // A settings modal overlays the tab — never switch under it. if (settingsModalIsOpen()) return; // Block horizontal tab-swipes on the Map tab — that gesture is "pan" // there, not "switch tab". The +/− and recenter buttons + the tabbar // icons remain the only way off the tab. if (getActiveTab() == MAP_TAB_INDEX) return; int idx = getActiveTab(); int next = idx + (swipe_x < 0 ? 1 : -1); if (next < 0) next = 0; if (next > TAB_LAST) next = TAB_LAST; if (next != idx) goToTab(next); return; } // Vertical swipes intentionally fall through: LVGL's native drag-scroll on // the underlying list (chats / contacts / settings tab) handles them // continuously during the gesture, which feels naturally finger-locked. // The old fixed-step jump on release was the source of "jittery / jumpy" // vertical scroll feel. (void)swipe_y; } static lv_obj_t* s_chat_msgs_scroll_obj = nullptr; // active virt chat scroll container #ifndef TRACE_MESSAGE_SCROLL_ACTIVITY #define TRACE_MESSAGE_SCROLL_ACTIVITY 0 #endif #if TRACE_MESSAGE_SCROLL_ACTIVITY #define CHAT_SCROLL_TRACE_PRINTF(...) Serial.printf(__VA_ARGS__) #define CHAT_SCROLL_TRACE_DO(stmt) do { stmt; } while (0) static bool s_chat_touch_on_msgs = false; static lv_coord_t s_chat_dbg_last_scroll_logged = -9999; #else #define CHAT_SCROLL_TRACE_PRINTF(...) do {} while (0) #define CHAT_SCROLL_TRACE_DO(stmt) do {} while (0) #endif /** After elastic/rubber-band overscroll, snap back to the furthest valid top or bottom. */ static void scrollClampOnEndCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_SCROLL_END) return; lv_obj_t* obj = lv_event_get_target(e); if (!obj || !lv_obj_has_flag(obj, LV_OBJ_FLAG_SCROLLABLE)) return; lv_dir_t d = lv_obj_get_scroll_dir(obj); if (!(d & LV_DIR_VER)) return; lv_obj_update_layout(obj); lv_coord_t sy = lv_obj_get_scroll_y(obj); lv_coord_t sm = lv_obj_get_scroll_top(obj) + lv_obj_get_scroll_bottom(obj); if (sm < 0) sm = 0; // Chat virt: only spacer sets scroll extent; use instant snap (no ANIM_ON) so // clamp doesn't fire a second SCROLL_END that races virt re-render. const bool is_chat_msgs = (s_chat_msgs_scroll_obj == obj); const lv_anim_enable_t anim = is_chat_msgs ? LV_ANIM_OFF : LV_ANIM_ON; if (sy < 0) lv_obj_scroll_to_y(obj, 0, anim); else if (sy > sm) lv_obj_scroll_to_y(obj, sm, anim); } // ============================================================ // Keyboard show / hide // Chat: keyboard binds to composer (layout already lifts composer above keys). // Settings modals: optional top mirror strip so low fields stay readable. // ============================================================ constexpr int KB_MIRROR_STRIP_H = 52; // Keyboard rotation helpers (defined here so showKb/hideKb/kbMirrorBind can use them). // Layout adjusts keyboard + mirror + rotate arrows for the current rotation. static void kbApplyLayoutForRotation(uint8_t rot) { lv_disp_t* disp = lv_disp_get_default(); if (!disp) return; const lv_coord_t scr_w = lv_disp_get_hor_res(disp); const lv_coord_t scr_h = lv_disp_get_ver_res(disp); const bool landscape = (rot == LV_DISP_ROT_90 || rot == LV_DISP_ROT_270); // Cap keyboard to half the screen so the textarea above stays visible. const lv_coord_t kb_h = landscape ? (scr_h / 2) : CHAT_KB_H; if (g_lv.keyboard) { lv_obj_set_size(g_lv.keyboard, scr_w, kb_h); lv_obj_align(g_lv.keyboard, LV_ALIGN_BOTTOM_MID, 0, 0); } if (s_kb_mirror_root) { lv_obj_set_width(s_kb_mirror_root, scr_w); if (s_kb_mirror_ta) lv_obj_set_width(s_kb_mirror_ta, scr_w - 16); } // Re-flow the active chat panel for the new orientation. Its children were // sized/positioned for whatever rotation was current when the panel was built // or last shown — so after a rotate the message area + composer keep a stale // (e.g. portrait) width and Y: the composer ends up narrow and parked off the // visible area until something nudges it (the auto-grow on the 2nd line was the // only thing fixing the Y). The display is already rotated here, so the chat*() // helpers return the new geometry. if (s_kb_panel) { // Resize the overlay container FIRST — it's sized to the screen at build time // and otherwise stays portrait-narrow in landscape, clipping everything // inside it (the composer looked "not full width" because its parent wasn't). if (s_kb_panel->overlay) lv_obj_set_size(s_kb_panel->overlay, chatScreenW(), chatScreenH()); if (s_kb_panel->msgs) lv_obj_set_size(s_kb_panel->msgs, chatScreenW(), chatMsgHKb()); if (s_kb_panel->composer_row) { lv_obj_set_size(s_kb_panel->composer_row, chatScreenW(), s_comp_h); lv_obj_set_y(s_kb_panel->composer_row, chatCompYKb()); } if (s_kb_panel->composer_ta) lv_obj_set_width(s_kb_panel->composer_ta, chatComposerTaW()); } // Rotation arrows. Default: just above the keyboard's top edge. In the // chat panel that area is occupied by the composer row (QR + textarea + // Send), so the arrows would land on top of the buttons. When the chat // composer is active, bump the arrows up to sit just above the COMPOSER // instead of just above the keyboard. lv_coord_t arrow_bottom_y_from_screen_top = scr_h - (kb_h + 2); if (s_kb_panel && s_kb_panel->composer_row) { // Sit just above the chat composer, which itself sits just above the // keyboard (composer bottom == keyboard top). COMPUTE that from this // rotation's keyboard height (kb_h) + composer height (s_comp_h) rather than // sampling the composer's live coords: on a rotate the composer hasn't been // re-laid-out for the new orientation yet, so sampling raced it and dropped // these buttons on top of the landscape keyboard. 2-px gap above the composer. arrow_bottom_y_from_screen_top = scr_h - kb_h - s_comp_h - 2; } // Convert "absolute y of arrow bottom" → offset usable by LV_ALIGN_BOTTOM_*. const lv_coord_t y_off = -(scr_h - arrow_bottom_y_from_screen_top); if (s_kb_rot_left_btn) lv_obj_align(s_kb_rot_left_btn, LV_ALIGN_BOTTOM_LEFT, 4, y_off); if (s_kb_rot_right_btn) lv_obj_align(s_kb_rot_right_btn, LV_ALIGN_BOTTOM_RIGHT, -4, y_off); // Language key sits just right of the left rotate arrow. if (s_kb_lang_btn) lv_obj_align(s_kb_lang_btn, LV_ALIGN_BOTTOM_LEFT, 40, y_off); // Accent ("Alt") key sits just right of the language key. if (s_kb_alt_btn) lv_obj_align(s_kb_alt_btn, LV_ALIGN_BOTTOM_LEFT, 76, y_off); } static void kbApplyRotation(uint8_t rot) { lv_disp_t* disp = lv_disp_get_default(); if (!disp) return; // This often runs MID-PRESS: the composer focuses on touch-DOWN, and rotating the display // transforms the live pointer's coordinates — LVGL sees the still-held finger "teleport" by // hundreds of px and treats it as a huge drag, scroll-throwing lv_scr_act() (the chat overlay // slides out of the viewport and the tab list slides in; on the P4's 284x616 the jump is a // full screen — hideKb's scroll_to(0,0) was this same artifact's band-aid). Swallow the rest // of the gesture BEFORE rotating so the jump is never seen as movement. lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); // In hardware-landscape the panel is already rotated; applying LVGL software // rotation on top would double-rotate. Software rotation is used ONLY for the // transient keyboard-landscape trick when the base orientation is portrait. const bool hw_landscape = (s_ui_rotation == LV_DISP_ROT_90 || s_ui_rotation == LV_DISP_ROT_270); if (!hw_landscape) { lv_disp_set_rotation(disp, static_cast<lv_disp_rot_t>(rot)); } // Keep the touch driver's swipe-axis transform in sync with the visible // orientation so horizontal swipes are detected correctly in landscape. heltecV4CapTouchSetRotation(rot); kbApplyLayoutForRotation(rot); // Pin the root scroll: any scroll the pre-rotation gesture already banked (or a // scroll-throw in flight) would leave the viewport parked off the chat page. lv_obj_scroll_to(lv_scr_act(), 0, 0, LV_ANIM_OFF); // Force a full redraw — without this the previous frame's contents linger // briefly at the new orientation's coordinates. lv_obj_invalidate(lv_scr_act()); } static void kbSaveRotationPref() { #if defined(ESP32) SdNvsPrefs pr; if (pr.begin("meshTouch", false)) { pr.putUChar("kbrot", s_kb_rotation); pr.end(); } #endif } static void kbSetRotateArrowsOpa(lv_opa_t opa) { if (s_kb_rot_left_btn) lv_obj_set_style_opa(s_kb_rot_left_btn, opa, LV_PART_MAIN); if (s_kb_rot_right_btn) lv_obj_set_style_opa(s_kb_rot_right_btn, opa, LV_PART_MAIN); if (s_kb_lang_btn) lv_obj_set_style_opa(s_kb_lang_btn, opa, LV_PART_MAIN); if (s_kb_alt_btn) lv_obj_set_style_opa(s_kb_alt_btn, opa, LV_PART_MAIN); } // ---- On-screen language-cycle key ---------------------------------------- // Boards without a physical keyboard (e.g. Heltec V4 TFT) can't double-tap a // hardware SPACE to switch keyboard languages, so the on-screen keyboard carries // a little key showing the active layout's 2-letter code; tapping it cycles // English -> each enabled secondary layout -> back, like the T-Deck's SPACE. static const char* kbLayoutCode(KeyboardLayoutId id) { static const char* k_codes[] = { "EN", "BG", "RU", "UK", "SR", "EL", "AR", "FR", "NL", "DE", "ES", "IT" }; const uint8_t i = static_cast<uint8_t>(id); return (i < (sizeof k_codes / sizeof k_codes[0])) ? k_codes[i] : "EN"; } static void kbLangBtnRefresh() { if (s_kb_lang_lbl) lv_label_set_text(s_kb_lang_lbl, kbLayoutCode(keyboardLayoutsGetCurrent())); } static void kbLangCycleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !keyboardLayoutsAnySecondary()) return; KeyboardLayoutId next = keyboardLayoutsCycle(g_lv.keyboard); #if defined(ESP32) touchPrefsSetKeyboardLayout(static_cast<uint8_t>(next)); #endif kbLangBtnRefresh(); if (g_lv.task) g_lv.task->showAlert(keyboardLayoutName(next), 800); } // Picking a UI language auto-enables + selects the matching keyboard layout (so choosing French // gives AZERTY without a separate trip to keyboard settings). EU layouts via #29 (samuelcoustet). static KeyboardLayoutId kbDefaultLayoutForUiLang(uint8_t lang) { switch (lang) { case LANG_NL: return KeyboardLayoutId::NL; case LANG_DE: return KeyboardLayoutId::DE; case LANG_ES: return KeyboardLayoutId::ES; case LANG_IT: return KeyboardLayoutId::IT; case LANG_BG: return KeyboardLayoutId::BG; case LANG_RU: return KeyboardLayoutId::RU; case LANG_UK: return KeyboardLayoutId::UK; case LANG_SR: return KeyboardLayoutId::SR; case LANG_EL: return KeyboardLayoutId::EL; case LANG_FR: return KeyboardLayoutId::FR; case LANG_RO: return KeyboardLayoutId::RO; default: return KeyboardLayoutId::EN; } } static void kbApplyUiLangDefault(uint8_t lang) { KeyboardLayoutId target = kbDefaultLayoutForUiLang(lang); uint32_t mask = keyboardLayoutsGetEnabledMask(); const uint8_t target_id = static_cast<uint8_t>(target); if (target != KeyboardLayoutId::EN && !(mask & (1u << target_id))) { mask |= (1u << target_id); keyboardLayoutsSetEnabledMask(mask); #if defined(ESP32) touchPrefsSetEnabledLayouts(static_cast<uint16_t>(mask)); #endif } if (g_lv.keyboard) keyboardLayoutsApply(g_lv.keyboard, target); #if defined(ESP32) touchPrefsSetKeyboardLayout(target_id); #endif kbLangBtnRefresh(); } static void kbShowRotateArrows(bool show) { if (s_kb_rot_left_btn) show ? lv_obj_clear_flag(s_kb_rot_left_btn, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag (s_kb_rot_left_btn, LV_OBJ_FLAG_HIDDEN); if (s_kb_rot_right_btn) show ? lv_obj_clear_flag(s_kb_rot_right_btn, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag (s_kb_rot_right_btn, LV_OBJ_FLAG_HIDDEN); // Language key: only when ≥1 secondary layout is enabled (else nothing to cycle). const bool lang_show = show && keyboardLayoutsAnySecondary(); if (s_kb_lang_btn) lang_show ? lv_obj_clear_flag(s_kb_lang_btn, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag (s_kb_lang_btn, LV_OBJ_FLAG_HIDDEN); // Accent ("Alt") key: shown whenever the keyboard is up. if (s_kb_alt_btn) show ? lv_obj_clear_flag(s_kb_alt_btn, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag (s_kb_alt_btn, LV_OBJ_FLAG_HIDDEN); if (show) { // Each new show starts at full opacity; the first keystroke fades arrows // to ~20% so they're out of the way while typing but still tappable. kbSetRotateArrowsOpa(LV_OPA_COVER); if (s_kb_rot_left_btn) lv_obj_move_foreground(s_kb_rot_left_btn); if (s_kb_rot_right_btn) lv_obj_move_foreground(s_kb_rot_right_btn); if (lang_show) { kbLangBtnRefresh(); lv_obj_move_foreground(s_kb_lang_btn); } if (s_kb_alt_btn) lv_obj_move_foreground(s_kb_alt_btn); } } static void kbRotLeftCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Tap the left arrow: rotate landscape that way; tap again returns to portrait. s_kb_rotation = (s_kb_rotation == LV_DISP_ROT_270) ? LV_DISP_ROT_NONE : LV_DISP_ROT_270; kbApplyRotation(s_kb_rotation); kbSaveRotationPref(); } static void kbRotRightCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_kb_rotation = (s_kb_rotation == LV_DISP_ROT_90) ? LV_DISP_ROT_NONE : LV_DISP_ROT_90; kbApplyRotation(s_kb_rotation); kbSaveRotationPref(); } static void kbMirrorEnsureCreated() { if (s_kb_mirror_root) return; s_kb_mirror_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_kb_mirror_root); lv_obj_set_size(s_kb_mirror_root, 240, KB_MIRROR_STRIP_H); // Sit just below the global status bar so the time/battery are still // visible while typing. lv_obj_align(s_kb_mirror_root, LV_ALIGN_TOP_MID, 0, STATUSBAR_H + 2); styleSurface(s_kb_mirror_root, COLOR_PANEL, 8); lv_obj_set_style_pad_hor(s_kb_mirror_root, 8, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_kb_mirror_root, 6, LV_PART_MAIN); lv_obj_clear_flag(s_kb_mirror_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN); lv_obj_t* hint = lv_label_create(s_kb_mirror_root); lv_label_set_text(hint, TR("Editing")); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(hint, LV_ALIGN_TOP_LEFT, 0, 0); s_kb_mirror_ta = lv_textarea_create(s_kb_mirror_root); lv_obj_set_size(s_kb_mirror_ta, 224, 30); lv_obj_align(s_kb_mirror_ta, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_textarea_set_one_line(s_kb_mirror_ta, true); lv_obj_set_style_bg_color(s_kb_mirror_ta, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_text_color(s_kb_mirror_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(s_kb_mirror_ta, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_kb_mirror_ta, 1, LV_PART_MAIN); lv_obj_set_style_text_font(s_kb_mirror_ta, &g_font_14, LV_PART_MAIN); // Live-sync the mirror's text into the bound real textarea on every // keystroke instead of waiting for hideKb. Lets the underlying field // reflect what the user is typing in real time (useful when the field is // partially visible behind the keyboard, e.g. a chat composer). lv_obj_add_event_cb(s_kb_mirror_ta, [](lv_event_t* e) { (void)e; kbMirrorSyncToReal(); }, LV_EVENT_VALUE_CHANGED, nullptr); } static void kbMirrorSyncToReal() { if (!kbMirrorActive()) return; // T-Deck edits the real field directly — nothing to sync if (!s_kb_bind_ta || !s_kb_mirror_ta) return; lv_textarea_set_text(s_kb_bind_ta, lv_textarea_get_text(s_kb_mirror_ta)); } // Set a textarea's text from code (not via keystrokes) while keeping the on-screen // keyboard mirror consistent. If the mirror is currently bound to this field, a // later kbMirrorSyncToReal() (on Save, or when focus moves to another field) would // otherwise copy the STALE mirror contents back over what we just set — which is // exactly what made the Wi-Fi scan picker "not stick" and save the OLD SSID. // Updating the mirror too keeps both in agreement. static void setTextareaSynced(lv_obj_t* ta, const char* text) { if (!ta) return; lv_textarea_set_text(ta, text ? text : ""); if (s_kb_bind_ta == ta && s_kb_mirror_ta) { lv_textarea_set_text(s_kb_mirror_ta, text ? text : ""); } } // Cleared automatically when the bound textarea is destroyed. kbMirrorSyncToReal // (and the click/keystroke paths) write into s_kb_bind_ta; if its modal is torn // down by a path that skips hideKb() — e.g. a hardware-key action or screen lock // firing mid-flow — the pointer would dangle and the next sync writes into freed // memory (the 'loopTask' LoadProhibited use-after-free seen in the coredump). An // LV_EVENT_DELETE hook nulls it the moment LVGL frees the field, so it can never // point at freed memory regardless of which close path runs. static void kbBoundTaDeletedCb(lv_event_t* e) { if (lv_event_get_target(e) == s_kb_bind_ta) s_kb_bind_ta = nullptr; } /** Show keyboard bound to a top mirror of `real_ta` so typed text stays visible above the keyboard. */ static void kbMirrorBind(lv_obj_t* real_ta) { if (!real_ta || !g_lv.keyboard) return; if (!kbMirrorActive()) { // Physical keyboard (T-Deck): bind the keys straight to the visible field so // the caret tracks taps (no mirror, no live-sync). s_kb_bind_ta still names // the bound field for the terminal/Enter logic; the mirror redirects are all // gated on kbMirrorActive(). s_kb_bind_ta = real_ta; lv_keyboard_set_textarea(g_lv.keyboard, real_ta); lv_obj_remove_event_cb(real_ta, kbBoundTaDeletedCb); lv_obj_add_event_cb(real_ta, kbBoundTaDeletedCb, LV_EVENT_DELETE, nullptr); return; } kbMirrorEnsureCreated(); if (s_kb_bind_ta && s_kb_bind_ta != real_ta) kbMirrorSyncToReal(); // Detach the bind while we mutate the mirror's properties below. // set_one_line / set_text emit VALUE_CHANGED on the mirror synchronously, // and the live-sync callback would otherwise copy the mirror's stale/empty // state into the freshly-bound real textarea before we've loaded it. s_kb_bind_ta = nullptr; const uint32_t max_len = lv_textarea_get_max_length(real_ta); lv_textarea_set_max_length(s_kb_mirror_ta, max_len); lv_textarea_set_password_mode(s_kb_mirror_ta, lv_textarea_get_password_mode(real_ta)); lv_textarea_set_one_line(s_kb_mirror_ta, true); lv_textarea_set_text(s_kb_mirror_ta, lv_textarea_get_text(real_ta)); // Mirror now holds the real_ta's current text; arm the bind so subsequent // user keystrokes get mirrored live. s_kb_bind_ta = real_ta; // Auto-clear the bind if this field is ever deleted (use-after-free guard). // Dedup so re-binding a long-lived field (chat composer) doesn't stack hooks. lv_obj_remove_event_cb(real_ta, kbBoundTaDeletedCb); lv_obj_add_event_cb(real_ta, kbBoundTaDeletedCb, LV_EVENT_DELETE, nullptr); lv_keyboard_set_textarea(g_lv.keyboard, s_kb_mirror_ta); #if !CAP_KEYBOARD // T-Deck has a physical keyboard — the mirror/keys never show; the typed text // syncs straight into the (visible) real field. Other boards show both. lv_obj_clear_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_kb_mirror_root); // Slide the mirror BELOW the settings modal header (when one is open) // so it doesn't sit on top of the modal's Close button. The mirror is on // lv_layer_top and gets move_foreground'd above the modal — so any tap // in the mirror's area was being consumed by the mirror's textarea // instead of reaching the Close button just behind it. const lv_coord_t mirror_y = settingsModalIsOpen() ? (STATUSBAR_H + 46 + 2) // below the 46-px modal header : (STATUSBAR_H + 2); // top of screen for chat / non-modal contexts lv_obj_align(s_kb_mirror_root, LV_ALIGN_TOP_MID, 0, mirror_y); lv_obj_clear_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_lv.keyboard); kbApplyRotation(effectiveKbRotation()); kbShowRotateArrows(true); #endif } static void hideKb() { accentExit(); // tear down any open accent picker accentBoxHide(); mentionBoxHide(); // tear down any open @-mention picker txtMenuHide(); // tear down any open edit menu kbMirrorSyncToReal(); s_kb_bind_ta = nullptr; #if defined(HAS_ATTAKY_MESH_KEYBOARD) // The summon lasts one editing session. Reset the panel to letters so a later // reveal does not inherit the symbol mode the summon opened. if (s_osk_forced && g_lv.keyboard) lv_keyboard_set_mode(g_lv.keyboard, LV_KEYBOARD_MODE_TEXT_LOWER); s_osk_forced = false; #endif if (s_kb_mirror_root) lv_obj_add_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN); if (g_lv.keyboard) { lv_keyboard_set_textarea(g_lv.keyboard, nullptr); lv_obj_add_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN); } kbShowRotateArrows(false); // Revert the display to the global UI orientation once the keyboard is // dismissed (portrait unless the user set a landscape base in Settings). // s_kb_rotation is preserved so the next showKb restores the transient // keyboard-landscape choice when the base is portrait. const bool kb_was_landscape = (effectiveKbRotation() == LV_DISP_ROT_90 || effectiveKbRotation() == LV_DISP_ROT_270); const bool base_is_landscape = (s_ui_rotation == LV_DISP_ROT_90 || s_ui_rotation == LV_DISP_ROT_270); const bool was_landscape = kb_was_landscape && !base_is_landscape; kbApplyRotation(s_ui_rotation); // Landscape forced the modal/chat scroll containers down to keep the field // visible above the rotated keyboard; reset them to the top so the user // doesn't land mid-scroll when the screen comes back to portrait. if (was_landscape) { if (g_set_modal.root) lv_obj_scroll_to(g_set_modal.root, 0, 0, LV_ANIM_OFF); lv_obj_scroll_to(lv_scr_act(), 0, 0, LV_ANIM_OFF); } if (s_kb_panel) { if (s_kb_panel->composer_ta) lv_obj_clear_state(s_kb_panel->composer_ta, LV_STATE_FOCUSED); if (s_kb_panel->msgs) lv_obj_set_height(s_kb_panel->msgs, chatMsgHOpen()); if (s_kb_panel->composer_row) lv_obj_set_y(s_kb_panel->composer_row, chatCompYOpen()); s_kb_panel = nullptr; } } // THE canonical popup/overlay closer — every modal teardown routes through this // (closers are enumerated in k_popup_registry at EOF). Tears the root down // without the lvgl sync-del-scroll use-after-free: if the overlay is (or holds) // the active scroll object when it's freed, LVGL's indev_proc_release still runs // _lv_indev_scroll_throw_handler on the (now-freed) object → LoadProhibited crash // — confirmed from a field beta_18 coredump (get_prop_core on a freed PSRAM obj at // EXCVADDR 0x18). Guard it exactly like closeSettingsCategory: // • lv_indev_wait_release → the next indev cycle short-circuits before the throw // handler, so scroll momentum can't re-fire on the gone object; // • lv_obj_del_async → the object survives the CURRENT indev cycle (whose // local scroll_obj was already captured) and is freed only after the tick. // Pass the static root by address so it's nulled here in one place. static void popupClose(lv_obj_t** root) { if (!root || !*root) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); lv_obj_del_async(*root); *root = nullptr; } static void showKb(LvChatPanel* p) { if (!g_lv.keyboard || !p || !p->composer_ta || !p->msgs || !p->composer_row) return; kbMirrorSyncToReal(); s_kb_bind_ta = nullptr; if (s_kb_mirror_root) lv_obj_add_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN); s_kb_panel = p; lv_keyboard_set_textarea(g_lv.keyboard, p->composer_ta); // lv_keyboard_set_textarea focuses the *keyboard*, not the field, so the // composer shows no cursor and reads as "not targeted". Focus it explicitly — // on the T-Deck this is the auto-target when a chat opens (typing already // routes here via handleHwKey; this just makes the cursor visible so the user // can see the field is ready). lv_obj_add_state(p->composer_ta, LV_STATE_FOCUSED); #if !CAP_KEYBOARD // No on-screen keyboard on the T-Deck — the physical keyboard types straight // into the composer (already visible), so skip showing the keys + the lift. #if defined(HAS_ATTAKY_MESH_KEYBOARD) // Same while the module is attached, until '#' summons the keys. kbMirrorActive() // guards the settings path; the chat composer comes through here and needs its own. if (attakyKeyboardPresent() && !s_osk_forced) return; #endif lv_obj_clear_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_lv.keyboard); // Shrink message area to keep composer visible above keyboard. lv_obj_set_height(p->msgs, chatMsgHKb()); lv_obj_set_y(p->composer_row, chatCompYKb()); kbApplyRotation(effectiveKbRotation()); kbShowRotateArrows(true); #endif } // The composer wraps long messages to multiple lines and grows UPWARD instead // of scrolling a single line sideways. Recompute the row height from how many // lines the current text wraps to (capped at CHAT_COMP_MAX_LINES), then re-apply // the chat layout so the message list above shrinks to match. Fires on every // composer text change (physical-keyboard insert, on-screen mirror sync, the // post-send clear, emoji/quick-reply inserts). Past the cap it scrolls vertically. static void chatComposerAutoGrow(LvChatPanel* p) { if (!p || !p->composer_ta || !p->composer_row || !p->msgs) return; const lv_coord_t lh = lv_font_get_line_height(&g_font_14); if (lh <= 0) return; const char* txt = lv_textarea_get_text(p->composer_ta); // Wrap width = the textarea's content box, less a few px for the cursor so the // box grows a hair before the field would actually need to scroll sideways. lv_coord_t maxw = lv_obj_get_content_width(p->composer_ta) - 4; if (maxw < 16) maxw = 16; lv_point_t sz; lv_txt_get_size(&sz, (txt && txt[0]) ? txt : " ", &g_font_14, 0, 0, maxw, LV_TEXT_FLAG_NONE); int lines = (int)((sz.y + lh - 1) / lh); // ceil to whole lines if (lines < 1) lines = 1; if (lines > CHAT_COMP_MAX_LINES) lines = CHAT_COMP_MAX_LINES; const lv_coord_t want = chatComposerBaseH() + (lv_coord_t)(lines - 1) * lh; if (want == s_comp_h) return; // height unchanged → nothing to relayout s_comp_h = want; // Keyboard shown (V4) lifts the composer above the keys; otherwise (T-Deck, or // V4 with the keyboard down) it sits at the screen bottom. const bool kb = g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(p->composer_row, s_comp_h); lv_obj_set_height(p->composer_ta, s_comp_h - 4); lv_obj_set_y(p->composer_row, kb ? chatCompYKb() : chatCompYOpen()); lv_obj_set_height(p->msgs, kb ? chatMsgHKb() : chatMsgHOpen()); // Grow the bottom inset with the composer so the newest bubble keeps clearing it. lv_obj_set_style_pad_bottom(p->msgs, s_comp_h + 6, LV_PART_MAIN); } static void composerAutoGrowCb(lv_event_t* e) { chatComposerAutoGrow(static_cast<LvChatPanel*>(lv_event_get_user_data(e))); } // ============================================================ // Callbacks // ============================================================ // ===== Long-press accent picker (issue #22) ================================= // Hold a Latin letter key -> a popup shows its accented variants; each further // tap of that key cycles the selection and replaces the just-typed char in // place; a short pause (or tapping a different key) commits the highlighted one. // On-screen, so it works on both V4 + T-Deck. The accent glyphs already live in // the extras_* fallback fonts (Latin-1 + Latin-Extended-A), so they render in // the textarea and chat. Keys carry LV_BTNMATRIX_CTRL_NO_REPEAT (KeyboardLayouts // .cpp) so a hold doesn't auto-repeat — it cleanly long-presses instead. static const char* const kAccA[] = {"à","á","â","ă","ä","ã","å","ą","æ"}; // æ: Danish/Norwegian (#237) static const char* const kAccA_u[] = {"À","Á","Â","Ă","Ä","Ã","Å","Ą","Æ"}; static const char* const kAccE[] = {"è","é","ê","ë","ě","ę"}; static const char* const kAccE_u[] = {"È","É","Ê","Ë","Ě","Ę"}; static const char* const kAccI[] = {"ì","í","î","ï"}; static const char* const kAccI_u[] = {"Ì","Í","Î","Ï"}; static const char* const kAccO[] = {"ò","ó","ô","ö","õ","ø"}; static const char* const kAccO_u[] = {"Ò","Ó","Ô","Ö","Õ","Ø"}; static const char* const kAccU[] = {"ù","ú","û","ü","ů"}; static const char* const kAccU_u[] = {"Ù","Ú","Û","Ü","Ů"}; static const char* const kAccN[] = {"ñ","ń"}; static const char* const kAccN_u[] = {"Ñ","Ń"}; static const char* const kAccC[] = {"ç","č","ć"}; static const char* const kAccC_u[] = {"Ç","Č","Ć"}; static const char* const kAccS[] = {"ß","ś","š","ş"}; static const char* const kAccY[] = {"ý","ÿ"}; // Czech carons / ring — base letters that otherwise carry no Latin-1 accent. static const char* const kAccT[] = {"ť","ţ"}; static const char* const kAccT_u[] = {"Ť","Ţ"}; static const char* const kAccZ[] = {"ž","ż","ź"}; static const char* const kAccZ_u[] = {"Ž","Ż","Ź"}; static const char* const kAccR[] = {"ř"}; static const char* const kAccR_u[] = {"Ř"}; static const char* const kAccL[] = {"ł"}; static const char* const kAccL_u[] = {"Ł"}; static const char* const kAccS_u[] = {"Ś","Š","Ş"}; struct AccentSet { char key; const char* const* v; uint8_t n; }; static const AccentSet kAccentSets[] = { {'a',kAccA,9},{'A',kAccA_u,9},{'e',kAccE,6},{'E',kAccE_u,6}, {'i',kAccI,4},{'I',kAccI_u,4},{'o',kAccO,6},{'O',kAccO_u,6}, {'u',kAccU,5},{'U',kAccU_u,5},{'n',kAccN,2},{'N',kAccN_u,2}, {'c',kAccC,3},{'C',kAccC_u,3},{'s',kAccS,4},{'S',kAccS_u,3},{'y',kAccY,2}, {'t',kAccT,2},{'T',kAccT_u,2},{'z',kAccZ,3},{'Z',kAccZ_u,3}, {'l',kAccL,1},{'L',kAccL_u,1},{'r',kAccR,1},{'R',kAccR_u,1}, }; static const AccentSet* accentLookup(const char* key) { if (!key || !key[0] || key[1]) return nullptr; // single ASCII-char keys only for (const auto& s : kAccentSets) if (s.key == key[0]) return &s; return nullptr; } static constexpr uint32_t kAccentCommitMs = 900; // pause that commits the choice static bool s_acc_active = false; static bool s_acc_first = false; // next VALUE_CHANGED is the base insert static char s_acc_base[6] = {0}; static const char* s_acc_opts[12]; static int s_acc_n = 0; static int s_acc_idx = 0; static char s_acc_anchor[256] = {0}; // text before the managed accent char static lv_obj_t* s_acc_popup = nullptr; static lv_obj_t* s_acc_cells[12] = {nullptr}; static lv_timer_t* s_acc_timer = nullptr; static void accentPopupHighlight() { for (int i = 0; i < s_acc_n; ++i) { if (!s_acc_cells[i]) continue; const bool sel = (i == s_acc_idx); lv_obj_set_style_bg_color(s_acc_cells[i], lv_color_hex(sel ? COLOR_ACCENT : 0x10202E), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_acc_cells[i], LV_OPA_COVER, LV_PART_MAIN); } } static void accentPopupHide() { if (s_acc_popup) { lv_obj_del(s_acc_popup); s_acc_popup = nullptr; } for (int i = 0; i < 12; ++i) s_acc_cells[i] = nullptr; } static void accentExit() { if (s_acc_timer) { lv_timer_del(s_acc_timer); s_acc_timer = nullptr; } accentPopupHide(); s_acc_active = false; s_acc_first = false; } static void accentCommitTimerCb(lv_timer_t* t) { (void)t; accentExit(); } static void accentPopupShow() { accentPopupHide(); // SC() so the box matches the scaled UI on the Tanmatsu (no-op at scale 100). const int cw = SC(30), ch = SC(30), gap = SC(4), pad = SC(6); s_acc_popup = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_acc_popup); // Passive display only — never a keyboard-nav focus target and never // clickable. Without this the nav collector steals focus onto the popup, // the focused-textarea lookup goes null and the ALT-accent cycle key stops // reaching its handler (issue #129 first test). lv_obj_add_flag(s_acc_popup, NAV_SKIP_FLAG); lv_obj_clear_flag(s_acc_popup, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_bg_color(s_acc_popup, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_acc_popup, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_acc_popup, 8, LV_PART_MAIN); lv_obj_set_style_border_color(s_acc_popup, lv_color_hex(0x2A3D52), LV_PART_MAIN); lv_obj_set_style_border_width(s_acc_popup, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_acc_popup, pad, LV_PART_MAIN); lv_obj_set_style_pad_column(s_acc_popup, gap, LV_PART_MAIN); lv_obj_set_flex_flow(s_acc_popup, LV_FLEX_FLOW_ROW); lv_obj_set_size(s_acc_popup, s_acc_n * cw + (s_acc_n - 1) * gap + pad * 2, ch + pad * 2); lv_obj_clear_flag(s_acc_popup, LV_OBJ_FLAG_SCROLLABLE); for (int i = 0; i < s_acc_n; ++i) { lv_obj_t* c = lv_obj_create(s_acc_popup); lv_obj_remove_style_all(c); lv_obj_add_flag(c, NAV_SKIP_FLAG); lv_obj_clear_flag(c, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_size(c, cw, ch); lv_obj_set_style_radius(c, 5, LV_PART_MAIN); lv_obj_clear_flag(c, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* l = lv_label_create(c); lv_label_set_text(l, s_acc_opts[i]); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); s_acc_cells[i] = c; } lv_obj_update_layout(s_acc_popup); const lv_coord_t kb_y = g_lv.keyboard ? lv_obj_get_y(g_lv.keyboard) : 0; lv_obj_align(s_acc_popup, LV_ALIGN_TOP_MID, 0, kb_y - lv_obj_get_height(s_acc_popup) - 6); accentPopupHighlight(); } // Re-assert the text as anchor + the selected option AFTER the keyboard's own // insert has run for this event, so we don't depend on cb-dispatch ordering and // fast taps still converge on the final choice (each call sets the whole tail). static void accentApplyAsync(void* unused) { (void)unused; if (!s_acc_active || !s_kb_mirror_ta) return; char buf[300]; snprintf(buf, sizeof buf, "%s%s", s_acc_anchor, s_acc_opts[s_acc_idx]); lv_textarea_set_text(s_kb_mirror_ta, buf); // fires mirror VALUE_CHANGED -> kbMirrorSyncToReal } static const char* kbSelectedKeyText() { if (!g_lv.keyboard) return nullptr; uint32_t id = lv_btnmatrix_get_selected_btn(g_lv.keyboard); if (id == LV_BTNMATRIX_BTN_NONE) return nullptr; return lv_btnmatrix_get_btn_text(g_lv.keyboard, id); } static void accentLongPressCb(lv_event_t* e) { // (dead on touch: LONG_PRESSED never reaches the keyboard) if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED || s_acc_active) return; const char* key = kbSelectedKeyText(); const AccentSet* set = accentLookup(key); if (!set) return; strncpy(s_acc_base, key, sizeof(s_acc_base) - 1); s_acc_base[sizeof(s_acc_base) - 1] = 0; s_acc_opts[0] = s_acc_base; s_acc_n = 1; for (uint8_t i = 0; i < set->n && s_acc_n < 12; ++i) s_acc_opts[s_acc_n++] = set->v[i]; s_acc_idx = 0; s_acc_active = true; s_acc_first = true; // the imminent release inserts the base (= option 0) accentPopupShow(); s_acc_timer = lv_timer_create(accentCommitTimerCb, kAccentCommitMs, nullptr); } // Called from keyboardCb on VALUE_CHANGED while a picker is open. static void accentHandleValueChanged() { if (!s_acc_active) return; const char* key = kbSelectedKeyText(); const bool is_base = key && s_acc_base[0] && strcmp(key, s_acc_base) == 0; if (!is_base) { accentExit(); return; } // a different key commits the choice if (s_acc_first) { // The base char was just inserted (on release); capture the text before it. s_acc_first = false; const char* t = s_kb_mirror_ta ? lv_textarea_get_text(s_kb_mirror_ta) : ""; size_t len = strlen(t); if (len > 0) len -= 1; // drop the trailing base (ASCII, 1 byte) if (len >= sizeof(s_acc_anchor)) len = sizeof(s_acc_anchor) - 1; memcpy(s_acc_anchor, t, len); s_acc_anchor[len] = 0; s_acc_idx = 0; } else { s_acc_idx = (s_acc_idx + 1) % s_acc_n; // cycle to the next variant lv_async_call(accentApplyAsync, nullptr); } accentPopupHighlight(); if (s_acc_timer) lv_timer_reset(s_acc_timer); } // =========================================================================== #if defined(HAS_TANMATSU) // ---- ALT-accent picker for the physical keyboard (issue #129) ------------- // The Tanmatsu has no touch and no on-screen keyboard, so the tap-to-pick // accent box is unreachable — umlauts and accents had NO input path at all. // Flow: hold ALT and press a letter with accent variants -> the accent popup // opens over the field with the first VARIANT highlighted (ALT+letter means // "I want the accented one", so the plain letter is the LAST option, not the // first); pressing the letter again while ALT stays down cycles the highlight // (key auto-repeat cycles too, so holding the letter walks the options); // releasing ALT — its own scancode event, handled at the top of navPump — // inserts the highlighted choice and closes the popup. Works with shift for // uppercase variants, in every language (kAccentSets is per-letter). // (State variables live next to the forward declarations above navPump.) static bool tanAltAccentHandleKey(char c, lv_obj_t* ta) { if (!ta || (uint8_t)c < 32) return false; const char keystr[2] = { c, 0 }; const AccentSet* set = accentLookup(keystr); if (!set) return s_altacc_active; // no variants: swallow strays mid-pick, else let it type if (s_altacc_active && s_altacc_key == c && s_acc_popup) { s_acc_idx = (s_acc_idx + 1) % s_acc_n; // same letter again: cycle the highlight accentPopupHighlight(); return true; } // First ALT+letter (or the letter changed mid-hold): (re)build the options. s_acc_n = 0; for (uint8_t i = 0; i < set->n && s_acc_n < 11; ++i) s_acc_opts[s_acc_n++] = set->v[i]; strncpy(s_acc_base, keystr, sizeof(s_acc_base) - 1); s_acc_base[sizeof(s_acc_base) - 1] = 0; s_acc_opts[s_acc_n++] = s_acc_base; // plain letter last (escape hatch) s_acc_idx = 0; s_altacc_active = true; s_altacc_key = c; s_altacc_ta = ta; accentPopupShow(); // accentPopupShow anchors to the on-screen keyboard, which this board doesn't // have — re-anchor just above the field being edited (below it if cramped), // centred via align (manual x math misplaced it under the scaled UI). if (s_acc_popup) { lv_obj_update_layout(s_acc_popup); lv_area_t a; lv_obj_get_coords(ta, &a); lv_coord_t by = a.y1 - lv_obj_get_height(s_acc_popup) - SC(4); if (by < STATUSBAR_H + 2) by = a.y2 + SC(4); lv_obj_align(s_acc_popup, LV_ALIGN_TOP_MID, 0, by); } return true; } static void tanAltAccentAltReleased() { if (!s_altacc_active) return; lv_obj_t* ta = s_altacc_ta; if (ta && lv_obj_is_valid(ta) && s_acc_idx >= 0 && s_acc_idx < s_acc_n) lv_textarea_add_text(ta, s_acc_opts[s_acc_idx]); accentPopupHide(); s_altacc_active = false; s_altacc_key = 0; s_altacc_ta = nullptr; } #endif // HAS_TANMATSU ALT-accent picker // ---- "Alt" accent key (issue #22) ---------------------------------------- // The touch keyboard can't do long-press (the cap-touch driver finalizes taps // before LVGL's long-press fires), so accents are entered with a dedicated // floating "Alt" key: type a letter, then tap Alt to cycle the character before // the cursor to its next accent variant in place (à->á->â->...->base). Glyphs // already live in the extras_* fallback fonts. static const char* lastUtf8Start(const char* s) { size_t n = s ? strlen(s) : 0; if (!n) return nullptr; size_t i = n - 1; while (i > 0 && (static_cast<unsigned char>(s[i]) & 0xC0u) == 0x80u) --i; // skip UTF-8 continuation bytes return s + i; } static void accentAltCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_kb_mirror_ta) return; const char* last = lastUtf8Start(lv_textarea_get_text(s_kb_mirror_ta)); if (!last) return; for (const auto& set : kAccentSets) { char base[2] = { set.key, 0 }; int idx = -1; if (strcmp(last, base) == 0) idx = 0; else for (uint8_t i = 0; i < set.n; ++i) if (strcmp(last, set.v[i]) == 0) { idx = i + 1; break; } if (idx < 0) continue; // last char not in this cycle const int next = (idx + 1) % (set.n + 1); // wrap: ...->last accent->base->... const char* repl = (next == 0) ? base : set.v[next - 1]; lv_textarea_del_char(s_kb_mirror_ta); // remove the char before the cursor lv_textarea_add_text(s_kb_mirror_ta, repl); kbMirrorSyncToReal(); return; } } // ---- Accent box: tap-to-pick accents (issue #22) ------------------------- // When a letter with accent variants is typed (on-screen OR physical keyboard), // a floating box of its variants pops up; tapping one replaces the just-typed // letter. Touch-driven, so it works on both keyboards. Both paths type into // lv_keyboard_get_textarea(), so one trigger covers them. Dismissed by the next // keystroke / a pick / hiding the keyboard. static lv_obj_t* s_accbox = nullptr; static lv_obj_t* s_accbox_ta = nullptr; // the field the box edits #if defined(TLORA_PAGER) // No touch on this board, and the cells below are NAV_SKIP_FLAG (excluded from // the normal keyboard/encoder focus group by design, since touch boards pick // them by tap) -- without this, the box is completely unreachable here. Fn // (Alt)+Space jumps in (handleHwKey()); the rotary encoder then walks // s_accbox_cells (updatePagerEncoder()); Enter (the encoder's own click) // confirms via accentNavConfirm(); Backspace cancels. static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (8, 'a'/'A') static lv_obj_t* s_accbox_cells[kAccentNavMax]; static uint8_t s_accbox_cell_n = 0; static bool s_accentnav_active = false; static int s_accentnav_idx = 0; #endif static const AccentSet* accentSetFor(char c) { for (const auto& s : kAccentSets) if (s.key == c) return &s; return nullptr; } static void accentBoxHide() { if (s_accbox) { lv_obj_del(s_accbox); s_accbox = nullptr; } s_accbox_ta = nullptr; #if defined(TLORA_PAGER) s_accentnav_active = false; s_accbox_cell_n = 0; #endif } #if defined(TLORA_PAGER) static void accentNavRestyle() { for (uint8_t i = 0; i < s_accbox_cell_n; ++i) { if (!s_accbox_cells[i]) continue; lv_obj_set_style_bg_color(s_accbox_cells[i], lv_color_hex((int)i == s_accentnav_idx ? COLOR_ACCENT : 0x1B2B3A), LV_PART_MAIN); } } // Encoder's short-click while picking: fire the highlighted cell's own CLICKED // binding (accentBoxCellCb, below) rather than duplicating its delete-base- // letter + insert-variant + accentBoxHide() logic. static void accentNavConfirm() { if (s_accentnav_idx >= 0 && (uint8_t)s_accentnav_idx < s_accbox_cell_n && s_accbox_cells[s_accentnav_idx]) { lv_event_send(s_accbox_cells[s_accentnav_idx], LV_EVENT_CLICKED, nullptr); } else { accentBoxHide(); } } #endif static void accentBoxCellCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* variant = static_cast<const char*>(lv_event_get_user_data(e)); lv_obj_t* ta = s_accbox_ta; if (ta && variant) { lv_textarea_del_char(ta); // remove the just-typed base letter lv_textarea_add_text(ta, variant); // insert the chosen accent if (ta == s_kb_mirror_ta) kbMirrorSyncToReal(); } accentBoxHide(); } static void accentBoxMaybeShow() { accentBoxHide(); // each new keystroke clears the last box #if defined(HAS_M9_KEYBOARD) // Never on the M9: the box is tap-to-pick (its cells are NAV_SKIP_FLAG by // design) and the key-selection machinery is pager-only (encoder walk + // Enter) — with no touch and no nav path here it would float over the // composer as dead chrome nothing can select. Deliberately suppressed // until an M9 d-pad selection path is built; the Accent-popups settings // row is hidden there for the same reason. return; #endif if (!s_accent_popups) return; // user turned accent popups off in settings if (!g_lv.keyboard) return; lv_obj_t* ta = lv_keyboard_get_textarea(g_lv.keyboard); if (!ta) return; const char* last = lastUtf8Start(lv_textarea_get_text(ta)); if (!last || last[1]) return; // last char must be a single ASCII byte const AccentSet* set = accentSetFor(last[0]); if (!set) return; s_accbox_ta = ta; #if defined(TLORA_PAGER) s_accentnav_active = false; // fresh box -> Fn+Space (re-)arms nav mode s_accentnav_idx = 0; s_accbox_cell_n = set->n < kAccentNavMax ? set->n : (uint8_t)kAccentNavMax; #endif const int cw = 34, ch = 40, gap = 4, pad = 6; s_accbox = lv_obj_create(lv_layer_top()); lv_obj_add_flag(s_accbox, NAV_SKIP_FLAG); // passive tap-only hint: never a keyboard-nav focus target (issue #22) lv_obj_remove_style_all(s_accbox); lv_obj_set_style_bg_color(s_accbox, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_accbox, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_accbox, 8, LV_PART_MAIN); lv_obj_set_style_border_color(s_accbox, lv_color_hex(0x2A3D52), LV_PART_MAIN); lv_obj_set_style_border_width(s_accbox, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_accbox, pad, LV_PART_MAIN); lv_obj_set_style_pad_column(s_accbox, gap, LV_PART_MAIN); lv_obj_set_flex_flow(s_accbox, LV_FLEX_FLOW_ROW); lv_obj_set_size(s_accbox, set->n * cw + (set->n - 1) * gap + pad * 2, ch + pad * 2); lv_obj_clear_flag(s_accbox, LV_OBJ_FLAG_SCROLLABLE); for (uint8_t i = 0; i < set->n; ++i) { lv_obj_t* c = lv_btn_create(s_accbox); lv_obj_add_flag(c, NAV_SKIP_FLAG); // tappable, but never a keyboard-nav focus stop lv_obj_set_size(c, cw, ch); lv_obj_set_style_radius(c, 5, LV_PART_MAIN); lv_obj_set_style_bg_color(c, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_bg_color(c, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(c, accentBoxCellCb, LV_EVENT_CLICKED, (void*)set->v[i]); #if defined(TLORA_PAGER) if (i < kAccentNavMax) s_accbox_cells[i] = c; #endif lv_obj_t* l = lv_label_create(c); lv_label_set_text(l, set->v[i]); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); } // Place it just above the field being edited (below if there's no room above). lv_obj_update_layout(s_accbox); lv_area_t a; lv_obj_get_coords(ta, &a); const lv_coord_t bw = lv_obj_get_width(s_accbox), bh = lv_obj_get_height(s_accbox); lv_coord_t bx = (lv_disp_get_hor_res(nullptr) - bw) / 2; lv_coord_t by = a.y1 - bh - 4; if (by < STATUSBAR_H + 2) by = a.y2 + 4; // Never let the box land on the on-screen keyboard. The field it anchors to may // be the chat composer (which sits right above the keys), and on a rotate that // field's live coords lag — so clamp the box's bottom above the keyboard top // (computed from the current rotation) and above the composer too in a chat. if (g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN)) { lv_coord_t limit = lv_disp_get_ver_res(nullptr) - chatKbH() - 2; if (s_kb_panel) limit -= s_comp_h; // chat: also clear the composer above the keys if (by + bh > limit) by = limit - bh; if (by < STATUSBAR_H + 2) by = STATUSBAR_H + 2; } lv_obj_set_pos(s_accbox, bx, by); lv_obj_move_foreground(s_accbox); } // ---- @-mention contact picker (issue #42) -------------------------------- // Typing "@name" in a chat composer pops a list of matching recently-heard // chat identities. It is modeled on the accent box: a passive overlay // (NAV_SKIP_FLAG) that never steals keyboard-nav focus. Touch or the board's // hardware selector replaces the active token with the chosen full name. static lv_obj_t* s_mentionbox = nullptr; static lv_obj_t* s_mentionbox_ta = nullptr; static constexpr int k_mention_max = 6; static constexpr int k_mention_source_max = 16; static char s_mention_names[k_mention_max][32]; // kept alive for the cell callbacks #if CAP_KEYPAD_NAV // The rows stay outside the normal focus group so rebuilding this per keystroke // never steals focus from the composer. Each board routes its selector into // this private state: trackball, Pager encoder, M9 d-pad, or Tanmatsu arrows. static lv_obj_t* s_mentionbox_cells[k_mention_max]; static uint8_t s_mentionbox_cell_n = 0; static bool s_mentionnav_active = false; static int s_mentionnav_idx = 0; static bool mentionNavActive() { return s_mentionnav_active; } #endif static void mentionBoxHide() { if (s_mentionbox) { lv_obj_del(s_mentionbox); s_mentionbox = nullptr; } s_mentionbox_ta = nullptr; #if CAP_KEYPAD_NAV s_mentionnav_active = false; s_mentionbox_cell_n = 0; #endif } #if CAP_KEYPAD_NAV static void mentionNavRestyle() { for (uint8_t i = 0; i < s_mentionbox_cell_n; ++i) { if (!s_mentionbox_cells[i]) continue; lv_obj_set_style_bg_color(s_mentionbox_cells[i], lv_color_hex((int)i == s_mentionnav_idx ? COLOR_ACCENT : 0x1B2B3A), LV_PART_MAIN); } } static void mentionNavMove(int delta) { if (!s_mentionnav_active || s_mentionbox_cell_n == 0 || delta == 0) return; while (delta > 0) { s_mentionnav_idx = (s_mentionnav_idx + 1) % (int)s_mentionbox_cell_n; --delta; } while (delta < 0) { s_mentionnav_idx = (s_mentionnav_idx - 1 + (int)s_mentionbox_cell_n) % (int)s_mentionbox_cell_n; ++delta; } mentionNavRestyle(); } // Encoder's short-click while picking: fire the highlighted cell's own CLICKED // binding (mentionBoxCellCb, below) rather than duplicating its delete-partial // + insert-name + mentionBoxHide() logic. static void mentionNavConfirm() { if (s_mentionnav_idx >= 0 && (uint8_t)s_mentionnav_idx < s_mentionbox_cell_n && s_mentionbox_cells[s_mentionnav_idx]) { lv_event_send(s_mentionbox_cells[s_mentionnav_idx], LV_EVENT_CLICKED, nullptr); } else { mentionBoxHide(); } } #endif static uint32_t taCpToByte(const char* txt, uint32_t cp_target); static uint32_t taByteToCp(const char* txt, uint32_t byte_target); static int collectRecentMentionNames(char out[][32], int max_num) { if (!out || max_num <= 0) return 0; RecentlyHeardName heard[k_mention_source_max]; const int heard_n = the_mesh.getRecentlyHeard(heard, k_mention_source_max); const uint8_t* self = the_mesh.getSelfPubKey(); int count = 0; for (int i = 0; i < heard_n && count < max_num; ++i) { if (heard[i].type != ADV_TYPE_CHAT || !heard[i].name[0]) continue; if (self && memcmp(heard[i].pubkey_prefix, self, sizeof(heard[i].pubkey_prefix)) == 0) continue; if (the_mesh.uiIsMeshcomodRecipient(heard[i].pubkey_prefix)) continue; if (touchPrefsIsIgnored(heard[i].pubkey_prefix)) continue; bool duplicate = false; for (int j = 0; j < count; ++j) { if (strcasecmp(out[j], heard[i].name) == 0) { duplicate = true; break; } } if (duplicate) continue; strlcpy(out[count++], heard[i].name, 32); } return count; } static bool mentionWhitespace(unsigned char c) { return c == ' ' || c == '\n' || c == '\t' || c == '\r'; } static bool mentionPunctuation(unsigned char c) { switch (c) { case ',': case '.': case ';': case ':': case '!': case '?': case '(': case ')': case '[': case ']': case '{': case '}': case '<': case '>': return true; default: return false; } } struct MentionToken { size_t at; size_t caret; size_t end; }; // Locate the active @token around the actual caret. The start must be at text // start or after whitespace, so an email address never opens the picker. static bool mentionTokenAtCaret(lv_obj_t* ta, MentionToken& token) { if (!ta) return false; const char* text = lv_textarea_get_text(ta); if (!text) return false; const size_t len = strlen(text); size_t caret = taCpToByte(text, lv_textarea_get_cursor_pos(ta)); if (caret > len) caret = len; size_t at = caret; bool found = false; while (at > 0) { const unsigned char c = (unsigned char)text[at - 1]; if (c == '@') { --at; found = true; break; } if (mentionWhitespace(c) || mentionPunctuation(c)) return false; --at; } if (!found || (at > 0 && !mentionWhitespace((unsigned char)text[at - 1]))) return false; size_t end = caret; while (end < len && !mentionWhitespace((unsigned char)text[end]) && !mentionPunctuation((unsigned char)text[end])) ++end; token = { at, caret, end }; return true; } static void mentionBoxCellCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* name = static_cast<const char*>(lv_event_get_user_data(e)); lv_obj_t* ta = s_mentionbox_ta; if (ta && name && name[0]) { const char* text = lv_textarea_get_text(ta); MentionToken token{}; if (text && mentionTokenAtCaret(ta, token)) { char out[UITask::MAX_MSG_TEXT + 1]; const size_t name_len = strlen(name); const size_t suffix_len = strlen(text + token.end); const bool add_space = text[token.end] == '\0'; const size_t needed = token.at + 1 + name_len + (add_space ? 1 : 0) + suffix_len; if (needed <= UITask::MAX_MSG_TEXT) { size_t pos = 0; memcpy(out + pos, text, token.at); pos += token.at; out[pos++] = '@'; memcpy(out + pos, name, name_len); pos += name_len; if (add_space) out[pos++] = ' '; const size_t caret_byte = pos; memcpy(out + pos, text + token.end, suffix_len + 1); mentionBoxHide(); // setting text emits VALUE_CHANGED; tear down first if (ta == s_kb_mirror_ta) { lv_textarea_set_text(ta, out); lv_textarea_set_cursor_pos(ta, (int32_t)taByteToCp(out, caret_byte)); kbMirrorSyncToReal(); if (s_kb_bind_ta) lv_textarea_set_cursor_pos(s_kb_bind_ta, (int32_t)taByteToCp(out, caret_byte)); } else { setTextareaSynced(ta, out); lv_textarea_set_cursor_pos(ta, (int32_t)taByteToCp(out, caret_byte)); if (s_kb_bind_ta == ta && s_kb_mirror_ta) lv_textarea_set_cursor_pos(s_kb_mirror_ta, (int32_t)taByteToCp(out, caret_byte)); } return; } } } mentionBoxHide(); } // Show the recent-name picker for the @token at the caret. Returns true // when a picker is shown (so the caller suppresses the accent box). static bool mentionBoxMaybeShow(lv_obj_t* ta) { mentionBoxHide(); if (!ta) return false; const char* text = lv_textarea_get_text(ta); MentionToken token{}; if (!text || !mentionTokenAtCaret(ta, token)) return false; const char* partial = text + token.at + 1; const size_t plen = token.caret - token.at - 1; char recent[k_mention_source_max][32]; const int recent_n = collectRecentMentionNames(recent, k_mention_source_max); int n = 0; for (int i = 0; i < recent_n && n < k_mention_max; ++i) { if (plen > 0 && strncasecmp(recent[i], partial, plen) != 0) continue; strlcpy(s_mention_names[n], recent[i], sizeof s_mention_names[n]); ++n; } if (n == 0) return false; s_mentionbox_ta = ta; #if CAP_KEYPAD_NAV s_mentionnav_active = true; s_mentionnav_idx = 0; s_mentionbox_cell_n = (uint8_t)n; #endif s_mentionbox = lv_obj_create(lv_layer_top()); lv_obj_add_flag(s_mentionbox, NAV_SKIP_FLAG); // passive, touch-only — never a keyboard-nav stop (issue #42) lv_obj_remove_style_all(s_mentionbox); lv_obj_set_style_bg_color(s_mentionbox, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_mentionbox, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_mentionbox, 8, LV_PART_MAIN); lv_obj_set_style_border_color(s_mentionbox, lv_color_hex(0x2A3D52), LV_PART_MAIN); lv_obj_set_style_border_width(s_mentionbox, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_mentionbox, 4, LV_PART_MAIN); lv_obj_set_style_pad_row(s_mentionbox, 3, LV_PART_MAIN); lv_obj_set_flex_flow(s_mentionbox, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(s_mentionbox, LV_OBJ_FLAG_SCROLLABLE); const int rowh = 30, boxw = 168; for (int i = 0; i < n; ++i) { lv_obj_t* b = lv_btn_create(s_mentionbox); lv_obj_add_flag(b, NAV_SKIP_FLAG); lv_obj_set_size(b, boxw, rowh); lv_obj_set_style_radius(b, 5, LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, mentionBoxCellCb, LV_EVENT_CLICKED, (void*)s_mention_names[i]); #if CAP_KEYPAD_NAV if (i < k_mention_max) s_mentionbox_cells[i] = b; #endif lv_obj_t* l = lv_label_create(b); lv_label_set_text_fmt(l, "@%s", s_mention_names[i]); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, boxw - 18); lv_obj_center(l); } lv_obj_set_size(s_mentionbox, boxw + 8, n * rowh + (n - 1) * 3 + 8); #if CAP_KEYPAD_NAV mentionNavRestyle(); #endif // Place it above the composer, clamped above the keyboard (mirrors the accent box). lv_obj_update_layout(s_mentionbox); lv_area_t a; lv_obj_get_coords(ta, &a); const lv_coord_t bw = lv_obj_get_width(s_mentionbox), bh = lv_obj_get_height(s_mentionbox); lv_coord_t bx = (lv_disp_get_hor_res(nullptr) - bw) / 2; lv_coord_t by = a.y1 - bh - 4; if (g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN)) { lv_coord_t limit = lv_disp_get_ver_res(nullptr) - chatKbH() - 2; if (s_kb_panel) limit -= s_comp_h; if (by + bh > limit) by = limit - bh; } if (by < STATUSBAR_H + 2) by = STATUSBAR_H + 2; lv_obj_set_pos(s_mentionbox, bx, by); lv_obj_move_foreground(s_mentionbox); return true; } static void composerMentionRefresh(lv_obj_t* ta) { if (mentionBoxMaybeShow(ta)) { accentBoxHide(); return; } mentionBoxHide(); } static void composerSuggestChangedCb(lv_event_t* e) { lv_obj_t* ta = lv_event_get_target(e); if (kbMirrorActive() && s_kb_bind_ta == ta && s_kb_mirror_ta) ta = s_kb_mirror_ta; composerMentionRefresh(ta); } static void keyboardCb(lv_event_t* e) { lv_event_code_t code = lv_event_get_code(e); #if defined(HAS_ATTAKY_MESH_KEYBOARD) if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { accentExit(); accentBoxHide(); // Dismissing the keys must not kill the module: hideKb() unbinds the textarea // the module's scan is gated on. Re-bind after hiding so the keys go down but // the module keeps typing, the state a suppressed field starts in. LvChatPanel* const kb_panel = s_kb_panel; lv_obj_t* const kb_field = s_kb_bind_ta; hideKb(); if (attakyKeyboardPresent()) { if (kb_panel && kb_panel->composer_ta && lv_obj_is_valid(kb_panel->composer_ta)) showKb(kb_panel); // chat: rebind + refocus the composer, keys stay down else if (kb_field && lv_obj_is_valid(kb_field)) kbMirrorBind(kb_field); // settings-style field: straight back to the direct bind } } #else if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { accentExit(); accentBoxHide(); hideKb(); } #endif // VALUE_CHANGED fires for any keypress (incl. backspace). Fade the rotate // arrows down to ~20% so they don't compete visually with the text the // user is typing. Reset to full opacity on the next showKb / kbMirrorBind. else if (code == LV_EVENT_VALUE_CHANGED) { kbSetRotateArrowsOpa(LV_OPA_20); accentHandleValueChanged(); if (!s_mentionbox) accentBoxMaybeShow(); } } static void composerFocusCb(lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_FOCUSED && code != LV_EVENT_CLICKED) return; if (code == LV_EVENT_CLICKED) { lv_indev_t* act = lv_indev_get_act(); if (act && (lv_indev_get_scroll_obj(act) || lv_indev_get_scroll_dir(act) != LV_DIR_NONE)) return; } auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (p && p->detail_open) { showKb(p); noteKbActivity(); } } // Discord-style unread divider state. s_unread_at_open is the thread's unread // count captured the instant the chat was opened (in setActiveThread, before it // clears unread). refreshChatDetail draws a "New messages" divider above the // first unread message and, on the first refresh after opening, scrolls to it // instead of the bottom. Both cleared on leave (backBtnCb). static uint16_t s_unread_at_open = 0; static bool s_chat_just_opened = false; static int s_chat_jump_msg_idx = -1; // ring-slot index to scroll to on open (tapped @mention), or -1 // Close an open chat/channel detail panel → back to the thread list. Shared by // the floating HOME button (backBtnCb) and the left→right swipe-back gesture. static void closeChatPanel(LvChatPanel* p) { if (!p || !p->detail_open) return; // already closed (e.g. a repeat fire) hideKb(); if (p->overlay) lv_obj_add_flag(p->overlay, LV_OBJ_FLAG_HIDDEN); p->detail_open = false; // Mark the active thread read on close so messages that arrived while the // chat was open don't leave a stale unread badge in the inbox. Also force // a list rebuild so the badge clears immediately instead of waiting for the // next 4-second periodic refresh. if (g_lv.task) g_lv.task->markThreadRead(g_lv.task->activeThreadIdx()); g_lv.dirty_threads = true; s_unread_at_open = 0; // clear the unread divider when leaving the chat s_chat_just_opened = false; chatVirtReset(p); if (p->jump_btn) lv_obj_add_flag(p->jump_btn, LV_OBJ_FLAG_HIDDEN); if (p->jump_oldest_btn) lv_obj_add_flag(p->jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); setChatStatusTitle(nullptr); // drop the thread name from the status bar } static void backBtnCb(lv_event_t* e) { // Fire on PRESSED rather than CLICKED so the back action triggers on // touch-down instead of touch-up. CLICKED was sometimes dropped because // the cap-touch driver's horizontal-swipe detector calls // lv_indev_wait_release mid-tap, which aborts the LVGL click on the // freshly-pressed button. Triggering on PRESSED side-steps that race. const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_PRESSED && code != LV_EVENT_CLICKED) return; closeChatPanel(static_cast<LvChatPanel*>(lv_event_get_user_data(e))); } // Long-press → delete-this-chat confirmation popup. The popup uses the // existing showConfirm helper, so we stash the thread context in module- // static state and consume it inside the OK callback. static int s_chat_del_thread_idx = -1; static bool s_chat_del_is_channel = false; // Modal that shows the channel's 32-hex PSK with a Copy action, so the user // can share it out-of-band (QR / chat / paper). Read-only. static char s_channel_share_secret_hex[33] = {0}; static char s_channel_share_name[33] = {0}; static void channelShareCopyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; clipboardSet(s_channel_share_secret_hex, "secret"); } static void openChannelShareModal(const char* channel_name, const uint8_t secret[16]) { // Render hex once and stash it for the Copy button. for (int i = 0; i < 16; ++i) { snprintf(s_channel_share_secret_hex + i*2, 3, "%02x", secret[i]); } strncpy(s_channel_share_name, channel_name ? channel_name : "Channel", sizeof(s_channel_share_name) - 1); lv_obj_t* body = createSettingsModal(TR("Share channel"), SettingsModalKind::AddContact); // reuse a kind int y = 0; lv_obj_t* name_l = lv_label_create(body); lv_label_set_text_fmt(name_l, LV_SYMBOL_LOOP " %s", s_channel_share_name); lv_obj_set_style_text_color(name_l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(name_l, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(name_l, 2, y); y += 26; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("32-hex secret. Anyone with this can read the channel.")); lv_obj_set_pos(hint, 2, y); y += 32; lv_obj_t* sec_lbl = lv_label_create(body); lv_label_set_long_mode(sec_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(sec_lbl, lv_pct(100)); lv_label_set_text(sec_lbl, s_channel_share_secret_hex); lv_obj_set_style_text_color(sec_lbl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(sec_lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_bg_color(sec_lbl, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_bg_opa(sec_lbl, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(sec_lbl, 6, LV_PART_MAIN); lv_obj_set_style_radius(sec_lbl, 4, LV_PART_MAIN); lv_obj_set_pos(sec_lbl, 2, y); // Long-press copies too — handy if the Copy button is hidden by the keyboard. lv_obj_add_flag(sec_lbl, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(sec_lbl, copyLabelLongPressCb, LV_EVENT_LONG_PRESSED, const_cast<char*>("secret")); y += 52; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, channelShareCopyCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Copy secret")); lv_obj_center(bl); } // Per-channel long-press sheet: [Share secret] [Remove channel] [Cancel]. static lv_obj_t* s_channel_long_sheet = nullptr; static int s_channel_long_idx = -1; static void closeChannelLongSheet() { popupClose(&s_channel_long_sheet); } static void channelLongSheetDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeChannelLongSheet(); } static void channelLongSheetShareCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = s_channel_long_idx; closeChannelLongSheet(); if (t < 0 || !g_lv.task) return; bool ch; uint16_t unread; uint32_t ts; char tname[UITask::MAX_THREAD_NAME + 1]; if (!g_lv.task->getThreadInfo(t, ch, unread, ts, tname, sizeof(tname)) || !ch) return; #ifdef MAX_GROUP_CHANNELS ChannelDetails cd{}; for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { if (the_mesh.getChannel(i, cd) && cd.name[0] && strncmp(cd.name, tname, sizeof(cd.name)) == 0) { openChannelShareModal(cd.name, cd.channel.secret); return; } } #endif if (g_lv.task) g_lv.task->showAlert(TR("Channel not found"), 1200); } static void channelLongSheetDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeChannelLongSheet(); bool ch; uint16_t unread; uint32_t ts; char tname[UITask::MAX_THREAD_NAME + 1]; if (!g_lv.task->getThreadInfo(s_chat_del_thread_idx, ch, unread, ts, tname, sizeof(tname))) return; char msg[80]; snprintf(msg, sizeof(msg), TR("Remove channel \"%s\"?\nLeaves it on this device only."), tname); showConfirm(msg, TR("Remove"), chatDeleteApply); } static void threadSheetMarkReadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = s_channel_long_idx; closeChannelLongSheet(); if (t < 0 || !g_lv.task) return; g_lv.task->markThreadRead(t); g_lv.dirty_threads = true; g_lv.task->showAlert(TR("Marked read"), 900); } static void threadSheetDeleteDmCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = s_channel_long_idx; closeChannelLongSheet(); if (t < 0 || !g_lv.task) return; bool ch; uint16_t unread; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!g_lv.task->getThreadInfo(t, ch, unread, ts, name, sizeof(name))) return; s_chat_del_thread_idx = t; s_chat_del_is_channel = false; char msg[96]; snprintf(msg, sizeof(msg), TR("Delete chat with \"%s\"?\nMessage history will be cleared."), name); showConfirm(msg, TR("Delete"), chatDeleteApply); } // Per-channel mute toggles (in the channel action sheet). Mute suppresses the // notification SOUND for that channel; the unread badge/divider still work. static lv_obj_t* s_chmute_msg_btn = nullptr; static lv_obj_t* s_chmute_men_btn = nullptr; static bool channelLongSheetName(char* out, size_t outsz) { if (s_channel_long_idx < 0 || !g_lv.task) return false; bool ch; uint16_t un; uint32_t ts; return g_lv.task->getThreadInfo(s_channel_long_idx, ch, un, ts, out, outsz); } static void chmuteRefreshLabels() { char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!channelLongSheetName(name, sizeof name)) return; const uint8_t f = touchPrefsGetChannelMute(name); // Short forms — these sit in the sheet's half-width grid cells now ("Unmute // messages" no longer fits); the pair shares one row, so "@" reads as mentions. if (s_chmute_msg_btn) { lv_obj_t* l = lv_obj_get_child(s_chmute_msg_btn, 0); const bool m = (f & TOUCH_CHMUTE_MSG); if (l) lv_label_set_text_fmt(l, "%s %s", m ? LV_SYMBOL_MUTE : LV_SYMBOL_AUDIO, m ? TR("Unmute msgs") : TR("Mute msgs")); } if (s_chmute_men_btn) { lv_obj_t* l = lv_obj_get_child(s_chmute_men_btn, 0); const bool m = (f & TOUCH_CHMUTE_MEN); if (l) lv_label_set_text_fmt(l, "%s %s", m ? LV_SYMBOL_MUTE : LV_SYMBOL_AUDIO, m ? TR("Unmute @") : TR("Mute @")); } } static void channelMuteMsgCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!channelLongSheetName(name, sizeof name)) return; touchPrefsSetChannelMute(name, touchPrefsGetChannelMute(name) ^ TOUCH_CHMUTE_MSG); chmuteRefreshLabels(); } static void channelMuteMenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!channelLongSheetName(name, sizeof name)) return; touchPrefsSetChannelMute(name, touchPrefsGetChannelMute(name) ^ TOUCH_CHMUTE_MEN); chmuteRefreshLabels(); } // "Region & scope" entry — opens the per-channel region-scope detail sheet (finds the slot from // the thread name). Same screen the in-channel cog opens. static void channelLongSheetRegionCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = s_channel_long_idx; closeChannelLongSheet(); if (t < 0 || !g_lv.task) return; bool ch; uint16_t un; uint32_t ts; char tname[UITask::MAX_THREAD_NAME + 1] = ""; if (!g_lv.task->getThreadInfo(t, ch, un, ts, tname, sizeof(tname)) || !ch) return; #ifdef MAX_GROUP_CHANNELS ChannelDetails cd{}; for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { if (the_mesh.getChannel(i, cd) && cd.name[0] && strncmp(cd.name, tname, sizeof(cd.name)) == 0) { openChannelScopeModal(i, tname); return; } } #endif if (g_lv.task) g_lv.task->showAlert(TR("Channel not found"), 1200); } // "Blocked users" entry — opens the ignore-list manager. static void channelLongSheetBlockedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeChannelLongSheet(); openBlockedUsersModal(); } // "Log in again" (room threads) — blank re-login to the room server. Passwordless // works for a client the room already knows (LOGIN_OK with stored permissions, // replay-guard-free; a flooded one also refreshes the server's path to us). The // LOGIN_OK re-arms our keep-alive, whose first REQ clears the server's // push-abandon counter — resuming a room that went one-way-dead (issue #89: // "no possibility to log in once it is done"). A REBOOTED server forgets // non-admin clients, and a blank login cannot recreate a record the server no // longer has: it simply never replies. roomReloginBegin() therefore arms the // no-reply watchdog, which escalates to the passworded Join prompt in place // rather than leaving the operator on a toast with no result (#267). static void threadSheetRoomLoginCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const int idx = s_channel_long_idx; closeChannelLongSheet(); uint8_t pub[32]; if (g_lv.task->getThreadContactPub(idx, pub)) roomReloginBegin(pub); else g_lv.task->showAlert(TR("Couldn't send login"), 1400); } // "Join with password" (room threads) — the recovery that actually recreates a // forgotten client record, previously reachable only from the Contacts sheet // even though a room with a thread is something you reach from Chats (#267). static void threadSheetRoomJoinCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const int idx = s_channel_long_idx; closeChannelLongSheet(); uint8_t pub[32]; if (g_lv.task->getThreadContactPub(idx, pub)) openRoomJoinPrompt(pub, /*after_relogin_failed=*/false); else g_lv.task->showAlert(TR("Contact gone"), 1200); } // "Reset path" (DM sheet) — wipes the cached return path for this chat's contact so // the next send re-floods, without the detour through the Contacts tab (wyvern.red // feedback; same action as the contact sheet's Reset path). // "Delete history" (chat/channel settings sheet): tombstone every message of the // thread after a confirm. The thread row itself stays (the channel stays joined, // the contact stays) — this only wipes the conversation content. static int s_hist_clear_thread = -1; static void histClearApply() { if (!g_lv.task || s_hist_clear_thread < 0) return; const int idx = s_hist_clear_thread; s_hist_clear_thread = -1; const int cleared = g_lv.task->clearThreadHistory(idx); refreshThreadLists(); // cleared history vanishes instantly if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); // Persist synchronously so the cleared chat stays cleared across an immediate hard // reset; the compact write keeps it fast enough not to hitch the UI. if (cleared) g_lv.task->persistHistoryNow(); char msg[40]; snprintf(msg, sizeof msg, TR("Deleted %d messages"), cleared); g_lv.task->showAlert(msg, 1400); } static void threadSheetClearHistCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const int idx = s_channel_long_idx; closeChannelLongSheet(); s_hist_clear_thread = idx; showConfirm(TR("Delete this chat's entire history?\nThe chat itself stays."), TR("Delete"), histClearApply); } static void threadSheetResetPathCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const int idx = s_channel_long_idx; closeChannelLongSheet(); uint8_t pub[32]; if (g_lv.task->getThreadContactPub(idx, pub) && the_mesh.uiResetContactPath(pub)) { g_lv.task->showAlert(TR("Path reset"), 1000); refreshContactsList(); } else { g_lv.task->showAlert(TR("Path reset failed"), 1200); } } // Chat-icon chooser (channels): tap picks an emoji for the chat-list avatar via the // emoji sheet in pick mode; long-press resets to the automatic two-letter avatar. static void openEmojiPickerPick(void (*cb)(const char* utf8), const char* title); // defined with the emoji sheet below static void threadSheetIconPicked(const char* g) { char nm[UITask::MAX_THREAD_NAME + 1] = ""; if (!channelLongSheetName(nm, sizeof nm)) return; touchPrefsSetChannelEmoji(nm, g); g_lv.dirty_threads = true; // chat list re-renders the avatar if (g_lv.task) g_lv.task->showAlert(TR("Chat icon set"), 1200); } static void threadSheetIconCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openEmojiPickerPick(threadSheetIconPicked, "Chat icon"); } static void threadSheetIconResetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; char nm[UITask::MAX_THREAD_NAME + 1] = ""; if (!channelLongSheetName(nm, sizeof nm)) return; touchPrefsSetChannelEmoji(nm, ""); g_lv.dirty_threads = true; if (g_lv.task) g_lv.task->showAlert(TR("Chat icon reset to letters"), 1400); } // Per-thread settings sheet (DM or channel) — opened by long-press AND the per-row gear AND the // in-channel cog, so all are the SAME screen. Mark-as-read, mute messages / @ mentions, region & // scope, share secret, blocked users, remove/delete. // Laid out exactly like the contact long-press sheet (openContactActionSheet): a half-width // 2-column grid + a full-width danger row, so every action fits on ONE screen — the old 42px // full-width rows overflowed and scrolled on both S3 boards (7 channel rows = ~380px vs the // T-Deck's ~206px visible area). static void openThreadActionSheet(int thread_idx, const char* name, bool is_channel) { closeChannelLongSheet(); s_channel_long_idx = thread_idx; s_channel_long_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_channel_long_sheet); // Backdrop sits below the global status bar — keeps the centered card // from being shoved partway behind the time/battery row and lets the // bar stay readable while the sheet is open. lv_obj_set_size(s_channel_long_sheet, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_set_pos(s_channel_long_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_channel_long_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_channel_long_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_channel_long_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_channel_long_sheet); lv_obj_add_event_cb(s_channel_long_sheet, channelLongSheetDismissCb, LV_EVENT_CLICKED, nullptr); // Same metrics as the contact action sheet — the two sheets are siblings and // should read identically. (PSC is a no-op on the smaller boards.) #if defined(TLORA_PAGER) // The Pager is twice as wide as the 240/320-px touch layouts but substantially // shorter. Spend that width: fixed-size one-line labels and shorter rows keep // the complete channel menu visible at every text preset. const int card_w = lv_disp_get_hor_res(nullptr) - 80; const int btn_h = 26; const int btn_gap = 3; const int title_h = 32; // reserve the full close-X focus/hit target const int pad = 6; const lv_font_t* row_font = uiChromeFont(); #elif CAP_LARGE_SCREEN const int card_w = PCW(232); const int btn_h = PSC(30); const int btn_gap = PSC(6); const int title_h = PSC(28); const int pad = PSC(6); const lv_font_t* row_font = &g_font_14; #else const int card_w = 232; const int btn_h = 30; const int btn_gap = 4; // 4 (was 6): the 7-item channel grid + danger row must fit the T-Deck's ~206 px const int title_h = 28; const int pad = 6; const lv_font_t* row_font = &g_font_12; #endif // Room-server thread? Adds the "Log in again" row (issue #89 session recovery). bool is_room_thread = false; if (!is_channel && g_lv.task) { uint8_t rpub[32]; if (g_lv.task->getThreadContactPub(thread_idx, rpub)) { ContactInfo* rc = the_mesh.lookupContactByPubKey(rpub, PUB_KEY_SIZE); is_room_thread = (rc && rc->type == ADV_TYPE_ROOM); } } // Everything except Remove/Delete goes in the 2-column grid; the danger row // spans the full width at the bottom. Channel: mark-read + region + the two // mutes + share + blocked + chat icon + delete history = 8 grid items // (4 rows). Room: mark-read + login-again + join + reset-path + blocked + // delete history = 6 (3 rows — the join row fills what was a half-empty // bottom row, so the card height is unchanged). DM: mark-read + reset-path // + blocked + delete history = 4 (2 rows). const int grid_items = is_channel ? 8 : (is_room_thread ? 6 : 4); const int grid_rows = (grid_items + 1) / 2; // ceil // Exact content height: outer padding + fixed header + grid rows + one // full-width danger row. There is no trailing gap after the danger row. int card_h = 2 * pad + title_h + (grid_rows + 1) * btn_h + grid_rows * btn_gap; const int max_h = lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12; const bool card_scroll = (card_h > max_h); // safety net only — all three variants fit both S3 boards if (card_scroll) card_h = max_h; s_chmute_msg_btn = nullptr; s_chmute_men_btn = nullptr; lv_obj_t* card = lv_obj_create(s_channel_long_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // header stays fixed; the body below scrolls addCloseXBadge(card, channelLongSheetDismissCb); lv_obj_t* title = lv_label_create(card); char nm[40]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), name ? name : ""); lv_label_set_text_fmt(title, "%s %s", is_channel ? LV_SYMBOL_LOOP : LV_SYMBOL_ENVELOPE, nm[0] ? nm : (is_channel ? "(channel)" : "(chat)")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); // Trim 32 px on the right so long channel names don't slide under the X. lv_obj_set_width(title, card_w - 2 * pad - 32); lv_obj_set_pos(title, 0, 0); // Scrollable body BELOW the fixed title + X header (scroll is a fallback that // shouldn't trigger — the grid fits). Buttons are clipped to the body, so the // floating X is never drawn on top of one. lv_obj_t* body = lv_obj_create(card); lv_obj_remove_style_all(body); lv_obj_set_pos(body, 0, title_h); lv_obj_set_size(body, card_w - 2 * pad, card_h - 2 * pad - title_h); lv_obj_set_style_pad_all(body, 0, LV_PART_MAIN); if (card_scroll) lv_obj_set_scroll_dir(body, LV_DIR_VER); else lv_obj_clear_flag(body, LV_OBJ_FLAG_SCROLLABLE); const int col_gap = btn_gap; const int half_w = (card_w - 2 * pad - col_gap) / 2; int y = 0; int col = 0; // 0 = left column, 1 = right column // Half-width grid button; advances column, wrapping to the next row. Labels // get DOT-ellipsis so a long translation ("Geblokkeerde gebruikers") degrades // gracefully instead of hard-clipping at the cell edge. auto mk = [&](const char* lbl, lv_event_cb_t cb, uint32_t bg) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, half_w, btn_h); lv_obj_set_pos(b, col == 0 ? 0 : (half_w + col_gap), y); styleButton(b); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 4, LV_PART_MAIN); if (bg) lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(lbl)); lv_obj_set_style_text_font(l, row_font, LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); // LONG_DOT only stays on one line when height is constrained. Previously a // Large-preset label wrapped into a second line and escaped its 30-px row. lv_obj_set_size(l, half_w - 8, lv_font_get_line_height(row_font)); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(l); if (col == 0) col = 1; else { col = 0; y += btn_h + btn_gap; } return b; }; // Full-width danger row (Remove/Delete). Closes any half-open grid row first. auto mk_full = [&](const char* lbl, lv_event_cb_t cb, uint32_t bg) { if (col == 1) { col = 0; y += btn_h + btn_gap; } lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 8, LV_PART_MAIN); if (bg) lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(lbl)); lv_obj_set_style_text_font(l, row_font, LV_PART_MAIN); lv_obj_center(l); y += btn_h + btn_gap; }; mk(TR(LV_SYMBOL_OK " Mark as read"), threadSheetMarkReadCb, 0); if (is_channel) { mk(TR(LV_SYMBOL_SETTINGS " Region & scope"), channelLongSheetRegionCb, 0); // The two mutes sit side by side on their own row; labels set by chmuteRefreshLabels(). s_chmute_msg_btn = mk("", channelMuteMsgCb, 0); s_chmute_men_btn = mk("", channelMuteMenCb, 0); chmuteRefreshLabels(); mk(TR(LV_SYMBOL_SHUFFLE " Share secret"), channelLongSheetShareCb, 0); mk(TR(LV_SYMBOL_CLOSE " Blocked users"), channelLongSheetBlockedCb, 0); // Chat-list avatar emoji: tap = pick, long-press = back to the two-letter auto avatar. lv_obj_t* icb = mk(TR(LV_SYMBOL_IMAGE " Chat icon"), threadSheetIconCb, 0); if (icb) lv_obj_add_event_cb(icb, threadSheetIconResetCb, LV_EVENT_LONG_PRESSED, nullptr); mk(TR(LV_SYMBOL_TRASH " Delete history"), threadSheetClearHistCb, 0); mk_full(TR(LV_SYMBOL_TRASH " Remove channel"), channelLongSheetDeleteCb, 0xB23A48); } else { if (is_room_thread) { mk(TR(LV_SYMBOL_REFRESH " Log in again"), threadSheetRoomLoginCb, 0); mk(TR(LV_SYMBOL_LOOP " Join w/ password"), threadSheetRoomJoinCb, 0); } mk(TR(LV_SYMBOL_LOOP " Reset path"), threadSheetResetPathCb, 0); mk(TR(LV_SYMBOL_CLOSE " Blocked users"), channelLongSheetBlockedCb, 0); mk(TR(LV_SYMBOL_TRASH " Delete history"), threadSheetClearHistCb, 0); mk_full(TR(LV_SYMBOL_TRASH " Delete chat"), threadSheetDeleteDmCb, 0xB23A48); } } static void chatDeleteApply() { if (!g_lv.task || s_chat_del_thread_idx < 0) return; const int idx = s_chat_del_thread_idx; const bool is_channel = s_chat_del_is_channel; s_chat_del_thread_idx = -1; // For a channel thread we also drop the slot from the_mesh's channel // table so the same secret doesn't keep matching new floods. For a DM // thread we just drop the UI-side thread; the contact stays in the // Contacts tab and can be removed there separately if the user wants. int channel_slot = -1; if (is_channel) { bool ch; uint16_t unread; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1]; if (g_lv.task->getThreadInfo(idx, ch, unread, ts, name, sizeof(name)) && ch) { #ifdef MAX_GROUP_CHANNELS ChannelDetails cd{}; // Prefer the thread's own pinned slot — validated by name at use time (a // stale slot index must never delete a DIFFERENT channel, see the // channel-send stale-slot bug). Truncation-tolerant compare: the thread // name is capped at MAX_THREAD_NAME, so match only that many chars. const int16_t pinned = g_lv.task->threadMeshChannelSlot(idx); if (pinned >= 0 && pinned < MAX_GROUP_CHANNELS && the_mesh.getChannel(pinned, cd) && cd.name[0] && strncmp(cd.name, name, UITask::MAX_THREAD_NAME) == 0) { channel_slot = pinned; } else { for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { if (the_mesh.getChannel(i, cd) && cd.name[0] && strncmp(cd.name, name, UITask::MAX_THREAD_NAME) == 0) { channel_slot = i; break; } } } #endif } } if (!g_lv.task->removeThread(idx)) { g_lv.task->showAlert(TR("Couldn't delete"), 1200); return; } if (is_channel && channel_slot >= 0) { the_mesh.uiDeleteChannel(channel_slot); } g_lv.dirty_threads = true; g_lv.task->showAlert(is_channel ? TR("Channel removed") : TR("Chat removed"), 1000); } static void threadLongPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED || !g_lv.task) return; auto* ctx = static_cast<LvThreadButtonCtx*>(lv_event_get_user_data(e)); if (!ctx) return; bool ch; uint16_t unread; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!g_lv.task->getThreadInfo(ctx->idx, ch, unread, ts, name, sizeof(name))) return; // Swallow the rest of this gesture so the matching CLICKED that LVGL // would otherwise fire on release doesn't also open the chat behind the // confirm popup. lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); s_chat_del_thread_idx = ctx->idx; s_chat_del_is_channel = ch; // Both DMs and channels get an action sheet: Mark as read, plus manage // (channels: Share secret / Remove; DMs: Delete chat). openThreadActionSheet(ctx->idx, name, ch); } // Per-row gear: opens the SAME thread-settings sheet as a long-press (and as the in-chat cog). static void threadGearCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; auto* ctx = static_cast<LvThreadButtonCtx*>(lv_event_get_user_data(e)); if (!ctx) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); // swallow the gesture so the row's CLICKED doesn't open the chat bool ch; uint16_t un; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!g_lv.task->getThreadInfo(ctx->idx, ch, un, ts, name, sizeof(name))) return; s_chat_del_thread_idx = ctx->idx; s_chat_del_is_channel = ch; openThreadActionSheet(ctx->idx, name, ch); } static void threadSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; auto* ctx = static_cast<LvThreadButtonCtx*>(lv_event_get_user_data(e)); if (!ctx) return; LvChatPanel& p = ctx->channel ? g_lv.ch : g_lv.dm; g_lv.task->enterThread(ctx->channel, ctx->idx); // Update header name bool ch; uint16_t unread; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1]; if (g_lv.task->getThreadInfo(ctx->idx, ch, unread, ts, name, sizeof(name))) { char san[UITask::MAX_THREAD_NAME + 8]; copyUtf8ReplacingMissingGlyphs(&g_font_12, san, sizeof(san), name); setChatStatusTitle(san); // thread name → status bar (no in-chat header bar) } p.detail_open = true; hideKb(); if (p.overlay) { lv_obj_clear_flag(p.overlay, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(p.overlay); } // Build + scroll AFTER un-hiding: bubbles measured in a hidden tree get a wrong height, // which collapses the content so the open-scroll lands at the top. Visible first = correct // heights = the open-scroll reaches the newest message. refreshChatDetailAsync(p); #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Physical keyboard: focus the composer on open so typing goes straight in. showKb(&p); #endif } #if defined(HAS_ATTAKY_MESH_KEYBOARD) // Send the panel composer's text and clear it. Split from the send button's // callback so the module's Enter can reach the same path. static void composerSendFromPanel(LvChatPanel* p) { if (!g_lv.task || !p || !p->composer_ta) return; const char* text = lv_textarea_get_text(p->composer_ta); if (!text || !text[0]) return; hideKb(); g_lv.task->setComposerMode(true); g_lv.task->composerReset(); for (const char* cp = text; *cp; ++cp) g_lv.task->composerAppendChar(*cp); if (g_lv.task->composerSend()) { lv_textarea_set_text(p->composer_ta, ""); refreshChatDetailAsync(*p); g_lv.dirty_threads = true; } } static void sendFromPanelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; composerSendFromPanel(static_cast<LvChatPanel*>(lv_event_get_user_data(e))); } #else static void sendFromPanelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->composer_ta) return; const char* text = lv_textarea_get_text(p->composer_ta); if (!text || !text[0]) return; hideKb(); g_lv.task->setComposerMode(true); g_lv.task->composerReset(); for (const char* cp = text; *cp; ++cp) g_lv.task->composerAppendChar(*cp); if (g_lv.task->composerSend()) { lv_textarea_set_text(p->composer_ta, ""); refreshChatDetailAsync(*p); g_lv.dirty_threads = true; } } #endif // ---- Quick-reply macro picker (composer bar → list icon) ---- // One sheet at a time; we cache the active panel pointer so the tap handler // can stuff the chosen text into the right composer textarea (DM vs channel // thread both reuse this picker). static lv_obj_t* s_qr_sheet = nullptr; static LvChatPanel* s_qr_panel = nullptr; // ===== Emoji / special-character picker ===================================== // Neither the on-screen keyboard nor the T-Deck's physical keyboard can type // emoji or many special characters, so this popup is the way to insert them. // The glyphs themselves render thanks to the extras_* fallback fonts (see // initTouchFontFallbacks); this picker just inserts the UTF-8 bytes. Grid of // tappable glyphs; tap inserts into the active composer + closes. static lv_obj_t* s_emoji_sheet = nullptr; static lv_obj_t* s_emoji_target_ta = nullptr; // textarea to insert into static lv_obj_t* s_emoji_grid = nullptr; // the scrollable button grid static int s_emoji_sel = -1; // trackball-highlighted index (-1 = none) static int s_emoji_cols = 1; // grid columns (computed at open) // Curated insert set, grouped. Kept in sync with what the extras fonts bake // (see scripts/build/regen-extras-fonts.sh) — a glyph here that isn't in the // font would just render as the font's notdef box, so only ship baked ones. static const char* const k_emoji_items[] = { // faces "\xF0\x9F\x98\x80","\xF0\x9F\x98\x83","\xF0\x9F\x98\x84","\xF0\x9F\x98\x81", "\xF0\x9F\x98\x86","\xF0\x9F\x98\x85","\xF0\x9F\x98\x82","\xF0\x9F\x98\x89", "\xF0\x9F\x98\x8A","\xF0\x9F\x98\x8D","\xF0\x9F\x98\x98","\xF0\x9F\x98\x8B", "\xF0\x9F\x98\x9C","\xF0\x9F\x98\x8E","\xF0\x9F\x98\x8F","\xF0\x9F\x98\x92", "\xF0\x9F\x98\x9E","\xF0\x9F\x98\x94","\xF0\x9F\x98\xA2","\xF0\x9F\x98\xAD", "\xF0\x9F\x98\xA1","\xF0\x9F\x98\xA0","\xF0\x9F\x98\xB1","\xF0\x9F\x98\xB4", "\xF0\x9F\x98\xAC","\xF0\x9F\x98\x90", // gestures + people "\xF0\x9F\x91\x8D","\xF0\x9F\x91\x8E","\xF0\x9F\x91\x8C","\xE2\x9C\x8C", "\xF0\x9F\x91\x8A","\xE2\x9C\x8A","\xF0\x9F\x91\x8F","\xF0\x9F\x99\x8C", "\xF0\x9F\x99\x8F","\xF0\x9F\x92\xAA","\xF0\x9F\x91\x8B", // hearts "\xE2\x9D\xA4","\xF0\x9F\x92\x94","\xF0\x9F\x92\x95","\xF0\x9F\x92\x99", "\xF0\x9F\x92\x9A","\xF0\x9F\x92\x9B","\xF0\x9F\x92\x9C", // symbols / celebration "\xF0\x9F\x8E\x89","\xF0\x9F\x8E\x8A","\xE2\x9C\xA8","\xF0\x9F\x94\xA5", "\xF0\x9F\x92\xAF","\xE2\x9C\x85","\xE2\x9D\x8C","\xE2\x9D\x97", "\xE2\x9D\x93","\xE2\x9A\xA0","\xF0\x9F\x92\xA9","\xE2\xAD\x90", "\xF0\x9F\x8C\x9F","\xE2\x9A\xA1","\xE2\x98\x80","\xE2\x98\x81", "\xE2\x9D\x84","\xE2\x98\x94","\xE2\x98\x95","\xF0\x9F\x8D\xBA","\xF0\x9F\x8D\xBB", "\xF0\x9F\x8D\x95","\xF0\x9F\x8E\x82","\xF0\x9F\x8E\x81","\xF0\x9F\x92\xAC", "\xF0\x9F\x92\xA4","\xF0\x9F\x92\xA5", // objects "\xF0\x9F\x9A\x80","\xF0\x9F\x93\xB7","\xF0\x9F\x93\xB1","\xF0\x9F\x92\xBB", "\xF0\x9F\x93\x8D","\xF0\x9F\x93\x8C","\xF0\x9F\x93\x85","\xF0\x9F\x94\x8B", "\xF0\x9F\x93\xA1","\xF0\x9F\x94\x91","\xF0\x9F\x94\x92","\xF0\x9F\x93\xA7", "\xF0\x9F\x8F\xA0","\xF0\x9F\x9A\x97","\xE2\x8C\x9A","\xF0\x9F\x92\xA1", // animals "\xF0\x9F\x90\xB6","\xF0\x9F\x90\xB1","\xF0\x9F\x90\xB8","\xF0\x9F\x90\xBB", "\xF0\x9F\x90\xA7","\xF0\x9F\x90\x9D", // activity / flags (soccer, football, moai, transgender flag, Lesotho flag) "\xE2\x9A\xBD","\xF0\x9F\x8F\x88","\xF0\x9F\x97\xBF", "\xF0\x9F\x8F\xB3\xEF\xB8\x8F\xE2\x80\x8D\xE2\x9A\xA7\xEF\xB8\x8F", "\xF0\x9F\x87\xB1\xF0\x9F\x87\xB8", // special characters / punctuation / currency / math "\xE2\x80\x94","\xE2\x80\xA6","\xE2\x80\x9C","\xE2\x80\x9D","\xE2\x80\x98", "\xE2\x80\x99","\xE2\x86\x92","\xE2\x86\x90","\xC2\xB0","\xC2\xB1", "\xC3\x97","\xC3\xB7","\xE2\x82\xAC","\xC2\xA3","\xC2\xA5","\xC2\xA9", "\xC2\xAE","\xE2\x84\xA2","\xE2\x89\xA0","\xE2\x89\xA4","\xE2\x89\xA5", "\xC2\xBD","\xC2\xBC","\xC2\xBE", }; static constexpr int k_emoji_count = (int)(sizeof(k_emoji_items) / sizeof(k_emoji_items[0])); // Special characters / symbols not easy to reach on a compact keyboard — their OWN picker, // separate from emoji. All render via the g_font_16 chain (montserrat ASCII base + extras_font // symbols/accents fallback), so no tofu. static const char* const k_special_items[] = { // common ASCII symbols "%","$","@","#","&","*","+","=","/","\\","|","<",">","~","^","`","[","]","{","}", // currency / math / punctuation "\xE2\x82\xAC","\xC2\xA3","\xC2\xA5","\xC2\xB0","\xC2\xB1","\xC3\x97","\xC3\xB7","\xC2\xBD","\xC2\xBC","\xC2\xBE", "\xC2\xA7","\xC2\xA9","\xC2\xAE","\xE2\x84\xA2","\xE2\x80\xA2","\xE2\x80\x93","\xE2\x80\x94","\xE2\x80\xA6", "\xE2\x86\x92","\xE2\x86\x90","\xE2\x86\x91","\xE2\x86\x93","\xE2\x89\xA0","\xE2\x89\xA4","\xE2\x89\xA5","\xE2\x84\x83","\xE2\x84\x89", // accented letters "\xC3\xA1","\xC3\xA9","\xC3\xAD","\xC3\xB3","\xC3\xBA","\xC3\xB1","\xC3\xBC","\xC3\xA7","\xC3\x9F", "\xC3\xA0","\xC3\xA8","\xC3\xAC","\xC3\xB2","\xC3\xB9","\xC3\xA2","\xC3\xAA","\xC3\xAE","\xC3\xB4","\xC3\xBB", "\xC3\xA4","\xC3\xAB","\xC3\xAF","\xC3\xB6","\xC3\xA3","\xC3\xB5","\xC3\xA5","\xC3\xB8","\xC3\xA6", // Polish diacritics (Latin Extended-A; o-acute already listed above) "\xC4\x85","\xC4\x87","\xC4\x99","\xC5\x82","\xC5\x84","\xC5\x9B","\xC5\xBA","\xC5\xBC", }; static constexpr int k_special_count = (int)(sizeof(k_special_items) / sizeof(k_special_items[0])); // The glyph picker (openEmojiPicker) renders whichever set these point at — emoji by default, // or the special-character set when opened from the text-edit menu's "Sym" button. static const char* const* s_glyph_items = k_emoji_items; static int s_glyph_count = k_emoji_count; // Pick-mode: when set, the next chosen glyph is handed to this callback instead // of being inserted into a textarea (used by the chat-icon chooser). Dismissing // the sheet without choosing cancels (the closer clears it). static void (*s_emoji_pick_cb)(const char* utf8) = nullptr; static void closeEmojiSheet() { popupClose(&s_emoji_sheet); s_emoji_target_ta = nullptr; s_emoji_grid = nullptr; s_emoji_sel = -1; s_emoji_pick_cb = nullptr; } static void emojiSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // backdrop only closeEmojiSheet(); } // Insert item `idx` into the active composer and close. Shared by finger-tap // (emojiPickCb) and the trackball selector (emojiSelectorClick). static void emojiInsertIndex(int idx) { if (s_emoji_pick_cb) { void (*cb)(const char*) = s_emoji_pick_cb; s_emoji_pick_cb = nullptr; // one-shot; also survives the closer's clear const char* g = (idx >= 0 && idx < s_glyph_count) ? s_glyph_items[idx] : nullptr; closeEmojiSheet(); if (g) cb(g); return; } lv_obj_t* ta = s_emoji_target_ta; if (idx < 0 || idx >= s_glyph_count || !ta) { closeEmojiSheet(); return; } const char* g = s_glyph_items[idx]; // Insert into the keyboard MIRROR when it's bound to this field — otherwise a // later kbMirrorSyncToReal() would overwrite our insert with the stale mirror // contents (same trap the Wi-Fi scan picker hit). When not bound, insert // straight into the real field. lv_obj_t* dest = (kbMirrorActive() && s_kb_bind_ta == ta && s_kb_mirror_ta) ? s_kb_mirror_ta : ta; lv_textarea_add_text(dest, g); closeEmojiSheet(); } static void emojiPickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; emojiInsertIndex((int)(intptr_t)lv_event_get_user_data(e)); } // Open the emoji sheet in PICK mode: the chosen glyph goes to `cb` (not a textarea). // Defined after openEmojiPicker; forward-declared next to the thread-sheet code. static void openEmojiPicker(lv_obj_t* ta, const char* const* items, int count, const char* picker_title); static void openEmojiPickerPick(void (*cb)(const char* utf8), const char* title) { openEmojiPicker(nullptr, k_emoji_items, k_emoji_count, title); // resets pick state via closeEmojiSheet() s_emoji_pick_cb = cb; // arm AFTER the open (the closer clears it) } // Paint the trackball-selected cell highlighted and the rest normal, and keep // the selection scrolled into view. No-op when nothing is selected (finger-only). static void emojiPaintSelection() { if (!s_emoji_grid) return; const uint32_t n = lv_obj_get_child_cnt(s_emoji_grid); for (uint32_t i = 0; i < n; ++i) { lv_obj_t* b = lv_obj_get_child(s_emoji_grid, i); if (!b) continue; const bool sel = ((int)i == s_emoji_sel); lv_obj_set_style_bg_color(b, lv_color_hex(sel ? COLOR_MENTION : 0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_border_width(b, sel ? 2 : 0, LV_PART_MAIN); lv_obj_set_style_border_color(b, lv_color_hex(0xCFE6FF), LV_PART_MAIN); } if (s_emoji_sel >= 0 && s_emoji_sel < (int)n) { lv_obj_t* b = lv_obj_get_child(s_emoji_grid, (uint32_t)s_emoji_sel); if (b) lv_obj_scroll_to_view(b, LV_ANIM_ON); } } // Trackball is high-resolution: one physical roll emits several motion counts, // so stepping one cell per raw count made the selector fly across the grid. // Accumulate raw motion and only advance one cell once the accumulator crosses // kEmojiSelStep — so it takes a deliberate roll to move each cell. static int s_emoji_acc_x = 0, s_emoji_acc_y = 0; static constexpr int kEmojiSelStep = 3; // raw counts per one-cell move (higher = less sensitive) // Feed raw trackball motion; advances the highlighted cell at most one step per // axis per call. Called from the trackball poll while the emoji sheet is open. static void emojiSelectorMove(int rawdx, int rawdy) { if (!s_emoji_grid || s_glyph_count == 0) return; if (s_emoji_sel < 0) { // first motion just lands on cell 0 s_emoji_sel = 0; s_emoji_acc_x = s_emoji_acc_y = 0; emojiPaintSelection(); return; } s_emoji_acc_x += rawdx; s_emoji_acc_y += rawdy; int dc = 0, dr = 0; if (s_emoji_acc_x >= kEmojiSelStep) { dc = 1; s_emoji_acc_x = 0; } else if (s_emoji_acc_x <= -kEmojiSelStep) { dc = -1; s_emoji_acc_x = 0; } if (s_emoji_acc_y >= kEmojiSelStep) { dr = 1; s_emoji_acc_y = 0; } else if (s_emoji_acc_y <= -kEmojiSelStep) { dr = -1; s_emoji_acc_y = 0; } if (dc == 0 && dr == 0) return; // not enough travel yet const int cols = s_emoji_cols > 0 ? s_emoji_cols : 1; int idx = s_emoji_sel; if (dc) idx += dc; // horizontal: free move across the flat list if (dr) { const int ni = idx + dr * cols; // vertical: jump a full row if (ni >= 0 && ni < s_glyph_count) idx = ni; } if (idx < 0) idx = 0; if (idx >= s_glyph_count) idx = s_glyph_count - 1; if (idx != s_emoji_sel) { s_emoji_sel = idx; emojiPaintSelection(); } } // Trackball centre-click while the sheet is open: insert the highlighted glyph. // Returns true if it consumed the click (so it isn't also injected as a tap). static bool emojiSelectorClick() { if (!s_emoji_sheet) return false; if (s_emoji_sel >= 0) emojiInsertIndex(s_emoji_sel); return true; // swallow the click even if nothing selected yet } // Opened from the composer's emoji button. `ta` is the composer textarea. static void openEmojiPicker(lv_obj_t* ta, const char* const* items = k_emoji_items, int count = k_emoji_count, const char* picker_title = "Insert emoji / symbol") { closeEmojiSheet(); s_emoji_target_ta = ta; s_glyph_items = items; s_glyph_count = count; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_emoji_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_emoji_sheet); lv_obj_set_size(s_emoji_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_emoji_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_emoji_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_emoji_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_emoji_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_emoji_sheet, emojiSheetCloseCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t cardw = sw - 16; const lv_coord_t cardh = sh - STATUSBAR_H - 16; lv_obj_t* card = lv_obj_create(s_emoji_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, cardw, cardh); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR(picker_title)); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 2, 0); addCloseXBadge(card, emojiSheetCloseCb); // Hint line: how to use the trackball (T-Deck) — finger tap also works. lv_obj_t* hint = lv_label_create(card); lv_label_set_text(hint, TR("Roll to highlight \xE2\x80\xA2 click to insert")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_align(hint, LV_ALIGN_TOP_RIGHT, -24, 4); // Scrollable grid of glyph buttons. const lv_coord_t grid_w = cardw - 16; lv_obj_t* grid = lv_obj_create(card); lv_obj_remove_style_all(grid); lv_obj_set_size(grid, grid_w, cardh - 16 - 26); lv_obj_set_pos(grid, 0, 26); lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(grid, 4, LV_PART_MAIN); lv_obj_set_style_pad_column(grid, 4, LV_PART_MAIN); lv_obj_set_scroll_dir(grid, LV_DIR_VER); lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO); s_emoji_grid = grid; // Emoji cells render the baked colour glyph as a ZOOMED image (70% bigger than the 16 px // font glyph) and get a bigger cell; special-character cells stay text labels. #if CAP_LARGE_SCREEN const bool big_emoji = (s_glyph_items == k_emoji_items); const lv_coord_t cell_px = big_emoji ? 48 : 38; #else const lv_coord_t cell_px = 38; #endif // Column count for the trackball selector's row jumps: floor((w + gap) / // (cell + gap)), gap=4. Matches the flex-wrap that LVGL computes. s_emoji_cols = (int)((grid_w + 4) / (cell_px + 4)); if (s_emoji_cols < 1) s_emoji_cols = 1; s_emoji_sel = -1; // start un-highlighted; first roll selects index 0 s_emoji_acc_x = s_emoji_acc_y = 0; for (int i = 0; i < s_glyph_count; ++i) { lv_obj_t* b = lv_btn_create(grid); lv_obj_set_size(b, cell_px, cell_px); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, emojiPickCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); #if CAP_LARGE_SCREEN && LV_USE_IMGFONT // Baked colour-emoji → draw it as a zoomed image (1.7× ≈ 70% bigger than the 16 px // font glyph). Special chars (no emoji dsc) fall through to the text label below. if (big_emoji) { uint32_t goff = 0; const lv_img_dsc_t* ed = emojiGlyphLookup(_lv_txt_encoded_next(s_glyph_items[i], &goff)); if (ed) { lv_obj_t* im = lv_img_create(b); lv_img_set_src(im, ed); lv_img_set_antialias(im, true); if (ed->header.w && ed->header.h) lv_img_set_pivot(im, ed->header.w / 2, ed->header.h / 2); lv_img_set_zoom(im, 435); // 256 = 1×, 435 ≈ 1.7× lv_obj_center(im); continue; } } #endif lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, s_glyph_items[i]); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); } } // Special-character picker: the same sheet/grid as the emoji picker, but the dedicated // symbol set + its own title. Opened from the text-edit menu's "Sym" button. static void openSpecialPicker(lv_obj_t* ta) { openEmojiPicker(ta, k_special_items, k_special_count, "Special characters"); } static void openEmojiPickerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->composer_ta) return; openEmojiPicker(p->composer_ta); } #if defined(HAS_TANMATSU) // Thin wrapper so navPump's yellow F3 key can open the emoji picker for a composer without // openEmojiPicker's default args having to be visible up there. static void openEmojiPickerForComposer(lv_obj_t* ta) { if (ta) openEmojiPicker(ta); } #endif static void closeQuickReplySheet() { popupClose(&s_qr_sheet); s_qr_panel = nullptr; } static void qrPickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(e); LvChatPanel* p = s_qr_panel; closeQuickReplySheet(); if (!p || !p->composer_ta) return; #if defined(ESP32) char buf[TOUCH_QUICK_REPLY_MAXLEN]; int n = touchPrefsGetQuickReply((int)idx, buf, sizeof(buf)); if (n <= 0) return; // Insert at the cursor (NOT set_text) so a quick-reply tapped after some typed // text adds to it instead of wiping it. Clear any active text-selection first — // otherwise lv_textarea_add_text REPLACES the selection (that was the "quick // reply wipes what I typed" report). No auto-send: lets the operator tweak first. taClearSelection(p->composer_ta); lv_textarea_add_text(p->composer_ta, buf); // Move keyboard focus to the textarea so a quick send tap works next. lv_obj_add_state(p->composer_ta, LV_STATE_FOCUSED); #else (void)idx; #endif } // First picker row: insert the node's current GPS position (issue-free text, no // auto-send - same contract as the macro rows). Only clickable with a live fix. static void qrGpsPickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; LvChatPanel* p = s_qr_panel; closeQuickReplySheet(); if (!p || !p->composer_ta) return; #if defined(ESP32) if (!g_lv.task || !g_lv.task->getGpsFix()) return; // disabled row should not fire; belt and braces char buf[48]; snprintf(buf, sizeof buf, "%.5f, %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); taClearSelection(p->composer_ta); // same insert contract as qrPickCb lv_textarea_add_text(p->composer_ta, buf); lv_obj_add_state(p->composer_ta, LV_STATE_FOCUSED); #endif } static void qrSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeQuickReplySheet(); } static void openQuickReplyPickerCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openQuickReplyPicker(static_cast<LvChatPanel*>(lv_event_get_user_data(e))); } // Build + show the quick-reply (macro) picker for a chat panel. Shared by the composer's // △ chip tap (openQuickReplyPickerCb) and the orange F2 hardware key on the Tanmatsu. static void openQuickReplyPicker(LvChatPanel* p) { if (!p) return; closeQuickReplySheet(); s_qr_panel = p; lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_qr_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_qr_sheet); // Sit below the status bar — the QR card (~290 px tall with 6 macro // rows) was getting its top row clipped behind the time/battery row. lv_obj_set_size(s_qr_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_qr_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_qr_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_qr_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_qr_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_qr_sheet, qrSheetCloseCb, LV_EVENT_CLICKED, nullptr); // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards. // The pager's 480-wide landscape panel has plenty of spare width next to the // old 220px single-column card (reported: half the screen sat empty) — give // it 2 columns and a wider card instead of the shared 1-column sizing. #if CAP_LARGE_SCREEN const int card_w = PCW(210); const int btn_h = SC(32); // SC not PSC: the 1.7x PSC boost made this 6-row card taller than the screen const int pad = SC(8); const int title_h = SC(26); const int hint_h = SC(22); const int row_gap = SC(4); const int cols = 1; const int col_gap = 0; #elif defined(TLORA_PAGER) const int card_w = 360; // Six replies fit in three compact rows. Keep the picker entirely inside // the 200-px viewport instead of relying on a height clamp whose absolutely // positioned footer was only reachable by scrolling past the card edge. const int btn_h = 30; const int pad = 6; const int title_h = 24; const int hint_h = 18; const int row_gap = 3; const int cols = 2; const int col_gap = 6; #else const int card_w = 220; const int btn_h = 32; // 34→32: 6 macro rows have to fit in the const int pad = 8; // visible area (298 px) below the status const int title_h = 26; // bar — the old sizing produced a 302 px const int hint_h = 22; // card that clipped behind the bar. const int row_gap = 4; const int cols = 1; const int col_gap = 0; #endif const int col_w = (card_w - 2 * pad - (cols - 1) * col_gap) / cols; const int rows = (TOUCH_QUICK_REPLY_COUNT + cols - 1) / cols; // ceil, in case the macro count ever changes const int gps_row_h = btn_h + row_gap; // GPS-position row: always full-width, sits above the macro grid int card_h = title_h + gps_row_h + rows * (btn_h + row_gap) + hint_h + pad; #if defined(TLORA_PAGER) card_h += pad; // both top and bottom content padding are inside the fixed-height card #endif if (card_h > sh - STATUSBAR_H - 8) card_h = sh - STATUSBAR_H - 8; // never taller than the visible area lv_obj_t* card = lv_obj_create(s_qr_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); // The card is clamped to the visible height above, but its rows are absolutely // positioned and can extend past that clamp (small screens + Large/Huge UI scale) — // enable vertical scrolling so the lower quick-replies + hint stay reachable (GH #151). lv_obj_add_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); addCloseXBadge(card, qrSheetCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Quick reply")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_set_style_text_font(title, uiChromeFont(), LV_PART_MAIN); #else lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); #endif lv_obj_set_pos(title, 0, 0); #if defined(ESP32) { // --- My position (GPS) --- first row; greyed out without a live fix const bool fix = g_lv.task && g_lv.task->getGpsFix(); char gbuf[64]; if (fix) snprintf(gbuf, sizeof gbuf, LV_SYMBOL_GPS " %.5f, %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); else snprintf(gbuf, sizeof gbuf, LV_SYMBOL_GPS " %s", TR("My position (no GPS fix)")); lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, title_h); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(fix ? 0x1A1B1C : 0x0C0D0E), LV_PART_MAIN); if (fix) lv_obj_add_event_cb(b, qrGpsPickCb, LV_EVENT_CLICKED, nullptr); else lv_obj_add_state(b, LV_STATE_DISABLED); // disabled = LVGL swallows the click lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, gbuf); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, card_w - 2 * pad - 16); lv_obj_set_style_text_font(lbl, #if defined(TLORA_PAGER) uiChromeFont(), #else &g_font_12, #endif LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(fix ? COLOR_TEXT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); } for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) { char buf[TOUCH_QUICK_REPLY_MAXLEN]; int n = touchPrefsGetQuickReply(i, buf, sizeof(buf)); if (n <= 0) { strncpy(buf, "(empty)", sizeof(buf) - 1); buf[sizeof(buf)-1] = '\0'; } const int col = i % cols, row = i / cols; lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, col_w, btn_h); lv_obj_set_pos(b, col * (col_w + col_gap), title_h + gps_row_h + row * (btn_h + row_gap)); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(n > 0 ? 0x1A1B1C : 0x0C0D0E), LV_PART_MAIN); lv_obj_add_event_cb(b, qrPickCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, buf); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, col_w - 16); lv_obj_set_style_text_font(lbl, #if defined(TLORA_PAGER) uiChromeFont(), #else &g_font_12, #endif LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(n > 0 ? COLOR_TEXT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); } #endif const int y = title_h + gps_row_h + rows * (btn_h + row_gap); // hint sits below the last row lv_obj_t* hint = lv_label_create(card); lv_label_set_text(hint, TR("Edit in Settings \xe2\x86\x92 Quick replies")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, LV_PART_MAIN); lv_label_set_long_mode(hint, LV_LABEL_LONG_DOT); lv_obj_set_width(hint, card_w - 2 * pad); #else lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); #endif lv_obj_set_pos(hint, 0, y + 2); } static void openAppDrawer(); // fwd — app-drawer overlay (defined below openControlCenter) static void closeAppDrawer(); static void setHomeDrawer(bool show); static void closeMentionsScreen(); // fwd — @-mentions screen (defined with the drawer) static void openMentionsScreen(); // fwd — same static void closeAppDrawerSync(); // fwd — synchronous drawer close (safe outside the drawer's own event) static lv_obj_t* s_mentions_root = nullptr; // @-mentions list overlay // The Home tab shows the command centre OR the app drawer; remember which so // leaving Home and coming back restores the same view. Toggled by the Home tab // button (tabBtnsCb); tabChangedCb shows/hides the overlay to match on enter/leave. static bool s_home_drawer_mode = false; static bool s_home_is_drawer = false; // persistent pref: Home tab defaults to the app drawer (loaded at boot) static bool s_tab_changed = false; // a real tab switch happened this tap; the Home-button toggle reads it // Slide the thin accent indicator bar under the active tab. Hidden on the // immersive map tab (transparent chrome, black icons over the tiles). static void updateTabIndicator() { if (!s_tab_indicator || !g_lv.tabview) return; const int idx = getActiveTab(); if (idx == MAP_TAB_INDEX) { lv_obj_add_flag(s_tab_indicator, LV_OBJ_FLAG_HIDDEN); return; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); #if defined(HAS_EXPANSION_KIT) // Tab count is runtime: 6 cells with the Sensors tab, 5 without (TAB_LAST is 5 // or 4 accordingly). Using (TAB_LAST + 1) keeps the indicator centred under the // active tab in either layout. const int cell = sw / (TAB_LAST + 1); // equal tab cells (Sensors present or not) const int xoff = cell * idx + cell/2 - sw/2; // center of tab idx relative to screen mid #else const int cell = sw / 5; // 5 equal tab cells const int xoff = (idx - 2) * cell; // Home (index 2) is the centred tab #endif lv_obj_clear_flag(s_tab_indicator, LV_OBJ_FLAG_HIDDEN); lv_obj_align(s_tab_indicator, LV_ALIGN_BOTTOM_MID, xoff, -2); } static void tabChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; scheduleHeavyRefresh(170); closeSettingsModal(); closeSettingsCategory(); // a category detail sheet floats on layer_top — drop it on tab change closeMentionsScreen(); // transient overlay — switching tabs leaves it const int new_t = getActiveTab(); updateTabIndicator(); // slide the accent glow bar under the newly-active tab const bool leaving_inbox = (s_lv_tab_prev == CHAT_INBOX_TAB_INDEX && new_t != CHAT_INBOX_TAB_INDEX); if (leaving_inbox) { hideKb(); if (g_lv.dm.detail_open && g_lv.dm.overlay) { lv_obj_add_flag(g_lv.dm.overlay, LV_OBJ_FLAG_HIDDEN); g_lv.dm.detail_open = false; } if (g_lv.ch.detail_open && g_lv.ch.overlay) { lv_obj_add_flag(g_lv.ch.overlay, LV_OBJ_FLAG_HIDDEN); g_lv.ch.detail_open = false; } // Leaving the inbox by ANY route (tab tap, swipe, or the route-replay jump to // the map) drops the open chat's name + settings cog from the status bar. // Previously only the explicit chat-close button cleared it, so a replay // started inside a channel left that channel's title/cog showing on Home. setChatStatusTitle(nullptr); } else { hideKb(); } // Chats / Contacts tab pages are non-scrolling shells; reset any stale scroll offset. if (new_t == CHAT_INBOX_TAB_INDEX || new_t == CONTACTS_TAB_INDEX) { lv_obj_t* t = getActiveTabPage(); if (t) lv_obj_scroll_to(t, 0, 0, LV_ANIM_OFF); } const int prev_t = s_lv_tab_prev; s_lv_tab_prev = new_t; // App-drawer view follows the Home tab — ALL of it lives here, one owner. The // tab btnmatrix bubbles a VALUE_CHANGED on every tap (same-tab OR switch), so: // • re-tap Home while already on Home -> toggle command centre / drawer // • arrive on Home from another tab -> restore whatever was showing (mode) // • leave Home -> hide the overlay (mode kept), SYNC // delete so the map's blocking first tile render can't paint under it. if (new_t != prev_t) s_tab_changed = true; // the Home toggle reads this to tell a switch from a same-tap if (new_t == HOME_TAB_INDEX && prev_t != HOME_TAB_INDEX) { if (s_home_drawer_mode || s_home_is_drawer) openAppDrawer(); // returned to Home in drawer mode (or drawer-as-home pref) -> restore it (NO toggle here) } else if (new_t != HOME_TAB_INDEX && prev_t == HOME_TAB_INDEX) { closeAppDrawerSync(); // leaving Home -> hide (mode kept); sync so the map can't paint under it } if (new_t == CONTACTS_TAB_INDEX) refreshContactsList(); #if defined(HAS_EXPANSION_KIT) if (new_t == SENSORS_TAB_INDEX) refreshSensorsTab(); #endif if (new_t == MAP_TAB_INDEX) { applyMapChrome(true); // transparent status bar + tab bar so the map shows through onMapTabActivated(); } else { if (prev_t == MAP_TAB_INDEX) applyMapChrome(false); // restore opaque chrome clearRouteReplay(); // drop any route-replay overlay when leaving the map // Leaving the map used to drop all nine decoded tiles, so every re-open // paid the full read + JPEG decode again — measured at ~2.5 s of blocked // UI on the M9 ([STALL] ui:lvgl), on EVERY visit, not just the first. // The grid costs 9 x 128 KB = 1.15 MB of PSRAM, which only matters on the // 2 MB V4 (the same board renderMapTiles already caps to a 4-tile pool), // so keep the decodes on boards with room and re-open as a pure blit. // Freshness is unaffected: renderMapTiles re-attempts MISSING tiles on // every pass, and a style / mode / storage change frees the cache at its // own call sites. // Nothing is released on a roomy board: the slots keep their identity, // their pixels AND their widgets, which is exactly the state panning // within the tab already leaves them in — the tab is simply not drawn // while it is hidden. (releaseMapTileSlot is not enough here: it clears // in_use, so the next render would treat the tile as absent and decode // it again.) static const size_t kPsramTotalB = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); // No non-zero guard: a board reporting 0 belongs on the CONSERVATIVE side of // this test, not the roomy one. const bool low_psram = (kPsramTotalB < 4u * 1024 * 1024); if (low_psram) freeMapTiles(); } // The status bar is shown on every tab now: the Settings page is a clean // category landing (not the cramped sub-tab UI that needed the extra strip), // so the clock/battery/signal row stays put. Keep the tabview docked below it. { const lv_coord_t hor = lv_disp_get_hor_res(nullptr); const lv_coord_t ver = lv_disp_get_ver_res(nullptr); if (g_lv.tabview) { lv_obj_set_pos(g_lv.tabview, 0, STATUSBAR_H); lv_obj_set_size(g_lv.tabview, hor, (lv_coord_t)(ver - STATUSBAR_H)); } if (g_statusbar.root) lv_obj_clear_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); } if (g_lv.task) g_lv.task->onLvTabChanged(new_t); } static void homeTileCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; uintptr_t idx = reinterpret_cast<uintptr_t>(lv_event_get_user_data(e)); // goToTab is defined below; can call it here because this runs at runtime if (g_lv.tabview) lv_tabview_set_act(g_lv.tabview, static_cast<uint32_t>(idx), LV_ANIM_ON); scheduleHeavyRefresh(170); s_lv_tab_prev = static_cast<int>(idx); if (g_lv.task) g_lv.task->onLvTabChanged(static_cast<int>(idx)); } static void resetPathCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->resetActiveDmPath(); refreshStatusLabels(); } static void toggleTcpCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const bool was_on = g_lv.task->isTcpEnabled(); was_on ? g_lv.task->disableTcp() : g_lv.task->enableTcp(); g_lv.task->showAlert(was_on ? TR("TCP off") : TR("TCP on"), 900); refreshStatusLabels(); } static bool bleEnableWaitingForWifi() { #if defined(TLORA_PAGER) && defined(MULTI_TRANSPORT_COMPANION) return wifiConfigPagerWifiBlocksBle(); #else return false; #endif } static const char* bleEnableFailureText() { if (bleEnableWaitingForWifi()) return TR("Bluetooth will start after Wi-Fi connects or times out."); return TR("Not enough free memory for Bluetooth. Turn Wi-Fi off first."); } // "The user wants BLE on", covering the Pager's queued-behind-Wi-Fi request as // well as a live stack. Scoped to the Pager on purpose: only UITask::enableBle() // records a pending request (wifiConfigSetBleEnabled(true) on the deferral // path), and only main.cpp retries one after association. Everywhere else // wifiConfigGetBleEnabled() is a plain persisted preference that DEFAULTS TO ON // and can sit true with no stack begun (the boot co-init heap guard, an OTA // release) — folding that in there would make the toggles read ON while BLE is // dead, and turn the first tap into "clear my preference" instead of "start it". static bool bleRequestedOrEnabled() { bool requested = g_lv.task && g_lv.task->isBleEnabled(); #if defined(TLORA_PAGER) && defined(MULTI_TRANSPORT_COMPANION) requested = requested || wifiConfigGetBleEnabled(); #endif return requested; } static void toggleBleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task || !g_lv.task->hasBleCapability()) return; const bool requested = bleRequestedOrEnabled(); if (requested) { g_lv.task->disableBle(); } else if (!g_lv.task->enableBle()) { g_lv.task->showAlert(bleEnableFailureText(), 2600); refreshStatusLabels(); return; } g_lv.task->showAlert(g_lv.task->isBleEnabled() ? TR("BLE on") : TR("BLE off"), 900); refreshStatusLabels(); } static void toggleGpsCb(lv_event_t* e) { // GPS on/off switch (VALUE_CHANGED) if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; const bool want = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); if (want == g_lv.task->getGPSState()) return; // already in the requested state g_lv.task->toggleGPS(); g_lv.task->showAlert(g_lv.task->getGPSState() ? TR("GPS on") : TR("GPS off"), 900); refreshStatusLabels(); } // Do Not Disturb: true while the current local time falls inside the configured // start/end window (30-min slots, may wrap past midnight). Unconditional (not // board-gated) so both the sound chokepoint and the status-bar icon code can call // it on every board. Silently inactive if the RTC hasn't been set yet. static bool dndActive() { if (!touchPrefsGetDndEnabled()) return false; time_t now_t = time(nullptr); if (now_t <= 1700000000) return false; // RTC not set yet -- clock is meaningless struct tm tm_loc; localtime_r(&now_t, &tm_loc); const int t = tm_loc.tm_hour * 60 + tm_loc.tm_min; const int start = (int)touchPrefsGetDndStartSlot() * 30; const int end = (int)touchPrefsGetDndEndSlot() * 30; // start == end -> zero-length window, always false here (never active). return (start <= end) ? (t >= start && t < end) : (t >= start || t < end); } // Do Not Disturb master switch (under the Sound settings section). Stays silent // on toggle (no preview chime) since the whole point of DND is not making noise. static void toggleDndCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetDndEnabled(on); refreshStatusLabels(); } static lv_obj_t* s_dnd_start_lbl = nullptr; static lv_obj_t* s_dnd_end_lbl = nullptr; static void dndFmtSlot(char* out, size_t out_sz, uint8_t slot) { snprintf(out, out_sz, "%02u:%02u", slot / 2, (unsigned)(slot % 2) * 30); } static void dndStartLabelRefresh() { if (!s_dnd_start_lbl) return; char b[8]; dndFmtSlot(b, sizeof b, touchPrefsGetDndStartSlot()); lv_label_set_text(s_dnd_start_lbl, b); } static void dndEndLabelRefresh() { if (!s_dnd_end_lbl) return; char b[8]; dndFmtSlot(b, sizeof b, touchPrefsGetDndEndSlot()); lv_label_set_text(s_dnd_end_lbl, b); } static void dndStartStep(int delta) { int s = ((int)touchPrefsGetDndStartSlot() + delta + 48) % 48; touchPrefsSetDndStartSlot((uint8_t)s); dndStartLabelRefresh(); refreshStatusLabels(); } static void dndEndStep(int delta) { int s = ((int)touchPrefsGetDndEndSlot() + delta + 48) % 48; touchPrefsSetDndEndSlot((uint8_t)s); dndEndLabelRefresh(); refreshStatusLabels(); } static void dndStartMinusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) dndStartStep(-1); } static void dndStartPlusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) dndStartStep(+1); } static void dndEndMinusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) dndEndStep(-1); } static void dndEndPlusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) dndEndStep(+1); } static void toggleBuzzerCb(lv_event_t* e) { // message-sound switch (VALUE_CHANGED) if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; g_lv.task->toggleBuzzer(); const bool quiet = g_lv.task->isBuzzerQuiet(); g_lv.task->showAlert(quiet ? TR("Sound off") : TR("Sound on"), 900); #if defined(HAS_TANMATSU) if (!quiet) tanBeep(); // confirmation tick through the I2S codec #elif defined(HAS_UI_SOUND) if (!quiet) uiPlayNotify(); // confirmation chime when enabling #endif refreshStatusLabels(); } #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) // Platform notify preview (codec tick on the Tanmatsu, buzzer chime elsewhere). static inline void uiSoundPreview() { #if defined(HAS_TANMATSU) tanBeep(); #else uiPlayNotify(); #endif } // Message-sound switch (under the master Sound switch). VALUE_CHANGED; previews. static void toggleMessageSoundCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetSoundMessages(on); if (on) uiSoundPreview(); } // Direct/DM-sound switch (own on/off + own slot sound). static void toggleDirectSoundCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetSoundDirect(on); if (on) uiPlaySlot(TOUCH_SND_DM); } // @-mention-sound switch (distinct chime; lets you keep mentions on with messages off). static void toggleMentionSoundCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetSoundMentions(on); #if defined(HAS_TANMATSU) if (on) tanBeep(); // the Tanmatsu has no distinct mention chime — same tick #else if (on) uiPlayMention(); #endif } #if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_TDISPLAY_P4) // Volume +/- step buttons (user_data = step, e.g. +10 / -10). Clamps 0..100, // updates the readout, previews. Simpler + more reliable than a drag slider. static lv_obj_t* s_vol_val_lbl = nullptr; static void volumeStepCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; int v = (int)touchPrefsGetSoundVolume() + (int)(intptr_t)lv_event_get_user_data(e); if (v < 0) v = 0; if (v > 100) v = 100; touchPrefsSetSoundVolume((uint8_t)v); if (s_vol_val_lbl) lv_label_set_text_fmt(s_vol_val_lbl, "%d%%", v); #if defined(HAS_TANMATSU) applyVolume((uint8_t)v); // push the new level to the codec live, then preview it tanBeep(); #elif defined(HAS_TDISPLAY_P4) applyVolume((uint8_t)v); // keep the CC slider position in sync, then preview uiPlayNotify(); #else uiPlayNotify(); // preview at the new volume #endif } #endif #endif static void settingsFieldFocusCb(lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); // Drop LV_EVENT_PRESSED: it fires on touch-down before LVGL can tell // whether the gesture is a tap or the start of a vertical scroll inside // the settings body. Reacting to PRESSED meant any scroll attempt that // started on a textarea spawned the keyboard. CLICKED only fires after // release IF the press qualified as a click (movement under the scroll // threshold), so it's the right hook for "open keyboard on real tap". if (code != LV_EVENT_FOCUSED && code != LV_EVENT_CLICKED) return; if (!g_lv.keyboard) return; // Belt-and-suspenders: even CLICKED can fire at the tail end of a flick // if the touch happened to lift while still on the textarea. Same guard // used for contactSelectCb — if anything is currently being scrolled, // treat this CLICKED as scroll spillover, not a deliberate tap. lv_indev_t* act = lv_indev_get_act(); if (act) { if (lv_indev_get_scroll_obj(act)) return; if (lv_indev_get_scroll_dir(act) != LV_DIR_NONE) return; } lv_obj_t* ta = lv_event_get_target(e); if (!ta) return; s_kb_panel = nullptr; kbMirrorBind(ta); noteKbActivity(); // focusing/tapping a field lights the auto backlight #if CAP_KEYPAD_NAV // A deliberate TAP enters edit mode immediately — even when the field was already the // selected (nav-focused) one, where no focus change fires navFocusCb (so tapping it again // wouldn't otherwise make it typable). CLICKED only fires on a real tap (past the scroll // guards above); keyboard-nav onto a field still starts in navigate mode (press Enter). if (code == LV_EVENT_CLICKED) s_nav_ta_editing = true; #endif } #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Recursively find the first text field under `obj` (depth-first). static lv_obj_t* findFirstTextarea(lv_obj_t* obj) { if (!obj) return nullptr; uint32_t n = lv_obj_get_child_cnt(obj); for (uint32_t i = 0; i < n; ++i) { lv_obj_t* c = lv_obj_get_child(obj, i); if (c && lv_obj_check_type(c, &lv_textarea_class)) return c; lv_obj_t* d = findFirstTextarea(c); if (d) return d; } return nullptr; } // Deferred via lv_async from createSettingsModal so the modal's fields exist by // the time it runs: focus the first text field of a freshly-opened popup modal // so the physical keyboard types straight into it. Mirrors settingsFieldFocusCb. static void tdeckModalAutoFocusAsync(void* root) { lv_obj_t* r = static_cast<lv_obj_t*>(root); if (!r || !lv_obj_is_valid(r)) return; // modal already closed in the meantime? lv_obj_t* ta = findFirstTextarea(r); if (!ta) return; // no text field -> nothing to focus s_kb_panel = nullptr; // a settings field, not the chat composer kbMirrorBind(ta); // bind the keyboard to it lv_obj_add_state(ta, LV_STATE_FOCUSED); // show the cursor noteKbActivity(); } #endif // ===== Clipboard ============================================================ // Mirrors MCterm's in-RAM clipboard. Anything the user long-presses to copy // lands here; long-press on a textarea pastes the contents at the cursor. // One global buffer keeps the code simple and avoids heap churn. constexpr size_t CLIPBOARD_MAX = 640; // must cover the largest copyable text — a full composer draft // plus the 600-byte label-staging buffer (clipboardSet's callers); // 192 silently truncated long copy/pastes (GH #120). static char s_clipboard[CLIPBOARD_MAX] = {0}; static void clipboardSet(const char* text, const char* tag) { if (!text) return; size_t n = strlen(text); if (n >= CLIPBOARD_MAX) n = CLIPBOARD_MAX - 1; memcpy(s_clipboard, text, n); s_clipboard[n] = '\0'; if (g_lv.task) { char toast[60]; snprintf(toast, sizeof(toast), TR("Copied %s"), tag && tag[0] ? tag : ""); g_lv.task->showAlert(toast, 900); } } // LV_EVENT_LONG_PRESSED handler: copies the target object's label text. // User-data carries an optional tag shown in the "Copied X" toast. // Copy with LVGL recolor tokens removed: "#RRGGBB text#" -> "text". Safe for // plain labels too (none of our copyable labels contain a literal '#'). static void stripRecolor(const char* src, char* dst, size_t dstsz) { auto ishex = [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }; size_t o = 0; for (const char* p = src; *p && o + 1 < dstsz; ) { if (*p == '#') { bool open = ishex(p[1]) && ishex(p[2]) && ishex(p[3]) && ishex(p[4]) && ishex(p[5]) && ishex(p[6]) && p[7] == ' '; if (open) { p += 8; continue; } // skip "#RRGGBB " ++p; continue; // skip the closing '#' } dst[o++] = *p++; } dst[o] = '\0'; } static void copyLabelLongPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; lv_obj_t* obj = lv_event_get_target(e); if (!obj) return; const char* text = lv_label_get_text(obj); if (!text || !text[0]) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); // swallow the trailing CLICKED char clean[600]; stripRecolor(text, clean, sizeof(clean)); clipboardSet(clean, static_cast<const char*>(lv_event_get_user_data(e))); } // Touch-down on a text field = backlight activity. Hooked on PRESSED (which // fires reliably on every tap, including on an already-focused field — unlike // CLICKED/FOCUSED, which the swipe detector can drop via lv_indev_wait_release). // It only bumps the auto backlight; it does NOT spawn the keyboard, so the // scroll-vs-tap reason PRESSED is kept out of settingsFieldFocusCb doesn't apply. static void kbActivityPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_PRESSED) return; noteKbActivity(); } // When a field loses focus (tapping away / another field), dismiss the long-press accent // popup — it used to linger after the field blurred (e.g. with the new auto-save-on-blur). // The accent box + its variants are NAV_SKIP_FLAG (passive, never focus targets), so picking // a variant doesn't blur the field; only a real tap-away does, which is when we want it gone. static void settingsFieldDefocusCb(lv_event_t* /*e*/) { accentBoxHide(); } // The full text-edit gestures — defined further down with the edit-menu code. static void composerEditClickedCb(lv_event_t* e); static void composerEditLongPressCb(lv_event_t* e); // A widget being destroyed still gets one last LV_EVENT_DEFOCUSED, and that is a // trap for every save-on-blur handler in this file. // // obj_del_core sends LV_EVENT_DELETE, then runs the destructor, and the // destructor's lv_group_remove_obj refocuses the group -- which delivers // DEFOCUSED to the object that is already half torn down. A blur handler then // reads the textarea's text and writes it back, on memory the destructor is in // the middle of freeing. // // That is the beta_69 crash in #330: open Radio & mesh, go to Known regions, add // and remove a few, leave the page. The regions overlay is deleted // asynchronously, its teardown refocuses, and the poll-interval field's blur // save runs inside its own destructor. Decoded from the reporter's core dump. // // DELETE always arrives before the destructor, so recording the object here and // checking it in the blur handlers closes the window. The pointer is only ever // compared, never dereferenced. static lv_obj_t* s_ta_deleting = nullptr; static void settingsFieldDeleteCb(lv_event_t* e) { s_ta_deleting = lv_event_get_target(e); } static inline bool blurFromDelete(lv_event_t* e) { return lv_event_get_target(e) == s_ta_deleting; } static void attachSettingsTaEvents(lv_obj_t* ta) { lv_obj_add_event_cb(ta, settingsFieldFocusCb, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(ta, settingsFieldFocusCb, LV_EVENT_CLICKED, nullptr); // PRESSED is used only for the keyboard backlight (see kbActivityPressCb), // never for focus — settingsFieldFocusCb still avoids it for the scroll case. lv_obj_add_event_cb(ta, kbActivityPressCb, LV_EVENT_PRESSED, nullptr); // Full edit gestures, same as the chat composer: long-press opens the // Cut / Copy / Paste / All / Sym menu (Sym = the special-character picker — // the only way to type symbols missing from the on-screen layouts, e.g. in // a Wi-Fi password; issue #122), double-tap selects the word under the tap. // This replaces the old silent paste-on-long-press, which offered no UI. lv_obj_add_event_cb(ta, composerEditClickedCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(ta, composerEditLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); lv_obj_add_event_cb(ta, settingsFieldDefocusCb, LV_EVENT_DEFOCUSED, nullptr); // Must be on every settings field, not just the ones that save on blur: this is // what tells those handlers the coming DEFOCUSED is a destructor, not a user. lv_obj_add_event_cb(ta, settingsFieldDeleteCb, LV_EVENT_DELETE, nullptr); } // ===== Text selection + edit menu ========================================== // Mobile-style editing for the chat composer: double-tap a word to highlight it, // long-press for a Cut / Copy / Paste / Select-All menu. LVGL's text selection // (compiled in via LV_LABEL_TEXT_SELECTION) only supports drag-select through // its public API, so for word/all selection we set the range on the textarea's // label directly — it sticks until the next tap (the class only rewrites the // selection on click/drag). // codepoint index -> byte offset within a UTF-8 string (and the reverse). static uint32_t taCpToByte(const char* txt, uint32_t cp_target) { uint32_t i = 0, cp = 0; while (txt[i] && cp < cp_target) { _lv_txt_encoded_next(txt, &i); cp++; } return i; } static uint32_t taByteToCp(const char* txt, uint32_t byte_target) { uint32_t i = 0, cp = 0; while (txt[i] && i < byte_target) { _lv_txt_encoded_next(txt, &i); cp++; } return cp; } // Remembered ("sticky") selection. The press that *starts* a long-press makes // the textarea reposition its cursor and clear its own highlight before // LONG_PRESSED fires — so by the time the edit menu opens the visual selection is // gone. We stash the last selected range here and re-apply it when the menu // shows, so a double-tapped (or select-all'd) word stays highlighted under the // menu. Invalidated on a single tap (cursor moved) or any text change. static lv_obj_t* s_sel_ta = nullptr; static uint32_t s_sel_a = 0, s_sel_b = 0; // Highlight [start_cp, end_cp) on a textarea. static void taSelectRange(lv_obj_t* ta, uint32_t start_cp, uint32_t end_cp) { if (!ta || start_cp == end_cp) return; if (start_cp > end_cp) { uint32_t t = start_cp; start_cp = end_cp; end_cp = t; } lv_textarea_set_text_selection(ta, true); lv_textarea_t* t = reinterpret_cast<lv_textarea_t*>(ta); t->sel_start = start_cp; t->sel_end = end_cp; if (t->label) { lv_label_set_text_sel_start(t->label, start_cp); lv_label_set_text_sel_end(t->label, end_cp); } lv_obj_invalidate(ta); s_sel_ta = ta; s_sel_a = start_cp; s_sel_b = end_cp; // remember for re-applying } // Clear both the label highlight AND the textarea's stored range, so a later // menu invocation doesn't act on an invisible stale selection. static void taClearSelection(lv_obj_t* ta) { lv_textarea_clear_selection(ta); lv_textarea_t* t = reinterpret_cast<lv_textarea_t*>(ta); t->sel_start = t->sel_end = LV_DRAW_LABEL_NO_TXT_SEL; if (s_sel_ta == ta) s_sel_ta = nullptr; } // Re-apply the remembered selection if it's still valid for `ta` (the long-press // that opens the menu clears the live highlight). No-op if nothing was selected. static void taReapplyStickySel(lv_obj_t* ta) { if (s_sel_ta != ta || s_sel_a == s_sel_b) return; const char* txt = lv_textarea_get_text(ta); uint32_t len = (txt && txt[0]) ? _lv_txt_get_encoded_length(txt) : 0; if (s_sel_b > len) { s_sel_ta = nullptr; return; } // text changed under us taSelectRange(ta, s_sel_a, s_sel_b); } static bool taHasSelection(lv_obj_t* ta, uint32_t* s_cp, uint32_t* e_cp) { lv_textarea_t* t = reinterpret_cast<lv_textarea_t*>(ta); uint32_t a = t->sel_start, b = t->sel_end; if (a == b || a == LV_DRAW_LABEL_NO_TXT_SEL || b == LV_DRAW_LABEL_NO_TXT_SEL) return false; if (a > b) { uint32_t tmp = a; a = b; b = tmp; } *s_cp = a; *e_cp = b; return true; } // Select the whitespace-delimited word under the cursor (the tap set the cursor). static void taSelectWordAtCursor(lv_obj_t* ta) { const char* txt = lv_textarea_get_text(ta); if (!txt || !txt[0]) return; uint32_t len_b = (uint32_t)strlen(txt); uint32_t cur_b = taCpToByte(txt, lv_textarea_get_cursor_pos(ta)); if (cur_b > len_b) cur_b = len_b; auto isWord = [](char c) { return !(c == ' ' || c == '\n' || c == '\t' || c == '\r'); }; uint32_t s = cur_b, e = cur_b; while (s > 0 && isWord(txt[s - 1])) s--; while (e < len_b && isWord(txt[e])) e++; if (s == e) return; // tapped on whitespace taSelectRange(ta, taByteToCp(txt, s), taByteToCp(txt, e)); } // Delete codepoints [s_cp, e_cp): park the cursor at the end and backspace // (no temp buffer, UTF-8 safe). static void taDeleteRange(lv_obj_t* ta, uint32_t s_cp, uint32_t e_cp) { if (s_cp >= e_cp) return; lv_textarea_set_cursor_pos(ta, (int32_t)e_cp); for (uint32_t k = s_cp; k < e_cp; ++k) lv_textarea_del_char(ta); } // ----- The floating Cut / Copy / Paste / Select-All menu ----- static lv_obj_t* s_txtmenu = nullptr; static lv_obj_t* s_txtmenu_ta = nullptr; static void txtMenuHide() { if (s_txtmenu) { popupClose(&s_txtmenu); } s_txtmenu_ta = nullptr; } enum { TXT_CUT = 0, TXT_COPY, TXT_PASTE, TXT_SELALL, TXT_SYM }; static void txtMenuCellCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; intptr_t act = reinterpret_cast<intptr_t>(lv_event_get_user_data(e)); lv_obj_t* ta = s_txtmenu_ta; if (!ta) { txtMenuHide(); return; } // Mirror-bound field (settings fields while the on-screen keyboard is up): // edit the mirror the user is looking at — live-sync propagates to the real // field. Editing the real ta directly here would be clobbered by the mirror // sync. Same routing the old silent long-press paste used. if (kbMirrorActive() && s_kb_bind_ta == ta && s_kb_mirror_ta) ta = s_kb_mirror_ta; if (act == TXT_SYM) { txtMenuHide(); openSpecialPicker(ta); return; } // dedicated special-character picker const char* txt = lv_textarea_get_text(ta); uint32_t s_cp = 0, e_cp = 0; bool sel = taHasSelection(ta, &s_cp, &e_cp); if (act == TXT_SELALL) { uint32_t len = _lv_txt_get_encoded_length(txt); txtMenuHide(); if (len) taSelectRange(ta, 0, len); return; } if (act == TXT_COPY || act == TXT_CUT) { char buf[CLIPBOARD_MAX]; if (sel) { uint32_t sb = taCpToByte(txt, s_cp), eb = taCpToByte(txt, e_cp); uint32_t n = eb - sb; if (n >= CLIPBOARD_MAX) n = CLIPBOARD_MAX - 1; memcpy(buf, txt + sb, n); buf[n] = '\0'; clipboardSet(buf, act == TXT_CUT ? "cut" : ""); } else { clipboardSet(txt, act == TXT_CUT ? "cut" : ""); } if (act == TXT_CUT) { if (sel) taDeleteRange(ta, s_cp, e_cp); else lv_textarea_set_text(ta, ""); } } else if (act == TXT_PASTE) { if (s_clipboard[0]) { if (sel) taDeleteRange(ta, s_cp, e_cp); lv_textarea_add_text(ta, s_clipboard); } } taClearSelection(ta); txtMenuHide(); } static void txtMenuShow(lv_obj_t* ta) { txtMenuHide(); if (!ta) return; s_txtmenu_ta = ta; static const char* const kLabels[] = { "Cut", "Copy", "Paste", "All", "Sym" }; const int n = 5, cw = 48, ch = 34, gap = 4, pad = 6; s_txtmenu = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_txtmenu); lv_obj_set_style_bg_color(s_txtmenu, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_txtmenu, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_txtmenu, 8, LV_PART_MAIN); lv_obj_set_style_border_color(s_txtmenu, lv_color_hex(0x2A3D52), LV_PART_MAIN); lv_obj_set_style_border_width(s_txtmenu, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_txtmenu, pad, LV_PART_MAIN); lv_obj_set_style_pad_column(s_txtmenu, gap, LV_PART_MAIN); lv_obj_set_flex_flow(s_txtmenu, LV_FLEX_FLOW_ROW); lv_obj_set_size(s_txtmenu, n * cw + (n - 1) * gap + pad * 2, ch + pad * 2); lv_obj_clear_flag(s_txtmenu, LV_OBJ_FLAG_SCROLLABLE); for (int i = 0; i < n; ++i) { lv_obj_t* c = lv_btn_create(s_txtmenu); lv_obj_set_size(c, cw, ch); lv_obj_set_style_radius(c, 5, LV_PART_MAIN); lv_obj_set_style_bg_color(c, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_bg_color(c, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(c, txtMenuCellCb, LV_EVENT_CLICKED, reinterpret_cast<void*>((intptr_t)i)); lv_obj_t* l = lv_label_create(c); lv_label_set_text(l, kLabels[i]); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); } // Above the field (below it if there's no room above the status bar). lv_obj_update_layout(s_txtmenu); lv_area_t a; lv_obj_get_coords(ta, &a); const lv_coord_t bw = lv_obj_get_width(s_txtmenu), bh = lv_obj_get_height(s_txtmenu); lv_coord_t bx = (lv_disp_get_hor_res(nullptr) - bw) / 2; if (bx < 2) bx = 2; lv_coord_t by = a.y1 - bh - 4; if (by < STATUSBAR_H + 2) by = a.y2 + 4; // Same keyboard clamp as the accent box: keep the menu off the on-screen keys. if (g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN)) { lv_coord_t limit = lv_disp_get_ver_res(nullptr) - chatKbH() - 2; if (s_kb_panel) limit -= s_comp_h; if (by + bh > limit) by = limit - bh; if (by < STATUSBAR_H + 2) by = STATUSBAR_H + 2; } lv_obj_set_pos(s_txtmenu, bx, by); lv_obj_move_foreground(s_txtmenu); } // Text-edit gestures (chat composer + every settings field via // attachSettingsTaEvents): double-tap (two clicks within 350 ms on the same // field) selects the word under the tap; long-press opens the edit menu. A // single tap dismisses an open menu. static void composerEditClickedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* ta = lv_event_get_target(e); txtMenuHide(); static uint32_t s_last_ms = 0; static lv_obj_t* s_last_ta = nullptr; uint32_t now = millis(); bool dbl = (s_last_ta == ta) && (now - s_last_ms < 350); s_last_ms = now; s_last_ta = ta; if (dbl) { s_last_ms = 0; // consume so a 3rd tap doesn't re-trigger taSelectWordAtCursor(ta); } else { s_sel_ta = nullptr; // a single tap moved the cursor -> drop the sticky selection } } static void composerEditLongPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; lv_obj_t* ta = lv_event_get_target(e); lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); // swallow the trailing CLICKED taReapplyStickySel(ta); // the press just cleared the highlight — restore it under the menu txtMenuShow(ta); } // On-screen keyboard (V4): backspace with an active selection deletes the whole // highlight instead of one character. Registered BEFORE the keyboard's default // handler (see buildUiTree) so it can replace the default single-char delete — // it stops processing so neither the default delete nor the accent box also run. static void kbBackspaceSelCb(lv_event_t* e) { lv_obj_t* kb = lv_event_get_target(e); uint32_t btn = lv_btnmatrix_get_selected_btn(kb); if (btn == LV_BTNMATRIX_BTN_NONE) return; const char* txt = lv_btnmatrix_get_btn_text(kb, btn); if (!txt || strcmp(txt, LV_SYMBOL_BACKSPACE) != 0) return; lv_obj_t* ta = lv_keyboard_get_textarea(kb); if (!ta) return; uint32_t s_cp, e_cp; if (!taHasSelection(ta, &s_cp, &e_cp)) return; // nothing selected -> default deletes one char taDeleteRange(ta, s_cp, e_cp); taClearSelection(ta); accentBoxHide(); txtMenuHide(); lv_event_stop_processing(e); } static void closeSettingsModal() { hideKb(); #if CAP_KEYPAD_NAV // The synchronous lv_obj_del below can take down the group-focused object — // detach first, exactly like the chat rebuilds do (navDetachBeforeTreeMutation // has no !CAP_KEYPAD_NAV fallback, hence the gate; navMarkDirty below does). navDetachBeforeTreeMutation(); #endif if (g_set_modal.root) { lv_obj_del(g_set_modal.root); } // Pointers into the add-channel modal body get freed with the root above; // null them so a later submit (queued event) doesn't dereference garbage. s_addch_name_ta = nullptr; s_addch_secret_ta = nullptr; s_addch_hashtag_ta = nullptr; s_addch_error_l = nullptr; s_addct_pub_ta = nullptr; s_addct_name_ta = nullptr; s_addct_error_l = nullptr; resetSettingsModalState(); navMarkDirty(); // focus group re-roots to whatever the modal was covering } static void settingsCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; (void)e; closeSettingsModal(); refreshStatusLabels(); } // When non-null, createSettingsModal() skips the modal chrome and returns an // inline content container (a flex item, with a coloured section header) on // this page — so the Set tab's sub-tab pages can host the exact same builders // inline. Set per page in makeSettings; g_set_modal is reset ONCE before the // pages are populated, and each builder writes its own distinct widget ptrs. static lv_obj_t* s_settings_inline_parent = nullptr; // Width (px) of the current settings content area — the inline card's inner // width, or the modal body's inner width. Builders read it to right-align // switches and spread multi-column rows responsively. static lv_coord_t s_settings_content_w = 280; static lv_obj_t* createSettingsModal(const char* title, SettingsModalKind kind) { if (s_settings_inline_parent) { // Grouped-card section: a muted group header above a rounded panel card with // internal padding. The card fills the page content width (symmetric 8 px // page margins); the returned `content` is the card's inner area, with an // explicit pixel width (lv_pct does NOT resolve through a flex column of // LV_SIZE_CONTENT items). The card padding keeps every control clear of the // screen edge and gives consistent visual separation between groups. // Leave a right gutter for the page scrollbar (6 px bar + 2 px pad): the grouped // card + its wide buttons used to run under the scrollbar. 8 px left margin, 14 px // right (the extra 6 clears the bar) — close enough to centred, scrollbar-clear. const lv_coord_t card_w = lv_disp_get_hor_res(nullptr) - 22; const lv_coord_t card_pad = 8; const lv_coord_t content_w = card_w - card_pad * 2; lv_obj_t* wrap = lv_obj_create(s_settings_inline_parent); lv_obj_remove_style_all(wrap); lv_obj_set_width(wrap, card_w); lv_obj_set_height(wrap, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(wrap, 0, LV_PART_MAIN); lv_obj_set_style_pad_bottom(wrap, 14, LV_PART_MAIN); // gap between cards lv_obj_set_flex_flow(wrap, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(wrap, LV_OBJ_FLAG_SCROLLABLE); if (title && title[0]) { lv_obj_t* h = lv_label_create(wrap); lv_label_set_text(h, TR(title)); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_pad_left(h, 4, LV_PART_MAIN); lv_obj_set_style_pad_bottom(h, 4, LV_PART_MAIN); } lv_obj_t* card = lv_obj_create(wrap); lv_obj_remove_style_all(card); lv_obj_set_width(card, card_w); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_set_style_bg_color(card, lv_color_hex(0x121417), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x2A2E34), LV_PART_MAIN); lv_obj_set_style_pad_all(card, card_pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* content = lv_obj_create(card); lv_obj_remove_style_all(content); lv_obj_set_width(content, content_w); lv_obj_set_height(content, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(content, 0, LV_PART_MAIN); // Right gutter inside the content box: many controls are lv_pct(100) placed at a // small x-offset, so they'd resolve to the FULL content width and spill 1-2 px past // the right edge (clipped → "cut off" buttons). The pad shrinks the percent-resolve // width; s_settings_content_w (explicit-width controls) is matched to it. lv_obj_set_style_pad_right(content, 6, LV_PART_MAIN); lv_obj_set_style_border_width(content, 0, LV_PART_MAIN); lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); (void)kind; s_settings_content_w = content_w - 6; return content; } closeSettingsModal(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); lv_obj_t* root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(root); // Root sits BELOW the global status bar (always on top, drawn on // lv_layer_sys). Without this offset the modal's own header (title + // Close button at y=0) was clipped behind the time/battery row. lv_obj_set_size(root, sw, sh - STATUSBAR_H); lv_obj_set_pos(root, 0, STATUSBAR_H); styleSurface(root, COLOR_BG, 0); lv_obj_set_style_pad_all(root, 0, LV_PART_MAIN); lv_obj_set_style_border_width(root, 0, LV_PART_MAIN); lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(root); lv_obj_t* header = lv_obj_create(root); lv_obj_remove_style_all(header); lv_obj_set_size(header, sw, SC(46)); lv_obj_set_pos(header, 0, 0); styleSurface(header, COLOR_PANEL, 0); lv_obj_set_style_pad_all(header, 0, LV_PART_MAIN); lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_border_width(header, 1, LV_PART_MAIN); lv_obj_set_style_border_color(header, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_t* lbl = lv_label_create(header); lv_label_set_text(lbl, TR(title)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* close_btn = lv_btn_create(header); lv_obj_set_size(close_btn, SC(58), SC(32)); lv_obj_align(close_btn, LV_ALIGN_RIGHT_MID, -6, 0); styleButton(close_btn); lv_obj_add_event_cb(close_btn, settingsCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* close_lbl = lv_label_create(close_btn); useChainedFont(close_lbl); #if defined(HAS_TANMATSU) { char _cb[40]; snprintf(_cb, sizeof _cb, LV_SYMBOL_CLOSE " %s", TR("Close")); lv_label_set_text(close_lbl, _cb); lv_obj_set_style_text_color(close_lbl, lv_color_hex(0xE05544), LV_PART_MAIN); } // red ✕ #else lv_label_set_text(close_lbl, TR("Close")); #endif lv_obj_center(close_lbl); lv_obj_t* body = lv_obj_create(root); lv_obj_remove_style_all(body); lv_obj_set_size(body, sw - 8, sh - SC(52)); // clears the SC-scaled header lv_obj_set_pos(body, 4, SC(48)); styleSurface(body, COLOR_BG, 0); lv_obj_set_style_pad_all(body, 6, LV_PART_MAIN); lv_obj_set_style_border_width(body, 0, LV_PART_MAIN); lv_obj_set_scroll_dir(body, LV_DIR_VER); lv_obj_set_scrollbar_mode(body, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_event_cb(body, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); // Single content child holding ALL the controls, sized to its contents. The // scrollable `body` therefore only ever has ONE child to scroll — tall modals // (e.g. Device, with 20+ controls) used to pile every control directly into // the scroll view, which wedged LVGL's scroll-bounds machinery so taps showed // their press style but never fired CLICKED ("buttons unresponsive"). Modals // add their widgets to this container; it grows and `body` scrolls it. lv_obj_t* content = lv_obj_create(body); lv_obj_remove_style_all(content); lv_obj_set_width(content, lv_pct(100)); lv_obj_set_height(content, LV_SIZE_CONTENT); lv_obj_set_pos(content, 0, 0); lv_obj_set_style_pad_all(content, 0, LV_PART_MAIN); lv_obj_set_style_border_width(content, 0, LV_PART_MAIN); lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); resetSettingsModalState(); g_set_modal.root = root; g_set_modal.kind = kind; s_settings_content_w = (sw - 8) - 12; // modal body width minus its 6px padding each side #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Physical keyboard: once the caller has finished adding this modal's fields // (deferred to the next frame), focus its first text field so the user can // type straight away. Popup modals only — the inline-settings path returns above. lv_async_call(tdeckModalAutoFocusAsync, root); #endif return content; } static void saveProfileNameCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task || !g_set_modal.name_ta) return; kbMirrorSyncToReal(); const char* name = lv_textarea_get_text(g_set_modal.name_ta); if (g_lv.task->setNodeName(name)) { g_lv.task->showAlert(TR("Name saved"), 1000); refreshStatusLabels(); } else { g_lv.task->showAlert(TR("Couldn't save the name to storage"), 2200); } } static void saveProfilePosCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: quiet if mid-edit kbMirrorSyncToReal(); float lat = 0.0f, lon = 0.0f; if (!parseFloatField(g_set_modal.lat_ta, lat) || !parseFloatField(g_set_modal.lon_ta, lon)) { if (!silent) g_lv.task->showAlert(TR("Invalid lat/lon"), 1200); return; } if (g_lv.task->setPosition(static_cast<double>(lat), static_cast<double>(lon))) { g_lv.task->showAlert(TR("Position saved"), 1000); refreshStatusLabels(); } } static void saveRadioParamsCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: apply quietly, no alerts kbMirrorSyncToReal(); float freq = 0.0f, bw = 0.0f, af = 0.0f; int sf = 0, cr = 0, tx = 0; if (!parseFloatField(g_set_modal.freq_ta, freq) || !parseFloatField(g_set_modal.bw_ta, bw) || !parseIntField(g_set_modal.sf_ta, sf) || !parseIntField(g_set_modal.cr_ta, cr) || !parseIntField(g_set_modal.tx_ta, tx) || !parseFloatField(g_set_modal.airtime_ta, af)) { if (!silent) g_lv.task->showAlert(TR("Invalid radio values"), 1200); return; } if (tx > (int)MAX_LORA_TX_POWER) { // radio can't exceed this — clamp + notify (else it appears to "reset" to the max on reboot) tx = (int)MAX_LORA_TX_POWER; char m[40]; snprintf(m, sizeof m, "TX max is %d dBm", tx); if (!silent) g_lv.task->showAlert(m, 1500); if (g_set_modal.tx_ta) { char v[8]; snprintf(v, sizeof v, "%d", tx); lv_textarea_set_text(g_set_modal.tx_ta, v); } // reflect the clamp in the field } // Region scope — trimmed the same way it's persisted, so it can be compared below. char region[TOUCH_REGION_SCOPE_MAXLEN] = {0}; const bool has_region_ta = (g_set_modal.region_ta != nullptr); if (has_region_ta) { strncpy(region, lv_textarea_get_text(g_set_modal.region_ta), sizeof(region) - 1); char* r = region; // trim so the stored name matches the key while (*r == ' ' || *r == '\t') r++; size_t rl = strlen(r); while (rl && (r[rl-1]==' '||r[rl-1]=='\t'||r[rl-1]=='\n'||r[rl-1]=='\r')) r[--rl] = '\0'; memmove(region, r, strlen(r) + 1); } #if defined(TLORA_PAGER) // Blur auto-save fires on EVERY field defocus, including pure keyboard/encoder nav that // never edits anything (no touch fallback on the pager — moving focus off a field IS a // defocus). Skip the flash write + live radio SPI reconfigure when nothing actually // changed, so tabbing through this row doesn't retrigger a several-hundred-ms-to- // multi-second radio reinit per field — observed on hardware as escalating // [STALL] ui:lvgl entries while navigating this screen with no edits made. Touch boards // don't hit this (a field only blurs on a deliberate tap-out after an edit), so they // keep the original always-save-on-blur behavior. if (silent) { NodePrefs* prefs = the_mesh.getNodePrefs(); char cur_region[TOUCH_REGION_SCOPE_MAXLEN] = {0}; if (has_region_ta) touchPrefsGetRegionScope(cur_region, sizeof(cur_region)); const bool unchanged = prefs && std::fabs(prefs->freq - freq) <= 0.002 && std::fabs(prefs->bw - bw) <= 0.02 && prefs->sf == (uint8_t)sf && prefs->cr == (uint8_t)cr && prefs->tx_power_dbm == (int8_t)tx && std::fabs(static_cast<double>(prefs->airtime_factor) - af) <= 0.005 && (!has_region_ta || strcmp(cur_region, region) == 0); if (unchanged) return; } #endif bool ok = g_lv.task->setRadioParams(freq, bw, static_cast<uint8_t>(sf), static_cast<uint8_t>(cr), static_cast<int8_t>(tx), af); // Region scope — independent of the freq/SF values above. Derive + persist the // flood-scope key from the typed "#region" (blank clears it back to unscoped), // and remember the display name for next time the form is shown. bool has_region = false; if (has_region_ta) { the_mesh.setDefaultFloodScope(region); touchPrefsSetRegionScope(region); // #271: keep the registry in step with the region we just adopted, so its // messages are named immediately. Any previously-registered region keeps its // slot, so changing our own region does not un-name the history we // received while we were in the old one. if (region[0]) the_mesh.regionRegistry().ensureRegion(region); has_region = (region[0] != '\0'); } if (ok) { if (!silent) g_lv.task->showAlert(has_region ? TR("Radio + region set") : TR("Radio applied"), 1000); refreshStatusLabels(); } } static void saveAutoAddCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: quiet if mid-edit kbMirrorSyncToReal(); int max_hops = 0; if (!parseIntField(g_set_modal.max_hops_ta, max_hops)) { if (!silent) g_lv.task->showAlert(TR("Invalid max hops"), 1200); return; } if (max_hops < 0) max_hops = 0; if (max_hops > 64) max_hops = 64; uint8_t mask = 0; if (g_set_modal.auto_overwrite_sw && lv_obj_has_state(g_set_modal.auto_overwrite_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_OVERWRITE_OLDEST; if (g_set_modal.auto_chat_sw && lv_obj_has_state(g_set_modal.auto_chat_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_CHAT; if (g_set_modal.auto_rep_sw && lv_obj_has_state(g_set_modal.auto_rep_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_REPEATER; if (g_set_modal.auto_room_sw && lv_obj_has_state(g_set_modal.auto_room_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_ROOM_SERVER; if (g_set_modal.auto_sensor_sw && lv_obj_has_state(g_set_modal.auto_sensor_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_SENSOR; uint8_t manual = 1u; // touch UI is always selective: the per-type Auto switches are authoritative (off = really off) g_lv.task->setAutoAddConfig(mask, static_cast<uint8_t>(max_hops), manual); if (!silent) g_lv.task->showAlert(TR("Auto-add saved"), 1000); refreshStatusLabels(); } // Auto-save on toggle: the auto-add switches persist immediately, so the user // doesn't have to press "Save auto-add" for them to stick across reboots. // Recomputes the whole mask from the current switch states + the hops field. static void autoAddSwitchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; uint8_t mask = 0; if (g_set_modal.auto_overwrite_sw && lv_obj_has_state(g_set_modal.auto_overwrite_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_OVERWRITE_OLDEST; if (g_set_modal.auto_chat_sw && lv_obj_has_state(g_set_modal.auto_chat_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_CHAT; if (g_set_modal.auto_rep_sw && lv_obj_has_state(g_set_modal.auto_rep_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_REPEATER; if (g_set_modal.auto_room_sw && lv_obj_has_state(g_set_modal.auto_room_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_ROOM_SERVER; if (g_set_modal.auto_sensor_sw && lv_obj_has_state(g_set_modal.auto_sensor_sw, LV_STATE_CHECKED)) mask |= AUTO_ADD_SENSOR; uint8_t manual = 1u; // touch UI is always selective: the per-type Auto switches are authoritative (off = really off) int max_hops = 3; int mh; if (g_set_modal.max_hops_ta && parseIntField(g_set_modal.max_hops_ta, mh)) max_hops = mh; if (max_hops < 0) max_hops = 0; if (max_hops > 64) max_hops = 64; g_lv.task->setAutoAddConfig(mask, static_cast<uint8_t>(max_hops), manual); } static void savePolicyCb(lv_event_t* e) { const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_VALUE_CHANGED) || !g_lv.task) return; uint8_t share = (g_set_modal.share_loc_sw && lv_obj_has_state(g_set_modal.share_loc_sw, LV_STATE_CHECKED)) ? 1u : 0u; g_lv.task->setAdvertLocationPolicy(share); // applies instantly on toggle refreshStatusLabels(); } static void saveExperimentalCb(lv_event_t* e) { const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_VALUE_CHANGED) || !g_lv.task) return; uint8_t multi = (g_set_modal.exp_multi_sw && lv_obj_has_state(g_set_modal.exp_multi_sw, LV_STATE_CHECKED)) ? 1u : 0u; uint8_t repeat = (g_set_modal.exp_repeat_sw && lv_obj_has_state(g_set_modal.exp_repeat_sw, LV_STATE_CHECKED)) ? 1u : 0u; uint8_t boost = (g_set_modal.exp_boost_sw && lv_obj_has_state(g_set_modal.exp_boost_sw, LV_STATE_CHECKED)) ? 1u : 0u; g_lv.task->setExperimentalFlags(multi, repeat, boost); #if defined(ESP32) bool dc_show = (g_set_modal.exp_dc_sw && lv_obj_has_state(g_set_modal.exp_dc_sw, LV_STATE_CHECKED)); touchPrefsSetDutyMeterShown(dc_show); #endif refreshStatusLabels(); // applies instantly on toggle (no Save button) } static void syncClockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const bool ok = g_lv.task->setDeviceTimeFromSystemClock(); g_lv.task->showAlert(ok ? TR("Clock synced") : TR("No system time yet"), 900); } // Chat-save fallback dropdown (Settings -> General): selection index -> number // of consecutive failed background history writes before the flush retries // synchronously on the UI task (0 = background retries only, never hitch). // Per-chat history cap (Settings -> Chats). "No limit" is last and warns, because an // uncapped busy channel is exactly what fills the shared ring and slows the UI down. static const uint16_t k_hist_cap_opts[] = { 100, 250, 500, 1000, 2000, 0 }; static lv_obj_t* s_hist_cap_dd = nullptr; static uint16_t s_hist_cap_pending = 0; static void histCapApplyUncapped() { touchPrefsSetHistPerChat(0); if (s_hist_cap_dd) lv_dropdown_set_selected(s_hist_cap_dd, sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]) - 1); if (g_lv.task) g_lv.task->showAlert(TR("History limit off"), 1200); } static void histCapSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; lv_obj_t* dd = lv_event_get_target(e); const uint16_t sel = lv_dropdown_get_selected(dd); if (sel >= sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0])) return; const uint16_t v = k_hist_cap_opts[sel]; if (v == 0) { // Revert first and only commit from the confirm handler: showConfirm has no cancel // callback, so a dismissed dialog must leave the old limit in place. s_hist_cap_dd = dd; uint16_t cur = touchPrefsGetHistPerChat(); uint16_t cur_sel = 1; for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) if (k_hist_cap_opts[i] == cur) { cur_sel = i; break; } lv_dropdown_set_selected(dd, cur_sel); showConfirm(TR("Turn the history limit off?\n\nA busy channel can then fill the whole message " "store, which uses more space and makes the chat list and app noticeably slower. " "Only do this if you really need the full backlog."), TR("Turn off"), histCapApplyUncapped); return; } touchPrefsSetHistPerChat(v); } static const uint8_t k_hist_sync_opts[] = { 0, 1, 2, 3, 5, 8 }; static void histSyncSelectCb(lv_event_t* e) { const uint16_t sel = lv_dropdown_get_selected(lv_event_get_target(e)); if (sel < sizeof(k_hist_sync_opts)) touchPrefsSetHistSyncAfter(k_hist_sync_opts[sel]); } static void advertNowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->showAlert(g_lv.task->sendAdvertNow() ? TR("Advert sent") : TR("Advert failed"), 900); } static void rebootCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->showAlert(TR("Rebooting..."), 600); g_lv.task->rebootDevice(); } #if defined(ESP32) // True when the running image has a spare OTA app slot BIG ENOUGH to write an // update into. Standalone wadamesh ships a dual-OTA (A/B) table with two equal // ~3.875 MB slots -> true -> in-firmware + phone-app update works. // // Installed under bmorcelli's Launcher the app runs from a slot in the Launcher's // OWN partition table, whose spare OTA slot is SMALLER than our build slot. A // mere "spare exists" test (esp_ota_get_next_update_partition != NULL) passes // there, so the update button showed and then Update.begin() failed with // "Bad Size Given" (reported by Keith/KM1USR, beta_24 under Launcher). Requiring // the target slot to be at least as large as the one we're running from keeps the // update affordances (Install / previous-version / beta toggle, gated on this) // hidden under Launcher, where updates go through the Launcher instead — the // "update manually" notice on the About page still shows. A null running/next // partition (e.g. an AppFS-hosted build) also reads as "no in-place OTA". static bool touchHasOtaUpdateSlot() { const esp_partition_t* nxt = esp_ota_get_next_update_partition(NULL); const esp_partition_t* run = esp_ota_get_running_partition(); if (nxt == NULL || run == NULL) return false; if (nxt->size < run->size) return false; #if !defined(HAS_TANMATSU) // Second guard, from a beta_21 field coredump: on a mixed/legacy layout (a // Launcher-class table with a slot at 0x200000) the slot esp_ota calls "next" // physically overlapped the region the firmware was EXECUTING from — the first // OTA erase tripped IDF's dangerous-write abort() mid-update. esp_ota resolves // "running" from the partition table + boot state, which can disagree with // reality on such installs. Cross-check: this very function's physical flash // address must fall inside the partition claimed as "running"; if it doesn't, // the table doesn't describe the image we're running and an in-place OTA could // erase live code — hide the update affordances (Launcher handles updates there). const size_t phys = spi_flash_cache2phys( reinterpret_cast<const void*>(reinterpret_cast<uintptr_t>(&touchHasOtaUpdateSlot))); if (phys == SPI_FLASH_CACHE2PHYS_FAIL) return false; if (phys < run->address || phys >= run->address + run->size) return false; #endif return true; } #endif // ---- Advert modal: pick flood (multi-hop) or zero-hop ---- static lv_obj_t* s_advert_root = nullptr; // Send-advert app page (tall topbar, like RF Monitor / Spectrum) static void closeAdvertPage(); static void advertFloodCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; bool ok = g_lv.task->sendAdvertFlood(); g_lv.task->showAlert(ok ? TR("Flood advert sent") : TR("Advert failed"), 1000); closeAdvertPage(); } static void advertZeroHopCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; bool ok = g_lv.task->sendAdvertZeroHop(); g_lv.task->showAlert(ok ? TR("Zero-hop advert sent") : TR("Advert failed"), 1000); closeAdvertPage(); } // ---- Automatic self-advert (Advertise app). Drives the STANDARD MeshCore prefs the phone app // + CLI already use (advert_interval / flood_advert_interval); MyMesh::loop() schedules from them. static const uint16_t kAutoFloodHrs[] = {0, 24, 48, 72, 168}; // min 24h — flood adverts load the whole mesh static const uint16_t kAutoLocalMin[] = {0, 60, 90, 120, 180, 240}; static uint16_t advertDdIdx(const uint16_t* arr, int n, uint16_t val) { for (int i = 0; i < n; i++) if (arr[i] == val) return (uint16_t)i; return 0; // a non-preset value (e.g. set oddly over the CLI) shows as Off until re-picked } static void advertAutoFloodCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; uint16_t i = (uint16_t)lv_dropdown_get_selected(lv_event_get_target(e)); if (i < (sizeof(kAutoFloodHrs) / sizeof(kAutoFloodHrs[0]))) touchPrefsSetFloodAdvHrs(kAutoFloodHrs[i]); } static void advertAutoLocalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; uint16_t i = (uint16_t)lv_dropdown_get_selected(lv_event_get_target(e)); if (i < (sizeof(kAutoLocalMin) / sizeof(kAutoLocalMin[0]))) touchPrefsSetLocalAdvMin(kAutoLocalMin[i]); } static void advertBootCb(lv_event_t* e) { // "On boot" switch — one-shot flood advert after boot (#76) if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetBootAdvert(lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)); } static void closeAdvertPage() { if (s_advert_root) { popupClose(&s_advert_root); } if (s_apppage_close == closeAdvertPage) { // release the tall title bar back to normal s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void advertDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // only a backdrop tap closes lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeAdvertPage(); } static void openAdvertPage() { closeAdvertPage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // Full-screen app page using the GLOBAL tall status bar ("‹ Send advert", tap = Back) — same // chrome as RF Monitor / Spectrum, instead of a modal with its own header. s_advert_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_advert_root); lv_obj_set_size(s_advert_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_advert_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_advert_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_advert_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_advert_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_advert_root, advertDismissCb, LV_EVENT_CLICKED, nullptr); s_apppage_title = "Send advert"; s_apppage_close = closeAdvertPage; statusBarSetTall(true); updateGlobalStatusBar(); const int top = STATUSBAR_H + 8; // Scroll viewport below the tall bar + a single content child that grows to its contents // (the modal used the same pattern to keep LVGL's scroll-bounds machinery happy). lv_obj_t* scroll = lv_obj_create(s_advert_root); lv_obj_remove_style_all(scroll); lv_obj_set_pos(scroll, 4, top); lv_obj_set_size(scroll, sw - 8, (sh - STATUSBAR_H) - top - 4); lv_obj_set_style_pad_all(scroll, 0, LV_PART_MAIN); lv_obj_set_scroll_dir(scroll, LV_DIR_VER); lv_obj_set_scrollbar_mode(scroll, LV_SCROLLBAR_MODE_ON); // always show — remove_style_all stripped the default bar lv_obj_set_style_width(scroll, 6, LV_PART_SCROLLBAR); lv_obj_set_style_pad_right(scroll, 1, LV_PART_SCROLLBAR); lv_obj_set_style_radius(scroll, 3, LV_PART_SCROLLBAR); lv_obj_set_style_bg_color(scroll, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(scroll, LV_OPA_80, LV_PART_SCROLLBAR); lv_obj_t* body = lv_obj_create(scroll); lv_obj_remove_style_all(body); lv_obj_set_width(body, sw - 8 - 14); // leave a gutter on the right so content clears the scrollbar lv_obj_set_height(body, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(body, 0, LV_PART_MAIN); int y = 4; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_pos(hint, 2, y); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Flood: relayed across the mesh (multi-hop).\n" "Zero-hop: neighbours only (no relaying).")); lv_obj_update_layout(hint); // measure the actual wrapped height (taller on the narrow V4) int hint_h = lv_obj_get_height(hint); y += (hint_h > 0 ? hint_h : SC(40)) + 10; // ---- The two manual advert buttons share one row (flood left, zero-hop right). ---- const int gap = 6; const int bhalf = (sw - 8 - 14 - 4 - gap) / 2; // content width (minus scrollbar gutter + margins) split in two lv_obj_t* b_flood = lv_btn_create(body); lv_obj_set_size(b_flood, bhalf, SC(40)); lv_obj_set_pos(b_flood, 2, y); styleButton(b_flood); lv_obj_add_event_cb(b_flood, advertFloodCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lf = lv_label_create(b_flood); useChainedFont(lf); lv_label_set_text(lf, TR(LV_SYMBOL_UPLOAD " Flood")); lv_obj_center(lf); lv_obj_t* b_zh = lv_btn_create(body); lv_obj_set_size(b_zh, bhalf, SC(40)); lv_obj_set_pos(b_zh, 2 + bhalf + gap, y); styleButton(b_zh); lv_obj_add_event_cb(b_zh, advertZeroHopCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lz = lv_label_create(b_zh); useChainedFont(lz); lv_label_set_text(lz, TR(LV_SYMBOL_WIFI " Zero-hop")); lv_obj_center(lz); y += SC(54); // ---- Automatic adverts: re-advertise on a timer so others keep seeing you. ---- lv_obj_t* asec = lv_label_create(body); lv_label_set_text(asec, TR("AUTOMATIC ADVERTS")); lv_obj_set_style_text_color(asec, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(asec, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(asec, 2, y); y += SC(22); // Advertise on boot (#76): fire one flood advert ~6s after boot so peers with auto-add on // refresh our pubkey (handy after a reflash wiped storage). Opt-in; off by default. lv_obj_t* boot_lab = lv_label_create(body); lv_label_set_text(boot_lab, TR("On boot")); lv_obj_set_style_text_font(boot_lab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(boot_lab, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(boot_lab, 2, y + SC(6)); lv_obj_t* boot_sw = lv_switch_create(body); lv_obj_align(boot_sw, LV_ALIGN_TOP_RIGHT, -4, y); if (touchPrefsGetBootAdvert()) lv_obj_add_state(boot_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(boot_sw, advertBootCb, LV_EVENT_VALUE_CHANGED, nullptr); y += SC(40); lv_obj_t* flab = lv_label_create(body); lv_label_set_text(flab, TR("Flood every")); lv_obj_set_style_text_font(flab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(flab, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(flab, 2, y + SC(8)); lv_obj_t* fdd = lv_dropdown_create(body); lv_dropdown_set_options_static(fdd, TR("Off\n1 day\n2 days\n3 days\n7 days")); lv_obj_set_width(fdd, SC(120)); lv_obj_align(fdd, LV_ALIGN_TOP_RIGHT, -4, y); lv_dropdown_set_selected(fdd, advertDdIdx(kAutoFloodHrs, (int)(sizeof(kAutoFloodHrs)/sizeof(kAutoFloodHrs[0])), touchPrefsGetFloodAdvHrs())); lv_obj_add_event_cb(fdd, advertAutoFloodCb, LV_EVENT_VALUE_CHANGED, nullptr); y += SC(44); lv_obj_t* llab = lv_label_create(body); lv_label_set_text(llab, TR("Local every")); lv_obj_set_style_text_font(llab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(llab, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(llab, 2, y + SC(8)); lv_obj_t* ldd = lv_dropdown_create(body); lv_dropdown_set_options_static(ldd, TR("Off\n60 min\n90 min\n2 hours\n3 hours\n4 hours")); lv_obj_set_width(ldd, SC(120)); lv_obj_align(ldd, LV_ALIGN_TOP_RIGHT, -4, y); lv_dropdown_set_selected(ldd, advertDdIdx(kAutoLocalMin, 6, touchPrefsGetLocalAdvMin())); lv_obj_add_event_cb(ldd, advertAutoLocalCb, LV_EVENT_VALUE_CHANGED, nullptr); y += SC(46); lv_obj_t* anote = lv_label_create(body); lv_label_set_long_mode(anote, LV_LABEL_LONG_WRAP); lv_obj_set_width(anote, lv_pct(100)); lv_obj_set_pos(anote, 2, y); lv_obj_set_style_text_color(anote, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(anote, &g_font_12, LV_PART_MAIN); lv_label_set_text(anote, TR("Mesh health: a flood advert is rebroadcast by every repeater in range, so frequent floods add " "airtime for the whole network. Keep them infrequent (a day or more). Your neighbours still see " "you from zero-hop adverts and your message replies, so flood mainly when distant nodes need to " "rediscover you. Leave both Off to advertise only when you tap the buttons above.")); lv_obj_move_foreground(s_advert_root); // above the tabview lv_obj_move_foreground(g_statusbar.root); // keep the tall title bar above this page (back chevron fully visible) } static void openAdvertModalCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openAdvertPage(); } // ---- Discovered list modal: shows adverts NOT yet in contacts[], with "Add" buttons ---- struct DiscoveredAddCtx { int slot_idx; }; static DiscoveredAddCtx s_disc_add_ctx[DISCOVERED_MAX]; // Forward declared so discoveredAddCb can re-open the list after a successful add. static void openDiscoveredModalCb(lv_event_t* e); // ---- Discovered-list sort (mirrors the Contacts sort) ------------------------- // Recently heard / Name / Distance, each with an ascending/descending toggle. // "Recent message" is contacts-only (discovered nodes have no message history) so // it's omitted. Distance excludes entries that have shared no location. enum DiscSortMode : uint8_t { DISC_SORT_RECENT = 0, // newest advert first (default) DISC_SORT_AZ = 1, // name A-Z DISC_SORT_DISTANCE = 2, // nearest first; no-location entries excluded DISC_SORT_COUNT = 3, }; static uint8_t g_disc_sort = DISC_SORT_RECENT; static bool g_disc_sort_desc = false; static const char* discSortOptName(uint8_t m) { switch (m) { case DISC_SORT_RECENT: return "Recently heard"; case DISC_SORT_AZ: return "Name (A-Z)"; case DISC_SORT_DISTANCE: return "Nearest first"; } return "?"; } // True if discovered entry ia sorts before ib for the current mode + direction. // `prim` < 0 = a before b in the mode's natural order; g_disc_sort_desc flips it. static bool discSortBefore(int ia, int ib, double self_lat, double self_lon) { const LvDiscoveredEntry& A = s_discovered[ia]; const LvDiscoveredEntry& B = s_discovered[ib]; int prim = 0; switch (g_disc_sort) { case DISC_SORT_DISTANCE: { const double da = contactDistanceKm(self_lat, self_lon, A.ci.gps_lat / 1.0e6, A.ci.gps_lon / 1.0e6); const double db = contactDistanceKm(self_lat, self_lon, B.ci.gps_lat / 1.0e6, B.ci.gps_lon / 1.0e6); if (da < db) prim = -1; else if (da > db) prim = 1; break; } case DISC_SORT_AZ: prim = strcasecmp(A.ci.name, B.ci.name); break; default: // DISC_SORT_RECENT — newest first if (A.recv_ms != B.recv_ms) prim = (A.recv_ms > B.recv_ms) ? -1 : 1; break; } if (prim == 0) prim = strcasecmp(A.ci.name, B.ci.name); // tiebreak by name if (g_disc_sort_desc) prim = -prim; return prim < 0; } static void openDiscSortSheetCb(lv_event_t* e); // sort button -> sheet (defined with the Contacts sheets) static void discoveredAddCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; auto* ctx = static_cast<DiscoveredAddCtx*>(lv_event_get_user_data(e)); if (!ctx) return; int idx = ctx->slot_idx; if (idx < 0 || idx >= DISCOVERED_MAX) return; LvDiscoveredEntry& e_disc = s_discovered[idx]; if (!e_disc.used) return; // Add the captured ContactInfo straight to the_mesh. if (the_mesh.addContact(e_disc.ci)) { the_mesh.uiPersistContacts(); // write /contacts3 so the add survives reboot e_disc.in_contacts = true; markDiscoveredDirty(); refreshContactsList(); // rebuild the Contacts tab now (count changed) so the // new contact shows without needing a tab reload g_lv.task->showAlert(TR("Added to contacts"), 1000); // Re-open the Discovered modal so the list updates immediately. closeSettingsModal(); lv_event_t synth{}; synth.code = LV_EVENT_CLICKED; openDiscoveredModalCb(&synth); } else { g_lv.task->showAlert(TR("Contacts full"), 1200); } } static void discoveredPurgeApply() { purgeDiscovered(); saveDiscovered(); // persist the cleared list immediately closeSettingsModal(); lv_event_t synth{}; synth.code = LV_EVENT_CLICKED; openDiscoveredModalCb(&synth); // re-open so the list shows the now-empty state } static void discoveredPurgeCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; showConfirm(TR("Purge all discovered nodes?"), TR("Purge"), discoveredPurgeApply); } // ---- Discovered settings (cogwheel): a small sheet floated over the modal ---- static lv_obj_t* s_disc_settings_root = nullptr; static void closeDiscoveredSettings() { if (s_disc_settings_root) { hideKb(); // dismiss the keyboard before the bound hop-limit textarea is freed popupClose(&s_disc_settings_root); } } static void discSettingsDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); const bool changed = discoveredSweepHops(); // apply the hop limit to existing entries closeDiscoveredSettings(); if (changed) { // re-render the Discovered list so the removed (over-limit) nodes disappear closeSettingsModal(); lv_event_t synth{}; synth.code = LV_EVENT_CLICKED; openDiscoveredModalCb(&synth); } } #if defined(ESP32) static void discAutoEvictToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetDiscoveredAutoEvict(lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)); } static void discHopsChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const char* t = lv_textarea_get_text(lv_event_get_target(e)); int v = (t && t[0]) ? atoi(t) : 0; if (v < 0) v = 0; if (v > 64) v = 64; touchPrefsSetDiscoveredMaxHops((uint8_t)v); } #endif static void openDiscoveredSettingsSheetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeDiscoveredSettings(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // Full-screen backdrop ON TOP of the Discovered modal — its 58 px Close button // is covered, so the sheet must be dismissed (tap outside) before the modal. s_disc_settings_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_disc_settings_root); lv_obj_set_size(s_disc_settings_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_disc_settings_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_disc_settings_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_disc_settings_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_disc_settings_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_disc_settings_root); lv_obj_add_event_cb(s_disc_settings_root, discSettingsDismissCb, LV_EVENT_CLICKED, nullptr); #if defined(HAS_TANMATSU) const int card_w = PCW(220); #else const int card_w = 220; #endif lv_obj_t* card = lv_obj_create(s_disc_settings_root); lv_obj_remove_style_all(card); // #109: centring the card and then lifting it a fixed -46 pushed its TOP off-screen on // the 240px-tall T-Deck (only 218px visible under the bar), so the sheet looked truncated // and could not be scrolled to reach the rest. Clamp the height to what the screen // actually has, and clamp the lift to the leftover slack so the top can never go negative. const lv_coord_t dsc_avail = lv_disp_get_ver_res(nullptr) - STATUSBAR_H; lv_coord_t dsc_h = PSC(188); if (dsc_h > dsc_avail - 8) dsc_h = dsc_avail - 8; lv_coord_t dsc_lift = -46; const lv_coord_t dsc_slack = (dsc_avail - dsc_h) / 2; if (-dsc_lift > dsc_slack) dsc_lift = (lv_coord_t)-dsc_slack; lv_obj_set_size(card, card_w, dsc_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, dsc_lift); // lift (clamped) so the keyboard clears the hop field lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Discovered settings")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); lv_obj_t* lbl = lv_label_create(card); lv_label_set_text(lbl, TR("Auto-delete oldest")); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(lbl, 0, 36); lv_obj_t* sw_e = lv_switch_create(card); lv_obj_align(sw_e, LV_ALIGN_TOP_RIGHT, 0, 30); #if defined(ESP32) if (touchPrefsGetDiscoveredAutoEvict()) lv_obj_add_state(sw_e, LV_STATE_CHECKED); lv_obj_add_event_cb(sw_e, discAutoEvictToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); #endif // Hop limit — numeric field on the right (like the switch). Type a number; the // keyboard binds on focus exactly like the contacts-search field. lv_obj_t* hlbl = lv_label_create(card); lv_label_set_text(hlbl, TR("Auto-delete above hops")); lv_obj_set_style_text_color(hlbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hlbl, 0, 80); lv_obj_t* hop_ta = lv_textarea_create(card); lv_obj_set_size(hop_ta, SC(48), SC(32)); lv_obj_align(hop_ta, LV_ALIGN_TOP_RIGHT, 0, 72); lv_textarea_set_one_line(hop_ta, true); lv_textarea_set_max_length(hop_ta, 2); lv_textarea_set_accepted_chars(hop_ta, "0123456789"); lv_obj_set_style_text_font(hop_ta, &g_font_14, LV_PART_MAIN); #if defined(ESP32) { uint8_t h = touchPrefsGetDiscoveredMaxHops(); if (h > 0) { char hb[4]; snprintf(hb, sizeof hb, "%u", h); lv_textarea_set_text(hop_ta, hb); } } attachSettingsTaEvents(hop_ta); lv_obj_add_event_cb(hop_ta, discHopsChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); #endif lv_obj_t* hint = lv_label_create(card); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, card_w - 24); lv_label_set_text(hint, TR("Auto-delete oldest makes room when full. Hops: 0 = keep all; nodes heard " "via more hops are removed.")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hint, 0, 116); } static void openDiscoveredModalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* body = createSettingsModal(TR("Discovered"), SettingsModalKind::Discovered); int y = 0; // Auto-add hint: any of the four "auto-add" type bits set means new contacts // of those kinds bypass this list and land straight in the Contacts tab. // Surfacing it here avoids the "where did my discoveries go?" confusion. { NodePrefs* prefs = the_mesh.getNodePrefs(); const uint8_t mask = prefs ? (prefs->autoadd_config & static_cast<uint8_t>( AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR)) : 0u; if (mask) { char hint[240]; // translations run long; the HU string is ~1.6x English char types[128]; types[0] = '\0'; // 4 translated plurals, UTF-8 size_t off = 0; auto add = [&](const char* s) { if (off > 0 && off + 3 < sizeof(types)) { types[off++] = ','; types[off++] = ' '; types[off] = '\0'; } size_t n = strlen(s); if (off + n < sizeof(types)) { memcpy(types + off, s, n); off += n; types[off] = '\0'; } }; if (mask & AUTO_ADD_CHAT) add(TR("chats")); if (mask & AUTO_ADD_REPEATER) add(TR("repeaters")); if (mask & AUTO_ADD_ROOM_SERVER) add(TR("rooms")); if (mask & AUTO_ADD_SENSOR) add(TR("sensors")); snprintf(hint, sizeof(hint), TR("Auto-add on for %s — new %s land in Contacts automatically."), types, types); lv_obj_t* l = lv_label_create(body); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, lv_pct(100)); lv_label_set_text(l, hint); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(l, 2, y); y += SC(38); } } // Collect the not-yet-contact discovered slots, then sort by the selected mode. const double disc_self_lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0; const double disc_self_lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0; int order[DISCOVERED_MAX]; int n = 0; for (int i = 0; i < DISCOVERED_MAX; ++i) { if (!(s_discovered[i].used && !s_discovered[i].in_contacts)) continue; // Distance sort needs a location — exclude entries with none from the results. if (g_disc_sort == DISC_SORT_DISTANCE && s_discovered[i].ci.gps_lat == 0 && s_discovered[i].ci.gps_lon == 0) continue; order[n++] = i; } // Insertion sort honouring g_disc_sort + the asc/desc flip (default: newest first). for (int i = 1; i < n; ++i) { int v = order[i]; int j = i - 1; while (j >= 0 && discSortBefore(v, order[j], disc_self_lat, disc_self_lon)) { order[j + 1] = order[j]; --j; } order[j + 1] = v; } // Header buttons left of Close: a cogwheel (Discovered settings) always, plus a // red trash (Purge all) when there's something to purge. Header is the modal // root's first child. if (g_set_modal.root) { lv_obj_t* header = lv_obj_get_child(g_set_modal.root, 0); if (header) { lv_obj_t* cog = lv_btn_create(header); lv_obj_set_size(cog, SC(32), SC(32)); lv_obj_align(cog, LV_ALIGN_RIGHT_MID, -68, 0); // just left of the 58 px "Close" (at -6) styleButton(cog); lv_obj_add_event_cb(cog, openDiscoveredSettingsSheetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(cog); useChainedFont(cl); lv_label_set_text(cl, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_color(cl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(cl); // Sort (⇅) — opens the discovered sort sheet (Recently heard / Name / Nearest, // each asc/desc). Always present so the control is visible even on an empty list. lv_obj_t* srt = lv_btn_create(header); lv_obj_set_size(srt, SC(32), SC(32)); lv_obj_align(srt, LV_ALIGN_RIGHT_MID, -106, 0); // left of the cog styleButton(srt); lv_obj_add_event_cb(srt, openDiscSortSheetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* srtl = lv_label_create(srt); useChainedFont(srtl); lv_label_set_text(srtl, LV_SYMBOL_SHUFFLE); lv_obj_set_style_text_color(srtl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(srtl); if (n > 0) { lv_obj_t* pb = lv_btn_create(header); lv_obj_set_size(pb, SC(32), SC(32)); lv_obj_align(pb, LV_ALIGN_RIGHT_MID, -144, 0); // left of the sort styleButton(pb); lv_obj_set_style_bg_color(pb, lv_color_hex(0x7A2A2A), LV_PART_MAIN); lv_obj_add_event_cb(pb, discoveredPurgeCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* pl = lv_label_create(pb); useChainedFont(pl); lv_label_set_text(pl, LV_SYMBOL_TRASH); lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(pl); } } } if (n == 0) { lv_obj_t* empty = lv_label_create(body); lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP); lv_obj_set_width(empty, lv_pct(100)); lv_obj_set_pos(empty, 2, y + 8); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); lv_label_set_text(empty, TR("No discovered nodes yet.\n\n" "Anything heard over the air that isn't in your contacts will show up " "here. When auto-add is off, you can manually add nodes to your contacts.")); return; } for (int k = 0; k < n; ++k) { int idx = order[k]; LvDiscoveredEntry& e_disc = s_discovered[idx]; // Card background — full content width (was a fixed 222 px, leaving a gap). const lv_coord_t cw = s_settings_content_w; const int pad = 4, add_w = 56, gap = 8; const lv_coord_t card_h = 52; const lv_coord_t name_h = lv_font_get_line_height(&g_font_14); const lv_coord_t text_w = cw - 2 * pad - add_w - gap; // room for name/meta beside Add lv_obj_t* card = lv_obj_create(body); lv_obj_remove_style_all(card); lv_obj_set_size(card, cw, card_h); lv_obj_set_pos(card, 0, y); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_radius(card, 6, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // Name label — clamped to ONE line (height = line height) so a long name // ellipsises instead of wrapping down onto the meta row below it. lv_obj_t* nm = lv_label_create(card); useChainedFont(nm); char nm_buf[40]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm_buf, sizeof(nm_buf), e_disc.ci.name); lv_label_set_text(nm, nm_buf[0] ? nm_buf : "(unnamed)"); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_set_size(nm, text_w, name_h); lv_obj_set_pos(nm, 2, 3); // Type + hops + key prefix (single line, ellipsised, below the name) lv_obj_t* meta = lv_label_create(card); useChainedFont(meta); const char* type_label = "node"; switch (e_disc.ci.type) { case ADV_TYPE_NONE: type_label = "none"; break; case ADV_TYPE_CHAT: type_label = "chat"; break; case ADV_TYPE_REPEATER: type_label = "repeater"; break; case ADV_TYPE_ROOM: type_label = "room"; break; case ADV_TYPE_SENSOR: type_label = "sensor"; break; default: break; } char keyhex[18]; mesh::Utils::toHex(keyhex, e_disc.ci.id.pub_key, 4); keyhex[8] = '\0'; char meta_buf[64]; snprintf(meta_buf, sizeof(meta_buf), "%s · %u hop · %s…", type_label, (unsigned)e_disc.path_len, keyhex); lv_label_set_text(meta, meta_buf); lv_obj_set_style_text_color(meta, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(meta, &g_font_12, LV_PART_MAIN); lv_label_set_long_mode(meta, LV_LABEL_LONG_DOT); lv_obj_set_width(meta, text_w); lv_obj_set_pos(meta, 2, 3 + name_h + 2); // "Add" button on the right, vertically centred lv_obj_t* add_btn = lv_btn_create(card); lv_obj_set_size(add_btn, add_w, card_h - 2 * pad - 2); lv_obj_align(add_btn, LV_ALIGN_RIGHT_MID, 0, 0); styleButton(add_btn); s_disc_add_ctx[idx].slot_idx = idx; lv_obj_add_event_cb(add_btn, discoveredAddCb, LV_EVENT_CLICKED, &s_disc_add_ctx[idx]); lv_obj_t* add_lbl = lv_label_create(add_btn); useChainedFont(add_lbl); lv_label_set_text(add_lbl, TR("Add")); lv_obj_center(add_lbl); y += card_h + 6; } } // Buffered Print over a File. The JSON export writes byte-at-a-time; on a raw // File that's one flash op per byte (a 60 KB backup ≈ 60k syscalls → a // multi-second UI freeze). Batching into 1 KB writes makes it ~60 ops. class FileBufWriter : public Print { File& _f; uint8_t _buf[1024]; size_t _n = 0; public: explicit FileBufWriter(File& f) : _f(f) {} void flushBuf() { if (_n) { _f.write(_buf, _n); _n = 0; } } size_t write(uint8_t b) override { _buf[_n++] = b; if (_n >= sizeof(_buf)) flushBuf(); return 1; } size_t write(const uint8_t* d, size_t n) override { for (size_t i = 0; i < n; ++i) { _buf[_n++] = d[i]; if (_n >= sizeof(_buf)) flushBuf(); } return n; } }; // Import settings opens a full-screen .json picker (internal flash + SD), the // same UX as the lock-wallpaper picker. Defined later in this file (where the // SD mount state is in scope); forward-declared here for the button below. static void openBackupPicker(); static void buildBackupsSettings(); // Settings -> Backups detail page (list/delete + factory reset) static void doExportBackupFile(const char* fname); // write a backup (SD if a card is present, else internal) // Heavy-flash-write watchdog guard (ref-counted, nesting-safe). A large or // fragmenting SPIFFS write can trigger garbage collection — a multi-second // flash burst that disables the CPU cache and starves the idle task, long // enough to trip the TASK watchdog (confirmed by coredump: an 'ipc0' abort from // task_wdt_isr while another thread was in spiffs_gc_clean). Bracket such writes // so a bounded-but-slow GC pass can't reset the device. The same pattern the // file-manager format/paste paths already use. Ref-counted so a nested guard // (import -> persistHistoryNow -> saveThreadsToStorage/saveMsgsToStorage) doesn't re-enable early. // wdtHeavyBegin/wdtHeavyEnd + WdtHeavyGuard now live in the shared header // helpers/esp32/WdtHeavyGuard.h (included near the top of this file) so the core // DataStore::saveContacts shares the SAME ref-count as these UI history/backup // saves: a backup import that itself calls saveContacts stays balanced and never // re-arms the watchdog early. The core-0-only rationale is documented there. // --- Settings row label helper -------------------------------------------- // The settings detail pages lay rows out with a manual y-cursor and used to // create labels with no width, so a long (translated) string overflowed the // card or slid under a right-aligned switch. This makes a width-constrained, // wrapping label and returns its laid-out height so the caller advances its // y-cursor by the real (possibly multi-line) height. `reserve_right` leaves room // for a control pinned to the card's right edge (~56 for a switch); pass 0 for a // full-width label. font==nullptr keeps the inherited default (g_font_14). The // text is run through TR() here, so callers pass the bare English literal. #if CAP_CONSOLE // Defined with the other boot-mode toggles further down, next to Remote UI's, // which is the same kind of switch. Declared here for Settings > General. static void consoleModeToggleCb(lv_event_t* e); #endif static int settingsRowLabel(lv_obj_t* body, int y, int y_off, const char* text, uint32_t color, const lv_font_t* font, int reserve_right) { lv_obj_t* l = lv_label_create(body); lv_obj_set_width(l, s_settings_content_w - 2 - reserve_right); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_label_set_text(l, TR(text)); lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN); // Always set a font. Inheriting looks equivalent but is not: the inherited // value is LVGL's raw LV_FONT_MONTSERRAT_14, which carries no fallback chain, // so every non-ASCII character in these labels rendered as a tofu box — // "Közösségi profil" came out "K□z□ss□gi profil" while the title bar beside it // was fine, because that one sets &g_font_16 explicitly. g_font_14 is the same // typeface and metrics, plus the accent/Greek/Cyrillic/Arabic fallbacks. lv_obj_set_style_text_font(l, font ? font : &g_font_14, LV_PART_MAIN); lv_obj_set_pos(l, 2, y + y_off); lv_obj_update_layout(l); return lv_obj_get_height(l); } static void buildProfileSettings() { // No "Profile" group header — it just duplicates the sub-tab button name. lv_obj_t* body = createSettingsModal("", SettingsModalKind::Profile); int y = 0; const lv_coord_t cw = s_settings_content_w; auto mk_label = [&](const char* text) { y += settingsRowLabel(body, y, 0, text, COLOR_SUB, nullptr, 0) + 2; }; auto mk_ta = [&](int w, int x, const char* ph, int max_len) -> lv_obj_t* { lv_obj_t* ta = lv_textarea_create(body); lv_obj_set_size(ta, w, SC(30)); lv_obj_set_pos(ta, x, y); lv_textarea_set_one_line(ta, true); taSetPlaceholder(ta, TR(ph)); lv_textarea_set_max_length(ta, max_len); attachSettingsTaEvents(ta); return ta; }; mk_label(TR("Node name")); g_set_modal.name_ta = mk_ta(cw, 0, TR("Node name"), 31); lv_obj_add_event_cb(g_set_modal.name_ta, saveProfileNameCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save on blur if (g_lv.task) { char nm_vis[40]; const char* raw = g_lv.task->getNodeNameCstr(); copyUtf8ReplacingMissingGlyphs(&g_font_14, nm_vis, sizeof(nm_vis), raw ? raw : ""); lv_textarea_set_text(g_set_modal.name_ta, nm_vis); } y += SC(36); mk_label(TR("Advert location")); const int loc_g = 8, loc_fw = (cw - loc_g) / 2; // lat / lon fill the row evenly g_set_modal.lat_ta = mk_ta(loc_fw, 0, TR("Latitude"), 20); g_set_modal.lon_ta = mk_ta(loc_fw, loc_fw + loc_g, TR("Longitude"), 20); lv_obj_add_event_cb(g_set_modal.lat_ta, saveProfilePosCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save the pair on blur lv_obj_add_event_cb(g_set_modal.lon_ta, saveProfilePosCb, LV_EVENT_DEFOCUSED, nullptr); if (g_lv.task) { char buf[24]; snprintf(buf, sizeof(buf), "%.6f", g_lv.task->getNodeLat()); lv_textarea_set_text(g_set_modal.lat_ta, buf); snprintf(buf, sizeof(buf), "%.6f", g_lv.task->getNodeLon()); lv_textarea_set_text(g_set_modal.lon_ta, buf); } y += SC(40); // No "Save name" / "Save position" buttons — both auto-save on blur. { auto mk_label = [&](const char* text) { y += settingsRowLabel(body, y, 0, text, COLOR_SUB, nullptr, 0) + 2; }; auto mk_switch = [&](const char* text, lv_obj_t** out) { int h = settingsRowLabel(body, y, 6, text, COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); // flush to the card's right edge if (out) *out = sw; y += LV_MAX(34, h + 12); }; // (Path-hash size moved to Radio settings as "Multi-byte routing" — it's the // same NodePrefs.path_hash_mode, so no duplicate control here.) mk_label(TR("Advert")); mk_switch(TR("Share location in advert"), &g_set_modal.share_loc_sw); NodePrefs* pol_prefs = the_mesh.getNodePrefs(); if (pol_prefs && pol_prefs->advert_loc_policy) lv_obj_add_state(g_set_modal.share_loc_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(g_set_modal.share_loc_sw, savePolicyCb, LV_EVENT_VALUE_CHANGED, nullptr); // instant apply — no Save button } // ---- Identity + Share QR (moved to the BOTTOM of the Profile page) ---- // Public key — long-press copies. We don't surface the *private* key here // (LocalIdentity::prv_key is library-private, and private material on a touch // screen is a footgun). mk_label(TR("Identity (public key)")); { const uint8_t* pk = the_mesh.getSelfPubKey(); char pk_hex[2 * PUB_KEY_SIZE + 1]; for (int i = 0; i < (int)PUB_KEY_SIZE; ++i) { snprintf(pk_hex + i*2, 3, "%02x", pk[i]); } lv_obj_t* pk_lbl = lv_label_create(body); lv_label_set_long_mode(pk_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(pk_lbl, lv_pct(100)); lv_label_set_text(pk_lbl, pk_hex); lv_obj_set_style_text_color(pk_lbl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(pk_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(pk_lbl, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_bg_opa(pk_lbl, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(pk_lbl, 4, LV_PART_MAIN); lv_obj_set_style_radius(pk_lbl, 4, LV_PART_MAIN); lv_obj_set_pos(pk_lbl, 2, y); lv_obj_add_flag(pk_lbl, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(pk_lbl, copyLabelLongPressCb, LV_EVENT_LONG_PRESSED, const_cast<char*>("pubkey")); y += SC(50); } // "Share QR" button — second entry point to the same popup as the Chats header. { lv_obj_t* sb = lv_btn_create(body); lv_obj_set_size(sb, lv_pct(100),SC(34)); lv_obj_set_pos(sb, 2, y); styleButton(sb); lv_obj_add_event_cb(sb, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openShareMyContactPopup(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(sb); lv_label_set_text(sl, TR(LV_SYMBOL_IMAGE " Share QR")); lv_obj_set_style_text_color(sl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(sl, &g_font_14, LV_PART_MAIN); lv_obj_center(sl); y += SC(42); } // "Export settings" — write a MeshCore-app-compatible JSON backup (identity, // radio/position, channels, contacts) to SD if a card is in, else internal // flash. The file opens in the stock app / web client. { lv_obj_t* eb = lv_btn_create(body); lv_obj_set_size(eb, lv_pct(100), SC(34)); lv_obj_set_pos(eb, 2, y); styleButton(eb); lv_obj_add_event_cb(eb, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Fixed, app-compatible name so the stock app / web client can read it back. doExportBackupFile("meshcore-backup.json"); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* el = lv_label_create(eb); lv_label_set_text(el, TR("Export settings")); lv_obj_set_style_text_color(el, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(el, &g_font_14, LV_PART_MAIN); lv_obj_center(el); y += SC(42); } // "Import settings" — restore a MeshCore-app-compatible JSON backup from // /meshcore-backup.json (SD if present, else internal). Replaces identity, // channels and contacts, then reboots so radio settings take effect. { lv_obj_t* ib = lv_btn_create(body); lv_obj_set_size(ib, lv_pct(100), SC(34)); lv_obj_set_pos(ib, 2, y); styleButton(ib); lv_obj_add_event_cb(ib, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openBackupPicker(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* il = lv_label_create(ib); lv_label_set_text(il, TR("Import settings")); lv_obj_set_style_text_color(il, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(il, &g_font_14, LV_PART_MAIN); lv_obj_center(il); y += SC(42); } } // Single-character spam filter. A lot of mesh spam is a 1-byte payload — the cheapest // message there is — and it still costs every receiver a notification and a chime. static void ignoreTinyMsgsChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetIgnoreTinyMsgs(on); g_lv.task->showAlert(on ? TR("1-character messages will be ignored") : TR("1-character messages will be shown"), 1600); } // Location telemetry (#266). Separate from the plain telemetry switch on purpose: // battery/environment is not position, and a single "allow telemetry" must never be // able to hand out where you are. Dropdown order matches TELEM_MODE_*: 0 deny, // 1 allow-flags (chosen contacts), 2 allow-all. static void locTelemetryModeChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; const uint8_t sel = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e)); g_lv.task->setLocationTelemetryMode(sel); const char* msg = (sel == TELEM_MODE_ALLOW_ALL) ? TR("Location shared with anyone who asks") : (sel == TELEM_MODE_ALLOW_FLAGS) ? TR("Location shared with chosen contacts only") : TR("Location sharing off"); g_lv.task->showAlert(msg, 1600); } static void pathHashModeChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; uint8_t sel = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e)); g_lv.task->setPathHashMode(sel); // existing setter: clamps to 0..2 + savePrefs char m[40]; snprintf(m, sizeof m, TR("Path hash: %u byte%s"), (unsigned)(sel + 1), sel ? "s" : ""); g_lv.task->showAlert(m, 1400); } // GPS serial baud picker (Device settings). Persisted in TouchPrefsStore and // read back at GPS init (EnvironmentSensorManager) — the T-Deck Plus GPS runs // at 38400, the older T-Deck v1.0 at 9600. Takes effect on reboot. static const uint32_t kGpsBaudOpts[] = {9600, 19200, 38400, 57600, 115200}; static const int kGpsBaudCount = (int)(sizeof(kGpsBaudOpts) / sizeof(kGpsBaudOpts[0])); static void gpsBaudChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; int sel = (int)lv_dropdown_get_selected(lv_event_get_target(e)); if (sel < 0 || sel >= kGpsBaudCount) return; touchPrefsSetGpsBaud(kGpsBaudOpts[sel]); if (g_lv.task) { char m[48]; snprintf(m, sizeof m, "GPS %lu baud — reboot to apply", (unsigned long)kGpsBaudOpts[sel]); g_lv.task->showAlert(m, 1800); } } // LVGL parents an open dropdown list to the screen layer and, when the button // sits in the lower half of a page, flips the list to drop *upward* — sliding // its top edge behind our global status bar (which lives on lv_layer_sys, above // everything) so the first option(s) get clipped. Run after the list is open & // positioned (post-CLICKED) to cap its height to the area below the status bar // and nudge it back on-screen. Wire onto every settings dropdown. static void clampDropdownListCb(lv_event_t* e) { lv_obj_t* dd = lv_event_get_target(e); lv_obj_t* list = lv_dropdown_get_list(dd); if (!list || lv_obj_has_flag(list, LV_OBJ_FLAG_HIDDEN)) return; // closed/just-closed // The open list is created on lv_scr_act, which sits BELOW the status bar (lv_layer_top), // so it tucks UNDER the topbar where they overlap. Reparent it to lv_layer_top and bring // it to the front so it always renders ON TOP of the bar. Both layers share the screen // origin, so the absolute position is preserved. if (lv_obj_get_parent(list) != lv_layer_top()) lv_obj_set_parent(list, lv_layer_top()); lv_obj_move_foreground(list); const lv_coord_t ver = lv_disp_get_ver_res(nullptr); const lv_coord_t bar = statusBarCurH(); // double height on settings pages const lv_coord_t avail = ver - bar; // usable height below the (current) bar lv_obj_update_layout(list); if (lv_obj_get_height(list) > avail) lv_obj_set_height(list, avail); // too tall -> cap + let it scroll lv_obj_update_layout(list); const lv_coord_t h = lv_obj_get_height(list); const lv_coord_t y1 = lv_obj_get_y(list); if (y1 < bar) lv_obj_set_y(list, bar); // opened up into the status bar else if (y1 + h > ver) lv_obj_set_y(list, ver - h); // hangs off the bottom edge } // Signal-probe controls also live in the home-graph "Signal & traffic" popup; // these handlers mirror it from the Radio settings page (same touch prefs). static lv_obj_t* s_radio_sig_poll_ta = nullptr; static void radioSigProbeToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetSigProbeEnabled(lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)); } // Opt-in (#64): scope direct/login/admin floods to the node's region. Persists + applies // live. OFF by default (unscoped) so cross-region login/DMs keep working. static void radioScopeDirectToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetScopeDirect(on); the_mesh.setScopeDirectFloods(on); } #if defined(HAS_TDISPLAY_P4) // T-Display P4 LoRa antenna select. Applies live (no reboot) but is deliberately SESSION-ONLY: // every boot re-forces the on-board antenna (see the boot-apply in begin() and the park in // Xl9535::powerOnSequence). Transmitting into an external connector with no antenna fitted is // what kills a PA, so the safe state has to be the one you get for free after a power cycle, // and picking the external antenna has to be a fresh, explicit decision each time. static lv_obj_t* s_p4_ant_dd = nullptr; // showConfirm takes a bare callback, so stash the widget static uint8_t s_p4_ant_pending = Xl9535::ANT_INTERNAL; static void radioP4AntennaConfirmApply() { xl9535.setAntennaMode(s_p4_ant_pending); // takes effect on the very next transmit if (s_p4_ant_dd) lv_dropdown_set_selected(s_p4_ant_dd, s_p4_ant_pending); if (g_lv.task) { g_lv.task->showAlert(s_p4_ant_pending == Xl9535::ANT_EXTERNAL ? TR("External antenna selected") : TR("Per-transmit switching on"), 1200); } } static void radioP4AntennaSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; lv_obj_t* dd = lv_event_get_target(e); const uint8_t m = (uint8_t)lv_dropdown_get_selected(dd); if (m == Xl9535::ANT_INTERNAL) { xl9535.setAntennaMode(m); return; } // always safe, no prompt // External moves BOTH directions onto the external socket, so it needs an antenna actually fitted // there — that is the one real hardware risk and it gets the damage warning. Auto (the legacy // per-TX toggle) transmits on the on-board antenna and only listens on the external socket, so it // is not a PA hazard, just permanently lopsided; it gets an accurate performance warning instead // of a scary one. Revert the widget FIRST and only re-select from the confirm handler: showConfirm // has no cancel callback, so a dismissed dialog must leave UI and hardware where they were. s_p4_ant_dd = dd; s_p4_ant_pending = m; lv_dropdown_set_selected(dd, xl9535.antennaMode()); showConfirm(m == Xl9535::ANT_EXTERNAL ? TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " "to the external antenna socket first. Transmitting with nothing attached can " "damage the radio.\n\nResets to the on-board antenna on every reboot.") : TR("Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison " "only. It transmits on the on-board antenna and listens on the external one, " "so your outbound signal will be much weaker than your inbound.\n\nResets to " "the on-board antenna on every reboot."), TR("Switch"), radioP4AntennaConfirmApply); } #endif #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 high-gain FEM LNA (~17 dB external receive amp). Persists + applies live. static void radioFemLnaToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetFemLna(on); board.setFemLnaEnable(on); } #endif // Buffered LoRa receive (experimental): a drain task lifts each packet out of the // radio within ~1 ms so a busy UI thread can't overwrite unread packets (the // missed-messages class). Persists + applies live. static void radioRxQueueToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetRxQueue(on); radio_driver.rxQueueEnable(on); } // Auto-retry sends until the mesh confirms them (echo/ACK) — mikecarper's #207 retry train. // Persists + applies live; OFF stops new trains, an in-flight one finishes its schedule. static void radioRetryEchoToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetRetryEcho(on); the_mesh.setCompanionRetryEnabled(on); } static void radioSigPollSaveCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !s_radio_sig_poll_ta) return; kbMirrorSyncToReal(); int m = atoi(lv_textarea_get_text(s_radio_sig_poll_ta)); if (m < 1) m = 1; if (m > 1440) m = 1440; touchPrefsSetSigPollMins((uint16_t)m); char b[8]; snprintf(b, sizeof b, "%d", m); lv_textarea_set_text(s_radio_sig_poll_ta, b); if (g_lv.task) { char msg[40]; snprintf(msg, sizeof msg, TR("Poll every %d min"), m); g_lv.task->showAlert(msg, 1100); } } // Semtech LoRa time-on-air (ms) for a payload, computed from the PHY config in // closed form (datasheet formula — no measurement). Drives the airtime / duty-cycle // readout in radio settings so the user can see EU-868 compliance at a glance. static uint32_t loraToaMs(uint8_t sf, float bw_khz, uint8_t crDen, int payload) { if (sf < 7) sf = 7; if (bw_khz < 1.0f) bw_khz = 250.0f; if (crDen < 5 || crDen > 8) crDen = 5; // MeshCore cr is 5..8 == coding rate 4/5..4/8 const double bw = (double)bw_khz * 1000.0; const double tSym = (double)(1u << sf) / bw; const int de = (tSym > 0.016) ? 1 : 0; // low-data-rate optimize (SF11/12 @ 125 kHz) const double tPreamble = (8 + 4.25) * tSym; // 8-symbol preamble const int num = 8 * payload - 4 * (int)sf + 28 + 16; // +16 = CRC on, explicit header const int den = 4 * ((int)sf - 2 * de); double pe = ceil((double)num / (double)(den > 0 ? den : 1)) * (double)crDen; if (pe < 0) pe = 0; return (uint32_t)((tPreamble + (8 + pe) * tSym) * 1000.0); } static void telemetryAllowChangedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); g_lv.task->setTelemetryAllow(on); g_lv.task->showAlert(on ? TR("Telemetry requests allowed") : TR("Telemetry requests off"), 1400); } static void buildRadioSettings() { // No "Radio" group header — it just duplicates the sub-tab button name. lv_obj_t* body = createSettingsModal("", SettingsModalKind::Radio); NodePrefs* prefs = the_mesh.getNodePrefs(); int y = 0; auto mk_label = [&](const char* text) { y += settingsRowLabel(body, y, 0, text, COLOR_SUB, nullptr, 0) + 2; }; auto mk_ta = [&](int w, int x, const char* ph, int max_len) -> lv_obj_t* { lv_obj_t* ta = lv_textarea_create(body); lv_obj_set_size(ta, w, SC(30)); lv_obj_set_pos(ta, x, y); lv_textarea_set_one_line(ta, true); taSetPlaceholder(ta, TR(ph)); lv_textarea_set_max_length(ta, max_len); attachSettingsTaEvents(ta); lv_obj_add_event_cb(ta, saveRadioParamsCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save the group on blur (silent if mid-edit) return ta; }; // Section divider + small header — groups this flat list into RADIO / MESH / SIGNAL. auto mk_section = [&](const char* title) { y += SC(6); lv_obj_t* sep = lv_obj_create(body); lv_obj_remove_style_all(sep); lv_obj_set_size(sep, s_settings_content_w, 1); lv_obj_set_pos(sep, 0, y); lv_obj_set_style_bg_color(sep, lv_color_hex(0x303438), LV_PART_MAIN); lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* sl = lv_label_create(body); lv_label_set_text(sl, TR(title)); lv_obj_set_style_text_color(sl, lv_color_hex(0x8A929B), LV_PART_MAIN); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(sl, 2, y + SC(7)); y += SC(28); }; // No "RADIO" divider here — it duplicated the sub-tab name; the page opens straight on // the preset picker. mk_label(TR("Community Preset")); { static const size_t PRESET_OPT_SZ = 1200; static char* preset_opt_buf = (char*)psAlloc(PRESET_OPT_SZ); // lazy-PSRAM (frees 1.2 KB internal .bss) if (preset_opt_buf) { size_t o = 0; int nw = snprintf(preset_opt_buf + o, PRESET_OPT_SZ - o, "Custom (manual)"); if (nw > 0) o += static_cast<size_t>(nw); for (size_t i = 0; i < k_mesh_radio_preset_count && o + 2 < PRESET_OPT_SZ; ++i) { preset_opt_buf[o++] = '\n'; nw = snprintf(preset_opt_buf + o, PRESET_OPT_SZ - o, "%s", k_mesh_radio_presets[i].label); if (nw < 0) break; o += static_cast<size_t>(nw); } preset_opt_buf[PRESET_OPT_SZ - 1] = '\0'; } g_set_modal.radio_preset_dd = lv_dropdown_create(body); lv_obj_set_size(g_set_modal.radio_preset_dd, lv_pct(100),SC(34)); lv_obj_set_pos(g_set_modal.radio_preset_dd, 2, y); lv_dropdown_set_options(g_set_modal.radio_preset_dd, preset_opt_buf ? preset_opt_buf : "Custom (manual)"); lv_obj_set_style_text_font(g_set_modal.radio_preset_dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(g_set_modal.radio_preset_dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(g_set_modal.radio_preset_dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(g_set_modal.radio_preset_dd, lv_color_hex(0x18191A), LV_PART_MAIN); /* Dropdown list (the popup once tapped) */ lv_obj_t* preset_list = lv_dropdown_get_list(g_set_modal.radio_preset_dd); lv_obj_set_style_bg_color(preset_list, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(preset_list, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(preset_list, &g_font_12, LV_PART_MAIN); lv_obj_add_event_cb(g_set_modal.radio_preset_dd, radioPresetChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_set_modal.radio_preset_dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(40); } const lv_coord_t cw = s_settings_content_w; mk_label(TR("Frequency / bandwidth")); { const int g = 8, fw = (cw - g) / 2; // two equal fields filling the row g_set_modal.freq_ta = mk_ta(fw, 0, TR("MHz"), 15); g_set_modal.bw_ta = mk_ta(fw, fw + g, TR("kHz"), 10); } y += SC(44); // was SC(36): only left a 6px gap below the 30px-tall boxes, crowding the label below mk_label(TR("SF / CR / TX / AF")); { const int g = 6, fw = (cw - 3 * g) / 4; // four equal fields filling the row g_set_modal.sf_ta = mk_ta(fw, 0, TR("SF"), 2); g_set_modal.cr_ta = mk_ta(fw, fw + g, TR("CR"), 2); g_set_modal.tx_ta = mk_ta(fw, 2 * (fw + g), TR("TX"), 4); g_set_modal.airtime_ta = mk_ta(fw, 3 * (fw + g), TR("AF"), 6); } y += SC(38); if (prefs) { char buf[24]; snprintf(buf, sizeof(buf), "%.3f", prefs->freq); lv_textarea_set_text(g_set_modal.freq_ta, buf); snprintf(buf, sizeof(buf), "%.1f", prefs->bw); lv_textarea_set_text(g_set_modal.bw_ta, buf); snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(prefs->sf)); lv_textarea_set_text(g_set_modal.sf_ta, buf); snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(prefs->cr)); lv_textarea_set_text(g_set_modal.cr_ta, buf); snprintf(buf, sizeof(buf), "%d", static_cast<int>(prefs->tx_power_dbm)); lv_textarea_set_text(g_set_modal.tx_ta, buf); snprintf(buf, sizeof(buf), "%.2f", prefs->airtime_factor); lv_textarea_set_text(g_set_modal.airtime_ta, buf); } if (g_set_modal.radio_preset_dd) { const int match = findMatchingMeshRadioPreset(prefs); g_radio_preset_cb_silent = true; lv_dropdown_set_selected(g_set_modal.radio_preset_dd, match < 0 ? 0 : static_cast<uint16_t>(match + 1)); g_radio_preset_cb_silent = false; } // Airtime / duty-cycle readout — derived from the current PHY config so the user // can see EU-868 compliance without doing the math. MeshCore stores airtime_factor // = (100/duty) - 1, so duty% = 100/(af+1); paired with the time-on-air per packet. { const float af = prefs ? prefs->airtime_factor : 0.0f; const int duty = (int)(100.0f / (af + 1.0f) + 0.5f); const uint32_t toa = prefs ? loraToaMs(prefs->sf, prefs->bw, prefs->cr, 40) : 0; lv_obj_t* rl = lv_label_create(body); char rb[80]; // No "≈" (U+2248) — not in the UI font, renders as tofu. "·" (U+00B7) is fine. snprintf(rb, sizeof rb, "~%d%% max duty cycle \xC2\xB7 ~%lu ms / 40B packet", duty, (unsigned long)toa); lv_label_set_text(rl, rb); lv_obj_set_width(rl, s_settings_content_w - SC(4)); lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_pos(rl, 2, y); y += SC(22); } mk_section("MESH"); // Answer telemetry requests — surfaces MeshCore's telemetry_mode_* gate (battery + // environment; location telemetry keeps its own separate setting so a simple // "allow" can't leak position). Off = ignore other nodes' requests for our telemetry. { // settingsRowLabel reserves the right 56 px for the switch; lv_obj_align // TOP_RIGHT then sits it flush to the body edge (the cw - SC(48) absolute pos // ran off-screen under the scrollbar). int h = settingsRowLabel(body, y, 4, TR("Answer telemetry requests"), COLOR_TEXT, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (prefs && prefs->telemetry_mode_base != 0) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, telemetryAllowChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(SC(40), h + SC(12)); } // Drop 1-character messages. Off by default: this filters other people's traffic, // so it is a deliberate choice rather than something we decide for the operator. { int h = settingsRowLabel(body, y, 4, TR("Ignore 1-character messages"), COLOR_TEXT, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetIgnoreTinyMsgs()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, ignoreTinyMsgsChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(SC(40), h + SC(12)); } { lv_obj_t* note = lv_label_create(body); lv_label_set_text(note, TR("Most mesh spam is a single character, because it is the cheapest " "message to send. Dropped silently: no chat entry, no notification, no sound.")); lv_obj_set_width(note, s_settings_content_w - SC(4)); lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(note, 2, y); lv_obj_update_layout(note); y += lv_obj_get_height(note) + SC(10); } // Position sharing (#266). Until now the only way to share position was to put it // in the advert, which broadcasts it in the clear to every node in range, forever. // MeshCore can answer position on REQUEST instead — encrypted to the contact that // asked, and only to contacts you picked — but nothing ever set telemetry_mode_loc, // so that whole path was unreachable. "Chosen contacts" reads the per-contact // permission set from each contact's own menu. mk_label(TR("Share my location when asked")); { lv_obj_t* dd = lv_dropdown_create(body); lv_obj_set_size(dd, lv_pct(100), SC(34)); lv_obj_set_pos(dd, 2, y); // Order MUST match TELEM_MODE_*: 0 = deny, 1 = allow-flags, 2 = allow-all. lv_dropdown_set_options(dd, TR("Never\nChosen contacts only\nAnyone who asks")); lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_t* loclist = lv_dropdown_get_list(dd); lv_obj_set_style_bg_color(loclist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(loclist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(loclist, &g_font_12, LV_PART_MAIN); uint8_t lm = prefs ? prefs->telemetry_mode_loc : TELEM_MODE_DENY; if (lm > TELEM_MODE_ALLOW_ALL) lm = TELEM_MODE_DENY; lv_dropdown_set_selected(dd, lm); lv_obj_add_event_cb(dd, locTelemetryModeChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(40); } { // Be explicit about the two things that surprise people: this is answered on // request (nothing is broadcast), and MeshCore only replies at all when plain // telemetry is allowed, so the switch above is a prerequisite rather than an // unrelated setting. lv_obj_t* note = lv_label_create(body); lv_label_set_text(note, TR("Sent only when that contact asks, encrypted to them. Nothing is broadcast. " "Needs \"Answer telemetry requests\" on. Pick contacts in a contact's menu.")); lv_obj_set_width(note, s_settings_content_w - SC(4)); lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(note, 2, y); lv_obj_update_layout(note); y += lv_obj_get_height(note) + SC(10); } // Multi-byte routing: how many bytes of each repeater's hash this node stamps // into the path when it adverts / sends. 1 byte (legacy) collides in large // regions; 2-3 bytes disambiguate. Applied immediately + persisted (no radio // reconfig). All repeaters on a path must support it (MeshCore >= v1.14); // older ones silently drop 2/3-byte packets. mk_label(TR("Multi-byte routing (path hash)")); { lv_obj_t* dd = lv_dropdown_create(body); lv_obj_set_size(dd, lv_pct(100), SC(34)); lv_obj_set_pos(dd, 2, y); lv_dropdown_set_options(dd, TR("1 byte (legacy)\n2 bytes\n3 bytes")); lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_t* phlist = lv_dropdown_get_list(dd); lv_obj_set_style_bg_color(phlist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(phlist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(phlist, &g_font_12, LV_PART_MAIN); uint8_t phm = prefs ? prefs->path_hash_mode : 0; if (phm > 2) phm = 0; lv_dropdown_set_selected(dd, phm); lv_obj_add_event_cb(dd, pathHashModeChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(40); } // Region scope: tags outgoing floods with a region so repeaters that only // re-flood their own region (region-scoped networks) still propagate them. // Public "#hashtag" region -> key = SHA256("#name"); blank = unscoped (default). mk_label(TR("Region scope (#tag, blank = none)")); g_set_modal.region_ta = mk_ta(cw, 0, TR("#region"), TOUCH_REGION_SCOPE_MAXLEN - 1); { char rbuf[TOUCH_REGION_SCOPE_MAXLEN]; if (touchPrefsGetRegionScope(rbuf, sizeof(rbuf)) > 0 && rbuf[0]) lv_textarea_set_text(g_set_modal.region_ta, rbuf); } y += SC(38); // Note: this region scope is the node-wide default. Each channel can override it // with its own region in that channel's settings. { lv_obj_t* note = lv_label_create(body); lv_label_set_text(note, TR("Channel-specific regions can be set in each channel's settings.")); lv_obj_set_width(note, s_settings_content_w - SC(4)); lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(note, 2, y); lv_obj_update_layout(note); y += lv_obj_get_height(note) + SC(8); } // #271: the regions above are the ones we SEND under. This opens the list of // regions we can RECOGNISE. Naming them in message details needs only the // name, since the key is derived from it. { lv_obj_t* rb = lv_btn_create(body); lv_obj_set_size(rb, s_settings_content_w - SC(4), SC(34)); lv_obj_set_pos(rb, 2, y); styleButton(rb); lv_obj_add_event_cb(rb, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeSettingsModal(); openRegionsModal(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* rl = lv_label_create(rb); lv_label_set_text(rl, TR(LV_SYMBOL_LIST " Known regions")); lv_obj_set_style_text_font(rl, &g_font_14, LV_PART_MAIN); lv_obj_center(rl); y += SC(40); } // Opt-in: also scope DIRECT messages / logins / remote-management floods to the region // (not just channels). For networks where your only repeater re-floods a single region. { int rh = settingsRowLabel(body, y, 6, TR("Scope direct msgs to region"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetScopeDirect()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, radioScopeDirectToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); lv_obj_t* note = lv_label_create(body); lv_label_set_text(note, TR("Enable only if your repeater re-floods just one region. Otherwise leave off — it can block cross-region login/DMs.")); lv_obj_set_width(note, s_settings_content_w - SC(4)); lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(note, 2, y); lv_obj_update_layout(note); y += lv_obj_get_height(note) + SC(8); } mk_section("SIGNAL"); #if defined(HAS_TDISPLAY_P4) // T-Display P4 antenna select: the SKY13453 on XL9535 IO1. Confirmed against LilyGo's own driver // (HIGH = RF1 = on-board, LOW = RF2 = external socket) — see the note on Xl9535.h. Internal is the // default and is re-forced at every boot; external is session-only and gated behind a confirmation, // because keying the PA into an empty socket is how you destroy one. "Auto" keeps the old // per-transmit toggle purely for comparison: it sends internal and listens external, so Trace SNR // reads lopsided on it and roughly equal on a correctly pinned antenna. mk_label(TR("LoRa antenna (P4)")); { lv_obj_t* dd = lv_dropdown_create(body); lv_obj_set_size(dd, lv_pct(100), SC(34)); lv_obj_set_pos(dd, 2, y); lv_dropdown_set_options(dd, TR("Internal (on-board)\nExternal (MMCX)\nAuto (legacy, per transmit)")); lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_t* antlist = lv_dropdown_get_list(dd); lv_obj_set_style_bg_color(antlist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(antlist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(antlist, &g_font_12, LV_PART_MAIN); lv_dropdown_set_selected(dd, xl9535.antennaMode()); // live state, not a stored pref lv_obj_add_event_cb(dd, radioP4AntennaSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(44); y += settingsRowLabel(body, y, 0, TR("Always starts on the on-board antenna after a reboot. Only pick External " "with an antenna fitted to the external socket."), COLOR_SUB, &g_font_12, 0) + 2; } #endif #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 only: the external FEM's high-gain receive amplifier (~17 dB). Bypassed by // default on the plain V4 (ON by default on the V4-R8 since prefs v49); a big win in // quiet/remote sites, but can desensitize in noisy areas. This is // SEPARATE from the SX1262's tiny internal "boosted gain". Hidden on V4.2 (no switchable LNA). if (board.femLnaControllable()) { int rh = settingsRowLabel(body, y, 4, TR("High-gain receiver (FEM LNA)"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetFemLna()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, radioFemLnaToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 10); y += settingsRowLabel(body, y, 0, TR("~17 dB amp for quiet/remote areas; turn off in noisy spots."), COLOR_SUB, &g_font_12, 0) + 2; } #endif // Buffered receive (experimental): decouple packet pickup from the UI loop. { int rh = settingsRowLabel(body, y, 4, TR("Buffered receive (experimental)"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetRxQueue()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, radioRxQueueToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 10); y += settingsRowLabel(body, y, 0, TR("Reads packets on a background task so a busy screen can't drop them."), COLOR_SUB, &g_font_12, 0) + 2; } // Auto-retry until heard (#207): resend until the mesh echoes/ACKs the packet. { int rh = settingsRowLabel(body, y, 4, TR("Retry sends until heard"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetRetryEcho()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, radioRetryEchoToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 10); y += settingsRowLabel(body, y, 0, TR("Resends a message until a repeater echoes it or the recipient ACKs. Turn off to send once only."), COLOR_SUB, &g_font_12, 0) + 2; } // Signal probe — same setting as the home-graph Signal popup. A periodic // discover advert keeps the signal bars / graph fresh. Poll interval in whole // minutes (1..1440); the toggle saves immediately, the field on its Set button. mk_label(TR("Signal probe")); { int rh = settingsRowLabel(body, y, 6, TR("Auto-discover"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetSigProbeEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, radioSigProbeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); lv_obj_t* pl = lv_label_create(body); lv_label_set_text(pl, TR("Poll (min)")); lv_obj_set_style_text_font(pl, &g_font_14, LV_PART_MAIN); // accents need the fallback chain lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(pl, 2, y + 6); s_radio_sig_poll_ta = lv_textarea_create(body); lv_obj_set_size(s_radio_sig_poll_ta, SC(50), SC(30)); lv_obj_set_pos(s_radio_sig_poll_ta, cw - 114, y); lv_textarea_set_one_line(s_radio_sig_poll_ta, true); lv_textarea_set_max_length(s_radio_sig_poll_ta, 4); attachSettingsTaEvents(s_radio_sig_poll_ta); lv_obj_add_event_cb(s_radio_sig_poll_ta, radioSigPollSaveCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save on blur { char pb[8]; snprintf(pb, sizeof pb, "%u", (unsigned)touchPrefsGetSigPollMins()); lv_textarea_set_text(s_radio_sig_poll_ta, pb); } y += SC(38); } // Auto advert — a shortcut to the Advertise app, where you send an advert now and set the // periodic flood / local self-advert timers. Surfaced here so it's discoverable from Radio & Mesh. mk_label(TR("Auto advert")); { lv_obj_t* b_adv = lv_btn_create(body); lv_obj_set_size(b_adv, lv_pct(100), SC(34)); lv_obj_set_pos(b_adv, 2, y); styleButton(b_adv); lv_obj_add_event_cb(b_adv, openAdvertModalCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_adv = lv_label_create(b_adv); useChainedFont(l_adv); lv_label_set_text_fmt(l_adv, LV_SYMBOL_UPLOAD " %s", TR("Open Advertise app")); lv_obj_center(l_adv); y += SC(42); y += settingsRowLabel(body, y, 0, TR("Send an advert now, or set how often you re-advertise."), COLOR_SUB, &g_font_12, 0) + 2; } // No "Apply radio params" button — each field auto-applies on blur (saveRadioParamsCb // via LV_EVENT_DEFOCUSED, silent until the whole group is valid). } // New-contact toast toggle (Auto-add page). Persists immediately like the // auto-add switches; default ON. Diag-log line + tab badge are unaffected — this // only gates the low-key chip in UITask::notify(). static void toggleNewContactToastCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetNewContactToast(lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)); } static void buildAutoAddSettings() { lv_obj_t* body = createSettingsModal(TR("Auto-add contacts"), SettingsModalKind::AutoAdd); NodePrefs* prefs = the_mesh.getNodePrefs(); int y = 0; auto mk_switch = [&](const char* text, lv_obj_t** out) { int h = settingsRowLabel(body, y, 6, text, COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); // flush to the card's right edge lv_obj_add_event_cb(sw, autoAddSwitchCb, LV_EVENT_VALUE_CHANGED, nullptr); // auto-save on toggle if (out) *out = sw; y += LV_MAX(34, h + 12); }; mk_switch(TR("Auto chat"), &g_set_modal.auto_chat_sw); mk_switch(TR("Auto repeater"), &g_set_modal.auto_rep_sw); mk_switch(TR("Auto room"), &g_set_modal.auto_room_sw); mk_switch(TR("Auto sensor"), &g_set_modal.auto_sensor_sw); // #178: favourites are already exempt — the core's eviction loop skips any // contact with the favourite flag — but the label never said so, leaving users // to guess whether starring a contact protected it while roaming. mk_switch(TR("Overwrite oldest non-favorite"), &g_set_modal.auto_overwrite_sw); g_set_modal.manual_add_sw = nullptr; // master "manual add" removed — per-type switches are authoritative now // "Notify on new contact" — a UI pref (not a NodePrefs autoadd bit), so it gets // its own callback + persistence rather than mk_switch's autoAddSwitchCb. { int h = settingsRowLabel(body, y, 6, TR("Notify on new contact"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetNewContactToast()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleNewContactToastCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 12); } lv_obj_t* hops_l = lv_label_create(body); useChainedFont(hops_l); lv_label_set_text(hops_l, TR("Max hops (0..64)")); lv_obj_set_style_text_color(hops_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(hops_l, 2, y + 6); g_set_modal.max_hops_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.max_hops_ta, SC(80), SC(30)); lv_obj_set_pos(g_set_modal.max_hops_ta, 142, y); lv_textarea_set_one_line(g_set_modal.max_hops_ta, true); lv_textarea_set_max_length(g_set_modal.max_hops_ta, 3); attachSettingsTaEvents(g_set_modal.max_hops_ta); lv_obj_add_event_cb(g_set_modal.max_hops_ta, saveAutoAddCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save on blur (switches already save on toggle) y += SC(38); if (prefs) { // Old "add everything" mode (manual_add_contacts == 0) ignored the per-type // bits, so reflect it by showing every Auto switch ON — they're authoritative // now, so the user can genuinely turn a type off. const bool add_all = (prefs->manual_add_contacts & 1) == 0; if (add_all || (prefs->autoadd_config & AUTO_ADD_CHAT)) lv_obj_add_state(g_set_modal.auto_chat_sw, LV_STATE_CHECKED); if (add_all || (prefs->autoadd_config & AUTO_ADD_REPEATER)) lv_obj_add_state(g_set_modal.auto_rep_sw, LV_STATE_CHECKED); if (add_all || (prefs->autoadd_config & AUTO_ADD_ROOM_SERVER)) lv_obj_add_state(g_set_modal.auto_room_sw, LV_STATE_CHECKED); if (add_all || (prefs->autoadd_config & AUTO_ADD_SENSOR)) lv_obj_add_state(g_set_modal.auto_sensor_sw, LV_STATE_CHECKED); if (prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) lv_obj_add_state(g_set_modal.auto_overwrite_sw, LV_STATE_CHECKED); char hops_buf[8]; snprintf(hops_buf, sizeof(hops_buf), "%u", static_cast<unsigned>(prefs->autoadd_max_hops)); lv_textarea_set_text(g_set_modal.max_hops_ta, hops_buf); } // No "Save auto-add" button — switches save on toggle, max-hops auto-saves on blur. } static void buildExperimentalSettings() { lv_obj_t* body = createSettingsModal(TR("Experimental"), SettingsModalKind::Experimental); NodePrefs* prefs = the_mesh.getNodePrefs(); int y = 0; auto mk_switch = [&](const char* text, lv_obj_t** out) { int h = settingsRowLabel(body, y, 6, text, COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); // flush to the card's right edge if (out) *out = sw; y += LV_MAX(34, h + 12); }; mk_switch(TR("Multi ACKs"), &g_set_modal.exp_multi_sw); mk_switch(TR("Client repeat"), &g_set_modal.exp_repeat_sw); mk_switch(TR("RX boosted gain"), &g_set_modal.exp_boost_sw); mk_switch(TR("Duty meter"), &g_set_modal.exp_dc_sw); if (prefs) { if (prefs->multi_acks) lv_obj_add_state(g_set_modal.exp_multi_sw, LV_STATE_CHECKED); if (prefs->client_repeat) lv_obj_add_state(g_set_modal.exp_repeat_sw, LV_STATE_CHECKED); if (prefs->rx_boosted_gain) lv_obj_add_state(g_set_modal.exp_boost_sw, LV_STATE_CHECKED); } #if defined(ESP32) if (touchPrefsGetDutyMeterShown()) lv_obj_add_state(g_set_modal.exp_dc_sw, LV_STATE_CHECKED); #endif // No "Save experimental" button — each switch applies instantly on toggle (the cb // re-reads all four states). Wired after the initial add_state so it isn't triggered. lv_obj_add_event_cb(g_set_modal.exp_multi_sw, saveExperimentalCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_set_modal.exp_repeat_sw, saveExperimentalCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_set_modal.exp_boost_sw, saveExperimentalCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_set_modal.exp_dc_sw, saveExperimentalCb, LV_EVENT_VALUE_CHANGED, nullptr); } // ---- Quick replies editor (Settings → Quick replies) ----------------------- static void saveQuickRepliesCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; kbMirrorSyncToReal(); int n_saved = 0; #if defined(ESP32) for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) { lv_obj_t* ta = g_set_modal.qr_tas[i]; if (!ta) continue; const char* text = lv_textarea_get_text(ta); if (touchPrefsSetQuickReply(i, text ? text : "")) ++n_saved; } #endif char msg[48]; snprintf(msg, sizeof(msg), "Saved %d quick %s", n_saved, n_saved == 1 ? "reply" : "replies"); g_lv.task->showAlert(msg, 1100); } static void buildQuickReplySettings() { lv_obj_t* body = createSettingsModal(TR("Quick replies"), SettingsModalKind::QuickReply); for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) g_set_modal.qr_tas[i] = nullptr; int y = 0; // Compact form: one row per slot, with the slot index as a tiny prefix // label so the user knows which macro they're editing. Single-line // textareas to keep the modal short; macro text caps at 31 chars on the // store side anyway, which fits one line. #if defined(ESP32) for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) { char buf[TOUCH_QUICK_REPLY_MAXLEN]; touchPrefsGetQuickReply(i, buf, sizeof(buf)); lv_obj_t* idxlbl = lv_label_create(body); char idxs[4]; snprintf(idxs, sizeof(idxs), "%d", i + 1); lv_label_set_text(idxlbl, idxs); lv_obj_set_style_text_color(idxlbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(idxlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(idxlbl, 2, y + 9); lv_obj_t* ta = lv_textarea_create(body); lv_obj_set_size(ta, SC(200), SC(32)); lv_obj_set_pos(ta, 20, y); styleCard(ta); // The 6 fields cover the whole page, so a drag always starts on a textarea. // A one-line textarea otherwise grabs the drag for its own (horizontal) // scroll and the page never scrolls — clear SCROLLABLE so the drag bubbles // up to the scrollable settings page. Tap-to-focus/typing is unaffected. lv_obj_clear_flag(ta, LV_OBJ_FLAG_SCROLLABLE); lv_textarea_set_one_line(ta, true); lv_textarea_set_max_length(ta, TOUCH_QUICK_REPLY_MAXLEN - 1); lv_textarea_set_text(ta, buf); lv_obj_set_style_text_color(ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ta, &g_font_12, LV_PART_MAIN); // Bind tap -> keyboard mirror exactly like every other settings field. // (The old composerFocusCb path is a chat-composer callback that no-ops // for a null panel, so QR fields never bound the keyboard and couldn't be // typed into; saveQuickRepliesCb already syncs the mirror back on save.) attachSettingsTaEvents(ta); g_set_modal.qr_tas[i] = ta; y += SC(36); } #endif lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),SC(34)); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_add_event_cb(b, saveQuickRepliesCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); useChainedFont(l); lv_label_set_text(l, TR("Save quick replies")); lv_obj_center(l); } static void saveScreenTimeoutCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; if (!g_set_modal.screen_to_ta) return; kbMirrorSyncToReal(); int secs = atoi(lv_textarea_get_text(g_set_modal.screen_to_ta)); if (secs < 0) secs = 0; if (secs != 0 && secs < 10) secs = 10; // 0 = never; any real timeout is at least 10 s if (secs > 3600) secs = 3600; if (g_lv.task->setScreenTimeoutSecs(static_cast<uint16_t>(secs))) { char msg[48]; if (secs == 0) snprintf(msg, sizeof(msg), TR("Screen timeout: never")); else snprintf(msg, sizeof(msg), TR("Screen timeout: %ds"), secs); g_lv.task->showAlert(msg, 1200); } else { g_lv.task->showAlert(TR("Save failed"), 1200); } } // ===== System info modal ===================================================== // Read-only diagnostic page: uptime, heap / PSRAM / flash usage, chip model // + revision, last reset reason, MeshCore + Meshcomod versions. One-shot // snapshot; no live updates while the modal is open. #if defined(ESP32) static const char* resetReasonString(esp_reset_reason_t r) { switch (r) { case ESP_RST_POWERON: return "Power on"; case ESP_RST_EXT: return "External pin"; case ESP_RST_SW: return "Software"; case ESP_RST_PANIC: return "Panic / exception"; case ESP_RST_INT_WDT: return "Int watchdog"; case ESP_RST_TASK_WDT: return "Task watchdog"; case ESP_RST_WDT: return "Other watchdog"; case ESP_RST_DEEPSLEEP: return "Deep sleep wake"; case ESP_RST_BROWNOUT: return "Brownout"; case ESP_RST_SDIO: return "SDIO"; case ESP_RST_UNKNOWN: default: return "Unknown"; } } #endif // "Memory detail" popup — heap usage by region (the ESP32 shares one heap // across all tasks, so there's no per-process split like a PC task manager; // this shows internal DRAM vs PSRAM used/free + largest free block, which is // what actually tells you where RAM is going and how fragmented it is). static lv_obj_t* s_meminfo_root = nullptr; static void closeMemInfo() { if (s_meminfo_root) { popupClose(&s_meminfo_root); } } static void memInfoCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMemInfo(); } static void openMemoryDetailCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeMemInfo(); #if defined(ESP32) auto kb = [](size_t v) -> unsigned { return (unsigned)((v + 512) / 1024); }; const size_t i_tot = heap_caps_get_total_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t i_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t i_min = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t i_lrg = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t s_tot = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); const size_t s_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); const size_t s_min = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); const size_t s_lrg = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); const size_t dma_free = heap_caps_get_free_size(MALLOC_CAP_DMA); char b[700]; int q = 0; q += snprintf(b + q, sizeof(b) - q, "Internal DRAM\n used %u / %u KB\n free %u KB (low %u)\n largest free %u KB\n\n", kb(i_tot - i_free), kb(i_tot), kb(i_free), kb(i_min), kb(i_lrg)); q += snprintf(b + q, sizeof(b) - q, "PSRAM\n used %u / %u KB\n free %u KB (low %u)\n largest free %u KB\n\n", kb(s_tot - s_free), kb(s_tot), kb(s_free), kb(s_min), kb(s_lrg)); q += snprintf(b + q, sizeof(b) - q, "DMA-capable free: %u KB\n", kb(dma_free)); q += snprintf(b + q, sizeof(b) - q, "UI loop stack free: %u B\n\n", (unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t))); q += snprintf(b + q, sizeof(b) - q, "Note: the ESP32 shares one heap\nacross all tasks - no per-task\nsplit like a PC. 'largest free'\nvs 'free' shows fragmentation."); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_meminfo_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_meminfo_root); lv_obj_set_size(s_meminfo_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_meminfo_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_meminfo_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_meminfo_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_meminfo_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_meminfo_root, memInfoCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* card = lv_obj_create(s_meminfo_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, sw - 24, (sh - STATUSBAR_H) - 24); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); addCloseXBadge(card, memInfoCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Memory detail")); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 0, 2); lv_obj_t* lbl = lv_label_create(card); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, sw - 24 - 20); lv_obj_set_pos(lbl, 0, 28); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_text(lbl, b); #endif } // Render the System-info text into buf. Re-callable so live values (uptime, // free heap) update; the inline About page calls it once at build then // refreshSysInfo() re-runs it ~1 Hz while that tab is visible. // beta_31 field-freeze tracer record (ring + logger defined with UITask::loop below). struct StallRec { uint32_t at_s; uint16_t dur_ms; const char* tag; }; extern StallRec g_stall_ring[16]; extern uint8_t g_stall_cnt, g_stall_w; // Live tier of the About text: the two blocks that genuinely change every second // (uptime + heap). Kept in their own small label so the 1 Hz refresh only re-lays // ~7 lines — re-setting the full ~40-line text made LVGL spend ~340 ms per second // rendering while About was open (the "ui:lvgl" stall-ring entries). static void sysInfoTextLive(char* buf, size_t cap) { int p = 0; #if defined(ESP32) const uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000ULL); const uint32_t up_d = up_s / 86400u; const uint32_t up_h = (up_s % 86400u) / 3600u; const uint32_t up_m = (up_s % 3600u) / 60u; const uint32_t up_ss = up_s % 60u; p += snprintf(buf + p, cap - p, "Uptime\n %ud %02uh %02um %02us\n\n", (unsigned)up_d, (unsigned)up_h, (unsigned)up_m, (unsigned)up_ss); const size_t dram_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t dram_tot = heap_caps_get_total_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t dram_min = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); const size_t psram_tot = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); p += snprintf(buf + p, cap - p, "Memory\n DRAM free: %u KB / %u KB (min %u)\n PSRAM free: %u KB / %u KB\n\n", (unsigned)(dram_free / 1024), (unsigned)(dram_tot / 1024), (unsigned)(dram_min / 1024), (unsigned)(psram_free / 1024), (unsigned)(psram_tot / 1024)); // LoRa RX health: DIO1 events vs packets actually read out. late-lost > 0 means // packets completed in the radio but were overwritten before the loop read them // (the missed-messages class); "buffered on" should hold it at zero. const uint32_t rx_evt = radio_driver.getRxEvents(); const uint32_t rx_ok = radio_driver.getPacketsRecv(); const uint32_t rx_err = radio_driver.getPacketsRecvErrors(); const uint32_t rx_lost = (rx_evt > rx_ok + rx_err) ? (rx_evt - rx_ok - rx_err) : 0; p += snprintf(buf + p, cap - p, "LoRa RX\n heard: %lu read: %lu err: %lu\n late-lost: %lu qdrop: %lu buffered: %s\n\n", (unsigned long)rx_evt, (unsigned long)rx_ok, (unsigned long)rx_err, (unsigned long)rx_lost, (unsigned long)radio_driver.getRxQueueDrops(), radio_driver.rxQueueEnabled() ? "on" : "off"); // Chat-history store health. Two failure modes this exposes that are // otherwise invisible: (a) the "wrong backend" trap — a boot whose SD mount // failed resolves the store to internal SPIFFS and every message written // that session is invisible on the next carded boot; (b) persistent ring // write failures (full card / damaged FAT) — the small threads file keeps // succeeding, so the thread list looks fresh while messages silently stop // persisting. { // Segmented store: the byte total is writer-maintained (volatile), so this // 1 Hz label costs ZERO file I/O — the old single-file size probe blocked // on the FatFs volume lock behind a sick write and froze the UI just by // WATCHING this page. const uint32_t nowms = (uint32_t)millis(); char stat[112]; char when[16]; if (s_msgs_write_fails) { char why[64]; chatSaveFailText(why, sizeof why); chatSaveStamp(when, sizeof when, s_msgs_write_fail_epoch); snprintf(stat, sizeof stat, "FAIL x%u (%s)\n %s", (unsigned)s_msgs_write_fails, when, why); } else if (s_msgs_write_ok_ms) { chatSaveStamp(when, sizeof when, s_msgs_write_ok_epoch); snprintf(stat, sizeof stat, "OK %s", when); } else snprintf(stat, sizeof stat, "none this session"); p += snprintf(buf + p, cap - p, "Chat store\n %s %d seg / %u KB%s\n last save: %s\n\n", uiDataFsIsSdCard() ? "SD /meshcomod" : "Internal flash", s_seg_count, (unsigned)(s_seg_total_bytes / 1024u), s_seg_store_ready ? "" : " (migration pending!)", stat); } #endif (void)p; (void)cap; } // Slow tier: changes rarely (an SD scan landing, a new stall entry) or never // (chip, flash, reset reason, build). refreshSysInfo() only pushes this to its // label when the generated text actually differs from what is displayed. static void sysInfoTextRest(char* buf, size_t cap) { int p = 0; #if defined(ESP32) esp_chip_info_t chip; esp_chip_info(&chip); const char* model = (chip.model == CHIP_ESP32S3) ? "ESP32-S3" : (chip.model == CHIP_ESP32S2) ? "ESP32-S2" : (chip.model == CHIP_ESP32) ? "ESP32" : "ESP32-?"; p += snprintf(buf + p, cap - p, "Chip\n %s rev %d, %d core(s)\n features:%s%s%s\n\n", model, (int)chip.revision, (int)chip.cores, (chip.features & CHIP_FEATURE_BLE) ? " BLE" : "", (chip.features & CHIP_FEATURE_WIFI_BGN) ? " WiFi" : "", (chip.features & CHIP_FEATURE_BT) ? " BT" : ""); const uint32_t flash_chip_size = ESP.getFlashChipSize(); #if defined(HAS_TANMATSU) // ESP.getSketchSize()/getFreeSketchSpace() abort() on the P4: this is an AppFS app (runs from the // appfs partition, not a standard OTA slot), so esp_ota_get_running_partition() returns null. const uint32_t sketch_size = 0; // TODO(device): surface the real AppFS app size const uint32_t sketch_free = 0; #else // Each of these runs esp_image_verify — a SHA walk over the whole ~3 MB app // image (~190 ms per call). At the 1 Hz About refresh the pair was a constant // ~390 ms/s UI stall (the "ui:sbar" entries field testers photographed) and it // flooded the stall ring, hiding the entries that mattered. The running image // cannot change while running, so pay the walk once and cache. static uint32_t s_sketch_size_c = 0, s_sketch_free_c = 0; static bool s_sketch_known = false; if (!s_sketch_known) { s_sketch_size_c = ESP.getSketchSize(); s_sketch_free_c = ESP.getFreeSketchSpace(); s_sketch_known = true; } const uint32_t sketch_size = s_sketch_size_c; const uint32_t sketch_free = s_sketch_free_c; #endif p += snprintf(buf + p, cap - p, "Flash\n chip: %u MB\n sketch: %u KB used\n app slot free: %u KB\n\n", (unsigned)(flash_chip_size / (1024u * 1024u)), (unsigned)(sketch_size / 1024u), (unsigned)(sketch_free / 1024u)); #if CAP_SD || defined(TLORA_PAGER) // microSD size + free. The FAT scan (usedBytes()) is done by the core-0 worker — never // on this UI thread — so the About sheet can't freeze on it. We just read the worker's // cached result here and request a (re)scan at most every 30 s. { static uint32_t sd_req_ms = 0; const uint32_t nowm = millis(); // Once armed, only the worker may clear this request (after publishing // busy). A loop-side timeout creates a false/false window in the VFS gate // and can let SD.end() race the worker's totalBytes()/usedBytes() scan. if (!s_sdinfo_request && (sd_req_ms == 0 || (uint32_t)(nowm - sd_req_ms) >= 30000)) { sd_req_ms = nowm ? nowm : 1; if (ensureTileFetchTaskRunning()) s_sdinfo_request = true; // worker rescans off-thread } if (!s_sdinfo_done) p += snprintf(buf + p, cap - p, "microSD\n \xE2\x80\xA6\n\n"); // … (computing) else if (s_sdinfo_ok) p += snprintf(buf + p, cap - p, "microSD\n size: %llu MB\n free: %llu MB\n\n", (unsigned long long)(s_sdinfo_tot / (1024ull * 1024ull)), (unsigned long long)(s_sdinfo_free / (1024ull * 1024ull))); else p += snprintf(buf + p, cap - p, "microSD\n not mounted\n\n"); } #endif // NVS usage across the whole 'nvs' partition (shared by Wi-Fi creds, the // Launcher, and our settings). Near-100% is what triggers the boot loop. nvs_stats_t nvs{}; if (nvs_get_stats(NULL, &nvs) == ESP_OK && nvs.total_entries > 0) { const unsigned pct = (unsigned)((nvs.used_entries * 100ULL) / nvs.total_entries); p += snprintf(buf + p, cap - p, "NVS (settings store)\n %u / %u entries (%u%%)\n free: %u namespaces: %u\n\n", (unsigned)nvs.used_entries, (unsigned)nvs.total_entries, pct, (unsigned)nvs.free_entries, (unsigned)nvs.namespace_count); } // Contact store pressure — the #222 diagnostic. The table is a flat file of 152-byte // records, so its size follows the contact count directly, and on a card-less board it // shares the internal volume with one blob file per contact. SPIFFS GC cost is driven by // how FULL that volume is, and GC suspends the flash cache, which stalls both cores. So // "used %" here is the number that predicts freezes; a reporter can photograph it. { const int nc = the_mesh.getNumContacts(); p += snprintf(buf + p, cap - p, "Contact store\n %d / %d contacts (~%u KB)\n", nc, (int)MAX_CONTACTS, (unsigned)(((uint32_t)nc * 152u) / 1024u)); const size_t sp_tot = SPIFFS.totalBytes(), sp_used = SPIFFS.usedBytes(); if (sp_tot) { p += snprintf(buf + p, cap - p, " internal flash: %u / %u KB (%u%%)\n", (unsigned)(sp_used / 1024u), (unsigned)(sp_tot / 1024u), (unsigned)((uint64_t)sp_used * 100ull / sp_tot)); } DataStore* ds = the_mesh.getStore(); if (ds && ds->lastContactsSaveValid()) { p += snprintf(buf + p, cap - p, " last save: %s, %u rec, %u ms\n", ds->lastContactsSaveInPlace() ? "in-place" : "FULL rewrite", (unsigned)ds->lastContactsSaveRecs(), (unsigned)ds->lastContactsSaveMs()); } const uint32_t orph = the_mesh.getOrphanedBlobs(); if (orph) p += snprintf(buf + p, cap - p, " orphaned blobs: %lu\n", (unsigned long)orph); p += snprintf(buf + p, cap - p, "\n"); } p += snprintf(buf + p, cap - p, "Last reset\n %s\n\n", resetReasonString(esp_reset_reason())); #endif // beta_31 field-freeze tracer: recent loop stalls (>0.2 s), newest first — a // field tester photographs this instead of needing a serial console. p += snprintf(buf + p, cap - p, "Loop stalls (>0.2s)\n"); if (g_stall_cnt == 0) { p += snprintf(buf + p, cap - p, " none recorded\n\n"); } else { const uint32_t nows = millis() / 1000u; for (int i = 0; i < (int)g_stall_cnt && i < 6; ++i) { const int idx = ((int)g_stall_w - 1 - i + 32) % 16; const StallRec& r = g_stall_ring[idx]; p += snprintf(buf + p, cap - p, " -%lus %s %ums\n", (unsigned long)(nows - r.at_s), r.tag, (unsigned)r.dur_ms); } p += snprintf(buf + p, cap - p, "\n"); } p += snprintf(buf + p, cap - p, "Build\n %s\n %s\n WADAMESH TOUCH\n", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); #if defined(WADAMESH_FORK_BUILD) // Defined in platformio.ini for this fork's builds; absent upstream. p += snprintf(buf + p, cap - p, " unofficial fork build\n"); #endif (void)p; } // Re-render the System-info text in place while the About sub-tab is visible. static void refreshSysInfo(unsigned long now) { static unsigned long next = 0; if ((long)(now - next) < 0) return; if (!s_sysinfo_lbl || getActiveTab() != SETTINGS_TAB_INDEX || s_settings_open_cat != CAT_ABOUT) { next = now + 1000; // not on the About sheet — just rate-limit return; } // Re-setting a label re-wraps + repaints it; doing that every second WHILE the // user drags or flings the page stutters the scroll badly. Hold off while a // pointer is pressed or a scroll/throw is in flight, and re-check soon so the // live uptime/heap resumes the instant they let go. for (lv_indev_t* in = lv_indev_get_next(nullptr); in; in = lv_indev_get_next(in)) { if (lv_indev_get_scroll_dir(in) != LV_DIR_NONE) { next = now + 120; return; } } next = now + 1000; char buf[704]; sysInfoTextLive(buf, sizeof buf); lv_label_set_text(s_sysinfo_lbl, buf); // Slow tier: pushing identical text would still invalidate + repaint the whole // ~30-line label (~340 ms of LVGL render per pass) — skip unless it changed. if (s_sysinfo_rest_lbl) { sysInfoTextRest(buf, sizeof buf); if (strcmp(lv_label_get_text(s_sysinfo_rest_lbl), buf) != 0) lv_label_set_text(s_sysinfo_rest_lbl, buf); } } #if defined(HAS_TDECK_GT911) // Re-render the idle-sleep instrumentation labels ~1 Hz while the Lock settings // panel is open. Mirrors the refreshSysInfo() guard: skip if the right sheet is // not visible, or while the user is mid-scroll (avoids layout churn). static void refreshSleepDiag(unsigned long now) { static unsigned long next = 0; if ((long)(now - next) < 0) return; if (!s_sleep_diag_lbl || !s_sleep_diag_lbl2 || getActiveTab() != SETTINGS_TAB_INDEX || s_settings_open_cat != CAT_LOCK) { next = now + 1000; return; } for (lv_indev_t* in = lv_indev_get_next(nullptr); in; in = lv_indev_get_next(in)) { if (lv_indev_get_scroll_dir(in) != LV_DIR_NONE) { next = now + 120; return; } } next = now + 1000; char buf[64]; snprintf(buf, sizeof buf, "%s · cycles %lu · asleep %u%%", touchSleep::isSleeping() ? "sleeping" : "awake", (unsigned long)touchSleep::wakeCount(), (unsigned)touchSleep::pctAsleep()); lv_label_set_text(s_sleep_diag_lbl, buf); char buf2[48]; snprintf(buf2, sizeof buf2, "%s%s", TR("last wake: "), tsWakeReasonStr(touchSleep::lastWakeReason())); lv_label_set_text(s_sleep_diag_lbl2, buf2); } #endif // HAS_TDECK_GT911 static void buildSystemInfoSettings() { lv_obj_t* body = createSettingsModal(TR("System info"), SettingsModalKind::SystemInfo); char buf[704]; // Live tier (uptime + heap): a small constant-height label refreshed at 1 Hz. lv_obj_t* lbl = lv_label_create(body); s_sysinfo_lbl = lbl; lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, lv_pct(100)); lv_obj_set_pos(lbl, 2, 0); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); sysInfoTextLive(buf, sizeof buf); lv_label_set_text(lbl, buf); // Slow tier below it: chip/flash/SD/NVS/stalls/build — re-set only on change. lv_obj_update_layout(lbl); lv_obj_t* rest = lv_label_create(body); s_sysinfo_rest_lbl = rest; lv_label_set_long_mode(rest, LV_LABEL_LONG_WRAP); lv_obj_set_width(rest, lv_pct(100)); lv_obj_set_pos(rest, 2, lv_obj_get_y(lbl) + lv_obj_get_height(lbl) + 2); lv_obj_set_style_text_font(rest, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(rest, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); sysInfoTextRest(buf, sizeof buf); lv_label_set_text(rest, buf); // "Memory detail" button below the info text (per-region heap breakdown). lv_obj_update_layout(rest); lv_obj_t* membtn = lv_btn_create(body); lv_obj_set_size(membtn, lv_pct(96), SC(34)); lv_obj_set_pos(membtn, 2, lv_obj_get_y(rest) + lv_obj_get_height(rest) + 8); styleButton(membtn); lv_obj_add_event_cb(membtn, openMemoryDetailCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* mbl = lv_label_create(membtn); lv_label_set_text(mbl, TR("Memory detail")); lv_obj_set_style_text_font(mbl, &g_font_14, LV_PART_MAIN); lv_obj_center(mbl); } // Map tile source: false = tile server + on-device cache, true = read tiles off the microSD. // The toggle + its callback (mapOptTilesSdCb) live in the map options popup, further down. static bool s_tiles_from_sd = false; // Map night mode: invert tile colours at render time only (the cached JPEG/PNG is // never altered). Loaded from prefs at boot; toggled in the map options popup. static bool s_map_night = false; // Map tile style: 0 = OpenStreetMap (default), 1 = OpenTopoMap (topographic relief). // Loaded from prefs at boot; toggled in the map options popup. Topo is namespaced // in the on-disk cache (/tiles/topo) and fetched via the proxy's /opentopo route, // so the OSM and topo caches never collide and toggling is instant for tiles that // are already cached. SD map packs are OSM-only, so topo always uses the online // cache + fetch (see loadTileJpeg / queueTileForFetch). static uint8_t s_map_style = 0; static inline const char* mapTileRoot() { return s_map_style == 1 ? "/tiles/topo" : "/tiles"; } // on-disk cache root static inline const char* mapTileUrlPath() { return s_map_style == 1 ? "/opentopo" : ""; } // proxy route segment // Distance units toggle (km <-> miles). Applies immediately — saves the // pref and forces the contacts list to re-render its distance badges. #if CAP_SD || defined(TLORA_PAGER) // Store all data (identity/prefs/contacts/channels) on SD under /meshcomod vs // internal SPIFFS. Read at boot before data loads, so it only takes effect on // the next reboot. static void useSdStorageToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetUseSdStorage(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Data -> SD card on reboot\n(card must be inserted)") : TR("Data -> internal on reboot"), 1800); } #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) // "Copy internal data to SD": recovery for the beta_36 upgrades where the live // profile was orphaned on internal flash while the honored SD toggle adopted an // empty card. Pager resumes only onto a card with no identity or the identical // profile; legacy targets retain their explicit overwrite behavior. On success // this forces the SD pref on and reboots. extern bool meshcomodMigrateSpiffsToSd(bool force); // main.cpp extern bool meshcomodPrepareSdMigration(); // clears the prior commit marker / claims empty Pager tree #if defined(TLORA_PAGER) extern bool meshcomodSdProfileMatchesInternal(); // compares bytes without logging identity data #endif extern bool meshcomodArmSdMigLatch(); // durable in-progress guard extern void meshcomodClearSdMigLatch(); // main.cpp (GH #142/#148 boot safe-mode) extern bool g_sd_migration_blocked; extern bool g_full_data_on_sd; // boot chose SD for identity/prefs static bool sdRuntimeLifecycleBusy(); static bool s_sd_restore_pending = false; // Run the blocking filesystem walk only after lv_timer_handler() has returned. // Doing it directly in the confirmation callback stacked the migration's File // objects and path buffers on top of LVGL's event/render frames; on Pager that // eventually corrupted a source path and made SPIFFS reads fail mid-copy. // Quiesce both persistence workers before enumerating SPIFFS so no handle can // be rewritten underneath the walk. static void sdRestoreRun() { if (!s_sd_restore_pending || !g_lv.task) return; if (sdRuntimeLifecycleBusy()) return; s_sd_restore_pending = false; if (!fmSdTryMount()) { g_lv.task->showAlert(TR("No SD card"), 1600); return; } WdtHeavyGuard idle_wdt_guard; // Land everything in RAM first. The history worker and queued A/B preference // snapshots must both be idle before the migration opens either filesystem. g_lv.task->persistHistoryNow(); discoveredFlushNow(); the_mesh.flushContactsIfDirty(); the_mesh.persistSyncHistoryNow(); if (!touchPrefsFlush()) { g_sd_migration_blocked = true; g_lv.task->showAlert(TR("Copy blocked: internal data is busy"), 2600); return; } // A separate SD consumer (tile/notification/info worker) may have started // while the internal writers were draining. Defer instead of overlapping // its open FAT handle with the migration. if (sdRuntimeLifecycleBusy()) { s_sd_restore_pending = true; return; } #if defined(TLORA_PAGER) if (!meshcomodSdProfileMatchesInternal()) { g_lv.task->showAlert(TR("Copy blocked: SD card holds a different or unreadable profile"), 3600); return; } #endif if (!meshcomodPrepareSdMigration()) { g_sd_migration_blocked = true; g_lv.task->showAlert(TR("Copy blocked: SD profile could not be prepared safely"), 3200); return; } if (!meshcomodArmSdMigLatch()) { g_sd_migration_blocked = true; g_lv.task->showAlert(TR("Copy blocked: migration guard unavailable"), 2600); return; } const UBaseType_t low_water = uxTaskGetStackHighWaterMark(nullptr); Serial.printf("[BOOT] deferred SD migration, loop stack low-water: %u bytes\n", (unsigned)(low_water * sizeof(StackType_t))); LoopWdtGuard loop_wdt_guard; #if defined(TLORA_PAGER) const bool ok = meshcomodMigrateSpiffsToSd(false); #else const bool ok = meshcomodMigrateSpiffsToSd(true); #endif if (!ok) { g_sd_migration_blocked = true; g_lv.task->showAlert(TR("Copy incomplete: SD data may be mixed. Retry before reboot."), 4200); return; } meshcomodClearSdMigLatch(); // manual copy succeeded -> re-arm boot auto-adoption (GH #142/#148) touchPrefsSetUseSdStorage(true); // make sure the reboot adopts the card delay(400); // let the alert render before the restart board.reboot(); } static void sdRestoreApply() { if (!g_lv.task) return; if (s_sd_restore_pending) return; #if defined(TLORA_PAGER) g_lv.task->showAlert(TR("Resuming missing files to SD...\nThis may take a few minutes; keep powered."), 180000); #else g_lv.task->showAlert(TR("Copying internal data to SD...\nThis may take a few minutes; keep powered."), 180000); #endif s_sd_restore_pending = true; } static void sdRestoreFromInternalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(TLORA_PAGER) showConfirm(TR("Copy only missing internal files to a matching SD profile,\nkeep existing SD data, then reboot?"), TR("Resume"), sdRestoreApply); #else showConfirm(TR("Overwrite the SD card's settings and\nidentity with the internal copies,\nthen reboot?"), TR("Copy"), sdRestoreApply); #endif } #endif #endif static void useMilesToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool miles = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetUseMiles(miles); #endif if (g_lv.task) g_lv.task->showAlert(miles ? TR("Distance: miles") : TR("Distance: km"), 900); // The contacts cache now keys on the units flag, so this rebuilds the // badges on the next refresh (and immediately if the list is visible). refreshContactsList(); } // Enter key sends the chat message (default) vs. inserts a newline so you send // only via the on-screen button — Tim kept firing messages on the public // channel by accident. Physical keyboard only (the on-screen keyboard's // checkmark never auto-sent). #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) static void enterSendsToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetEnterSends(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Enter sends messages") : TR("Enter adds a new line"), 1200); } // New-message notify flash (opt-in): the T-Deck analog of the Tanmatsu envelope LED. On a new // message we wake the screen and briefly pulse the keyboard backlight. The statics below are set // here / in notify() and consumed in the loop()'s keyboard-backlight tick. static volatile uint32_t s_msgflash_until = 0; // keyboard-backlight pulse deadline (0 = idle) static volatile bool s_msgflash_wake = false; // one-shot screen-wake request (consumed in loop()) static uint32_t s_notify_wake_ms = 0; // screen lit BY a message (not input) at this ms — re-dims after a short window (burn-in) static void msgFlashToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetMsgFlash(on); if (on) { s_msgflash_until = millis() + 1600; s_msgflash_wake = true; } // brief confirmation pulse } #endif // 12-hour vs 24-hour clock. Applies on the next time render (status bar, chat // rows, message bubbles). static void clock12hToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetClock12h(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Clock: 12-hour") : TR("Clock: 24-hour"), 900); } #if CAP_UI_SIZE // UI-size selector — saves the board-specific preset. Fonts and any matching // geometry are selected while the UI tree is built, so prompt for a restart. static void uiScaleSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetUiScale((uint8_t)lv_dropdown_get_selected(lv_event_get_target(e))); if (g_lv.task) g_lv.task->showAlert(TR("UI size saved — restart to apply"), 2200); } #endif // Hard-lock (not just dim) when the screen idles off, so the touchscreen is #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) // Toggle idle light-sleep via the Settings row. Updates NVS, the live // touchSleep state, and the status-bar icon in one shot (mirrors lockOnScreenOffToggleCb). static void sleepIdleToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetSleepIdle(on); #endif touchSleep::setEnabled(on); updateGlobalStatusBar(); if (g_lv.task) g_lv.task->showAlert(on ? TR("Idle sleep enabled") : TR("Idle sleep disabled"), 1200); } #endif // inert until a deliberate unlock. Cached in s_lock_on_screen_off so the loop's // idle check never hits NVS. static void lockOnScreenOffToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetLockOnScreenOff(on); #endif s_lock_on_screen_off = on; #if defined(HAS_TDECK_GT911) const char* unlock_hint = on ? TR("Locks when screen off\n(hold the trackball to unlock)") : TR("Screen-off just dims"); #elif defined(HAS_THINKNODE_M9) const char* unlock_hint = on ? TR("Locks when screen off\n(hold the d-pad to unlock)") : TR("Screen-off just dims"); #elif defined(HAS_TANMATSU) const char* unlock_hint = on ? TR("Locks when screen off\n(press Volume Down to unlock)") : TR("Screen-off just dims"); #elif defined(TLORA_PAGER) const char* unlock_hint = on ? TR("Locks when screen off\n(hold Backspace to unlock)") : TR("Screen-off just dims"); #else const char* unlock_hint = on ? TR("Locks when screen off\n(press the button to unlock)") : TR("Screen-off just dims"); #endif if (g_lv.task) g_lv.task->showAlert(unlock_hint, 1600); } // "At a glance" is read straight from NVS at message time (not cached), same as // the sound/DND checks right next to its call site in newMsgImpl -- it's not a // per-loop hot path, just once per inbound message. static void glanceWhenLockedToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetGlanceWhenLocked(on); #endif if (g_lv.task) { g_lv.task->showAlert(on ? TR("Glance shows message previews\nwhile the screen is locked") : TR("Glance only shows when unlocked"), 1600); } } // Master "At a glance" toggle (Options > Display): gates the whole feature. // The secondary "while locked" switch's own saved preference is left // alone -- only its editability follows the master, so re-enabling always // restores its true prior state. static void glanceEnabledToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetGlanceEnabled(on); #endif if (g_set_modal.glance_locked_sw) { if (on) lv_obj_clear_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); else lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); } if (g_lv.task) g_lv.task->showAlert(on ? TR("At a glance enabled") : TR("At a glance disabled"), 1200); } #if CAP_TRACKBALL // Invert the scrollball direction everywhere it drives motion (cursor, map pan, // emoji selector). Cached in s_tb_reverse so the per-tick poll never hits NVS. static void scrollReverseToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetScrollReverse(on); #endif s_tb_reverse = on; if (g_lv.task) g_lv.task->showAlert(on ? TR("Scrollball: reversed") : TR("Scrollball: normal"), 900); } // Edge scroll: when cursor hits a screen edge, excess trackball motion scrolls // the content under the cursor instead of being absorbed. Applied live. static void edgeScrollToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetEdgeScroll(on); #endif s_tb_edge_scroll = on; } // Keyboard ESDFX navigation: off (default) vs on (E/X/S/F move focus, D select, Q // back, while no text field is focused). Applied live: flip the cached flag; when // turning OFF, empty the focus group so the amber ring clears on the next loop. static void kbdNavToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetKbdNav(on); #endif s_kbd_nav = on; if (on) { // #45: turning nav ON rebuilds the focus group; with no prior focus to keep it lands on the FIRST // item and the settings list snaps to the top. Pin focus to the switch you just toggled instead. s_nav_focus_hint = lv_event_get_target(e); navMarkDirty(); // force the rebuild that consumes the hint } else { navHideFocus(); // clear + hide the focus highlight if (s_nav_group) { lv_group_remove_all_objs(s_nav_group); navMarkDirty(); } } navMenubarKeysSync(); // show/hide the per-tab key hints in the menubar if (g_lv.task) g_lv.task->showAlert(on ? TR("Keyboard nav: WASDZ on") : TR("Keyboard nav: off"), 1100); } #if CAP_TRACKBALL // Trackball navigation: ON (default) drives the focus-group D-pad (up/down/left/right move the // selection, centre click selects — the same 2D logic the Tanmatsu keypad uses); OFF is the // soft mouse cursor. Applied live: flip the cached flag; updateTrackball + navMaybeRebuild // (in the loop, gated on s_kbd_nav || s_tb_nav) pick it up next tick. static void tbNavToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetTbNav(on); #endif s_tb_nav = on; if (on) { navMarkDirty(); // (re)populate the focus group so the trackball has targets } else { navHideFocus(); // back to cursor: drop the amber ring if (!s_kbd_nav && s_nav_group) lv_group_remove_all_objs(s_nav_group); // free the group if keys aren't using it } if (g_lv.task) g_lv.task->showAlert(on ? TR("Trackball: navigate UI") : TR("Trackball: cursor"), 1100); } #endif // "Show menu-bar letters": reveal/hide the per-tab hotkey letters over the menubar icons. // Default off (the letters never show unless the user turns this on). static void navMbarKeysToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetNavMenubarKeys(on); #endif s_nav_mbar_keys = on; navMenubarKeysSync(); // apply immediately } #endif // Hide the device/profile name in the status bar and park the clock on the left // where the name used to be. Applies immediately via updateGlobalStatusBar(). static void hideNameToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool hide = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetHideNodeName(hide); #endif updateGlobalStatusBar(); // hide/show the name + reposition the clock now if (g_lv.task) g_lv.task->showAlert(hide ? TR("Device name hidden") : TR("Device name shown"), 1000); } // Colourful chat bubbles toggle. On enable: the "taste the rainbow" easter egg. // Bubbles recolour the next time a chat opens (this control lives on Settings). static void colorfulBubblesToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetColorfulBubbles(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Taste the rainbow!") : TR("Chat bubbles: plain"), on ? 1500 : 900); } // Compact chat toggle (wyvern.red): IRC-style dense rows instead of bubbles. An // open chat re-renders immediately so the switch gives instant feedback. static void compactChatToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetCompactChat(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Compact messages") : TR("Chat bubbles"), 1000); if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); } #if defined(HAS_EXPANSION_KIT) // V4 Expansion Kit: show/hide the Sensors tab + Home env widget. The tab layout // is decided in buildUiTree, so this only persists the pref and prompts for a // restart (it intentionally does not rebuild the live tree). static void showSensorsTabToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetShowSensorsTab(on); #endif if (g_lv.task) g_lv.task->showAlert(on ? TR("Sensors tab: on (restart to apply)") : TR("Sensors tab: off (restart to apply)"), 1800); } #endif // Accent-popup picker on/off. Persisted + live: gates accentBoxMaybeShow() so // the tap-to-pick accent box stops appearing as you type. Default ON. // (Not compiled on the M9 — the pickers are suppressed there and its settings // row is hidden, see accentBoxMaybeShow.) #if !defined(HAS_M9_KEYBOARD) static void accentPopupsToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); s_accent_popups = on; #if defined(ESP32) touchPrefsSetAccentPopups(on); #endif if (!on) accentBoxHide(); // dismiss any box already on screen if (g_lv.task) g_lv.task->showAlert(on ? TR("Accent popups: on") : TR("Accent popups: off"), 1100); } #endif // One handler for every per-language switch in the multi-select list. The // layout id is stashed in the switch's user_data. Flipping a switch updates the // enabled-mask (persisted + live) and refreshes the on-screen keyboard, which // snaps back to English if the layout that was just switched off was active. static void kbLayoutSwitchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; lv_obj_t* sw = lv_event_get_target(e); int id = (int)(intptr_t)lv_obj_get_user_data(sw); if (id <= 0 || id >= KEYBOARD_LAYOUT_COUNT) return; bool on = lv_obj_has_state(sw, LV_STATE_CHECKED); #if defined(ESP32) uint16_t mask = touchPrefsGetEnabledLayouts(); if (on) mask |= (uint16_t)(1u << id); else mask &= (uint16_t)~(1u << id); touchPrefsSetEnabledLayouts(mask); keyboardLayoutsSetEnabledMask(mask); // SetEnabledMask snaps the cached layout to EN if it was just disabled; // re-apply it so the keyboard widget reflects the (possibly changed) layout. keyboardLayoutsApply(g_lv.keyboard, keyboardLayoutsGetCurrent()); touchPrefsSetKeyboardLayout(static_cast<uint8_t>(keyboardLayoutsGetCurrent())); #endif if (g_lv.task) { char buf[40]; snprintf(buf, sizeof(buf), "%s: %s", keyboardLayoutName(static_cast<KeyboardLayoutId>(id)), on ? "on" : "off"); g_lv.task->showAlert(buf, 800); } } // Screen orientation: the persistent base rotation, applied at boot. Tapping // cycles Portrait -> Landscape -> Landscape (flipped) and reboots so the whole // UI rebuilds at the chosen resolution (rebootDevice() saves chat history // synchronously first, so nothing is lost). static const char* uiRotationLabel() { #if defined(ESP32) // Portrait (0) vs Landscape (anything else). The button toggles between the // two so a single tap always returns to portrait from any landscape. return (touchPrefsGetUiRotation() == LV_DISP_ROT_NONE) ? "Screen: Portrait" : "Screen: Landscape"; #else return "Screen: Portrait"; #endif } static void rotateScreenCycleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(ESP32) // Simple toggle: portrait <-> landscape (90 degrees). One tap each way. const uint8_t cur = touchPrefsGetUiRotation(); const uint8_t next = (cur == LV_DISP_ROT_NONE) ? (uint8_t)LV_DISP_ROT_90 : (uint8_t)LV_DISP_ROT_NONE; touchPrefsSetUiRotation(next); #endif if (g_lv.task) { g_lv.task->showAlert(TR("Rotating\xe2\x80\xa6 rebooting"), 600); g_lv.task->rebootDevice(); } } // ---- Manual clock offset (Device modal): nudge local time +/- whole hours ---- // Applied on top of the automatic (NTP / companion / mesh) time, purely at the // display layer via the POSIX TZ; the RTC and mesh timestamps stay UTC. static lv_obj_t* s_time_offset_lbl = nullptr; // current offset readout (modal is a singleton) static void applyTimeOffsetNow() { char tz[48]; touchPrefsBuildLocalTz(tz, sizeof tz); // rebuilds from the saved offset setenv("TZ", tz, 1); tzset(); // every later localtime_r() reflects it } static void timeOffsetLabelRefresh() { if (!s_time_offset_lbl) return; int off = touchPrefsGetTimeOffsetHours(); char b[16]; if (off == 0) snprintf(b, sizeof b, "0 h"); else snprintf(b, sizeof b, "%+d h", off); lv_label_set_text(s_time_offset_lbl, b); } static void timeOffsetStep(int delta) { int off = touchPrefsGetTimeOffsetHours() + delta; if (off < -23) off = -23; if (off > 23) off = 23; touchPrefsSetTimeOffsetHours(off); applyTimeOffsetNow(); timeOffsetLabelRefresh(); if (g_lv.task) { char m[28]; snprintf(m, sizeof m, TR("Time offset: %+d h"), off); g_lv.task->showAlert(m, 900); } } static void timeOffsetMinusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) timeOffsetStep(-1); } static void timeOffsetPlusCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) timeOffsetStep(+1); } // ---- Time-zone picker (Device modal) ---------------------------------------- // A curated list of zones, each with the correct DST rules, so non-EU users get // the right time year-round instead of the CET base + EU DST dates. The last // entry ("Custom (UTC offset)") uses the +/- hour offset above. static lv_obj_t* s_tz_btn_lbl = nullptr; // Device-modal button caption (current zone) static lv_obj_t* s_tz_picker = nullptr; // picker overlay (singleton) static void tzBtnLabelRefresh() { if (s_tz_btn_lbl) lv_label_set_text(s_tz_btn_lbl, touchPrefsTimezoneLabel(touchPrefsGetTimezone())); } static void tzPickerClose() { if (s_tz_picker) { popupClose(&s_tz_picker); } } static void tzPickerCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) tzPickerClose(); } static void tzPickerSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); touchPrefsSetTimezone((uint8_t)idx); applyTimeOffsetNow(); // rebuild the POSIX TZ from the new zone + tzset() tzBtnLabelRefresh(); tzPickerClose(); if (g_lv.task) { char m[48]; snprintf(m, sizeof m, TR("Time zone: %s"), touchPrefsTimezoneLabel(idx)); g_lv.task->showAlert(m, 1100); } } static void openTimezonePicker() { tzPickerClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_tz_picker = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_tz_picker); lv_obj_set_size(s_tz_picker, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_tz_picker, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_tz_picker, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_tz_picker, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_tz_picker, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(s_tz_picker); lv_label_set_text(title, TR("Time zone")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 8, 8); lv_obj_t* close = lv_btn_create(s_tz_picker); lv_obj_set_size(close, SC(30), SC(26)); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 4); styleButton(close); lv_obj_add_event_cb(close, tzPickerCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); lv_obj_t* list = lv_obj_create(s_tz_picker); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw - 12, sh - STATUSBAR_H - 44); lv_obj_set_pos(list, 6, 40); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(list, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(list, LV_DIR_VER); const int cur = (int)touchPrefsGetTimezone(); const int n = touchPrefsTimezoneCount(); for (int i = 0; i < n; ++i) { lv_obj_t* row = lv_btn_create(list); lv_obj_set_size(row, sw - 16, SC(40)); styleButton(row); if (i == cur) lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_add_event_cb(row, tzPickerSelectCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* lbl = lv_label_create(row); lv_label_set_text(lbl, touchPrefsTimezoneLabel(i)); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_center(lbl); } lv_obj_move_foreground(s_tz_picker); } static void openTimezoneCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openTimezonePicker(); } #if defined(HAS_EXPANSION_KIT) // ---- Heltec V4 Expansion Kit: quick card (Home) + detailed Local Sensors page ---- static void expansionBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; closeExpansionCard(); } static void expansionCloseCb(lv_event_t* e) { (void)e; closeExpansionCard(); } static void closeExpansionCard() { if (s_expansion_root) { popupClose(&s_expansion_root); } } static void localSensorsBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; closeLocalSensorsPage(); } static void localSensorsCloseCb(lv_event_t* e) { (void)e; closeLocalSensorsPage(); } static void localSensorsOpenTerminalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeLocalSensorsPage(); #if defined(HAS_TOUCH_UI) homeTerminalCb(e); #else if (g_lv.task) g_lv.task->showAlert(TR("Console not available on this build"), 1200); #endif } static void closeLocalSensorsPage() { if (s_local_sensors_root) { popupClose(&s_local_sensors_root); } } static void openLocalSensorsPage() { if (s_local_sensors_root) { lv_obj_move_foreground(s_local_sensors_root); return; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const int H = sh - STATUSBAR_H; s_local_sensors_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_local_sensors_root); lv_obj_set_size(s_local_sensors_root, sw, H); lv_obj_set_pos(s_local_sensors_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_local_sensors_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_local_sensors_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_local_sensors_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_local_sensors_root, localSensorsBackdropCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* title = lv_label_create(s_local_sensors_root); lv_label_set_text(title, TR("Local sensors")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 10, 6); addCloseXBadge(s_local_sensors_root, localSensorsCloseCb); UITask::LocalEnvSnapshot snap; const bool have_env = g_lv.task && g_lv.task->getLocalEnvSnapshot(snap); lv_obj_t* card = lv_obj_create(s_local_sensors_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, sw - 12, H - 44); lv_obj_set_pos(card, 6, 36); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* status = lv_label_create(card); lv_obj_set_width(status, lv_pct(100)); lv_label_set_long_mode(status, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(status, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); char detail[640]; buildLocalEnvDetailText(snap, detail, sizeof detail); lv_label_set_text(status, have_env ? detail : TR("No local expansion data")); bool have_hist = false; for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { if (s_home_env_hist_batt_mv[i] > 0 || s_home_env_hist_temp_t10[i] != INT16_MIN || s_home_env_hist_hum[i] >= 0) { have_hist = true; break; } } if (have_hist) { lv_obj_t* chart = lv_chart_create(card); lv_obj_set_size(chart, sw - 28, 116); lv_obj_align(status, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_align_to(chart, status, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 8); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, kHomeEnvHistoryPoints); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 3400, 4600); lv_chart_set_range(chart, LV_CHART_AXIS_SECONDARY_Y, -10, 100); lv_chart_set_div_line_count(chart, 3, 4); lv_obj_set_style_bg_color(chart, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(chart, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(chart, LV_OPA_30, LV_PART_MAIN); lv_obj_set_style_border_width(chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_text_color(chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_text_font(chart, &g_font_12, LV_PART_TICKS); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 3, 1, true, 40); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_SECONDARY_Y, 4, 0, 3, 1, true, 32); lv_chart_series_t* bs = lv_chart_add_series(chart, lv_color_hex(0x4F9DF7), LV_CHART_AXIS_PRIMARY_Y); lv_chart_series_t* ts = lv_chart_add_series(chart, lv_color_hex(0xF5A623), LV_CHART_AXIS_SECONDARY_Y); lv_chart_series_t* hs = lv_chart_add_series(chart, lv_color_hex(0x35C9C9), LV_CHART_AXIS_SECONDARY_Y); for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { lv_chart_set_next_value(chart, bs, s_home_env_hist_batt_mv[i] > 0 ? (lv_coord_t)s_home_env_hist_batt_mv[i] : LV_CHART_POINT_NONE); lv_chart_set_next_value(chart, ts, s_home_env_hist_temp_t10[i] != INT16_MIN ? (lv_coord_t)(s_home_env_hist_temp_t10[i] / 10) : LV_CHART_POINT_NONE); lv_chart_set_next_value(chart, hs, s_home_env_hist_hum[i] >= 0 ? (lv_coord_t)s_home_env_hist_hum[i] : LV_CHART_POINT_NONE); } lv_obj_t* legend = lv_label_create(card); lv_label_set_text(legend, TR("Blue battery Orange temp Cyan humidity")); lv_obj_set_style_text_font(legend, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(legend, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align_to(legend, chart, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 4); } lv_obj_t* btn_row = lv_obj_create(card); lv_obj_remove_style_all(btn_row); lv_obj_set_size(btn_row, sw - 28, 36); lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_t* term = lv_btn_create(btn_row); lv_obj_set_size(term, sw - 40, 34); lv_obj_align(term, LV_ALIGN_CENTER, 0, 0); styleButton(term); lv_obj_add_event_cb(term, localSensorsOpenTerminalCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* tl = lv_label_create(term); useChainedFont(tl); lv_label_set_text(tl, TR("Open mesh console")); lv_obj_center(tl); lv_obj_move_foreground(s_local_sensors_root); } static void openLocalSensorsPageCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openLocalSensorsPage(); } static void openExpansionCard() { if (s_expansion_root) { lv_obj_move_foreground(s_expansion_root); return; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_expansion_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_expansion_root); lv_obj_set_size(s_expansion_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_expansion_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_expansion_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_expansion_root, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(s_expansion_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_expansion_root, expansionBackdropCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t card_w = LV_MIN(sw - 16, (lv_coord_t)PSC(224)); // wider cap on the Tanmatsu lv_obj_t* card = lv_obj_create(s_expansion_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, LV_SIZE_CONTENT); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, expansionCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Expansion Kit")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_width(title, card_w - 20 - 32); lv_obj_set_pos(title, 0, 0); char info[256]; buildExpansionInfoText(info, sizeof info); lv_obj_t* lbl = lv_label_create(card); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, card_w - 20); lv_label_set_text(lbl, info[0] ? info : TR("No local expansion data")); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(lbl, 0, PSC(28)); lv_obj_update_layout(lbl); lv_obj_t* d = lv_btn_create(card); lv_obj_set_size(d, card_w - 20, PSC(34)); lv_obj_set_pos(d, 0, PSC(36) + lv_obj_get_height(lbl)); styleButton(d); lv_obj_add_event_cb(d, openLocalSensorsPageCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* dl = lv_label_create(d); useChainedFont(dl); lv_label_set_text(dl, TR("Detailed sensors")); lv_obj_center(dl); lv_obj_set_height(card, PSC(44) + lv_obj_get_height(lbl) + PSC(42)); lv_obj_move_foreground(s_expansion_root); } static void openExpansionCardCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openExpansionCard(); } #endif // HAS_EXPANSION_KIT #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) // Lock-screen settings live in the Device modal but the picker implementation // needs the SD-mount state (declared further down), so split: the small bits // the modal body uses directly are here; the picker is defined below. static lv_obj_t* s_lockwall_btn_lbl = nullptr; // Device-modal button caption (current wallpaper) static const uint32_t kLockColors[] = { 0xE6F2FF, 0xFFFFFF, 0x5BC0FF, 0x5BE5A0, 0xFFC857, 0xFF6B6B, 0xB39DFF, 0x9AA7B4 }; // Human-readable name from a stored pref path ("sd:/lock/x.jpg" -> "SD: x.jpg"). static void lockwallDisplayName(const char* path, char* out, int cap) { if (!out || cap <= 0) return; if (!path || !path[0]) { snprintf(out, cap, "(default)"); return; } const bool sd = !strncmp(path, "sd:", 3); const char* p = sd ? path + 3 : path; const char* base = strrchr(p, '/'); base = base ? base + 1 : p; snprintf(out, cap, "%s%s", sd ? "SD: " : "", base); } static void openLockWallPickerCb(lv_event_t* e); // defined with the picker, below static void lockColorChosenCb(lv_event_t* e); #endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 #if CAP_SOUND_FILES // custom WAV notification sounds -- T-Deck/pager (SD or SPIFFS) // Per-event notification-sound picker (Settings -> Sound). Mirrors the wallpaper picker. static lv_obj_t* s_snd_btn_lbl[3] = { nullptr, nullptr, nullptr }; static int s_snd_pending_slot = TOUCH_SND_MSG; // slot we're choosing a sound for static void soundDisplayName(const char* path, char* out, int cap); static void openSoundPickerCb(lv_event_t* e); // defined with the chooser, below #endif static void calibrateBatteryCb(lv_event_t* e); // defined with the battery helpers below #if defined(HAS_TDECK_KEYBOARD) // Keyboard-backlight brightness (issue #84). The T-Keyboard's C3 firmware takes a // 2-byte I2C command [0x01, level 0-255]; the ON level was hardcoded 0xFF (100%). // This percent scales that level; the per-loop tick recomputes it and the I2C layer // only writes on change, so adjusting any control gives a live preview for free. static uint8_t s_tdeck_kb_bl_pct = 100; // Perceived LED brightness is roughly logarithmic in PWM duty, so a linear pct->duty // map feels coarse at the bottom (small pct steps = big visible jumps). A squared // (gamma-2) curve spends most of the percent range on the dim end, evening it out. static inline uint8_t tdeckKbBlLevel() { if (s_tdeck_kb_bl_pct == 0) return 0; uint32_t lvl = ((uint32_t)s_tdeck_kb_bl_pct * s_tdeck_kb_bl_pct * 255 + 5000) / 10000; return (uint8_t)(lvl < 1 ? 1 : lvl); // any nonzero pct stays visibly lit } // Apply a new percent: instant visual feedback (the loop tick + I2C layer pick it // up within ~2 keyboard polls). Turning the knob while the mode is off flips it to // on - mirroring the Tanmatsu control-centre behaviour. static void kbBlSetPct(int pct, bool sync_slider) { if (pct < 0) pct = 0; if (pct > 100) pct = 100; s_tdeck_kb_bl_pct = (uint8_t)pct; if (s_kb_bl_mode == 0) s_kb_bl_mode = 1; noteKbActivity(); // auto mode: light up now so the preview shows tdeckKeyboardSetBacklight(tdeckKbBlLevel()); // request NOW; the touch-poll task flushes it over I2C within ~8 ms if (sync_slider && g_set_modal.kbbl_slider) lv_slider_set_value(g_set_modal.kbbl_slider, pct, LV_ANIM_OFF); } // Persisting rewrites the whole TouchCfg blob to NVS (cfgFlush), which can block the // UI thread for a good fraction of a second - far too slow to run on EVERY -/+ tap. // Defer it: one one-shot timer, re-armed on each change, writes ONCE after the user // stops clicking. Reads only statics, so it is safe even after the page is closed. static lv_timer_t* s_kbbl_save_timer = nullptr; static void kbBlSaveTimerCb(lv_timer_t*) { s_kbbl_save_timer = nullptr; // repeat-count 1 -> LVGL deletes the timer after this touchPrefsSetKbdBacklight(s_tdeck_kb_bl_pct); touchPrefsSetKbBacklight(s_kb_bl_mode); // persist the (possibly flipped) mode too } static void kbBlScheduleSave() { if (s_kbbl_save_timer) { lv_timer_reset(s_kbbl_save_timer); return; } s_kbbl_save_timer = lv_timer_create(kbBlSaveTimerCb, 1200, nullptr); lv_timer_set_repeat_count(s_kbbl_save_timer, 1); } static void kbBlSliderCb(lv_event_t* e) { // live while dragging kbBlSetPct(lv_slider_get_value(lv_event_get_target(e)), false); } static void kbBlSliderReleaseCb(lv_event_t*) { kbBlScheduleSave(); } static void kbBlPresetCb(lv_event_t* e) { // Off / 25 / 50 / 75 / Max: user_data = the percent (0 = Off) int pct = (int)(intptr_t)lv_event_get_user_data(e); if (pct == 0) { // "Off" switches the MODE off (what the CC chip shows), instead of s_kb_bl_mode = 0; // inventing a second dark state (brightness 0 with the mode still on) tdeckKeyboardSetBacklight(0); // that left the CC chip saying "on" over a dark keyboard. kbBlScheduleSave(); // brightness pct is left untouched -> re-enabling restores it return; } kbBlSetPct(pct, true); kbBlScheduleSave(); } #endif #if defined(HAS_PAGER_KEYBOARD) static lv_obj_t* s_pager_kbbl_lbl = nullptr; // Settings->Keyboard row caption (modal is a singleton) static const char* pagerKbBlModeText() { return s_kb_bl_mode == 0 ? "Off" : (s_kb_bl_mode == 1 ? "On" : "Auto"); } // Cycle off -> on -> auto -> off and persist -- same semantics as the Control // Center's ccKbBacklightCb, duplicated rather than shared since CC is // unreachable on this board (no touch launcher to open it) and the two update // their UI differently (CC rebuilds the whole popup; this just relabels one // button). static void pagerKbBlCycleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_kb_bl_mode = (uint8_t)((s_kb_bl_mode + 1) % 3); touchPrefsSetKbBacklight(s_kb_bl_mode); if (s_pager_kbbl_lbl) lv_label_set_text(s_pager_kbbl_lbl, pagerKbBlModeText()); } #endif static void buildDeviceSettings(int sec) { // One detail page per section: each block below is gated to its DSEC_* section // (skipped blocks don't advance y, so every page lays out from the top). lv_obj_t* body = createSettingsModal("", SettingsModalKind::Device); int y = 0; if (sec == DSEC_GPS) { // --- GPS hardware (toggle / status / baud) --- #if !CAP_GPS // No GPS module on the Tanmatsu — show a note instead of a dead toggle. { lv_obj_t* nl = lv_label_create(body); lv_obj_set_style_text_color(nl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(nl, &g_font_12, LV_PART_MAIN); lv_obj_set_width(nl, lv_pct(100)); lv_label_set_long_mode(nl, LV_LABEL_LONG_WRAP); lv_obj_set_pos(nl, 4, y); lv_label_set_text(nl, TR("No GPS module on this device.")); y += SC(24); } #else { int h = settingsRowLabel(body, y, 6, "GPS", COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (g_lv.task && g_lv.task->getGPSState()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleGpsCb, LV_EVENT_VALUE_CHANGED, nullptr); // instant on/off y += LV_MAX(SC(34), h + 12); } // Live GPS fix status — refreshed each tick while this modal is open. g_set_modal.gps_status = lv_label_create(body); lv_label_set_long_mode(g_set_modal.gps_status, LV_LABEL_LONG_WRAP); lv_obj_set_width(g_set_modal.gps_status, lv_pct(100)); lv_obj_set_style_text_color(g_set_modal.gps_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_set_modal.gps_status, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(g_set_modal.gps_status, 4, y); lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); lv_obj_update_layout(g_set_modal.gps_status); y += LV_MAX(SC(22), lv_obj_get_height(g_set_modal.gps_status) + SC(6)); // status is 2 lines now // (Expansion Kit moved to the Sensors page — see the DSEC_SENSORS block below.) // GPS serial baud. T-Deck Plus = 38400, T-Deck v1.0 = 9600. Persisted to NVS // and read at GPS init; reboot to apply. Also settable via `set gps.baud`. { lv_obj_t* bl = lv_label_create(body); lv_obj_set_style_text_color(bl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(bl, 4, y); lv_label_set_text(bl, TR("GPS serial baud")); y += SC(18); lv_obj_t* dd = lv_dropdown_create(body); lv_obj_set_size(dd, lv_pct(100), SC(34)); lv_obj_set_pos(dd, 2, y); lv_dropdown_set_options(dd, "9600\n19200\n38400\n57600\n115200"); lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_t* gblist = lv_dropdown_get_list(dd); lv_obj_set_style_bg_color(gblist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(gblist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(gblist, &g_font_12, LV_PART_MAIN); #ifdef GPS_BAUD_RATE uint32_t cur = touchPrefsGetGpsBaud(GPS_BAUD_RATE); #else uint32_t cur = touchPrefsGetGpsBaud(9600); #endif uint16_t match = 0; for (int i = 0; i < kGpsBaudCount; i++) if (kGpsBaudOpts[i] == cur) { match = (uint16_t)i; break; } lv_dropdown_set_selected(dd, match); lv_obj_add_event_cb(dd, gpsBaudChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(40); } #endif // !HAS_TANMATSU (GPS toggle/baud) } if (sec == DSEC_SENSORS) { // --- Sensors / expansion (V4-with-kit only) --- #if defined(HAS_EXPANSION_KIT) lv_obj_t* b_exp = lv_btn_create(body); lv_obj_set_size(b_exp, lv_pct(100), SC(34)); lv_obj_set_pos(b_exp, 2, y); styleButton(b_exp); lv_obj_add_event_cb(b_exp, openExpansionCardCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* le = lv_label_create(b_exp); useChainedFont(le); lv_label_set_text(le, TR("Expansion Kit")); lv_obj_center(le); y += SC(38); #endif // The "Show Sensors tab" toggle is appended here too (moved from Display). } if (sec == DSEC_SOUND) { // --- Sound --- // Sound toggle — T-Deck I2S speaker, Heltec V4 expansion-kit piezo buzzer, or // the Tanmatsu's ES8156 codec / speaker amp. #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) // Master Sound switch — off overrules everything (fully silent). { int rh = settingsRowLabel(body, y, 6, TR("Sound"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (g_lv.task && !g_lv.task->isBuzzerQuiet()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleBuzzerCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } // Per-event on/off switches: Message, DM, @-mention (under the master Sound). { int rh = settingsRowLabel(body, y, 6, TR("Message sound"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetSoundMessages()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleMessageSoundCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } { int rh = settingsRowLabel(body, y, 6, TR("DM sound"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetSoundDirect()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleDirectSoundCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } { int rh = settingsRowLabel(body, y, 6, TR("@ mention sound"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetSoundMentions()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleMentionSoundCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } // Do Not Disturb — silences the incoming-message chime during a configured // daily window (wraps past midnight; see dndActive()). Doesn't affect chat // bubbles/badges, and the per-event switches above still preview audibly. { int rh = settingsRowLabel(body, y, 6, TR("Do Not Disturb"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetDndEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, toggleDndCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } { y += settingsRowLabel(body, y, 0, TR("Start time"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* bminus = lv_btn_create(body); lv_obj_set_size(bminus, SC(50), SC(34)); lv_obj_set_pos(bminus, 2, y); styleButton(bminus); lv_obj_add_event_cb(bminus, dndStartMinusCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(bminus); lv_label_set_text(l, "-30m"); lv_obj_center(l); } s_dnd_start_lbl = lv_label_create(body); lv_obj_set_width(s_dnd_start_lbl, 100); lv_obj_set_style_text_align(s_dnd_start_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_color(s_dnd_start_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_dnd_start_lbl, &g_font_16, LV_PART_MAIN); lv_obj_set_pos(s_dnd_start_lbl, 58, y + 9); dndStartLabelRefresh(); lv_obj_t* bplus = lv_btn_create(body); lv_obj_set_size(bplus, SC(50), SC(34)); lv_obj_set_pos(bplus, 168, y); styleButton(bplus); lv_obj_add_event_cb(bplus, dndStartPlusCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(bplus); lv_label_set_text(l, "+30m"); lv_obj_center(l); } y += SC(42); } { y += settingsRowLabel(body, y, 0, TR("End time"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* bminus = lv_btn_create(body); lv_obj_set_size(bminus, SC(50), SC(34)); lv_obj_set_pos(bminus, 2, y); styleButton(bminus); lv_obj_add_event_cb(bminus, dndEndMinusCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(bminus); lv_label_set_text(l, "-30m"); lv_obj_center(l); } s_dnd_end_lbl = lv_label_create(body); lv_obj_set_width(s_dnd_end_lbl, 100); lv_obj_set_style_text_align(s_dnd_end_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_color(s_dnd_end_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_dnd_end_lbl, &g_font_16, LV_PART_MAIN); lv_obj_set_pos(s_dnd_end_lbl, 58, y + 9); dndEndLabelRefresh(); lv_obj_t* bplus = lv_btn_create(body); lv_obj_set_size(bplus, SC(50), SC(34)); lv_obj_set_pos(bplus, 168, y); styleButton(bplus); lv_obj_add_event_cb(bplus, dndEndPlusCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(bplus); lv_label_set_text(l, "+30m"); lv_obj_center(l); } y += SC(42); } #if CAP_SOUND_FILES // WAV notification-sound rows (file-browsing sound picker, SD or SPIFFS) // Per-event notification sound files. Each slot: built-in chime, or a 16-bit // PCM WAV from /sounds/ (internal) or the SD card. Empty = built-in. { static const char* const kSndRowLbl[3] = { "Message sound", "Direct (DM) sound", "@ mention sound" }; for (int slot = 0; slot < 3; ++slot) { y += settingsRowLabel(body, y, 0, kSndRowLbl[slot], COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100), SC(34)); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_add_event_cb(b, openSoundPickerCb, LV_EVENT_CLICKED, (void*)(intptr_t)slot); s_snd_btn_lbl[slot] = lv_label_create(b); { char cur[TOUCH_SOUND_PATH_MAXLEN], disp[48]; touchPrefsGetSoundFile(slot, cur, sizeof cur); soundDisplayName(cur, disp, sizeof disp); lv_label_set_text(s_snd_btn_lbl[slot], disp); } lv_label_set_long_mode(s_snd_btn_lbl[slot], LV_LABEL_LONG_DOT); lv_obj_set_width(s_snd_btn_lbl[slot], lv_pct(92)); lv_obj_set_style_text_align(s_snd_btn_lbl[slot], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(s_snd_btn_lbl[slot]); y += SC(40); } } #endif #if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_TDISPLAY_P4) // Volume with - / + step buttons (T-Deck I2S / Tanmatsu / pager / P4 codec; the V4 piezo can't vary volume). { lv_obj_t* vl = lv_label_create(body); lv_label_set_text(vl, TR("Volume")); lv_obj_set_style_text_color(vl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(vl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(vl, 2, y + 8); const int rx = s_settings_content_w, bw = 38, vw = 50, gp = 6; const int xplus = rx - bw, xval = xplus - gp - vw, xminus = xval - gp - bw; auto vbtn = [&](const char* t, int bx, int delta){ lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, bw, SC(32)); lv_obj_set_pos(b, bx, y); styleButton(b); lv_obj_add_event_cb(b, volumeStepCb, LV_EVENT_CLICKED, (void*)(intptr_t)delta); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, t); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_center(l); }; vbtn(LV_SYMBOL_MINUS, xminus, -10); s_vol_val_lbl = lv_label_create(body); lv_label_set_text_fmt(s_vol_val_lbl, "%d%%", (int)touchPrefsGetSoundVolume()); lv_obj_set_size(s_vol_val_lbl, vw, SC(32)); lv_obj_set_pos(s_vol_val_lbl, xval, y + 6); lv_obj_set_style_text_color(s_vol_val_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_vol_val_lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_align(s_vol_val_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); vbtn(LV_SYMBOL_PLUS, xplus, +10); y += SC(40); } #endif #endif } if (sec == DSEC_CLOCK) { // --- Sync clock --- lv_obj_t* b_time = lv_btn_create(body); lv_obj_set_size(b_time, lv_pct(100),SC(34)); lv_obj_set_pos(b_time, 2, y); styleButton(b_time); lv_obj_add_event_cb(b_time, syncClockCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_time = lv_label_create(b_time); useChainedFont(l_time); lv_label_set_text(l_time, TR("Sync clock from system")); lv_obj_center(l_time); y += SC(40); } if (sec == DSEC_GENERAL) { // --- Send advert (device action) --- lv_obj_t* b_adv = lv_btn_create(body); lv_obj_set_size(b_adv, lv_pct(100),SC(34)); lv_obj_set_pos(b_adv, 2, y); styleButton(b_adv); lv_obj_add_event_cb(b_adv, advertNowCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_adv = lv_label_create(b_adv); useChainedFont(l_adv); lv_label_set_text(l_adv, TR("Send advert now")); lv_obj_center(l_adv); y += SC(40); /* Chat-save fallback: after how many consecutive failed BACKGROUND history writes the flush retries synchronously on the UI task. The sync write is the empirically reliable path on the shared SPI bus, but the screen hitches for its duration — users who prefer a never-hitching UI pick Off (background retries only; the RAM ring keeps everything meanwhile). */ { // Per-chat history limit. Sits above the save-fallback control because it is the one // people actually need: without it a single busy channel fills the shared message store, // which is what makes the chat list crawl. y += settingsRowLabel(body, y, 0, TR("Keep per chat (messages)"), COLOR_SUB, &g_font_12, 0) + 2; { lv_obj_t* cd = lv_dropdown_create(body); lv_dropdown_set_options(cd, TR("100\n250\n500\n1000\n2000\nNo limit")); const uint16_t cur_cap = touchPrefsGetHistPerChat(); uint16_t cap_sel = 1; // index of 250 — the default when the stored value isn't listed for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) if (k_hist_cap_opts[i] == cur_cap) { cap_sel = i; break; } lv_dropdown_set_selected(cd, cap_sel); lv_obj_set_width(cd, lv_pct(100)); lv_obj_set_pos(cd, 2, y); lv_obj_add_event_cb(cd, histCapSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(cd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(44); y += settingsRowLabel(body, y, 0, TR("Older messages in a chat are dropped past this. A very large or " "unlimited history makes the chat list slower."), COLOR_SUB, &g_font_12, 0) + 6; } y += settingsRowLabel(body, y, 0, TR("Chat save fallback (failed tries)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* dd = lv_dropdown_create(body); lv_dropdown_set_options(dd, TR("Off\n1\n2\n3\n5\n8")); const uint8_t cur = touchPrefsGetHistSyncAfter(); uint16_t sel = 2; // index of "2" — the default when the stored value isn't a listed option for (uint16_t i = 0; i < sizeof(k_hist_sync_opts); ++i) if (k_hist_sync_opts[i] == cur) { sel = i; break; } lv_dropdown_set_selected(dd, sel); lv_obj_set_width(dd, lv_pct(100)); lv_obj_set_pos(dd, 2, y); lv_obj_add_event_cb(dd, histSyncSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); // open list on top of the bar y += SC(44); } } if (sec == DSEC_DISPLAY) { // --- Display --- /* Screen timeout (seconds, 0 = never). Persists in NVS via TouchPrefsStore. */ { y += settingsRowLabel(body, y, 0, TR("Screen timeout (s, 0 = never, min 10)"), COLOR_SUB, &g_font_12, 0) + 2; g_set_modal.screen_to_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.screen_to_ta, SC(100), SC(30)); lv_obj_set_pos(g_set_modal.screen_to_ta, 2, y); lv_textarea_set_one_line(g_set_modal.screen_to_ta, true); lv_textarea_set_max_length(g_set_modal.screen_to_ta, 4); attachSettingsTaEvents(g_set_modal.screen_to_ta); lv_obj_add_event_cb(g_set_modal.screen_to_ta, saveScreenTimeoutCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save on blur (no Save button) if (g_lv.task) { char buf[8]; snprintf(buf, sizeof(buf), "%u", (unsigned)g_lv.task->getScreenTimeoutSecs()); lv_textarea_set_text(g_set_modal.screen_to_ta, buf); } y += SC(38); } #if CAP_UI_SIZE /* Board-specific UI-size presets. Applied while the UI tree is built, so a restart is required. */ { y += settingsRowLabel(body, y, 0, TR("UI size (restart to apply)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* dd = lv_dropdown_create(body); #if defined(TLORA_PAGER) lv_dropdown_set_options(dd, TR("Small\nMedium\nLarge\nJumbo")); #else lv_dropdown_set_options(dd, TR("Normal (100%)\nLarge (150%)\nHuge (200%)")); #endif lv_dropdown_set_selected(dd, touchPrefsGetUiScale()); lv_obj_set_width(dd, lv_pct(100)); lv_obj_set_pos(dd, 2, y); lv_obj_add_event_cb(dd, uiScaleSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); // open list on top of the bar y += SC(44); } /* Message LED: flash the envelope-icon LED on a new message and softly breathe it while there are unread messages. Turn off to keep that LED dark. (Tanmatsu only — the others have no such LED. It lives under CAP_LARGE_SCREEN, so it must be gated to HAS_TANMATSU explicitly now that the T-Display P4 is also a large-screen board.) */ #if defined(HAS_TANMATSU) { int h = settingsRowLabel(body, y, 6, TR("Message LED"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (touchPrefsGetMsgLed()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, msgLedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } #endif #endif /* Distance units: OFF = km (default), ON = miles. Applies immediately. */ { int h = settingsRowLabel(body, y, 6, TR("Distance in miles"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); // flush to the card's right edge #if defined(ESP32) if (touchPrefsGetUseMiles()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, useMilesToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } } if (sec == DSEC_CLOCK) { /* Clock format: OFF = 24-hour (default), ON = 12-hour AM/PM. Lives on the Clock & time tab. */ { int h = settingsRowLabel(body, y, 6, TR("12-hour clock"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetClock12h()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, clock12hToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } } if (sec == DSEC_DISPLAY) { /* Colourful chat bubbles: colour every bubble + sender name by a hash of the sender's name (same name -> same colour). "Taste the rainbow" on enable. */ { int h = settingsRowLabel(body, y, 6, TR("Colourful chat bubbles"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetColorfulBubbles()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, colorfulBubblesToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } /* Compact messages (IRC-style): one dense "HH:MM name: text" row per message instead of bubbles — far more history on screen. Opt-in (wyvern.red). */ { int h = settingsRowLabel(body, y, 6, TR("Compact messages (IRC style)"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetCompactChat()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, compactChatToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } /* Hide device name: blank the scrolling profile name in the status bar and move the clock to the left where it sat. */ { int h = settingsRowLabel(body, y, 6, TR("Hide device name"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetHideNodeName()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, hideNameToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } /* At a glance: master enable for the "at a glance" message-preview overlay (see atGlanceShow()/newMsgImpl()) -- on by default, matching the previously-unconditional behavior. Gates the "while locked" switch below. */ bool glance_on = true; { int h = settingsRowLabel(body, y, 6, TR("At a glance"), COLOR_SUB, nullptr, 56); g_set_modal.glance_en_sw = lv_switch_create(body); lv_obj_align(g_set_modal.glance_en_sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) glance_on = touchPrefsGetGlanceEnabled(); if (glance_on) lv_obj_add_state(g_set_modal.glance_en_sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(g_set_modal.glance_en_sw, glanceEnabledToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } #if !defined(HAS_TDISPLAY_P4) /* "At a glance" also fires while manually/idle locked, not just while unlocked+dimmed -- off by default since it's a real trade-off (message text becomes readable off a locked device). Disabled (not cleared) while the master switch above is off. Moved here from Options > Lock screen so it sits with the feature it modifies. */ { int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); g_set_modal.glance_locked_sw = lv_switch_create(body); lv_obj_align(g_set_modal.glance_locked_sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_CHECKED); if (!glance_on) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); #endif lv_obj_add_event_cb(g_set_modal.glance_locked_sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } #endif #if defined(HAS_EXPANSION_KIT) /* Show Sensors tab (V4 Expansion Kit): toggles the bottom Sensors tab + the Home env widget. Also auto-hidden when no environment sensor is attached. The tab layout is decided at boot, so this applies after a restart. */ { int h = settingsRowLabel(body, y, 6, TR("Show Sensors tab"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetShowSensorsTab()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, showSensorsTabToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); // Subtitle: this only takes effect on the next boot (tabs are built once). y += settingsRowLabel(body, y, 0, TR("Applies after restart."), COLOR_SUB, &g_font_12, 0) + 6; } #endif /* Theme colour (UI accent): opens a colour-wheel + hex picker. The chosen colour is clamped dark enough that off-white button text stays readable. */ { y += settingsRowLabel(body, y, 0, TR("Theme colour"), COLOR_SUB, &g_font_12, 0) + 4; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, SC(150), SC(32)); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_add_event_cb(b, openAccentPickerCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Pick colour")); lv_obj_center(bl); lv_obj_t* swatch = lv_obj_create(body); lv_obj_remove_style_all(swatch); lv_obj_set_size(swatch, SC(30), SC(30)); lv_obj_set_pos(swatch, 162, y + 1); lv_obj_set_style_radius(swatch, 6, LV_PART_MAIN); lv_obj_set_style_bg_color(swatch, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(swatch, LV_OPA_COVER, LV_PART_MAIN); y += SC(40); } } if (sec == DSEC_GENERAL) { // --- Storage (SD) --- #if CAP_SD || defined(TLORA_PAGER) /* Store all data (identity/prefs/contacts/channels) on the SD card under /meshcomod instead of internal flash — for running under Launcher, or just to keep everything on a card. Read at boot, so it applies after a reboot. */ { int h = settingsRowLabel(body, y, 6, TR("Store data on SD (reboot)"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetUseSdStorage()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, useSdStorageToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) /* Where contacts ACTUALLY live this boot. The toggle above is only an intent — if the card failed to mount at boot (cold/slow card), contacts silently stay on internal flash even with it ON. This line shows the truth and flags that mismatch. */ { extern bool g_contacts_on_sd; extern bool g_sd_migration_blocked; const bool want_sd = touchPrefsGetUseSdStorage(); lv_obj_t* st = lv_label_create(body); lv_label_set_long_mode(st, LV_LABEL_LONG_WRAP); lv_obj_set_width(st, lv_pct(96)); lv_obj_set_style_text_font(st, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(st, 0, y); if (g_contacts_on_sd && SD.cardType() == CARD_NONE) { lv_label_set_text(st, want_sd ? TR("SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.") : TR("SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.")); lv_obj_set_style_text_color(st, lv_color_hex(0xE34B4B), LV_PART_MAIN); } else if (want_sd && g_sd_migration_blocked) { lv_label_set_text(st, TR("SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.")); lv_obj_set_style_text_color(st, lv_color_hex(0xE3A127), LV_PART_MAIN); } else if (g_contacts_on_sd) { lv_label_set_text(st, TR("Contacts are saved to the SD card.")); lv_obj_set_style_text_color(st, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); } else if (want_sd) { lv_label_set_text(st, TR("Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.")); lv_obj_set_style_text_color(st, lv_color_hex(0xE3A127), LV_PART_MAIN); // amber warning } else { lv_label_set_text(st, TR("Contacts are on internal flash.")); lv_obj_set_style_text_color(st, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } lv_obj_update_layout(st); y += lv_obj_get_height(st) + SC(10); } #endif /* Recovery for the beta_36 "lost my profile" upgrades: the live data was orphaned on internal flash when the honored SD toggle adopted an empty (or fresh-identity) card. This copies EVERYTHING from internal flash over the card's copies and reboots into the restored profile. This is a SPIFFS->SD recovery on T-Deck, V4-R8 and Pager; Tanmatsu uses SD_MMC with no SPIFFS. */ #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) { lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(96), SC(30)); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_add_event_cb(b, sdRestoreFromInternalCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, TR(LV_SYMBOL_DOWNLOAD " Copy internal data to SD")); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(lbl); y += SC(40); } #endif // HAS_TDECK_GT911 || HELTEC_LORA_V4_R8 || TLORA_PAGER || HAS_THINKNODE_M9 (SPIFFS->SD recovery copy) #endif } if (sec == DSEC_KEYBOARD) { // --- Keyboard --- #if defined(HAS_TDECK_KEYBOARD) /* Keyboard backlight brightness (issue #84). A live slider (gamma-2 mapped so the dim end is fine-grained) plus Off / 25 / 50 / 75 / Max one-tap presets; changes apply within ~8 ms, the NVS save is debounced. "Off" sets the backlight MODE off (the same axis the control-center chip cycles), so the chip and this page never disagree; the slider (1-100) only sets how bright on/auto glow. */ { y += settingsRowLabel(body, y, 0, TR("Keyboard backlight"), COLOR_SUB, &g_font_12, 0) + 4; lv_obj_t* sl = lv_slider_create(body); g_set_modal.kbbl_slider = sl; lv_obj_set_size(sl, lv_pct(96), SC(8)); lv_obj_set_pos(sl, 2, y + SC(6)); lv_slider_set_range(sl, 1, 100); // dark is the mode's job ("Off"); on/auto always visibly lit lv_slider_set_value(sl, s_tdeck_kb_bl_pct, LV_ANIM_OFF); lv_obj_set_style_bg_color(sl, lv_color_hex(0x202428), LV_PART_MAIN); lv_obj_set_style_bg_color(sl, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_color(sl, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); lv_obj_set_style_pad_all(sl, 6, LV_PART_KNOB); lv_obj_add_event_cb(sl, kbBlSliderCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(sl, kbBlSliderReleaseCb, LV_EVENT_RELEASED, nullptr); y += SC(26); static const struct { const char* txt; int pct; } k_presets[] = { { "Off", 0 }, { "25", 25 }, { "50", 50 }, { "75", 75 }, { "Max", 100 } }; for (int i = 0; i < 5; ++i) { // one-tap presets lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, SC(48), SC(30)); lv_obj_set_pos(b, 2 + i * SC(54), y); lv_obj_add_event_cb(b, kbBlPresetCb, LV_EVENT_CLICKED, (void*)(intptr_t)k_presets[i].pct); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, k_presets[i].txt); lv_obj_center(l); useChainedFont(l); } y += SC(38); } #elif defined(HAS_PAGER_KEYBOARD) /* Keyboard backlight: off / on / auto (lit while typing, dark after ~3 s idle). No brightness slider here (unlike the T-Deck) -- this board's backlight is a simple on/off strip, so there's nothing to dial in beyond the mode. */ { y += settingsRowLabel(body, y, 0, "Keyboard backlight", COLOR_SUB, &g_font_12, 0) + 4; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100), SC(34)); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_add_event_cb(b, pagerKbBlCycleCb, LV_EVENT_CLICKED, nullptr); s_pager_kbbl_lbl = lv_label_create(b); useChainedFont(s_pager_kbbl_lbl); lv_label_set_text(s_pager_kbbl_lbl, pagerKbBlModeText()); lv_obj_center(s_pager_kbbl_lbl); y += SC(42); y += settingsRowLabel(body, y, 0, "tap to cycle off / on / auto", COLOR_SUB, &g_font_12, 0) + 2; } #endif /* Secondary keyboards (multi-select). Switch on any layouts you want in the rotation; a double-tap of SPACE on the physical keyboard cycles English -> each enabled layout -> back. The active layout is remembered across reboots. */ { y += settingsRowLabel(body, y, 0, TR("Secondary keyboards"), COLOR_SUB, &g_font_12, 0) + 2; #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) const char* kb_cycle_hint = "double-tap SPACE cycles through the ones you enable"; #else const char* kb_cycle_hint = "tap the language key (e.g. EN) on the keyboard to cycle the ones you enable"; #endif y += settingsRowLabel(body, y, 0, kb_cycle_hint, COLOR_SUB, &g_font_12, 0) + 2; #if defined(ESP32) uint16_t en_mask = touchPrefsGetEnabledLayouts(); #else uint16_t en_mask = 0; #endif /* One row per non-English layout; index id-1 into the display names. Keep in KeyboardLayoutId order (BG=1 .. IT=11). MUST have one entry per non-EN layout or the loop below reads past the end. */ static const char* k_kb_disp[] = { "Bulgarian", "Russian", "Ukrainian", "Serbian", "Greek", "Arabic (experimental)", "French", "Dutch", "German", "Spanish", "Italian", "Romanian" }; for (int id = 1; id < KEYBOARD_LAYOUT_COUNT; ++id) { int h = settingsRowLabel(body, y, 4, k_kb_disp[id - 1], COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (en_mask & (1u << id)) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_set_user_data(sw, (void*)(intptr_t)id); lv_obj_add_event_cb(sw, kbLayoutSwitchCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); } } #if !defined(HAS_M9_KEYBOARD) /* Accent popups. Typing a Latin letter that has accented variants pops up a tap-to-pick box; turn this off for plain typing. Default on. Not on the M9: the pickers are suppressed there (no touch and no key-selection path — see accentBoxMaybeShow), so the switch would control nothing. */ { int h = settingsRowLabel(body, y, 4, TR("Accent popups"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetAccentPopups()) lv_obj_add_state(sw, LV_STATE_CHECKED); #else if (s_accent_popups) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, accentPopupsToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); y += settingsRowLabel(body, y, 0, TR("pick accented letters as you type; off = plain typing"), COLOR_SUB, &g_font_12, 0) + 2; } #endif #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) /* Enter sends the message (default) vs. inserts a newline so you send only via the on-screen button — avoids accidental sends on the public channel. */ { int h = settingsRowLabel(body, y, 4, TR("Enter key sends message"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetEnterSends()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, enterSendsToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); y += settingsRowLabel(body, y, 0, TR("off = Enter adds a new line; tap Send to send"), COLOR_SUB, &g_font_12, 0) + 2; } /* Flash the keyboard backlight + briefly wake the screen when a message arrives — the T-Deck analog of the Tanmatsu's envelope LED notifier. Opt-in (default off). */ { int h = settingsRowLabel(body, y, 4, TR("Flash on new message"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetMsgFlash()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, msgFlashToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); y += settingsRowLabel(body, y, 0, TR("lights the keyboard + wakes the screen on an incoming message"), COLOR_SUB, &g_font_12, 0) + 2; } #endif #if defined(HAS_TANMATSU) /* Tanmatsu keyboard navigation: the arrow keys + Enter ALWAYS navigate; these letter keys are programmable extras. The menu/tab keys use the coloured F-keys and aren't remappable here. Applied live. */ { y += settingsRowLabel(body, y, 4, TR("Navigation keys"), COLOR_TEXT, &g_font_12, 0) + 2; y += settingsRowLabel(body, y, 0, TR("Arrows + Enter always work. These letters are extra \xe2\x80\x94 tap a row, then press a key."), COLOR_SUB, &g_font_12, 0) + 2; static const int kTanNavRows[7] = { 0, 2, 4, 1, 3, 6, 7 }; // Up, Left, Select, Down, Right, Scroll up, Scroll down (Back omitted) for (int r = 0; r < 7; r++) { const int d = kTanNavRows[r], bi = d + 5; // binding index 5-12 lv_obj_t* row = lv_btn_create(body); lv_obj_set_size(row, lv_pct(100), SC(30)); lv_obj_set_pos(row, 2, y); styleButton(row); lv_obj_add_event_cb(row, navKeyCaptureStartCb, LV_EVENT_CLICKED, (void*)(intptr_t)bi); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, TR(kNavDirNames[d])); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(nm, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* kv = lv_label_create(row); const int lk = navKeyLower(s_dir_keys[d]); char kb[2] = { (char)(lk >= 'a' && lk <= 'z' ? lk - 'a' + 'A' : (lk ? lk : '-')), 0 }; lv_label_set_text(kv, kb); lv_obj_set_style_text_font(kv, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(kv, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(kv, LV_ALIGN_RIGHT_MID, -10, 0); s_navkey_row_val[bi] = kv; y += SC(36); } } #endif #if CAP_TRACKBALL #if defined(HAS_TDECK_KEYBOARD) // never the M9: nav is force-set on at boot there (the board's only input) and must not grow an off-switch /* Keyboard navigation: off (default) vs on. When no text field is focused, the WASDZ cluster moves focus so the whole UI is reachable from the keyboard (incl. Settings), and the tab hotkeys below jump straight to a tab. The trackball always stays a mouse cursor. Applied live. */ { int h = settingsRowLabel(body, y, 4, TR("Keyboard navigation"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetKbdNav()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, kbdNavToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); y += settingsRowLabel(body, y, 0, TR("W up \xc2\xb7 A left \xc2\xb7 D right \xc2\xb7 Z down \xc2\xb7 S select \xc2\xb7 Q back"), COLOR_SUB, &g_font_12, 0) + 2; y += settingsRowLabel(body, y, 0, TR("drive the UI without the screen; keys still type in text fields"), COLOR_SUB, &g_font_12, 0) + 2; #if CAP_TRACKBALL // Trackball navigation (EXPERIMENTAL, default OFF) = the trackball moves the focus // selection (up/down/left/right) and a click selects; OFF = the trackball is a soft cursor. { int h3 = settingsRowLabel(body, y, 4, TR("Trackball navigates UI"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw3 = lv_switch_create(body); lv_obj_align(sw3, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetTbNav()) lv_obj_add_state(sw3, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw3, tbNavToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h3 + 10); y += settingsRowLabel(body, y, 0, TR("Experimental - has known issues. Off = soft mouse cursor."), COLOR_SUB, &g_font_12, 0) + 2; } #endif // Show the per-tab hotkey letters over the menubar icons. Off by default — the letters // only appear when this is turned on. { int h2 = settingsRowLabel(body, y, 4, TR("Show menu-bar letters"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw2 = lv_switch_create(body); lv_obj_align(sw2, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetNavMenubarKeys()) lv_obj_add_state(sw2, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw2, navMbarKeysToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h2 + 10); } // Programmable tab hotkeys — tap a row, then press a key to reassign it. y += settingsRowLabel(body, y, 0, TR("Tab hotkeys \xe2\x80\x94 tap a row, then press a key"), COLOR_SUB, &g_font_12, 0) + 2; for (int t = 0; t < 5; t++) { lv_obj_t* row = lv_btn_create(body); lv_obj_set_size(row, lv_pct(100), SC(30)); lv_obj_set_pos(row, 2, y); styleButton(row); lv_obj_add_event_cb(row, navKeyCaptureStartCb, LV_EVENT_CLICKED, (void*)(intptr_t)t); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, TR(kNavTabNames[t])); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(nm, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* kv = lv_label_create(row); const int lk = navKeyLower(s_nav_keys[t]); char kb[2] = { (char)(lk >= 'a' && lk <= 'z' ? lk - 'a' + 'A' : lk), 0 }; lv_label_set_text(kv, kb); lv_obj_set_style_text_font(kv, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(kv, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(kv, LV_ALIGN_RIGHT_MID, -10, 0); s_navkey_row_val[t] = kv; y += SC(36); } // Programmable control keys (move/select/back + scroll up/down) — tap a row, press a key. y += settingsRowLabel(body, y, 0, TR("Navigation keys \xe2\x80\x94 tap a row, then press a key"), COLOR_SUB, &g_font_12, 0) + 2; for (int d = 0; d < 8; d++) { const int bi = d + 5; // binding index 5-12 lv_obj_t* row = lv_btn_create(body); lv_obj_set_size(row, lv_pct(100), SC(30)); lv_obj_set_pos(row, 2, y); styleButton(row); lv_obj_add_event_cb(row, navKeyCaptureStartCb, LV_EVENT_CLICKED, (void*)(intptr_t)bi); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, TR(kNavDirNames[d])); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(nm, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* kv = lv_label_create(row); const int lk = navKeyLower(s_dir_keys[d]); char kb[2] = { (char)(lk >= 'a' && lk <= 'z' ? lk - 'a' + 'A' : lk), 0 }; lv_label_set_text(kv, kb); lv_obj_set_style_text_font(kv, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(kv, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(kv, LV_ALIGN_RIGHT_MID, -10, 0); s_navkey_row_val[bi] = kv; y += SC(36); } } #endif /* Reverse scrollball: invert trackball direction (cursor, map pan, emoji selector) for users who expect the opposite roll-to-move mapping. */ { int h = settingsRowLabel(body, y, 4, TR("Reverse scrollball"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetScrollReverse()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, scrollReverseToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); } /* Edge scroll: push cursor past a screen edge to scroll the content beneath it. */ { int h = settingsRowLabel(body, y, 4, TR("Edge scroll"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetEdgeScroll()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, edgeScrollToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, h + 10); y += settingsRowLabel(body, y, 0, TR("push past a screen edge to scroll content"), COLOR_SUB, &g_font_12, 0) + 2; } #endif } if (sec == DSEC_DISPLAY) { // --- Orientation (display) --- #if CAP_ROTATABLE /* Screen orientation. Cycles Portrait -> Landscape -> Landscape (flipped); applied at boot, so tapping reboots the device. Hidden on the T-Deck — it's a fixed-landscape device (physical keyboard along the bottom), so rotating it makes no sense. */ { y += settingsRowLabel(body, y, 0, TR("Orientation (tap to rotate, reboots)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* b_rot = lv_btn_create(body); lv_obj_set_size(b_rot, lv_pct(100),SC(34)); lv_obj_set_pos(b_rot, 2, y); styleButton(b_rot); lv_obj_add_event_cb(b_rot, rotateScreenCycleCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lr = lv_label_create(b_rot); useChainedFont(lr); lv_label_set_text(lr, uiRotationLabel()); lv_obj_center(lr); y += SC(42); } #endif } if (sec == DSEC_LOCK) { // --- Lock screen --- #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) /* Lock screen: pick the wallpaper (internal /lock/ or SD) and the colour of the clock + lock text drawn over it. */ { y += settingsRowLabel(body, y, 0, TR("Lock screen wallpaper"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* b_wall = lv_btn_create(body); lv_obj_set_size(b_wall, lv_pct(100), SC(34)); lv_obj_set_pos(b_wall, 2, y); styleButton(b_wall); lv_obj_add_event_cb(b_wall, openLockWallPickerCb, LV_EVENT_CLICKED, nullptr); s_lockwall_btn_lbl = lv_label_create(b_wall); useChainedFont(s_lockwall_btn_lbl); { char cur[TOUCH_LOCK_WALLPAPER_MAXLEN], disp[64]; touchPrefsGetLockWallpaper(cur, sizeof cur); lockwallDisplayName(cur, disp, sizeof disp); lv_label_set_text(s_lockwall_btn_lbl, disp); } lv_label_set_long_mode(s_lockwall_btn_lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(s_lockwall_btn_lbl, lv_pct(92)); lv_obj_set_style_text_align(s_lockwall_btn_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); // centre text within the fixed-width label lv_obj_center(s_lockwall_btn_lbl); y += SC(40); y += settingsRowLabel(body, y, 0, TR("Lock text colour"), COLOR_SUB, &g_font_12, 0) + 2; const int ncol = (int)(sizeof(kLockColors) / sizeof(kLockColors[0])); const uint32_t curcol = touchPrefsGetLockTextColor(); const int swz = 22, gap = 3; for (int i = 0; i < ncol; ++i) { lv_obj_t* sb = lv_btn_create(body); lv_obj_set_size(sb, swz, swz); lv_obj_set_pos(sb, 2 + i * (swz + gap), y); lv_obj_set_style_radius(sb, 5, LV_PART_MAIN); lv_obj_set_style_bg_color(sb, lv_color_hex(kLockColors[i]), LV_PART_MAIN); lv_obj_set_style_bg_opa(sb, LV_OPA_COVER, LV_PART_MAIN); // Highlight the current colour with a white ring. const bool sel = (kLockColors[i] == curcol); lv_obj_set_style_border_width(sb, sel ? 2 : 1, LV_PART_MAIN); lv_obj_set_style_border_color(sb, lv_color_hex(sel ? 0xFFFFFF : 0x18191A), LV_PART_MAIN); lv_obj_add_event_cb(sb, lockColorChosenCb, LV_EVENT_CLICKED, (void*)(uintptr_t)kLockColors[i]); } y += swz + 10; } #endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 // Auto-lock on screen-off: when the idle timeout dims the screen, also hard-lock so the // touchscreen is inert (Tim: pocket-taps while dark). NOT on the P4 — it has no unlock path // (no button/trackball and no touch-unlock overlay yet), so a hard lock there is a power-cycle // trap. The P4 still dims on idle (screen-off wakes on touch); a proper P4 lock is a follow-up. #if !defined(HAS_TDISPLAY_P4) { int h = settingsRowLabel(body, y, 6, TR("Lock when screen off"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetLockOnScreenOff()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } #endif } if (sec == DSEC_CLOCK) { // --- Time zone + UTC offset --- /* Time zone: pick a region with the correct DST rules. Fixes non-EU users who were stuck on the CET base. Applies immediately. Both boards. */ { y += settingsRowLabel(body, y, 0, TR("Time zone"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* b_tz = lv_btn_create(body); lv_obj_set_size(b_tz, lv_pct(100), SC(34)); lv_obj_set_pos(b_tz, 2, y); styleButton(b_tz); lv_obj_add_event_cb(b_tz, openTimezoneCb, LV_EVENT_CLICKED, nullptr); s_tz_btn_lbl = lv_label_create(b_tz); useChainedFont(s_tz_btn_lbl); lv_label_set_text(s_tz_btn_lbl, touchPrefsTimezoneLabel(touchPrefsGetTimezone())); lv_label_set_long_mode(s_tz_btn_lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(s_tz_btn_lbl, lv_pct(92)); lv_obj_set_style_text_align(s_tz_btn_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); // centre text within the fixed-width label lv_obj_center(s_tz_btn_lbl); y += SC(42); } /* Time offset: only used when Time zone = "Custom (UTC offset)"; otherwise the selected zone (with its own DST) is authoritative. Display-only. Both boards. */ { y += settingsRowLabel(body, y, 0, TR("Custom UTC offset (hours)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* bminus = lv_btn_create(body); lv_obj_set_size(bminus, SC(50), SC(34)); lv_obj_set_pos(bminus, 2, y); styleButton(bminus); lv_obj_add_event_cb(bminus, timeOffsetMinusCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lm = lv_label_create(bminus); lv_label_set_text(lm, "-1 h"); lv_obj_center(lm); useChainedFont(lm); s_time_offset_lbl = lv_label_create(body); lv_obj_set_width(s_time_offset_lbl, 100); lv_obj_set_style_text_align(s_time_offset_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_color(s_time_offset_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_time_offset_lbl, &g_font_16, LV_PART_MAIN); lv_obj_set_pos(s_time_offset_lbl, 58, y + 9); timeOffsetLabelRefresh(); lv_obj_t* bplus = lv_btn_create(body); lv_obj_set_size(bplus, SC(50), SC(34)); lv_obj_set_pos(bplus, 168, y); styleButton(bplus); lv_obj_add_event_cb(bplus, timeOffsetPlusCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lp = lv_label_create(bplus); lv_label_set_text(lp, "+1 h"); lv_obj_center(lp); useChainedFont(lp); y += SC(42); } } #if CAP_CONSOLE if (sec == DSEC_GENERAL) { // --- Console mode (boot mode; reboots) --- y += settingsRowLabel(body, y, 0, TR("Console mode"), COLOR_SUB, &g_font_12, 0) + 2; { lv_obj_t* crow = lv_obj_create(body); lv_obj_remove_style_all(crow); lv_obj_set_size(crow, lv_pct(100), SC(34)); lv_obj_set_pos(crow, 2, y); lv_obj_set_style_bg_color(crow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(crow, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(crow, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(crow, 10, LV_PART_MAIN); lv_obj_set_style_pad_right(crow, 10, LV_PART_MAIN); lv_obj_clear_flag(crow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* cl = lv_label_create(crow); lv_label_set_text(cl, TR("Text console (experimental)")); useChainedFont(cl); lv_obj_set_style_text_color(cl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(cl, LV_LABEL_LONG_DOT); lv_obj_set_width(cl, lv_pct(70)); lv_obj_align(cl, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* csw = lv_switch_create(crow); lv_obj_set_size(csw, 44, 24); lv_obj_align(csw, LV_ALIGN_RIGHT_MID, 0, 0); if (touchPrefsGetConsoleMode()) lv_obj_add_state(csw, LV_STATE_CHECKED); lv_obj_add_event_cb(csw, consoleModeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += SC(42); } y += settingsRowLabel(body, y, 0, TR("EXPERIMENTAL. Boots into a text console with no graphical interface: type commands, " "read replies. Frees the memory and processor time the interface uses. Type 'ui' in " "the console to come back, and if it ever fails to start the device returns here on " "its own. Toggling reboots."), COLOR_SUB, &g_font_12, 1) + 8; } #endif if (sec == DSEC_GENERAL) { // --- Run setup + reboot --- // Re-run the first-boot setup flow (name / region / Wi-Fi) on demand. lv_obj_t* b_setup = lv_btn_create(body); lv_obj_set_size(b_setup, lv_pct(100), SC(34)); lv_obj_set_pos(b_setup, 2, y); styleButton(b_setup); lv_obj_add_event_cb(b_setup, setupRerunCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_setup = lv_label_create(b_setup); useChainedFont(l_setup); lv_label_set_text_fmt(l_setup, LV_SYMBOL_REFRESH " %s", TR("Run setup again")); lv_obj_center(l_setup); y += SC(42); lv_obj_t* b_reboot = lv_btn_create(body); lv_obj_set_size(b_reboot, lv_pct(100),SC(34)); lv_obj_set_pos(b_reboot, 2, y); styleButton(b_reboot); lv_obj_set_style_bg_color(b_reboot, lv_color_hex(0xC44B55), LV_PART_MAIN); lv_obj_set_style_bg_color(b_reboot, lv_color_hex(0xA13F47), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_text_color(b_reboot, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_add_event_cb(b_reboot, rebootCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_reboot = lv_label_create(b_reboot); useChainedFont(l_reboot); lv_label_set_text(l_reboot, TR("Reboot device")); lv_obj_center(l_reboot); y += SC(42); } if (sec == DSEC_BATTERY) { // --- Battery --- // ---- Battery ---- // Open the battery / power screen (same as tapping the battery icon in the top // bar). On the T-Deck this group also hosts the experimental battery saver. y += settingsRowLabel(body, y, 0, TR("Battery"), COLOR_SUB, &g_font_12, 0) + 2; { lv_obj_t* b_bat = lv_btn_create(body); lv_obj_set_size(b_bat, lv_pct(100), SC(34)); lv_obj_set_pos(b_bat, 2, y); styleButton(b_bat); lv_obj_add_event_cb(b_bat, batteryTapCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l_bat = lv_label_create(b_bat); useChainedFont(l_bat); lv_label_set_text_fmt(l_bat, LV_SYMBOL_BATTERY_2 " %s", TR("Battery & power history")); lv_obj_center(l_bat); y += SC(42); } #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) // Experimental battery saver (idle power-save) — throttles the CPU when the // device is parked (screen off, on battery, standalone). Moved here from // Settings -> Lock so it lives with the battery. T-Deck + V4-R8 (the old // "gate never passes on the V4" note was stale for the R8: batteryIsCharging // can't block it there, and the other gates are user state). { int h = settingsRowLabel(body, y, 6, TR("Battery saver (experimental)"), COLOR_TEXT, &g_font_12, 56); lv_obj_t* sw = lv_switch_create(body); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); #if defined(ESP32) if (touchPrefsGetSleepIdle()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, sleepIdleToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(SC(34), h + 12); const char* reason = tsBlockReason(); y += settingsRowLabel(body, y, 0, reason ? reason : TR("Throttles the CPU when idle to save power"), COLOR_SUB, &g_font_12, 0) + 6; } #endif // Calibrate battery: capture the current voltage as 100% (for custom packs / // builds whose full voltage isn't 4.2 V). Tap = set 100%; long-press = reset. { lv_obj_t* b_cal = lv_btn_create(body); lv_obj_set_size(b_cal, lv_pct(100), SC(34)); lv_obj_set_pos(b_cal, 2, y); styleButton(b_cal); lv_obj_set_style_bg_color(b_cal, lv_color_hex(0x2F6B57), LV_PART_MAIN); lv_obj_set_style_bg_color(b_cal, lv_color_hex(0x244F41), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_text_color(b_cal, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_add_event_cb(b_cal, calibrateBatteryCb, LV_EVENT_SHORT_CLICKED, nullptr); lv_obj_add_event_cb(b_cal, calibrateBatteryCb, LV_EVENT_LONG_PRESSED, nullptr); lv_obj_t* l_cal = lv_label_create(b_cal); useChainedFont(l_cal); lv_label_set_text_fmt(l_cal, LV_SYMBOL_BATTERY_FULL " %s", TR("Calibrate battery (full = 100%)")); lv_obj_center(l_cal); y += SC(46); } #if 0 // Live-info panel retired — About's System Info already covers firmware/model/key/counts/batt/time. // ----- Live info panel (below action buttons) ----- // Mirrors the web client's "Device (live)" block: firmware, model, public // key prefix, contacts/channels counts, battery, current device time. These // are snapshot-at-open — re-open the Device modal to refresh. { lv_obj_t* sep = lv_obj_create(body); lv_obj_set_size(sep, lv_pct(100),SC(1)); lv_obj_set_pos(sep, 2, y); lv_obj_set_style_bg_color(sep, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(sep, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(sep, 0, LV_PART_MAIN); lv_obj_clear_flag(sep, LV_OBJ_FLAG_SCROLLABLE); y += SC(6); } auto mk_info = [&](const char* label, const char* value) { lv_obj_t* row = lv_label_create(body); char line[96]; snprintf(line, sizeof(line), "%s %s", label, value); lv_label_set_text(row, line); /* Constrain height to one line so LONG_DOT actually ellipsizes instead of * wrapping — long firmware strings were overlapping the next row. */ lv_obj_set_size(row, lv_pct(100),SC(14)); lv_label_set_long_mode(row, LV_LABEL_LONG_DOT); lv_obj_set_style_text_color(row, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(row, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(row, 2, y); y += SC(16); }; { char buf[96]; // Firmware snprintf(buf, sizeof(buf), "%s · %s", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); mk_info(TR("Firmware:"), buf); // Device model (build-time constant for this variant) #if defined(HELTEC_LORA_V4_TFT) mk_info(TR("Model:"), "Heltec LoRa32 V4 TFT (touch)"); #else mk_info(TR("Model:"), "Heltec LoRa32 V4"); #endif #if defined(HELTEC_LORA_V4_R8) // Perf-pass readout (2026-08-20). This board has no reachable serial console, so the // four runtime facts that pass needs verified live here: CPU clock, display bus clock + // flush mode (DMA = async band flush active), micro-SD operating clock, FEM LNA state. { char sd[16]; if (g_sd_operating_hz >= 1000000) snprintf(sd, sizeof sd, "%lu MHz", (unsigned long)(g_sd_operating_hz / 1000000)); else if (g_sd_operating_hz > 0) snprintf(sd, sizeof sd, "%lu kHz", (unsigned long)(g_sd_operating_hz / 1000)); else snprintf(sd, sizeof sd, "none"); snprintf(buf, sizeof(buf), "CPU %u · TFT %u MHz %s · SD %s · LNA %s", (unsigned)getCpuFrequencyMhz(), (unsigned)(LGFX_SPI_WRITE_HZ / 1000000), display.asyncFlushActive() ? "DMA" : "sync", sd, board.femLnaControllable() ? (touchPrefsGetFemLna() ? "on" : "off") : "n/a"); mk_info(TR("Perf:"), buf); } #endif // Public key prefix (first 8 bytes = 16 hex) { const uint8_t* pk = the_mesh.getSelfPubKey(); if (pk) { char hex[20]; mesh::Utils::toHex(hex, pk, 8); hex[16] = '\0'; snprintf(buf, sizeof(buf), "%s…", hex); mk_info(TR("Public key:"), buf); } else { mk_info(TR("Public key:"), "—"); } } // Contacts / Channels with max limits #ifdef MAX_CONTACTS snprintf(buf, sizeof(buf), "%d / %d", the_mesh.getNumContacts(), (int)MAX_CONTACTS); #else snprintf(buf, sizeof(buf), "%d", the_mesh.getNumContacts()); #endif mk_info(TR("Contacts:"), buf); #ifdef MAX_GROUP_CHANNELS snprintf(buf, sizeof(buf), "%d / %d", the_mesh.getNumChannels(), (int)MAX_GROUP_CHANNELS); #else snprintf(buf, sizeof(buf), "%d", the_mesh.getNumChannels()); #endif mk_info(TR("Channels:"), buf); // Battery if (g_lv.task) { uint16_t mv = g_lv.task->getBattMilliVolts(); if (mv > 0) { snprintf(buf, sizeof(buf), "%u mV", (unsigned)mv); } else { snprintf(buf, sizeof(buf), "—"); } mk_info(TR("Battery:"), buf); } // Device time (formatted as YYYY-MM-DD HH:MM in local timezone) { mesh::RTCClock* rtc = the_mesh.getRTCClock(); uint32_t now = rtc ? rtc->getCurrentTime() : 0; if (now > 0) { time_t t = (time_t)now; struct tm tmv; /* RTC stores UTC; show user-local time using TZ set via configTzTime * in main.cpp (CET-1CEST,M3.5.0,M10.5.0/3). */ localtime_r(&t, &tmv); char ts[40]; snprintf(ts, sizeof(ts), "%04d-%02d-%02d %02d:%02d %s", tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_isdst > 0 ? "CEST" : "CET"); mk_info(TR("Device time:"), ts); } else { mk_info(TR("Device time:"), "not set"); } } } y += SC(6); lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, 216); lv_obj_set_pos(hint, 2, y); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Diagnostics are on the About page.")); #endif } } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static void trimWifiField(char* s) { if (!s) return; size_t n = strlen(s); while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t')) s[--n] = '\0'; char* p = s; while (*p == ' ' || *p == '\t') ++p; if (p != s) memmove(s, p, strlen(p) + 1); } static void saveTransportWifiCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; if (!g_set_modal.wifi_ssid_ta || !g_set_modal.wifi_pwd_ta || !g_set_modal.wifi_sw) return; kbMirrorSyncToReal(); char ssid[WIFI_CONFIG_SSID_MAX]; char pwd[WIFI_CONFIG_PWD_MAX]; strncpy(ssid, lv_textarea_get_text(g_set_modal.wifi_ssid_ta), sizeof(ssid) - 1); ssid[sizeof(ssid) - 1] = '\0'; strncpy(pwd, lv_textarea_get_text(g_set_modal.wifi_pwd_ta), sizeof(pwd) - 1); pwd[sizeof(pwd) - 1] = '\0'; trimWifiField(ssid); trimWifiField(pwd); if (!wifiConfigSetSsid(ssid)) { g_lv.task->showAlert(TR("SSID too long"), 1400); return; } if (!wifiConfigSetPwd(pwd)) { g_lv.task->showAlert(TR("Password too long"), 1400); return; } const bool radio_on = lv_obj_has_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); wifiConfigSetRadioEnabled(radio_on); if (radio_on) { if (!wifiConfigHasRuntime() || ssid[0] == '\0') { g_lv.task->showAlert(TR("Set an SSID first"), 1400); return; } wifiConfigRequestApply(); g_lv.task->showAlert(TR("Wi-Fi saved, applying"), 1400); } else { wifiConfigRequestApply(); g_lv.task->showAlert(TR("Wi-Fi saved (radio off)"), 1400); } refreshStatusLabels(); } #endif // ----- Confirm popup ----- // Small floating modal with Cancel + Confirm buttons. Used by the Wi-Fi and // Bluetooth pages so the user doesn't need to scroll a long inline hint to // reach the Save button; the explanation appears on click instead. namespace { typedef void (*SimpleCb)(); SimpleCb s_confirm_cb = nullptr; lv_obj_t* s_confirm_modal = nullptr; void confirmDismiss() { if (s_confirm_modal) { popupClose(&s_confirm_modal); } s_confirm_cb = nullptr; } void confirmCancelEvt(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; confirmDismiss(); } void confirmOkEvt(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; SimpleCb cb = s_confirm_cb; confirmDismiss(); if (cb) cb(); } } // namespace static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confirm) { confirmDismiss(); s_confirm_cb = on_confirm; // Backdrop: semi-opaque full-screen catcher so taps outside the card do nothing. s_confirm_modal = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_confirm_modal); // Backdrop starts below the global status bar — full screen so the card // centers on-screen and the tap-catcher covers everything. (When this was a // fixed 240-wide rect, in landscape the card landed partly off-screen with // its buttons unreachable while the catcher still ate every tap — the UI // looked "frozen".) lv_obj_set_size(s_confirm_modal, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_set_pos(s_confirm_modal, 0, STATUSBAR_H); lv_obj_set_style_bg_opa(s_confirm_modal, LV_OPA_60, LV_PART_MAIN); lv_obj_set_style_bg_color(s_confirm_modal, lv_color_black(), LV_PART_MAIN); lv_obj_clear_flag(s_confirm_modal, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_confirm_modal, LV_OBJ_FLAG_FLOATING); lv_obj_move_foreground(s_confirm_modal); // Card — the height FOLLOWS the wrapped message. It used to be a fixed PSC(160) with a // freely-wrapping label, so a long message (the crash-report prompt is the worst case) // simply ran down over the Cancel/OK buttons (#97). Grow to fit the text, keep the old // 160 as a floor so every short dialog looks exactly as before, and cap to the screen so // the card can never overflow; the message area below scrolls if the cap bit. lv_obj_t* card = lv_obj_create(s_confirm_modal); lv_obj_remove_style_all(card); #if CAP_LARGE_SCREEN const lv_coord_t cf_lblw = PSC(186 - 32); #else const lv_coord_t cf_lblw = 186 - 32; #endif lv_point_t cf_tsz; lv_txt_get_size(&cf_tsz, TR(msg), &g_font_14, 0, 2, cf_lblw, LV_TEXT_FLAG_NONE); const lv_coord_t cf_chrome = (lv_coord_t)(PSC(12) * 2 + PSC(14) + PSC(34)); // pads + gap + buttons lv_coord_t cf_h = (lv_coord_t)(cf_tsz.y + cf_chrome); if (cf_h < PSC(160)) cf_h = PSC(160); const lv_coord_t cf_max = lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12; if (cf_h > cf_max) cf_h = cf_max; lv_coord_t cf_msgh = (lv_coord_t)(cf_h - cf_chrome); if (cf_msgh < PSC(20)) cf_msgh = PSC(20); // never let a tall UI scale invert this lv_obj_set_size(card, PCW(210), cf_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 12); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_pad_all(card, PSC(12), LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, confirmCancelEvt); // X behaves like Cancel // Message area: its own box occupying exactly the space ABOVE the buttons, so the text // physically cannot reach them; scrolls vertically when the card hit the screen cap. // Width is still shortened (cf_lblw) so the first line doesn't slide under the X badge. lv_obj_t* msg_box = lv_obj_create(card); lv_obj_remove_style_all(msg_box); lv_obj_set_size(msg_box, cf_lblw, cf_msgh); lv_obj_align(msg_box, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_set_scroll_dir(msg_box, LV_DIR_VER); lv_obj_t* lbl = lv_label_create(msg_box); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, cf_lblw); lv_label_set_text(lbl, TR(msg)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_t* b_cancel = lv_btn_create(card); lv_obj_set_size(b_cancel, PSC(80), PSC(34)); lv_obj_align(b_cancel, LV_ALIGN_BOTTOM_LEFT, 0, 0); styleButton(b_cancel); lv_obj_set_style_bg_color(b_cancel, lv_color_hex(0x3A4A5C), LV_PART_MAIN); lv_obj_set_style_bg_color(b_cancel, lv_color_hex(0x2D3947), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_text_color(b_cancel, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_add_event_cb(b_cancel, confirmCancelEvt, LV_EVENT_CLICKED, nullptr); lv_obj_t* lc = lv_label_create(b_cancel); useChainedFont(lc); lv_label_set_text(lc, TR("Cancel")); uiFitLabelWidth(lc, PSC(80) - 8); lv_obj_center(lc); lv_obj_t* b_ok = lv_btn_create(card); lv_obj_set_size(b_ok, SC(100), SC(34)); lv_obj_align(b_ok, LV_ALIGN_BOTTOM_RIGHT, 0, 0); styleButton(b_ok); lv_obj_add_event_cb(b_ok, confirmOkEvt, LV_EVENT_CLICKED, nullptr); lv_obj_t* lo = lv_label_create(b_ok); useChainedFont(lo); lv_label_set_text(lo, ok_label ? TR(ok_label) : "OK"); uiFitLabelWidth(lo, SC(100) - 8); lv_obj_center(lo); } // ----- Bluetooth settings page ----- // Wi-Fi + BLE coexist once both stacks are allocated. Re-enabling a resident // stack is live; a cold Pager allocation restarts through setup's Wi-Fi-first // ordering. State is persisted (ble_en), and Wi-Fi is left enabled. // The pairing code is editable here (persisted to _prefs.ble_pin; applied at the // next boot, since the passkey is baked into serial_interface.begin()). static lv_obj_t* s_ble_pin_ta = nullptr; // editable 6-digit pairing-code field on the BLE page #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Bluetooth enable switch: resident BLE toggles live; enableBle() handles the // Pager's ordered-restart fallback for a cold stack. No Save button is needed. static void bleEnableSwitchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; if (!g_lv.task->hasBleCapability()) { g_lv.task->showAlert(TR("No Bluetooth on this device"), 1400); return; } const bool want = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); const bool requested = bleRequestedOrEnabled(); if (want == requested) return; if (want) { if (!g_lv.task->enableBle()) { // A Pager request queued behind Wi-Fi is still ON as user intent. Keep it // checked so a second tap can cancel; only a true refusal reverts to OFF. // Other boards have no pending-request state, so a refusal always reverts. bool still_requested = false; #if defined(TLORA_PAGER) still_requested = wifiConfigGetBleEnabled(); #endif if (still_requested) lv_obj_add_state(lv_event_get_target(e), LV_STATE_CHECKED); else lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); g_lv.task->showAlert(bleEnableFailureText(), 2600); return; } } else { g_lv.task->disableBle(); } g_lv.task->showAlert(want ? TR("Bluetooth on") : TR("Bluetooth off"), 1000); } // Pairing code: persist a 6-digit PIN on blur (applies next reboot, same contract as the // companion CMD_SET_DEVICE_PIN). Silent if the field is mid-edit (not exactly 6 digits). static void blePinSaveCb(lv_event_t* e) { if (blurFromDelete(e)) return; // the widget is being destroyed if (lv_event_get_code(e) != LV_EVENT_DEFOCUSED || !g_lv.task) return; if (!s_ble_pin_ta || !lv_obj_is_valid(s_ble_pin_ta)) return; kbMirrorSyncToReal(); const char* txt = lv_textarea_get_text(s_ble_pin_ta); if (!txt || strlen(txt) != 6) return; // incomplete code on blur → leave the saved one unchanged uint32_t pin = (uint32_t)atol(txt); NodePrefs* pr = the_mesh.getNodePrefs(); if (pr && pin == pr->ble_pin) return; // unchanged if (the_mesh.setBLEPin(pin)) g_lv.task->showAlert(TR("Pairing code saved — reboot to apply"), 2000); } #endif static void buildBluetoothSettings() { lv_obj_t* body = createSettingsModal(TR("Bluetooth"), SettingsModalKind::Bluetooth); int y = 0; if (!g_lv.task || !g_lv.task->hasBleCapability()) { // No BLE on this board (T-Display P4: the C6 radio's factory firmware can't do BLE companion). // An explanation beats a hidden row — users coming from other MeshCore devices WILL look here. lv_obj_t* why = lv_label_create(body); lv_label_set_text(why, TR("Bluetooth pairing isn't available on this device.\n\n" "The radio chip's factory firmware doesn't support it.\n\n" "Pair the phone app over Wi-Fi instead: connect this device to your " "network, then add it in the app by its IP address (TCP, port 5000) — " "shown under Settings > Network. USB works too.")); lv_label_set_long_mode(why, LV_LABEL_LONG_WRAP); lv_obj_set_width(why, lv_pct(100)); lv_obj_set_style_text_color(why, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(why, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(why, 2, y); return; } // ---- Mode line ---- (Wi-Fi + BLE coexist now; show both radios' state) const bool ble_active = g_lv.task && g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled(); lv_obj_t* mode = lv_label_create(body); useChainedFont(mode); #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool wifi_on_m = wifiConfigGetRadioEnabled(); const bool ble_cap_m = g_lv.task && g_lv.task->hasBleCapability(); if (ble_active) lv_label_set_text(mode, wifi_on_m ? "Mode: BLE on (+ Wi-Fi)" : "Mode: BLE on"); // "starting" only where something will actually act on the request later // (the Pager's post-association retry). Elsewhere a saved-but-not-resident // preference just means off, and one tap brings the stack up. else if (ble_cap_m && bleRequestedOrEnabled()) lv_label_set_text(mode, TR("Mode: BLE starting / low memory")); else lv_label_set_text(mode, wifi_on_m ? "Mode: BLE off (Wi-Fi on)" : "Mode: BLE off"); #else lv_label_set_text(mode, ble_active ? "Mode: BLE on" : "Mode: BLE off"); #endif lv_obj_set_style_text_color(mode, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(mode, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(mode, 2, y); y += SC(18); // ---- Editable pairing code (6-digit; persisted to _prefs.ble_pin, applied at // the next boot since the passkey is baked into serial_interface.begin()). ---- s_ble_pin_ta = nullptr; if (g_lv.task && g_lv.task->hasBleCapability()) { lv_obj_t* pin_lbl = lv_label_create(body); lv_label_set_text(pin_lbl, TR("Pairing code")); lv_obj_set_style_text_color(pin_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(pin_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(pin_lbl, 2, y + 7); s_ble_pin_ta = lv_textarea_create(body); lv_obj_set_size(s_ble_pin_ta, SC(96), SC(32)); lv_obj_align(s_ble_pin_ta, LV_ALIGN_TOP_RIGHT, 0, y); lv_textarea_set_one_line(s_ble_pin_ta, true); lv_textarea_set_accepted_chars(s_ble_pin_ta, "0123456789"); lv_textarea_set_max_length(s_ble_pin_ta, 6); { // Show the CURRENT code as a greyed placeholder and leave the field EMPTY, // so the user types a fresh 6-digit code without fighting the 6-char cap on // a pre-filled value (which caused partial entries -> "must be 6 digits"). // Empty on save = leave the code unchanged. const NodePrefs* pr = the_mesh.getNodePrefs(); const uint32_t cur = pr ? pr->ble_pin : 0; char pb[12]; if (cur >= 1 && cur <= 999999) snprintf(pb, sizeof pb, "%06lu", static_cast<unsigned long>(cur)); else snprintf(pb, sizeof pb, "------"); taSetPlaceholder(s_ble_pin_ta, pb); lv_textarea_set_text(s_ble_pin_ta, ""); } attachSettingsTaEvents(s_ble_pin_ta); lv_obj_add_event_cb(s_ble_pin_ta, blePinSaveCb, LV_EVENT_DEFOCUSED, nullptr); // auto-save on blur y += SC(38); lv_obj_t* hint = lv_label_create(body); lv_label_set_text(hint, TR("Type a new 6-digit code \xC2\xB7 applies after a reboot")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hint, 2, y); y += SC(18); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // ---- Enable switch + save button ---- lv_obj_t* sw_lbl = lv_label_create(body); useChainedFont(sw_lbl); lv_label_set_text(sw_lbl, TR("Enable Bluetooth")); lv_obj_set_style_text_color(sw_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(sw_lbl, 2, y + 6); g_set_modal.wifi_sw = lv_switch_create(body); // reuse the same slot — only one switch lives in a modal at a time lv_obj_align(g_set_modal.wifi_sw, LV_ALIGN_TOP_RIGHT, 0, y); // flush right // Must be the SAME predicate bleEnableSwitchCb compares against, or its // `want == requested` early-out swallows the first tap. if (ble_active || bleRequestedOrEnabled()) lv_obj_add_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(g_set_modal.wifi_sw, bleEnableSwitchCb, LV_EVENT_VALUE_CHANGED, nullptr); // instant toggle (BLE is live) y += SC(38); // No Save button — the enable switch toggles BLE live, and the pairing code auto-saves on blur. #else (void)y; #endif refreshStatusLabels(); } // ----- Dedicated Wi-Fi settings page ----- // Shows live connection state (status, IP, RSSI), web-socket count, and the // SSID / password fields with a single Save+reconnect button. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // State captured at saveWifiCb time, applied inside the confirm callback. static char s_pending_wifi_ssid[WIFI_CONFIG_SSID_MAX]; static char s_pending_wifi_pwd[WIFI_CONFIG_PWD_MAX]; static bool s_pending_wifi_radio_on = false; static bool s_pending_wifi_was_wifi = false; // Wi-Fi already the live transport at save time? static void doApplyWifi() { if (!wifiConfigSetSsid(s_pending_wifi_ssid)) { if (g_lv.task) g_lv.task->showAlert(TR("SSID too long"), 1400); return; } if (!wifiConfigSetPwd(s_pending_wifi_pwd)) { if (g_lv.task) g_lv.task->showAlert(TR("Password too long"), 1400); return; } wifiConfigSetRadioEnabled(s_pending_wifi_radio_on); wifiConfigRequestApply(); // Credential changes apply live. Pager first releases resident NimBLE in the // main loop, re-associates Wi-Fi, then recreates BLE after GOT_IP. if (g_lv.task) g_lv.task->showAlert( s_pending_wifi_radio_on ? TR("Saved — applying\xE2\x80\xA6") : TR("Wi-Fi saved (radio off)"), 1600); refreshStatusLabels(); } // Forward decl so wifiSlotSaveCb can refresh the modal after writing. static void buildWifiSettings(); static void buildMqttSettings(); // Saved Wi-Fi profile slot: tap to load. Fills the SSID/PWD textareas with // the stored creds — does NOT auto-apply, so the operator can still tweak // (or change their mind by tapping Close) before the main "Save / turn on" // commits (live — no reboot). static void wifiSlotLoadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(e); if (!g_set_modal.wifi_ssid_ta || !g_set_modal.wifi_pwd_ta) return; char label[TOUCH_WIFI_LABEL_MAX]; char ssid_b[WIFI_CONFIG_SSID_MAX]; char pwd_b[WIFI_CONFIG_PWD_MAX]; if (!touchPrefsGetWifiSlot((int)idx, label, sizeof(label), ssid_b, sizeof(ssid_b), pwd_b, sizeof(pwd_b))) { if (g_lv.task) g_lv.task->showAlert(TR("Slot empty"), 1000); return; } if (ssid_b[0] == '\0') { if (g_lv.task) g_lv.task->showAlert(TR("Slot empty"), 1000); return; } lv_textarea_set_text(g_set_modal.wifi_ssid_ta, ssid_b); lv_textarea_set_text(g_set_modal.wifi_pwd_ta, pwd_b); if (g_set_modal.wifi_sw) lv_obj_add_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); char msg[40]; snprintf(msg, sizeof(msg), "Loaded slot %d — Save to apply", (int)idx + 1); if (g_lv.task) g_lv.task->showAlert(msg, 1300); } // Per-slot "Saved profiles" row labels, so a slot Save refreshes just that row // in place (below) instead of tearing down + rebuilding the page. Rebuilding // from this callback called buildWifiSettings() with s_settings_inline_parent // already null, which spawned a rogue modal on lv_layer_top() over the inline // Network sub-tab and stranded the Close button (reboot was the only way out). static lv_obj_t* s_wifi_slot_row_lbl[TOUCH_WIFI_SLOT_COUNT] = { nullptr }; static void wifiSlotFmtRow(int i, const char* label, const char* ssid, char* out, size_t out_sz) { if (!ssid || ssid[0] == '\0') snprintf(out, out_sz, "%d: (empty)", i + 1); else if (label && label[0]) snprintf(out, out_sz, "%d: %s (%s)", i + 1, label, ssid); else snprintf(out, out_sz, "%d: %s", i + 1, ssid); } // Save the current SSID/PWD form contents into a slot. Label defaults to // "Slot N" so the row text isn't blank; user can rename via the same flow // after editing (no inline rename UI — keeps the page short). static void wifiSlotSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(e); if (!g_set_modal.wifi_ssid_ta || !g_set_modal.wifi_pwd_ta) return; kbMirrorSyncToReal(); const char* ssid = lv_textarea_get_text(g_set_modal.wifi_ssid_ta); const char* pwd = lv_textarea_get_text(g_set_modal.wifi_pwd_ta); if (!ssid || !ssid[0]) { if (g_lv.task) g_lv.task->showAlert(TR("Enter SSID first"), 1200); return; } // Use the SSID as the slot label so the row text reads naturally even // without an explicit naming step. char label[TOUCH_WIFI_LABEL_MAX]; strncpy(label, ssid, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; if (!touchPrefsSetWifiSlot((int)idx, label, ssid, pwd)) { if (g_lv.task) g_lv.task->showAlert(TR("Save failed"), 1200); return; } char msg[40]; snprintf(msg, sizeof(msg), TR("Saved to slot %d"), (int)idx + 1); if (g_lv.task) g_lv.task->showAlert(msg, 1100); // Refresh just this slot's row label in place. Do NOT close+rebuild here: on // the inline Network sub-tab s_settings_inline_parent is null in this callback, // so buildWifiSettings() would spawn a rogue modal over the page and strand the // Close button. The slot's label is the saved SSID (label = ssid above). if (idx >= 0 && idx < TOUCH_WIFI_SLOT_COUNT && s_wifi_slot_row_lbl[idx]) { char row_text[80]; wifiSlotFmtRow((int)idx, label, ssid, row_text, sizeof(row_text)); lv_label_set_text(s_wifi_slot_row_lbl[idx], row_text); lv_obj_set_style_text_color(s_wifi_slot_row_lbl[idx], lv_color_hex(COLOR_TEXT), LV_PART_MAIN); } } // Clear a saved Wi-Fi slot (the trash button on its row). Refreshes the row // label to "(empty)" in place — same in-place pattern as the slot Save. static void wifiSlotDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_WIFI_SLOT_COUNT) return; touchPrefsSetWifiSlot((int)idx, "", "", ""); if (s_wifi_slot_row_lbl[idx]) { char row_text[80]; wifiSlotFmtRow((int)idx, "", "", row_text, sizeof(row_text)); lv_label_set_text(s_wifi_slot_row_lbl[idx], row_text); lv_obj_set_style_text_color(s_wifi_slot_row_lbl[idx], lv_color_hex(COLOR_SUB), LV_PART_MAIN); } if (g_lv.task) { char m[28]; snprintf(m, sizeof m, TR("Slot %d cleared"), (int)idx + 1); g_lv.task->showAlert(m, 1000); } } static void saveWifiCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; if (!g_set_modal.wifi_ssid_ta || !g_set_modal.wifi_pwd_ta || !g_set_modal.wifi_sw) return; kbMirrorSyncToReal(); strncpy(s_pending_wifi_ssid, lv_textarea_get_text(g_set_modal.wifi_ssid_ta), sizeof(s_pending_wifi_ssid) - 1); s_pending_wifi_ssid[sizeof(s_pending_wifi_ssid) - 1] = '\0'; strncpy(s_pending_wifi_pwd, lv_textarea_get_text(g_set_modal.wifi_pwd_ta), sizeof(s_pending_wifi_pwd) - 1); s_pending_wifi_pwd[sizeof(s_pending_wifi_pwd) - 1] = '\0'; trimWifiField(s_pending_wifi_ssid); trimWifiField(s_pending_wifi_pwd); // If the user entered an SSID, treat that as intent to connect — force the // radio switch on even if they forgot to toggle it. Otherwise honour switch. s_pending_wifi_radio_on = lv_obj_has_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); if (!s_pending_wifi_radio_on && s_pending_wifi_ssid[0] != '\0') { s_pending_wifi_radio_on = true; lv_obj_add_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); } s_pending_wifi_was_wifi = wifiConfigWantsWifi(); // kept for status display; no longer gates a reboot // Save immediately. Pager's main loop preserves Wi-Fi -> BLE ordering with a // live controller handoff rather than a reboot. doApplyWifi(); } #endif static void wifiScanPopupClose() { popupClose(&s_wifi_scan_popup); s_wifi_scan_list = nullptr; } static void wifiScanPopupCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) wifiScanPopupClose(); } // Tap a scanned SSID -> drop it into the SSID field + close the popup. static void wifiScanSsidCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); const bool ok = (idx >= 0 && idx < s_wifiscan_count && g_set_modal.wifi_ssid_ta); // setTextareaSynced (not plain set_text): if the keyboard mirror is bound to // the SSID field, a later sync would clobber this back to the old SSID. if (ok) setTextareaSynced(g_set_modal.wifi_ssid_ta, s_wifiscan_ssids[idx]); wifiScanPopupClose(); if (ok && g_lv.task) g_lv.task->showAlert(TR("SSID set — enter password, then Save"), 1700); } // (Re)draw the scan-results list inside the popup from the latest scan. static void wifiScanFillList() { if (!s_wifi_scan_list) return; lv_obj_clean(s_wifi_scan_list); const lv_coord_t rw = lv_disp_get_hor_res(nullptr) - 28; if (s_wifiscan_count <= 0) { lv_obj_t* l = lv_label_create(s_wifi_scan_list); lv_label_set_text(l, TR("No networks found (2.4 GHz only)")); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); return; } for (int i = 0; i < s_wifiscan_count; ++i) { lv_obj_t* r = lv_btn_create(s_wifi_scan_list); lv_obj_set_size(r, rw, SC(40)); // tall rows = easy to tap + scroll styleButton(r); lv_obj_set_style_bg_color(r, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_add_event_cb(r, wifiScanSsidCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* l = lv_label_create(r); lv_label_set_text(l, s_wifiscan_ssids[i]); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, rw - 16); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_LEFT_MID, 8, 0); } } // Full-screen scan overlay (covers the bottom bar + sub-tabs) so the SSID list // is big and easy to scroll. Sits below the status bar, on lv_layer_top. static void openWifiScanPopup() { wifiScanPopupClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_wifi_scan_popup = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_wifi_scan_popup); lv_obj_set_size(s_wifi_scan_popup, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_wifi_scan_popup, 0, STATUSBAR_H); styleSurface(s_wifi_scan_popup, COLOR_BG, 0); lv_obj_set_style_pad_all(s_wifi_scan_popup, 0, LV_PART_MAIN); lv_obj_clear_flag(s_wifi_scan_popup, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(s_wifi_scan_popup); lv_label_set_text(title, TR("Select network")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 8, 9); lv_obj_t* close = lv_btn_create(s_wifi_scan_popup); lv_obj_set_size(close, SC(60), SC(30)); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 4); styleButton(close); lv_obj_add_event_cb(close, wifiScanPopupCloseCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* cl = lv_label_create(close); #if defined(HAS_TANMATSU) char _cb[40]; snprintf(_cb, sizeof _cb, LV_SYMBOL_CLOSE " %s", TR("Close")); lv_label_set_text(cl, _cb); lv_obj_set_style_text_color(cl, lv_color_hex(0xE05544), LV_PART_MAIN); // red ✕ #else lv_label_set_text(cl, TR("Close")); #endif lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); } s_wifi_scan_list = lv_obj_create(s_wifi_scan_popup); lv_obj_remove_style_all(s_wifi_scan_list); lv_obj_set_size(s_wifi_scan_list, sw - 12, sh - STATUSBAR_H - 44); lv_obj_set_pos(s_wifi_scan_list, 6, 40); lv_obj_set_flex_flow(s_wifi_scan_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(s_wifi_scan_list, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(s_wifi_scan_list, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_wifi_scan_list, LV_SCROLLBAR_MODE_AUTO); } // Open the picker and kick a fresh scan on the core-0 worker. Shows the last // results instantly (if any) while the rescan lands. Shared by the Settings // Wi-Fi page, the setup wizard's Scan button, and the wizard's auto-scan on // arrival. Caller must have already confirmed wifiConfigWantsWifi() (radio up). static void wifiQueueScanWhenReady() { if (wifiScanIsActive() || __atomic_load_n(&s_wifiscan_request, __ATOMIC_ACQUIRE) || s_wifiscan_drop_req || s_wifiscan_drop_ms != 0 #if defined(TLORA_PAGER) || s_wifiscan_wait_for_radio #endif ) return; ensureTileFetchTaskRunning(); // the worker idles out after 5 s — respawn it #if defined(TLORA_PAGER) wifiScanSetActive(true); // suppress main-loop reconnects for the whole sweep s_wifiscan_pager_guard_ms = millis(); s_wifiscan_reconnect_after = false; if ((WiFi.getMode() & WIFI_MODE_STA) == 0 || wifiConfigPagerWifiBlocksBle()) { // The worker must never call esp_wifi_init. Ask the main-loop handoff to // initialize STA, and release the scan only after the ordering phase ends. s_wifiscan_wait_for_radio = true; if ((WiFi.getMode() & WIFI_MODE_STA) == 0) { s_wifiscan_reconnect_after = wifiConfigHasRuntime(); wifiConfigRequestApply(); } return; } #elif defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && !defined(HAS_TANMATSU) wifiScanSetActive(true); s_wifiscan_guard_ms = millis(); s_wifiscan_reconnect_after = wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED; #endif __atomic_store_n(&s_wifiscan_request, true, __ATOMIC_RELEASE); } static void wifiScanOpenAndKick() { hideKb(); // unbind the keyboard mirror so it can't later revert the picked SSID openWifiScanPopup(); if (s_wifiscan_count > 0) { wifiScanFillList(); // show last results immediately, refresh when the rescan lands } else if (s_wifi_scan_list) { lv_obj_t* l = lv_label_create(s_wifi_scan_list); lv_label_set_text(l, TR("Scanning\xE2\x80\xA6")); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); } wifiQueueScanWhenReady(); } // Mirror of the BLE-enable guard: bringing esp_wifi up needs ~50 KB free internal // heap, and with BLE already holding its share on a tight board the init fails // DEEP in the Wi-Fi state machine (main.cpp) where nothing reports it. Pager // uses the live main-loop BLE handoff; the preflight remains for other boards. enum class WifiEnableGate : uint8_t { Ready, LowMemory }; static WifiEnableGate wifiEnableGate() { #if defined(ESP32) // Once esp_wifi is initialized, a live off/on toggle does not need its large // one-time DMA allocation. Apply the reserve only to a cold start. if (WiFi.getMode() != WIFI_MODE_NULL) return WifiEnableGate::Ready; #if defined(TLORA_PAGER) // The main loop releases NimBLE, allocates Wi-Fi, then recreates NimBLE live. // Let that path make the authoritative allocation attempt and report failure. return WifiEnableGate::Ready; #else const uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; const size_t freeh = heap_caps_get_free_size(internal_caps); const size_t maxblk = heap_caps_get_largest_free_block(internal_caps); if (freeh >= 50u * 1024u && maxblk >= 20u * 1024u) return WifiEnableGate::Ready; Serial.printf("[wifi] cold start refused: free=%u maxblk=%u\n", (unsigned)freeh, (unsigned)maxblk); return WifiEnableGate::LowMemory; #endif #else return WifiEnableGate::Ready; #endif } static bool wifiPrepareEnable(lv_obj_t* switch_to_revert = nullptr) { const WifiEnableGate gate = wifiEnableGate(); if (gate == WifiEnableGate::Ready) return true; if (gate == WifiEnableGate::LowMemory) { if (switch_to_revert) lv_obj_clear_state(switch_to_revert, LV_STATE_CHECKED); if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); return false; } return true; } // "Scan" button -> open the popup + queue a scan on the core-0 worker. If Wi-Fi // is off, prepare the radio first, then let the main loop initialize it before // the worker lists networks. static void wifiScanStartCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(MULTI_TRANSPORT_COMPANION) if (!wifiConfigWantsWifi()) { if (!wifiPrepareEnable()) return; wifiConfigSetRadioEnabled(true); // wantsWifi() now true -> the scan worker can bring STA up wifiConfigRequestApply(); // main loop applies the live Pager handoff if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi on, scanning\xE2\x80\xA6"), 1200); } wifiScanOpenAndKick(); #endif } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static void wifiRebuildNetworkList(); // fwd: reworked Wi-Fi page list (defined before buildWifiSettings) #endif // Kick a Wi-Fi scan from the UI (LVGL ctx — only sets flags, never calls WiFi). // Most S3 targets get more reliable results by dropping an association first. // Pager keeps an established link intact because re-authenticating while NimBLE // is resident crosses its known-unsafe coexistence boundary; its scan is // best-effort while associated and never turns a page-open into a reboot. static void wifiKickScan() { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) if (wifiScanIsActive() || __atomic_load_n(&s_wifiscan_request, __ATOMIC_ACQUIRE) || s_wifiscan_drop_req || s_wifiscan_drop_ms != 0) return; ensureTileFetchTaskRunning(); #if defined(HAS_TANMATSU) __atomic_store_n(&s_wifiscan_request, true, __ATOMIC_RELEASE); // esp-hosted C6 sweeps fine while associated #elif defined(TLORA_PAGER) wifiQueueScanWhenReady(); // main task owns any cold STA allocation #else if (WiFi.status() == WL_CONNECTED) s_wifiscan_drop_req = true; else wifiQueueScanWhenReady(); #endif #endif } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && !defined(HAS_TANMATSU) // MAIN-task half of the scan-while-connected handshake. ALL WiFi state changes live // here (sequential with main.cpp's wifi loop on the same task — no worker race, and // never eraseap, which wedges this radio). Drop the link, let the disassoc settle, // then kick the worker; wifiScanService rejoins once results land. static void wifiScanMainService() { if (s_wifiscan_drop_req) { s_wifiscan_drop_req = false; if (WiFi.status() == WL_CONNECTED) { wifiScanSetActive(true); // gate main.cpp's reconnect-retry for the sweep WiFi.setAutoReconnect(false); // don't auto-rejoin mid-sweep WiFi.disconnect(false, false); // leave the BSS (keep cred + stored AP; NO eraseap) s_wifiscan_drop_ms = millis(); s_wifiscan_guard_ms = millis(); // arm the stuck-gate deadline (recovery below) s_wifiscan_reconnect_after = true; } else { wifiQueueScanWhenReady(); // already disconnected — establish scan ownership now } } else if (s_wifiscan_drop_ms && (uint32_t)(millis() - s_wifiscan_drop_ms) >= 400) { s_wifiscan_drop_ms = 0; __atomic_store_n(&s_wifiscan_request, true, __ATOMIC_RELEASE); // disassoc settled -> run the sweep } // RECOVERY: opening the Wi-Fi page drops the link and raises the scan gate, which is // normally lowered in wifiScanService() once the worker reports s_wifiscan_done. But // that flag is set ONLY by the tile-fetch worker, and that worker can fail to start at // all (its 8 KB contiguous-DRAM stack allocation is best-effort) or sit blocked in a // long tile/LOS/OTA job. If it never reports, the gate LATCHES: autoreconnect is off // and main.cpp's 10 s reconnect retry stays suppressed (main.cpp gates it on // !wifiScanIsActive()), so Wi-Fi never comes back until a reboot — a plausible source // of "connects on a fresh flash, never reconnects after a reboot" (issue #171). // Only cancel a request the worker has not claimed. Once the worker atomically // consumes the request, its bounded scan owns the gate until normal completion; // clearing it on a wall-clock guess would race esp_wifi_scan_start. if (s_wifiscan_guard_ms && wifiScanIsActive() && (uint32_t)(millis() - s_wifiscan_guard_ms) >= 60000UL) { s_wifiscan_guard_ms = 0; bool cancelled = false; if (s_wifiscan_drop_ms != 0) { s_wifiscan_drop_ms = 0; cancelled = true; } if (__atomic_exchange_n(&s_wifiscan_request, false, __ATOMIC_ACQ_REL)) cancelled = true; if (cancelled) { const bool reconnect = s_wifiscan_reconnect_after; s_wifiscan_reconnect_after = false; wifiScanSetActive(false); // un-gate main.cpp's reconnect retry #if !defined(TLORA_PAGER) WiFi.setAutoReconnect(true); #endif if (reconnect) wifiConfigRequestApply(); } } } #endif // Loop service: when the worker finishes a scan, repopulate the popup list. static void wifiScanService() { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && !defined(HAS_TANMATSU) wifiScanMainService(); // drive the drop-link-then-scan handshake (main task) #endif #if defined(TLORA_PAGER) && defined(MULTI_TRANSPORT_COMPANION) if (s_wifiscan_wait_for_radio) { const bool sta_ready = (WiFi.getMode() & WIFI_MODE_STA) != 0; if (!wifiConfigWantsWifi()) { s_wifiscan_wait_for_radio = false; s_wifiscan_reconnect_after = false; s_wifiscan_done = true; } else if (sta_ready && !wifiConfigPagerWifiBlocksBle()) { s_wifiscan_wait_for_radio = false; __atomic_store_n(&s_wifiscan_request, true, __ATOMIC_RELEASE); } else if (!sta_ready && wifiConfigPagerBleFallbackActive()) { // main.cpp already reported the failed allocation and restored BLE. s_wifiscan_wait_for_radio = false; s_wifiscan_reconnect_after = false; s_wifiscan_done = true; } } // Cancel only a wait or queue entry the worker has not claimed. If the worker // already atomically consumed the request, normal completion alone releases // the scan gate; a timeout must never race its esp_wifi_scan_start call. if (s_wifiscan_pager_guard_ms && wifiScanIsActive() && (uint32_t)(millis() - s_wifiscan_pager_guard_ms) >= 60000UL) { s_wifiscan_pager_guard_ms = 0; bool cancelled = s_wifiscan_wait_for_radio; s_wifiscan_wait_for_radio = false; if (__atomic_exchange_n(&s_wifiscan_request, false, __ATOMIC_ACQ_REL)) cancelled = true; if (cancelled) s_wifiscan_done = true; } #endif if (!s_wifiscan_done) return; s_wifiscan_done = false; wifiScanFillList(); // legacy scan popup — no-op if closed #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) wifiRebuildNetworkList(); // refresh the reworked Wi-Fi page (guarded: no-op unless it's open) #if !defined(HAS_TANMATSU) #if defined(TLORA_PAGER) const bool reconnect_after_scan = s_wifiscan_reconnect_after; s_wifiscan_reconnect_after = false; s_wifiscan_pager_guard_ms = 0; wifiScanSetActive(false); if (reconnect_after_scan) wifiConfigRequestApply(); #else if (wifiScanIsActive()) { const bool reconnect_after_scan = s_wifiscan_reconnect_after; s_wifiscan_reconnect_after = false; s_wifiscan_guard_ms = 0; // normal completion — disarm the stuck-gate deadline wifiScanSetActive(false); #if !defined(TLORA_PAGER) WiFi.setAutoReconnect(true); #endif if (reconnect_after_scan) wifiConfigRequestApply(); // main.cpp re-begins with the stored cred (main task) } #endif #endif #endif } // Wi-Fi radio toggle on the Wi-Fi settings page (mirrors the control-center // toggle). Existing stacks apply live; a cold Pager uses the main-loop BLE // handoff above. No separate Save action is needed. static void wifiRadioToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); if (on && !wifiPrepareEnable(lv_event_get_target(e))) return; wifiConfigSetRadioEnabled(on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi on") : TR("Wi-Fi off"), 800); refreshStatusLabels(); #endif } // ============================================================================ // Reworked Wi-Fi: iPhone/Android-style known-networks list + join / details / // hidden-network sheets. The scrollable network list lives in s_wifi_list_cont // (rebuilt on open, on scan-complete, and after connect/forget); the active // credentials + the 8-slot known-networks store (TouchPrefsStore) back it. // ============================================================================ #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static lv_obj_t* s_wifi_list_cont = nullptr; // network list (valid only while the Wi-Fi modal is open) // s_wifi_sheet itself is declared up near s_settings_sheet (navMaybeRebuild needs it early). static lv_obj_t* s_wifi_sheet_ssid_ta = nullptr; static lv_obj_t* s_wifi_sheet_pwd_ta = nullptr; static lv_obj_t* s_wifi_sheet_aj_sw = nullptr; static char s_wifi_sheet_ssid[33] = {0}; static int s_wifi_sheet_net_idx = -1; static void wifiRebuildNetworkList(); static char s_wifi_sheet_title[40] = {0}; // backs s_apppage_title while a sheet is open static void wifiSheetClose() { if (s_apppage_close == &wifiSheetClose) { // release the status-bar title/back hook s_apppage_title = nullptr; s_apppage_close = nullptr; } if (s_wifi_sheet) popupClose(&s_wifi_sheet); // del_async + indev reset s_wifi_sheet_ssid_ta = nullptr; s_wifi_sheet_pwd_ta = nullptr; s_wifi_sheet_aj_sw = nullptr; s_wifi_sheet_net_idx = -1; updateGlobalStatusBar(); // bar title falls back to the Wi-Fi category navMarkDirty(); } static void wifiSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) wifiSheetClose(); } // Full-screen scaffold for the join / details / hidden sheets: an opaque page // styled exactly like a settings detail page — the SAME tall status bar carries // "‹ <sheet title>" (via the s_apppage_title hook, so tapping the bar closes // the sheet, then falls back to "‹ Wi-Fi" for the category), and the body below // scrolls. NO second header bar of its own. Replaces the old small floating // card on a dimmed backdrop, which was cramped, scrolled badly under the // on-screen keyboard and had no obvious close affordance. static lv_obj_t* wifiSheetOpenFullscreen(const char* title) { const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // On the BASE layer (lv_scr_act), NOT lv_layer_top: the status bar lives on // lv_layer_top and its tall-mode glass lower row — which carries the bottom // half of the "‹ <title>" text — must paint OVER this sheet. A page on // lv_layer_top covers that row and clips the title in half (the recurring // gotcha with every full-screen page that rides the tall bar; see // openSettingsCategory's identical layering note). s_wifi_sheet = lv_obj_create(lv_scr_act()); lv_obj_remove_style_all(s_wifi_sheet); lv_obj_set_size(s_wifi_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_wifi_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_wifi_sheet, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_wifi_sheet, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_wifi_sheet, LV_OBJ_FLAG_SCROLLABLE); // The sheet title rides in the tall status bar (already tall on the Wi-Fi // settings page): "‹ <title>", tap = close this sheet. strncpy(s_wifi_sheet_title, title ? title : "", sizeof(s_wifi_sheet_title) - 1); s_wifi_sheet_title[sizeof(s_wifi_sheet_title) - 1] = '\0'; s_apppage_title = s_wifi_sheet_title; s_apppage_close = &wifiSheetClose; updateGlobalStatusBar(); lv_obj_t* body = lv_obj_create(s_wifi_sheet); lv_obj_remove_style_all(body); lv_obj_set_size(body, sw, sh - STATUSBAR_H); lv_obj_set_pos(body, 0, 0); lv_obj_set_style_pad_all(body, 12, LV_PART_MAIN); // Clear the tall bar's glass lower row (it overlays the top of this sheet, // same as prepSettingsPage's inset on the category pages). lv_obj_set_style_pad_top(body, STATUSBAR_H + 8, LV_PART_MAIN); // Generous bottom padding so the last field/button can scroll clear of the // on-screen keyboard (attachSettingsTaEvents scrolls the focused field into view). lv_obj_set_style_pad_bottom(body, SC(64), LV_PART_MAIN); return body; } // Save the network (passphrase preserved if blank) + connect to it. static void wifiDoJoin(const char* ssid, const char* pwd, bool auto_join) { if (!ssid || !ssid[0]) return; const int idx = touchPrefsSaveWifiNet(ssid, pwd, auto_join); if (idx >= 0) touchPrefsConnectWifiNet(idx); wifiSheetClose(); hideKb(); if (g_lv.task) g_lv.task->showAlert(TR("Connecting\xE2\x80\xA6"), 1400); wifiRebuildNetworkList(); } static void wifiJoinConfirmCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED && lv_event_get_code(e) != LV_EVENT_READY) return; char ssid[33] = {0}, pwd[65] = {0}; if (s_wifi_sheet_ssid_ta) strlcpy(ssid, lv_textarea_get_text(s_wifi_sheet_ssid_ta), sizeof ssid); else strlcpy(ssid, s_wifi_sheet_ssid, sizeof ssid); if (s_wifi_sheet_pwd_ta) strlcpy(pwd, lv_textarea_get_text(s_wifi_sheet_pwd_ta), sizeof pwd); if (!ssid[0]) { if (g_lv.task) g_lv.task->showAlert(TR("Enter a network name"), 1200); return; } wifiDoJoin(ssid, pwd, true); } // Join / hidden-network sheet, full-screen (see wifiSheetOpenFullscreen). // manual=true adds an editable SSID field (hidden network); else the ssid is // fixed and shown in the title bar. static void openWifiJoinSheet(const char* ssid, bool manual) { wifiSheetClose(); strlcpy(s_wifi_sheet_ssid, ssid ? ssid : "", sizeof s_wifi_sheet_ssid); const char* title = manual ? TR("Hidden network") : (ssid && ssid[0] ? ssid : TR("Join network")); lv_obj_t* body = wifiSheetOpenFullscreen(title); const int iw = lv_disp_get_hor_res(nullptr) - 24; int yy = 0; if (manual) { lv_obj_t* sl = lv_label_create(body); lv_label_set_text(sl, TR("Network name (SSID)")); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(sl, 0, yy); yy += SC(16); s_wifi_sheet_ssid_ta = lv_textarea_create(body); lv_obj_set_size(s_wifi_sheet_ssid_ta, iw, SC(32)); lv_obj_set_pos(s_wifi_sheet_ssid_ta, 0, yy); lv_textarea_set_one_line(s_wifi_sheet_ssid_ta, true); taSetPlaceholder(s_wifi_sheet_ssid_ta, TR("Network name")); lv_textarea_set_max_length(s_wifi_sheet_ssid_ta, WIFI_CONFIG_SSID_MAX - 1); attachSettingsTaEvents(s_wifi_sheet_ssid_ta); yy += SC(40); } else { s_wifi_sheet_ssid_ta = nullptr; } lv_obj_t* pl = lv_label_create(body); lv_label_set_text(pl, TR("Password (empty = open)")); lv_obj_set_style_text_font(pl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(pl, 0, yy); yy += SC(16); s_wifi_sheet_pwd_ta = lv_textarea_create(body); lv_obj_set_size(s_wifi_sheet_pwd_ta, iw, SC(32)); lv_obj_set_pos(s_wifi_sheet_pwd_ta, 0, yy); lv_textarea_set_one_line(s_wifi_sheet_pwd_ta, true); lv_textarea_set_password_mode(s_wifi_sheet_pwd_ta, true); taSetPlaceholder(s_wifi_sheet_pwd_ta, "PSK"); lv_textarea_set_max_length(s_wifi_sheet_pwd_ta, WIFI_CONFIG_PWD_MAX - 1); attachSettingsTaEvents(s_wifi_sheet_pwd_ta); // Prefill the saved passphrase if we already know this network. if (ssid && ssid[0]) { int e = touchPrefsFindWifiNet(ssid); if (e >= 0) { TouchWifiNet n; if (touchPrefsGetWifiNet(e, n)) lv_textarea_set_text(s_wifi_sheet_pwd_ta, n.pwd); } } yy += SC(44); lv_obj_t* join = lv_btn_create(body); lv_obj_set_size(join, iw, SC(38)); lv_obj_set_pos(join, 0, yy); styleButton(join); lv_obj_set_style_bg_color(join, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(join, wifiJoinConfirmCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(join); lv_label_set_text(l, TR("Join")); lv_obj_center(l); } yy += SC(46); lv_obj_t* cancel = lv_btn_create(body); lv_obj_set_size(cancel, iw, SC(34)); lv_obj_set_pos(cancel, 0, yy); styleButton(cancel); lv_obj_add_event_cb(cancel, wifiSheetCloseCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(cancel); lv_label_set_text(l, TR("Cancel")); lv_obj_center(l); } lv_obj_move_foreground(s_wifi_sheet); navMarkDirty(); } // Details for a SAVED network: connect/disconnect, auto-join, forget. static void wifiDetailsConnectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = s_wifi_sheet_net_idx; wifiSheetClose(); if (idx >= 0) touchPrefsConnectWifiNet(idx); if (g_lv.task) g_lv.task->showAlert(TR("Connecting\xE2\x80\xA6"), 1400); wifiRebuildNetworkList(); } static void wifiDetailsForgetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = s_wifi_sheet_net_idx; wifiSheetClose(); if (idx >= 0) touchPrefsForgetWifiNet(idx); wifiRebuildNetworkList(); } static void wifiDetailsAutoJoinCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; if (s_wifi_sheet_net_idx < 0) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetWifiNetAutoJoin(s_wifi_sheet_net_idx, on); // The active credential is what main.cpp auto-reconnects to. Auto-Join OFF on the // network we'd reconnect to must actually stop that — clear the active cred (the // current link stays up until it drops; a reboot / Wi-Fi cycle won't bring it back). // Turning it back ON while nothing else is targeted re-arms it. TouchWifiNet n; if (!touchPrefsGetWifiNet(s_wifi_sheet_net_idx, n) || !n.used) return; char active[WIFI_CONFIG_SSID_MAX] = {0}; wifiConfigGetSsid(active, sizeof active); const bool is_active = (active[0] && strcmp(active, n.ssid) == 0); if (!on && is_active) wifiConfigClear(); else if (on && active[0] == '\0') { wifiConfigSetSsid(n.ssid); wifiConfigSetPwd(n.pwd); } } static void openWifiDetailsSheet(int net_idx) { TouchWifiNet n; if (!touchPrefsGetWifiNet(net_idx, n) || !n.used) return; wifiSheetClose(); s_wifi_sheet_net_idx = net_idx; char cur[33] = {0}; const bool wifi_up = (WiFi.status() == WL_CONNECTED); if (wifi_up) strlcpy(cur, WiFi.SSID().c_str(), sizeof cur); const bool is_active = wifi_up && strcmp(cur, n.ssid) == 0; lv_obj_t* body = wifiSheetOpenFullscreen(n.ssid); const int iw = lv_disp_get_hor_res(nullptr) - 24; int yy = 0; lv_obj_t* st = lv_label_create(body); lv_label_set_text(st, is_active ? TR("Connected") : TR("Saved network")); lv_obj_set_style_text_font(st, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(st, lv_color_hex(is_active ? COLOR_STATUS_OK : COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(st, 0, yy); yy += SC(24); // Auto-Join row (label + switch) lv_obj_t* ajl = lv_label_create(body); lv_label_set_text(ajl, TR("Auto-Join")); lv_obj_set_style_text_font(ajl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(ajl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(ajl, 0, yy + SC(4)); s_wifi_sheet_aj_sw = lv_switch_create(body); lv_obj_set_pos(s_wifi_sheet_aj_sw, iw - SC(52), yy); if (n.auto_join) lv_obj_add_state(s_wifi_sheet_aj_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(s_wifi_sheet_aj_sw, wifiDetailsAutoJoinCb, LV_EVENT_VALUE_CHANGED, nullptr); yy += SC(44); if (!is_active) { lv_obj_t* con = lv_btn_create(body); lv_obj_set_size(con, iw, SC(38)); lv_obj_set_pos(con, 0, yy); styleButton(con); lv_obj_set_style_bg_color(con, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(con, wifiDetailsConnectCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(con); lv_label_set_text(l, TR("Connect")); lv_obj_center(l); } yy += SC(46); } lv_obj_t* forget = lv_btn_create(body); lv_obj_set_size(forget, iw, SC(38)); lv_obj_set_pos(forget, 0, yy); styleButton(forget); lv_obj_set_style_bg_color(forget, lv_color_hex(0xC44B55), LV_PART_MAIN); lv_obj_set_style_bg_color(forget, lv_color_hex(0xA13F47), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(forget, wifiDetailsForgetCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(forget); lv_label_set_text(l, TR("Forget network")); lv_obj_center(l); } lv_obj_move_foreground(s_wifi_sheet); navMarkDirty(); } // Row tap handlers. static void wifiSavedRowCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openWifiDetailsSheet((int)(intptr_t)lv_event_get_user_data(e)); } static void wifiOtherRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int i = (int)(intptr_t)lv_event_get_user_data(e); if (i >= 0 && i < s_wifiscan_count) openWifiJoinSheet(s_wifiscan_ssids[i], false); } static void wifiHiddenRowCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openWifiJoinSheet(nullptr, true); } static void wifiRefreshRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!wifiConfigWantsWifi()) { if (g_lv.task) g_lv.task->showAlert(TR("Turn Wi-Fi on first"), 1200); return; } if (wifiScanIsActive() || s_wifiscan_request || s_wifiscan_drop_req || s_wifiscan_drop_ms #if defined(TLORA_PAGER) || s_wifiscan_wait_for_radio #endif ) return; // a scan is already queued/running wifiKickScan(); // connected -> main task drops link first wifiRebuildNetworkList(); // re-render so the row shows "Scanning…" } // Explicit pixel layout (the grouped-card body is LV_SIZE_CONTENT — lv_pct / flex // don't resolve there, which left rows zero-width). A y-cursor drives placement. static int s_wifi_list_y = 0; static void wifiListHeader(const char* text) { lv_obj_t* h = lv_label_create(s_wifi_list_cont); lv_label_set_text(h, TR(text)); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(h, 2, s_wifi_list_y + SC(2)); s_wifi_list_y += SC(15); } static void wifiListRow(const char* ssid, const char* right, lv_color_t txtcol, lv_event_cb_t cb, void* ud) { const lv_coord_t W = s_settings_content_w; lv_obj_t* row = lv_btn_create(s_wifi_list_cont); lv_obj_set_size(row, W, SC(30)); lv_obj_set_pos(row, 0, s_wifi_list_y); styleButton(row); lv_obj_set_style_bg_color(row, lv_color_hex(0x1A1B1C), LV_PART_MAIN); if (cb) lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, ud); lv_obj_t* l = lv_label_create(row); lv_label_set_text(l, ssid); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, W - SC(44)); lv_obj_set_style_text_color(l, txtcol, LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_LEFT_MID, 8, 0); if (right && right[0]) { lv_obj_t* r = lv_label_create(row); lv_label_set_text(r, right); lv_obj_set_style_text_color(r, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(r, &g_font_14, LV_PART_MAIN); lv_obj_align(r, LV_ALIGN_RIGHT_MID, -8, 0); } s_wifi_list_y += SC(30) + SC(3); } static void wifiRebuildNetworkList() { // Valid only while the Wi-Fi page is live. NOTE: the page renders as an inline // grouped card, so g_set_modal.root/kind are NOT set here — guard on the // container itself (lv_obj_is_valid catches a torn-down page for the async // scan-complete refresh). if (!s_wifi_list_cont || !lv_obj_is_valid(s_wifi_list_cont)) return; lv_indev_reset(nullptr, nullptr); // scrollable rebuild: drop any indev scroll target first (UAF guard) lv_obj_clean(s_wifi_list_cont); s_wifi_list_y = 0; char cur[33] = {0}; const bool connected = (WiFi.status() == WL_CONNECTED); if (connected) strlcpy(cur, WiFi.SSID().c_str(), sizeof cur); // "Scanning…" spans the whole cycle: the main-task drop window AND the worker sweep. const bool scanning_now = (wifiScanIsActive() || s_wifiscan_request || s_wifiscan_drop_req || s_wifiscan_drop_ms != 0 #if defined(TLORA_PAGER) || s_wifiscan_wait_for_radio #endif ); // Saved networks, most-recent first. TouchWifiNet nets[TOUCH_WIFI_NET_COUNT]; int order[TOUCH_WIFI_NET_COUNT], ns = 0; for (int i = 0; i < TOUCH_WIFI_NET_COUNT; ++i) if (touchPrefsGetWifiNet(i, nets[i]) && nets[i].used) order[ns++] = i; for (int a = 0; a < ns; ++a) for (int b = a + 1; b < ns; ++b) if (nets[order[b]].rank > nets[order[a]].rank) { int t = order[a]; order[a] = order[b]; order[b] = t; } if (ns > 0) { wifiListHeader("Saved networks"); for (int i = 0; i < ns; ++i) { const int idx = order[i]; const bool active = connected && strcmp(cur, nets[idx].ssid) == 0; wifiListRow(nets[idx].ssid, active ? LV_SYMBOL_OK " " LV_SYMBOL_RIGHT : LV_SYMBOL_RIGHT, lv_color_hex(COLOR_TEXT), wifiSavedRowCb, (void*)(intptr_t)idx); } } // Other (scanned, not saved) networks. wifiListHeader(ns > 0 ? "Other networks" : "Networks"); int other = 0; for (int i = 0; i < s_wifiscan_count; ++i) { if (touchPrefsFindWifiNet(s_wifiscan_ssids[i]) >= 0) continue; wifiListRow(s_wifiscan_ssids[i], LV_SYMBOL_RIGHT, lv_color_hex(COLOR_TEXT), wifiOtherRowCb, (void*)(intptr_t)i); ++other; } if (other == 0) { lv_obj_t* note = lv_label_create(s_wifi_list_cont); lv_label_set_text(note, scanning_now ? TR("Scanning\xE2\x80\xA6") : TR("No other networks found")); lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(note, 6, s_wifi_list_y + SC(2)); s_wifi_list_y += SC(22); } // Manual rescan (the scan is also kicked on open). Label flips while one runs. s_wifi_list_y += SC(6); // The glyph has to be joined at runtime: TR() returns a pointer, so it cannot // take part in the compiler's adjacent-string-literal concatenation. char wifi_rescan[48]; snprintf(wifi_rescan, sizeof wifi_rescan, LV_SYMBOL_REFRESH " %s", scanning_now ? TR("Scanning\xE2\x80\xA6") : TR("Scan again")); wifiListRow(wifi_rescan, nullptr, lv_color_hex(scanning_now ? COLOR_SUB : COLOR_TEXT), scanning_now ? nullptr : wifiRefreshRowCb, nullptr); // Hidden / manual network. s_wifi_list_y += SC(3); char wifi_hidden[64]; snprintf(wifi_hidden, sizeof wifi_hidden, LV_SYMBOL_PLUS " %s", TR("Other (hidden) network\xE2\x80\xA6")); wifiListRow(wifi_hidden, nullptr, lv_color_hex(COLOR_ACCENT), wifiHiddenRowCb, nullptr); lv_obj_set_height(s_wifi_list_cont, s_wifi_list_y + SC(2)); // grow the card to fit navMarkDirty(); } #endif // ESP32 && MULTI_TRANSPORT_COMPANION // ---- MQTT bridge settings callbacks ---------------------------------------- #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // The consent checkbox gates the enable switch: the bridge cannot be turned on // until the user ticks acknowledgement of the privacy / experimental warning. static void mqttConsentCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; if (!g_set_modal.mqtt_consent_cb || !g_set_modal.mqtt_en_sw) return; bool ok = lv_obj_has_state(g_set_modal.mqtt_consent_cb, LV_STATE_CHECKED); if (ok) { lv_obj_clear_state(g_set_modal.mqtt_en_sw, LV_STATE_DISABLED); } else { lv_obj_clear_state(g_set_modal.mqtt_en_sw, LV_STATE_CHECKED); // force off if consent withdrawn lv_obj_add_state(g_set_modal.mqtt_en_sw, LV_STATE_DISABLED); } } static void mqttSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!g_set_modal.mqtt_host_ta) return; const char* host = lv_textarea_get_text(g_set_modal.mqtt_host_ta); const char* portStr = lv_textarea_get_text(g_set_modal.mqtt_port_ta); const char* user = lv_textarea_get_text(g_set_modal.mqtt_user_ta); const char* pwd = lv_textarea_get_text(g_set_modal.mqtt_pwd_ta); uint16_t port = portStr && portStr[0] ? (uint16_t)atoi(portStr) : 1883; if (!port) port = 1883; const char* psk = g_set_modal.mqtt_psk_ta ? lv_textarea_get_text(g_set_modal.mqtt_psk_ta) : ""; // Enable is only honoured when consent is ticked — belt-and-suspenders with the UI gating. bool consent = g_set_modal.mqtt_consent_cb && lv_obj_has_state(g_set_modal.mqtt_consent_cb, LV_STATE_CHECKED); bool en = consent && g_set_modal.mqtt_en_sw && lv_obj_has_state(g_set_modal.mqtt_en_sw, LV_STATE_CHECKED); bool pub_ch = !g_set_modal.mqtt_ch_sw || lv_obj_has_state(g_set_modal.mqtt_ch_sw, LV_STATE_CHECKED); bool pub_dm = g_set_modal.mqtt_dm_sw && lv_obj_has_state(g_set_modal.mqtt_dm_sw, LV_STATE_CHECKED); MqttBridge::saveConfig(host, port, user, pwd, pub_dm, pub_ch, psk, en); { SdNvsPrefs p; if (p.begin("mqtt", false)) { p.putBool("consent", consent); p.end(); } } // file-backed, not NVS (GH #128) mqtt_bridge.reloadConfig(); closeSettingsModal(); } #endif // ESP32 && MULTI_TRANSPORT_COMPANION static void buildMqttSettings() { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) lv_obj_t* body = createSettingsModal(TR("MQTT bridge"), SettingsModalKind::Mqtt); const lv_coord_t cw = s_settings_content_w; int y = 0; // ---- Privacy warning (read before enabling) ---- lv_obj_t* warn = lv_label_create(body); lv_label_set_text(warn, TR("Highly experimental. This forwards the text, sender name and timestamp of every message your node receives to an MQTT broker, where anyone able to read the broker can read them. Direct messages are private messages from other people who never agreed to be shared. Use a broker you control, set an encryption key below, and never a public broker.")); lv_label_set_long_mode(warn, LV_LABEL_LONG_WRAP); lv_obj_set_width(warn, cw); lv_obj_set_style_text_color(warn, lv_color_hex(0xCC6A00), LV_PART_MAIN); lv_obj_set_style_text_font(warn, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(warn, 2, y); lv_obj_update_layout(warn); y += lv_obj_get_height(warn) + SC(8); // ---- Consent checkbox (must be ticked to unlock the enable switch) ---- g_set_modal.mqtt_consent_cb = lv_checkbox_create(body); lv_checkbox_set_text(g_set_modal.mqtt_consent_cb, TR("I accept the privacy risk")); lv_obj_set_width(g_set_modal.mqtt_consent_cb, cw); // High-contrast label + a clearly outlined box. It was inheriting the dim default // checkbox text colour (every other checkbox sets one), which made the consent // line next to the box hard to read. lv_obj_set_style_text_color(g_set_modal.mqtt_consent_cb, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(g_set_modal.mqtt_consent_cb, &g_font_14, LV_PART_MAIN); lv_obj_set_style_radius(g_set_modal.mqtt_consent_cb, 3, LV_PART_INDICATOR); lv_obj_set_style_border_color(g_set_modal.mqtt_consent_cb, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_border_width(g_set_modal.mqtt_consent_cb, 1, LV_PART_INDICATOR); lv_obj_set_style_bg_color(g_set_modal.mqtt_consent_cb, lv_color_hex(0x4F9DF7), LV_PART_INDICATOR | LV_STATE_CHECKED); lv_obj_set_pos(g_set_modal.mqtt_consent_cb, 2, y); lv_obj_add_event_cb(g_set_modal.mqtt_consent_cb, mqttConsentCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_update_layout(g_set_modal.mqtt_consent_cb); y += lv_obj_get_height(g_set_modal.mqtt_consent_cb) + SC(10); // ---- Enable toggle (disabled until consent is given) ---- lv_obj_t* en_lbl = lv_label_create(body); lv_label_set_text(en_lbl, TR("Enable MQTT bridge")); lv_obj_set_style_text_color(en_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(en_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(en_lbl, 2, y + 8); g_set_modal.mqtt_en_sw = lv_switch_create(body); lv_obj_align(g_set_modal.mqtt_en_sw, LV_ALIGN_TOP_RIGHT, 0, y); lv_obj_add_state(g_set_modal.mqtt_en_sw, LV_STATE_DISABLED); // unlocked by the consent checkbox y += SC(38); // ---- Broker host ---- lv_obj_t* host_lbl = lv_label_create(body); lv_label_set_text(host_lbl, TR("Broker host / IP")); lv_obj_set_style_text_color(host_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(host_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(host_lbl, 2, y); y += SC(16); g_set_modal.mqtt_host_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.mqtt_host_ta, cw - 86, SC(30)); lv_obj_set_pos(g_set_modal.mqtt_host_ta, 0, y); lv_textarea_set_one_line(g_set_modal.mqtt_host_ta, true); taSetPlaceholder(g_set_modal.mqtt_host_ta, "192.168.1.x"); lv_textarea_set_max_length(g_set_modal.mqtt_host_ta, 63); attachSettingsTaEvents(g_set_modal.mqtt_host_ta); // Port field on the same row lv_obj_t* port_lbl = lv_label_create(body); lv_label_set_text(port_lbl, TR("Port")); lv_obj_set_style_text_color(port_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(port_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(port_lbl, cw - 82, y - SC(16)); g_set_modal.mqtt_port_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.mqtt_port_ta, 80, SC(30)); lv_obj_set_pos(g_set_modal.mqtt_port_ta, cw - 80, y); lv_textarea_set_one_line(g_set_modal.mqtt_port_ta, true); taSetPlaceholder(g_set_modal.mqtt_port_ta, "1883"); lv_textarea_set_max_length(g_set_modal.mqtt_port_ta, 5); lv_textarea_set_accepted_chars(g_set_modal.mqtt_port_ta, "0123456789"); attachSettingsTaEvents(g_set_modal.mqtt_port_ta); y += SC(36); // ---- Username (optional) ---- lv_obj_t* user_lbl = lv_label_create(body); lv_label_set_text(user_lbl, TR("Username (optional)")); lv_obj_set_style_text_color(user_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(user_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(user_lbl, 2, y); y += SC(16); g_set_modal.mqtt_user_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.mqtt_user_ta, lv_pct(100), SC(30)); lv_obj_set_pos(g_set_modal.mqtt_user_ta, 0, y); lv_textarea_set_one_line(g_set_modal.mqtt_user_ta, true); taSetPlaceholder(g_set_modal.mqtt_user_ta, TR("Leave empty if not required")); lv_textarea_set_max_length(g_set_modal.mqtt_user_ta, 31); attachSettingsTaEvents(g_set_modal.mqtt_user_ta); y += SC(36); // ---- Password (optional) ---- lv_obj_t* pwd_lbl = lv_label_create(body); lv_label_set_text(pwd_lbl, TR("Password (optional)")); lv_obj_set_style_text_color(pwd_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(pwd_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(pwd_lbl, 2, y); y += SC(16); g_set_modal.mqtt_pwd_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.mqtt_pwd_ta, lv_pct(100), SC(30)); lv_obj_set_pos(g_set_modal.mqtt_pwd_ta, 0, y); lv_textarea_set_one_line(g_set_modal.mqtt_pwd_ta, true); lv_textarea_set_password_mode(g_set_modal.mqtt_pwd_ta, true); taSetPlaceholder(g_set_modal.mqtt_pwd_ta, TR("Leave empty if not required")); lv_textarea_set_max_length(g_set_modal.mqtt_pwd_ta, 31); attachSettingsTaEvents(g_set_modal.mqtt_pwd_ta); y += SC(36); // ---- Publish toggles: channel on by default, DMs opt-in ---- lv_obj_t* ch_lbl = lv_label_create(body); lv_label_set_text(ch_lbl, TR("Publish channel messages")); lv_obj_set_style_text_color(ch_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(ch_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(ch_lbl, 2, y + 8); g_set_modal.mqtt_ch_sw = lv_switch_create(body); lv_obj_align(g_set_modal.mqtt_ch_sw, LV_ALIGN_TOP_RIGHT, 0, y); y += SC(38); lv_obj_t* dm_lbl = lv_label_create(body); lv_label_set_text(dm_lbl, TR("Publish direct messages")); lv_obj_set_style_text_color(dm_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(dm_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(dm_lbl, 2, y + 8); g_set_modal.mqtt_dm_sw = lv_switch_create(body); lv_obj_align(g_set_modal.mqtt_dm_sw, LV_ALIGN_TOP_RIGHT, 0, y); y += SC(38); // ---- Encryption key (PSK): seals payloads with AES-GCM; empty = plaintext ---- lv_obj_t* psk_lbl = lv_label_create(body); lv_label_set_text(psk_lbl, TR("Encryption key (optional)")); lv_obj_set_style_text_color(psk_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(psk_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(psk_lbl, 2, y); y += SC(16); g_set_modal.mqtt_psk_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.mqtt_psk_ta, lv_pct(100), SC(30)); lv_obj_set_pos(g_set_modal.mqtt_psk_ta, 0, y); lv_textarea_set_one_line(g_set_modal.mqtt_psk_ta, true); lv_textarea_set_password_mode(g_set_modal.mqtt_psk_ta, true); taSetPlaceholder(g_set_modal.mqtt_psk_ta, TR("Empty = plaintext to a private broker")); lv_textarea_set_max_length(g_set_modal.mqtt_psk_ta, 32); attachSettingsTaEvents(g_set_modal.mqtt_psk_ta); y += SC(36); // ---- Load current config ---- { SdNvsPrefs p; // file-backed, matches MqttBridge (GH #128) bool cur_en = false, cur_dm = false, cur_ch = true, cur_consent = false; char cur_host[64] = {}, cur_port_s[8] = "1883", cur_user[32] = {}, cur_pwd[32] = {}, cur_psk[33] = {}; if (p.begin("mqtt", true)) { cur_en = p.getBool("en", false); cur_dm = p.getBool("dm", false); cur_ch = p.getBool("ch", true); cur_consent = p.getBool("consent", false); uint16_t port = (uint16_t)p.getUInt("port", 1883); snprintf(cur_port_s, sizeof(cur_port_s), "%u", port); if (p.isKey("host")) p.getString("host", cur_host, sizeof(cur_host)); if (p.isKey("user")) p.getString("user", cur_user, sizeof(cur_user)); if (p.isKey("pwd")) p.getString("pwd", cur_pwd, sizeof(cur_pwd)); if (p.isKey("psk")) p.getString("psk", cur_psk, sizeof(cur_psk)); p.end(); } if (cur_consent) { lv_obj_add_state(g_set_modal.mqtt_consent_cb, LV_STATE_CHECKED); lv_obj_clear_state(g_set_modal.mqtt_en_sw, LV_STATE_DISABLED); // consent already given → unlock } if (cur_en && cur_consent) lv_obj_add_state(g_set_modal.mqtt_en_sw, LV_STATE_CHECKED); if (cur_ch) lv_obj_add_state(g_set_modal.mqtt_ch_sw, LV_STATE_CHECKED); if (cur_dm) lv_obj_add_state(g_set_modal.mqtt_dm_sw, LV_STATE_CHECKED); lv_textarea_set_text(g_set_modal.mqtt_host_ta, cur_host); lv_textarea_set_text(g_set_modal.mqtt_port_ta, cur_port_s); lv_textarea_set_text(g_set_modal.mqtt_user_ta, cur_user); lv_textarea_set_text(g_set_modal.mqtt_pwd_ta, cur_pwd); lv_textarea_set_text(g_set_modal.mqtt_psk_ta, cur_psk); } // ---- Save button ---- lv_obj_t* b_save = lv_btn_create(body); lv_obj_set_size(b_save, lv_pct(100), SC(36)); lv_obj_set_pos(b_save, 0, y); styleButton(b_save); lv_obj_add_event_cb(b_save, mqttSaveCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* sl = lv_label_create(b_save); lv_label_set_text(sl, TR(LV_SYMBOL_SAVE " Save")); lv_obj_center(sl); } y += SC(44); lv_obj_set_height(body, y + SC(8)); #else (void)0; // MQTT bridge only active on ESP32 multi-transport builds #endif } static void buildWifiSettings() { lv_obj_t* body = createSettingsModal("", SettingsModalKind::Wifi); // no group header — the top bar already says "Wi-Fi" int y = 0; const lv_coord_t cw = s_settings_content_w; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Compact header: radio toggle on the right, live status (1 line) on the left. g_set_modal.wifi_sw = lv_switch_create(body); lv_obj_align(g_set_modal.wifi_sw, LV_ALIGN_TOP_RIGHT, 0, y); if (wifiConfigGetRadioEnabled()) lv_obj_add_state(g_set_modal.wifi_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(g_set_modal.wifi_sw, wifiRadioToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); // live on/off — no Save needed g_set_modal.wifi_sta_status_l = lv_label_create(body); lv_label_set_long_mode(g_set_modal.wifi_sta_status_l, LV_LABEL_LONG_DOT); lv_obj_set_width(g_set_modal.wifi_sta_status_l, cw - SC(54)); // clear of the switch lv_obj_set_style_text_color(g_set_modal.wifi_sta_status_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_set_modal.wifi_sta_status_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(g_set_modal.wifi_sta_status_l, 2, y + SC(7)); lv_label_set_text(g_set_modal.wifi_sta_status_l, TR("Loading...")); y += SC(30); // Make sure the network we're currently using shows up as saved, and on first // run import any legacy 3-slot profiles into the new known-networks store. { bool any_saved = false; for (int i = 0; i < TOUCH_WIFI_NET_COUNT && !any_saved; ++i) { TouchWifiNet n; if (touchPrefsGetWifiNet(i, n) && n.used) any_saved = true; } if (!any_saved) { for (int i = 0; i < TOUCH_WIFI_SLOT_COUNT; ++i) { char lbl[TOUCH_WIFI_LABEL_MAX], ss[WIFI_CONFIG_SSID_MAX], pw[WIFI_CONFIG_PWD_MAX]; if (touchPrefsGetWifiSlot(i, lbl, sizeof lbl, ss, sizeof ss, pw, sizeof pw) && ss[0] && touchPrefsFindWifiNet(ss) < 0) touchPrefsSaveWifiNet(ss, pw, true); } } char ssid_buf[WIFI_CONFIG_SSID_MAX], pwd_buf[WIFI_CONFIG_PWD_MAX]; wifiConfigGetSsid(ssid_buf, sizeof ssid_buf); wifiConfigGetPwd(pwd_buf, sizeof pwd_buf); if (ssid_buf[0] && touchPrefsFindWifiNet(ssid_buf) < 0) touchPrefsSaveWifiNet(ssid_buf, pwd_buf, true); } // Scrollable known-networks + scan list (content-sized; the modal body scrolls). s_wifi_list_cont = lv_obj_create(body); lv_obj_remove_style_all(s_wifi_list_cont); lv_obj_set_width(s_wifi_list_cont, cw); lv_obj_set_height(s_wifi_list_cont, LV_SIZE_CONTENT); lv_obj_set_pos(s_wifi_list_cont, 0, y); lv_obj_clear_flag(s_wifi_list_cont, LV_OBJ_FLAG_SCROLLABLE); // explicit pixel layout (see wifiListRow); the modal page scrolls // Kick a fresh scan so "Other networks" fills in (results land via wifiScanService). // wifiKickScan drops the link first if connected (S3 can't sweep while associated). if (wifiConfigWantsWifi()) wifiKickScan(); wifiRebuildNetworkList(); #else (void)body; (void)y; lv_obj_t* unsup = lv_label_create(body); useChainedFont(unsup); lv_label_set_text(unsup, TR("Wi-Fi is not enabled in this build.")); lv_obj_set_style_text_color(unsup, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(unsup, 2, 8); #endif refreshStatusLabels(); } static void openLogModalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* body = createSettingsModal(TR("Logs"), SettingsModalKind::Log); int y = 0; g_set_modal.log_rx_btn = lv_btn_create(body); lv_obj_set_size(g_set_modal.log_rx_btn, 108, 32); lv_obj_set_pos(g_set_modal.log_rx_btn, 2, y); styleButton(g_set_modal.log_rx_btn); lv_obj_add_event_cb(g_set_modal.log_rx_btn, logModeRxCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* rx_lbl = lv_label_create(g_set_modal.log_rx_btn); useChainedFont(rx_lbl); lv_label_set_text(rx_lbl, TR("RX log")); lv_obj_center(rx_lbl); g_set_modal.log_raw_btn = lv_btn_create(body); lv_obj_set_size(g_set_modal.log_raw_btn, 108, 32); lv_obj_set_pos(g_set_modal.log_raw_btn, 114, y); styleButton(g_set_modal.log_raw_btn); lv_obj_add_event_cb(g_set_modal.log_raw_btn, logModeRawCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* raw_lbl = lv_label_create(g_set_modal.log_raw_btn); useChainedFont(raw_lbl); lv_label_set_text(raw_lbl, TR("Raw log")); lv_obj_center(raw_lbl); y += 38; g_set_modal.log_view_ta = lv_textarea_create(body); lv_obj_set_size(g_set_modal.log_view_ta, lv_pct(100),220); lv_obj_set_pos(g_set_modal.log_view_ta, 2, y); lv_textarea_set_one_line(g_set_modal.log_view_ta, false); lv_textarea_set_cursor_click_pos(g_set_modal.log_view_ta, false); lv_obj_clear_flag(g_set_modal.log_view_ta, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_text_font(g_set_modal.log_view_ta, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(g_set_modal.log_view_ta, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(g_set_modal.log_view_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); g_set_modal.log_mode = 0; refreshLogModalView(); } // goToTab defined after all callbacks that need it static void goToTab(int idx) { if (!g_lv.tabview) return; // Clear any keyboard-nav focus highlight BEFORE the tab switches. Activating a // clickable element with Enter (e.g. the Home "Unread" line) fires its CLICKED // handler WITHOUT moving group focus, so navFocusCb never runs to un-highlight it // — and the element lives on the now-hidden outgoing tab. Without this, its // reverse-video highlight stayed painted (the "home unread line stays highlighted" // bug). The destination tab's rebuild re-styles whatever it focuses next. // (Keyboard-nav only exists on the Tanmatsu + T-Deck-trackball builds.) #if CAP_KEYPAD_NAV if (s_nav_styled) { navUnstyle(s_nav_styled); s_nav_styled = nullptr; } #endif lv_tabview_set_act(g_lv.tabview, static_cast<uint32_t>(idx), LV_ANIM_ON); // lv_tabview_set_act() does NOT emit LV_EVENT_VALUE_CHANGED, so the full // tab-change handler (tabChangedCb → onMapTabActivated, status bar, chat // overlay cleanup, onLvTabChanged, heavy refresh) was skipped for swipe-driven // switches — which left the Map black when it was reached by a swipe instead of // a tap. Fire it explicitly so a swipe behaves exactly like a bottom-bar tap. lv_event_send(g_lv.tabview, LV_EVENT_VALUE_CHANGED, nullptr); } // ---- Contacts search overlay (magnifier icon) ------------------------------- static lv_obj_t* s_contacts_search_sheet = nullptr; static lv_obj_t* s_contacts_search_ta = nullptr; static void closeContactsSearchSheet() { if (s_contacts_search_sheet) { // hideKb() *first* so the keyboard mirror's bind pointer (s_kb_bind_ta) // is cleared before we destroy the textarea it points at. Skipping this // crashed the device when the keyboard fired another sync into the // freed textarea after the sheet had been deleted. hideKb(); // Async delete so we don't free the sheet while LVGL is still walking // the event chain that called us (same pattern as the action sheet). popupClose(&s_contacts_search_sheet); s_contacts_search_ta = nullptr; } } static void contactsSearchApplyCb(lv_event_t* e) { // Fire on both CLICKED (the Apply button) and READY (keyboard Enter), so // pressing Enter on the on-screen keyboard does the obvious thing. const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_CLICKED && code != LV_EVENT_READY) return; // Pull the latest text out of the keyboard mirror *before* anything // tears the sheet down — otherwise hideKb() inside closeContactsSearchSheet // races us to read s_kb_mirror_ta's text. kbMirrorSyncToReal(); if (s_contacts_search_ta) { const char* text = lv_textarea_get_text(s_contacts_search_ta); strncpy(g_lv.contacts_search, text ? text : "", sizeof(g_lv.contacts_search) - 1); g_lv.contacts_search[sizeof(g_lv.contacts_search) - 1] = '\0'; } closeContactsSearchSheet(); refreshContactsList(); if (g_lv.contacts_list) lv_obj_scroll_to(g_lv.contacts_list, 0, 0, LV_ANIM_OFF); } static void contactsSearchClearCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; g_lv.contacts_search[0] = '\0'; closeContactsSearchSheet(); refreshContactsList(); if (g_lv.contacts_list) lv_obj_scroll_to(g_lv.contacts_list, 0, 0, LV_ANIM_OFF); } static void contactsSearchSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Swallow the trailing touch and let async delete clean up next tick. lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeContactsSearchSheet(); } static void openContactsSearchSheetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsSearchSheet(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_contacts_search_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_contacts_search_sheet); // Backdrop starts below the global status bar. lv_obj_set_size(s_contacts_search_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_contacts_search_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_contacts_search_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_contacts_search_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_contacts_search_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_contacts_search_sheet, contactsSearchSheetCloseCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN const int card_w = PCW(220); const int card_h = PSC(130); #else const int card_w = 220; const int card_h = 130; #endif lv_obj_t* card = lv_obj_create(s_contacts_search_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, -40); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, contactsSearchSheetCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR(LV_SYMBOL_EYE_OPEN " Search contacts")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); s_contacts_search_ta = lv_textarea_create(card); lv_obj_set_size(s_contacts_search_ta, card_w - 20, PSC(34)); lv_obj_set_pos(s_contacts_search_ta, 0, PSC(28)); styleCard(s_contacts_search_ta); lv_textarea_set_one_line(s_contacts_search_ta, true); lv_textarea_set_max_length(s_contacts_search_ta, (uint32_t)(sizeof(g_lv.contacts_search) - 1)); taSetPlaceholder(s_contacts_search_ta, TR("Name fragment")); lv_textarea_set_text(s_contacts_search_ta, g_lv.contacts_search); lv_obj_set_style_text_color(s_contacts_search_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_contacts_search_ta, &g_font_14, LV_PART_MAIN); // attachSettingsTaEvents binds the global keyboard mirror (via // kbMirrorBind) on FOCUSED / CLICKED / PRESSED. composerFocusCb (used by // the chat textarea) only shows the keyboard while a chat panel detail // is open, so it silently no-op'd here. attachSettingsTaEvents(s_contacts_search_ta); // Eager bind so the keyboard appears the instant the overlay opens — // matches the behaviour of the other modals that pop up textareas. if (g_lv.keyboard) kbMirrorBind(s_contacts_search_ta); lv_obj_t* clear_btn = lv_btn_create(card); lv_obj_set_size(clear_btn, PSC(88), PSC(34)); lv_obj_set_pos(clear_btn, 0, PSC(70)); styleButton(clear_btn); lv_obj_add_event_cb(clear_btn, contactsSearchClearCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(clear_btn); useChainedFont(cl); lv_label_set_text(cl, TR("Clear")); lv_obj_center(cl); lv_obj_t* apply_btn = lv_btn_create(card); lv_obj_set_size(apply_btn, PSC(110), PSC(34)); lv_obj_set_pos(apply_btn, card_w - 20 - PSC(110), PSC(70)); styleButton(apply_btn); lv_obj_set_style_bg_color(apply_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(apply_btn, contactsSearchApplyCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* al = lv_label_create(apply_btn); useChainedFont(al); lv_label_set_text(al, TR("Apply")); lv_obj_center(al); } // Action-sheet shown when tapping a contact in the Contacts list. Lets the // user pick: send message / ping (repeaters only) / delete. State lives in // statics so the lv event handlers can look the contact up by mesh index. static uint32_t s_action_sheet_mesh_idx = 0; static bool s_action_sheet_is_repeater = false; static lv_obj_t* s_action_sheet_root = nullptr; static void closeActionSheet() { if (s_action_sheet_root) { // lv_obj_del_async — safe from inside an event callback. Synchronous // lv_obj_del while we're still walking the event chain can leave LVGL // input state pointing at a freed object, which manifested as "next // contact tap does nothing" after closing the sheet by tapping // outside. popupClose(&s_action_sheet_root); } } static void actionSheetSendMsgCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; uint32_t idx = s_action_sheet_mesh_idx; // Swallow the trailing touch so the upcoming tab switch + overlay show // doesn't have the cap-touch driver's stale "swipe in progress" state // bleeding into the freshly-opened chat detail (otherwise the detail // looked frozen until the next manual tap). lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeActionSheet(); g_lv.task->openMeshContactDm(idx); } // UI-side ping timeout state. If the reply doesn't arrive in N ms we show a // "No reply from <name>" toast and cancel the_mesh's pending slot. static unsigned long s_ui_ping_deadline_ms = 0; static char s_ui_ping_target_name[24] = {0}; // ---- Telemetry window state (window + auto-poll defined further down) ---- enum { TELEM_IN_PROGRESS = 0, TELEM_RECEIVED, TELEM_FAILED, TELEM_HISTORY }; static uint8_t s_telem_node[6] = {0}; static char s_telem_name[24] = {0}; static char s_telem_reading[140] = {0}; // latest decoded reading line static int s_telem_state = TELEM_HISTORY; static bool s_telem_manual_pending = false; // a manual request is awaiting its reply static uint32_t s_telem_deadline_ms = 0; // manual-request timeout (separate from ping) static int s_telem_win_now_h = 0; // display window: newer bound (hours ago) static int s_telem_win_past_h = 24; // display window: older bound (hours ago) #if CAP_SD || defined(TLORA_PAGER) static void openTelemetryWindow(const uint8_t* key6, const char* name, int state); // fwd #endif static constexpr unsigned long UI_PING_TIMEOUT_MS = 30000; // 30 s for flood paths static void actionSheetPingCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } /* Wait for the guest LOGIN response before sending STATUS. Besides avoiding * the first-contact ACL race, the interactive login re-discovers the path so * older repeaters can return LOGIN_OK before the request is sent. */ int r = the_mesh.uiSendRequestAfterGuestLogin(c, MyMesh::UiReqKind::Status); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { copyUtf8ReplacingMissingGlyphs(&g_font_14, s_ui_ping_target_name, sizeof(s_ui_ping_target_name), c.name[0] ? c.name : "repeater"); s_ui_ping_deadline_ms = millis() + UI_PING_TIMEOUT_MS; g_lv.task->showAlert(r == MSG_SEND_SENT_DIRECT ? TR("Pinged (direct) — waiting…") : TR("Pinged (flood) — waiting…"), 1400); } else { g_lv.task->showAlert(TR("Ping failed"), 1200); } } static void actionSheetDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } if (the_mesh.uiRemoveContact(c)) { // persists the removal (/contacts3) so it doesn't reappear on reboot g_lv.task->showAlert(TR("Contact deleted"), 1000); refreshContactsList(); } else { g_lv.task->showAlert(TR("Delete failed"), 1200); } } // "Reset path" — wipes the cached return path so the next send will re-flood // instead of routing through a stale hop list. Mirrors MCterm's RSTPath. static void actionSheetResetPathCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } if (the_mesh.uiResetContactPath(c.id.pub_key)) { g_lv.task->showAlert(TR("Path reset"), 1000); refreshContactsList(); } else { g_lv.task->showAlert(TR("Path reset failed"), 1200); } } // "Telemetry" — fire REQ_TYPE_GET_TELEMETRY_DATA at the selected contact. // The reply lands on UITask::onPingReply (same as STATUS) with the raw LPP // payload; the user sees "<name>: reply (N bytes)" until proper LPP // decoding lands. State-pings + telemetry-requests share _ui_pending_status, // so the timeout / single-flight handling already in place applies. static void actionSheetTelemetryCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } #if CAP_SD || defined(TLORA_PAGER) // Open the telemetry window on its history; it does NOT auto-request. The user // taps the Request button (refresh glyph, left of the gear) to poll. memcpy(s_telem_node, c.id.pub_key, 6); copyUtf8ReplacingMissingGlyphs(&g_font_14, s_telem_name, sizeof s_telem_name, c.name[0] ? c.name : "node"); s_telem_reading[0] = '\0'; s_telem_manual_pending = false; openTelemetryWindow(s_telem_node, s_telem_name, TELEM_HISTORY); #else // No telemetry window on this board — send straight away and toast the result. // Wait for guest LOGIN + path discovery before sending the telemetry REQ. int r = the_mesh.uiSendRequestAfterGuestLogin(c, MyMesh::UiReqKind::Telemetry); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) g_lv.task->showAlert(TR("Telemetry req\xe2\x80\xa6"), 1400); else g_lv.task->showAlert(TR("Telemetry req failed"), 1200); #endif } // ---- Repeater admin console (login + CLI passthrough) ---------------------- // // State for the admin login + console modals. We only support one open at a // time. After a successful sendLogin, the console modal opens with a // scrolling log + a textarea so the operator can type CLI commands ("ver", // "neighbours", "log", etc.). Replies arrive asynchronously via // onAdminCommandReply and append to the log. static lv_obj_t* s_admin_pw_root = nullptr; static lv_obj_t* s_admin_pw_ta = nullptr; static lv_obj_t* s_admin_pw_remember = nullptr; // checkbox static lv_obj_t* s_admin_root = nullptr; static lv_obj_t* s_admin_log_label = nullptr; static lv_obj_t* s_admin_log_box = nullptr; static lv_obj_t* s_admin_cmd_ta = nullptr; static uint8_t s_admin_pub32[32] = {0}; static char s_admin_name[24] = {0}; #define ADMIN_LOG_SZ 1024 static char* s_admin_log = (char*)psAlloc(ADMIN_LOG_SZ); // ring-ish log buffer — PSRAM // When the login prompt is opened to JOIN a room server (not admin a repeater), // this holds that room's contact index so onAdminLoginResult can open the room // chat on success. -1 = the current login is a normal repeater admin login. static int s_room_join_idx = -1; static int s_admin_log_len = 0; // The room server a user-initiated "Log in again" is currently waiting on (#267). // That path sends a blank login WITHOUT opening the prompt, so it never sets // s_admin_pub32 — which is why onAdminLoginResult's identity guard used to drop // the reply and the row said nothing whether it worked or not. Tracked separately // so the two pending-login kinds can't be confused for one another. static uint8_t s_room_relogin_pub32[32] = {0}; static bool s_room_relogin_active = false; static void roomReloginClear() { s_room_relogin_active = false; memset(s_room_relogin_pub32, 0, sizeof s_room_relogin_pub32); } // Reason line for the next login prompt, consumed (and cleared) by // openAdminLoginPrompt. Lets the escalated Join explain why it appeared instead // of looking like a password box out of nowhere. nullptr = the default subtitle. static const char* s_admin_pw_reason = nullptr; // Static help banner shown when the console opens. Lists the common // CommonCLI commands plus the simple_repeater-specific extras. The repeater // fork the user is talking to might not implement all of these — unknown // commands just produce a brief error reply. static const char* k_admin_help_banner = "[logged in]\n" "Info:\n" " ver firmware version\n" " board hardware id\n" " clock show clock\n" "Radio / mesh:\n" " advert flood advert\n" " advert.zerohop 0-hop advert\n" " neighbors list neighbours\n" " discover.neighbors active scan\n" " neighbor.remove <hex>\n" " tempradio f bw sf cr mins\n" " clear stats\n" "Settings:\n" " get <key> / set <key> <val>\n" " sensor list / get / set\n" "Admin:\n" " get acl list clients\n" " setperm <pubkey> <perms>\n" " password <new>\n" " reboot / poweroff\n" " clock sync / time <epoch>\n" " start ota"; // Repeater CLI catalogue presented in the picker modal. Each entry is the // template text to stuff into the textarea on tap — including placeholder // tokens like `<key>` and `<value>` so the operator knows what to fill in. // Sorted roughly by usefulness within categories so the most common ones // stay at the top of each section. Categories themselves separated by // "─" headers so the picker doesn't read as one big wall of commands. struct AdminCmdEntry { const char* label; // shown on the picker row (may include hint text) const char* command; // template stuffed into the textarea, or nullptr for a header }; // Curated to commands that are universally available on a stock // simple_repeater build. Anything hardware-conditional (gps*, powersaving*, // sensor *) or build-flag-conditional (log start/stop/erase need // LOG_STORE_ENABLED, set radio.rxgain is SX126x-only) is left out — listing // them in the picker only to have the repeater reply "not found" is // confusing. If you DO want to drive GPS / sensors / logs on a node that // supports them, just type the command into the textarea directly. static const AdminCmdEntry k_admin_cmds[] = { // Info { "[ INFO ]", nullptr }, { "ver - firmware version", "ver" }, { "board - hardware id", "board" }, { "clock - show clock", "clock" }, // Radio / mesh { "[ RADIO / MESH ]", nullptr }, { "advert - flood self advert", "advert" }, { "advert.zerohop - 0-hop advert", "advert.zerohop" }, { "neighbors - list neighbours", "neighbors" }, { "discover.neighbors - active scan", "discover.neighbors" }, { "neighbor.remove <hex>", "neighbor.remove " }, { "tempradio <freq> <bw> <sf> <cr> <m>", "tempradio " }, { "clear stats", "clear stats" }, // Settings / radio prefs (always available — these target NodePrefs, // not sensor hardware) { "[ RADIO PREFS ]", nullptr }, { "set name <new>", "set name " }, { "set radio <freq> <bw> <sf> <cr>", "set radio " }, { "set repeat on/off", "set repeat " }, { "set advert.interval <minutes>", "set advert.interval " }, { "set flood.advert.interval <hours>", "set flood.advert.interval " }, { "set dutycycle <1-100>", "set dutycycle " }, { "set af <factor>", "set af " }, // Admin { "[ ADMIN ]", nullptr }, { "get acl - list clients", "get acl" }, { "setperm <pubkey> <perms>", "setperm " }, { "password <new>", "password " }, { "set guest.password <new>", "set guest.password " }, { "clock sync", "clock sync" }, { "time <epoch_secs>", "time " }, { "start ota", "start ota" }, { "reboot", "reboot" }, { "poweroff", "poweroff" }, }; constexpr int k_admin_cmds_count = sizeof(k_admin_cmds) / sizeof(k_admin_cmds[0]); static lv_obj_t* s_admin_picker_root = nullptr; static void closeAdminCmdPicker() { if (s_admin_picker_root) { popupClose(&s_admin_picker_root); } } // Login-reply watchdog (#89): server login failures are SILENT — wrong password, // a replay-guard drop (device clock behind the server's stored high-water mark) // and an offline server all simply never reply, so the password prompt used to // sit on "Logging in…" forever with zero feedback. Arm a one-shot timer per // attempt; any login result (or closing the prompt) disarms it. 20 s covers the // core's worst-case flood est_timeout on slow spreading factors, with margin. static lv_timer_t* s_login_wait_timer = nullptr; // What the pending attempt was, so the timeout can say something useful about // it (#267). A typed password that goes unanswered is a password/clock/server // problem; a blank room re-login that goes unanswered means the server has // forgotten our client record, which is a different message AND a different // remedy. enum class LoginWaitKind : uint8_t { Password, RoomRelogin }; static LoginWaitKind s_login_wait_kind = LoginWaitKind::Password; static void loginWaitCancel() { if (s_login_wait_timer) { lv_timer_del(s_login_wait_timer); s_login_wait_timer = nullptr; } } static void loginWaitTimeoutCb(lv_timer_t* t) { (void)t; s_login_wait_timer = nullptr; // one-shot: LVGL frees the timer after this cb if (!g_lv.task) return; // Blank room re-login went unanswered: the server no longer holds our client // record (a reboot drops non-admin ACL entries), and no amount of passwordless // retrying will recreate it. Escalate to the passworded Join right here rather // than ending on a toast that leaves the operator with no result and no next // step — the whole point of #267. if (s_login_wait_kind == LoginWaitKind::RoomRelogin) { if (!s_room_relogin_active) return; uint8_t pub[32]; memcpy(pub, s_room_relogin_pub32, sizeof pub); roomReloginClear(); openRoomJoinPrompt(pub, /*after_relogin_failed=*/true); return; } // Show what the DEVICE thinks the time is — a wrong clock here is the tell // for the replay-guard case (the other causes read the first two hints). char when[24] = "?"; time_t tt = (time_t)the_mesh.getRTCClock()->getCurrentTime(); struct tm tmv; if (localtime_r(&tt, &tmv)) strftime(when, sizeof when, "%H:%M %d %b", &tmv); char msg[160]; // Multi-byte path hashing is the one setting that makes a login fail EXACTLY like // this: a repeater that predates it, or does not implement it, silently drops 2- and // 3-byte-hash packets, so the request never arrives and no error is ever generated // anywhere. It is opt-in and defaults to 1 byte, but someone who turned it on has no // way to connect that choice to "remote management stopped working" — reported twice // (#278, #280), once explicitly while running 3-byte hashes. Name it here, where the // operator is already looking, rather than leaving them to find it in Settings. const uint8_t phm = the_mesh.getNodePrefs() ? the_mesh.getNodePrefs()->path_hash_mode : 0; if (phm > 0) { snprintf(msg, sizeof msg, TR("No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)"), (unsigned)(phm + 1)); } else { snprintf(msg, sizeof msg, TR("No reply. Check password, server,\nor device clock (device: %s)"), when); } g_lv.task->showAlert(msg, 5000); } static void loginWaitArm(LoginWaitKind kind) { loginWaitCancel(); s_login_wait_kind = kind; s_login_wait_timer = lv_timer_create(loginWaitTimeoutCb, 20000, nullptr); if (s_login_wait_timer) lv_timer_set_repeat_count(s_login_wait_timer, 1); } static void closeAdminPwPrompt() { if (s_admin_pw_root) { hideKb(); popupClose(&s_admin_pw_root); s_admin_pw_ta = nullptr; s_admin_pw_remember = nullptr; } loginWaitCancel(); // abandoning the prompt abandons the no-reply watchdog too } static void closeAdminConsole() { if (s_admin_root) { hideKb(); closeAdminCmdPicker(); popupClose(&s_admin_root); s_admin_log_label = nullptr; s_admin_log_box = nullptr; s_admin_cmd_ta = nullptr; } } static void adminLogAppend(const char* prefix, const char* text) { if (!text) return; size_t cap = (size_t)ADMIN_LOG_SZ; // Ring behaviour: if appending would overflow, drop the first half so we // keep recent output (the operator cares about the latest reply, not the // first one). Cheap memmove rather than a proper ring. size_t add = (prefix ? strlen(prefix) : 0) + strlen(text) + 2; // + "\n\0" if ((size_t)s_admin_log_len + add >= cap) { size_t keep_from = cap / 2; if ((size_t)s_admin_log_len > keep_from) { memmove(s_admin_log, s_admin_log + keep_from, (size_t)s_admin_log_len - keep_from); s_admin_log_len -= (int)keep_from; s_admin_log[s_admin_log_len] = '\0'; } else { s_admin_log_len = 0; s_admin_log[0] = '\0'; } } int n = snprintf(s_admin_log + s_admin_log_len, cap - (size_t)s_admin_log_len, "%s%s\n", prefix ? prefix : "", text); if (n > 0) s_admin_log_len += n; if (s_admin_log_label) { lv_label_set_text(s_admin_log_label, s_admin_log); } if (s_admin_log_box) { lv_obj_scroll_to_y(s_admin_log_box, LV_COORD_MAX, LV_ANIM_OFF); } } // Open a scrollable list of CLI commands. Tapping an entry stuffs the // template into the admin textarea and dismisses the picker; the operator // can then edit the placeholder tokens before sending. Headers (entries // with command==nullptr) are non-interactive separators. static void openAdminCmdPicker() { closeAdminCmdPicker(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_admin_picker_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_admin_picker_root); // Backdrop starts below the global status bar so the near-full-height // command-picker card fits without its top row clipping behind the bar. lv_obj_set_size(s_admin_picker_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_admin_picker_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_admin_picker_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_admin_picker_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_admin_picker_root, LV_OBJ_FLAG_SCROLLABLE); // Tap on the dim backdrop dismisses the picker (matches the action // sheet / search overlay convention). lv_obj_add_event_cb(s_admin_picker_root, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeAdminCmdPicker(); }, LV_EVENT_CLICKED, nullptr); const int card_w = sw - 20; // Use the *visible* area height (sh - STATUSBAR_H), not raw sh — the // root now starts below the status bar so a card sized to full sh would // overflow the bottom of the root. const int card_h = (sh - STATUSBAR_H) - 40; lv_obj_t* card = lv_obj_create(s_admin_picker_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 6, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Commands")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_align(title, LV_ALIGN_TOP_LEFT, 4, 4); // Replaced the old 60x24 "Close" button with the standard X badge so // every popup dismisses the same way. addCloseXBadge(card, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAdminCmdPicker(); }); lv_obj_t* list = lv_list_create(card); lv_obj_set_size(list, card_w - 12, card_h - 12 - 28); lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 28); lv_obj_set_style_bg_color(list, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); for (int i = 0; i < k_admin_cmds_count; ++i) { const AdminCmdEntry& e = k_admin_cmds[i]; if (!e.command) { // Header / section break — non-interactive label. lv_list_add_text // returns a label with the LVGL-default light theme background; // re-skin so the section header reads as a dim divider on the dark // panel instead of a white bar. lv_obj_t* h = lv_list_add_text(list, e.label); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(h, lv_color_hex(0x0F1722), LV_PART_MAIN); lv_obj_set_style_bg_opa(h, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(h, 0, LV_PART_MAIN); lv_obj_set_style_pad_ver(h, 6, LV_PART_MAIN); lv_obj_set_style_pad_left(h, 10, LV_PART_MAIN); continue; } lv_obj_t* btn = lv_list_add_btn(list, nullptr, e.label); lv_obj_set_style_text_font(btn, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(btn, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // lv_list_add_btn uses a light theme bg by default — flip to the // panel-dark palette so the text is actually legible. lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); lv_obj_set_style_min_height(btn, 30, LV_PART_MAIN); lv_obj_set_style_pad_ver(btn, 4, LV_PART_MAIN); lv_obj_set_style_pad_left(btn, 10, LV_PART_MAIN); // Stash the index in user_data; the click handler reads the template. lv_obj_add_event_cb(btn, [](lv_event_t* ev) { if (lv_event_get_code(ev) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(ev); if (idx < 0 || idx >= k_admin_cmds_count) return; const char* tpl = k_admin_cmds[idx].command; if (!tpl || !s_admin_cmd_ta) { closeAdminCmdPicker(); return; } lv_textarea_set_text(s_admin_cmd_ta, tpl); lv_textarea_set_cursor_pos(s_admin_cmd_ta, LV_TEXTAREA_CURSOR_LAST); closeAdminCmdPicker(); // Don't pop the keyboard automatically — most picked commands are // run as-is (no placeholders to fill), and an unwanted keyboard // covers the log. The operator can tap the textarea if they need // to edit the template before sending. hideKb(); }, LV_EVENT_CLICKED, (void*)(intptr_t)i); } } static void openAdminConsole(const ContactInfo& c) { closeAdminPwPrompt(); closeAdminConsole(); memcpy(s_admin_pub32, c.id.pub_key, 32); copyUtf8ReplacingMissingGlyphs(&g_font_14, s_admin_name, sizeof(s_admin_name), c.name[0] ? c.name : "repeater"); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_admin_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_admin_root); // Start below the global status bar so the "Admin: <name>" header + Close // button at y=0 aren't clipped by the time/battery row. lv_obj_set_size(s_admin_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_admin_root, 0, STATUSBAR_H); styleSurface(s_admin_root, COLOR_BG, 0); lv_obj_clear_flag(s_admin_root, LV_OBJ_FLAG_SCROLLABLE); // Header lv_obj_t* hdr = lv_obj_create(s_admin_root); lv_obj_remove_style_all(hdr); lv_obj_set_size(hdr, sw, 36); lv_obj_set_pos(hdr, 0, 0); styleSurface(hdr, COLOR_PANEL, 0); lv_obj_set_style_border_side(hdr, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_border_width(hdr, 1, LV_PART_MAIN); lv_obj_set_style_border_color(hdr, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(hdr); char title_buf[40]; snprintf(title_buf, sizeof(title_buf), "Admin: %s", s_admin_name); lv_label_set_text(title, title_buf); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_align(title, LV_ALIGN_LEFT_MID, 8, 0); // Standard close-X badge on the admin console header — same affordance // as every other popup, no more bespoke "Close" button. addCloseXBadge(hdr, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAdminConsole(); }); // Trim the title's right side so a long repeater name can't slide under // the X. Header is sw=240 wide; reserve 36 px for the X badge. lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, sw - 8 - 36); // Scrolling log area. Heights are relative to the SHIFTED root (which // starts at y=STATUSBAR_H and is sh-STATUSBAR_H tall) — using bare `sh` // here would push the bottom row off the root and re-clip behind the // status bar in the new geometry. const lv_coord_t admin_h = sh - STATUSBAR_H; s_admin_log_box = lv_obj_create(s_admin_root); lv_obj_remove_style_all(s_admin_log_box); lv_obj_set_size(s_admin_log_box, sw - 8, admin_h - 36 - 8 - 44); lv_obj_set_pos(s_admin_log_box, 4, 40); styleSurface(s_admin_log_box, 0x0A0B0C, 6); lv_obj_set_style_border_color(s_admin_log_box, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_admin_log_box, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_admin_log_box, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(s_admin_log_box, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_admin_log_box, LV_SCROLLBAR_MODE_AUTO); s_admin_log_label = lv_label_create(s_admin_log_box); lv_label_set_long_mode(s_admin_log_label, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_admin_log_label, sw - 8 - 12); lv_obj_set_style_text_color(s_admin_log_label, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_admin_log_label, &g_font_12, LV_PART_MAIN); // Reset the log buffer to the help banner so the operator sees the // full command vocabulary up front instead of staring at an empty box. s_admin_log_len = 0; s_admin_log[0] = '\0'; adminLogAppend("", k_admin_help_banner); // Command row at the bottom lv_obj_t* row = lv_obj_create(s_admin_root); lv_obj_remove_style_all(row); lv_obj_set_size(row, sw, 44); lv_obj_set_pos(row, 0, admin_h - 44); styleSurface(row, COLOR_PANEL, 0); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_TOP, LV_PART_MAIN); lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); lv_obj_set_style_border_color(row, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); // "?" button on the left opens the command picker. The textarea // shrinks to make room. lv_obj_t* picker_btn = lv_btn_create(row); lv_obj_set_size(picker_btn, 32, 32); lv_obj_align(picker_btn, LV_ALIGN_LEFT_MID, 4, 0); styleButton(picker_btn); lv_obj_set_style_bg_color(picker_btn, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(picker_btn, 0, LV_PART_MAIN); lv_obj_add_event_cb(picker_btn, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openAdminCmdPicker(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* picker_lbl = lv_label_create(picker_btn); lv_label_set_text(picker_lbl, LV_SYMBOL_LIST); lv_obj_set_style_text_font(picker_lbl, &g_font_14, LV_PART_MAIN); lv_obj_center(picker_lbl); s_admin_cmd_ta = lv_textarea_create(row); // Layout: 4 px pad + 32 picker + 4 gap + ta + 4 gap + 56 send + 4 pad. // sw = 240 → ta width = 240 - (4+32+4+56+4+4) = 136. lv_obj_set_size(s_admin_cmd_ta, sw - 104, 32); lv_obj_align(s_admin_cmd_ta, LV_ALIGN_LEFT_MID, 40, 0); styleCard(s_admin_cmd_ta); lv_textarea_set_one_line(s_admin_cmd_ta, true); lv_textarea_set_max_length(s_admin_cmd_ta, 64); taSetPlaceholder(s_admin_cmd_ta, TR("command")); lv_obj_set_style_text_color(s_admin_cmd_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_admin_cmd_ta, &g_font_14, LV_PART_MAIN); attachSettingsTaEvents(s_admin_cmd_ta); lv_obj_t* send_btn = lv_btn_create(row); lv_obj_set_size(send_btn, 56, 32); lv_obj_align(send_btn, LV_ALIGN_RIGHT_MID, -4, 0); styleButton(send_btn); lv_obj_set_style_bg_color(send_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(send_btn, [](lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_CLICKED && code != LV_EVENT_READY) return; kbMirrorSyncToReal(); if (!s_admin_cmd_ta) return; const char* text = lv_textarea_get_text(s_admin_cmd_ta); if (!text || !text[0]) return; // Resolve the contact each time — pointer-stale-after-rebuild is the // standard gotcha (saved contacts can move when a refresh runs). ContactInfo* c = the_mesh.lookupContactByPubKey(s_admin_pub32, PUB_KEY_SIZE); if (!c) { adminLogAppend("[err] ", "contact missing"); return; } int r = the_mesh.uiSendAdminCommand(*c, text); char prompt_line[80]; snprintf(prompt_line, sizeof(prompt_line), "> %s", text); adminLogAppend("", prompt_line); if (r != MSG_SEND_SENT_FLOOD && r != MSG_SEND_SENT_DIRECT) { adminLogAppend("[err] ", "send failed"); } lv_textarea_set_text(s_admin_cmd_ta, ""); }, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(send_btn, [](lv_event_t* e) { lv_event_send(static_cast<lv_obj_t*>(lv_event_get_user_data(e)), LV_EVENT_CLICKED, nullptr); }, LV_EVENT_READY, send_btn); // Enter key on textarea sends lv_obj_t* send_lbl = lv_label_create(send_btn); useChainedFont(send_lbl); lv_label_set_text(send_lbl, LV_SYMBOL_RIGHT); lv_obj_center(send_lbl); } // Captured password + remember-flag at the moment Login was tapped, so we // can persist (or clear) the entry once the async login result lands. The // password prompt may already be torn down by then. static char s_admin_pw_attempt[TOUCH_REPEATER_PW_LEN] = {0}; static bool s_admin_pw_remember_flag = false; static void adminPwSubmitCb(lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_CLICKED && code != LV_EVENT_READY) return; kbMirrorSyncToReal(); if (!s_admin_pw_ta) return; const char* pw = lv_textarea_get_text(s_admin_pw_ta); ContactInfo* c = the_mesh.lookupContactByPubKey(s_admin_pub32, PUB_KEY_SIZE); if (!c) { if (g_lv.task) g_lv.task->showAlert(TR("Contact missing"), 1200); return; } // Stash the attempted password + remember preference for the async // success/fail handler. Snapshot the checkbox state now because the // prompt closes as soon as the result lands. strncpy(s_admin_pw_attempt, pw ? pw : "", sizeof(s_admin_pw_attempt) - 1); s_admin_pw_attempt[sizeof(s_admin_pw_attempt) - 1] = '\0'; s_admin_pw_remember_flag = (s_admin_pw_remember && lv_obj_has_state(s_admin_pw_remember, LV_STATE_CHECKED)); // An explicit login supersedes any blank re-login still in flight (usually to // this very room): without this, that one's late reply — or its watchdog — // could pop the escalation prompt on top of the login the user just started. roomReloginClear(); int r = the_mesh.uiSendAdminLogin(*c, s_admin_pw_attempt); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { markMeshRequest(); // status-bar async spinner if (g_lv.task) g_lv.task->showAlert(TR("Logging in\xe2\x80\xa6"), 1500); loginWaitArm(LoginWaitKind::Password); // silence past 20 s gets a real error instead of forever-spin (#89) } else { if (g_lv.task) g_lv.task->showAlert(TR("Send failed"), 1200); } } static void adminPwCancelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAdminPwPrompt(); } static void openAdminLoginPrompt(const ContactInfo& c) { closeAdminPwPrompt(); closeAdminConsole(); memcpy(s_admin_pub32, c.id.pub_key, 32); // Consume the one-shot reason line before any layout maths — a reason needs two // lines where the stock hint needs one, so the card grows by exactly one line // and everything below the subtitle shifts down with it (#267). Bounded on // purpose, and bounded TIGHT: the card is top-anchored inside a root only // 218 px tall on a 240 px T-Deck, so a third line would leave no margin at all. // A translation that overruns two lines clips rather than pushing the Join // button off the card. const char* const reason = s_admin_pw_reason; s_admin_pw_reason = nullptr; const int shift = reason ? PSC(14) : 0; lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_admin_pw_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_admin_pw_root); // Backdrop starts below the global status bar. lv_obj_set_size(s_admin_pw_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_admin_pw_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_admin_pw_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_admin_pw_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_admin_pw_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_admin_pw_root, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeAdminPwPrompt(); }, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN const int card_w = PCW(220); const int card_h = PSC(180) + shift; #else const int card_w = 220; const int card_h = 180 + shift; // taller now: room for the Remember checkbox (+ a reason line) #endif lv_obj_t* card = lv_obj_create(s_admin_pw_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); // Pin near the top of the area below the status bar. This 180px card is too // tall to center with a -40 lift (its title/X clipped off the top). The // on-screen keyboard always rises from the bottom, so a top-anchored card // never clips and keeps the title + password field visible above it. lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 10); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeAdminPwPrompt(); }); const bool is_room = (c.type == ADV_TYPE_ROOM); // Sanitize like every other name-display path: copyUtf8ReplacingMissingGlyphs swaps an // unrenderable codepoint for '*'. This Join title skipped it and drew tofu (▯) for a name's // leading unsupported codepoint, while the contacts list/details showed '*' (GH #116). char safe_name[24]; copyUtf8ReplacingMissingGlyphs(&g_font_14, safe_name, sizeof(safe_name), c.name); char hdr_buf[40]; snprintf(hdr_buf, sizeof(hdr_buf), "%s %.20s", is_room ? "Join:" : "Login:", safe_name); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, hdr_buf); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); // Trim 32 px on the right so long repeater names don't slide under the X. lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, card_w - 20 - 32); lv_obj_set_pos(title, 0, 0); lv_obj_t* sub = lv_label_create(card); // A caller-supplied reason wins over the generic hint: when this prompt opens // by itself after a failed re-login it has to say why (#267), or it reads as a // password box appearing out of nowhere. lv_label_set_text(sub, reason ? reason : (is_room ? "Room password (blank = guest)" : "Password (blank = guest)")); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(sub, &g_font_12, LV_PART_MAIN); // Only the reason gets a wrap box, sized to the two lines `shift` paid for. // The stock one-line hint keeps its auto-size — pinning a height here would // risk clipping it if the font's line height exceeds the guess. if (reason) { lv_label_set_long_mode(sub, LV_LABEL_LONG_WRAP); lv_obj_set_size(sub, card_w - 20, PSC(28)); } lv_obj_set_pos(sub, 0, PSC(22)); s_admin_pw_ta = lv_textarea_create(card); lv_obj_set_size(s_admin_pw_ta, card_w - 20, PSC(32)); lv_obj_set_pos(s_admin_pw_ta, 0, PSC(42) + shift); styleCard(s_admin_pw_ta); lv_textarea_set_one_line(s_admin_pw_ta, true); lv_textarea_set_password_mode(s_admin_pw_ta, true); lv_textarea_set_max_length(s_admin_pw_ta, 15); taSetPlaceholder(s_admin_pw_ta, ""); lv_obj_set_style_text_color(s_admin_pw_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_admin_pw_ta, &g_font_14, LV_PART_MAIN); attachSettingsTaEvents(s_admin_pw_ta); // Prefill from NVS if we've remembered a password for this repeater. // The state of the Remember checkbox follows: if a saved password // exists, Remember stays on (re-saving on next login is a no-op); // otherwise default to on so the typical case (type once) just works. bool has_saved_pw = false; #if defined(ESP32) { char saved[TOUCH_REPEATER_PW_LEN]; int n = touchPrefsGetRepeaterPassword(c.id.pub_key, saved, sizeof(saved)); if (n > 0) { lv_textarea_set_text(s_admin_pw_ta, saved); has_saved_pw = true; } } #endif // Eager-bind the keyboard ONLY when there's no saved password — the // operator clearly needs to type. If we already have the password from // NVS the typical action is "tap Login", so keep the keyboard down and // leave space for the Remember checkbox + buttons. The textarea still // attaches the normal CLICKED handler (see attachSettingsTaEvents) so // tapping the field explicitly opens the keyboard on demand. if (g_lv.keyboard && !has_saved_pw) kbMirrorBind(s_admin_pw_ta); // Pressing Enter on the keyboard submits. lv_obj_add_event_cb(s_admin_pw_ta, adminPwSubmitCb, LV_EVENT_READY, nullptr); // Remember checkbox sits between the password field and the buttons. On // a successful login we read its state in onAdminLoginResult and save // (or clear) the NVS entry accordingly. s_admin_pw_remember = lv_checkbox_create(card); lv_checkbox_set_text(s_admin_pw_remember, TR("Remember password")); lv_obj_set_style_text_color(s_admin_pw_remember, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(s_admin_pw_remember, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(s_admin_pw_remember, 0, PSC(82) + shift); lv_obj_add_state(s_admin_pw_remember, LV_STATE_CHECKED); lv_obj_t* cancel_btn = lv_btn_create(card); lv_obj_set_size(cancel_btn, PSC(88), PSC(32)); lv_obj_set_pos(cancel_btn, 0, PSC(116) + shift); styleButton(cancel_btn); lv_obj_add_event_cb(cancel_btn, adminPwCancelCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(cancel_btn); useChainedFont(cl); lv_label_set_text(cl, TR("Cancel")); lv_obj_center(cl); lv_obj_t* login_btn = lv_btn_create(card); lv_obj_set_size(login_btn, PSC(100), PSC(32)); lv_obj_set_pos(login_btn, card_w - 20 - PSC(100), PSC(116) + shift); styleButton(login_btn); lv_obj_set_style_bg_color(login_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(login_btn, adminPwSubmitCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* ll = lv_label_create(login_btn); useChainedFont(ll); lv_label_set_text(ll, is_room ? "Join" : "Login"); lv_obj_center(ll); } // Blank re-login to a room server, with the no-reply watchdog armed (#267). The // watchdog is what makes this row honest: a server that has forgotten our client // record never answers a passwordless login, so without it the action ended on // "Logging in again…" and nothing more, forever. // // Deliberately NOT used by the once-a-minute self-heal in the main loop — that // one runs unprompted while the operator reads a room, and must never be able to // throw a password prompt on screen by itself. static void roomReloginBegin(const uint8_t pub[32]) { if (!g_lv.task) return; if (the_mesh.uiRoomRelogin(pub) == MSG_SEND_FAILED) { g_lv.task->showAlert(TR("Couldn't send login"), 1400); return; } memcpy(s_room_relogin_pub32, pub, sizeof s_room_relogin_pub32); s_room_relogin_active = true; markMeshRequest(); // status-bar async spinner g_lv.task->showAlert(TR("Logging in again..."), 1400); loginWaitArm(LoginWaitKind::RoomRelogin); } // Passworded Join against a room server, from anywhere. This is the only action // that can RECREATE a client record the server has dropped — the blank re-login // can merely resume one that still exists. Reachable from the room thread sheet // and as the automatic escalation when a blank re-login goes unanswered, so the // recovery no longer depends on the operator knowing to walk back to Contacts. static void openRoomJoinPrompt(const uint8_t pub[32], bool after_relogin_failed) { if (!g_lv.task) return; ContactInfo* c = the_mesh.lookupContactByPubKey(pub, PUB_KEY_SIZE); if (!c || c->type != ADV_TYPE_ROOM) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } // s_room_join_idx is what routes the LOGIN_OK into the room chat instead of the // CLI console, and it is a contact-table index, not a thread index. const int idx = the_mesh.uiContactIdxByPubKey(pub); if (idx < 0) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } roomReloginClear(); // supersede the attempt that got us here s_room_join_idx = idx; if (after_relogin_failed) // Two short lines: the 200 px-wide subtitle fits ~30 chars per line at font_12. s_admin_pw_reason = TR("Server has forgotten you.\nJoin with the room password."); openAdminLoginPrompt(*c); } static void actionSheetAdminCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } s_room_join_idx = -1; // repeater admin login, not a room join openAdminLoginPrompt(c); } // "Join" a room server: same login mechanism as repeater admin (sendLogin is // room-aware and sends our sync_since so the server replays room history), but // on success we open the room's chat instead of the admin console. Stash the // contact index for onAdminLoginResult to reopen. static void actionSheetJoinRoomCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); s_room_join_idx = ok ? (int)s_action_sheet_mesh_idx : -1; closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } openAdminLoginPrompt(c); } // Toggle a contact's favorite state. Persists in NVS via TouchPrefsStore (the // star marker + favorites filter) AND mirrors into the firmware contact // table's flags bit 0 — the bit the core's overwrite-oldest eviction skips // (#178). Without the mirror a device-starred contact was still evictable. static void actionSheetFavoriteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } #if defined(ESP32) bool was_fav = touchPrefsIsFavorite(c.id.pub_key); bool now_fav = touchPrefsSetFavorite(c.id.pub_key, !was_fav); if (!was_fav && !now_fav) { // Add refused: the favorites table is at capacity, nothing changed — saying // "Removed from favorites" here (the plain now_fav mapping) is a lie. char full_msg[48]; snprintf(full_msg, sizeof full_msg, TR("Favorites full (max %d)"), TOUCH_FAVORITES_MAX); g_lv.task->showAlert(full_msg, 1500); return; } the_mesh.uiSetContactFavorite(c.id.pub_key, now_fav); // eviction protection (#178) g_lv.task->showAlert(now_fav ? TR("Added to favorites") : TR("Removed from favorites"), 1100); // Force a list rebuild so the star (or its removal) shows immediately — a plain // refresh hits the no-change cache (the contact count is unchanged by a fav toggle). contactsListForceRefresh(); #else g_lv.task->showAlert(TR("Favorites unsupported"), 1100); #endif } // Share position with THIS contact (#266). Sharing position used to mean putting it // in the advert, i.e. broadcasting it in the clear to everyone in range. MeshCore can // instead answer position only when a contact asks, encrypted to that contact — the // permission is a bit in contact.flags, read whenever telemetry_mode_loc is // ALLOW_FLAGS. Granting is per contact and revocable from the same menu. static void actionSheetShareLocCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; #if defined(ESP32) ContactInfo c; const bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } const bool was = the_mesh.uiGetContactTelemetryPerm(c.id.pub_key, TELEM_PERM_LOCATION); if (!the_mesh.uiSetContactTelemetryPerm(c.id.pub_key, TELEM_PERM_LOCATION, !was)) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } if (!was) { // Granting one contact is an unambiguous "share with someone". If position sharing // is switched off entirely the grant would silently do nothing, so move to the // per-contact mode — which still shares with nobody except the contacts picked here. NodePrefs* np = the_mesh.getNodePrefs(); if (np && np->telemetry_mode_loc == TELEM_MODE_DENY) g_lv.task->setLocationTelemetryMode(TELEM_MODE_ALLOW_FLAGS); g_lv.task->showAlert(TR("Location shared with this contact"), 1600); } else { g_lv.task->showAlert(TR("Location no longer shared"), 1600); } #else closeActionSheet(); g_lv.task->showAlert(TR("Not supported on this board"), 1100); #endif } // Block / unblock toggle: stores the sender's 6-byte pub-key prefix in the // ignore list (newMsgImpl drops messages from ignored senders). Same prefix // scheme as favorites; managed here or from the chat "Blocked users" sheet. static void actionSheetBlockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } #if defined(ESP32) bool was_blocked = touchPrefsIsIgnored(c.id.pub_key); bool now_blocked = touchPrefsSetIgnored(c.id.pub_key, !was_blocked); g_lv.task->showAlert(now_blocked ? TR("Blocked \xe2\x80\x94 their messages are hidden") : TR("Unblocked"), 1300); contactsListForceRefresh(); // show/hide the red blocked icon immediately #else g_lv.task->showAlert(TR("Blocking unsupported"), 1100); #endif } // "Trace ping" — send a PAYLOAD_TYPE_TRACE direct packet with a single-hop // path (the contact's hash). When the contact is a repeater (or any node // with allowPacketForward enabled) it appends its RX SNR and retransmits. // We pick that retransmission off the air and the dispatcher fires // onTraceRecv at us; MyMesh routes the SNRs to UITask::onTracePingResult, // which raises a 5 s alert showing both directions. Useful for diagnosing // link quality without exchanging a DM. Repeater-only (chat peers don't // forward). static void actionSheetTracePingCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } uint32_t tag = the_mesh.uiSendTracePing(c.id.pub_key); if (tag == 0) { g_lv.task->showAlert(TR("Trace failed"), 1200); } else { markMeshRequest(); // status-bar async spinner g_lv.task->showAlert(TR("Trace sent\xe2\x80\xa6"), 1200); } } // "Range test" — send a recognisable DM the recipient app can echo back so // the user can read off RTT. Drops a normal text frame. static void actionSheetRangeTestCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } const uint32_t ts = the_mesh.getRTCClock()->getCurrentTime(); uint32_t est = 0, hash4 = 0; uint32_t ack_hash = 0; int r = the_mesh.sendMessage(c, ts, 0, "RangeTest \xe2\x80\x94 ACK?", ack_hash, est, &hash4); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { the_mesh.uiRegisterExpectedAck(ack_hash, c.id.pub_key); markMeshRequest(); // status-bar async spinner g_lv.task->showAlert(r == MSG_SEND_SENT_DIRECT ? TR("RangeTest sent (direct)") : TR("RangeTest sent (flood)"), 1400); } else { g_lv.task->showAlert(TR("RangeTest failed"), 1200); } } static void actionSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Swallow the rest of this touch so the click event doesn't bubble down // to whatever widget is underneath after the sheet root is gone. // Without this, the next contact tap silently failed because LVGL still // had the just-deleted sheet root in its pressed-object slot. lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeActionSheet(); } // ===== Line-of-sight analyzer =========================================== // // Terrain-aware LOS between our node and a contact. We sample the great // circle between the two GPS points, fetch ground elevation for each // sample from the proxy's /elev endpoint, add the earth-curvature "bulge" // (4/3-earth-radius radio model) to the terrain, then compare against a // straight antenna-to-antenna sight line. Fresnel-zone clearance (0.6·F1) // separates "clear" from "marginal". #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static constexpr int k_los_samples = 24; // path sample points (was 40: // shorter request = faster, // cross-section still smooth) static constexpr double k_los_re_eff = 8504000.0; // 4/3 × 6371 km (m) // Antenna heights above ground (m), operator-adjustable via the +/- buttons // on the LOS graph. Persist for the session so "your" antenna height sticks // across analyses; tweak the peer side per contact. static float s_los_ant_self = 2.0f; static float s_los_ant_peer = 2.0f; static lv_obj_t* s_los_root = nullptr; static lv_obj_t* s_los_card = nullptr; static lv_obj_t* s_los_msg = nullptr; static lv_obj_t* s_los_plot = nullptr; // graph box, recreated each draw static lv_obj_t* s_los_verdict = nullptr; // verdict label, text updated static lv_obj_t* s_los_self_h_lbl = nullptr; // "2m" value labels static lv_obj_t* s_los_peer_h_lbl = nullptr; // lv_line keeps the caller's point array by pointer, so these must persist. static lv_point_t s_los_terrain_pts[k_los_samples]; static lv_point_t s_los_sight_pts[2]; // Async elevation fetch — runs on a core-0 worker so the HTTP round-trip // (up to several seconds) never blocks the LVGL/UI thread. The modal shows // "Analyzing…" while busy; UITask::loop calls losPoll() each tick and // renders the result once it lands. static double s_los_self_lat = 0, s_los_self_lon = 0; static double s_los_peer_lat = 0, s_los_peer_lon = 0; static double s_los_slat[k_los_samples], s_los_slon[k_los_samples]; static float s_los_elev[k_los_samples]; static volatile int s_los_got = 0; static volatile bool s_los_busy = false; // worker mid-fetch static volatile bool s_los_result_ready = false; // worker -> UI handoff static volatile bool s_los_request = false; // UI -> worker trigger static volatile uint32_t s_los_req_ms = 0; // when the fetch started static volatile int s_los_attempt = 0; // 1-based attempt # (UI progress) static volatile int s_los_dbg_parsed = 0; // diag: CSV fields returned static volatile int s_los_dbg_valid = 0; // diag: non-null among them static void losRenderResult(); // fwd // The elevation fetch is serviced by the SHARED tile-fetch worker (core 0) // rather than a dedicated task — they never run concurrently (map tab vs // contacts tab) and internal DRAM can't afford two ~8 KB task stacks. static bool ensureTileFetchTaskRunning(); // fwd (defined with the map code) static double losBearingDeg(double lat1, double lon1, double lat2, double lon2) { const double p1 = lat1 * M_PI / 180.0, p2 = lat2 * M_PI / 180.0; const double dl = (lon2 - lon1) * M_PI / 180.0; const double y = sin(dl) * cos(p2); const double x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl); double b = atan2(y, x) * 180.0 / M_PI; if (b < 0) b += 360.0; return b; } static const char* losCompass(double deg) { static const char* d[] = {"N","NE","E","SE","S","SW","W","NW"}; return d[(int)((deg + 22.5) / 45.0) & 7]; } // GET <server>/elev?locations=lat,lon|... -> CSV of metres. Returns the // number of samples parsed into out_m (0 on failure). Blocking call. static volatile int s_los_dbg_code = 0; // last HTTP code (-1 WiFi, -2 begin) static int losFetchElevations(const double* lat, const double* lon, int n, float* out_m) { if (WiFi.status() != WL_CONNECTED) { s_los_dbg_code = -1; return 0; } char base[TOUCH_TILE_SERVER_MAXLEN]; touchPrefsGetTileServer(base, sizeof(base)); size_t blen = strlen(base); while (blen > 0 && base[blen - 1] == '/') base[--blen] = '\0'; static const size_t URL_SZ = 1100; static char* url = (char*)psAlloc(URL_SZ); // lazy-PSRAM (frees 1.1 KB internal .bss) if (!url) { s_los_dbg_code = -3; return 0; } // OOM only: behaves like the other early-return failures int o = snprintf(url, URL_SZ, "%s/elev?locations=", base); for (int i = 0; i < n && o < (int)URL_SZ - 24; ++i) { o += snprintf(url + o, URL_SZ - o, "%s%.5f,%.5f", i ? "|" : "", lat[i], lon[i]); } WiFiClient client; HTTPClient http; http.setReuse(false); http.setConnectTimeout(5000); // Bounded so a slow/stuck fetch can't pin the shared worker (and block a // SECOND line-of-sight request) for too long. The worker now retries a few // times (see the LOS branch in tileFetchTaskFn): attempt 1 warms the // proxy's nginx cache (30-day /elev cache, keyed on the exact query), so // the retry hits the cache and returns fast. Three attempts at this read // timeout still fit under the UI busy-watchdog. http.setTimeout(9000); if (!http.begin(client, url)) { s_los_dbg_code = -2; return 0; } const int code = http.GET(); s_los_dbg_code = code; if (code != HTTP_CODE_OK) { http.end(); client.stop(); return 0; } String body = http.getString(); http.end(); client.stop(); int cnt = 0; const char* p = body.c_str(); while (*p && cnt < n) { while (*p == ' ') ++p; if (!strncmp(p, "null", 4)) { out_m[cnt++] = NAN; p += 4; } else { char* end = nullptr; const double v = strtod(p, &end); if (end == p) break; out_m[cnt++] = (float)v; p = end; } while (*p && *p != ',') ++p; if (*p == ',') ++p; } return cnt; } // NaN-only check (we store NaN for the backend's "null" no-data points; real // elevations are never NaN). Avoids <cmath> isfinite namespace ambiguity. static inline bool losReal(float v) { return v == v; } // Repair an elevation array in place so all n samples are finite, tolerating // the backend returning "null" for no-data points or fewer points than asked. // `parsed` = number of CSV fields actually returned. Leading/trailing gaps are // clamped to the nearest real sample; interior gaps are linearly interpolated. // Returns false only when too little real data came back to be meaningful — the // caller then retries (or reports failure). This is what lets a path with the // odd null sample still render instead of failing the whole analysis. static bool losRepairElevations(float* m, int n, int parsed) { for (int i = parsed; i < n; ++i) m[i] = NAN; // trailing missing points int valid = 0, first = -1, last = -1; for (int i = 0; i < n; ++i) { if (losReal(m[i])) { ++valid; if (first < 0) first = i; last = i; } } // Need at least ~1/3 real coverage; below that the profile would be mostly // fabricated, so treat it as a fetch failure worth retrying. if (valid < 2 || valid * 3 < n) return false; for (int i = 0; i < first; ++i) m[i] = m[first]; // clamp leading gap for (int i = last + 1; i < n; ++i) m[i] = m[last]; // clamp trailing gap int i = first; while (i <= last) { if (losReal(m[i])) { ++i; continue; } const int lo = i - 1; // last real sample before the gap int hi = i; while (hi <= last && !losReal(m[hi])) ++hi; // first real sample after const float a = m[lo], b = m[hi]; const int span = hi - lo; for (int k = lo + 1; k < hi; ++k) m[k] = a + (b - a) * (float)(k - lo) / (float)span; i = hi; } return true; } static void closeLosModal() { if (s_los_root) { popupClose(&s_los_root); } s_los_card = nullptr; s_los_msg = nullptr; s_los_plot = nullptr; s_los_verdict = nullptr; s_los_self_h_lbl = nullptr; s_los_peer_h_lbl = nullptr; } static void losModalCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeLosModal(); } // (Re)compute the analysis with the current antenna heights and (re)draw // the cross-section + verdict. Cheap — uses the already-fetched elevation // profile, so the +/- height buttons call it directly (no network). static void losDrawPlot() { if (!s_los_card || s_los_got < k_los_samples) return; lv_obj_t* card = s_los_card; // PSC() scales card geometry up ~1.7× on Tanmatsu (no-op on T-Deck/V4), so // the chart fills the 800×480 panel instead of looking lost. Width, graph // height and every stacked Y offset below all go through PSC() so the plot // stays aligned with its axis labels / sliders / verdict at any board+scale. const int card_w = PCW(230); const double self_lat = s_los_self_lat, self_lon = s_los_self_lon; const double peer_lat = s_los_peer_lat, peer_lon = s_los_peer_lon; const float* elev = s_los_elev; const double dist_km = contactDistanceKm(self_lat, self_lon, peer_lat, peer_lon); const double D = dist_km * 1000.0; const double brg = losBearingDeg(self_lat, self_lon, peer_lat, peer_lon); NodePrefs* prefs = the_mesh.getNodePrefs(); const double freq_mhz = (prefs && prefs->freq > 1.0) ? prefs->freq : 868.0; const double lambda = 300.0 / freq_mhz; const double h0 = elev[0] + s_los_ant_self; const double hN = elev[k_los_samples - 1] + s_los_ant_peer; double min_clear = 1e9; int worst_i = -1; double worst_d1 = 0; double min_fres_margin = 1e9; double worst_f1 = 0; for (int i = 1; i < k_los_samples - 1; ++i) { const double f = (double)i / (k_los_samples - 1); const double d1 = f * D, d2 = (1.0 - f) * D; const double bulge = d1 * d2 / (2.0 * k_los_re_eff); const double terr = (double)elev[i] + bulge; const double sight = h0 + f * (hN - h0); const double clear = sight - terr; const double F1 = sqrt(lambda * d1 * d2 / D); if (clear < min_clear) { min_clear = clear; worst_i = i; worst_d1 = d1; } const double fm = clear - 0.6 * F1; if (fm < min_fres_margin) { min_fres_margin = fm; worst_f1 = F1; } } enum { V_CLEAR, V_MARGINAL, V_BLOCKED } verdict; if (min_clear < 0) verdict = V_BLOCKED; else if (min_fres_margin < 0) verdict = V_MARGINAL; else verdict = V_CLEAR; // ---- Cross-section drawing area (recreated each draw) ---- // Shorter graph in landscape so the antenna controls + verdict still fit the // capped card height. cy in losRenderResult must stay = gy + gh + 6. const int gx = 0, gy = PSC(28), gw = card_w - 16, gh = PSC(chatLandscape() ? 58 : 80); if (s_los_plot) { lv_obj_del(s_los_plot); s_los_plot = nullptr; } lv_obj_t* graph = lv_obj_create(card); s_los_plot = graph; lv_obj_remove_style_all(graph); lv_obj_set_size(graph, gw, gh); lv_obj_set_pos(graph, gx, gy); lv_obj_set_style_bg_color(graph, lv_color_hex(0x0B0D0F), LV_PART_MAIN); lv_obj_set_style_bg_opa(graph, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(graph, 4, LV_PART_MAIN); lv_obj_clear_flag(graph, LV_OBJ_FLAG_SCROLLABLE); double vmin = 1e9, vmax = -1e9; auto bump = [&](double v){ if (v < vmin) vmin = v; if (v > vmax) vmax = v; }; for (int i = 0; i < k_los_samples; ++i) { const double f = (double)i / (k_los_samples - 1); const double d1 = f * D, d2 = (1.0 - f) * D; bump((double)elev[i] + d1 * d2 / (2.0 * k_los_re_eff)); } bump(h0); bump(hN); if (vmax - vmin < 1.0) vmax = vmin + 1.0; const int pad = 6; auto X = [&](int i){ return (lv_coord_t)(i * (gw - 1) / (k_los_samples - 1)); }; auto Y = [&](double v){ const double t = (v - vmin) / (vmax - vmin); return (lv_coord_t)(gh - pad - t * (gh - 2 * pad)); }; for (int i = 0; i < k_los_samples; ++i) { const double f = (double)i / (k_los_samples - 1); const double d1 = f * D, d2 = (1.0 - f) * D; const double terr = (double)elev[i] + d1 * d2 / (2.0 * k_los_re_eff); s_los_terrain_pts[i].x = X(i); s_los_terrain_pts[i].y = Y(terr); } lv_obj_t* tline = lv_line_create(graph); lv_line_set_points(tline, s_los_terrain_pts, k_los_samples); lv_obj_set_style_line_color(tline, lv_color_hex(0x6FBF73), LV_PART_MAIN); lv_obj_set_style_line_width(tline, 2, LV_PART_MAIN); lv_obj_set_style_line_rounded(tline, true, LV_PART_MAIN); s_los_sight_pts[0] = { X(0), Y(h0) }; s_los_sight_pts[1] = { X(k_los_samples - 1), Y(hN) }; lv_obj_t* sline = lv_line_create(graph); lv_line_set_points(sline, s_los_sight_pts, 2); lv_obj_set_style_line_color(sline, lv_color_hex(verdict == V_BLOCKED ? 0xD7574E : verdict == V_MARGINAL ? 0xC8A030 : 0x4DA8FF), LV_PART_MAIN); lv_obj_set_style_line_width(sline, 2, LV_PART_MAIN); if (verdict != V_CLEAR && worst_i >= 0) { lv_obj_t* dot = lv_obj_create(graph); lv_obj_remove_style_all(dot); lv_obj_set_size(dot, 6, 6); lv_obj_set_style_radius(dot, 3, LV_PART_MAIN); lv_obj_set_style_bg_color(dot, lv_color_hex(0xD7574E), LV_PART_MAIN); lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, LV_PART_MAIN); const double f = (double)worst_i / (k_los_samples - 1); const double d1 = f * D, d2 = (1.0 - f) * D; lv_obj_set_pos(dot, X(worst_i) - 3, Y((double)elev[worst_i] + d1 * d2 / (2.0 * k_los_re_eff)) - 3); } // ---- Height value labels ---- if (s_los_self_h_lbl) lv_label_set_text_fmt(s_los_self_h_lbl, "%dm", (int)s_los_ant_self); if (s_los_peer_h_lbl) lv_label_set_text_fmt(s_los_peer_h_lbl, "%dm", (int)s_los_ant_peer); // ---- Verdict text ---- if (s_los_verdict) { #if defined(ESP32) const bool miles = touchPrefsGetUseMiles(); #else const bool miles = false; #endif char dist_s[16]; if (miles) snprintf(dist_s, sizeof(dist_s), "%.1f mi", dist_km * 0.621371); else snprintf(dist_s, sizeof(dist_s), "%.1f km", dist_km); const char* vstr = (verdict == V_BLOCKED) ? "NO LINE OF SIGHT" : (verdict == V_MARGINAL) ? "MARGINAL (Fresnel)" : "LINE OF SIGHT"; const uint32_t vcol = (verdict == V_BLOCKED) ? 0xD7574E : (verdict == V_MARGINAL) ? 0xC8A030 : 0x6FBF73; char body[180]; int bo = snprintf(body, sizeof(body), "#a0a6ad %s \xc2\xb7 brg %03d\xc2\xb0 %s#\n", dist_s, (int)(brg + 0.5), losCompass(brg)); if (verdict == V_BLOCKED) { bo += snprintf(body + bo, sizeof(body) - bo, "#a0a6ad Blocked @ %.1f km, %dm over sight#\n", worst_d1 / 1000.0, (int)(-min_clear + 0.5)); } else if (verdict == V_MARGINAL) { bo += snprintf(body + bo, sizeof(body) - bo, "#a0a6ad Grazes terrain \xc2\xb7 F1=%dm#\n", (int)(worst_f1 + 0.5)); } else { bo += snprintf(body + bo, sizeof(body) - bo, "#a0a6ad Clear by %dm at tightest#\n", (int)(min_clear + 0.5)); } snprintf(body + bo, sizeof(body) - bo, "#%06x %s#\n#5b6168 you %dm \xc2\xb7 peer %dm \xc2\xb7 %.0f MHz#", (unsigned)vcol, vstr, (int)s_los_ant_self, (int)s_los_ant_peer, freq_mhz); lv_label_set_text(s_los_verdict, body); } } // Antenna +/- handlers — adjust by 1 m (clamped 0..150) and redraw. No // network: reuses the cached elevation profile. static void losSelfMinusCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_los_ant_self >= 1.0f) s_los_ant_self -= 1.0f; losDrawPlot(); } static void losSelfPlusCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_los_ant_self < 150.0f) s_los_ant_self += 1.0f; losDrawPlot(); } static void losPeerMinusCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_los_ant_peer >= 1.0f) s_los_ant_peer -= 1.0f; losDrawPlot(); } static void losPeerPlusCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_los_ant_peer < 150.0f) s_los_ant_peer += 1.0f; losDrawPlot(); } // Runs on the UI thread once the worker has the elevation profile. Builds // the persistent controls (height +/- and verdict label), then draws. static void losRenderResult() { if (!s_los_card) return; lv_obj_t* card = s_los_card; const int card_w = PCW(230); // matches losDrawPlot()/builder — see PSC() note there const int gw = card_w - 16; if (s_los_got < k_los_samples) { if (s_los_msg) lv_label_set_text(s_los_msg, TR("Couldn't fetch terrain data.\nCheck the connection and the\n" "elevation server, then\ntry again.")); return; } if (s_los_msg) lv_obj_add_flag(s_los_msg, LV_OBJ_FLAG_HIDDEN); // Antenna height controls, directly under each end of the graph: // left = your antenna, right = the contact's. [-] value [+] // Just below the graph: gy + gh + 6, derived from the SAME PSC()-scaled pieces // losDrawPlot() uses for the graph (gy=PSC(28), gh=PSC(58|80)) so the controls // stay glued under the graph at any board/UI-scale instead of drifting. Button // + value sizes/X-offsets also go through PSC() so the clusters don't bunch up. const int cy = PSC(28) + PSC(chatLandscape() ? 58 : 80) + PSC(6); auto mk_h_btn = [&](const char* sym, int x, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, PSC(26), PSC(24)); lv_obj_set_pos(b, x, cy); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, sym); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_center(l); }; auto mk_h_val = [&](int x) { lv_obj_t* l = lv_label_create(card); lv_obj_set_size(l, PSC(42), PSC(24)); lv_obj_set_pos(l, x, cy + PSC(4)); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_label_set_text(l, "2m"); return l; }; // Left cluster (you) mk_h_btn("-", 0, losSelfMinusCb); s_los_self_h_lbl = mk_h_val(PSC(28)); mk_h_btn("+", PSC(72), losSelfPlusCb); // Right cluster (peer) mk_h_btn("-", gw - PSC(98), losPeerMinusCb); s_los_peer_h_lbl = mk_h_val(gw - PSC(70)); mk_h_btn("+", gw - PSC(26), losPeerPlusCb); // Verdict label (text updated by losDrawPlot). s_los_verdict = lv_label_create(card); lv_label_set_long_mode(s_los_verdict, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_los_verdict, gw); lv_obj_set_style_text_font(s_los_verdict, &g_font_12, LV_PART_MAIN); lv_label_set_recolor(s_los_verdict, true); lv_obj_set_pos(s_los_verdict, 0, cy + PSC(30)); losDrawPlot(); } // Called every UITask::loop tick — picks up the worker's result without // blocking the UI. Renders into the modal only if it's still open. static void losPoll() { // While the worker is fetching, reflect retry progress so a slow cold path // reads as work-in-progress rather than a freeze. if (s_los_busy) { if (s_los_root && s_los_msg) { static int shown = -1; const int a = s_los_attempt; if (a != shown) { shown = a; if (a >= 2) lv_label_set_text_fmt(s_los_msg, TR("Analyzing terrain\xe2\x80\xa6\n(retry %d of 3)"), a); else lv_label_set_text(s_los_msg, TR("Analyzing terrain\xe2\x80\xa6")); } } return; } if (!s_los_result_ready) return; s_los_result_ready = false; // One diag line per analysis so a failing path can be diagnosed: peer coords, // last HTTP code (-1 WiFi down, -2 begin failed), CSV fields returned, and // how many were non-null. code=200 + low valid => path has no SRTM data; // code!=200 => proxy rejected the query (e.g. 400 = coords out of range). { char d[80]; snprintf(d, sizeof(d), "LOS %.4f,%.4f code=%d got=%d ok=%d", s_los_peer_lat, s_los_peer_lon, s_los_dbg_code, s_los_dbg_parsed, s_los_dbg_valid); pushDiagLine(d); } if (s_los_root) losRenderResult(); } static void openLosModal(uint32_t mesh_idx) { closeLosModal(); ContactInfo c; if (!the_mesh.getContactByIdx(mesh_idx, c)) return; s_los_self_lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0; s_los_self_lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0; s_los_peer_lat = (double)c.gps_lat / 1.0e6; s_los_peer_lon = (double)c.gps_lon / 1.0e6; // ---- Modal shell ---- const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_los_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_los_root); lv_obj_set_size(s_los_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_los_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_los_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_los_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_los_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_los_root); lv_obj_add_event_cb(s_los_root, losModalCloseCb, LV_EVENT_CLICKED, nullptr); // Cap the card to the usable height so it fits the shorter landscape screen // (the graph + controls shrink to match — see chatLandscape() in losDrawPlot // / losRenderResult). const int card_w = PCW(230); // matches losDrawPlot()/losRenderResult() — see PSC() note in losDrawPlot() int card_h = PSC(248); if (card_h > modalAvailH()) card_h = modalAvailH(); lv_obj_t* card = lv_obj_create(s_los_root); s_los_card = card; lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, losModalCloseCb); char nm[36]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), c.name); lv_obj_t* title = lv_label_create(card); lv_label_set_text_fmt(title, TR(LV_SYMBOL_GPS " LOS \xe2\x86\x92 %s"), nm[0] ? nm : "?"); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, card_w - 16 - 28); lv_obj_set_pos(title, 0, 0); lv_obj_t* msg = lv_label_create(card); s_los_msg = msg; lv_label_set_long_mode(msg, LV_LABEL_LONG_WRAP); lv_obj_set_width(msg, card_w - 16); lv_obj_set_style_text_font(msg, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(msg, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(msg, 0, 24); // Early outs that need no network. if (s_los_self_lat == 0.0 && s_los_self_lon == 0.0) { lv_label_set_text(msg, TR("Your location is unknown.\nSet GPS / position in\nSettings \xe2\x86\x92 Profile first.")); return; } if (s_los_peer_lat == 0.0 && s_los_peer_lon == 0.0) { lv_label_set_text(msg, TR("This contact hasn't shared\na GPS position.")); return; } if (WiFi.status() != WL_CONNECTED) { lv_label_set_text(msg, TR("Wi-Fi needed to fetch the\nterrain profile for this path.\nConnect in Settings \xe2\x86\x92 Wi-Fi.")); return; } // Busy-watchdog: only block a new request if a fetch genuinely started // recently. If the worker has been "busy" too long (slow/hung fetch), // treat it as stale and let this request proceed rather than locking the // UI out indefinitely. if (s_los_busy && (uint32_t)(millis() - s_los_req_ms) < 48000u) { lv_label_set_text(msg, TR("Still analyzing the previous\npath\xe2\x80\xa6 try again in a moment.")); return; } // Sample the great circle (linear lat/lon interp is fine < 100 km). for (int i = 0; i < k_los_samples; ++i) { const double f = (double)i / (k_los_samples - 1); s_los_slat[i] = s_los_self_lat + f * (s_los_peer_lat - s_los_self_lat); s_los_slon[i] = s_los_self_lon + f * (s_los_peer_lon - s_los_self_lon); } lv_label_set_text(msg, TR("Analyzing terrain\xe2\x80\xa6")); s_los_result_ready = false; // Hand off to the shared tile-fetch worker (core 0). It checks // s_los_request between tile fetches and runs the elevation fetch on its // own stack — no extra task/stack, so no "low memory" failure. losPoll() // renders when it's done; the UI stays responsive meanwhile. if (!ensureTileFetchTaskRunning()) { lv_label_set_text(msg, TR("Couldn't start the analyzer.\nTry again.")); return; } s_los_req_ms = millis(); s_los_attempt = 0; // reset progress so losPoll shows "Analyzing…" s_los_busy = true; s_los_request = true; } static void actionSheetLosCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const uint32_t idx = s_action_sheet_mesh_idx; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeActionSheet(); openLosModal(idx); } #endif // ESP32 && MULTI_TRANSPORT_COMPANION static void actionSheetShowOnMapCb(lv_event_t* e); // defined with the map code (uses map statics) static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const char* name, bool from_map = false) { s_action_sheet_mesh_idx = mesh_idx; s_action_sheet_is_repeater = is_repeater; closeActionSheet(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_action_sheet_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_action_sheet_root); // Backdrop starts below the global status bar so the card is centered in // the visible content area instead of drifting behind the time/battery row. lv_obj_set_size(s_action_sheet_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_action_sheet_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_action_sheet_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_action_sheet_root, LV_OPA_60, LV_PART_MAIN); lv_obj_set_style_pad_all(s_action_sheet_root, 0, LV_PART_MAIN); lv_obj_clear_flag(s_action_sheet_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_action_sheet_root); // Tap outside closes the sheet lv_obj_add_event_cb(s_action_sheet_root, actionSheetCloseCb, LV_EVENT_CLICKED, nullptr); // On the big 800×480 Tanmatsu the 232-wide sheet looked lost in the middle, so // widen the card and grow the rows/gaps/title. The Pager gets its own wide, // short layout; the remaining compact boards keep the historical integers. #if defined(TLORA_PAGER) // Match the sibling chat-details sheet: use the Pager's width while keeping // rows compact enough for its short display. Semantic text presets still // resize the contact title; action labels remain fixed-size UI chrome. const int card_w = sw - 80; const int btn_h = 26; const int btn_gap = 3; const int title_h = 32; const int padding = 6; #elif CAP_LARGE_SCREEN const int card_w = PCW(232); const int btn_h = PSC(30); const int btn_gap = PSC(6); const int title_h = PSC(28); const int padding = PSC(6); #else const int card_w = 232; // Two-column button grid keeps the common actions compact. Delete spans the // full-width bottom danger row, and unusually tall sets scroll below. const int btn_h = 30; const int btn_gap = 6; const int title_h = 28; const int padding = 6; #endif // msg/ping + telemetry + (trace ping + admin, repeaters only) + range // test + favorite/unfavorite + reset path + delete. Chat peers get 6 // rows; repeaters get 8 (Trace SNR + Admin). +1 for "Line of sight" when // both this contact and our node have a GPS fix. bool has_los = false; bool is_room = false; { ContactInfo _tc; if (the_mesh.getContactByIdx(mesh_idx, _tc)) is_room = (_tc.type == ADV_TYPE_ROOM); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { ContactInfo _lc; const bool peer_gps = the_mesh.getContactByIdx(mesh_idx, _lc) && (_lc.gps_lat != 0 || _lc.gps_lon != 0); const bool self_gps = g_lv.task && (g_lv.task->getNodeLat() != 0.0 || g_lv.task->getNodeLon() != 0.0); has_los = peer_gps && self_gps; } #endif bool has_map_btn = false; if (!from_map) { ContactInfo _mc; has_map_btn = the_mesh.getContactByIdx(mesh_idx, _mc) && (_mc.gps_lat != 0 || _mc.gps_lon != 0); } // Everything except Delete goes in a 2-column grid; Delete is a full-width // bottom row. Grid items = msg/ping + telemetry + range + favorite + reset + // block (6), + trace/admin for repeaters (2), + Join for rooms (1), + // line-of-sight (1), + Show on map (1 when contact has GPS and !from_map). const int grid_items = (from_map ? 5 : 6) + (is_repeater ? 2 : 0) + (is_room ? 1 : 0) + (has_los ? 1 : 0) + (has_map_btn ? 1 : 0); const int grid_rows = (grid_items + 1) / 2; // ceil const int body_content_h = (grid_rows + 1) * btn_h + grid_rows * btn_gap; int card_h = 2 * padding + title_h + body_content_h; // Cap the card to the visible area and let the BUTTON BODY (below the fixed title/X) scroll when it // overflows — so a tall sheet (repeater, big UI scale) always fits on-screen and every button stays // reachable (keyboard nav focus-scrolls it into view). Replaces the old shrink-to-fit-the-rows hack. const int avail_card_h = sh - STATUSBAR_H - 4; if (card_h > avail_card_h) card_h = avail_card_h; lv_obj_t* card = lv_obj_create(s_action_sheet_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, padding, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // title + X stay fixed; the body below scrolls addCloseXBadge(card, actionSheetCloseCb); lv_obj_t* title = lv_label_create(card); char nm[40]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), name ? name : ""); lv_label_set_text_fmt(title, "%s%s", is_repeater ? TOUCH_SYM_ANTENNA " " : is_room ? LV_SYMBOL_LOOP " " : TOUCH_SYM_PERSON " ", nm[0] ? nm : "(unnamed)"); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); // Trim 32 px on the right so the close-X badge doesn't sit on top of // the contact name when it's long. lv_obj_set_width(title, card_w - 2 * padding - 32); lv_obj_set_pos(title, 0, 0); // Scrollable button body below the fixed title/X. The absolute-positioned grid buttons go in here; // when they overflow it scrolls (touch + keyboard focus-scroll), while the title/X stay pinned. lv_obj_t* body = lv_obj_create(card); lv_obj_remove_style_all(body); lv_obj_set_pos(body, 0, title_h); lv_obj_set_size(body, card_w - 2 * padding, card_h - 2 * padding - title_h); lv_obj_set_style_pad_all(body, 0, LV_PART_MAIN); if (body_content_h > card_h - 2 * padding - title_h) lv_obj_set_scroll_dir(body, LV_DIR_VER); else lv_obj_clear_flag(body, LV_OBJ_FLAG_SCROLLABLE); #if defined(TLORA_PAGER) const int col_gap = btn_gap; #elif CAP_LARGE_SCREEN const int col_gap = PSC(6); #else const int col_gap = 6; #endif const int half_w = (card_w - 2 * padding - col_gap) / 2; int y = 0; int col = 0; // 0 = left column, 1 = right column // Tanmatsu rows grow with the panel; the Pager uses fixed-size chrome so text // presets do not overfill compact rows. Other boards retain g_font_12. #if defined(TLORA_PAGER) const lv_font_t* row_font = uiChromeFont(); #elif CAP_LARGE_SCREEN const lv_font_t* row_font = &g_font_14; #else const lv_font_t* row_font = &g_font_12; #endif // Half-width grid button. Advances column, wrapping to the next row. auto mk_btn = [&](const char* label, lv_event_cb_t cb, uint32_t bg) { lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, half_w, btn_h); lv_obj_set_pos(b, col == 0 ? 0 : (half_w + col_gap), y); styleButton(b); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 4, LV_PART_MAIN); if (bg) lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, row_font, LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_size(l, half_w - 8, lv_font_get_line_height(row_font)); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(l); if (col == 0) col = 1; else { col = 0; y += btn_h + btn_gap; } }; // Full-width row (Delete). Closes any half-open grid row first. auto mk_btn_full = [&](const char* label, lv_event_cb_t cb, uint32_t bg) { if (col == 1) { col = 0; y += btn_h + btn_gap; } lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, card_w - 2 * padding, btn_h); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 8, LV_PART_MAIN); if (bg) lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, row_font, LV_PART_MAIN); lv_obj_center(l); y += btn_h + btn_gap; }; // Room servers: "Join" (login) is the headline action — green so it reads as // the thing to do first; the server only accepts posts + pushes you the room // history once you've joined. "Open chat" sits next to it. if (is_room) { mk_btn(TR(LV_SYMBOL_LOOP " Join"), actionSheetJoinRoomCb, COLOR_STATUS_OK); mk_btn(TR(LV_SYMBOL_ENVELOPE " Open chat"), actionSheetSendMsgCb, 0); } else if (is_repeater) { mk_btn(TR(LV_SYMBOL_REFRESH " Ping"), actionSheetPingCb, 0); } else { mk_btn(TR(LV_SYMBOL_ENVELOPE " Message"), actionSheetSendMsgCb, 0); } mk_btn(TR(LV_SYMBOL_BATTERY_3 " Telemetry"), actionSheetTelemetryCb, 0); if (has_map_btn) mk_btn(TR(LV_SYMBOL_GPS " Show on map"), actionSheetShowOnMapCb, 0); if (is_repeater) { mk_btn(TR(LV_SYMBOL_GPS " Trace SNR"), actionSheetTracePingCb, 0); mk_btn(TR(LV_SYMBOL_SETTINGS " Admin"), actionSheetAdminCb, 0); } mk_btn(TR(LV_SYMBOL_WIFI " Range test"), actionSheetRangeTestCb, 0); #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Terrain-aware line-of-sight (needs both GPS fixes; gated by has_los). if (has_los) mk_btn(TR(LV_SYMBOL_GPS " Sightline"), actionSheetLosCb, 0); #endif // Favorite toggle: label flips based on current state. #if defined(ESP32) bool _is_fav_now = false; { ContactInfo _c; if (the_mesh.getContactByIdx(s_action_sheet_mesh_idx, _c)) { _is_fav_now = touchPrefsIsFavorite(_c.id.pub_key); } } mk_btn(_is_fav_now ? TOUCH_SYM_STAR " Unfav" : TOUCH_SYM_STAR " Favorite", actionSheetFavoriteCb, 0); #else mk_btn(TOUCH_SYM_STAR " Favorite", actionSheetFavoriteCb, 0); #endif // Share position with this contact (#266) — label flips on the current grant. // Hidden on the map-marker sheet, which stays compact. #if defined(ESP32) if (!from_map) { bool _loc_shared = false; ContactInfo _lc; if (the_mesh.getContactByIdx(s_action_sheet_mesh_idx, _lc)) _loc_shared = the_mesh.uiGetContactTelemetryPerm(_lc.id.pub_key, TELEM_PERM_LOCATION); mk_btn(_loc_shared ? TR(LV_SYMBOL_GPS " Stop sharing loc") : TR(LV_SYMBOL_GPS " Share my loc"), actionSheetShareLocCb, 0); } #endif mk_btn(TR(LV_SYMBOL_LOOP " Reset path"), actionSheetResetPathCb, 0); // Block / unblock — label flips on the current ignore state. Skipped when the // sheet is opened from a map marker (keeps that popup compact). if (!from_map) { #if defined(ESP32) bool _blk = false; ContactInfo _bc; if (the_mesh.getContactByIdx(s_action_sheet_mesh_idx, _bc)) _blk = touchPrefsIsIgnored(_bc.id.pub_key); mk_btn(_blk ? LV_SYMBOL_OK " Unblock" : LV_SYMBOL_CLOSE " Block", actionSheetBlockCb, 0); #else mk_btn(TR(LV_SYMBOL_CLOSE " Block"), actionSheetBlockCb, 0); #endif } mk_btn_full(LV_SYMBOL_TRASH " Delete", actionSheetDeleteCb, 0xB23A48); // Scrollability is decided HERE, from the height the buttons actually reached, // not from the grid_items tally above. That tally is hand-maintained and has // to be updated every time a conditional button is added; the location-sharing // row added for #266 is not in it, so a contact showing that button has one // more row than the card was sized for. When the estimate is short the body is // left unscrollable and the overflow is simply unreachable, which is the shape // of #306. `y` is the truth, so use it and let the tally be wrong harmlessly. { const int body_h = card_h - 2 * padding - title_h; if (y > body_h) { lv_obj_set_scroll_dir(body, LV_DIR_VER); lv_obj_add_flag(body, LV_OBJ_FLAG_SCROLLABLE); } } } static void contactSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Guard against scroll-triggered taps. LVGL's default click threshold is // forgiving on flicks — operator complaint was the action sheet opened // every time they tried to scroll the contact list. If the indev was in // a scroll state during this press, treat as scroll not tap. lv_indev_t* act = lv_indev_get_act(); if (act) { // lv_indev_get_scroll_obj returns the object currently being scrolled // (or NULL). If anything is being scrolled, this CLICKED was the tail // end of a flick, not a deliberate tap. lv_obj_t* scroll_obj = lv_indev_get_scroll_obj(act); if (scroll_obj) return; lv_dir_t sdir = lv_indev_get_scroll_dir(act); if (sdir != LV_DIR_NONE) return; } auto* ctx = static_cast<LvContactButtonCtx*>(lv_event_get_user_data(e)); if (!ctx || !g_lv.task) return; ContactInfo c; if (!the_mesh.getContactByIdx(ctx->mesh_idx, c)) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } openContactActionSheet(ctx->mesh_idx, ctx->is_repeater, c.name); } // ============================================================ // Chats "+" → add-channel menu and modals // Mirrors the web client's "Add channel" dropdown: create a private channel // (random or supplied 32-hex secret), join a private channel by secret, join // the public channel (PUBLIC_GROUP_PSK), or join a hashtag channel where the // key is the first 16 bytes of SHA-256("#" + lowercased name). // State pointers are forward-declared at the top of the file so // closeSettingsModal() can null them on close. // ============================================================ static void closeAddChannelSheet() { popupClose(&s_addch_sheet); } // Convert 32 hex chars in `hex` to 16 raw bytes. Returns false if input is // not exactly 32 hex chars. static bool hexToSecret16(const char* hex, uint8_t out[16]) { if (!hex) return false; int n = 0; while (hex[n]) ++n; if (n != 32) return false; for (int i = 0; i < 16; ++i) { auto v = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); return -1; }; int hi = v(hex[i*2]); int lo = v(hex[i*2 + 1]); if (hi < 0 || lo < 0) return false; out[i] = static_cast<uint8_t>((hi << 4) | lo); } return true; } static void setAddChannelError(const char* msg) { if (s_addch_error_l) lv_label_set_text(s_addch_error_l, msg ? TR(msg) : ""); } // ---- Create-private channel ---- static void createPrivateChannelSubmitCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); if (!s_addch_name_ta || !s_addch_secret_ta) return; char name[32]; strncpy(name, lv_textarea_get_text(s_addch_name_ta), sizeof(name) - 1); name[sizeof(name) - 1] = '\0'; // Trim trailing whitespace and default to "Private" if empty. for (int i = (int)strlen(name) - 1; i >= 0 && (name[i] == ' ' || name[i] == '\t'); --i) name[i] = '\0'; if (name[0] == '\0') strncpy(name, "Private", sizeof(name)); const char* sec_raw = lv_textarea_get_text(s_addch_secret_ta); char hex[33]; int hn = 0; for (const char* p = sec_raw; *p && hn < 32; ++p) { if (*p == ' ' || *p == '\t' || *p == '\n') continue; hex[hn++] = *p; } hex[hn] = '\0'; uint8_t secret[16]; if (hn == 0) { #if defined(ESP32) esp_fill_random(secret, sizeof(secret)); #else for (int i = 0; i < 16; ++i) secret[i] = static_cast<uint8_t>(rand() & 0xFF); #endif } else if (!hexToSecret16(hex, secret)) { setAddChannelError(TR("Secret must be 32 hex chars (or empty).")); return; } const int slot = the_mesh.findFirstEmptyChannelSlot(); if (slot < 0) { setAddChannelError(TR("Channel table is full.")); return; } if (!the_mesh.uiAddOrUpdateChannel(slot, name, secret)) { setAddChannelError(TR("Failed to save channel.")); return; } // Don't wait for the loop's deferred refresh — pull the channel into the // thread list right now so the chats list shows the new entry the instant // the modal closes. if (g_lv.task) { g_lv.task->refreshThreadsFromMesh(); g_lv.dirty_threads = true; } closeSettingsModal(); if (g_lv.task) g_lv.task->showAlert(TR("Channel created"), 1200); } // ===== Contacts → Add manually (pubkey + name) ============================== // Add a peer we want to talk to before we've heard their advert. Convert a // 64-hex pubkey into raw bytes and ask MyMesh::uiAddManualContact to wedge // a synthetic chat-type contact into the table. State pointers are // declared earlier in the file so closeSettingsModal can null them. static bool hexToPubkey32(const char* hex, uint8_t out[32]) { if (!hex) return false; int n = 0; while (hex[n]) ++n; if (n != 64) return false; for (int i = 0; i < 32; ++i) { auto v = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); return -1; }; int hi = v(hex[i*2]); int lo = v(hex[i*2 + 1]); if (hi < 0 || lo < 0) return false; out[i] = static_cast<uint8_t>((hi << 4) | lo); } return true; } static void addContactSubmitCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); if (!s_addct_pub_ta || !s_addct_name_ta) return; const char* pub_raw = lv_textarea_get_text(s_addct_pub_ta); const char* name_raw = lv_textarea_get_text(s_addct_name_ta); char hex[80]; int hn = 0; for (const char* p = pub_raw; *p && hn < 65; ++p) { if (*p == ' ' || *p == ':' || *p == '\n' || *p == '\t' || *p == '\r') continue; hex[hn++] = *p; } hex[hn] = '\0'; uint8_t pub[32]; if (!hexToPubkey32(hex, pub)) { if (s_addct_error_l) lv_label_set_text(s_addct_error_l, TR("Pubkey must be 64 hex chars.")); return; } char name[32] = {0}; strncpy(name, name_raw, sizeof(name) - 1); // trim trailing whitespace for (int i = (int)strlen(name) - 1; i >= 0 && (name[i] == ' ' || name[i] == '\t'); --i) name[i] = '\0'; if (name[0] == '\0') { if (s_addct_error_l) lv_label_set_text(s_addct_error_l, TR("Name can't be empty.")); return; } if (!the_mesh.uiAddManualContact(pub, name)) { if (s_addct_error_l) lv_label_set_text(s_addct_error_l, TR("Already exists or table full.")); return; } closeSettingsModal(); if (g_lv.task) g_lv.task->showAlert(TR("Contact added"), 1200); refreshContactsList(); } static void openAddContactModalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* body = createSettingsModal(TR("Add contact"), SettingsModalKind::AddContact); int y = 0; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Paste the 64-hex public key and a name. Useful when you want to DM someone before their advert has arrived.")); lv_obj_set_pos(hint, 2, y); y += 48; lv_obj_t* pub_l = lv_label_create(body); lv_label_set_text(pub_l, TR("Public key (64 hex chars)")); lv_obj_set_style_text_color(pub_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(pub_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(pub_l, 2, y); y += 16; s_addct_pub_ta = lv_textarea_create(body); lv_obj_set_size(s_addct_pub_ta, lv_pct(100),56); lv_obj_set_pos(s_addct_pub_ta, 2, y); lv_textarea_set_one_line(s_addct_pub_ta, false); taSetPlaceholder(s_addct_pub_ta, TR("0123456789abcdef…")); lv_textarea_set_max_length(s_addct_pub_ta, 80); attachSettingsTaEvents(s_addct_pub_ta); y += 60; lv_obj_t* name_l = lv_label_create(body); lv_label_set_text(name_l, TR("Name")); lv_obj_set_style_text_color(name_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(name_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(name_l, 2, y); y += 16; s_addct_name_ta = lv_textarea_create(body); lv_obj_set_size(s_addct_name_ta, lv_pct(100),30); lv_obj_set_pos(s_addct_name_ta, 2, y); lv_textarea_set_one_line(s_addct_name_ta, true); taSetPlaceholder(s_addct_name_ta, TR("Display name")); lv_textarea_set_max_length(s_addct_name_ta, 31); attachSettingsTaEvents(s_addct_name_ta); y += 36; s_addct_error_l = lv_label_create(body); lv_label_set_long_mode(s_addct_error_l, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_addct_error_l, lv_pct(100)); lv_obj_set_style_text_color(s_addct_error_l, lv_color_hex(0xE08080), LV_PART_MAIN); lv_obj_set_style_text_font(s_addct_error_l, &g_font_12, LV_PART_MAIN); lv_label_set_text(s_addct_error_l, ""); lv_obj_set_pos(s_addct_error_l, 2, y); y += 24; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, addContactSubmitCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Add contact")); lv_obj_center(bl); } static void openCreatePrivateChannelModal() { lv_obj_t* body = createSettingsModal(TR("Create private channel"), SettingsModalKind::ChCreatePrv); int y = 0; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Share the 32-char secret so others can join. Leave the secret empty to generate a random one.")); lv_obj_set_pos(hint, 2, y); y += 44; lv_obj_t* name_l = lv_label_create(body); lv_label_set_text(name_l, TR("Channel name")); lv_obj_set_style_text_color(name_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(name_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(name_l, 2, y); y += 16; s_addch_name_ta = lv_textarea_create(body); lv_obj_set_size(s_addch_name_ta, lv_pct(100),30); lv_obj_set_pos(s_addch_name_ta, 2, y); lv_textarea_set_one_line(s_addch_name_ta, true); taSetPlaceholder(s_addch_name_ta, TR("e.g. Family")); lv_textarea_set_max_length(s_addch_name_ta, 31); attachSettingsTaEvents(s_addch_name_ta); y += 36; lv_obj_t* sec_l = lv_label_create(body); lv_label_set_text(sec_l, TR("Secret (32 hex, optional)")); lv_obj_set_style_text_color(sec_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(sec_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(sec_l, 2, y); y += 16; s_addch_secret_ta = lv_textarea_create(body); lv_obj_set_size(s_addch_secret_ta, lv_pct(100),30); lv_obj_set_pos(s_addch_secret_ta, 2, y); lv_textarea_set_one_line(s_addch_secret_ta, true); taSetPlaceholder(s_addch_secret_ta, TR("leave empty to generate")); lv_textarea_set_max_length(s_addch_secret_ta, 32); attachSettingsTaEvents(s_addch_secret_ta); y += 36; s_addch_error_l = lv_label_create(body); lv_label_set_long_mode(s_addch_error_l, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_addch_error_l, lv_pct(100)); lv_obj_set_style_text_color(s_addch_error_l, lv_color_hex(0xE08080), LV_PART_MAIN); lv_obj_set_style_text_font(s_addch_error_l, &g_font_12, LV_PART_MAIN); lv_label_set_text(s_addch_error_l, ""); lv_obj_set_pos(s_addch_error_l, 2, y); y += 24; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, createPrivateChannelSubmitCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Create")); lv_obj_center(bl); } // ---- Join-private channel ---- static void joinPrivateChannelSubmitCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); if (!s_addch_secret_ta) return; char jname[32] = "Joined"; // default if the user leaves Name blank if (s_addch_name_ta) { strncpy(jname, lv_textarea_get_text(s_addch_name_ta), sizeof(jname) - 1); jname[sizeof(jname) - 1] = '\0'; for (int i = (int)strlen(jname) - 1; i >= 0 && (jname[i] == ' ' || jname[i] == '\t'); --i) jname[i] = '\0'; if (jname[0] == '\0') strncpy(jname, "Joined", sizeof(jname)); } const char* raw = lv_textarea_get_text(s_addch_secret_ta); char hex[33]; int hn = 0; for (const char* p = raw; *p && hn < 32; ++p) { if (*p == ' ' || *p == '\t' || *p == '\n') continue; hex[hn++] = *p; } hex[hn] = '\0'; uint8_t secret[16]; if (!hexToSecret16(hex, secret)) { setAddChannelError(TR("Enter exactly 32 hex characters.")); return; } const int slot = the_mesh.findFirstEmptyChannelSlot(); if (slot < 0) { setAddChannelError(TR("Channel table is full.")); return; } if (!the_mesh.uiAddOrUpdateChannel(slot, jname, secret)) { setAddChannelError(TR("Failed to save channel.")); return; } if (g_lv.task) { g_lv.task->refreshThreadsFromMesh(); g_lv.dirty_threads = true; } closeSettingsModal(); if (g_lv.task) g_lv.task->showAlert(TR("Channel joined"), 1200); } static void openJoinPrivateChannelModal() { lv_obj_t* body = createSettingsModal(TR("Join private channel"), SettingsModalKind::ChJoinPrv); int y = 0; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Enter the 32-hex secret shared by the channel creator.")); lv_obj_set_pos(hint, 2, y); y += 32; lv_obj_t* name_l = lv_label_create(body); lv_label_set_text(name_l, TR("Name")); lv_obj_set_style_text_color(name_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(name_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(name_l, 2, y); y += 16; s_addch_name_ta = lv_textarea_create(body); lv_obj_set_size(s_addch_name_ta, lv_pct(100), 30); lv_obj_set_pos(s_addch_name_ta, 2, y); lv_textarea_set_one_line(s_addch_name_ta, true); taSetPlaceholder(s_addch_name_ta, TR("Channel name")); lv_textarea_set_max_length(s_addch_name_ta, 30); attachSettingsTaEvents(s_addch_name_ta); y += 36; lv_obj_t* sec_l = lv_label_create(body); lv_label_set_text(sec_l, TR("Secret (32 hex chars)")); lv_obj_set_style_text_color(sec_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(sec_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(sec_l, 2, y); y += 16; s_addch_secret_ta = lv_textarea_create(body); lv_obj_set_size(s_addch_secret_ta, lv_pct(100),30); lv_obj_set_pos(s_addch_secret_ta, 2, y); lv_textarea_set_one_line(s_addch_secret_ta, true); taSetPlaceholder(s_addch_secret_ta, TR("32 hex characters")); lv_textarea_set_max_length(s_addch_secret_ta, 32); attachSettingsTaEvents(s_addch_secret_ta); y += 36; s_addch_error_l = lv_label_create(body); lv_label_set_long_mode(s_addch_error_l, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_addch_error_l, lv_pct(100)); lv_obj_set_style_text_color(s_addch_error_l, lv_color_hex(0xE08080), LV_PART_MAIN); lv_obj_set_style_text_font(s_addch_error_l, &g_font_12, LV_PART_MAIN); lv_label_set_text(s_addch_error_l, ""); lv_obj_set_pos(s_addch_error_l, 2, y); y += 24; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, joinPrivateChannelSubmitCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Join")); lv_obj_center(bl); } // ---- Join hashtag channel ---- static void joinHashtagChannelSubmitCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); if (!s_addch_hashtag_ta) return; // Normalize: strip leading '#'s, drop whitespace, ASCII-lowercase. Matches // Meshcomod-client/apps/web-client/src/main.ts:deriveHashtagChannelSecret. const char* raw = lv_textarea_get_text(s_addch_hashtag_ta); char norm[40]; int nn = 0; const char* p = raw; while (*p == '#') ++p; while (*p && nn < (int)sizeof(norm) - 1) { char c = *p++; if (c == ' ' || c == '\t' || c == '\n') continue; if (c >= 'A' && c <= 'Z') c = (char)(c + 32); norm[nn++] = c; } norm[nn] = '\0'; if (nn == 0) strncpy(norm, "public", sizeof(norm)); char hashed[42] = "#"; strncat(hashed, norm, sizeof(hashed) - 2); uint8_t secret[16]; mesh::Utils::sha256(secret, sizeof(secret), reinterpret_cast<const uint8_t*>(hashed), (int)strlen(hashed)); const int slot = the_mesh.findFirstEmptyChannelSlot(); if (slot < 0) { setAddChannelError(TR("Channel table is full.")); return; } if (!the_mesh.uiAddOrUpdateChannel(slot, hashed, secret)) { setAddChannelError(TR("Failed to save channel.")); return; } if (g_lv.task) { g_lv.task->refreshThreadsFromMesh(); g_lv.dirty_threads = true; } closeSettingsModal(); if (g_lv.task) g_lv.task->showAlert(TR("Channel joined"), 1200); } static void openJoinHashtagChannelModal() { lv_obj_t* body = createSettingsModal(TR("Join hashtag channel"), SettingsModalKind::ChJoinTag); int y = 0; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_label_set_text(hint, TR("Anyone can join. Key is derived from the hashtag (lowercase).")); lv_obj_set_pos(hint, 2, y); y += 32; lv_obj_t* name_l = lv_label_create(body); lv_label_set_text(name_l, TR("Hashtag name")); lv_obj_set_style_text_color(name_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(name_l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(name_l, 2, y); y += 16; s_addch_hashtag_ta = lv_textarea_create(body); lv_obj_set_size(s_addch_hashtag_ta, lv_pct(100),30); lv_obj_set_pos(s_addch_hashtag_ta, 2, y); lv_textarea_set_one_line(s_addch_hashtag_ta, true); taSetPlaceholder(s_addch_hashtag_ta, TR("e.g. mesh")); lv_textarea_set_text(s_addch_hashtag_ta, "#"); lv_textarea_set_max_length(s_addch_hashtag_ta, 31); attachSettingsTaEvents(s_addch_hashtag_ta); y += 36; s_addch_error_l = lv_label_create(body); lv_label_set_long_mode(s_addch_error_l, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_addch_error_l, lv_pct(100)); lv_obj_set_style_text_color(s_addch_error_l, lv_color_hex(0xE08080), LV_PART_MAIN); lv_obj_set_style_text_font(s_addch_error_l, &g_font_12, LV_PART_MAIN); lv_label_set_text(s_addch_error_l, ""); lv_obj_set_pos(s_addch_error_l, 2, y); y += 24; lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); lv_obj_set_pos(b, 2, y); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, joinHashtagChannelSubmitCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(b); useChainedFont(bl); lv_label_set_text(bl, TR("Join")); lv_obj_center(bl); } // ---- "+" action sheet on the Chats tab ---- static void addChannelSheetDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAddChannelSheet(); } static void addChannelCreatePrivateCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAddChannelSheet(); openCreatePrivateChannelModal(); } static void addChannelJoinPrivateCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAddChannelSheet(); openJoinPrivateChannelModal(); } static void addChannelJoinPublicCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAddChannelSheet(); if (the_mesh.uiJoinPublicChannel()) { if (g_lv.task) { g_lv.task->refreshThreadsFromMesh(); g_lv.dirty_threads = true; g_lv.task->showAlert(TR("Public channel ready"), 1200); } } else { if (g_lv.task) g_lv.task->showAlert(TR("Channel table is full"), 1400); } } static void addChannelJoinHashtagCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeAddChannelSheet(); openJoinHashtagChannelModal(); } static void openAddChannelSheet() { closeAddChannelSheet(); s_addch_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_addch_sheet); // Backdrop starts below the global status bar — full screen so it covers // everything and the card centers on-screen in either orientation. lv_obj_set_size(s_addch_sheet, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_set_pos(s_addch_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_addch_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_addch_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_addch_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_addch_sheet); lv_obj_add_event_cb(s_addch_sheet, addChannelSheetDismissCb, LV_EVENT_CLICKED, nullptr); const int rows = 4; const int pad = PSC(10); const int hdr_h = PSC(36); const int row_gap = PSC(6); // Shrink the button height if the four rows + header won't fit the (shorter) // landscape viewport, so the card never runs off-screen. Bigger on the Tanmatsu. int btn_h = PSC(38); if (hdr_h + rows * (btn_h + row_gap) + pad > modalAvailH()) btn_h = ((modalAvailH() - hdr_h - pad) / rows) - row_gap; if (btn_h < PSC(26)) btn_h = PSC(26); int card_w = PCW(220); if (card_w > modalAvailW()) card_w = modalAvailW(); const int card_h = hdr_h + rows * (btn_h + row_gap) + pad; lv_obj_t* card = lv_obj_create(s_addch_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, addChannelSheetDismissCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Add channel")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); int y = PSC(28); auto mk = [&](const char* label, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_center(l); y += btn_h + row_gap; }; mk(TR("Create a private channel"), addChannelCreatePrivateCb); mk(TR("Join a private channel"), addChannelJoinPrivateCb); mk(TR("Join the public channel"), addChannelJoinPublicCb); mk(TR("Join a hashtag channel"), addChannelJoinHashtagCb); } static void chatsAddBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openAddChannelSheet(); } static void markAllReadApply() { if (!g_lv.task) return; g_lv.task->markAllThreadsRead(); g_lv.dirty_threads = true; // rebuild the list -> badges clear g_lv.task->showAlert(TR("All marked read"), 1000); } static void chatsMarkAllReadBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; showConfirm(TR("Mark all chats and channels as read?"), TR("Mark read"), markAllReadApply); } // ---- Share-my-contact QR popup ------------------------------------------ // // Renders an lv_qrcode encoding our own pubkey + node name so another // touch device can scan it (with a phone or another firmware build that // grows a camera/scanner later) and import us as a contact. // // Payload = the official MeshCore contact-share URI so the phone app's "Add // Contact → Scan QR Code" imports us directly: // meshcore://contact/add?name=<urlenc>&public_key=<64 lowercase hex>&type=1 // (per docs.meshcore.io/qr_codes). The old home-grown `meshcore://add?p=…&n=…` // form was parseable by nothing — that was issue #16 ("wrong QR"). static lv_obj_t* s_share_my_root = nullptr; static void closeShareMyContact() { if (s_share_my_root) { popupClose(&s_share_my_root); } } static void shareMyContactBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeShareMyContact(); } static void openShareMyContactPopup() { closeShareMyContact(); // Collect self identity. Fallbacks keep the popup useful even when the // node name hasn't been set yet (the QR will just say "node"). const uint8_t* pub = the_mesh.getSelfPubKey(); const char* node_name = (g_lv.task ? g_lv.task->getNodeNameCstr() : ""); const char* name = (node_name && node_name[0]) ? node_name : "node"; char pub_hex[65] = {0}; for (int i = 0; i < 32; ++i) { static const char* k_hex = "0123456789abcdef"; pub_hex[i*2 + 0] = k_hex[(pub[i] >> 4) & 0xF]; pub_hex[i*2 + 1] = k_hex[ pub[i] & 0xF]; } // URL-encode the name: only the chars likely to appear (spaces, punct) // really need it; this is a pragmatic subset. Touch firmware will read // the raw bytes back anyway. 96-byte buffer = up to ~32 char name with // %xx escapes. char name_enc[96] = {0}; { int o = 0; for (int i = 0; name[i] && o + 4 < (int)sizeof(name_enc); ++i) { const unsigned char c = (unsigned char)name[i]; const bool plain = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; if (plain) { name_enc[o++] = c; } else { static const char* k_hex = "0123456789ABCDEF"; name_enc[o++] = '%'; name_enc[o++] = k_hex[(c >> 4) & 0xF]; name_enc[o++] = k_hex[ c & 0xF]; } } name_enc[o] = '\0'; } char payload[256]; // Official MeshCore contact-share URI (matches what the phone app's "Add Contact // → Scan QR Code" expects): meshcore://contact/add?name=<urlenc>&public_key=<64 // lowercase hex>&type=<1=Companion,2=Repeater,3=Room,4=Sensor>. A touch radio is // always a Companion. The old `meshcore://add?p=…&n=…` form parsed in nothing. snprintf(payload, sizeof(payload), "meshcore://contact/add?name=%s&public_key=%s&type=1", name_enc, pub_hex); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_share_my_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_share_my_root); // Sit below the global status bar — same pattern as every other popup. lv_obj_set_size(s_share_my_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_share_my_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_share_my_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_share_my_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_share_my_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_share_my_root, shareMyContactBackdropCb, LV_EVENT_CLICKED, nullptr); // Card: title row at top, QR centered, hex pubkey under it (handy when // the operator wants to dictate it over voice and there's no camera). // Clamp to the usable area and scale the QR to fit so the popup never runs // off the shorter landscape screen. int card_w; int card_h; #if defined(TLORA_PAGER) // The Pager is very wide and very short. Use that width instead of squeezing // a portrait card into the middle: QR on the left, identity prefix on the // right, with a single title row across the top. card_w = 360; if (card_w > modalAvailW()) card_w = modalAvailW(); card_h = modalAvailH(); int qr_size = card_h - 54; // 10-px card padding + 32-px title row + 2-px bottom breathing room #else card_w = PCW(220); if (card_w > modalAvailW()) card_w = modalAvailW(); card_h = PSC(270); if (card_h > modalAvailH()) card_h = modalAvailH(); int qr_size = card_h - 34 - 30 - 20; // title row + hex caption + padding #endif if (qr_size > PSC(160)) qr_size = PSC(160); // bigger QR on the Tanmatsu panel if (qr_size > card_w - 20) qr_size = card_w - 20; if (qr_size < 96) qr_size = 96; lv_obj_t* card = lv_obj_create(s_share_my_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, shareMyContactBackdropCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text_fmt(title, TR("Share: %s"), name); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); // Constrain both dimensions: LONG_DOT with only a width can wrap to a second // line, which puts a long node name on top of the QR. lv_obj_set_size(title, card_w - 20 - 32, lv_font_get_line_height(&g_font_14)); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 4); // QR widget — 160 px in a 220 px card with 10 px padding leaves 20 px // gutters left/right. Dark = pure black, light = white-ish; LVGL needs // strong contrast for the camera-side decoder. lv_obj_t* qr = lv_qrcode_create(card, qr_size, lv_color_hex(0x000000), lv_color_hex(0xFFFFFF)); lv_qrcode_update(qr, payload, strlen(payload)); #if defined(TLORA_PAGER) lv_obj_align(qr, LV_ALIGN_TOP_LEFT, 0, 32); #else lv_obj_align(qr, LV_ALIGN_TOP_MID, 0, 32); #endif // White frame around the QR so the camera-side QR detector doesn't get // confused by the dark card bleeding into the quiet zone. lv_obj_set_style_border_color(qr, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_border_width(qr, 4, LV_PART_MAIN); // Short pubkey prefix below the QR — quick visual check that two // devices show the same identity. char prefix_buf[24]; snprintf(prefix_buf, sizeof(prefix_buf), "%02X%02X%02X%02X%02X%02X", pub[0], pub[1], pub[2], pub[3], pub[4], pub[5]); lv_obj_t* hex_lbl = lv_label_create(card); lv_label_set_text(hex_lbl, prefix_buf); lv_obj_set_style_text_color(hex_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hex_lbl, &g_font_12, LV_PART_MAIN); #if defined(TLORA_PAGER) const int hex_w = card_w - 20 - qr_size - 12; lv_obj_set_width(hex_lbl, hex_w); lv_obj_set_style_text_align(hex_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_align_to(hex_lbl, qr, LV_ALIGN_OUT_RIGHT_MID, 12, 0); #else // Anchor under the QR (not the card bottom) so it can't ride up onto the // code when the QR shrinks in landscape. lv_obj_align_to(hex_lbl, qr, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); #endif } static void shareMyContactBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openShareMyContactPopup(); } // ============================================================ // UI Builders // ============================================================ // ---- Home battery icon + heartbeat + TX/RX chart ---- static lv_obj_t* s_home_batt_icon = nullptr; static lv_obj_t* s_home_batt_pct = nullptr; static lv_obj_t* s_home_clock = nullptr; static void fmtClockHM(char* buf, size_t cap, const struct tm* t); // 12/24h-aware HH:MM (defined below) // Small pulsing dot next to the title — animates while the LVGL timer is alive. static lv_obj_t* s_home_heartbeat = nullptr; // TX / RX activity chart on Home. Two series: TX (sent, green) and RX (recv, // blue). Refreshed on each refreshStatusLabels tick from the_mesh's dispatcher // counters; chart is a rolling window of the last N samples (auto-shift). static lv_obj_t* s_home_chart = nullptr; static lv_chart_series_t* s_home_chart_tx = nullptr; static lv_chart_series_t* s_home_chart_rx = nullptr; static int s_home_chart_max_y = 10; // shared with the Pager's full-width detail chart #if defined(HAS_EXPANSION_KIT) // Expansion Kit: tiny batt/temp/hum history chart on Home + the Advert button // pointer (relayoutHomeCharts repositions it under the re-flowed TX/RX chart). static lv_obj_t* s_home_env_chart = nullptr; static lv_chart_series_t* s_home_env_batt = nullptr; static lv_chart_series_t* s_home_env_temp = nullptr; static lv_chart_series_t* s_home_env_hum = nullptr; static lv_obj_t* s_home_adv_btn = nullptr; #endif // Compact legend label above the chart showing live TX/RX totals. static lv_obj_t* s_home_chart_legend = nullptr; static lv_obj_t* s_home_chart_sig = nullptr; // live signal chip drawn inside the graph box #if CAP_LARGE_SCREEN static lv_obj_t* s_home_info = nullptr; // Commander info-panel values column (big screen) — refreshed live #endif static void heartbeatAnimOpa(void* var, int32_t v) { lv_obj_set_style_bg_opa(static_cast<lv_obj_t*>(var), static_cast<lv_opa_t>(v), LV_PART_MAIN); } static void heartbeatAnimSize(void* var, int32_t v) { // v is the diameter (and radius via styleCard logic below). lv_obj_set_size(static_cast<lv_obj_t*>(var), v, v); lv_obj_set_style_radius(static_cast<lv_obj_t*>(var), v / 2, LV_PART_MAIN); } // ---- Battery / charge state ---- // A single Li-ion cell can't rest above ~4.2 V. When USB is plugged the charger // drives the rail higher: a reading above this threshold means "on charger". // This charge-from-voltage trick + the EMA smoothing are tuned for the T-Deck's // calibrated analogReadMilliVolts. The Heltec V4 uses a different, noisier ADC // path (raw analogRead × a fixed scale) whose plugged-in reading already sat at // ~100 %; running the EMA over that noise dragged the average DOWN (≈64 %) and // it never crossed the charge threshold. So gate both to the T-Deck and let the // V4 keep its prior direct read. // User-calibrated "full" voltage (the reading captured when the pack was fully // charged) = 100%. 0 = use the 4200 mV default Li-ion full point. Lets custom // packs / builds read 100%. Cached so the per-tick % calc doesn't hit NVS; // updated in place by the Calibrate-battery action. static uint16_t s_batt_full_mv = 0; static bool s_batt_full_loaded = false; static uint16_t batteryFullMv() { if (!s_batt_full_loaded) { s_batt_full_mv = touchPrefsGetBattFullMv(); s_batt_full_loaded = true; } return s_batt_full_mv ? s_batt_full_mv : 4200; } #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) // (R8: the divider is permanently connected — PIN_ADC_CTRL=-1 — so the same // EMA + above-full voltage heuristic applies; threshold accuracy vs the // uncalibrated ADC_MULTIPLIER=5.07 conversion needs on-device confirmation.) static constexpr uint16_t kBattChargingMv = 4250; // Per-board battery sampler: EMA over the noisy ADC so the value doesn't jitter // ±1 every tick. Wrapped by batteryMvSmoothed() (below), which only publishes a // fresh value every 20 s. Returns 0 if unsupported. static uint16_t batteryMvSampled() { if (!g_lv.task) return 0; const uint16_t raw = g_lv.task->getBattMilliVolts(); if (raw == 0) return 0; static float s_ema = 0.0f; if (s_ema < 1.0f) { s_ema = (float)raw; // seed on first read } else { const float d = (float)raw - s_ema; // A charger plug/unplug steps the rail ~100 mV+ — snap straight to it so the // charge state is recognized within a tick instead of crawling there over // the EMA. Smaller deltas are ADC noise, so keep smoothing those. if (d > 70.0f || d < -70.0f) s_ema = (float)raw; else s_ema += d * 0.15f; // ~7-sample time constant } return (uint16_t)(s_ema + 0.5f); } static bool batteryIsCharging(uint16_t mv) { return mv >= (uint16_t)(batteryFullMv() + 50); } #else // V4 (and any non-T-Deck touch board): direct read, no charge-from-voltage. static uint16_t batteryMvSampled() { return g_lv.task ? g_lv.task->getBattMilliVolts() : 0; } static bool batteryIsCharging(uint16_t) { return false; } #endif // Hold the battery reading steady: publish a fresh value only every 20 s so the // %, icon and voltage stop twitching tick-to-tick. The per-board sampler above // still runs on every call (keeps the T-Deck EMA fed); every readout just sees // the same held value for 20 s. The first call publishes immediately so there's // no blank battery at boot. static uint16_t batteryMvSmoothed() { const uint16_t cur = batteryMvSampled(); if (cur == 0) return 0; // not ready / unsupported — don't latch a 0 static uint16_t s_pub = 0; static unsigned long s_pub_ms = 0; const unsigned long now = millis(); // Normally hold for 20 s to kill jitter — BUT publish at once when the charge // state flips, so plugging/unplugging the charger is recognized immediately // instead of waiting out the hold window. const bool charge_flip = (s_pub != 0) && (batteryIsCharging(cur) != batteryIsCharging(s_pub)); if (s_pub == 0 || charge_flip || (unsigned long)(now - s_pub_ms) >= 20000UL) { s_pub = cur; s_pub_ms = now; } return s_pub; } static int batteryPercentFromMv(uint16_t mv); // fwd decl (icon derives from %) static const char* batteryGlyphForMv(uint16_t mv) { if (mv == 0) return LV_SYMBOL_BATTERY_EMPTY; if (batteryIsCharging(mv)) return LV_SYMBOL_CHARGE; // on USB power // Derive the icon from the (calibrated) percent so custom packs whose full // voltage differs from 4.2 V still fill the icon at their real 100%. const int pct = batteryPercentFromMv(mv); if (pct >= 90) return LV_SYMBOL_BATTERY_FULL; if (pct >= 65) return LV_SYMBOL_BATTERY_3; if (pct >= 40) return LV_SYMBOL_BATTERY_2; if (pct >= 15) return LV_SYMBOL_BATTERY_1; return LV_SYMBOL_BATTERY_EMPTY; } static int batteryPercentFromMv(uint16_t mv) { #if defined(HAS_TDISPLAY_P4) // This board has a BQ27220 fuel gauge, which coulomb-counts against its learned // pack profile and therefore knows the actual state of charge. Ask it, and only // fall back to the voltage curve below if it does not answer. Terminal voltage is // charger-driven, so the curve read 100% the moment USB was plugged in while the // pack was nearly empty, and never quite reached 100% resting on battery (#273). { const int soc = board.getBattStateOfCharge(); if (soc >= 0) return soc; } #endif // Li-ion curve: 3.30 V empty, FULL = the calibrated full (default 4.20 V). // While charging the rail reads above full (charger-driven) — cap at 100 // rather than show a bogus number; the real cell % isn't observable here. if (mv == 0) return -1; const uint16_t full = batteryFullMv(); if (mv >= full) return 100; if (mv <= 3300) return 0; return (int)((mv - 3300) * 100 / (full - 3300)); } // Calibrate-battery action (Device settings). SHORT_CLICKED captures the current // voltage as the new 100%; LONG_PRESSED resets to the default Li-ion curve. Same // on V4 + T-Deck. SHORT_CLICKED (not CLICKED) so a long-press to reset doesn't // also fall through and calibrate. static void calibrateBatteryCb(lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_LONG_PRESSED) { touchPrefsSetBattFullMv(0); s_batt_full_mv = 0; s_batt_full_loaded = true; if (g_lv.task) g_lv.task->showAlert(TR("Battery calibration reset to default"), 2200); return; } if (code != LV_EVENT_SHORT_CLICKED) return; // Average a short burst so one noisy ADC sample (esp. the V4 direct read) // doesn't skew the reference. uint32_t sum = 0; int n = 0; for (int i = 0; i < 8; ++i) { uint16_t s = batteryMvSampled(); if (s) { sum += s; ++n; } delay(20); } const uint16_t mv = n ? (uint16_t)(sum / n) : 0; if (mv < 3500) { // implausibly low for a "full" pack — refuse rather than store junk if (g_lv.task) g_lv.task->showAlert(TR("Battery read too low — charge fully first"), 2600); return; } touchPrefsSetBattFullMv(mv); s_batt_full_mv = mv; s_batt_full_loaded = true; char b[72]; snprintf(b, sizeof b, TR("Calibrated: 100%% = %u.%02u V (long-press to reset)"), (unsigned)(mv / 1000), (unsigned)((mv % 1000) / 10)); if (g_lv.task) g_lv.task->showAlert(b, 3000); } // ----- SD wedge detection (flag side; the probe/remount lives in sdHealthTick) ----- // A runtime SD failure is a one-way door in arduino-esp32: one timed-out command // latches STA_NOINIT (or a data-phase error leaves FR_DISK_ERR everywhere), while // SD.cardType() keeps returning the type CACHED at mount — so every // `cardType() != CARD_NONE` gate still passes, I/O fails silently, and nothing // ever re-runs the mount ladder until a reboot power-cycles the card. Any SD // user (loop task, core-0 worker, notify task) that sees SD I/O fail while the // card is supposedly mounted stamps this; UITask::loop verifies with real I/O // and remounts if the card wedged. Volatile store only — safe from any task. static volatile uint32_t s_sd_fail_note_ms = 0; static inline void sdNoteIoFailure() { const uint32_t m = (uint32_t)millis(); s_sd_fail_note_ms = m ? m : 1; } // ----- Battery history: 5-minute log + a 24h chart popup (every board) ----- // Logging piggybacks the always-on UITask::loop (no extra wakeup): every // k_batt_log_period_ms a line is appended to /meshcomod/battery.log and the file // is trimmed to the last 24h. The rewrite streams line-by-line via a temp file, // so memory cost is one short String at a time. Tapping the battery in the status // bar opens a voltage chart built from that file. // The log follows the RESOLVED ui-data backend (where chat history lives), not // bare card presence: telemetry/discover logging mkdirs /meshcomod on ANY // mounted card, which used to flip the battery log onto a card the profile // never opted into — the log and its chart then disagreed with where the rest // of the data lived. SD path lives under /meshcomod with the other files; // SPIFFS (flat) uses a top-level path. static fs::FS& battLogFs() { #if CAP_SD || defined(TLORA_PAGER) if (uiDataFsIsSdCard()) { if (SD.exists("/meshcomod")) return SD; // Backend resolved to SD but real I/O failed — wedge/removal tell. sdNoteIoFailure(); } #endif return SPIFFS; } static bool battLogOnSd() { #if CAP_SD || defined(TLORA_PAGER) return uiDataFsIsSdCard() && SD.exists("/meshcomod"); #else return false; #endif } static const char* battLogPath() { return battLogOnSd() ? "/meshcomod/battery.log" : "/battery.log"; } static const char* battLogTmp() { return battLogOnSd() ? "/meshcomod/battery.tmp" : "/battery.tmp"; } static const uint32_t k_batt_log_period_ms = 5u * 60u * 1000u; // 5 min static const uint32_t k_batt_log_keep_secs = 24u * 60u * 60u; // 24h window static lv_obj_t* s_batt_chart_root = nullptr; // Append one sample + drop anything older than 24h. Line columns (tab-separated, // human-readable): <epoch>\t<YYYY-MM-DD HH:MM>\t<millivolts>\t<percent>\t<cpuMHz> static void batteryLogAppend(uint32_t epoch, uint16_t mv, int pct, uint16_t cpu_mhz) { fs::FS& fs = battLogFs(); if (battLogOnSd()) markSdIo(); char when[20] = "----------------"; time_t t = (time_t)epoch; struct tm tmv; if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = fs.open(battLogPath(), FILE_READ); File wf = fs.open(battLogTmp(), FILE_WRITE); // truncate/create if (!wf) { if (rf) rf.close(); if (battLogOnSd()) sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); if (ln.length() == 0) continue; const uint32_t e = (uint32_t)strtoul(ln.c_str(), nullptr, 10); if (e != 0 && epoch > e && (epoch - e) > k_batt_log_keep_secs) continue; // older than 24h wf.print(ln); wf.print('\n'); } rf.close(); } wf.printf("%lu\t%s\t%u\t%d\t%u\n", (unsigned long)epoch, when, (unsigned)mv, pct, (unsigned)cpu_mhz); wf.close(); fs.remove(battLogPath()); fs.rename(battLogTmp(), battLogPath()); } // Called every UITask::loop tick; rate-limited to k_batt_log_period_ms. No new // timer — the UI loop already runs continuously on this always-on device. static void batteryLogTick(uint32_t now_ms) { static uint32_t s_next_ms = 0; if (s_next_ms != 0 && (int32_t)(now_ms - s_next_ms) < 0) return; s_next_ms = (now_ms ? now_ms : 1) + k_batt_log_period_ms; const uint16_t mv = batteryMvSmoothed(); if (mv == 0) return; // no battery reading yet batteryLogAppend((uint32_t)time(nullptr), mv, batteryPercentFromMv(mv), (uint16_t)getCpuFrequencyMhz()); } static void batteryChartClose() { if (s_batt_chart_root) { popupClose(&s_batt_chart_root); } } static void batteryChartDismissCb(lv_event_t* e) { if (lv_event_get_target(e) != s_batt_chart_root) return; // backdrop only batteryChartClose(); } static void batteryChartCloseCb(lv_event_t* e) { (void)e; batteryChartClose(); } // the X badge static void openBatteryChartWindow(); // fwd (clear reopens to show the empty chart) static void batteryLogClearConfirmed() { if (battLogOnSd()) markSdIo(); battLogFs().remove(battLogPath()); batteryChartClose(); openBatteryChartWindow(); } static void batteryClearCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; showConfirm(TR("Clear battery history?"), TR("Clear"), batteryLogClearConfirmed); } static bool s_batt_show_cpu = true; // chart: draw the CPU-MHz series (session-scoped) static void batteryCpuToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_batt_show_cpu = !s_batt_show_cpu; batteryChartClose(); openBatteryChartWindow(); // redraw immediately with the new visibility } // Reformat the battery chart's primary-Y axis ticks from raw millivolts to volts // (e.g. 4200 -> "4.2"). Secondary-Y (CPU MHz) keeps the raw number. RF-monitor // pattern: hook DRAW_PART_BEGIN and rewrite the tick label buffer. static void battChartTickCb(lv_event_t* e) { lv_obj_draw_part_dsc_t* dsc = lv_event_get_draw_part_dsc(e); if (!lv_obj_draw_part_check_type(dsc, &lv_chart_class, LV_CHART_DRAW_PART_TICK_LABEL)) return; if (dsc->text == nullptr) return; if (dsc->id == LV_CHART_AXIS_PRIMARY_Y) lv_snprintf(dsc->text, dsc->text_length, "%d.%01d", (int)(dsc->value / 1000), (int)((dsc->value % 1000) / 100)); } // Estimate remaining battery life from the logged samples. Charging samples // (mv > calibrated full) are ignored, and only the discharge segment AFTER the // most recent charge is used. A least-squares slope over that segment gives // mV/s; time-to-empty targets 3300 mV (the Li-ion empty point used by the % curve). static void batteryEstimateText(const uint32_t* eps, const uint16_t* mvs, int n, int full_mv, char* out, size_t cap) { int start = 0; for (int i = 1; i < n; ++i) { if ((int)mvs[i] > full_mv) start = i + 1; // charging spike -> resume after else if ((int)mvs[i] - (int)mvs[i - 1] >= 25) start = i; // a >=25 mV rise = charge event } double sx = 0, sy = 0, sxx = 0, sxy = 0; int m = 0; uint32_t t0 = 0, last_t = 0; int last_v = 0; for (int i = start; i < n; ++i) { if ((int)mvs[i] > full_mv) continue; // skip any charging spikes in the window if (m == 0) t0 = eps[i]; const double x = (double)(eps[i] - t0), y = (double)mvs[i]; sx += x; sy += y; sxx += x * x; sxy += x * y; ++m; last_v = mvs[i]; last_t = eps[i]; } if (m < 3 || (last_t - t0) < 900) { snprintf(out, cap, "%s", TR("Battery life: gathering data\xe2\x80\xa6")); return; } const double denom = (double)m * sxx - sx * sx; if (denom <= 0) { snprintf(out, cap, "Battery life: \xe2\x80\x94"); return; } const double slope = ((double)m * sxy - sx * sy) / denom; // mV/s (negative = discharging) if (slope >= -1e-7) { snprintf(out, cap, "Battery life: charging / steady"); return; } double secs = (double)(last_v - 3300) / (-slope); if (secs < 0) secs = 0; long s = (long)secs; const int d = (int)(s / 86400); s %= 86400; const int h = (int)(s / 3600); s %= 3600; const int mn = (int)(s / 60); snprintf(out, cap, "Est. life: %dd %dh %dm", d, h, mn); } static void openBatteryChartWindow() { if (s_batt_chart_root) return; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_batt_chart_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_batt_chart_root); lv_obj_set_size(s_batt_chart_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_batt_chart_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_batt_chart_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_batt_chart_root, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(s_batt_chart_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_batt_chart_root, batteryChartDismissCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t cardw = sw - 24; lv_obj_t* card = lv_obj_create(s_batt_chart_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, cardw, LV_SIZE_CONTENT); // grow to content... lv_obj_set_style_max_height(card, sh - STATUSBAR_H - 16, LV_PART_MAIN); // ...and scroll past the screen lv_obj_set_style_min_height(card, 120, LV_PART_MAIN); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 8); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); addCloseXBadge(card, batteryChartCloseCb); // universal top-right X lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Battery \xe2\x80\x94 last 24h")); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); // Read the log into voltage + cpu arrays (oldest..newest). The file is trimmed // to 24h on write, so reading from the start gives the right window. cpu is the // 5th column (absent in pre-upgrade lines -> 0, drawn as a gap). static const int k_max_pts = 288; // 24h / 5min // Lazy-PSRAM (frees ~2.3 KB internal DRAM .bss; allocated once, only when the chart is opened). static uint16_t* mvs = (uint16_t*)psAlloc(sizeof(uint16_t) * k_max_pts); static uint16_t* cpus = (uint16_t*)psAlloc(sizeof(uint16_t) * k_max_pts); static uint32_t* eps = (uint32_t*)psAlloc(sizeof(uint32_t) * k_max_pts); int n = 0; if (mvs && cpus && eps) { // null only under extreme OOM -> n stays 0 -> the empty-state below if (battLogOnSd()) markSdIo(); File rf = battLogFs().open(battLogPath(), FILE_READ); if (rf) { while (rf.available() && n < k_max_pts) { String ln = rf.readStringUntil('\n'); if (ln.length() == 0) continue; const int t1 = ln.indexOf('\t'); const int t2 = (t1 >= 0) ? ln.indexOf('\t', t1 + 1) : -1; const int t3 = (t2 >= 0) ? ln.indexOf('\t', t2 + 1) : -1; if (t2 < 0 || t3 < 0) continue; const int t4 = ln.indexOf('\t', t3 + 1); const int mv = ln.substring(t2 + 1, t3).toInt(); if (mv <= 0) continue; eps[n] = (uint32_t)strtoul(ln.c_str(), nullptr, 10); // first column = epoch mvs[n] = (uint16_t)mv; cpus[n] = (uint16_t)((t4 >= 0) ? ln.substring(t4 + 1).toInt() : 0); ++n; } rf.close(); } } if (n <= 0) { lv_obj_t* empty = lv_label_create(card); lv_label_set_text(empty, TR("No battery history yet.\n\nLogged every 5 minutes.")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(empty, 0, 28); return; } // Battery range: bottom 3.4 V, top = calibrated 100% (Settings). Charging can // push the rail above the calibrated full (up to ~4.7 V) — clamp those samples // to y_top so the line rides along the chart's maximum instead of vanishing. const uint16_t full_mv = batteryFullMv(); const lv_coord_t y_top = (lv_coord_t)(full_mv < 3500 ? 4200 : full_mv); const lv_coord_t y_bot = 3400; // Inset for the axis-label gutters (V left, MHz right) — RF-monitor style. const int chart_x = 30, chart_rpad = 30, chart_y = 18, chart_h = 122; lv_obj_t* chart = lv_chart_create(card); lv_obj_set_size(chart, cardw - 20 - chart_x - chart_rpad, chart_h); lv_obj_set_pos(chart, chart_x, chart_y); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, n); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, y_bot, y_top); // 3.4 V .. calibrated full (blue) lv_chart_set_div_line_count(chart, 4, 6); // helper grid: 4 horizontal, 6 vertical lv_obj_set_style_bg_color(chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); // faint grid lines lv_obj_set_style_size(chart, 4, LV_PART_INDICATOR); // visible point dots // Axis ticks + labels (RF-monitor style): primary Y reformatted to volts by the // draw callback, secondary Y shows raw MHz. Reserve a label gutter on each side. lv_obj_set_style_pad_top(chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(chart, 6, LV_PART_MAIN); lv_obj_set_style_text_font(chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_pad_left(chart, 4, LV_PART_TICKS); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 4, 1, true, 40); lv_obj_add_event_cb(chart, battChartTickCb, LV_EVENT_DRAW_PART_BEGIN, nullptr); lv_chart_series_t* vser = lv_chart_add_series(chart, lv_color_hex(0x4F9DF7), LV_CHART_AXIS_PRIMARY_Y); // blue battery // CPU MHz series is optional (toggled by the "CPU" chip next to the trash button). lv_chart_series_t* cser = nullptr; if (s_batt_show_cpu) { lv_chart_set_range(chart, LV_CHART_AXIS_SECONDARY_Y, 60, 260); // CPU MHz (orange) lv_chart_set_axis_tick(chart, LV_CHART_AXIS_SECONDARY_Y, 4, 0, 4, 1, true, 40); cser = lv_chart_add_series(chart, lv_color_hex(0x4A5256), LV_CHART_AXIS_SECONDARY_Y); // grey CPU (RF-monitor noise-floor tone) } for (int i = 0; i < n; ++i) { lv_coord_t v = (lv_coord_t)mvs[i]; if (v > y_top) v = y_top; // charging spike -> ride the top line if (v < y_bot) v = y_bot; lv_chart_set_next_value(chart, vser, v); if (cser) lv_chart_set_next_value(chart, cser, cpus[i] > 0 ? (lv_coord_t)cpus[i] : LV_CHART_POINT_NONE); } // Time labels on the X (the tick mechanism counts points, not hours). auto x_lbl = [&](const char* txt, lv_align_t al) { lv_obj_t* l = lv_label_create(card); lv_label_set_text(l, txt); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align_to(l, chart, al, 0, 2); }; x_lbl("-24h", LV_ALIGN_OUT_BOTTOM_LEFT); x_lbl("now", LV_ALIGN_OUT_BOTTOM_RIGHT); // ---- Bottom block (running y, top-down so nothing overlaps; the card scrolls // if it overflows the screen). Text capped to clear the CPU + trash buttons. ---- int by = chart_y + chart_h + 18; // below the chart + its x-axis labels const int row_y = by; // buttons ride to the right of this block const lv_coord_t btxt_w = cardw - 20 - 72; // estimate (accent headline) char est[44]; batteryEstimateText(eps, mvs, n, (int)full_mv, est, sizeof est); lv_obj_t* el = lv_label_create(card); lv_label_set_text(el, est); lv_obj_set_style_text_font(el, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(el, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_width(el, btxt_w); lv_label_set_long_mode(el, LV_LABEL_LONG_DOT); lv_obj_set_pos(el, 0, by); by += 28; // clear the 26-px CPU/trash button row before the full-width stats line // stats char sub[72]; if (s_batt_show_cpu) snprintf(sub, sizeof sub, "now %u.%02u V (full %u.%01u) CPU %u MHz", (unsigned)(mvs[n-1] / 1000), (unsigned)((mvs[n-1] % 1000) / 10), (unsigned)(y_top / 1000), (unsigned)((y_top % 1000) / 100), (unsigned)getCpuFrequencyMhz()); else snprintf(sub, sizeof sub, "now %u.%02u V (full %u.%01u)", (unsigned)(mvs[n-1] / 1000), (unsigned)((mvs[n-1] % 1000) / 10), (unsigned)(y_top / 1000), (unsigned)((y_top % 1000) / 100)); lv_obj_t* sl = lv_label_create(card); lv_label_set_text(sl, sub); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(sl, cardw - 20); // full width — its row has no buttons, so "MHz" never wraps lv_label_set_long_mode(sl, LV_LABEL_LONG_DOT); lv_obj_set_pos(sl, 0, by); by += 18; // points count lv_obj_t* pl = lv_label_create(card); lv_label_set_text_fmt(pl, "%d pts", n); lv_obj_set_style_text_font(pl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(pl, 0, by); by += 18; // Clear the battery history (with confirmation) — to the right of the text block. lv_obj_t* clr = lv_btn_create(card); lv_obj_set_size(clr, 30, 26); lv_obj_align(clr, LV_ALIGN_TOP_RIGHT, 0, row_y); lv_obj_set_style_bg_color(clr, lv_color_hex(0xB23A48), LV_PART_MAIN); lv_obj_add_event_cb(clr, batteryClearCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* clrl = lv_label_create(clr); lv_label_set_text(clrl, LV_SYMBOL_TRASH); lv_obj_center(clrl); useChainedFont(clrl); // Show/hide the CPU-MHz series (same size/style as the trash button, to its left). lv_obj_t* cpub = lv_btn_create(card); lv_obj_set_size(cpub, 30, 26); lv_obj_align(cpub, LV_ALIGN_TOP_RIGHT, -36, row_y); lv_obj_set_style_pad_all(cpub, 0, LV_PART_MAIN); lv_obj_set_style_bg_color(cpub, lv_color_hex(s_batt_show_cpu ? 0x4A5256 : 0x3A3D40), LV_PART_MAIN); lv_obj_add_event_cb(cpub, batteryCpuToggleCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cpul = lv_label_create(cpub); lv_label_set_text(cpul, "CPU"); lv_obj_set_style_text_font(cpul, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(cpul, lv_color_hex(s_batt_show_cpu ? 0xFFFFFF : COLOR_SUB), LV_PART_MAIN); lv_obj_center(cpul); // ---- Idle power-save stats (snapshot mirror of the Lock-settings diag; the // chart itself is a snapshot too, so this refreshes each time the window opens). // Same two lines as refreshSleepDiag(): state / wakes / % asleep, then last-wake. ---- by += 8; lv_obj_t* slph = lv_label_create(card); lv_label_set_text(slph, TR("Idle power-save")); lv_obj_set_style_text_font(slph, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(slph, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(slph, 0, by); by += 18; char slp1[64]; snprintf(slp1, sizeof slp1, "%s \xc2\xb7 cycles %lu \xc2\xb7 asleep %u%%", touchSleep::isSleeping() ? "sleeping" : "awake", (unsigned long)touchSleep::wakeCount(), (unsigned)touchSleep::pctAsleep()); lv_obj_t* slp1l = lv_label_create(card); lv_label_set_text(slp1l, slp1); lv_obj_set_style_text_font(slp1l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(slp1l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(slp1l, cardw - 20); lv_label_set_long_mode(slp1l, LV_LABEL_LONG_WRAP); lv_obj_set_pos(slp1l, 0, by); by += 18; lv_obj_t* slp2l = lv_label_create(card); lv_label_set_text_fmt(slp2l, "%s%s", TR("last wake: "), tsWakeReasonStr(touchSleep::lastWakeReason())); lv_obj_set_style_text_font(slp2l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(slp2l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(slp2l, 0, by); by += 18; } static void batteryTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openBatteryChartWindow(); } #if CAP_SD || defined(TLORA_PAGER) // ----- Per-node telemetry log (/meshcomod/telemetry/<id>.log) ----- // One file per node (6-byte pubkey prefix, hex). Columns (tab-separated): // epoch \t YYYY-MM-DD HH:MM \t battery_mv \t tempC*10 \t humidity% // Missing sensors use sentinels (mv=0, t10=-30000, hum=-1) -> drawn as gaps. // Trimmed to a 7-day window via a streaming temp-file rewrite (like battery.log). // SD-backed (T-Deck only); telemetry on V4 stays request/reply-only (a toast). static const uint32_t k_telem_keep_secs = 7u * 24u * 60u * 60u; static void telemetryNodePath(const uint8_t* key, char* out, size_t cap) { snprintf(out, cap, "/meshcomod/telemetry/%02X%02X%02X%02X%02X%02X.log", key[0], key[1], key[2], key[3], key[4], key[5]); } static void telemetryLogAppend(const uint8_t* key, uint32_t epoch, int mv, int t10, int hum) { if (SD.cardType() == CARD_NONE) return; markSdIo(); SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/telemetry"); char path[48]; telemetryNodePath(key, path, sizeof path); const char* tmp = "/meshcomod/telemetry/_t.tmp"; char when[20] = "----------------"; time_t t = (time_t)epoch; struct tm tmv; if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = SD.open(path, FILE_READ); File wf = SD.open(tmp, FILE_WRITE); if (!wf) { if (rf) rf.close(); sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); if (ln.length() == 0) continue; const uint32_t e = (uint32_t)strtoul(ln.c_str(), nullptr, 10); if (e != 0 && epoch > e && (epoch - e) > k_telem_keep_secs) continue; // older than 7d wf.print(ln); wf.print('\n'); } rf.close(); } wf.printf("%lu\t%s\t%d\t%d\t%d\n", (unsigned long)epoch, when, mv, t10, hum); wf.close(); SD.remove(path); SD.rename(tmp, path); } #endif // HAS_TDECK_GT911 static void refreshHomeBattery() { if (!g_lv.task) return; uint16_t mv = batteryMvSmoothed(); const bool charging = batteryIsCharging(mv); // Icon: update only when the glyph actually changes (avoids hammering // lv_label_set_text every tick which froze the loop with multi-byte // FontAwesome symbols). static const char* s_last_glyph = nullptr; if (s_home_batt_icon) { const char* g = batteryGlyphForMv(mv); if (g != s_last_glyph) { lv_label_set_text(s_home_batt_icon, g); s_last_glyph = g; } } // Percent: ASCII-only text. Cache last pct so we don't churn the label. if (s_home_batt_pct) { static int s_last_pct = -9999; static bool s_last_chg = false; int pct = batteryPercentFromMv(mv); if (pct != s_last_pct || charging != s_last_chg) { char buf[12]; if (pct < 0) snprintf(buf, sizeof(buf), "?"); else if (charging) snprintf(buf, sizeof(buf), "CHG"); else snprintf(buf, sizeof(buf), "%d%%", pct); lv_label_set_text(s_home_batt_pct, buf); s_last_pct = pct; s_last_chg = charging; } } // Live clock from the RTC (NTP / mesh-synced). Honors the 12/24-hour pref via // fmtClockHM. Dedup on the rendered STRING (not the minute) so flipping the // 12-hour toggle re-renders on the next tick, and only touch the label on change. if (s_home_clock) { static char s_last_clock[12] = {0}; #if defined(ESP32) time_t now_t = time(nullptr); if (now_t > 1700000000) { // ~ "RTC has been bootstrapped" sentinel struct tm tm_loc; localtime_r(&now_t, &tm_loc); char buf[12]; fmtClockHM(buf, sizeof(buf), &tm_loc); if (strcmp(buf, s_last_clock) != 0) { lv_label_set_text(s_home_clock, buf); strncpy(s_last_clock, buf, sizeof(s_last_clock) - 1); } } #endif } } // Long-press the home battery glyph → details popup. static void homeBatteryLongPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED || !g_lv.task) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); const uint16_t mv = g_lv.task->getBattMilliVolts(); const int pct = batteryPercentFromMv(mv); // Rough charging heuristic: voltage above 4.10 V on this board usually // means USB power is in. const char* state = (mv == 0) ? "no reading" : (mv >= 4100) ? "charging / USB" : (pct < 15) ? "low" : "discharging"; char msg[120]; if (pct < 0) { snprintf(msg, sizeof(msg), "Battery\n %u mV\n %s", (unsigned)mv, state); } else { snprintf(msg, sizeof(msg), "Battery\n %u mV (%d%%)\n %s", (unsigned)mv, pct, state); } g_lv.task->showAlert(msg, 3500); } // Reason the last downscaling JPEG decode failed (shown in the UI). File-scope + // UNGUARDED: the decoder lives outside the HAS_TDECK_GT911 block below, while the // file-manager viewer (inside it) also reads it. static char s_jpgScaleErr[52] = {0}; #if defined(HAS_TOUCH_UI) // ---- Full-screen tool views (Mesh console / File explorer) ---- // Compiled on every touch board so the mesh console is available everywhere. // (Tanmatsu shares this whole block; its SD-mount paths are kept HAS_TDECK_GT911-only and the // file browser uses the internal FFat 'locfd' partition instead — see fmIsSd / fmInternalClickCb.) // A fullscreen overlay below the status bar, with a Home button that returns to // the Home tab. Body is returned so the (future) content can be added into it. static lv_obj_t* s_fullscreen_view = nullptr; static char s_fullscreen_title[40] = {0}; // shown in the status bar while open // ===== On-device MeshCore terminal ========================================= // A real CLI that runs commands on THIS node (the_mesh.runLocalCli) and streams // every reply line into a scrollable log. Output is captured via // MyMesh::setTerminalSink. Output runs on the main loop task (main.cpp calls // ui_task.loop() and the_mesh.loop() sequentially, never concurrently with the // LVGL render), so each line is built as its own coloured child label here. static lv_obj_t* s_term_log_box = nullptr; // scrollable flex-column of line labels static lv_obj_t* s_term_input_ta = nullptr; static lv_obj_t* s_term_picker_root = nullptr; static const uint32_t TERM_MAX_LINES = 150; // prune oldest lines past this // Current terminal recipient, set by `to <name>` (a DM contact OR a channel). static bool s_term_to_set = false; static bool s_term_to_is_channel = false; static uint8_t s_term_to_pub[32] = {0}; static int16_t s_term_to_chan_slot = -1; static char s_term_to_name[40] = {0}; // ===== File manager ========================================================= static lv_obj_t* s_fm_list = nullptr; // scrollable list of entries static lv_obj_t* s_fm_path_lbl = nullptr; // address-bar / current location static lv_obj_t* s_fm_search_ta = nullptr; // inline search field (when active) static lv_obj_t* s_fm_sort_lbl = nullptr; // sort button label (shows current mode) static fs::FS* s_fm_fs = nullptr; // current filesystem (nullptr = roots screen) static char s_fm_path[160] = {0}; // current dir within s_fm_fs (e.g. "/" or "/foo") // Light the status-bar SD LED on any file-manager microSD access. The fm runs on // a generic fs::FS*; only &SD is real microSD I/O (Internal = SPIFFS). Browsing // (fmRefresh) and the file open/save paths call this; mutations re-list via // fmRefresh, so they blip the LED too. #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // Arduino SD (T-Deck/pager/M9 LoRa bus, V4-R8 TFT bus) #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD_MMC; } // microSD on SDMMC slot 0 #else static inline bool fmIsSd(fs::FS*) { return false; } // Heltec: no Arduino SD global in this FM path #endif static inline void fmMarkSdIo() { if (fmIsSd(s_fm_fs)) markSdIo(); } #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // Arduino loop-task WDT shim: the P4 IDF builds (Tanmatsu + T-Display P4) don't expose feedLoopWDT // (the long file-copy loop calls it). The IDF task WDT isn't armed on our LVGL loop, so a no-op is safe. static inline void feedLoopWDT() {} #endif static char s_fm_store[12] = {0}; // storage label ("Internal" / "SD") static char s_fm_filter[40] = {0}; // active search filter (empty = none) static uint8_t s_fm_sort = 0; // 0 Name A-Z, 1 Z-A, 2 Size, 3 Type struct FmEntry { char name[64]; uint32_t size; uint32_t mtime; bool isdir; }; // mtime: epoch secs, 0 = unknown (#185) static const int FM_MAX_ENTRIES = 192; static FmEntry* s_fm_entries = nullptr; // PSRAM-allocated while the file manager is open static int s_fm_count = 0; static bool s_fm_show_hidden = false; // reveal MeshCore system files on flat SPIFFS (toggle in + menu) static lv_obj_t* s_editor_root = nullptr; // text editor overlay static lv_obj_t* s_editor_ta = nullptr; static char s_editor_path[200] = {0}; static lv_obj_t* s_fm_img_root = nullptr; // read-only image viewer overlay static uint8_t* s_fm_img_buf = nullptr; // decoded RGB565 (PSRAM); freed on close static lv_img_dsc_t s_fm_img_dsc; // wraps s_fm_img_buf for the lv_img widget static lv_obj_t* s_fm_img_widget = nullptr; // the scaled lv_img inside the viewer static lv_obj_t* s_fm_img_hdr = nullptr; // filename/size label (windowed only) static lv_obj_t* s_fm_img_close = nullptr; // close (X) button (windowed only) static lv_obj_t* s_fm_img_full = nullptr; // full-screen toggle button (windowed only) static lv_obj_t* s_fm_img_hint = nullptr; // "tap to exit" hint (full-screen only) static int s_fm_img_w = 0, s_fm_img_h = 0; // decoded image dimensions static bool s_fm_img_fs = false; // viewer is in full-screen mode static bool s_sd_mounted = false; // microSD mount state static uint64_t s_sd_size = 0; // card capacity (bytes) static unsigned long s_sd_retry_after_ms = 0; // don't re-probe SD before this (backoff) #if defined(TLORA_PAGER) static uint32_t s_sd_data_warn_next_ms = 0; static inline uint32_t sdDataWarnDeadline(uint32_t now) { const uint32_t next = now + 60000; return next ? next : 1; } static inline void sdPagerParkCs() { pinMode(PIN_SD_CS, OUTPUT); digitalWrite(PIN_SD_CS, HIGH); } static const char* sdRemovedAlertText() { extern bool g_contacts_on_sd; return g_contacts_on_sd ? TR("SD removed - card-backed data cannot be saved") : TR("SD card removed"); } #endif static int s_sd_format_pending = 0; // deferred FAT32 format (runs after notice paints) static lv_obj_t* s_fm_fmt_overlay = nullptr; // full-screen "formatting..." notice static lv_obj_t* s_fm_actions = nullptr; // per-entry action sheet static lv_obj_t* s_fm_prompt = nullptr; // text-input modal (rename / new folder) static lv_obj_t* s_fm_prompt_ta = nullptr; static void (*s_fm_prompt_cb)(const char*) = nullptr; static char s_fm_sel_name[64] = {0}; // entry the action sheet targets static bool s_fm_sel_isdir = false; static int s_fm_paste_pending = 0; // deferred copy/move (runs after notice paints) // Copy/Cut clipboard. static struct { fs::FS* fs; char path[200]; char name[64]; bool isdir; bool is_cut; bool active; } s_fm_clip = {}; static const char* k_term_banner = "WADAMESH terminal - CLI + chat on this node.\n" "Type a command or tap the list icon for the picker.\n" "Chat: 'to <name>' joins a contact/channel, then just\n" " type to send. 'exit' leaves. list / channels.\n" " All incoming msgs print live.\n" "Config: ver clock status get advert reboot\n" " set <name|freq|bw|sf|cr|tx> <value>\n" "MeshCom: wifi status, tcp status, ble status,\n" " ota status, tcp on/off, ble on/off.\n"; // Quick-pick catalogue (mirrors the repeater admin picker). Commands here are // the ones MyMesh::handleMeshcomodCommand understands natively when run // locally. Tapping a row stuffs the template into the input; "set ..." rows // carry a trailing space so the value can be typed straight after. static const AdminCmdEntry k_term_cmds[] = { { "[ CHAT ]", nullptr }, { "list - list contacts", "list" }, { "channels - list channels", "channels" }, { "to <name> - join contact/chan", "to " }, { "send <text> - msg recipient", "send " }, { "public <text> - public channel", "public " }, { "exit - leave current chat", "exit" }, { "[ INFO ]", nullptr }, { "help - command list", "help" }, { "ver - firmware version", "ver" }, { "clock - show RTC time", "clock" }, { "status - device status", "status" }, { "get - show radio params", "get" }, { "[ RADIO / MESH ]", nullptr }, { "advert - flood advert", "advert" }, { "advert.zerohop - 0-hop advert", "advert.zerohop" }, { "set name <new>", "set name " }, { "set freq <MHz>", "set freq " }, { "set bw <kHz>", "set bw " }, { "set sf <7-12>", "set sf " }, { "set cr <5-8>", "set cr " }, { "set tx <dBm>", "set tx " }, { "[ CONNECTIVITY ]", nullptr }, { "wifi status", "wifi status" }, { "wifi on", "wifi on" }, { "wifi off", "wifi off" }, { "wifi scan", "wifi scan" }, { "wifi set ssid <v>", "wifi set ssid " }, { "wifi set pwd <v>", "wifi set pwd " }, { "wifi apply - connect now", "wifi apply" }, { "wifi clear - forget creds", "wifi clear" }, { "tcp status", "tcp status" }, { "tcp on", "tcp on" }, { "tcp off", "tcp off" }, { "ble status", "ble status" }, { "ble on", "ble on" }, { "ble off", "ble off" }, { "ota status", "ota status" }, { "ota start", "ota start" }, { "ota url <https://...bin>", "ota url " }, { "ota netdiag - net check", "ota netdiag" }, { "[ SYSTEM ]", nullptr }, { "reboot", "reboot" }, { "bootloader - download mode", "bootloader" }, }; constexpr int k_term_cmds_count = sizeof(k_term_cmds) / sizeof(k_term_cmds[0]); // Per-line colors (0xRRGGBB, applied as the label's real text color). static const uint32_t TERM_C_INPUT = 0x7fe0ff; // "> cmd" echo (cyan) static const uint32_t TERM_C_TX = 0x63d863; // your sent messages (green) static const uint32_t TERM_C_RX_DM = 0x4fc3f7; // incoming DM (light blue) static const uint32_t TERM_C_RX_CH = 0xffcc66; // incoming channel msg (amber) static const uint32_t TERM_C_ERR = 0xff6b6b; // errors (red) static const uint32_t TERM_C_INFO = 0x9aa7b0; // info / status (gray) static const uint32_t TERM_C_REPLY = 0xcfd6dc; // config-CLI replies (light) static const uint32_t TERM_C_BANNER = 0x7f8c99; // opening banner (dim) // Append a line to the terminal as its own coloured label (a child of the // scrollable flex-column box). No recolor markup, so message text can contain // any character. Safe to build LVGL objects here: all callers run on the main // loop task (see the s_term_log_box note above). Oldest lines prune past // TERM_MAX_LINES; the view always scrolls to the newest line. static void termLogAppendC(uint32_t color, const char* prefix, const char* text) { #if !defined(HAS_TANMATSU) // Mirror every terminal line to the web mesh terminal too (before the on-device box // null-check, so it works headless when the console isn't open). One funnel = full parity. if (text && g_web_mirror.terminalOn()) { if (prefix) g_web_mirror.pushTermReply(prefix); g_web_mirror.pushTermReply(text); g_web_mirror.pushTermReply("\n"); } #endif if (!s_term_log_box || !text) return; char buf[512]; snprintf(buf, sizeof buf, "%s%s", prefix ? prefix : "", text); lv_obj_t* lbl = lv_label_create(s_term_log_box); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, lv_pct(100)); lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_label_set_text(lbl, buf); uint32_t n = lv_obj_get_child_cnt(s_term_log_box); while (n > TERM_MAX_LINES) { lv_obj_del(lv_obj_get_child(s_term_log_box, 0)); --n; } lv_obj_scroll_to_y(s_term_log_box, LV_COORD_MAX, LV_ANIM_OFF); } // Default-coloured append (config-CLI replies / uncategorised lines). static void termLogAppend(const char* prefix, const char* text) { termLogAppendC(TERM_C_REPLY, prefix, text); } // Output sink registered with MyMesh while the terminal is open. static void terminalSink(const char* line) { if (line) termLogAppend(nullptr, line); } static void closeTermCmdPicker() { if (s_term_picker_root) { popupClose(&s_term_picker_root); } } static void fmImageClose(); // fwd — defined in the file-manager section static void closeFullscreenView() { closeTermCmdPicker(); // Detach the sink + null the widgets BEFORE the async delete so a late reply // (or the loop renderer) can't write into a freed label/box. MyMesh::setTerminalSink(nullptr); const bool had_kb = (s_term_input_ta || s_fm_search_ta || s_fm_prompt_ta); if (s_editor_root) { if (g_lv.keyboard) lv_keyboard_set_textarea(g_lv.keyboard, nullptr); popupClose(&s_editor_root); s_editor_ta = nullptr; } fmImageClose(); // tear down the image viewer + free its PSRAM buffer (sync del) if (s_fm_actions) { popupClose(&s_fm_actions); } if (s_fm_prompt) { popupClose(&s_fm_prompt); } s_term_log_box = nullptr; s_term_input_ta = nullptr; s_fm_list = nullptr; // file-manager widgets live in the same body s_fm_path_lbl = nullptr; s_fm_search_ta = nullptr; s_fm_sort_lbl = nullptr; s_fm_prompt_ta = nullptr; s_fm_fs = nullptr; s_fm_filter[0] = '\0'; if (s_fm_entries) { free(s_fm_entries); s_fm_entries = nullptr; } // release the PSRAM cache s_fm_count = 0; if (had_kb) hideKb(); // unbind the keyboard mirror from the (soon-freed) input/field if (s_fullscreen_view) { popupClose(&s_fullscreen_view); } s_fullscreen_title[0] = '\0'; updateGlobalStatusBar(); // restore MESHCOMOD / unread in the status bar } static void fullscreenHomeCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeFullscreenView(); if (g_lv.tabview) lv_tabview_set_act(g_lv.tabview, HOME_TAB_INDEX, LV_ANIM_OFF); // back to Home } static lv_obj_t* openFullscreenView(const char* title) { closeFullscreenView(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fullscreen_view = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fullscreen_view); lv_obj_set_size(s_fullscreen_view, sw, sh - STATUSBAR_H); // keep the status bar visible lv_obj_set_pos(s_fullscreen_view, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fullscreen_view, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fullscreen_view, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_fullscreen_view, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_fullscreen_view); // Show the view name in the status bar's left zone (where MESHCOMOD sits) // rather than a dedicated title row — that reclaims the vertical space. strncpy(s_fullscreen_title, title ? title : "", sizeof(s_fullscreen_title) - 1); s_fullscreen_title[sizeof(s_fullscreen_title) - 1] = '\0'; updateGlobalStatusBar(); // Body fills the whole view (no header row). lv_obj_t* body = lv_obj_create(s_fullscreen_view); lv_obj_remove_style_all(body); lv_obj_set_size(body, sw, sh - STATUSBAR_H); lv_obj_set_pos(body, 0, 0); lv_obj_set_style_pad_all(body, 6, LV_PART_MAIN); // Home button floats as a small overlay over the top-right of the body. lv_obj_t* home = lv_btn_create(s_fullscreen_view); lv_obj_set_size(home, 40, 28); lv_obj_align(home, LV_ALIGN_TOP_RIGHT, -6, 4); styleButton(home); lv_obj_add_event_cb(home, fullscreenHomeCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* hl = lv_label_create(home); useChainedFont(hl); #if defined(HAS_TANMATSU) lv_label_set_text(hl, LV_SYMBOL_CLOSE); // red ✕ close lv_obj_set_style_text_color(hl, lv_color_hex(0xE05544), LV_PART_MAIN); #else lv_label_set_text(hl, LV_SYMBOL_HOME); #endif lv_obj_center(hl); lv_obj_move_foreground(home); return body; } // ---- meshcore-cli-style chat commands (to / send / public / list / channels) ---- // Transmit `text` to a DM contact (is_channel=false) or a channel slot. Reuses // the same the_mesh send primitives the Chats composer uses; echoes a TX line. static void termDoSend(bool is_channel, const uint8_t* pub, int16_t chan_slot, const char* disp, const char* text) { static uint32_t s_last_term_tx_ts = 0; static uint8_t s_term_attempt = 4; const char* sender = the_mesh.getNodePrefs()->node_name; if (!sender || !sender[0]) sender = "me"; size_t tlen = strlen(text); if (tlen > MAX_TEXT_LEN) tlen = MAX_TEXT_LEN; char body[MAX_TEXT_LEN + 1]; memcpy(body, text, tlen); body[tlen] = '\0'; uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique(); if (ts <= s_last_term_tx_ts) ts = s_last_term_tx_ts + 1; s_last_term_tx_ts = ts; char r[MAX_TEXT_LEN + 48]; if (is_channel) { ChannelDetails cd; if (chan_slot < 0 || !the_mesh.getChannel(chan_slot, cd) || !cd.name[0]) { termLogAppendC(TERM_C_ERR, nullptr, "channel not found"); return; } if (!the_mesh.sendGroupMessage(ts, cd.channel, sender, body, (int)strlen(body))) { termLogAppendC(TERM_C_ERR, nullptr, "send failed"); return; } snprintf(r, sizeof r, "TX [%s] %s", disp, body); termLogAppendC(TERM_C_TX, nullptr, r); // Mirror into the on-device chat ring so a terminal / web-chat send shows in the // channel thread too (same as a companion-app send via appSentMsgToChannel). Pass the // sent fingerprint so repeats-heard shows on the mirrored bubble like a composer send. if (g_lv.task) g_lv.task->appSentMsgToChannel(cd.name, body, the_mesh.uiLastSentFp()); } else { ContactInfo* by = the_mesh.lookupContactByPubKey((uint8_t*)pub, PUB_KEY_SIZE); if (!by) { termLogAppendC(TERM_C_ERR, nullptr, "contact missing"); return; } ContactInfo rcpt = *by; rcpt.out_path_len = OUT_PATH_UNKNOWN; // force flood routing (matches Chats TX) uint8_t attempt = s_term_attempt++; if (attempt < 4) { attempt = 4; s_term_attempt = 5; } uint32_t expected_ack = 0, est_timeout = 0; int rr = the_mesh.sendMessage(rcpt, ts, attempt, body, expected_ack, est_timeout); if (rr == MSG_SEND_FAILED) { termLogAppendC(TERM_C_ERR, nullptr, "send failed"); return; } the_mesh.uiRegisterExpectedAck(expected_ack, by->id.pub_key); snprintf(r, sizeof r, "TX -> %s: %s", disp, body); termLogAppendC(TERM_C_TX, nullptr, r); // Mirror into the DM thread + map it to the contact pubkey (delivery state tracks the ack, // sent_fp tracks repeats-heard) — same metadata a composer send records. if (g_lv.task) g_lv.task->appSentMsgToContact(by->id.pub_key, disp, body, expected_ack, the_mesh.uiLastSentFp()); } } // `to <name>`: resolve a name to a DM contact (by name prefix) or a channel // (by name), and remember it as the current recipient. static void termCmdTo(const char* arg) { while (*arg == ' ' || *arg == '\t') ++arg; if (!*arg) { if (s_term_to_set) { char r[64]; snprintf(r, sizeof r, "recipient: %s%s", s_term_to_is_channel ? "[chan] " : "", s_term_to_name); termLogAppendC(TERM_C_INFO, nullptr, r); } else { termLogAppendC(TERM_C_INFO, nullptr, "no recipient set - 'to <name>'"); } return; } size_t alen = strlen(arg); int nc = the_mesh.getNumContacts(); for (int i = 0; i < nc; ++i) { ContactInfo c{}; if (the_mesh.getContactByIdx((uint32_t)i, c) && c.name[0] && strncasecmp(c.name, arg, alen) == 0) { memcpy(s_term_to_pub, c.id.pub_key, 32); s_term_to_is_channel = false; s_term_to_set = true; strncpy(s_term_to_name, c.name, sizeof(s_term_to_name) - 1); s_term_to_name[sizeof(s_term_to_name) - 1] = '\0'; char r[64]; snprintf(r, sizeof r, TR("now talking to %s"), s_term_to_name); termLogAppendC(TERM_C_INFO, nullptr, r); termLogAppendC(TERM_C_INFO, nullptr, "(type to send; 'exit' leaves)"); return; } } for (int s = 0; s < MAX_GROUP_CHANNELS; ++s) { ChannelDetails cd; if (the_mesh.getChannel(s, cd) && cd.name[0] && strncasecmp(cd.name, arg, alen) == 0) { s_term_to_chan_slot = (int16_t)s; s_term_to_is_channel = true; s_term_to_set = true; strncpy(s_term_to_name, cd.name, sizeof(s_term_to_name) - 1); s_term_to_name[sizeof(s_term_to_name) - 1] = '\0'; char r[64]; snprintf(r, sizeof r, TR("now on channel %s"), s_term_to_name); termLogAppendC(TERM_C_INFO, nullptr, r); termLogAppendC(TERM_C_INFO, nullptr, "(type to send; 'exit' leaves)"); return; } } char r[80]; snprintf(r, sizeof r, "no contact/channel matching '%s'", arg); termLogAppendC(TERM_C_ERR, nullptr, r); } static void termCmdList() { int nc = the_mesh.getNumContacts(); char hdr[40]; snprintf(hdr, sizeof hdr, TR("contacts (%d):"), nc); termLogAppendC(TERM_C_INFO, nullptr, hdr); int shown = 0; for (int i = 0; i < nc && shown < 60; ++i) { ContactInfo c{}; if (the_mesh.getContactByIdx((uint32_t)i, c) && c.name[0]) { termLogAppend(" ", c.name); ++shown; } } } static void termCmdChannels() { termLogAppendC(TERM_C_INFO, nullptr, "channels:"); for (int s = 0; s < MAX_GROUP_CHANNELS; ++s) { ChannelDetails cd; if (the_mesh.getChannel(s, cd) && cd.name[0]) { char line[48]; snprintf(line, sizeof line, " [%d] %s", s, cd.name); termLogAppend(nullptr, line); } } } static void termCmdSend(const char* text) { while (*text == ' ' || *text == '\t') ++text; if (!*text) { termLogAppendC(TERM_C_ERR, nullptr, "usage: send <text>"); return; } if (!s_term_to_set) { termLogAppendC(TERM_C_ERR, nullptr, "no recipient - use 'to <name>' first"); return; } termDoSend(s_term_to_is_channel, s_term_to_pub, s_term_to_chan_slot, s_term_to_name, text); } static void termCmdPublic(const char* text) { while (*text == ' ' || *text == '\t') ++text; if (!*text) { termLogAppendC(TERM_C_ERR, nullptr, "usage: public <text>"); return; } ChannelDetails cd; if (!the_mesh.getChannel(0, cd) || !cd.name[0]) { termLogAppendC(TERM_C_ERR, nullptr, "no public channel"); return; } termDoSend(true, nullptr, 0, cd.name, text); } // `exit` / `leave`: drop the current recipient so bare text is a command again. static void termCmdExit() { if (!s_term_to_set) { termLogAppendC(TERM_C_INFO, nullptr, "not in a chat"); return; } char r[64]; snprintf(r, sizeof r, "left %s", s_term_to_name); s_term_to_set = false; s_term_to_is_channel = false; s_term_to_chan_slot = -1; s_term_to_name[0] = '\0'; termLogAppendC(TERM_C_INFO, nullptr, r); } // Is `word` (length `len`) one of the local config-CLI keywords? Used so that // while we're "in" a chat, typed config commands still run instead of being // sent as a message — only non-keyword lines are treated as chat text. static bool termIsCliKeyword(const char* word, size_t len) { static const char* kw[] = { "help", "ver", "version", "clock", "time", "status", "get", "set", "advert", "advert.zerohop", "reboot", "wifi", "tcp", "ble", "ota", "ok", "cancel", }; for (const char* k : kw) { if (strlen(k) == len && strncasecmp(word, k, len) == 0) return true; } return false; } // Returns true if `cmd` was handled as a chat command or chat message; false // lets it fall through to the local config CLI (the_mesh.runLocalCli). static bool terminalRunChatCommand(const char* cmd) { while (*cmd == ' ' || *cmd == '\t') ++cmd; auto is = [](const char* s, const char* name, const char** rest) -> bool { size_t n = strlen(name); if (strncasecmp(s, name, n) == 0 && (s[n] == '\0' || s[n] == ' ' || s[n] == '\t')) { if (rest) { const char* r = s + n; while (*r == ' ' || *r == '\t') ++r; *rest = r; } return true; } return false; }; const char* rest = nullptr; if (is(cmd, "help", &rest) || is(cmd, "?", &rest)) { // prepend the messaging commands, then fall through to the config-CLI help termLogAppendC(TERM_C_INFO, nullptr, "messaging:"); termLogAppend(" ", "list / contacts - list your contacts"); termLogAppend(" ", "channels - list channels"); termLogAppend(" ", "to <name> - talk to a contact or channel"); termLogAppend(" ", "send <text> - send to the current recipient"); termLogAppend(" ", "public <text> - send on the public channel"); termLogAppend(" ", "exit / leave - stop talking to that recipient"); termLogAppend(" ", "(after 'to', a bare line is sent as a message)"); termLogAppendC(TERM_C_INFO, nullptr, "node / config:"); return false; // config-CLI help is appended by runLocalCli } if (is(cmd, "list", &rest) || is(cmd, "contacts", &rest)) { termCmdList(); return true; } if (is(cmd, "channels", &rest)) { termCmdChannels(); return true; } if (is(cmd, "to", &rest)) { termCmdTo(rest); return true; } if (is(cmd, "exit", &rest) || is(cmd, "leave", &rest)) { termCmdExit(); return true; } if (is(cmd, "send", &rest)) { termCmdSend(rest); return true; } if (is(cmd, "public", &rest)) { termCmdPublic(rest); return true; } // Room mode: once a recipient is selected, a line whose first word isn't a // config-CLI keyword is sent straight to that recipient as a chat message. // (To send text that starts with a keyword, use `send <text>`.) if (s_term_to_set && *cmd) { size_t wl = 0; while (cmd[wl] && cmd[wl] != ' ' && cmd[wl] != '\t') ++wl; if (!termIsCliKeyword(cmd, wl)) { termDoSend(s_term_to_is_channel, s_term_to_pub, s_term_to_chan_slot, s_term_to_name, cmd); return true; } } return false; } // Run whatever is in the input: chat commands handled here, everything else // falls through to the local config CLI. Replies stream back via terminalSink; // we just echo the prompt line and clear the field. static void terminalSubmit() { if (!s_term_input_ta) return; kbMirrorSyncToReal(); // pull the latest text out of the keyboard mirror const char* text = lv_textarea_get_text(s_term_input_ta); if (!text || !text[0]) return; char cmd[128]; strncpy(cmd, text, sizeof(cmd) - 1); cmd[sizeof(cmd) - 1] = '\0'; termLogAppendC(TERM_C_INPUT, "> ", cmd); if (!terminalRunChatCommand(cmd)) the_mesh.runLocalCli(cmd); lv_textarea_set_text(s_term_input_ta, ""); // Re-bind so the cleared mirror tracks the field and the next Enter submits. if (g_lv.keyboard) kbMirrorBind(s_term_input_ta); } // ============================================================================ // Web Contacts + Chats data API — JSON over the /term WS (browser->device '@' // commands, device->browser framed JSON via g_web_mirror.pushTermData). Runs on // the UI loop, single-threaded, so a static PSRAM scratch buffer is safe. // ============================================================================ #if !defined(HAS_TANMATSU) static char* s_webdata_buf = nullptr; static const size_t WEBDATA_BUF = 16000; static volatile bool s_web_rx_nudge = false; // set by mesh callbacks; the UI loop does the actual pushTermData (keeps it single-producer) static void jsonEsc(char*& p, const char* e, const char* s) { for (; s && *s && p < e - 8; ++s) { unsigned char c = (unsigned char)*s; if (c == '"' || c == '\\') { *p++ = '\\'; *p++ = (char)c; } else if (c == '\n') { *p++ = '\\'; *p++ = 'n'; } else if (c == '\r') { *p++ = '\\'; *p++ = 'r'; } else if (c == '\t') { *p++ = '\\'; *p++ = 't'; } else if (c < 0x20) { p += snprintf(p, (size_t)(e - p), "\\u%04x", c); } else { *p++ = (char)c; } } } // UI thread index whose DM contact pubkey matches (-1 if none). static int webThreadForPub(const uint8_t pub[32]) { if (!g_lv.task || !pub) return -1; int idx[UITask::MAX_UI_THREADS]; int n = g_lv.task->getCombinedInboxCount(idx, UITask::MAX_UI_THREADS); for (int k = 0; k < n; ++k) { uint8_t tp[32]; if (g_lv.task->getThreadContactPub(idx[k], tp) && memcmp(tp, pub, 32) == 0) return idx[k]; } return -1; } // UI thread index for a group-channel slot (-1 if that channel has no thread yet). static int webThreadForChannel(int slot) { if (!g_lv.task) return -1; int idx[UITask::MAX_UI_THREADS]; int n = g_lv.task->getCombinedInboxCount(idx, UITask::MAX_UI_THREADS); for (int k = 0; k < n; ++k) { bool ch = false; uint16_t u = 0; uint32_t ts = 0; char nm[48]; if (g_lv.task->getThreadInfo(idx[k], ch, u, ts, nm, sizeof nm) && ch && g_lv.task->threadMeshChannelSlot(idx[k]) == slot) return idx[k]; } return -1; } static void webPushMessages(int tidx) { if (!g_lv.task || !s_webdata_buf || tidx < 0) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; bool ch = false; uint16_t unread = 0; uint32_t ts = 0; char name[48] = {0}; g_lv.task->getThreadInfo(tidx, ch, unread, ts, name, sizeof name); p += snprintf(p, e - p, "{\"t\":\"m\",\"i\":%d,\"ch\":%d,\"n\":\"", tidx, ch ? 1 : 0); jsonEsc(p, e, name); p += snprintf(p, e - p, "\",\"msgs\":["); int midx[60]; int n = g_lv.task->getThreadMessageIndexes(tidx, midx, 60, true); // newest first bool first = true; // Emit newest-first so that if the JSON buffer fills, the guard drops the OLDEST // (not the newest). The browser reverses for display (oldest at top, newest at bottom). for (int k = 0; k < n && p < e - 340; ++k) { UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(midx[k], m)) continue; if (!first) *p++ = ','; first = false; int reps = (m.outgoing && m.sent_fp) ? (int)the_mesh.uiRepeatsForFp(m.sent_fp) : 0; p += snprintf(p, e - p, "{\"o\":%d,\"ts\":%u,\"ds\":%u,\"pl\":%d,\"sn\":%d,\"rs\":%d,\"rp\":%d,\"s\":\"", m.outgoing ? 1 : 0, (unsigned)m.ts, m.deliv_state, (int)m.path_len, (int)(m.snr_q4 / 4), (int)m.rssi, reps); jsonEsc(p, e, m.sender); p += snprintf(p, e - p, "\",\"x\":\""); jsonEsc(p, e, m.text); p += snprintf(p, e - p, "\"}"); } p += snprintf(p, e - p, "]}"); g_web_mirror.pushTermData(s_webdata_buf); g_lv.task->markThreadRead(tidx); // viewing a chat marks it read (matches the on-device UI) } // Membership test against a snapshot of 6-byte pubkey prefixes (favorites / ignore list). static bool webSnapContains(const uint8_t* snap, int count, const uint8_t* pub6) { for (int i = 0; i < count; ++i) if (memcmp(&snap[i * TOUCH_FAVORITE_KEY_BYTES], pub6, TOUCH_FAVORITE_KEY_BYTES) == 0) return true; return false; } static void webPushContacts() { if (!g_lv.task || !s_webdata_buf) return; uint8_t fav[TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES]; // snapshot once (not per-contact NVS reads) int nfav = touchPrefsCopyFavorites(fav); uint8_t ign[TOUCH_IGNORED_MAX * TOUCH_FAVORITE_KEY_BYTES]; int nign = touchPrefsCopyIgnored(ign); char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"c\",\"dc\":%d,\"contacts\":[", discoveredCount()); int nc = the_mesh.getNumContacts(); bool first = true; for (int i = 0; i < nc && p < e - 300; ++i) { ContactInfo c{}; if (!the_mesh.getContactByIdx((uint32_t)i, c) || !c.name[0]) continue; int t = webThreadForPub(c.id.pub_key); if (!first) *p++ = ','; first = false; p += snprintf(p, e - p, "{\"i\":%d,\"t\":%d,\"rp\":%d,\"rm\":%d,\"fav\":%d,\"ign\":%d,\"dir\":%d,\"la\":%ld,\"lo\":%ld,\"adv\":%u,\"n\":\"", i, t, (c.type == ADV_TYPE_REPEATER) ? 1 : 0, (c.type == ADV_TYPE_ROOM) ? 1 : 0, webSnapContains(fav, nfav, c.id.pub_key) ? 1 : 0, webSnapContains(ign, nign, c.id.pub_key) ? 1 : 0, (c.out_path_len == 0) ? 1 : 0, (long)c.gps_lat, (long)c.gps_lon, (unsigned)c.last_advert_timestamp); jsonEsc(p, e, c.name); p += snprintf(p, e - p, "\"}"); } p += snprintf(p, e - p, "],\"channels\":["); first = true; for (int s = 0; s < MAX_GROUP_CHANNELS && p < e - 160; ++s) { ChannelDetails cd; if (!the_mesh.getChannel(s, cd) || !cd.name[0]) continue; int t = webThreadForChannel(s); if (!first) *p++ = ','; first = false; p += snprintf(p, e - p, "{\"i\":%d,\"t\":%d,\"n\":\"", s, t); jsonEsc(p, e, cd.name); p += snprintf(p, e - p, "\"}"); } p += snprintf(p, e - p, "]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webPushMentionNames(uint32_t request_id) { if (!s_webdata_buf) return; char names[k_mention_source_max][32]; const int count = collectRecentMentionNames(names, k_mention_source_max); char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"mh\",\"q\":%lu,\"names\":[", (unsigned long)request_id); for (int i = 0; i < count && p < e - 48; ++i) { if (i) *p++ = ','; *p++ = '"'; jsonEsc(p, e, names[i]); *p++ = '"'; } p += snprintf(p, e - p, "]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webPushThreads() { if (!g_lv.task || !s_webdata_buf) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"t\",\"threads\":["); int idx[UITask::MAX_UI_THREADS]; int n = g_lv.task->getCombinedInboxCount(idx, UITask::MAX_UI_THREADS); bool first = true; for (int k = 0; k < n && p < e - 340; ++k) { int ti = idx[k]; bool ch = false; uint16_t unread = 0; uint32_t ts = 0; char name[48] = {0}; if (!g_lv.task->getThreadInfo(ti, ch, unread, ts, name, sizeof name)) continue; char sender[40] = {0}, text[180] = {0}; bool outgoing = false; g_lv.task->getThreadLastMessage(ti, sender, sizeof sender, text, sizeof text, &outgoing); if (!first) *p++ = ','; first = false; p += snprintf(p, e - p, "{\"i\":%d,\"ch\":%d,\"u\":%u,\"ts\":%u,\"n\":\"", ti, ch ? 1 : 0, unread, (unsigned)ts); jsonEsc(p, e, name); p += snprintf(p, e - p, "\",\"last\":\""); jsonEsc(p, e, text); p += snprintf(p, e - p, "\"}"); } p += snprintf(p, e - p, "]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webSendToThread(int tidx, const char* text) { if (!g_lv.task || tidx < 0 || !text || !*text) return; bool ch = false; uint16_t unread = 0; uint32_t ts = 0; char name[48] = {0}; if (!g_lv.task->getThreadInfo(tidx, ch, unread, ts, name, sizeof name)) return; if (ch) { // Resolve the channel slot by NAME at TX time, not the thread's cached slot — a stale // cached slot TXes on the wrong key (see [channel-send-stale-slot]). int16_t slot = -1; for (int s = 0; s < MAX_GROUP_CHANNELS; ++s) { ChannelDetails cd; if (the_mesh.getChannel(s, cd) && cd.name[0] && strcmp(cd.name, name) == 0) { slot = (int16_t)s; break; } } if (slot < 0) slot = g_lv.task->threadMeshChannelSlot(tidx); // fall back to cached if name didn't resolve termDoSend(true, nullptr, slot, name, text); } else { uint8_t pub[32]; if (g_lv.task->getThreadContactPub(tidx, pub)) termDoSend(false, pub, -1, name, text); } webPushMessages(tidx); } static void webSendToContact(int cidx, const char* text) { ContactInfo c{}; if (!the_mesh.getContactByIdx((uint32_t)cidx, c) || !c.name[0] || !text || !*text) return; termDoSend(false, c.id.pub_key, -1, c.name, text); int t = webThreadForPub(c.id.pub_key); if (t >= 0) webPushMessages(t); } static void webOpenContact(int cidx) { ContactInfo c{}; if (!the_mesh.getContactByIdx((uint32_t)cidx, c)) return; int t = webThreadForPub(c.id.pub_key); if (t >= 0) { webPushMessages(t); return; } if (!s_webdata_buf) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; // empty chat keyed to the contact p += snprintf(p, e - p, "{\"t\":\"m\",\"i\":-1,\"c\":%d,\"ch\":0,\"n\":\"", cidx); jsonEsc(p, e, c.name); p += snprintf(p, e - p, "\",\"msgs\":[]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webOpenChannel(int slot) { ChannelDetails cd; if (!the_mesh.getChannel(slot, cd) || !cd.name[0]) return; int t = webThreadForChannel(slot); if (t >= 0) { webPushMessages(t); return; } if (!s_webdata_buf) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; // empty chat keyed to the channel p += snprintf(p, e - p, "{\"t\":\"m\",\"i\":-1,\"h\":%d,\"ch\":1,\"n\":\"", slot); jsonEsc(p, e, cd.name); p += snprintf(p, e - p, "\",\"msgs\":[]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webSendToChannel(int slot, const char* text) { ChannelDetails cd; if (!the_mesh.getChannel(slot, cd) || !cd.name[0] || !text || !*text) return; termDoSend(true, nullptr, (int16_t)slot, cd.name, text); int t = webThreadForChannel(slot); if (t >= 0) webPushMessages(t); } // Status bar for the web header — node name, battery, wifi, ble, clock, mesh signal, unread. static void webPushStatus() { if (!g_lv.task || !s_webdata_buf) return; uint16_t mv = batteryMvSmoothed(); int pct = batteryPercentFromMv(mv); bool wc = (WiFi.status() == WL_CONNECTED); uint32_t clk = 0; auto* rtc = the_mesh.getRTCClock(); if (rtc) clk = rtc->getCurrentTime(); int snr = -128; // "no signal"; else last-heard SNR (dB), stale after 60 s if (the_mesh.uiSignalMs() != 0 && (millis() - the_mesh.uiSignalMs()) < 60000) snr = the_mesh.uiSignalSnrQ4() / 4; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"st\",\"batt\":%d,\"chg\":%d,\"wifi\":%d,\"rssi\":%d,\"ble\":%d,\"clk\":%u,\"snr\":%d,\"unr\":%d,\"mi\":%d,\"sla\":%ld,\"slo\":%ld,\"nm\":\"", pct, batteryIsCharging(mv) ? 1 : 0, wc ? 1 : 0, wc ? (int)WiFi.RSSI() : 0, (g_lv.task->isBleEnabled() && g_lv.task->hasBleCapability()) ? 1 : 0, (unsigned)clk, snr, g_lv.task->getUnreadTotal(), touchPrefsGetUseMiles() ? 1 : 0, (long)(g_lv.task->getNodeLat() * 1e6), (long)(g_lv.task->getNodeLon() * 1e6)); jsonEsc(p, e, g_lv.task->getNodeNameCstr()); p += snprintf(p, e - p, "\"}"); g_web_mirror.pushTermData(s_webdata_buf); } // Contact detail popup (Info action). static void webPushContactInfo(int cidx) { ContactInfo c{}; if (!the_mesh.getContactByIdx((uint32_t)cidx, c) || !s_webdata_buf) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"ci\",\"i\":%d,\"rp\":%d,\"rm\":%d,\"fav\":%d,\"ign\":%d,\"pl\":%d,\"adv\":%u,\"la\":%ld,\"lo\":%ld,\"k\":\"", cidx, (c.type == ADV_TYPE_REPEATER) ? 1 : 0, (c.type == ADV_TYPE_ROOM) ? 1 : 0, touchPrefsIsFavorite(c.id.pub_key) ? 1 : 0, touchPrefsIsIgnored(c.id.pub_key) ? 1 : 0, (c.out_path_len == OUT_PATH_UNKNOWN) ? -1 : (int)c.out_path_len, (unsigned)c.last_advert_timestamp, (long)c.gps_lat, (long)c.gps_lon); for (int b = 0; b < 6; ++b) p += snprintf(p, e - p, "%02x", c.id.pub_key[b]); // short pubkey id p += snprintf(p, e - p, "\",\"n\":\""); jsonEsc(p, e, c.name); p += snprintf(p, e - p, "\"}"); g_web_mirror.pushTermData(s_webdata_buf); } // Contact mutations from the web action sheet: (f)avorite, (b)lock, reset (p)ath, delete (x). Refresh after. static void webContactAction(int cidx, char op) { ContactInfo c{}; if (!the_mesh.getContactByIdx((uint32_t)cidx, c)) return; switch (op) { case 'f': touchPrefsSetFavorite(c.id.pub_key, !touchPrefsIsFavorite(c.id.pub_key)); break; case 'b': touchPrefsSetIgnored(c.id.pub_key, !touchPrefsIsIgnored(c.id.pub_key)); break; case 'p': the_mesh.uiResetContactPath(c.id.pub_key); break; case 'x': the_mesh.uiRemoveContact(c); break; default: return; } webPushContacts(); } // Discovered (heard-but-not-added) nodes -> the "add contacts without touching the device" flow. static void webPushDiscovered() { if (!s_discovered || !s_webdata_buf) return; char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"dc\",\"nodes\":["); bool first = true; for (int i = 0; i < DISCOVERED_MAX && p < e - 240; ++i) { if (!s_discovered[i].used || s_discovered[i].in_contacts) continue; const ContactInfo& c = s_discovered[i].ci; if (!first) *p++ = ','; first = false; p += snprintf(p, e - p, "{\"i\":%d,\"rp\":%d,\"rm\":%d,\"pl\":%d,\"adv\":%u,\"la\":%ld,\"lo\":%ld,\"n\":\"", i, (c.type == ADV_TYPE_REPEATER) ? 1 : 0, (c.type == ADV_TYPE_ROOM) ? 1 : 0, (int)s_discovered[i].path_len, (unsigned)s_discovered[i].last_advert_ts, (long)c.gps_lat, (long)c.gps_lon); jsonEsc(p, e, c.name[0] ? c.name : "(unnamed)"); p += snprintf(p, e - p, "\"}"); } p += snprintf(p, e - p, "]}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webAddDiscovered(int i) { if (!s_discovered || i < 0 || i >= DISCOVERED_MAX || !s_discovered[i].used || s_discovered[i].in_contacts) return; if (the_mesh.addContact(s_discovered[i].ci)) { // same path the on-device "Add" button uses the_mesh.uiPersistContacts(); s_discovered[i].in_contacts = true; markDiscoveredDirty(); } webPushDiscovered(); // refresh (the added node drops off the list) } // Settings page — read current node/radio/connectivity state. Writes reuse the config CLI // (set name / wifi|ble|tcp on|off / advert / reboot) except radio, which uses setRadioParams (live). static void webPushSettings() { if (!g_lv.task || !s_webdata_buf) return; NodePrefs* pr = the_mesh.getNodePrefs(); const bool wc = (WiFi.status() == WL_CONNECTED); char* p = s_webdata_buf; const char* e = s_webdata_buf + WEBDATA_BUF; p += snprintf(p, e - p, "{\"t\":\"sg\",\"freq\":%.3f,\"bw\":%.0f,\"sf\":%d,\"cr\":%d,\"tx\":%d,\"wifi\":%d,\"ble\":%d,\"blecap\":%d,\"tcp\":%d,\"name\":\"", pr ? pr->freq : 0.0f, pr ? pr->bw : 0.0f, pr ? pr->sf : 0, pr ? pr->cr : 0, pr ? (int)pr->tx_power_dbm : 0, wc ? 1 : 0, g_lv.task->isBleEnabled() ? 1 : 0, g_lv.task->hasBleCapability() ? 1 : 0, g_lv.task->isTcpEnabled() ? 1 : 0); jsonEsc(p, e, pr ? pr->node_name : ""); p += snprintf(p, e - p, "\"}"); g_web_mirror.pushTermData(s_webdata_buf); } static void webSetRadio(float freq, float bw, int sf, int cr, int tx) { if (!g_lv.task || freq <= 0) return; NodePrefs* pr = the_mesh.getNodePrefs(); g_lv.task->setRadioParams(freq, bw, (uint8_t)sf, (uint8_t)cr, (int8_t)tx, pr ? pr->airtime_factor : 1.0f); webPushSettings(); // echo the applied values back } // Returns true if the line was a data-API command ('@...'); it is fully handled here. static bool handleWebDataCmd(const char* cmd) { if (!cmd || cmd[0] != '@') return false; if (!s_webdata_buf) { s_webdata_buf = (char*)heap_caps_malloc(WEBDATA_BUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_webdata_buf) s_webdata_buf = (char*)malloc(WEBDATA_BUF); } const char* a = cmd + 1; auto argAfter = [](const char* s) { while (*s && *s != ' ') ++s; if (*s == ' ') ++s; return s; }; if (a[0] == 'c' && (a[1] == 0 || a[1] == ' ')) { webPushContacts(); return true; } if (a[0] == 't' && (a[1] == 0 || a[1] == ' ')) { webPushThreads(); return true; } if (a[0] == 'd' && a[1] == 'c' && (a[2] == 0 || a[2] == ' ')) { webPushDiscovered(); return true; } if (a[0] == 'a' && a[1] == 'd' && a[2] == ' ') { webAddDiscovered(atoi(a + 3)); return true; } // add discovered node if (a[0] == 's' && a[1] == 'g' && (a[2] == 0 || a[2] == ' ')) { webPushSettings(); return true; } if (a[0] == 's' && a[1] == 'r' && a[2] == ' ') { float fq=0,bw=0; int sf=0,cr=0,tx=0; sscanf(a+3, "%f %f %d %d %d", &fq,&bw,&sf,&cr,&tx); webSetRadio(fq,bw,sf,cr,tx); return true; } if (a[0] == 's' && a[1] == 't' && (a[2] == 0 || a[2] == ' ')) { webPushStatus(); return true; } if (a[0] == 'm' && a[1] == 'h' && (a[2] == 0 || a[2] == ' ')) { webPushMentionNames((uint32_t)strtoul(a + 2, nullptr, 10)); return true; } if (a[0] == 'm' && a[1] == ' ') { webPushMessages(atoi(a + 2)); return true; } if (a[0] == 'o' && a[1] == 'c' && a[2] == ' ') { webOpenContact(atoi(a + 3)); return true; } if (a[0] == 'o' && a[1] == 'h' && a[2] == ' ') { webOpenChannel(atoi(a + 3)); return true; } if (a[0] == 's' && a[1] == 'c' && a[2] == ' ') { const char* t = a + 3; webSendToContact(atoi(t), argAfter(t)); return true; } if (a[0] == 's' && a[1] == 'h' && a[2] == ' ') { const char* t = a + 3; webSendToChannel(atoi(t), argAfter(t)); return true; } if (a[0] == 's' && a[1] == ' ') { const char* t = a + 2; webSendToThread(atoi(t), argAfter(t)); return true; } if (a[0] == 'i' && a[1] == 'c' && a[2] == ' ') { webPushContactInfo(atoi(a + 3)); return true; } if (a[0] == 'f' && a[1] == 'v' && a[2] == ' ') { webContactAction(atoi(a + 3), 'f'); return true; } // favorite toggle if (a[0] == 'b' && a[1] == 'k' && a[2] == ' ') { webContactAction(atoi(a + 3), 'b'); return true; } // block toggle if (a[0] == 'p' && a[1] == 'r' && a[2] == ' ') { webContactAction(atoi(a + 3), 'p'); return true; } // reset path if (a[0] == 'x' && a[1] == 'c' && a[2] == ' ') { webContactAction(atoi(a + 3), 'x'); return true; } // delete contact if (a[0] == 'x' && a[1] == 't' && a[2] == ' ' && g_lv.task) { g_lv.task->removeThread(atoi(a + 3)); webPushThreads(); return true; } if (a[0] == 'd' && a[1] == ' ' && g_lv.task) { g_lv.task->clearThreadHistory(atoi(a + 2)); webPushThreads(); return true; } if (a[0] == 'r' && a[1] == ' ' && g_lv.task) { g_lv.task->markThreadRead(atoi(a + 2)); webPushThreads(); return true; } return true; // unknown '@' -> swallow (never run as a terminal command) } #endif // !HAS_TANMATSU #if CAP_CONSOLE // Outside the !HAS_TANMATSU block above ON PURPOSE. The Settings row that binds // this callback lives in buildDeviceSettings, which every board compiles, and // the console boot path itself is only gated on CAP_CONSOLE -- so leaving the // callback in a board-excluded region gave the Tanmatsu a switch it could see, // could not link, and would not have worked from anyway. // Console mode is a boot mode like remote mode, so it is toggled the same way: // set the pref, then reboot through rebootDevice() so chat history is flushed // first rather than lost to the reset. static void consoleModeToggleCb(lv_event_t* e) { const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetConsoleMode(on); if (g_lv.task) { g_lv.task->showAlert(on ? TR("Entering console mode - rebooting...") : TR("Leaving console mode - rebooting..."), 1600); g_lv.task->rebootDevice(); } } #endif // Scrollable command picker, modelled on openAdminCmdPicker. Tapping a row // stuffs the template into the terminal input and keeps it focused so Enter // (or the value typed after a "set ..." template) sends it. static void openTermCmdPicker() { closeTermCmdPicker(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_term_picker_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_term_picker_root); lv_obj_set_size(s_term_picker_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_term_picker_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_term_picker_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_term_picker_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_term_picker_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_term_picker_root, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeTermCmdPicker(); }, LV_EVENT_CLICKED, nullptr); const int card_w = sw - 20; const int card_h = (sh - STATUSBAR_H) - 40; lv_obj_t* card = lv_obj_create(s_term_picker_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 6, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Commands")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_align(title, LV_ALIGN_TOP_LEFT, 4, 4); addCloseXBadge(card, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeTermCmdPicker(); }); lv_obj_t* list = lv_list_create(card); lv_obj_set_size(list, card_w - 12, card_h - 12 - 28); lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 28); lv_obj_set_style_bg_color(list, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); for (int i = 0; i < k_term_cmds_count; ++i) { const AdminCmdEntry& e = k_term_cmds[i]; if (!e.command) { lv_obj_t* h = lv_list_add_text(list, e.label); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(h, lv_color_hex(0x0F1722), LV_PART_MAIN); lv_obj_set_style_bg_opa(h, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(h, 0, LV_PART_MAIN); lv_obj_set_style_pad_ver(h, 6, LV_PART_MAIN); lv_obj_set_style_pad_left(h, 10, LV_PART_MAIN); continue; } lv_obj_t* btn = lv_list_add_btn(list, nullptr, e.label); lv_obj_set_style_text_font(btn, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(btn, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); lv_obj_set_style_min_height(btn, 30, LV_PART_MAIN); lv_obj_set_style_pad_ver(btn, 4, LV_PART_MAIN); lv_obj_set_style_pad_left(btn, 10, LV_PART_MAIN); lv_obj_add_event_cb(btn, [](lv_event_t* ev) { if (lv_event_get_code(ev) != LV_EVENT_CLICKED) return; intptr_t idx = (intptr_t)lv_event_get_user_data(ev); if (idx < 0 || idx >= k_term_cmds_count) return; const char* tpl = k_term_cmds[idx].command; if (!tpl || !s_term_input_ta) { closeTermCmdPicker(); return; } lv_textarea_set_text(s_term_input_ta, tpl); lv_textarea_set_cursor_pos(s_term_input_ta, LV_TEXTAREA_CURSOR_LAST); closeTermCmdPicker(); // Keep the input focused so the operator can type a value / press Enter. if (g_lv.keyboard) kbMirrorBind(s_term_input_ta); }, LV_EVENT_CLICKED, (void*)(intptr_t)i); } } // Build the terminal into the fullscreen body: scrolling log on top, an input // row (picker button + textarea + send) at the bottom. static void buildTerminal(lv_obj_t* body) { lv_obj_set_style_pad_all(body, 0, LV_PART_MAIN); lv_obj_clear_flag(body, LV_OBJ_FLAG_SCROLLABLE); const lv_coord_t bw = lv_disp_get_hor_res(nullptr); const lv_coord_t bh = (lv_disp_get_ver_res(nullptr) - STATUSBAR_H); // body fills the view const lv_coord_t row_h = 40; s_term_log_box = lv_obj_create(body); lv_obj_remove_style_all(s_term_log_box); lv_obj_set_size(s_term_log_box, bw - 8, bh - row_h - 4); lv_obj_set_pos(s_term_log_box, 4, 2); styleSurface(s_term_log_box, 0x0A0B0C, 6); lv_obj_set_style_border_color(s_term_log_box, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_term_log_box, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(s_term_log_box, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(s_term_log_box, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_term_log_box, LV_SCROLLBAR_MODE_AUTO); // Stack each log line as its own coloured label. lv_obj_set_flex_flow(s_term_log_box, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(s_term_log_box, 1, LV_PART_MAIN); // Seed the banner (fresh each open) — must come AFTER the box exists. termLogAppendC(TERM_C_BANNER, nullptr, k_term_banner); // Input row lv_obj_t* row = lv_obj_create(body); lv_obj_remove_style_all(row); lv_obj_set_size(row, bw, row_h); lv_obj_set_pos(row, 0, bh - row_h); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* picker_btn = lv_btn_create(row); lv_obj_set_size(picker_btn, 32, 32); lv_obj_align(picker_btn, LV_ALIGN_LEFT_MID, 4, 0); styleButton(picker_btn); lv_obj_set_style_bg_color(picker_btn, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(picker_btn, 0, LV_PART_MAIN); lv_obj_add_event_cb(picker_btn, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openTermCmdPicker(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* picker_lbl = lv_label_create(picker_btn); lv_label_set_text(picker_lbl, LV_SYMBOL_LIST); lv_obj_set_style_text_font(picker_lbl, &g_font_14, LV_PART_MAIN); lv_obj_center(picker_lbl); s_term_input_ta = lv_textarea_create(row); lv_obj_set_size(s_term_input_ta, bw - 104, 32); lv_obj_align(s_term_input_ta, LV_ALIGN_LEFT_MID, 40, 0); styleCard(s_term_input_ta); lv_textarea_set_one_line(s_term_input_ta, true); lv_textarea_set_max_length(s_term_input_ta, 96); taSetPlaceholder(s_term_input_ta, TR("command")); lv_obj_set_style_text_color(s_term_input_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_term_input_ta, &g_font_14, LV_PART_MAIN); attachSettingsTaEvents(s_term_input_ta); lv_obj_t* send_btn = lv_btn_create(row); lv_obj_set_size(send_btn, 56, 32); lv_obj_align(send_btn, LV_ALIGN_RIGHT_MID, -4, 0); styleButton(send_btn); lv_obj_set_style_bg_color(send_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(send_btn, [](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; terminalSubmit(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* send_lbl = lv_label_create(send_btn); useChainedFont(send_lbl); lv_label_set_text(send_lbl, LV_SYMBOL_RIGHT); lv_obj_center(send_lbl); // Capture replies + auto-focus the input so HW keys type immediately. MyMesh::setTerminalSink(&terminalSink); if (g_lv.keyboard) kbMirrorBind(s_term_input_ta); } static void homeTerminalCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* body = openFullscreenView("Mesh console"); buildTerminal(body); } // ---- File manager (Phase 1 + header: Back / address bar / Sort / Find) ---- static void fmRefresh(); static void fmRender(); static void fmShowRoots(); static void fmOpenEditor(const char* name); static const char* k_fm_sort_names[] = { "A-Z", "Z-A", "Size", "Type" }; static void fmFmtSize(size_t bytes, char* out, size_t outsz) { if (bytes < 1024) snprintf(out, outsz, "%u B", (unsigned)bytes); else if (bytes < 1024UL * 1024) snprintf(out, outsz, "%.1f KB", bytes / 1024.0); else snprintf(out, outsz, "%.1f MB", bytes / (1024.0 * 1024.0)); } // Style a list button to the dark palette (matches the command picker). static void fmStyleRow(lv_obj_t* btn, uint32_t text_color) { lv_obj_set_style_text_font(btn, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(btn, lv_color_hex(text_color), LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); lv_obj_set_style_min_height(btn, 34, LV_PART_MAIN); lv_obj_set_style_pad_ver(btn, 5, LV_PART_MAIN); } // Case-insensitive substring test for the search filter. static bool fmContainsCI(const char* hay, const char* needle) { if (!needle || !needle[0]) return true; size_t nl = strlen(needle); for (const char* p = hay; *p; ++p) if (strncasecmp(p, needle, nl) == 0) return true; return false; } // qsort comparator honouring s_fm_sort. Dirs sort before files for Size/Type. static int fmCmp(const void* a, const void* b) { const FmEntry* x = (const FmEntry*)a; const FmEntry* y = (const FmEntry*)b; switch (s_fm_sort) { case 1: return strcasecmp(y->name, x->name); // Name Z-A case 2: // Size (dirs first) if (x->isdir != y->isdir) return x->isdir ? -1 : 1; if (x->isdir) return strcasecmp(x->name, y->name); return (y->size > x->size) ? 1 : (y->size < x->size ? -1 : 0); case 3: // Type (dirs first) if (x->isdir != y->isdir) return x->isdir ? -1 : 1; return strcasecmp(x->name, y->name); default: return strcasecmp(x->name, y->name); // Name A-Z } } // Append `name` onto the current path (handles the root "/" case). static void fmEnterDir(const char* base) { size_t n = strlen(s_fm_path); if (n == 0) { snprintf(s_fm_path, sizeof s_fm_path, "/%s", base); } else if (s_fm_path[n - 1] == '/') { snprintf(s_fm_path + n, sizeof(s_fm_path) - n, "%s", base); } else { snprintf(s_fm_path + n, sizeof(s_fm_path) - n, "/%s", base); } fmRefresh(); } static void fmUp() { if (!s_fm_fs) return; // already at the roots screen if (strcmp(s_fm_path, "/") == 0) { fmShowRoots(); return; } // root -> roots char* slash = strrchr(s_fm_path, '/'); if (slash == s_fm_path) s_fm_path[1] = '\0'; // keep leading "/" else if (slash) *slash = '\0'; fmRefresh(); } // Free the strdup'd basename stashed on a directory row when it's deleted. static void fmRowFreeCb(lv_event_t* e) { void* ud = lv_obj_get_user_data(lv_event_get_target(e)); if (ud) free(ud); } static void fmUpCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmUp(); } static void fmOpenStorage(fs::FS* fs, const char* store, const char* path) { s_fm_fs = fs; snprintf(s_fm_store, sizeof s_fm_store, "%s", store); snprintf(s_fm_path, sizeof s_fm_path, "%s", path); fmRefresh(); } static void fmInternalClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(HAS_TANMATSU) fmOpenStorage(&FFat, "Internal", "/"); // internal FAT data partition (locfd) #elif defined(HAS_TDISPLAY_P4) fmOpenStorage(&LittleFS, "Internal", "/"); // internal LittleFS 'storage' partition #else fmOpenStorage(&SPIFFS, "Internal", "/"); #endif } // 64-bit size formatter for card capacity (cards routinely exceed 4 GB). static void fmFmtSize64(uint64_t bytes, char* out, size_t outsz) { if (bytes < 1024ULL * 1024) snprintf(out, outsz, "%.0f KB", bytes / 1024.0); else if (bytes < 1024ULL * 1024 * 1024) snprintf(out, outsz, "%.0f MB", bytes / (1024.0 * 1024)); else snprintf(out, outsz, "%.1f GB", bytes / (1024.0 * 1024 * 1024)); } #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // microSD mount/format helpers — Arduino SD on the shared SPI bus // One shared-SPI accessor per board: the T-Deck/M9 expose their pre-begun SPIClass // via tdeckSharedSPI()/m9SharedSPI(); the V4-R8's microSD shares its TFT FSPI bus // (heltecV4R8SharedSPI()); the pager accessor returns the same TFT_eSPI SPIClass // already used by its display and RadioLib module. #if defined(TLORA_PAGER) static inline SPIClass* sdSharedSPI() { return tloraPagerSharedSPI(); } #elif defined(HELTEC_LORA_V4_R8) static inline SPIClass* sdSharedSPI() { return heltecV4R8SharedSPI(); } // V4-R8: micro-SD shares the TFT FSPI bus (CS=3) #elif defined(HAS_THINKNODE_M9) static inline SPIClass* sdSharedSPI() { return m9SharedSPI(); } #else static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); } #endif // Mount the microSD on its shared SPI bus. Safe to call repeatedly (no-op // once mounted). SD.begin's internal spi.begin() is a no-op because the bus is // already initialised by the radio/display, so those pins are untouched. static bool sdAdoptLiveMount() { if (SD.cardType() == CARD_NONE) return false; s_sd_mounted = true; s_sd_size = SD.cardSize(); s_sd_retry_after_ms = 0; return true; } static bool sdRuntimeLifecycleBusy(); static bool fmSdTryMount() { // main.cpp may already have mounted this global SD object for DataStore. // Adopt that live VFS before considering any lifecycle operation: SD.end() // here would invalidate open prefs/history files beneath the boot sequence. #if defined(TLORA_PAGER) // An SD-backed worker can enter here while its own busy flag is set. It may // use an already-live VFS without changing its lifetime. Keep the mount // diagnostics coherent so every successful return has the same contract. if (SD.cardType() != CARD_NONE && sdRuntimeLifecycleBusy()) { if (board.sdCardState() == TLoraPagerBoard::SdCardState::Absent) return false; s_sd_mounted = true; s_sd_size = SD.cardSize(); s_sd_retry_after_ms = 0; return true; } #endif if (sdAdoptLiveMount()) return true; s_sd_mounted = false; s_sd_size = 0; // Fast-fail inside the post-failure backoff window. Hot paths call this // directly (map tile lookups on the loop task, history writes via // uiDataFsReady, WAV chimes) — without this gate every such call re-walks // the multi-second mount ladder while the card is out, freezing the UI. // Explicit user retries clear the backoff first (fmSdMountOrFormatCb). if (s_sd_retry_after_ms && millis() < s_sd_retry_after_ms) return false; #if defined(TLORA_PAGER) if (sdRuntimeLifecycleBusy()) return false; if (!board.sdCardPresent()) return false; #endif SPIClass* spi = sdSharedSPI(); if (!spi) return false; #if defined(TLORA_PAGER) // Vendor-compatible Pager sequence: the shared display/radio bus is already // running, all other CS lines are parked HIGH, and the card gets one 4 MHz // mount attempt. Never tear down that live shared bus or add a retry ladder. const bool begin_ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); const bool mounted = begin_ok && SD.cardType() != CARD_NONE; #else // Cold microSD cards — especially the first mount after boot — often fail // the initial SD.begin and historically only recovered after a physical // reinsert (which power-cycles the card). The T-Deck shares ONE power rail // (GPIO10) across radio + display + SD, so we can't power-cycle the card on // its own. Instead coax it: several SD.begin attempts, each preceded by an // SD.end() (de-inits the SD host so the next begin re-runs the full card // wake-up handshake) and a progressively longer settle delay. A healthy card // mounts on the first attempt and returns immediately; only a flaky/cold card // walks the whole ladder (~1.1 s worst case). The cumulative delay can // approach the loop watchdog window, so drop the WDT around the loop. 4 MHz // is reliable on the shared LoRa bus. max_files=6 (was 3): the card now // serves telemetry log rewrites (2 handles), battery log rewrites (2), // the history flush, tile reads/writes, WAV chimes and the About readout // CONCURRENTLY — with only 3 VFS slots the history flush's open("w") lost // that race and chat saves failed with ENFILE while the card was fine. // Ladder of (settle, clock) attempts. The first three use the fast 4 MHz the // shared LoRa bus handles well; a cold / cheap card that won't wake at 4 MHz // then gets progressively longer settles AND a lower clock (1 MHz, then // 400 kHz) — many such cards only complete their power-up handshake at a slow // clock, and previously needed a physical reinsert (power-cycle) to mount. A // healthy card mounts on attempt 1 and returns immediately; only a stubborn // one walks the whole ladder (~2.7 s worst case, loop WDT dropped below). The // clock that succeeds becomes the operating clock — slower is fine here (the // file list + a settings backup are small; map tiles live on internal flash). // UNVERIFIED on the M9: this ladder was tuned against the T-Deck's shared // bus electrically — the M9 shares the same three signals (SCLK/MISO/MOSI) // across radio+LCD+SD too, but timing margins haven't been confirmed on // real M9 hardware yet. If SD never mounts, or mounts unreliably, this is // the first place to look. static const struct { uint16_t settle_ms; uint32_t hz; } kMountLadder[] = { { 40, 4000000 }, { 120, 4000000 }, { 200, 4000000 }, { 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 }, }; const int kAttempts = (int)(sizeof(kMountLadder) / sizeof(kMountLadder[0])); bool mounted = false; uint32_t mounted_hz = 0; { LoopWdtGuard loop_wdt_guard; for (int attempt = 0; attempt < kAttempts; ++attempt) { SD.end(); delay(kMountLadder[attempt].settle_ms); if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { mounted = true; mounted_hz = kMountLadder[attempt].hz; break; } } // Renegotiate upward after a slow-rung success (GH #194): the succeeding rung's clock used // to become the SESSION clock, so a card whose wake-up needed 400 kHz served contacts, // history and tiles at ~25 KB/s forever after ("slower is fine" above predates the SD being // the primary store). Init slow, then raise — fall back to the proven clock if 4 MHz fails. if (mounted && mounted_hz < 4000000) { SD.end(); delay(60); if (!(SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE)) { SD.end(); delay(120); mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; } else { mounted_hz = 4000000; } } // Operating-clock raise with read-verify (SD_SPI_FAST_HZ boards only; no-op elsewhere). if (mounted) { mounted_hz = sdTryFastClock(PIN_SD_CS, *spi, mounted_hz, "SD"); mounted = mounted_hz != 0; } if (mounted) g_sd_operating_hz = mounted_hz; } #endif if (mounted) { s_sd_mounted = true; s_sd_size = SD.cardSize(); s_sd_retry_after_ms = 0; // clear any prior backoff markSdIo(); // mount touched the card -> blip the LED #if defined(TLORA_PAGER) // History stays pinned to the backend loaded during UITask::begin(); a // later mount only restores the independently SD-backed data consumers. s_sd_data_warn_next_ms = 0; #endif return true; } #if defined(TLORA_PAGER) if (begin_ok) { // Clean up only the unusable VFS created by our quiescent mount attempt. // SD.end() leaves the display/radio SPIClass running. SD.end(); } sdPagerParkCs(); #else SD.end(); // clean up on failure #endif s_sd_retry_after_ms = millis() + 10000; // back off so we don't hammer the card return false; } static void fmSdUnmount() { if (s_sd_mounted) { SD.end(); #if defined(TLORA_PAGER) sdPagerParkCs(); #endif s_sd_mounted = false; s_sd_size = 0; } s_sd_retry_after_ms = 0; // a reinsert should be able to mount right away } // True when the card still answers real I/O. SD.cardType() is useless for this: // arduino-esp32 caches the type at mount and only an explicit SD.end() ever // returns CARD_NONE again — a wedged (or even physically removed) card keeps // reporting its mount-time type forever. Opening the root dir DOES hit the card: // the root `stat` is answered from RAM (vfs_fat special-cases "/"), but the // opendir behind it runs f_opendir -> disk_status = a real SEND_STATUS // transaction, and a NOINIT'd card then gets one full re-init handshake — so a // recoverable card self-heals right here, a dead one returns an invalid File // (opendir fails -> operator bool false). Healthy-card cost is sub-ms; a dead // card rides out the SPI timeouts once, so callers rate-limit the probe. static bool sdProbeAlive() { File d = SD.open("/"); const bool ok = d && d.isDirectory(); if (d) d.close(); return ok; } // Discriminator for hot READ paths (map tile lookups run up to five SD opens // per tile on the loop task): a missing FILE on a healthy card must stay cheap // and silent, while a dead card must stamp the failure AND let the caller // short-circuit — otherwise one render pass serializes behind sd_diskio's // per-op timeout/re-init and the whole UI freezes while a card is out. Call // after a failed SD read/open; returns true when the card itself is dead // (bail out of the whole SD pass), false when it was just a missing file. static bool sdReadFailedCardDead() { if (s_sd_fail_note_ms) return true; // already suspected — sdHealthTick arbitrates within ~5 s if (sdProbeAlive()) return false; // card fine: genuinely missing file sdNoteIoFailure(); return true; } static void fmSdClickCb(lv_event_t* e) { // SHORT_CLICKED (not CLICKED) so a long-press — which formats the card — does // not also fall through and open it. if (lv_event_get_code(e) != LV_EVENT_SHORT_CLICKED) return; if (s_sd_mounted) fmOpenStorage(&SD, "SD", "/"); } #if defined(TLORA_PAGER) // Recovery entry for a card the Pager can SEE (card-detect asserted) but cannot // mount: exFAT/NTFS, or a damaged FAT. Without this the roots page renders no SD // row at all (fmShowRoots below), so there is no way to tell "no card" from "card // the firmware won't take", and no way to retry without a reboot. // // Deliberately NON-destructive. It clears the mount backoff and makes exactly the // one vendor-compatible 4 MHz attempt fmSdTryMount() already makes — it does not // add a retry ladder and never tears down the live shared display/radio bus. If // that attempt fails the card needs formatting, and on this board that is a // deliberate host-side task: see the format-helper guard below for why the in-app // formatter stays off for the Pager. static void fmSdPagerRetryMountCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_sd_retry_after_ms = 0; // explicit user retry — bypass the backoff if (fmSdTryMount() && s_sd_mounted) { fmShowRoots(); return; } if (g_lv.task) g_lv.task->showAlert(TR("SD unreadable - format it as FAT32 on a computer"), 3600); } #endif #endif // HAS_TDECK_GT911 || TLORA_PAGER || HAS_THINKNODE_M9 || HELTEC_LORA_V4_R8 (microSD mount helpers; the busy overlays below are generic LVGL) // Full-screen "busy" notice (copy/move/format). Pure LVGL — used by the generic paste path too. static void fmShowBusyOverlay(const char* msg) { if (s_fm_fmt_overlay) return; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fm_fmt_overlay = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fm_fmt_overlay); lv_obj_set_size(s_fm_fmt_overlay, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_fm_fmt_overlay, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fm_fmt_overlay, lv_color_hex(0x0E1216), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_fmt_overlay, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_fm_fmt_overlay, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* l = lv_label_create(s_fm_fmt_overlay); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, sw - 36); lv_label_set_text(l, msg); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(0xFFCC66), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_center(l); } static void fmHideFormatOverlay() { if (s_fm_fmt_overlay) { popupClose(&s_fm_fmt_overlay); } } // TLORA_PAGER is deliberately NOT in this list — the in-app formatter is omitted // on the Pager, not merely unimplemented. Three reasons, in order of weight: // 1. f_mkfs needs SD.end() + sdcard_init/uninit around it. That is a card and // diskio lifecycle teardown on a bus the display AND the radio are actively // driving, and this board's mount path is explicitly documented (fmSdTryMount // above) to make ONE 4 MHz attempt and never tear the live shared bus down. // The Arduino SD library never touches the SPI peripheral itself, so this is // very likely fine — but "very likely" is not a basis for a one-way, whole-card // erase, and it has never been run on Pager hardware. // 2. There is no touchscreen, so the T-Deck's tap-to-open / hold-to-format split // does not map. The nearest equivalent is holding ENTER on the focused row — // an undiscoverable gesture that destroys the card, on a device where the // encoder and ENTER are the only ways to move at all. // 3. The recoverable case is already covered non-destructively: // fmSdPagerRetryMountCb above surfaces a detected-but-unmountable card and // retries the mount, and the alert it raises on failure points at the host. // Formatting a card on a computer is a 30-second task with no such risk. Revisit // only with a Pager in hand and a card that is safe to lose. #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // SD format helpers resume (Arduino SD, T-Deck + M9 + V4-R8) // Confirm callback: paint the formatting notice, then defer the (blocking) // f_mkfs to UITask::loop so the notice is on-screen before the loop freezes. static void fmSdDoFormat() { // f_mkfs rewrites the volume under every open handle, and the SD.end() that // precedes it unregisters the pdrv those handles point at. Never start that // while an SD consumer is live — the tile worker can own an open File for a // whole download — so apply the same gate on every board that carries the // deferred formatter below (T-Deck, ThinkNode M9, and Heltec V4-R8). if (sdRuntimeLifecycleBusy()) { if (g_lv.task) g_lv.task->showAlert(TR("SD is busy - close tools and retry"), 2600); return; } fmShowBusyOverlay("Formatting SD as MESHCOMOD (FAT32)\n\n" "Creates the core folders too.\n" "Do NOT power off, disconnect,\nor remove the card.\n\n" "This can take up to a minute..."); s_sd_format_pending = 2; } // Tap on an unmounted SD row: try to mount, and if the card is unreadable // (e.g. exFAT, which this build can't read — only FAT16/FAT32) offer to format. static void fmSdMountOrFormatCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_sd_retry_after_ms = 0; // explicit user retry — bypass the mount backoff if (fmSdTryMount()) { fmShowRoots(); return; } showConfirm(TR("Format SD as MESHCOMOD (FAT32)?\nAll data on the card will be erased."), TR("Format"), fmSdDoFormat); } // Seed the core folder layout on a freshly-formatted MESHCOMOD card. These back // the recovery/launcher: BINS (flashable firmware), RECBCK (recovery backups), // SETTINGS (exported config), plus MAPS/LOGS. Idempotent (mkdir-if-absent), so // it's also safe to re-run on an already-set-up card. static void sdEnsureMeshcomodFolders() { // /meshcomod + the chat-segment dir were MISSING here for years: after an // in-session FAT32 format every chat/telemetry write failed at open (ENOENT) // until a remount or reboot recreated the data root. static const char* const folders[] = { "/BINS", "/UPDATES", "/RECBCK", "/SETTINGS", "/MAPS", "/LOGS", "/meshcomod", "/meshcomod/msgs" }; for (unsigned i = 0; i < sizeof(folders) / sizeof(folders[0]); ++i) if (!SD.exists(folders[i])) SD.mkdir(folders[i]); if (!SD.exists("/README.TXT")) { File rf = SD.open("/README.TXT", FILE_WRITE); if (rf) { rf.print("MESHCOMOD recovery card\r\n\r\n" "BINS/ firmware images for the recovery launcher\r\n" "UPDATES/ firmware update images the launcher applies\r\n" "RECBCK/ recovery backups (identity / settings snapshots)\r\n" "SETTINGS/ exported settings (meshcore-backup.json)\r\n" "MAPS/ offline map tile packs\r\n" "LOGS/ diagnostic / packet logs\r\n"); rf.close(); } } } // Set the FAT volume label by hand (the prebuilt ESP-IDF has FF_USE_LABEL=0, so // f_setlabel isn't linkable): patch the boot-sector BS_VolLab and add/replace // the root-directory volume-label entry via the Arduino SD raw-sector API. Call // right after f_mkfs with the card initialized at `pdrv` and the FS NOT mounted // (so there's no FATFS cache to fight). Best-effort — silently no-ops on any // layout it doesn't recognise. Handles both MBR-partitioned and superfloppy. static void sdWriteFatLabel(uint8_t pdrv, const char* label) { uint8_t sec[512]; if (!sd_read_raw(pdrv, sec, 0)) return; if (sec[510] != 0x55 || sec[511] != 0xAA) return; uint32_t part_lba = 0; const bool boot_at_0 = (sec[0] == 0xEB || sec[0] == 0xE9) && (uint16_t)(sec[11] | (sec[12] << 8)) == 512; if (!boot_at_0) { // MBR: first partition entry @446, LBA-first @ +8 (454) part_lba = (uint32_t)sec[454] | ((uint32_t)sec[455] << 8) | ((uint32_t)sec[456] << 16) | ((uint32_t)sec[457] << 24); if (!part_lba) return; } uint8_t bs[512]; if (!sd_read_raw(pdrv, bs, part_lba)) return; if (bs[510] != 0x55 || bs[511] != 0xAA) return; const uint16_t bps = bs[11] | (bs[12] << 8); const uint8_t spc = bs[13]; const uint16_t rsvd = bs[14] | (bs[15] << 8); const uint8_t nfats = bs[16]; const uint16_t fatsz16 = bs[22] | (bs[23] << 8); const uint32_t fatsz32 = (uint32_t)bs[36] | ((uint32_t)bs[37] << 8) | ((uint32_t)bs[38] << 16) | ((uint32_t)bs[39] << 24); const uint32_t rootcl = (uint32_t)bs[44] | ((uint32_t)bs[45] << 8) | ((uint32_t)bs[46] << 16) | ((uint32_t)bs[47] << 24); const uint32_t fatsz = fatsz16 ? fatsz16 : fatsz32; if (bps != 512 || !spc || !nfats || !fatsz || rootcl < 2) return; // not the FAT32 we expect char lab[11]; memset(lab, ' ', sizeof lab); for (int i = 0; i < 11 && label[i]; ++i) { char c = label[i]; if (c >= 'a' && c <= 'z') c -= 32; lab[i] = c; } memcpy(bs + 71, lab, 11); // BS_VolLab (FAT32 boot sector) sd_write_raw(pdrv, bs, part_lba); const uint32_t root_lba = part_lba + rsvd + (uint32_t)nfats * fatsz + (rootcl - 2) * spc; uint8_t rd[512]; if (!sd_read_raw(pdrv, rd, root_lba)) return; int slot = -1; for (int i = 0; i < 512; i += 32) { if (rd[i] == 0x00 || rd[i] == 0xE5) { slot = i; break; } // free slot if (rd[i + 11] == 0x08) { slot = i; break; } // existing volume label -> replace } if (slot < 0) return; memset(rd + slot, 0, 32); memcpy(rd + slot, lab, 11); rd[slot + 11] = 0x08; // ATTR_VOLUME_ID sd_write_raw(pdrv, rd, root_lba); } // Long-press the SD card row -> confirm -> explicit reformat (vs the tap, which // only mounts, or formats an already-unreadable card). static void fmSdLongPressFormatCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; showConfirm(TR("Reformat SD as MESHCOMOD (FAT32)?\nALL data on the card will be erased."), TR("Format"), fmSdDoFormat); } #endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 (microSD mount/format helpers) // ---- File operations (Phase 4a: delete / rename / new folder) ---- struct FmRowData { char name[64]; bool isdir; }; // Build an absolute path for `name` inside the current directory. static void fmFullPath(const char* name, char* out, size_t outsz) { if (strcmp(s_fm_path, "/") == 0) snprintf(out, outsz, "/%s", name); else snprintf(out, outsz, "%s/%s", s_fm_path, name); } // Recursively delete a file or directory tree. Return false immediately when // an entry cannot be removed; rewinding onto the same undeletable FAT entry // forever would hang factory reset with its watchdog intentionally suspended. static bool fmRmRecursive(fs::FS* fs, const char* path) { File d = fs->open(path); const bool isdir = d && d.isDirectory(); if (isdir) { // Delete one child per pass, rewinding the directory after each removal. // Removing entries during a single openNextFile() walk invalidates the FatFS // read cursor and silently skips entries — which left files behind on a // recursive delete / factory reset. Rewinding re-reads from the top each pass // (O(n^2), fine for these tiny trees) so every child is actually removed. for (;;) { File e = d.openNextFile(); if (!e) break; const char* full = e.name(); const char* base = strrchr(full, '/'); base = base ? base + 1 : full; const bool cdir = e.isDirectory(); char child[200]; const bool have = (base[0] != '\0'); if (have) snprintf(child, sizeof child, "%s/%s", path, base); e.close(); if (!have) { d.close(); return false; } const bool removed = cdir ? fmRmRecursive(fs, child) : fs->remove(child); if (!removed) { d.close(); return false; } d.rewindDirectory(); } d.close(); return fs->rmdir(path); } else { if (d) d.close(); return fs->remove(path); } } // ----- text-input modal (rename / new folder) ----- // A Lua app driving this prompt (wada.ui.input) must hear about EVERY way it // closes, or it sits waiting on a callback that will never arrive. Set while an // OK is being delivered so the close below does not also report a cancel. void luaHostTextPromptCancel(); static bool s_fm_prompt_ok = false; static void fmPromptClose() { if (s_fm_prompt) { hideKb(); popupClose(&s_fm_prompt); s_fm_prompt_ta = nullptr; } #if CAP_LUA_APPS if (!s_fm_prompt_ok) luaHostTextPromptCancel(); #endif } static void fmPromptOkCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_fm_prompt_ta) { fmPromptClose(); return; } kbMirrorSyncToReal(); char buf[80]; const char* t = lv_textarea_get_text(s_fm_prompt_ta); snprintf(buf, sizeof buf, "%s", t ? t : ""); void (*cb)(const char*) = s_fm_prompt_cb; s_fm_prompt_ok = (cb && buf[0]); // empty text is a cancel, not a delivery fmPromptClose(); s_fm_prompt_ok = false; if (cb && buf[0]) cb(buf); } static void fmPromptCancelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmPromptClose(); } static void fmTextPrompt(const char* title, const char* initial, void (*cb)(const char*)) { fmPromptClose(); s_fm_prompt_cb = cb; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fm_prompt = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fm_prompt); lv_obj_set_size(s_fm_prompt, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_fm_prompt, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fm_prompt, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_prompt, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_fm_prompt, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* card = lv_obj_create(s_fm_prompt); lv_obj_remove_style_all(card); lv_obj_set_size(card, sw - 30, 124); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 8); styleSurface(card, COLOR_PANEL, 10); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x2A2E33), LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* tl = lv_label_create(card); lv_label_set_text(tl, title); lv_obj_set_style_text_color(tl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(tl, &g_font_14, LV_PART_MAIN); lv_obj_align(tl, LV_ALIGN_TOP_LEFT, 2, 0); s_fm_prompt_ta = lv_textarea_create(card); lv_obj_set_size(s_fm_prompt_ta, sw - 30 - 20, 32); lv_obj_align(s_fm_prompt_ta, LV_ALIGN_TOP_MID, 0, 24); styleCard(s_fm_prompt_ta); lv_textarea_set_one_line(s_fm_prompt_ta, true); lv_textarea_set_max_length(s_fm_prompt_ta, 63); lv_obj_set_style_text_font(s_fm_prompt_ta, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(s_fm_prompt_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); if (initial) lv_textarea_set_text(s_fm_prompt_ta, initial); attachSettingsTaEvents(s_fm_prompt_ta); lv_obj_t* bc = lv_btn_create(card); lv_obj_set_size(bc, 80, 32); lv_obj_align(bc, LV_ALIGN_BOTTOM_LEFT, 0, 0); styleButton(bc); lv_obj_set_style_bg_color(bc, lv_color_hex(0x3A4A5C), LV_PART_MAIN); lv_obj_add_event_cb(bc, fmPromptCancelCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lc = lv_label_create(bc); lv_label_set_text(lc, TR("Cancel")); lv_obj_center(lc); useChainedFont(lc); lv_obj_t* bo = lv_btn_create(card); lv_obj_set_size(bo, 80, 32); lv_obj_align(bo, LV_ALIGN_BOTTOM_RIGHT, 0, 0); styleButton(bo); lv_obj_set_style_bg_color(bo, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(bo, fmPromptOkCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lo = lv_label_create(bo); lv_label_set_text(lo, "OK"); lv_obj_center(lo); useChainedFont(lo); if (g_lv.keyboard) kbMirrorBind(s_fm_prompt_ta); } // ----- operations (act on s_fm_sel_name in the current dir) ----- static void fmDoDelete() { if (!s_fm_fs || !s_fm_sel_name[0]) return; char path[200]; fmFullPath(s_fm_sel_name, path, sizeof path); if (s_fm_sel_isdir) fmRmRecursive(s_fm_fs, path); else s_fm_fs->remove(path); if (g_lv.task) g_lv.task->showAlert(TR("Deleted"), 1200); fmRefresh(); } static void fmRenameApply(const char* newname) { if (!s_fm_fs || !s_fm_sel_name[0] || !newname[0]) return; char oldp[200], newp[200]; fmFullPath(s_fm_sel_name, oldp, sizeof oldp); fmFullPath(newname, newp, sizeof newp); bool ok = s_fm_fs->rename(oldp, newp); if (g_lv.task) g_lv.task->showAlert(ok ? TR("Renamed") : TR("Rename failed"), ok ? 1200 : 1600); fmRefresh(); } static void fmNewFolderApply(const char* name) { if (!s_fm_fs || !name[0]) return; if (s_fm_fs == &SPIFFS) { if (g_lv.task) g_lv.task->showAlert(TR("Internal has no folders"), 1800); return; } char p[200]; fmFullPath(name, p, sizeof p); bool ok = s_fm_fs->mkdir(p); if (g_lv.task) g_lv.task->showAlert(ok ? TR("Folder created") : TR("mkdir failed"), ok ? 1200 : 1600); fmRefresh(); } static void fmNewFileApply(const char* name) { if (!s_fm_fs || !name[0]) return; char p[200]; fmFullPath(name, p, sizeof p); if (s_fm_fs->exists(p)) { if (g_lv.task) g_lv.task->showAlert(TR("Already exists"), 1600); return; } File f = s_fm_fs->open(p, "w"); // create an empty file if (!f) { if (g_lv.task) g_lv.task->showAlert(TR("Create failed"), 1600); return; } f.close(); fmRefresh(); fmOpenEditor(name); // jump straight into the editor } // Stream a single file src -> dst (any fs to any fs). feedLoopWDT each chunk so // a large file can't trip the watchdog mid-copy. static bool fmCopyFile(fs::FS* sf, const char* sp, fs::FS* df, const char* dp) { if (fmIsSd(sf) || fmIsSd(df)) markSdIo(); // copy touching the SD card -> activity LED File in = sf->open(sp, "r"); if (!in) return false; File out = df->open(dp, "w"); if (!out) { in.close(); return false; } // Copy scratch, lazily allocated once. MUST be internal DMA-capable RAM: a // destination on SD DMAs straight out of this buffer, and PSRAM as a DMA source // short-writes at a random offset (same defect as the SD firmware download). static uint8_t* buf = nullptr; if (!buf) buf = (uint8_t*)heap_caps_malloc(2048, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!buf) { out.close(); in.close(); return false; } bool ok = true; int n; while ((n = in.read(buf, 2048)) > 0) { if (out.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } feedLoopWDT(); } out.close(); in.close(); return ok; } // Recursively copy a file or directory tree src -> dst. static bool fmCopyPath(fs::FS* sf, const char* sp, fs::FS* df, const char* dp, bool isdir) { if (!isdir) return fmCopyFile(sf, sp, df, dp); df->mkdir(dp); File d = sf->open(sp); if (!d) return false; bool ok = true; File e = d.openNextFile(); while (e) { const char* full = e.name(); const char* base = strrchr(full, '/'); base = base ? base + 1 : full; const bool cdir = e.isDirectory(); char cs[200], cd[200]; if (base[0]) { snprintf(cs, sizeof cs, "%s/%s", sp, base); snprintf(cd, sizeof cd, "%s/%s", dp, base); } e.close(); if (base[0]) ok = fmCopyPath(sf, cs, df, cd, cdir) && ok; e = d.openNextFile(); } d.close(); return ok; } // Perform the pending paste (copy or move) from s_fm_clip into the current dir. // Returns true on success. Runs from UITask::loop with the loop WDT disabled. static bool fmDoPaste() { if (!s_fm_clip.active || !s_fm_fs) return false; // Source's parent dir — used to detect "paste into the same folder". char srcparent[200]; snprintf(srcparent, sizeof srcparent, "%s", s_fm_clip.path); char* sl = strrchr(srcparent, '/'); if (sl == srcparent) srcparent[1] = '\0'; else if (sl) *sl = '\0'; const bool same_dir = (s_fm_clip.fs == s_fm_fs) && (strcmp(srcparent, s_fm_path) == 0); if (s_fm_clip.is_cut && same_dir) { s_fm_clip.active = false; return true; } // already here // Pick a non-colliding destination name. char name[80]; char dst[200]; snprintf(name, sizeof name, "%s", s_fm_clip.name); fmFullPath(name, dst, sizeof dst); for (int k = 1; k < 1000 && s_fm_fs->exists(dst); ++k) { snprintf(name, sizeof name, "%s-%d", s_fm_clip.name, k); fmFullPath(name, dst, sizeof dst); } bool ok; if (s_fm_clip.is_cut && s_fm_clip.fs == s_fm_fs) { ok = s_fm_fs->rename(s_fm_clip.path, dst); // same-volume move = rename if (!ok) { // fallback: copy then delete ok = fmCopyPath(s_fm_clip.fs, s_fm_clip.path, s_fm_fs, dst, s_fm_clip.isdir); if (ok) fmRmRecursive(s_fm_clip.fs, s_fm_clip.path); } } else { ok = fmCopyPath(s_fm_clip.fs, s_fm_clip.path, s_fm_fs, dst, s_fm_clip.isdir); if (ok && s_fm_clip.is_cut) fmRmRecursive(s_fm_clip.fs, s_fm_clip.path); } if (s_fm_clip.is_cut) s_fm_clip.active = false; // a move consumes the clipboard return ok; } // ----- action sheet ----- static lv_obj_t* fmActionBtn(lv_obj_t* parent, const char* text, lv_event_cb_t cb, uint32_t bg) { lv_obj_t* b = lv_btn_create(parent); lv_obj_set_width(b, lv_pct(100)); lv_obj_set_height(b, 34); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); useChainedFont(l); lv_label_set_text(l, TR(text)); lv_obj_center(l); return b; } static void fmCloseActions() { if (s_fm_actions) { popupClose(&s_fm_actions); } } static void fmActRenameCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); fmTextPrompt("Rename", s_fm_sel_name, fmRenameApply); } static void fmActDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); char m[110]; snprintf(m, sizeof m, "Delete %s%s?%s", s_fm_sel_isdir ? "folder " : "", s_fm_sel_name, s_fm_sel_isdir ? "\n(and everything inside)" : ""); showConfirm(m, TR("Delete"), fmDoDelete); } static void fmActNewFolderCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); fmTextPrompt("New folder name", "", fmNewFolderApply); } static void fmActNewFileCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); fmTextPrompt("New file name", "note.txt", fmNewFileApply); } static void fmClipSet(bool cut) { s_fm_clip.fs = s_fm_fs; fmFullPath(s_fm_sel_name, s_fm_clip.path, sizeof s_fm_clip.path); snprintf(s_fm_clip.name, sizeof s_fm_clip.name, "%s", s_fm_sel_name); s_fm_clip.isdir = s_fm_sel_isdir; s_fm_clip.is_cut = cut; s_fm_clip.active = true; if (g_lv.task) g_lv.task->showAlert(cut ? TR("Cut - Paste in a folder") : TR("Copied - Paste in a folder"), 1600); } static void fmActCopyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); fmClipSet(false); } static void fmActCutCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); fmClipSet(true); } static void fmActPasteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmCloseActions(); if (!s_fm_clip.active || !s_fm_fs) return; fmShowBusyOverlay(s_fm_clip.is_cut ? "Moving...\n\nDo NOT power off or\nremove the card." : "Copying...\n\nDo NOT power off or\nremove the card."); s_fm_paste_pending = 2; } // name==nullptr -> folder-level menu only (used by the ".." row). static void fmActToggleHiddenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_fm_show_hidden = !s_fm_show_hidden; fmCloseActions(); fmRefresh(); } static void fmOpenActions(const char* name, bool isdir) { fmCloseActions(); if (name) { snprintf(s_fm_sel_name, sizeof s_fm_sel_name, "%s", name); s_fm_sel_isdir = isdir; } else { s_fm_sel_name[0] = '\0'; s_fm_sel_isdir = false; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fm_actions = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fm_actions); lv_obj_set_size(s_fm_actions, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_fm_actions, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fm_actions, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_actions, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_fm_actions, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_fm_actions, [](lv_event_t* ev) { if (lv_event_get_code(ev) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); fmCloseActions(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* card = lv_obj_create(s_fm_actions); lv_obj_remove_style_all(card); lv_obj_set_width(card, sw - 50); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 10); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x2A2E33), LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_style_max_height(card, (sh - STATUSBAR_H) - 16, LV_PART_MAIN); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(card, 5, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* tl = lv_label_create(card); lv_label_set_long_mode(tl, LV_LABEL_LONG_DOT); lv_obj_set_width(tl, lv_pct(100)); lv_label_set_text(tl, name ? name : "Folder"); lv_obj_set_style_text_color(tl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(tl, &g_font_12, LV_PART_MAIN); if (name) { fmActionBtn(card, TR("Rename"), fmActRenameCb, 0x2B3440); fmActionBtn(card, TR("Copy"), fmActCopyCb, 0x2B3440); fmActionBtn(card, TR("Cut"), fmActCutCb, 0x2B3440); fmActionBtn(card, TR("Delete"), fmActDeleteCb, 0x5A2D2D); } else { fmActionBtn(card, TR("New file"), fmActNewFileCb, 0x2B3440); fmActionBtn(card, TR("New folder"), fmActNewFolderCb, 0x2B3440); #if defined(ESP32) if (s_fm_fs == &SPIFFS) // Internal only: toggle MeshCore's hidden system files fmActionBtn(card, s_fm_show_hidden ? "Hide system files" : "Show system files", fmActToggleHiddenCb, 0x2B3440); #endif } if (s_fm_clip.active) { fmActionBtn(card, s_fm_clip.is_cut ? TR("Paste (move)") : TR("Paste (copy)"), fmActPasteCb, 0x2D4A2D); } } // ----- text editor (Phase 5) ----- static const size_t FM_EDIT_MAX = 8192; // size cap for on-device editing static void fmEditorClose() { if (!s_editor_root) return; if (g_lv.keyboard) lv_keyboard_set_textarea(g_lv.keyboard, nullptr); popupClose(&s_editor_root); s_editor_ta = nullptr; } static void fmEditorCancelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmEditorClose(); } static void fmEditorSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_editor_ta || !s_fm_fs) { fmEditorClose(); return; } const char* txt = lv_textarea_get_text(s_editor_ta); size_t len = txt ? strlen(txt) : 0; fmMarkSdIo(); // SD file save -> activity LED File f = s_fm_fs->open(s_editor_path, "w"); bool ok = false; if (f) { ok = (f.write((const uint8_t*)txt, len) == len); f.close(); } if (g_lv.task) g_lv.task->showAlert(ok ? TR("Saved") : TR("Save failed"), ok ? 1300 : 1800); fmEditorClose(); fmRefresh(); } static void fmOpenEditor(const char* name) { if (!s_fm_fs || !name || !name[0]) return; char path[200]; fmFullPath(name, path, sizeof path); File f = s_fm_fs->open(path, "r"); if (!f) { if (g_lv.task) g_lv.task->showAlert(TR("Cannot open file"), 1500); return; } size_t sz = f.size(); if (sz > FM_EDIT_MAX) { f.close(); if (g_lv.task) g_lv.task->showAlert(TR("Too large to edit (>8 KB)"), 2200); return; } char* buf = (char*)heap_caps_malloc(sz + 1, MALLOC_CAP_SPIRAM); if (!buf) buf = (char*)malloc(sz + 1); if (!buf) { f.close(); if (g_lv.task) g_lv.task->showAlert(TR("Out of memory"), 1500); return; } size_t rd = f.readBytes(buf, sz); buf[rd] = '\0'; f.close(); snprintf(s_editor_path, sizeof s_editor_path, "%s", path); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_editor_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_editor_root); lv_obj_set_size(s_editor_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_editor_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_editor_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_editor_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_editor_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* fn = lv_label_create(s_editor_root); lv_label_set_long_mode(fn, LV_LABEL_LONG_DOT); lv_obj_set_pos(fn, 6, 9); lv_obj_set_width(fn, sw - 6 - 112); lv_label_set_text(fn, name); lv_obj_set_style_text_font(fn, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(fn, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_t* save = lv_btn_create(s_editor_root); lv_obj_set_size(save, 58, 28); lv_obj_align(save, LV_ALIGN_TOP_RIGHT, -50, 4); styleButton(save); lv_obj_set_style_bg_color(save, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_add_event_cb(save, fmEditorSaveCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(save); lv_label_set_text(sl, TR("Save")); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_center(sl); lv_obj_t* cancel = lv_btn_create(s_editor_root); lv_obj_set_size(cancel, 44, 28); lv_obj_align(cancel, LV_ALIGN_TOP_RIGHT, -3, 4); styleButton(cancel); lv_obj_set_style_bg_color(cancel, lv_color_hex(0x3A4A5C), LV_PART_MAIN); lv_obj_add_event_cb(cancel, fmEditorCancelCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(cancel); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); s_editor_ta = lv_textarea_create(s_editor_root); lv_obj_set_size(s_editor_ta, sw - 8, (sh - STATUSBAR_H) - 38 - 4); lv_obj_set_pos(s_editor_ta, 4, 36); lv_textarea_set_one_line(s_editor_ta, false); lv_textarea_set_max_length(s_editor_ta, FM_EDIT_MAX); styleCard(s_editor_ta); lv_obj_set_style_text_font(s_editor_ta, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_editor_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_textarea_set_text(s_editor_ta, buf); free(buf); // Bind the physical keyboard directly (no one-line mirror); the on-screen // keyboard stays hidden on the T-Deck, handleHwKey routes keys to this ta. if (g_lv.keyboard) { s_kb_bind_ta = nullptr; if (s_kb_mirror_root) lv_obj_add_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN); lv_keyboard_set_textarea(g_lv.keyboard, s_editor_ta); } } // ---- Read-only image viewer (PNG / JPEG; decoders enabled in lv_conf.h) ----- static const size_t FM_IMG_MAX = 4u * 1024 * 1024; // encoded-file read cap static bool fmIsImage(const char* name) { if (!name) return false; const char* dot = strrchr(name, '.'); if (!dot) return false; return !strcasecmp(dot, ".png") || !strcasecmp(dot, ".jpg") || !strcasecmp(dot, ".jpeg") || !strcasecmp(dot, ".sjpg") || !strcasecmp(dot, ".bmp"); } #if CAP_SD || defined(TLORA_PAGER) static bool fmIsAudio(const char* name) { if (!name) return false; const char* dot = strrchr(name, '.'); return dot && !strcasecmp(dot, ".wav"); } #endif // Defined further down (with the map tile code); used here by the image viewer. static uint8_t* decodeJpegToRgb565(const uint8_t* jpeg, size_t jpeg_len, int* out_w, int* out_h); // Same, but DOWNSCALES (tjpgd 1/2..1/8) so images larger than max_dim still // decode — into a small buffer — instead of being rejected. FM viewer + wallpaper. // dst_buf/dst_cap: optional caller-owned output buffer. When given and the decoded // RGB565 fits, decode straight into it (no PSRAM alloc) — the map tile pool passes // its persistent 128 KB slot buffer so tiles never malloc per-render (fragmentation // fix). If the image is bigger than dst_cap, returns nullptr. dst_buf==nullptr keeps // the old "alloc a fresh buffer" behaviour (FM viewer / wallpaper). static uint8_t* decodeJpegScaledToRgb565(const uint8_t* jpeg, size_t jpeg_len, int* out_w, int* out_h, int max_dim, uint8_t* dst_buf = nullptr, size_t dst_cap = 0); static uint8_t* decodePngToRgb565(const uint8_t* png, size_t png_len, int* out_w, int* out_h, uint8_t* dst_buf = nullptr, size_t dst_cap = 0); // Minimal BMP -> RGB565 decoder for the image viewer. Handles the common // uncompressed layouts: 16-bpp 565 (what our screenshots write) and 24-bpp BGR, // both bottom-up and top-down. Returns an lvglPsramAlloc'd buffer (freed via // lvglPsramFree), or nullptr on anything unsupported. static uint8_t* decodeBmpToRgb565(const uint8_t* bmp, size_t len, int* out_w, int* out_h) { if (len < 54 || bmp[0] != 'B' || bmp[1] != 'M') return nullptr; auto rd32 = [&](size_t o) -> uint32_t { return (uint32_t)bmp[o] | ((uint32_t)bmp[o+1] << 8) | ((uint32_t)bmp[o+2] << 16) | ((uint32_t)bmp[o+3] << 24); }; auto rd16 = [&](size_t o) -> uint16_t { return (uint16_t)(bmp[o] | (bmp[o+1] << 8)); }; const uint32_t data_off = rd32(10); const int32_t w = (int32_t)rd32(18); const int32_t h_raw = (int32_t)rd32(22); const uint16_t bpp = rd16(28); const uint32_t comp = rd32(30); if (w <= 0 || w > 1024) return nullptr; const bool top_down = (h_raw < 0); const int32_t h = top_down ? -h_raw : h_raw; if (h <= 0 || h > 1024) return nullptr; if (bpp != 16 && bpp != 24) return nullptr; if (comp != 0 && comp != 3) return nullptr; // 0 BI_RGB, 3 BI_BITFIELDS const uint32_t row_bytes = (((uint32_t)w * bpp + 31) / 32) * 4; if ((uint64_t)data_off + (uint64_t)row_bytes * (uint32_t)h > len) return nullptr; uint16_t* out = (uint16_t*)lvglPsramAlloc((size_t)w * (size_t)h * sizeof(uint16_t)); if (!out) return nullptr; for (int32_t y = 0; y < h; ++y) { const int32_t srcrow = top_down ? y : (h - 1 - y); // BMP rows are bottom-up by default const uint8_t* row = bmp + data_off + (size_t)srcrow * row_bytes; uint16_t* dst = out + (size_t)y * w; if (bpp == 16) { memcpy(dst, row, (size_t)w * 2); // assume RGB565 (our screenshots) } else { for (int32_t x = 0; x < w; ++x) // 24-bpp stored BGR dst[x] = lv_color_make(row[x*3+2], row[x*3+1], row[x*3+0]).full; } } *out_w = (int)w; *out_h = (int)h; return (uint8_t*)out; } static void fmImageClose() { // Sync delete (not async): the lv_img references s_fm_img_buf, so the widget // must be gone before we free the buffer it draws from. if (s_fm_img_root) { popupClose(&s_fm_img_root); } // also deletes the children below lv_img_cache_invalidate_src(&s_fm_img_dsc); // drop the cache entry before the buffer it points at is freed if (s_fm_img_buf) { lvglPsramFree(s_fm_img_buf); s_fm_img_buf = nullptr; } // decoded RGB565 (lvglPsramAlloc) s_fm_img_widget = s_fm_img_hdr = s_fm_img_close = s_fm_img_full = s_fm_img_hint = nullptr; s_fm_img_fs = false; if (g_statusbar.root) lv_obj_clear_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); // ensure it's back } static void fmImageCloseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); // swallow trailing click fmImageClose(); } static void fmShowObj(lv_obj_t* o, bool show) { if (!o) return; if (show) lv_obj_clear_flag(o, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN); } // "Set as lock wallpaper" action shown in the windowed image viewer, plus the // open image's path / filesystem so the action can persist it. The button is // CAP_LOCK_SCREEN-gated: on the one filesystem board WITHOUT a lock screen // (V4-R8) nothing ever renders the wallpaper, so offering to set one — with a // "Lock wallpaper set" toast — was a dead control. #if CAP_LOCK_SCREEN static lv_obj_t* s_fm_img_wall = nullptr; #endif static char s_fm_img_path[208] = {0}; static bool s_fm_img_on_sd = false; // Position the image + chrome for the current mode. Windowed: slim header with // the close/full buttons, image scaled to fit below it (never upscaled). Full // screen: chrome hidden, image fitted to the whole display, a "tap to exit" // hint at the bottom. static void fmImageRelayout() { if (!s_fm_img_root || !s_fm_img_widget) return; const int w = s_fm_img_w, h = s_fm_img_h; if (w <= 0 || h <= 0) return; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const bool fs = s_fm_img_fs; fmShowObj(s_fm_img_hdr, !fs); fmShowObj(s_fm_img_close, !fs); fmShowObj(s_fm_img_full, !fs); #if CAP_LOCK_SCREEN fmShowObj(s_fm_img_wall, !fs); #endif fmShowObj(s_fm_img_hint, fs); // The status bar lives on lv_layer_sys (above this overlay), so hide it // outright for a true full-screen image; restore it otherwise. fmShowObj(g_statusbar.root, !fs); if (fs) { lv_obj_set_pos(s_fm_img_root, 0, 0); lv_obj_set_size(s_fm_img_root, sw, sh); // Fit the full display, preserving aspect (letterbox); allow upscaling. uint32_t zx = (uint32_t)sw * 256u / (uint32_t)w; uint32_t zy = (uint32_t)sh * 256u / (uint32_t)h; uint32_t zoom = (zx < zy) ? zx : zy; if (zoom < 1) zoom = 1; if (zoom > 1024) zoom = 1024; lv_img_set_zoom(s_fm_img_widget, (uint16_t)zoom); lv_obj_align(s_fm_img_widget, LV_ALIGN_CENTER, 0, 0); } else { lv_obj_set_pos(s_fm_img_root, 0, STATUSBAR_H); lv_obj_set_size(s_fm_img_root, sw, sh - STATUSBAR_H); const int hdr_h = 30; uint32_t zx = (uint32_t)sw * 256u / (uint32_t)w; uint32_t zy = (uint32_t)((sh - STATUSBAR_H) - hdr_h) * 256u / (uint32_t)h; uint32_t zoom = (zx < zy) ? zx : zy; if (zoom > 256) zoom = 256; // never upscale in windowed mode if (zoom < 1) zoom = 1; lv_img_set_zoom(s_fm_img_widget, (uint16_t)zoom); lv_obj_align(s_fm_img_widget, LV_ALIGN_CENTER, 0, hdr_h / 2); } } static void fmImageFullCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); s_fm_img_fs = true; fmImageRelayout(); } static void fmImageRootClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_fm_img_fs) return; // windowed: background taps do nothing s_fm_img_fs = false; // full screen: any tap returns to windowed fmImageRelayout(); } // Persist the currently-viewed JPEG as the lock-screen wallpaper. By the time the // viewer is up the file is a confirmed JPEG (PNG is rejected earlier), so it's a // valid wallpaper. SD paths get the "sd:" prefix the decoder expects. #if CAP_LOCK_SCREEN static void fmSetWallpaperCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_fm_img_path[0]) return; char pref[TOUCH_LOCK_WALLPAPER_MAXLEN]; if (s_fm_img_on_sd) snprintf(pref, sizeof pref, "sd:%s", s_fm_img_path); else snprintf(pref, sizeof pref, "%s", s_fm_img_path); touchPrefsSetLockWallpaper(pref); #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) // the set of boards that declare s_lockwall_btn_lbl if (s_lockwall_btn_lbl && lv_obj_is_valid(s_lockwall_btn_lbl)) { // update the settings button if still around char disp[64]; lockwallDisplayName(pref, disp, sizeof disp); lv_label_set_text(s_lockwall_btn_lbl, disp); } #endif fmImageClose(); if (g_lv.task) g_lv.task->showAlert(TR("Lock wallpaper set"), 1300); } #endif // CAP_LOCK_SCREEN (fmSetWallpaperCb) #if CAP_SOUND_FILES // ---- .wav -> notification-sound chooser (opened from the File Manager) ------ static char s_fm_snd_path[208] = {0}; static bool s_fm_snd_on_sd = false; static lv_obj_t* s_fm_snd_root = nullptr; static void fmSndClose() { if (s_fm_snd_root) { popupClose(&s_fm_snd_root); } } static void fmSndCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) fmSndClose(); } static void fmSndBuildPref(char* pref, int cap) { if (s_fm_snd_on_sd) snprintf(pref, cap, "sd:%s", s_fm_snd_path); else snprintf(pref, cap, "%s", s_fm_snd_path); } static void fmSndPlayCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_fm_snd_path[0]) return; char pref[TOUCH_SOUND_PATH_MAXLEN]; fmSndBuildPref(pref, sizeof pref); if (!wavIsSupported(pref)) { if (g_lv.task) g_lv.task->showAlert(TR("Unsupported WAV (need 16-bit PCM)"), 2200); return; } uiPreviewWavFile(pref); } static void fmSndSetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_fm_snd_path[0]) return; char pref[TOUCH_SOUND_PATH_MAXLEN]; fmSndBuildPref(pref, sizeof pref); if (!wavIsSupported(pref)) { if (g_lv.task) g_lv.task->showAlert(TR("Unsupported WAV (need 16-bit PCM)"), 2200); return; } int slot = s_snd_pending_slot; if (slot < 0 || slot > 2) slot = TOUCH_SND_MSG; touchPrefsSetSoundFile(slot, pref); fmSndClose(); closeFullscreenView(); // tear down the File Manager so closing Sound won't reveal it openSettingsCategory(CAT_SOUND); // back to Settings -> Sound, fresh (shows the new file) if (g_lv.task) g_lv.task->showAlert(TR("Notification sound set"), 1300); } static void fmOpenAudio(const char* name) { if (!s_fm_fs || !name || !name[0]) return; fmMarkSdIo(); char path[208]; fmFullPath(name, path, sizeof path); strncpy(s_fm_snd_path, path, sizeof s_fm_snd_path - 1); s_fm_snd_path[sizeof s_fm_snd_path - 1] = '\0'; s_fm_snd_on_sd = fmIsSd(s_fm_fs); fmSndClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fm_snd_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fm_snd_root); lv_obj_set_size(s_fm_snd_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_fm_snd_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fm_snd_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_snd_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_fm_snd_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* fn = lv_label_create(s_fm_snd_root); lv_label_set_long_mode(fn, LV_LABEL_LONG_DOT); lv_obj_set_pos(fn, 8, 10); lv_obj_set_width(fn, sw - 16); lv_label_set_text(fn, name); lv_obj_set_style_text_font(fn, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(fn, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); static const char* const kSlot[3] = { "Message", "Direct (DM)", "@ mention" }; lv_obj_t* sub = lv_label_create(s_fm_snd_root); char subt[56]; snprintf(subt, sizeof subt, "Target: %s sound", kSlot[(s_snd_pending_slot >= 0 && s_snd_pending_slot < 3) ? s_snd_pending_slot : 0]); lv_label_set_text(sub, subt); lv_obj_set_pos(sub, 8, 40); lv_obj_set_style_text_font(sub, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_t* play = lv_btn_create(s_fm_snd_root); lv_obj_set_size(play, sw - 16, 40); lv_obj_set_pos(play, 8, 76); styleButton(play); lv_obj_add_event_cb(play, fmSndPlayCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* pl = lv_label_create(play); lv_label_set_text(pl, TR(LV_SYMBOL_PLAY " Play")); lv_obj_center(pl); useChainedFont(pl); lv_obj_t* setb = lv_btn_create(s_fm_snd_root); lv_obj_set_size(setb, sw - 16, 44); lv_obj_set_pos(setb, 8, 124); styleButton(setb); lv_obj_set_style_bg_color(setb, lv_color_hex(0x35C9C9), LV_PART_MAIN); lv_obj_add_event_cb(setb, fmSndSetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(setb); lv_label_set_text(sl, TR(LV_SYMBOL_OK " Set as notification sound")); useChainedFont(sl); lv_obj_set_style_text_color(sl, lv_color_black(), LV_PART_MAIN); lv_obj_center(sl); lv_obj_t* close = lv_btn_create(s_fm_snd_root); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 6); styleButton(close); lv_obj_add_event_cb(close, fmSndCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); } #endif // HAS_TDECK_GT911 || TLORA_PAGER (.wav notification-sound chooser) static void fmOpenImage(const char* name) { if (!s_fm_fs || !name || !name[0]) return; fmMarkSdIo(); // SD file read -> activity LED char path[200]; fmFullPath(name, path, sizeof path); strncpy(s_fm_img_path, path, sizeof s_fm_img_path - 1); s_fm_img_path[sizeof s_fm_img_path - 1] = '\0'; s_fm_img_on_sd = fmIsSd(s_fm_fs); File f = s_fm_fs->open(path, "r"); if (!f) { if (g_lv.task) g_lv.task->showAlert(TR("Cannot open file"), 1500); return; } size_t sz = f.size(); if (sz == 0 || sz > FM_IMG_MAX) { f.close(); if (g_lv.task) g_lv.task->showAlert(sz ? TR("Image too large (>4 MB)") : TR("Empty file"), 2000); return; } uint8_t* enc = (uint8_t*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); if (!enc) enc = (uint8_t*)malloc(sz); if (!enc) { f.close(); if (g_lv.task) g_lv.task->showAlert(TR("Out of memory"), 1500); return; } size_t rd = f.readBytes((char*)enc, sz); f.close(); // Decode to a full RGB565 buffer up front (LVGL's line-by-line readers can't // feed a scaled/zoomed lv_img — that renders black). Route by magic bytes: // PNG -> lodepng, BMP -> our reader, else JPEG (SJPG/TJpgDec). Same TRUE_COLOR // path the map tiles use, so it scales. int dw = 0, dh = 0; uint8_t* rgb; s_jpgScaleErr[0] = '\0'; // only the JPEG path sets this (e.g. "progressive") if (rd >= 4 && enc[0] == 0x89 && enc[1] == 'P' && enc[2] == 'N' && enc[3] == 'G') { rgb = decodePngToRgb565(enc, rd, &dw, &dh); } else if (rd >= 2 && enc[0] == 'B' && enc[1] == 'M') { rgb = decodeBmpToRgb565(enc, rd, &dw, &dh); } else { rgb = decodeJpegToRgb565(enc, rd, &dw, &dh); if (!rgb) rgb = decodeJpegScaledToRgb565(enc, rd, &dw, &dh, 1024); // > 1024 px JPEG: downscale to fit } free(enc); // encoded bytes no longer needed once decoded if (!rgb || dw <= 0 || dh <= 0) { if (rgb) lvglPsramFree(rgb); char emsg[96]; if (s_jpgScaleErr[0]) snprintf(emsg, sizeof emsg, "%s", s_jpgScaleErr); // specific JPEG reason (e.g. progressive) else snprintf(emsg, sizeof emsg, "Can't display image\n(JPEG/PNG/BMP, <= 1024 px)"); if (g_lv.task) g_lv.task->showAlert(emsg, 3600); return; } fmImageClose(); // drop any previous viewer (frees its buffer) s_fm_img_buf = rgb; // decoded RGB565 pixels; freed on close memset(&s_fm_img_dsc, 0, sizeof s_fm_img_dsc); s_fm_img_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; s_fm_img_dsc.header.w = (uint32_t)dw; s_fm_img_dsc.header.h = (uint32_t)dh; s_fm_img_dsc.data = s_fm_img_buf; s_fm_img_dsc.data_size = (uint32_t)dw * (uint32_t)dh * sizeof(lv_color_t); // s_fm_img_dsc is a reused static, so LVGL's image cache (keyed on the src // pointer) would hand back the PREVIOUS image's decoded geometry/data — the // "half old, half new" render. Drop the stale entry so this dsc decodes fresh. lv_img_cache_invalidate_src(&s_fm_img_dsc); const int w = dw, h = dh; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_fm_img_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_fm_img_root); lv_obj_set_size(s_fm_img_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_fm_img_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_fm_img_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_img_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_fm_img_root, LV_OBJ_FLAG_SCROLLABLE); // A tap on the backdrop leaves full screen (no-op while windowed). lv_obj_add_flag(s_fm_img_root, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(s_fm_img_root, fmImageRootClickCb, LV_EVENT_CLICKED, nullptr); // The image itself; zoom + position are applied by fmImageRelayout(). lv_obj_t* img = lv_img_create(s_fm_img_root); lv_img_set_src(img, &s_fm_img_dsc); lv_img_set_antialias(img, true); lv_img_set_pivot(img, w / 2, h / 2); // scale around the image centre lv_obj_clear_flag(img, LV_OBJ_FLAG_CLICKABLE); // let taps fall through to the backdrop // Header: filename + native dimensions (windowed only). char hdr[88]; snprintf(hdr, sizeof hdr, "%.48s %dx%d", name, w, h); lv_obj_t* fn = lv_label_create(s_fm_img_root); lv_label_set_long_mode(fn, LV_LABEL_LONG_DOT); lv_obj_set_pos(fn, 6, 8); lv_obj_set_width(fn, sw - 6 - 96); lv_label_set_text(fn, hdr); lv_obj_set_style_text_font(fn, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(fn, lv_color_hex(COLOR_SUB), LV_PART_MAIN); // Close (X), top-right. lv_obj_t* close = lv_btn_create(s_fm_img_root); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -3, 3); styleButton(close); lv_obj_set_style_bg_color(close, lv_color_hex(0x3A4A5C), LV_PART_MAIN); lv_obj_add_event_cb(close, fmImageCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); // Full-screen toggle, just left of the close (X). lv_obj_t* full = lv_btn_create(s_fm_img_root); lv_obj_set_size(full, 52, 26); lv_obj_align(full, LV_ALIGN_TOP_RIGHT, -3 - 30 - 4, 3); styleButton(full); lv_obj_set_style_bg_color(full, lv_color_hex(0x3A4A5C), LV_PART_MAIN); lv_obj_add_event_cb(full, fmImageFullCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* fll = lv_label_create(full); lv_label_set_text(fll, TR("Full")); lv_obj_set_style_text_font(fll, &g_font_12, LV_PART_MAIN); lv_obj_center(fll); #if CAP_LOCK_SCREEN // "Set as lock wallpaper" — bottom-left, windowed chrome only. lv_obj_t* wall = lv_btn_create(s_fm_img_root); lv_obj_set_height(wall, 30); lv_obj_align(wall, LV_ALIGN_BOTTOM_LEFT, 6, -6); styleButton(wall); lv_obj_set_style_bg_color(wall, lv_color_hex(0x35C9C9), LV_PART_MAIN); lv_obj_add_event_cb(wall, fmSetWallpaperCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* wll = lv_label_create(wall); lv_label_set_text(wll, TR(LV_SYMBOL_IMAGE " Set as wallpaper")); lv_obj_set_style_text_font(wll, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(wll, lv_color_black(), LV_PART_MAIN); lv_obj_center(wll); s_fm_img_wall = wall; #endif // CAP_LOCK_SCREEN (wallpaper button) // "tap to exit" hint, shown only in full-screen mode. lv_obj_t* hint = lv_label_create(s_fm_img_root); lv_label_set_text(hint, TR("tap to exit full screen")); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, lv_color_white(), LV_PART_MAIN); lv_obj_set_style_bg_color(hint, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(hint, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_pad_hor(hint, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(hint, 3, LV_PART_MAIN); lv_obj_set_style_radius(hint, 4, LV_PART_MAIN); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); s_fm_img_widget = img; s_fm_img_hdr = fn; s_fm_img_close = close; s_fm_img_full = full; s_fm_img_hint = hint; s_fm_img_w = w; s_fm_img_h = h; s_fm_img_fs = false; fmImageRelayout(); // apply the windowed layout (zoom, positions, visibility) if (g_lv.keyboard) lv_keyboard_set_textarea(g_lv.keyboard, nullptr); // no text entry here } // ----- row interaction ----- static void fmRowClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; FmRowData* rd = (FmRowData*)lv_obj_get_user_data(lv_event_get_target(e)); if (!rd) return; if (rd->isdir) fmEnterDir(rd->name); #if CAP_SOUND_FILES else if (fmIsAudio(rd->name)) fmOpenAudio(rd->name); // .wav -> notification-sound chooser #endif else if (fmIsImage(rd->name)) fmOpenImage(rd->name); // images -> read-only viewer else fmOpenEditor(rd->name); // text -> editor; long-press -> manage } static void fmRowLongPressCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); // swallow trailing click FmRowData* rd = (FmRowData*)lv_obj_get_user_data(lv_event_get_target(e)); if (rd) fmOpenActions(rd->name, rd->isdir); else fmOpenActions(nullptr, false); // ".." row -> folder actions } // Folder-level menu: the "+" header button (CLICKED) and a long-press on the // list's empty area (LONG_PRESSED). Only meaningful inside a filesystem. static void fmFolderMenuCb(lv_event_t* e) { const lv_event_code_t c = lv_event_get_code(e); if (c != LV_EVENT_CLICKED && c != LV_EVENT_LONG_PRESSED) return; if (c == LV_EVENT_LONG_PRESSED) { lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); } if (s_fm_fs) fmOpenActions(nullptr, false); } // Draw the current entries into the list, applying the active sort + filter. // (No FS access — works off the cached s_fm_entries so sort/search are instant.) static void fmRender() { if (!s_fm_list) return; lv_obj_clean(s_fm_list); if (s_fm_path_lbl) { char buf[180]; snprintf(buf, sizeof buf, "%s:%s", s_fm_store, s_fm_path); lv_label_set_text(s_fm_path_lbl, buf); } lv_obj_t* up = lv_list_add_btn(s_fm_list, LV_SYMBOL_LEFT, ".."); fmStyleRow(up, COLOR_SUB); lv_obj_add_event_cb(up, fmUpCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(up, fmRowLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); // folder actions if (s_fm_count > 1) qsort(s_fm_entries, s_fm_count, sizeof(FmEntry), fmCmp); int shown = 0; for (int i = 0; i < s_fm_count; ++i) { FmEntry& en = s_fm_entries[i]; if (!fmContainsCI(en.name, s_fm_filter)) continue; char label[120]; if (en.isdir) { snprintf(label, sizeof label, "%s", en.name); } else { char sz[16]; fmFmtSize((size_t)en.size, sz, sizeof sz); // Timestamp when the FS provides one and it's sane (> 2001; FAT's 1980 // epoch / a clockless write shows as garbage, better omitted) (#185). char ts[24] = ""; if (en.mtime > 978307200u) { time_t t = (time_t)en.mtime; struct tm tmv; localtime_r(&t, &tmv); strftime(ts, sizeof ts, " %d %b %H:%M", &tmv); } snprintf(label, sizeof label, "%s %s%s", en.name, sz, ts); } lv_obj_t* row = lv_list_add_btn(s_fm_list, en.isdir ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_FILE, label); fmStyleRow(row, COLOR_TEXT); FmRowData* rd = (FmRowData*)malloc(sizeof(FmRowData)); if (rd) { snprintf(rd->name, sizeof rd->name, "%s", en.name); rd->isdir = en.isdir; } lv_obj_set_user_data(row, rd); lv_obj_add_event_cb(row, fmRowFreeCb, LV_EVENT_DELETE, nullptr); lv_obj_add_event_cb(row, fmRowClickCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(row, fmRowLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); ++shown; } if (shown == 0) { lv_obj_t* empty = lv_list_add_text(s_fm_list, s_fm_filter[0] ? "(no matches)" : "(empty)"); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } } // Read the current directory into s_fm_entries, then render. // MeshCore/touch internal data files, hidden from the Internal view by default // (toggle via the + menu). The /bl/<hash> contact blobs are the bulk of the // clutter; the rest are a handful of named stores. static bool fmIsSystemPath(const char* full) { if (!full) return false; if (!strncmp(full, "/bl/", 4) || !strcmp(full, "/bl")) return true; const char* base = strrchr(full, '/'); base = base ? base + 1 : full; static const char* const kSys[] = { "contacts3", "channels2", "adv_blobs", "com_prefs", "new_prefs", "node_prefs", "regions2", "s_contacts", "packet_log", "log", "identity", "ui_chat_history_v1.bin", }; for (unsigned i = 0; i < sizeof(kSys)/sizeof(kSys[0]); ++i) if (!strcmp(base, kSys[i])) return true; return false; } // OS-metadata cruft on removable FAT/exFAT cards: macOS AppleDouble sidecars // ("._<name>") and dot-files (.DS_Store, .Spotlight-V100, .Trashes, .fseventsd, // .TemporaryItems…), the Finder "__MACOSX" ZIP folder, and Windows' "System // Volume Information". Hidden in the file manager unless "Show system files" is // on — same toggle that reveals MeshCore's own SPIFFS files. static bool fmIsHiddenName(const char* base) { if (!base || !base[0]) return false; if (base[0] == '.') return true; // ._* AppleDouble + all dot-files if (!strcmp(base, "__MACOSX")) return true; if (!strcasecmp(base, "System Volume Information")) return true; return false; } // Append an entry, de-duplicating by (name,isdir) so synthesised virtual folders collapse. static void fmAddEntry(const char* name, uint32_t size, bool isdir, uint32_t mtime = 0) { if (!name[0] || s_fm_count >= FM_MAX_ENTRIES) return; for (int i = 0; i < s_fm_count; ++i) if (s_fm_entries[i].isdir == isdir && !strcmp(s_fm_entries[i].name, name)) return; FmEntry& en = s_fm_entries[s_fm_count++]; snprintf(en.name, sizeof en.name, "%s", name); en.size = size; en.isdir = isdir; en.mtime = mtime; } static void fmRefresh() { if (!s_fm_list) return; if (!s_fm_fs) { fmShowRoots(); return; } s_fm_count = 0; if (!s_fm_entries) { fmRender(); return; } #if defined(ESP32) const bool flat = (s_fm_fs == &SPIFFS); // SPIFFS is flat: synthesise folders from path prefixes + hide system files #else const bool flat = false; #endif if (flat) { // Prefix of the current virtual folder: "" at root, "lock/" inside /lock, etc. char pfx[200]; if (s_fm_path[0] == '\0' || (s_fm_path[0] == '/' && s_fm_path[1] == '\0')) pfx[0] = '\0'; else snprintf(pfx, sizeof pfx, "%s/", s_fm_path + 1); const size_t pfxlen = strlen(pfx); File root = s_fm_fs->open("/"); if (root) { File e = root.openNextFile(); while (e) { const char* full = e.path(); // full path incl. leading '/' (name() is basename-only on this core) const uint32_t esz = (uint32_t)e.size(); const uint32_t emt = (uint32_t)e.getLastWrite(); // 0 when the FS has no mtime (#185) if (s_fm_show_hidden || !fmIsSystemPath(full)) { const char* rel = (full[0] == '/') ? full + 1 : full; if (!strncmp(rel, pfx, pfxlen)) { // belongs in the current virtual folder const char* sub = rel + pfxlen; if (sub[0]) { const char* slash = strchr(sub, '/'); if (slash) { // deeper path -> a sub-folder char seg[64]; size_t n = (size_t)(slash - sub); if (n >= sizeof seg) n = sizeof seg - 1; memcpy(seg, sub, n); seg[n] = '\0'; fmAddEntry(seg, 0, true); } else { fmAddEntry(sub, esz, false, emt); // a file in this folder } } } } e.close(); e = root.openNextFile(); } root.close(); } } else { fmMarkSdIo(); // SD browse -> activity LED File dir = s_fm_fs->open(s_fm_path); // SD / FAT: real directory listing if (dir) { File e = dir.openNextFile(); while (e && s_fm_count < FM_MAX_ENTRIES) { const char* full = e.name(); const char* base = strrchr(full, '/'); base = base ? base + 1 : full; if (s_fm_show_hidden || !fmIsHiddenName(base)) fmAddEntry(base, (uint32_t)e.size(), e.isDirectory(), (uint32_t)e.getLastWrite()); e.close(); e = dir.openNextFile(); } dir.close(); } } fmRender(); } // Roots screen: list the available storages. #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // ---- Tanmatsu / T-Display P4 microSD (SDMMC slot 0) ---------------------------------------------- // The card sits on the P4's SDMMC *slot 0* (IOMUX pins CLK43/CMD44/D0-3 39-42, selected by the // BOARD_SDMMC_SLOT=0 build define); slot 1 is the C6 radio (esp-hosted / ESP-AT SDIO), which we never // touch — so mounting here can't disturb Wi-Fi/BLE. 4-bit, mounted LAZILY on first browse (a no-op // on the T-Display P4, whose main.cpp already mounted the card at boot — SD_MMC.begin early-returns // when mounted). SD_MMC is the fs::FS the file manager browses, exactly like the T-Deck's &SD. static bool s_tan_sd_mounted = false; static uint64_t s_tan_sd_size = 0; static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render static bool tanSdTryMount() { if (s_tan_sd_mounted) return true; #if CAP_LUA_AUDIO if (luaAudioStorageBusy()) return false; #endif if (millis() < s_tan_sd_retry_after) return false; #if defined(HAS_TDISPLAY_P4) // Hot-insert path (no-op when main.cpp already mounted at boot): 20 MHz like the boot ladder, // not Arduino's 40 MHz HIGHSPEED default. if (SD_MMC.begin("/sdcard", false /*4-bit*/, false, SDMMC_FREQ_DEFAULT) && SD_MMC.cardType() != CARD_NONE) { #else if (SD_MMC.begin("/sdcard", false /*4-bit*/) && SD_MMC.cardType() != CARD_NONE) { #endif s_tan_sd_mounted = true; s_tan_sd_size = SD_MMC.cardSize(); return true; } SD_MMC.end(); s_tan_sd_retry_after = millis() + 8000; // don't grind a missing card on every roots render return false; } static void tanSdClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_tan_sd_retry_after = 0; // user tapped explicitly — bypass the backoff if (tanSdTryMount()) fmOpenStorage(&SD_MMC, "SD", "/"); else if (g_lv.task) g_lv.task->showAlert(TR("No SD card (or unreadable format)"), 2000); } #endif static void fmShowRoots() { s_fm_fs = nullptr; s_fm_path[0] = '\0'; s_fm_store[0] = '\0'; s_fm_count = 0; if (!s_fm_list) return; lv_obj_clean(s_fm_list); if (s_fm_path_lbl) lv_label_set_text(s_fm_path_lbl, TR("Storage")); char sub[48], us[16], ts[16]; #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) fmFmtSize(FFat.usedBytes(), us, sizeof us); // "Internal" = the FAT data partition (locfd / 'storage') fmFmtSize(FFat.totalBytes(), ts, sizeof ts); #else fmFmtSize(SPIFFS.usedBytes(), us, sizeof us); fmFmtSize(SPIFFS.totalBytes(), ts, sizeof ts); #endif snprintf(sub, sizeof sub, TR("Internal storage %s / %s"), us, ts); lv_obj_t* b = lv_list_add_btn(s_fm_list, LV_SYMBOL_DRIVE, sub); fmStyleRow(b, COLOR_TEXT); lv_obj_add_event_cb(b, fmInternalClickCb, LV_EVENT_CLICKED, nullptr); #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // microSD row (Arduino SD) — T-Deck + M9 + V4-R8 // Probe the SD only when not in a mount-backoff window, so a persistently // unmountable card doesn't re-grind the full retry ladder on every render of // this page. Tapping the row below (fmSdMountOrFormatCb) bypasses the gate. if ((s_sd_mounted || millis() >= s_sd_retry_after_ms) && fmSdTryMount()) { char sdl[48], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(sdl, sizeof sdl, TR("SD card %s (hold: format)"), cs); lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, sdl); fmStyleRow(sd, COLOR_TEXT); lv_obj_add_event_cb(sd, fmSdClickCb, LV_EVENT_SHORT_CLICKED, nullptr); // tap = open lv_obj_add_event_cb(sd, fmSdLongPressFormatCb, LV_EVENT_LONG_PRESSED, nullptr); // hold = format } else { lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, "SD card (tap to mount/format)"); fmStyleRow(sd, COLOR_SUB); lv_obj_add_event_cb(sd, fmSdMountOrFormatCb, LV_EVENT_CLICKED, nullptr); } #elif defined(TLORA_PAGER) // microSD row — browse + mount recovery, no in-app format // The card-detect line makes "not present" unambiguous, unlike the T-Deck (no detect // pin at all) -- so unlike its always-shown "tap to mount/format" fallback row, a bare // pager just shows no SD row at all rather than a row that can never succeed. if (board.sdCardPresent() && (s_sd_mounted || millis() >= s_sd_retry_after_ms) && fmSdTryMount() && s_sd_mounted) { char sdl[48], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(sdl, sizeof sdl, TR("SD card %s"), cs); lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, sdl); fmStyleRow(sd, COLOR_TEXT); lv_obj_add_event_cb(sd, fmSdClickCb, LV_EVENT_SHORT_CLICKED, nullptr); } else if (board.sdCardPresent()) { // Detected but not mounted. Surfacing it (greyed) is the whole recovery path // on this board: it distinguishes "card the firmware won't take" from the // absent case above, and gives a retry that bypasses the mount backoff. lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, TR("SD card (unreadable - tap to retry)")); fmStyleRow(sd, COLOR_SUB); lv_obj_add_event_cb(sd, fmSdPagerRetryMountCb, LV_EVENT_CLICKED, nullptr); } #endif #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // microSD (SDMMC slot 0). Probe outside the mount-backoff window so an absent card doesn't grind // the bus on every render; tapping the row forces a fresh mount attempt (tanSdClickCb clears it). if ((s_tan_sd_mounted || millis() >= s_tan_sd_retry_after) && tanSdTryMount()) { char sdl[48], cs[16]; fmFmtSize64(s_tan_sd_size, cs, sizeof cs); snprintf(sdl, sizeof sdl, TR("SD card %s"), cs); lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, sdl); fmStyleRow(sd, COLOR_TEXT); lv_obj_add_event_cb(sd, tanSdClickCb, LV_EVENT_CLICKED, nullptr); } else { lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, TR("SD card (tap to mount)")); fmStyleRow(sd, COLOR_SUB); lv_obj_add_event_cb(sd, tanSdClickCb, LV_EVENT_CLICKED, nullptr); } #endif } static void fmSortBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_fm_sort = (uint8_t)((s_fm_sort + 1) % 4); if (s_fm_sort_lbl) lv_label_set_text(s_fm_sort_lbl, k_fm_sort_names[s_fm_sort]); fmRender(); } static void fmSearchChangedCb(lv_event_t* e) { (void)e; if (!s_fm_search_ta) return; const char* t = lv_textarea_get_text(s_fm_search_ta); snprintf(s_fm_filter, sizeof s_fm_filter, "%s", t ? t : ""); fmRender(); } // Toggle the inline search field over the address bar. static void fmToggleSearch() { if (s_fm_search_ta) { hideKb(); lv_obj_del(s_fm_search_ta); s_fm_search_ta = nullptr; s_fm_filter[0] = '\0'; if (s_fm_path_lbl) lv_obj_clear_flag(s_fm_path_lbl, LV_OBJ_FLAG_HIDDEN); fmRender(); return; } if (!s_fm_path_lbl) return; lv_obj_t* parent = lv_obj_get_parent(s_fm_path_lbl); lv_coord_t x = lv_obj_get_x(s_fm_path_lbl); lv_coord_t y = lv_obj_get_y(s_fm_path_lbl); lv_coord_t w = lv_obj_get_width(s_fm_path_lbl); lv_obj_add_flag(s_fm_path_lbl, LV_OBJ_FLAG_HIDDEN); s_fm_search_ta = lv_textarea_create(parent); lv_obj_set_pos(s_fm_search_ta, x, y - 5); lv_obj_set_size(s_fm_search_ta, w, 26); styleCard(s_fm_search_ta); lv_textarea_set_one_line(s_fm_search_ta, true); taSetPlaceholder(s_fm_search_ta, TR("search")); lv_textarea_set_max_length(s_fm_search_ta, sizeof(s_fm_filter) - 1); lv_obj_set_style_text_font(s_fm_search_ta, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_fm_search_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_pad_ver(s_fm_search_ta, 2, LV_PART_MAIN); lv_obj_add_event_cb(s_fm_search_ta, fmSearchChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); attachSettingsTaEvents(s_fm_search_ta); if (g_lv.keyboard) kbMirrorBind(s_fm_search_ta); } static void fmBackCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_fm_search_ta) { fmToggleSearch(); return; } // close search first if (!s_fm_fs) { // already on the Storage (roots) page: Back == Home closeFullscreenView(); if (g_lv.tabview) lv_tabview_set_act(g_lv.tabview, HOME_TAB_INDEX, LV_ANIM_OFF); return; } fmUp(); } static void fmSearchBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; fmToggleSearch(); } // Write the bundled placeholder into SPIFFS /lock/ once, so the "lock" folder // exists for the (future) lockscreen and is viewable now. SPIFFS is flat, so // writing "/lock/placeholder.png" implicitly creates the folder. static void fmSeedLockFolder() { #if defined(ESP32) static bool tried = false; if (tried) return; tried = true; // Drop the stale PNG placeholder from earlier builds (PNG renders as noise here). if (SPIFFS.exists("/lock/placeholder.png")) SPIFFS.remove("/lock/placeholder.png"); // Seed the JPEG placeholder if missing or the wrong size. File chk = SPIFFS.open("/lock/placeholder.jpg", "r"); const bool good = chk && ((size_t)chk.size() == lockscreen_placeholder_jpg_len); if (chk) chk.close(); if (good) return; File f = SPIFFS.open("/lock/placeholder.jpg", "w"); if (!f) return; f.write(lockscreen_placeholder_jpg, lockscreen_placeholder_jpg_len); f.close(); #endif } static void buildFileManager(lv_obj_t* body) { fmSeedLockFolder(); // ensure /lock/placeholder.png exists (once per boot) // Keep the entry cache in PSRAM — it's 12 KB, and internal DRAM is tight // enough that holding it there can push SD mounting into an OOM abort. if (!s_fm_entries) { s_fm_entries = (FmEntry*)heap_caps_malloc(sizeof(FmEntry) * FM_MAX_ENTRIES, MALLOC_CAP_SPIRAM); if (!s_fm_entries) s_fm_entries = (FmEntry*)heap_caps_malloc(sizeof(FmEntry) * FM_MAX_ENTRIES, MALLOC_CAP_8BIT); } lv_obj_set_style_pad_all(body, 0, LV_PART_MAIN); lv_obj_clear_flag(body, LV_OBJ_FLAG_SCROLLABLE); const lv_coord_t bw = lv_disp_get_hor_res(nullptr); const lv_coord_t bh = (lv_disp_get_ver_res(nullptr) - STATUSBAR_H); const lv_coord_t HDR_H = 34, BTN_W = 38, BTN_H = 28, HOME_RES = 48; // Back button (far left): closes search if open, else goes up a folder. lv_obj_t* back = lv_btn_create(body); lv_obj_set_size(back, 30, BTN_H); lv_obj_set_pos(back, 3, 3); styleButton(back); lv_obj_set_style_bg_color(back, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(back, 0, LV_PART_MAIN); lv_obj_add_event_cb(back, fmBackCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* backl = lv_label_create(back); useChainedFont(backl); lv_label_set_text(backl, LV_SYMBOL_LEFT); lv_obj_center(backl); // "+" button (next to Back): opens the folder menu (New folder / Paste). lv_obj_t* add = lv_btn_create(body); lv_obj_set_size(add, 30, BTN_H); lv_obj_set_pos(add, 36, 3); styleButton(add); lv_obj_set_style_bg_color(add, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(add, 0, LV_PART_MAIN); lv_obj_add_event_cb(add, fmFolderMenuCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* addl = lv_label_create(add); useChainedFont(addl); lv_label_set_text(addl, LV_SYMBOL_PLUS); lv_obj_center(addl); // Find (search) button — rightmost before the floating Home. const lv_coord_t find_x = bw - HOME_RES - BTN_W - 3; lv_obj_t* find = lv_btn_create(body); lv_obj_set_size(find, BTN_W, BTN_H); lv_obj_set_pos(find, find_x, 3); styleButton(find); lv_obj_set_style_bg_color(find, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(find, 0, LV_PART_MAIN); lv_obj_add_event_cb(find, fmSearchBtnCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* findl = lv_label_create(find); lv_label_set_text(findl, TR("Find")); lv_obj_set_style_text_font(findl, &g_font_12, LV_PART_MAIN); lv_obj_center(findl); // Sort button — cycles A-Z / Z-A / Size / Type, label shows the mode. const lv_coord_t sort_x = find_x - BTN_W - 3; lv_obj_t* sort = lv_btn_create(body); lv_obj_set_size(sort, BTN_W, BTN_H); lv_obj_set_pos(sort, sort_x, 3); styleButton(sort); lv_obj_set_style_bg_color(sort, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(sort, 0, LV_PART_MAIN); lv_obj_add_event_cb(sort, fmSortBtnCb, LV_EVENT_CLICKED, nullptr); s_fm_sort_lbl = lv_label_create(sort); lv_label_set_text(s_fm_sort_lbl, k_fm_sort_names[s_fm_sort]); lv_obj_set_style_text_font(s_fm_sort_lbl, &g_font_12, LV_PART_MAIN); lv_obj_center(s_fm_sort_lbl); // Address bar (between the +/Back group and Sort), styled like a URL field. const lv_coord_t loc_x = 36 + 30 + 4; // past Back(3+30) and "+"(36+30) const lv_coord_t loc_w = sort_x - 4 - loc_x; s_fm_path_lbl = lv_label_create(body); lv_label_set_long_mode(s_fm_path_lbl, LV_LABEL_LONG_DOT); lv_obj_set_pos(s_fm_path_lbl, loc_x, 6); lv_obj_set_width(s_fm_path_lbl, loc_w); lv_obj_set_style_text_font(s_fm_path_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_fm_path_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_color(s_fm_path_lbl, lv_color_hex(0x101418), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_fm_path_lbl, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_fm_path_lbl, lv_color_hex(0x2A2E33), LV_PART_MAIN); lv_obj_set_style_border_width(s_fm_path_lbl, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_fm_path_lbl, 5, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_fm_path_lbl, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_fm_path_lbl, 3, LV_PART_MAIN); lv_label_set_text(s_fm_path_lbl, TR("Storage")); // Entry list fills the rest. s_fm_list = lv_list_create(body); lv_obj_set_size(s_fm_list, bw - 8, bh - HDR_H - 2); lv_obj_set_pos(s_fm_list, 4, HDR_H); lv_obj_set_style_bg_color(s_fm_list, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_border_width(s_fm_list, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(s_fm_list, 0, LV_PART_MAIN); // Long-press on the list's empty area opens the folder menu too. lv_obj_add_event_cb(s_fm_list, fmFolderMenuCb, LV_EVENT_LONG_PRESSED, nullptr); fmShowRoots(); } static void homeFilesCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_obj_t* body = openFullscreenView("Files"); buildFileManager(body); } #endif // ===== Signal & traffic detail popup (tap the home RX/TX graph) ============= static lv_obj_t* s_siginfo_root = nullptr; static lv_obj_t* s_sig_poll_ta = nullptr; // poll-interval entry (minutes) inside the popup static lv_obj_t* s_siginfo_chart = nullptr; static lv_chart_series_t* s_siginfo_chart_tx = nullptr; static lv_chart_series_t* s_siginfo_chart_rx = nullptr; static void closeSigInfoPopup() { if (s_siginfo_root) { // The poll-interval field can leave the shared on-screen keyboard up; tear it // down so it doesn't linger over the home screen after the popup is gone. if (g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN)) hideKb(); s_sig_poll_ta = nullptr; s_siginfo_chart = nullptr; s_siginfo_chart_tx = nullptr; s_siginfo_chart_rx = nullptr; popupClose(&s_siginfo_root); } } static void sigInfoDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Close on the backdrop or the X (target == the object the cb is on); a tap on // the card (which bubbles up) leaves it open. if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeSigInfoPopup(); } // Auto-discover toggle in the signal popup: persist immediately — the loop probe // reads the flag each cycle, so the change takes effect without a reboot. static void sigProbeToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetSigProbeEnabled(on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Auto-discover on") : TR("Auto-discover off"), 900); } // Poll-interval "Set": parse the minutes field, clamp 1..1440, persist, and write // the clamped value back so the user sees exactly what was stored. static void sigPollSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_sig_poll_ta) return; kbMirrorSyncToReal(); int m = atoi(lv_textarea_get_text(s_sig_poll_ta)); if (m < 1) m = 1; if (m > 1440) m = 1440; touchPrefsSetSigPollMins((uint16_t)m); char buf[8]; snprintf(buf, sizeof buf, "%d", m); lv_textarea_set_text(s_sig_poll_ta, buf); if (g_lv.task) { char msg[40]; snprintf(msg, sizeof msg, TR("Poll every %d min"), m); g_lv.task->showAlert(msg, 1100); } } // Manual probe: send a flood advert now so nearby repeaters echo it back. static void sigProbeNowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->sendSignalProbe(); // directed trace-ping to the nearest repeater (no flood); it replies with our link signal g_lv.task->showAlert(TR("Probing neighbours\xE2\x80\xA6"), 900); } static void openSignalInfoPopup() { closeSigInfoPopup(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_siginfo_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_siginfo_root); lv_obj_set_size(s_siginfo_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_siginfo_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_siginfo_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_siginfo_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_siginfo_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_siginfo_root, sigInfoDismissCb, LV_EVENT_CLICKED, nullptr); const int card_w = sw - 16; // near full-width, like the Send-advert modal int card_h = 340; // signal + auto-discover + probe + traffic const int avail = sh - STATUSBAR_H - 8; if (card_h > avail) card_h = avail; lv_obj_t* card = lv_obj_create(s_siginfo_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); addCloseXBadge(card, sigInfoDismissCb); lv_obj_t* ttl = lv_label_create(card); lv_label_set_text(ttl, TR("Signal & traffic")); lv_obj_set_style_text_color(ttl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ttl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(ttl, 0, 2); // ---- Signal readout (compact: header + two value rows, paired columns) ---- const uint32_t sms = the_mesh.uiSignalMs(); const bool heard = (sms != 0); const uint32_t age = heard ? (millis() - sms) : 0; const bool stale = !heard || age > 300000UL; // small helper: a font_12 label at a fixed cell position auto sigCell = [&](const char* txt, int x, int y, uint32_t col) { lv_obj_t* o = lv_label_create(card); lv_label_set_text(o, txt); lv_obj_set_style_text_color(o, lv_color_hex(col), LV_PART_MAIN); lv_obj_set_style_text_font(o, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(o, x, y); }; int sig_hdr_y = 30; int sig_row1_y = 48; int sig_row2_y = 66; #if defined(TLORA_PAGER) // The Home graph is necessarily shallow at Large/Jumbo because status text // and the launcher column consume the short viewport. Put a full-width copy // first in this scrollable detail card and mirror the same samples into it. constexpr int detail_chart_y = 34; constexpr int detail_chart_h = 104; s_siginfo_chart = lv_chart_create(card); lv_obj_set_size(s_siginfo_chart, card_w - 20, detail_chart_h); lv_obj_set_pos(s_siginfo_chart, 0, detail_chart_y); lv_obj_clear_flag(s_siginfo_chart, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(s_siginfo_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_siginfo_chart, 60); lv_chart_set_update_mode(s_siginfo_chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_range(s_siginfo_chart, LV_CHART_AXIS_PRIMARY_Y, 0, s_home_chart_max_y); lv_chart_set_div_line_count(s_siginfo_chart, 3, 6); lv_obj_set_style_bg_color(s_siginfo_chart, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_siginfo_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_siginfo_chart, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(s_siginfo_chart, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_border_width(s_siginfo_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_siginfo_chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(s_siginfo_chart, lv_color_hex(0x25292C), LV_PART_MAIN); lv_obj_set_style_line_width(s_siginfo_chart, 2, LV_PART_ITEMS); lv_obj_set_style_size(s_siginfo_chart, 0, LV_PART_INDICATOR); s_siginfo_chart_tx = lv_chart_add_series(s_siginfo_chart, lv_color_hex(COLOR_STATUS_OK), LV_CHART_AXIS_PRIMARY_Y); s_siginfo_chart_rx = lv_chart_add_series(s_siginfo_chart, lv_color_hex(0x4F94CD), LV_CHART_AXIS_PRIMARY_Y); // Clone the Home chart in display order. New samples are mirrored in // refreshStatusLabels(), so this remains live without a second sampler. if (s_home_chart && s_home_chart_tx && s_home_chart_rx) { uint16_t n = lv_chart_get_point_count(s_home_chart); const uint16_t tx0 = lv_chart_get_x_start_point(s_home_chart, s_home_chart_tx); const uint16_t rx0 = lv_chart_get_x_start_point(s_home_chart, s_home_chart_rx); lv_coord_t* tx = lv_chart_get_y_array(s_home_chart, s_home_chart_tx); lv_coord_t* rx = lv_chart_get_y_array(s_home_chart, s_home_chart_rx); for (uint16_t i = 0; i < n; ++i) { lv_chart_set_next_value(s_siginfo_chart, s_siginfo_chart_tx, tx[(tx0 + i) % n]); lv_chart_set_next_value(s_siginfo_chart, s_siginfo_chart_rx, rx[(rx0 + i) % n]); } } lv_obj_t* graph_legend = lv_label_create(s_siginfo_chart); char graph_legend_text[40]; snprintf(graph_legend_text, sizeof graph_legend_text, "#%06X TX# #4F94CD RX#", (unsigned)COLOR_STATUS_OK); lv_label_set_recolor(graph_legend, true); lv_label_set_text(graph_legend, graph_legend_text); lv_obj_set_style_text_font(graph_legend, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_bg_color(graph_legend, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(graph_legend, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_pad_hor(graph_legend, 3, LV_PART_MAIN); lv_obj_set_style_radius(graph_legend, 3, LV_PART_MAIN); lv_obj_align(graph_legend, LV_ALIGN_TOP_LEFT, 3, 3); const int sig_line_h = lv_font_get_line_height(&g_font_12); sig_hdr_y = detail_chart_y + detail_chart_h + 10; sig_row1_y = sig_hdr_y + sig_line_h; sig_row2_y = sig_row1_y + sig_line_h + 2; #endif sigCell(stale && heard ? "Signal (stale)" : "Signal", 0, sig_hdr_y, COLOR_TEXT); int cy; if (!heard) { sigCell("nothing heard yet", 8, sig_row1_y, COLOR_SUB); #if defined(TLORA_PAGER) cy = sig_row1_y + lv_font_get_line_height(&g_font_12) + 10; #else cy = 48 + 18 + 12; #endif } else { const float snr = the_mesh.uiSignalSnrQ4() / 4.0f; const int rssi = the_mesh.uiSignalRssi(); const int level = stale ? 0 : (snr >= 5 ? 4 : snr >= 0 ? 3 : snr >= -7 ? 2 : 1); char ago[24]; if (age < 60000UL) snprintf(ago, sizeof ago, "%lus ago", (unsigned long)(age / 1000)); else if (age < 3600000UL) snprintf(ago, sizeof ago, "%lum ago", (unsigned long)(age / 60000)); else snprintf(ago, sizeof ago, "%luh ago", (unsigned long)(age / 3600000UL)); char l1[28], r1[28], l2[28], r2[28]; snprintf(l1, sizeof l1, "SNR %.1f dB", (double)snr); snprintf(r1, sizeof r1, "RSSI %d dBm", rssi); snprintf(l2, sizeof l2, "Bars %d / 4", level); snprintf(r2, sizeof r2, TR("Heard %s"), ago); const int colx = (card_w - 20) / 2; // second column start sigCell(l1, 8, sig_row1_y, COLOR_TEXT); sigCell(r1, colx, sig_row1_y, COLOR_TEXT); // SNR | RSSI sigCell(l2, 8, sig_row2_y, COLOR_TEXT); sigCell(r2, colx, sig_row2_y, COLOR_TEXT); // Bars | Heard #if defined(TLORA_PAGER) cy = sig_row2_y + lv_font_get_line_height(&g_font_12) + 10; #else cy = 66 + 18 + 12; #endif } // ---- Auto-discover: on/off toggle (the periodic signal probe in loop()) ---- lv_obj_t* dlbl = lv_label_create(card); lv_label_set_text(dlbl, TR("Auto-discover")); lv_obj_set_style_text_color(dlbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(dlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(dlbl, 0, cy + 4); lv_obj_t* dsw = lv_switch_create(card); // theme recolours the "on" track to the accent lv_obj_set_size(dsw, 44, 24); lv_obj_set_pos(dsw, card_w - 20 - 44, cy); if (touchPrefsGetSigProbeEnabled()) lv_obj_add_state(dsw, LV_STATE_CHECKED); lv_obj_add_event_cb(dsw, sigProbeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); cy += 32; // ---- Poll interval (minutes; clamped 1..1440 on Set) ---- lv_obj_t* plbl = lv_label_create(card); lv_label_set_text(plbl, TR("Poll every")); lv_obj_set_style_text_color(plbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(plbl, &g_font_12, LV_PART_MAIN); #if defined(TLORA_PAGER) const int poll_line_h = lv_font_get_line_height(&g_font_12); const int poll_h = LV_MAX(32, poll_line_h + 10); lv_obj_update_layout(plbl); const int poll_x = lv_obj_get_width(plbl) + 12; const int poll_w = 64; lv_obj_set_pos(plbl, 0, cy + (poll_h - poll_line_h) / 2); #else lv_obj_set_pos(plbl, 0, cy + 7); #endif s_sig_poll_ta = lv_textarea_create(card); #if defined(TLORA_PAGER) lv_obj_set_size(s_sig_poll_ta, poll_w, poll_h); lv_obj_set_pos(s_sig_poll_ta, poll_x, cy); lv_obj_set_style_text_font(s_sig_poll_ta, &g_font_12, LV_PART_MAIN); #else lv_obj_set_size(s_sig_poll_ta, 46, 30); lv_obj_set_pos(s_sig_poll_ta, 70, cy); #endif lv_textarea_set_one_line(s_sig_poll_ta, true); lv_textarea_set_max_length(s_sig_poll_ta, 4); attachSettingsTaEvents(s_sig_poll_ta); { char b[8]; snprintf(b, sizeof b, "%u", (unsigned)touchPrefsGetSigPollMins()); lv_textarea_set_text(s_sig_poll_ta, b); } lv_obj_t* ulbl = lv_label_create(card); lv_label_set_text(ulbl, "min"); lv_obj_set_style_text_color(ulbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(ulbl, &g_font_12, LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_update_layout(ulbl); const int unit_x = poll_x + poll_w + 10; lv_obj_set_pos(ulbl, unit_x, cy + (poll_h - poll_line_h) / 2); #else lv_obj_set_pos(ulbl, 122, cy + 7); #endif lv_obj_t* setb = lv_btn_create(card); #if defined(TLORA_PAGER) lv_obj_update_layout(ulbl); const int set_x = unit_x + lv_obj_get_width(ulbl) + 12; lv_obj_set_size(setb, 58, poll_h); lv_obj_set_pos(setb, set_x, cy); #else lv_obj_set_size(setb, 50, 30); lv_obj_set_pos(setb, 168, cy); #endif styleButton(setb); lv_obj_add_event_cb(setb, sigPollSaveCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* setl = lv_label_create(setb); lv_label_set_text(setl, TR("Set")); #if defined(TLORA_PAGER) lv_obj_set_style_text_font(setl, &g_font_12, LV_PART_MAIN); #endif lv_obj_center(setl); #if defined(TLORA_PAGER) // The focused textarea's outline extends beyond its object bounds. Leave a // full control gap so that outline cannot cover the Probe button below it. cy += poll_h + 16; #else cy += 40; #endif // ---- Manual probe button ---- lv_obj_t* rfb = lv_btn_create(card); lv_obj_set_size(rfb, card_w - 20, 32); lv_obj_set_pos(rfb, 0, cy); styleButton(rfb); lv_obj_add_event_cb(rfb, sigProbeNowCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* rfl = lv_label_create(rfb); lv_label_set_text(rfl, TR(LV_SYMBOL_REFRESH " Probe now")); lv_obj_set_style_text_font(rfl, &g_font_14, LV_PART_MAIN); lv_obj_center(rfl); cy += 40; // ---- Traffic readout ---- char tbody[160]; snprintf(tbody, sizeof tbody, TR("Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct"), (unsigned)the_mesh.getNumSentFlood(), (unsigned)the_mesh.getNumSentDirect(), (unsigned)the_mesh.getNumRecvFlood(), (unsigned)the_mesh.getNumRecvDirect()); lv_obj_t* tlbl = lv_label_create(card); lv_label_set_long_mode(tlbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(tlbl, card_w - 20); lv_label_set_text(tlbl, tbody); lv_obj_set_style_text_color(tlbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(tlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(tlbl, 0, cy); } // ============================ Reader: on-device text browser ========================= // A minimal browser that fetches a URL over HTTP/HTTPS ENTIRELY ON THE DEVICE (no // proxy), strips the HTML to plain text, and shows it scrollable. HTTPS uses // setInsecure() — no cert store, no validation: this is a text reader, not a secure // client (the ask: an "apocalyptic" browser — on-device only, nothing leaves the // device to a third party). No JS / CSS / images by design. All network + parse work // runs on a core-0 worker task so LVGL never blocks; the UI loop polls s_reader_dirty. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static const char kReaderDefaultUrl[] = "wadamesh.com"; static const char kReaderHomeUrl[] = "sd:/home.htm"; static lv_obj_t* s_reader_root = nullptr; static lv_obj_t* s_reader_url_ta = nullptr; static lv_obj_t* s_reader_go = nullptr; static lv_obj_t* s_reader_editbtn= nullptr; // floating "edit URL" button (shown while reading) static lv_obj_t* s_reader_scroll = nullptr; // flex-column body container (holds text + link labels) static lv_obj_t* s_reader_status = nullptr; // status / error line static char s_reader_url[300] = ""; // the URL being (or last) fetched static char* s_reader_text = nullptr; // PSRAM: extracted text (NUL-terminated) static volatile bool s_reader_dirty = false; // worker -> UI: text/status changed static volatile bool s_reader_busy = false; // a fetch is in flight static volatile bool s_reader_ok = false; // last fetch produced a page (worker -> UI) static volatile bool s_reader_home_missing = false; // initial SD probe failed; restore URL-entry state static volatile bool s_reader_sd_busy = false; // open reader File owns the removable-card VFS static TaskHandle_t s_reader_sd_owner = nullptr; static volatile uint32_t s_reader_generation = 0; static volatile uint32_t s_reader_task_generation = 0; static char s_reader_pending_url[300] = ""; static char s_reader_msg[110] = ""; // status text (worker writes, UI shows) static TaskHandle_t s_reader_task = nullptr; static bool s_reader_pristine = false; // URL field still holds the untouched default static const size_t kReaderRawCap = 192 * 1024; // max raw HTML we buffer (PSRAM) static const size_t kReaderTextCap = 60 * 1024; // max extracted text we keep // Links found on the page: byte range in s_reader_text + resolved absolute href. using ReaderLink = ReaderContent::Link; static const int kReaderMaxLinks = 280; static ReaderLink* s_reader_links = nullptr; // PSRAM array, lazily allocated static int s_reader_nlinks = 0; // Navigation history (back / forward). A simple bounded stack in PSRAM: s_reader_hist_pos // is the current entry; back/forward walk it, a new navigation truncates any forward tail. static const int kReaderHistMax = 24; static char (*s_reader_hist)[300] = nullptr; // PSRAM [kReaderHistMax][300] static int s_reader_hist_n = 0; // entries in use static int s_reader_hist_pos = -1; // current index (-1 = none yet) static lv_obj_t* s_reader_navbar = nullptr; // bottom toolbar static lv_obj_t* s_reader_back = nullptr; static lv_obj_t* s_reader_fwd = nullptr; static void closeReaderPage(); static bool readerNavigate(const char* in, bool push); static void readerUpdateNavButtons(); static void readerRenderBody(); static void readerSetAddrExpanded(bool exp); static void readerShowLoading(); // case-insensitive prefix test static bool readerCiPrefix(const char* s, size_t n, const char* pfx) { size_t ln = strlen(pfx); if (n < ln) return false; for (size_t i = 0; i < ln; i++) { char a = s[i], b = pfx[i]; if (a >= 'A' && a <= 'Z') a += 32; if (b >= 'A' && b <= 'Z') b += 32; if (a != b) return false; } return true; } // A bounded Stream lets HTTPClient remove chunk framing while writing directly into // PSRAM. Short-writing at the cap stops oversized pages without allocating a String. class ReaderBufferStream final : public Stream { public: ReaderBufferStream(uint8_t* data, size_t capacity, uint32_t generation) : data_(data), capacity_(capacity), generation_(generation) {} using Print::write; size_t write(uint8_t byte) override { return write(&byte, 1); } size_t write(const uint8_t* data, size_t size) override { const size_t before = size_; const size_t room = size_ < capacity_ ? capacity_ - size_ : 0; const size_t copied = size < room ? size : room; if (copied) { memcpy(data_ + size_, data, copied); size_ += copied; if (generation_ == s_reader_generation && (before >> 14) != (size_ >> 14)) { snprintf(s_reader_msg, sizeof s_reader_msg, "Loading\xe2\x80\xa6 %uk", (unsigned)(size_ / 1024)); s_reader_dirty = true; } } return copied; } int available() override { return 0; } int read() override { return -1; } int peek() override { return -1; } void flush() override {} size_t size() const { return size_; } bool full() const { return size_ == capacity_; } private: uint8_t* data_; size_t capacity_; uint32_t generation_; size_t size_ = 0; }; enum class ReaderLocalResult : uint8_t { Ok, NoStorage, NotFound, ReadFailed }; static ReaderLocalResult readerReadLocal(const char* url, uint8_t* raw, size_t capacity, size_t* total, uint32_t generation) { fs::FS* storage = nullptr; bool storage_claimed = false; auto releaseStorage = [&]() { if (!storage_claimed) return; s_reader_sd_owner = nullptr; s_reader_sd_busy = false; storage_claimed = false; }; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) s_reader_sd_busy = true; s_reader_sd_owner = xTaskGetCurrentTaskHandle(); storage_claimed = true; if (sdAdoptLiveMount()) storage = &SD; else if (!sdRuntimeLifecycleBusy() && fmSdTryMount()) storage = &SD; #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) s_reader_sd_busy = true; s_reader_sd_owner = xTaskGetCurrentTaskHandle(); storage_claimed = true; if (tanSdTryMount()) storage = &SD_MMC; #endif if (!storage) { releaseStorage(); return ReaderLocalResult::NoStorage; } char path[300]; snprintf(path, sizeof path, "%s", url + 3); char* query = strchr(path, '?'); if (query) *query = 0; if (path[0] != '/') { releaseStorage(); return ReaderLocalResult::NotFound; } markSdIo(); File file = storage->open(path, "r"); if (!file || file.isDirectory()) { if (file) file.close(); releaseStorage(); return ReaderLocalResult::NotFound; } bool read_failed = false; while (*total < capacity && file.available()) { const size_t request = ((capacity - *total) < 4096) ? (capacity - *total) : 4096; const int read_count = file.read(raw + *total, request); if (read_count <= 0) { read_failed = true; break; } *total += (size_t)read_count; if (generation == s_reader_generation && ((*total) & 0x3FFF) == 0) { snprintf(s_reader_msg, sizeof s_reader_msg, "Opening SD page\xe2\x80\xa6 %uk", (unsigned)(*total / 1024)); s_reader_dirty = true; } vTaskDelay(1); } file.close(); releaseStorage(); return read_failed ? ReaderLocalResult::ReadFailed : ReaderLocalResult::Ok; } // Worker (core 0): load s_reader_url, strip to s_reader_text, publish via s_reader_dirty. static void readerFetchTaskFn(void*) { const uint32_t generation = s_reader_task_generation; char url[300]; strncpy(url, s_reader_url, sizeof url - 1); url[sizeof url - 1] = 0; bool result_ok = false; bool result_home_missing = false; char result_msg[sizeof s_reader_msg] = ""; const bool local = readerCiPrefix(url, strlen(url), "sd:/"); const bool https = readerCiPrefix(url, strlen(url), "https://"); // Big contiguous PSRAM is scarce on the 2 MB V4 (a chat's message ring fragments it), // so fall back to smaller buffers — a typical text page still fits in 48 KB. size_t rawcap = kReaderRawCap; uint8_t* raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); if (!raw) { rawcap = 96 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); } if (!raw) { rawcap = 48 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); } if (!raw) { rawcap = 24 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); } int code = -1; size_t total = 0; bool source_ok = false; ReaderLocalResult local_result = ReaderLocalResult::NoStorage; if (raw) { if (local) { local_result = readerReadLocal(url, raw, rawcap - 1, &total, generation); source_ok = local_result == ReaderLocalResult::Ok; } else { HTTPClient httpc; httpc.setReuse(false); httpc.setConnectTimeout(9000); httpc.setTimeout(15000); httpc.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); httpc.setUserAgent("wadamesh-reader"); WiFiClientSecure scli; WiFiClient pcli; bool began; if (https) { scli.setInsecure(); began = httpc.begin(scli, url); } else { began = httpc.begin(pcli, url); } if (began) { httpc.addHeader("Accept-Encoding", "identity"); code = httpc.GET(); } if (code == HTTP_CODE_OK) { ReaderBufferStream sink(raw, rawcap - 1, generation); const int transfer_result = httpc.writeToStream(&sink); total = sink.size(); source_ok = transfer_result >= 0 || sink.full() || total > 0; } httpc.end(); } } if (!raw) snprintf(result_msg, sizeof result_msg, "Out of memory"); else if (source_ok && total >= 2 && raw[0] == 0x1f && (uint8_t)raw[1] == 0x8b) snprintf(result_msg, sizeof result_msg, "Page is gzip-compressed \xe2\x80\x94 not supported yet"); else if (source_ok) { if (!s_reader_text) s_reader_text = (char*)heap_caps_malloc(kReaderTextCap, MALLOC_CAP_SPIRAM); if (!s_reader_links) s_reader_links = (ReaderLink*)heap_caps_malloc(sizeof(ReaderLink) * kReaderMaxLinks, MALLOC_CAP_SPIRAM); if (s_reader_text) { size_t parsed_links = 0; size_t tl = ReaderContent::htmlToText((const char*)raw, total, s_reader_text, kReaderTextCap, url, s_reader_links, kReaderMaxLinks, &parsed_links); s_reader_nlinks = (int)parsed_links; if (tl == 0) { strcpy(s_reader_text, "(no readable text on this page)"); s_reader_nlinks = 0; } snprintf(result_msg, sizeof result_msg, "%s", url); result_ok = true; } else snprintf(result_msg, sizeof result_msg, "Out of memory"); } else if (local) { const bool home = strcmp(url, kReaderHomeUrl) == 0; result_home_missing = home; if (home) snprintf(result_msg, sizeof result_msg, "No SD /home.htm found"); else if (local_result == ReaderLocalResult::NoStorage) snprintf(result_msg, sizeof result_msg, "SD card not available"); else if (local_result == ReaderLocalResult::ReadFailed) snprintf(result_msg, sizeof result_msg, "Couldn't read SD file"); else snprintf(result_msg, sizeof result_msg, "SD file not found"); } else if (code > 0) { snprintf(result_msg, sizeof result_msg, "HTTP %d", code); } else { snprintf(result_msg, sizeof result_msg, "Couldn't load (offline, bad URL, or TLS failed)"); } if (raw) heap_caps_free(raw); if (generation == s_reader_generation) { s_reader_ok = result_ok; s_reader_home_missing = result_home_missing; snprintf(s_reader_msg, sizeof s_reader_msg, "%s", result_msg); } s_reader_task = nullptr; s_reader_busy = false; s_reader_dirty = true; vTaskDelete(nullptr); } // Navigate to a URL. push=true records it in history (a fresh navigation from Go / a // link / Enter); push=false is a back / forward / refresh replay that must NOT grow // history. readerStart() is the public "new navigation" entry point. static bool readerNavigate(const char* in, bool push) { if (s_reader_busy || !in) return false; while (*in == ' ') in++; const bool local = readerCiPrefix(in, strlen(in), "sd:/"); char next_url[sizeof s_reader_url]; if (local || readerCiPrefix(in, strlen(in), "http://") || readerCiPrefix(in, strlen(in), "https://")) snprintf(next_url, sizeof next_url, "%s", in); else snprintf(next_url, sizeof next_url, "https://%s", in); // default to https if (!next_url[0] || (!local && !strchr(next_url, '.'))) { snprintf(s_reader_msg, sizeof s_reader_msg, "Enter a valid URL"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return false; } if (!local && WiFi.status() != WL_CONNECTED) { snprintf(s_reader_msg, sizeof s_reader_msg, "Wi-Fi not connected (Settings \xe2\x86\x92 Wi-Fi)"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return false; } char previous_url[sizeof s_reader_url]; snprintf(previous_url, sizeof previous_url, "%s", s_reader_url); snprintf(s_reader_url, sizeof s_reader_url, "%s", next_url); // Keep the address field in sync with what we're actually loading, so a failed load // shows the URL we tried (not the stale default that made it look like it "opened // wadamesh.com"). if (s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, s_reader_url); s_reader_pristine = false; } s_reader_ok = false; s_reader_home_missing = false; s_reader_task_generation = ++s_reader_generation; s_reader_busy = true; snprintf(s_reader_msg, sizeof s_reader_msg, "%s", local ? "Opening SD page\xe2\x80\xa6" : "Connecting\xe2\x80\xa6"); s_reader_dirty = true; // Immediate feedback (we're on the UI thread): collapse the URL into the topbar and // show a Loading placeholder at once, so a tapped link visibly does something now. if (s_reader_root) { readerSetAddrExpanded(false); readerShowLoading(); } if (xTaskCreatePinnedToCore(readerFetchTaskFn, "reader", 16384, nullptr, 4, &s_reader_task, 0) != pdPASS) { // Couldn't spawn the fetch worker (low internal RAM) — don't leave it stuck on // "Loading…" forever; surface it and drop the busy flag so a retry works. s_reader_task = nullptr; s_reader_busy = false; snprintf(s_reader_msg, sizeof s_reader_msg, "Low memory \xe2\x80\x94 try again"); s_reader_ok = false; s_reader_dirty = true; snprintf(s_reader_url, sizeof s_reader_url, "%s", previous_url); if (s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, previous_url[0] ? previous_url : kReaderDefaultUrl); s_reader_pristine = !previous_url[0]; } return false; } if (push && s_reader_hist) { // Drop any forward tail, then push (unless it repeats the current entry). if (s_reader_hist_pos < 0 || strcmp(s_reader_hist[s_reader_hist_pos], s_reader_url) != 0) { if (s_reader_hist_pos + 1 >= kReaderHistMax) { // full: slide the window down one memmove(s_reader_hist[0], s_reader_hist[1], sizeof(s_reader_hist[0]) * (kReaderHistMax - 1)); s_reader_hist_pos = kReaderHistMax - 2; } snprintf(s_reader_hist[++s_reader_hist_pos], 300, "%s", s_reader_url); } s_reader_hist_n = s_reader_hist_pos + 1; } readerUpdateNavButtons(); return true; } static void readerStart(const char* in) { readerNavigate(in, true); } static void readerBackCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_reader_busy) return; if (s_reader_hist_pos > 0) { const int target = s_reader_hist_pos - 1; if (readerNavigate(s_reader_hist[target], false)) { s_reader_hist_pos = target; readerUpdateNavButtons(); } } } static void readerFwdCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED && !s_reader_busy && s_reader_hist && s_reader_hist_pos + 1 < s_reader_hist_n) { const int target = s_reader_hist_pos + 1; if (readerNavigate(s_reader_hist[target], false)) { s_reader_hist_pos = target; readerUpdateNavButtons(); } } } static void readerRefreshCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED && !s_reader_busy && s_reader_url[0]) readerNavigate(s_reader_url, false); } // Dim the back / forward arrows when there's nowhere to go that way. static void readerUpdateNavButtons() { auto dim = [](lv_obj_t* b, bool on) { if (!b) return; lv_obj_t* l = lv_obj_get_child(b, 0); if (l) lv_obj_set_style_text_opa(l, on ? LV_OPA_COVER : LV_OPA_30, LV_PART_MAIN); }; dim(s_reader_back, s_reader_hist && s_reader_hist_pos > 0); dim(s_reader_fwd, s_reader_hist && s_reader_hist_pos + 1 < s_reader_hist_n); } static void readerGoCb(lv_event_t*) { if (s_reader_url_ta) readerStart(lv_textarea_get_text(s_reader_url_ta)); } static void readerUrlReadyCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_READY && s_reader_url_ta) readerStart(lv_textarea_get_text(s_reader_url_ta)); } // Tapping the pre-filled default clears it so you type over a blank field. static void readerUrlFocusCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_FOCUSED) return; if (s_reader_pristine && s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, ""); s_reader_pristine = false; } } static void readerUrlChangedCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_VALUE_CHANGED) s_reader_pristine = false; } // Tap a link -> navigate to its resolved href. static void readerLinkClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_reader_busy || !s_reader_links) return; int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx >= 0 && idx < s_reader_nlinks) readerStart(s_reader_links[idx].href); } // Show/hide the address bar: expanded = URL field + Go (entry); collapsed = a thin // button showing the loaded URL (so the page text gets almost the whole screen). static const int kReaderNavH = 14; // bottom toolbar height (thin) static inline int readerEditTop() { return STATUSBAR_H + 4; } // edit row sits BELOW the tall bar (live: STATUSBAR_H scales) static void readerSetAddrExpanded(bool exp) { if (!s_reader_root) return; const lv_coord_t sw = lv_disp_get_hor_res(nullptr), sh = lv_disp_get_ver_res(nullptr); const int H = sh - STATUSBAR_H; auto vis = [](lv_obj_t* o, bool show) { if (!o) return; if (show) lv_obj_clear_flag(o, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN); }; vis(s_reader_url_ta, exp); vis(s_reader_go, exp); vis(s_reader_status, exp); // The topbar ALWAYS shows a plain "‹ Web" (like the RF Monitor page) — no URL in the // bar (it was interfering with the back tap). The address only appears in the edit // field, which opens from the ✎ pencil in the bottom toolbar. Reading (collapsed): // fullscreen body. Editing (expanded): the field + Go sit clear of the tall bar. s_apppage_title = "Web"; s_reader_bar_url = false; updateGlobalStatusBar(); const int body_y = exp ? (readerEditTop() + 72) : (STATUSBAR_H + 2); if (s_reader_scroll) { lv_obj_set_pos(s_reader_scroll, 4, body_y); lv_obj_set_size(s_reader_scroll, sw - 8, (H - kReaderNavH) - body_y - 4); } readerUpdateNavButtons(); } static void readerShowMessage(const char* msg, uint32_t color) { if (!s_reader_scroll) return; lv_obj_clean(s_reader_scroll); lv_obj_set_flex_flow(s_reader_scroll, LV_FLEX_FLOW_COLUMN); lv_obj_t* l = lv_label_create(s_reader_scroll); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, lv_disp_get_hor_res(nullptr) - 8 - 16); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN); lv_label_set_text(l, msg); } static void readerShowLoading() { readerShowMessage(LV_SYMBOL_REFRESH " Loading\xe2\x80\xa6", 0x6FB7FF); } // Rebuild the body: flowing text split into paragraph labels + tappable link labels. static void readerRenderBody() { if (!s_reader_scroll) return; lv_obj_clean(s_reader_scroll); lv_obj_set_flex_flow(s_reader_scroll, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(s_reader_scroll, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_set_style_pad_row(s_reader_scroll, 2, LV_PART_MAIN); const int cw = lv_disp_get_hor_res(nullptr) - 8 - 16; auto mkLabel = [&](const char* s, bool link, int idx) { lv_obj_t* l = lv_label_create(s_reader_scroll); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, cw); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(link ? 0x6FB7FF : COLOR_TEXT), LV_PART_MAIN); if (link) { lv_obj_set_style_text_decor(l, LV_TEXT_DECOR_UNDERLINE, LV_PART_MAIN); lv_obj_add_flag(l, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(l, readerLinkClickCb, LV_EVENT_CLICKED, (void*)(intptr_t)idx); } lv_label_set_text(l, s); }; if (!s_reader_text || !s_reader_text[0]) { mkLabel("Type a URL above and tap Go.\n\nText-only: reads the words on a page (articles, wikis, docs, plain sites). No JavaScript, images or logins. Links are tappable.", false, -1); return; } // Walk the text, emitting text[cursor..link.start] as paragraphs and each link // range as a tappable label. Boundary whitespace is redundant once a range becomes // its own flex row; trimming it prevents paragraph newlines from becoming blank rows. // NUL-swap in place so we never copy big runs. const uint32_t tlen = (uint32_t)strlen(s_reader_text); auto mkRange = [&](uint32_t start, uint32_t end, bool link, int idx) { auto boundarySpace = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; while (start < end && boundarySpace(s_reader_text[start])) start++; while (end > start && boundarySpace(s_reader_text[end - 1])) end--; if (start == end) return false; char saved = s_reader_text[end]; s_reader_text[end] = 0; mkLabel(s_reader_text + start, link, idx); s_reader_text[end] = saved; return true; }; uint32_t cursor = 0; int blocks = 0; for (int i = 0; i < s_reader_nlinks && blocks < 500; i++) { ReaderLink& lk = s_reader_links[i]; if (lk.start < cursor || lk.end > tlen || lk.end <= lk.start) continue; if (lk.start > cursor && mkRange(cursor, lk.start, false, -1)) blocks++; if (mkRange(lk.start, lk.end, true, i)) blocks++; cursor = lk.end; } if (cursor < tlen) mkRange(cursor, tlen, false, -1); lv_obj_scroll_to_y(s_reader_scroll, 0, LV_ANIM_OFF); } static void closeReaderPage() { if (!s_reader_root) return; ++s_reader_generation; s_reader_pending_url[0] = 0; s_apppage_title = nullptr; s_apppage_close = nullptr; s_reader_bar_url = false; // un-hide the status-bar clock s_reader_page_open = false; // restore the tall-bar glass fade for other pages s_reader_hist_n = 0; s_reader_hist_pos = -1; // fresh history next open (buffer kept in PSRAM) statusBarSetTall(false); updateGlobalStatusBar(); popupClose(&s_reader_root); s_reader_url_ta = s_reader_go = s_reader_editbtn = s_reader_scroll = s_reader_status = nullptr; // a running worker only touches s_reader_text/msg/flags/links — safe s_reader_navbar = s_reader_back = s_reader_fwd = nullptr; } // The floating edit button (shown while reading) reopens the address field. static void readerEditBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; readerSetAddrExpanded(true); if (s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, s_reader_url); s_reader_pristine = false; lv_group_focus_obj(s_reader_url_ta); } } static void openReaderPage(const char* initial_url = nullptr) { closeReaderPage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const int H = sh - STATUSBAR_H; s_reader_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_reader_root); lv_obj_set_size(s_reader_root, sw, H); lv_obj_set_pos(s_reader_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_reader_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_reader_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_reader_root, LV_OBJ_FLAG_SCROLLABLE); s_apppage_title = "Web"; s_apppage_close = closeReaderPage; // ‹ / bar tap always closes the app s_reader_page_open = true; // solid tall bar on this page (no glass fade) statusBarSetTall(true); updateGlobalStatusBar(); if (!s_reader_hist) s_reader_hist = (char(*)[300])psAlloc(sizeof(s_reader_hist[0]) * kReaderHistMax); const int top = readerEditTop(), gow = 50; // edit row sits clear of the tall bar // --- Expanded address bar: URL field + Go --- s_reader_url_ta = lv_textarea_create(s_reader_root); lv_textarea_set_one_line(s_reader_url_ta, true); taSetPlaceholder(s_reader_url_ta, TR("Type a URL, then Go")); if (s_reader_url[0]) { lv_textarea_set_text(s_reader_url_ta, s_reader_url); s_reader_pristine = false; } else { lv_textarea_set_text(s_reader_url_ta, kReaderDefaultUrl); s_reader_pristine = true; } lv_obj_set_pos(s_reader_url_ta, 6, top); lv_obj_set_size(s_reader_url_ta, sw - 6 - gow - 14, 36); lv_obj_set_style_text_font(s_reader_url_ta, &g_font_14, LV_PART_MAIN); attachSettingsTaEvents(s_reader_url_ta); // keyboard + edit; keeps backspace in the field lv_obj_add_event_cb(s_reader_url_ta, readerUrlReadyCb, LV_EVENT_READY, nullptr); // Enter = Go lv_obj_add_event_cb(s_reader_url_ta, readerUrlFocusCb, LV_EVENT_FOCUSED, nullptr); // clear the default on tap lv_obj_add_event_cb(s_reader_url_ta, readerUrlChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); s_reader_go = lv_btn_create(s_reader_root); lv_obj_set_size(s_reader_go, gow, 36); lv_obj_align_to(s_reader_go, s_reader_url_ta, LV_ALIGN_OUT_RIGHT_MID, 8, 0); styleButton(s_reader_go); lv_obj_add_event_cb(s_reader_go, readerGoCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* gl = lv_label_create(s_reader_go); lv_label_set_text(gl, TR("Go")); lv_obj_center(gl); } s_reader_status = lv_label_create(s_reader_root); lv_label_set_long_mode(s_reader_status, LV_LABEL_LONG_DOT); lv_obj_set_width(s_reader_status, sw - 12); lv_obj_set_pos(s_reader_status, 6, top + 44); lv_obj_set_style_text_font(s_reader_status, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_reader_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_text(s_reader_status, s_reader_msg[0] ? s_reader_msg : TR("On-device text browser \xc2\xb7 no proxy, no tracking")); // (Collapsed state shows the URL in the status-bar title zone — see readerSetAddrExpanded.) // --- Body: a vertical scroll container we fill with paragraph + link labels --- s_reader_scroll = lv_obj_create(s_reader_root); lv_obj_remove_style_all(s_reader_scroll); lv_obj_set_style_bg_color(s_reader_scroll, lv_color_hex(0x14161A), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_reader_scroll, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_reader_scroll, 6, LV_PART_MAIN); lv_obj_set_style_pad_all(s_reader_scroll, 8, LV_PART_MAIN); lv_obj_set_scroll_dir(s_reader_scroll, LV_DIR_VER); // --- Bottom toolbar: back / forward / refresh / edit-URL (always visible) --- s_reader_navbar = lv_obj_create(s_reader_root); lv_obj_remove_style_all(s_reader_navbar); lv_obj_set_size(s_reader_navbar, sw, kReaderNavH); lv_obj_set_pos(s_reader_navbar, 0, H - kReaderNavH); lv_obj_set_style_bg_color(s_reader_navbar, lv_color_hex(0x000000), LV_PART_MAIN); // black bar lv_obj_set_style_bg_opa(s_reader_navbar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_reader_navbar, lv_color_hex(0x222222), LV_PART_MAIN); lv_obj_set_style_border_width(s_reader_navbar, 1, LV_PART_MAIN); lv_obj_set_style_border_side(s_reader_navbar, LV_BORDER_SIDE_TOP, LV_PART_MAIN); lv_obj_set_style_pad_all(s_reader_navbar, 0, LV_PART_MAIN); lv_obj_clear_flag(s_reader_navbar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(s_reader_navbar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(s_reader_navbar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); auto navBtn = [&](const char* sym, lv_event_cb_t cb) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(s_reader_navbar); lv_obj_remove_style_all(b); lv_obj_set_size(b, 46, kReaderNavH - 2); lv_obj_set_style_bg_opa(b, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_radius(b, 3, LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x1E1E1E), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, LV_STATE_PRESSED); lv_obj_set_ext_click_area(b, 8); // thin bar, so pad the tap target lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, sym); lv_obj_center(l); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(0x9FD8FF), LV_PART_MAIN); return b; }; s_reader_back = navBtn(LV_SYMBOL_LEFT, readerBackCb); s_reader_fwd = navBtn(LV_SYMBOL_RIGHT, readerFwdCb); navBtn(LV_SYMBOL_REFRESH, readerRefreshCb); s_reader_editbtn = navBtn(LV_SYMBOL_EDIT, readerEditBtnCb); if (s_reader_busy) readerShowLoading(); else readerRenderBody(); // Reopen after a successful load -> collapsed (fullscreen reading); first open or // after an error -> expanded so you can type a URL. readerSetAddrExpanded(!(s_reader_ok && s_reader_text && s_reader_text[0])); // Force the status bar ABOVE the reader so its "‹ title" back tap is hittable. The // shared move-foreground in updateGlobalStatusBar is edge-triggered and can miss when // this page opens over a chat, leaving the tall bar's lower row (the back tap) buried // under the reader root. if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root); const char* target = initial_url && initial_url[0] ? initial_url : kReaderHomeUrl; if (s_reader_busy) { snprintf(s_reader_pending_url, sizeof s_reader_pending_url, "%s", target); readerSetAddrExpanded(false); readerShowLoading(); } else { s_reader_pending_url[0] = 0; readerStart(target); } } #endif // Reader // ===== Discover app (active node-discovery + live signal-ranked list) ===================== // Fires a zero-hop NODE_DISCOVER_REQ sweep (ALL node types) every few seconds while open and lists // every node that answers, ranked by signal. The engine (MyMesh::_discover[] + uiStartDiscoverScan // + discoverUpsert) collects the replies; this page renders them + drives the cadence, airtime-gated // so a repeating scan respects the duty-cycle budget. Rendered as ONE recolored label (like the RF // Monitor feed) — light + scroll-safe, no per-row object churn. Map + wardriving build on top later. static lv_obj_t* s_discover_root = nullptr; static lv_obj_t* s_discover_feed = nullptr; // single recolored multi-line label static lv_obj_t* s_discover_status = nullptr; // header: "Scanning… · N nearby" static lv_obj_t* s_discover_btn_lbl = nullptr; // Scan/Stop button label static lv_timer_t* s_discover_timer = nullptr; static bool s_discover_scanning = true; static uint32_t s_discover_last_scan_ms = 0; static void closeDiscoverPage(); static void discoverJumpToMapHere(); // "Show on map" — defined with the map code (uses map statics) // node_type -> row colour / short label (mirrors the map marker colours). static uint32_t discTypeColor(uint8_t t) { switch (t) { case ADV_TYPE_REPEATER: return 0x4DA8FF; // blue case ADV_TYPE_CHAT: return 0x53C06B; // green case ADV_TYPE_ROOM: return 0xF0A020; // amber case ADV_TYPE_SENSOR: return 0x35C9C9; // cyan default: return 0x9AA0A6; // grey } } static const char* discTypeName(uint8_t t) { switch (t) { case ADV_TYPE_REPEATER: return "repeater"; case ADV_TYPE_CHAT: return "companion"; case ADV_TYPE_ROOM: return "room"; case ADV_TYPE_SENSOR: return "sensor"; default: return "node"; } } // Single-letter type for the LIST rows (#191): the full word plus a long node name pushed the // trailing last-heard age off the 320px line. The summary header keeps the full words. static const char* discTypeShort(uint8_t t) { switch (t) { case ADV_TYPE_REPEATER: return "R"; case ADV_TYPE_CHAT: return "C"; case ADV_TYPE_ROOM: return "Rm"; case ADV_TYPE_SENSOR: return "S"; default: return "?"; } } // ---- Wardriving: log GPS-tagged sightings as we scan (a standalone, on-device MeshMapper) ---- // While the Discover app scans, sample our GPS position; at each sample, log every currently-heard // node (our position + its type + signal) to an SD CSV, and keep an in-RAM coverage ring for the map // overlay. A 0-hop hit means "reachable from HERE", so the track of samples IS a personal RF-coverage // map. All UI-side (needs GPS, which the engine doesn't have). Map rendering reads s_disc_track[]. struct DiscTrackPt { int32_t lat_e6, lon_e6; int8_t best_rssi; uint8_t node_count; }; static const int k_disc_track_max = 160; static DiscTrackPt* s_disc_track = (DiscTrackPt*)psAlloc(sizeof(DiscTrackPt) * k_disc_track_max); // PSRAM (~1KB off .bss) static int s_disc_track_n = 0; // total samples ever (ring slot = n % max) static int32_t s_disc_last_lat = 0, s_disc_last_lon = 0; static uint32_t s_disc_last_sample_ms = 0; static uint32_t s_disc_log_count = 0; // sightings written to SD this session static lv_obj_t* s_disc_footer = nullptr; // bottom status line: GPS / wardrive state static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::DiscoverHit& h) { #if CAP_SD || defined(TLORA_PAGER) if (SD.cardType() == CARD_NONE) return; markSdIo(); SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/discover"); File f = SD.open("/meshcomod/discover/wardrive.csv", FILE_APPEND); if (!f) { sdNoteIoFailure(); return; } if (f.size() == 0) f.print("epoch,lat,lon,type,pubkey,rssi,snr,hops\n"); uint32_t epoch = (uint32_t)rtc_clock.getCurrentTime(); f.printf("%lu,%.6f,%.6f,%s,%02X%02X%02X%02X,%d,%.1f,%u\n", (unsigned long)epoch, (double)lat_e6 / 1e6, (double)lon_e6 / 1e6, discTypeName(h.node_type), h.pubkey[0], h.pubkey[1], h.pubkey[2], h.pubkey[3], (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, (unsigned)h.path_len); f.close(); s_disc_log_count++; #else (void)lat_e6; (void)lon_e6; (void)h; #endif } // Defined later (contacts detail section); used here for the Discover rows (#239). static void formatDistanceBadge(char* out, size_t out_cap, double self_lat, double self_lon, int32_t c_lat_e6, int32_t c_lon_e6); // Called every scan tick: refresh the footer + (once we've moved ~15 m) log a coverage sample. static void discoverWardriveTick() { UITask* task = g_lv.task; const bool fix = task && task->getGpsFix(); const double latd = fix ? task->getNodeLat() : 0.0, lond = fix ? task->getNodeLon() : 0.0; if (fix && !(latd == 0.0 && lond == 0.0)) { const int32_t lat_e6 = (int32_t)lround(latd * 1e6), lon_e6 = (int32_t)lround(lond * 1e6); const uint32_t now = millis(); int32_t dlat = lat_e6 - s_disc_last_lat; if (dlat < 0) dlat = -dlat; int32_t dlon = lon_e6 - s_disc_last_lon; if (dlon < 0) dlon = -dlon; if (s_disc_last_sample_ms == 0 || dlat > 150 || dlon > 150 || (now - s_disc_last_sample_ms) > 20000) { int nodes = 0; int8_t best = -128; uint8_t n = the_mesh.discoverCount(); for (uint8_t i = 0; i < n; i++) { MyMesh::DiscoverHit h; if (!the_mesh.discoverGet(i, h) || (now - h.last_ms) > 8000) continue; // only currently-heard nodes++; if (h.our_rssi > best) best = h.our_rssi; discoverLogSighting(lat_e6, lon_e6, h); } if (nodes > 0) { DiscTrackPt& p = s_disc_track[s_disc_track_n % k_disc_track_max]; p.lat_e6 = lat_e6; p.lon_e6 = lon_e6; p.best_rssi = best; p.node_count = (uint8_t)(nodes > 255 ? 255 : nodes); s_disc_track_n++; } s_disc_last_lat = lat_e6; s_disc_last_lon = lon_e6; s_disc_last_sample_ms = now; } } if (s_disc_footer) { if (!fix) lv_label_set_text(s_disc_footer, TR("#7A7F87 Wardrive: waiting for GPS fix\xE2\x80\xA6#")); else lv_label_set_text_fmt(s_disc_footer, TR("#7A7F87 Wardrive: %d coverage pts \xC2\xB7 %lu logged to SD#"), s_disc_track_n > k_disc_track_max ? k_disc_track_max : s_disc_track_n, (unsigned long)s_disc_log_count); } } // Per-row snapshot for tap-to-add (populated by discoverBuildFeed in the displayed sort order): // full pubkey + type + name, so a tap on the feed resolves to the exact node under it. static const int DISC_MAXROWS = 40; static uint8_t (*s_disc_row_key)[32] = nullptr; // PSRAM, allocated when Discover opens static uint8_t s_disc_row_type[DISC_MAXROWS]; static char (*s_disc_row_name)[26] = nullptr; // PSRAM (see discoverRowsReady) static int s_disc_row_n = 0; static void recolorEscape(char* dst, size_t cap, const char* src); // defined with the chat helpers // Rebuild the feed label from the engine's _discover[] table, strongest-signal first. // Discover's per-row snapshots are ~2.3 KB that only matter while the page is // open; on the V4 that is a meaningful slice of internal DRAM, so PSRAM it is. static bool discoverRowsReady() { if (s_disc_row_key) return true; s_disc_row_key = (uint8_t(*)[32])heap_caps_calloc(DISC_MAXROWS, 32, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); s_disc_row_name = (char(*)[26]) heap_caps_calloc(DISC_MAXROWS, 26, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_disc_row_key) s_disc_row_key = (uint8_t(*)[32])calloc(DISC_MAXROWS, 32); if (!s_disc_row_name) s_disc_row_name = (char(*)[26]) calloc(DISC_MAXROWS, 26); return s_disc_row_key && s_disc_row_name; } static void discoverBuildFeed() { if (!s_discover_feed) return; const uint32_t now = millis(); static MyMesh::DiscoverHit hits[MyMesh::DISCOVER_MAX]; static uint8_t idx[MyMesh::DISCOVER_MAX]; uint8_t m = 0; uint8_t n = the_mesh.discoverCount(); for (uint8_t i = 0; i < n && m < MyMesh::DISCOVER_MAX; i++) { if (the_mesh.discoverGet(i, hits[m])) { idx[m] = m; m++; } } for (uint8_t a = 0; a < m; a++) // insertion sort by our_rssi desc for (uint8_t b = (uint8_t)(a + 1); b < m; b++) if (hits[idx[b]].our_rssi > hits[idx[a]].our_rssi) { uint8_t t = idx[a]; idx[a] = idx[b]; idx[b] = t; } static char buf[3200]; int q = 0, rpt = 0, comp = 0; const int MAXROWS = DISC_MAXROWS; s_disc_row_n = 0; for (uint8_t k = 0; k < m && k < MAXROWS && q < (int)sizeof(buf) - 112; k++) { const MyMesh::DiscoverHit& h = hits[idx[k]]; if (h.node_type == ADV_TYPE_REPEATER) rpt++; else if (h.node_type == ADV_TYPE_CHAT) comp++; // #223: a name can carry emoji (multi-byte UTF-8) or a literal '#'. Cutting it at // a byte count splits a sequence, and an unescaped '#' toggles this label's // recolor mode — either desyncs the '#RRGGBB …#' runs and visually kills every // row after this one. Keep a structurally-clean copy here (font=nullptr: emoji // preserved for the tap-to-add snapshot), and escape a capped display copy below. char name[26]; ContactInfo* c = the_mesh.lookupContactByPubKey((uint8_t*)h.pubkey, 6); if (c && c->name[0]) copyUtf8ReplacingMissingGlyphs(nullptr, name, sizeof name, c->name); else snprintf(name, sizeof name, "Node \xC2\xB7%02X%02X", h.pubkey[0], h.pubkey[1]); if (!discoverRowsReady()) break; memcpy(s_disc_row_key[k], h.pubkey, 32); // snapshot this row for tap-to-add s_disc_row_type[k] = h.node_type; snprintf(s_disc_row_name[k], sizeof s_disc_row_name[k], "%s", name); s_disc_row_n = k + 1; uint32_t age = (now - h.last_ms) / 1000; char ago[10]; if (age < 60) snprintf(ago, sizeof ago, "%us", (unsigned)age); else snprintf(ago, sizeof ago, "%um", (unsigned)(age / 60)); const char* direct = (h.path_len == 0) ? " #53C06B direct#" : ""; // Distance (#239): the DISCOVER_RESP wire format carries no position, but a // node we've also heard an advert from has lat/lon on its contact entry — // pair that with our own fix. Empty badge = one of the fixes is unknown. char dist[16] = ""; if (c && g_lv.task && g_lv.task->getGpsFix()) formatDistanceBadge(dist, sizeof dist, g_lv.task->getNodeLat(), g_lv.task->getNodeLon(), c->gps_lat, c->gps_lon); char dist_seg[24] = ""; if (dist[0]) snprintf(dist_seg, sizeof dist_seg, " %s", dist); // #191: cap the name to 14 bytes (on a codepoint boundary, glyphs mapped to the // feed's font) + single-letter type so the trailing age ALWAYS fits the line. // recolorEscape doubles '#' so a hash in a name can't break the colour run. char disp[15], disp_esc[30]; copyUtf8ReplacingMissingGlyphs(&g_font_12, disp, sizeof disp, name); recolorEscape(disp_esc, sizeof disp_esc, disp); q += snprintf(buf + q, sizeof(buf) - q, "#%06X %s# %s %ddBm %.1f%s%s \xC2\xB7 %s\n", (unsigned)discTypeColor(h.node_type), disp_esc, discTypeShort(h.node_type), (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, direct, dist_seg, ago); } if (q == 0) snprintf(buf, sizeof buf, TR("#7A7F87 Scanning\xE2\x80\xA6 nothing has answered yet#")); else if (buf[q - 1] == '\n') buf[q - 1] = '\0'; lv_label_set_text(s_discover_feed, buf); if (s_discover_status) lv_label_set_text_fmt(s_discover_status, TR("%s \xC2\xB7 %d nearby (%d rpt, %d comp)"), s_discover_scanning ? TR("Scanning\xE2\x80\xA6") : TR("Paused"), (int)m, rpt, comp); } // Sweep interval, backing off. A probe is not one packet: it is our zero-hop // broadcast PLUS a reply from every node that hears it, so a fixed 4 s sweep // with the page left open put a burst on the whole neighbourhood every four // seconds, for as long as it stayed open. Reported from a regional packet // monitor as "a ton of Control packets" that could not be attributed to any // node (a discovery REQ carries no sender identity, by design). // // The answer is not simply "slower": the first seconds are when the user is // actually looking, so those stay fast and the steady state goes quiet. Same // reasoning as the 15 s floor the Lua SDK enforces on wada.mesh.discover(), // which this used to undercut by nearly 4x while being the built-in. static const uint32_t kDiscSweepMs[] = { 4000, 4000, 8000, 15000, 30000 }; static uint8_t s_disc_sweep_n = 0; static uint32_t discoverSweepInterval() { const uint8_t n = sizeof(kDiscSweepMs) / sizeof(kDiscSweepMs[0]); return kDiscSweepMs[s_disc_sweep_n < n ? s_disc_sweep_n : (n - 1)]; } static void discoverTimerCb(lv_timer_t* t) { (void)t; if (!s_discover_root) return; const uint32_t now = millis(); if (s_discover_scanning && (s_discover_last_scan_ms == 0 || (now - s_discover_last_scan_ms) >= discoverSweepInterval())) { if (the_mesh.getRemainingTxBudget() >= 300) { // airtime gate: keep a duty-cycle cushion the_mesh.uiStartDiscoverScan(0); // 0 = all node types s_discover_last_scan_ms = now; if (s_disc_sweep_n < 250) s_disc_sweep_n++; } } discoverBuildFeed(); discoverWardriveTick(); } static void discoverScanToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_discover_scanning = !s_discover_scanning; if (s_discover_scanning) { s_discover_last_scan_ms = 0; // resume -> sweep immediately... s_disc_sweep_n = 0; // ...and at the fast cadence again } if (s_discover_btn_lbl) lv_label_set_text(s_discover_btn_lbl, s_discover_scanning ? "Stop" : "Scan"); discoverBuildFeed(); } static void discoverShowMapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; discoverJumpToMapHere(); // centre the map on us + open the Map tab (coverage overlay draws there) } // Tap a row in the feed -> add that node to contacts (using the full pubkey the discovery reply // carried). Resolves the row from the tap's Y (uniform one-line rows: LONG_CLIP + a fixed line_space). static void discoverFeedTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_discover_feed || s_disc_row_n == 0) return; lv_indev_t* indev = lv_indev_get_act(); if (!indev) return; lv_point_t p; lv_indev_get_point(indev, &p); lv_area_t a; lv_obj_get_coords(s_discover_feed, &a); const int local_y = (int)p.y - (int)a.y1; if (local_y < 0) return; const int row_h = (int)lv_font_get_line_height(&g_font_12) + 3; // == the label's line_space const int row = local_y / row_h; if (row < 0 || row >= s_disc_row_n) return; // tapped empty space below the list const uint8_t* pk = s_disc_row_key[row]; if (the_mesh.lookupContactByPubKey((uint8_t*)pk, PUB_KEY_SIZE) != nullptr) { if (g_lv.task) g_lv.task->showAlert(TR("Already in contacts"), 1100); return; } if (the_mesh.uiAddDiscoveredContact(pk, s_disc_row_type[row], s_disc_row_name[row])) { if (g_lv.task) { char msg[44]; snprintf(msg, sizeof msg, "%s %s", TR("Added"), s_disc_row_name[row]); g_lv.task->showAlert(msg, 1400); } } else if (g_lv.task) { g_lv.task->showAlert(TR("Could not add"), 1100); } } static void closeDiscoverPage() { if (s_discover_timer) { lv_timer_del(s_discover_timer); s_discover_timer = nullptr; } if (s_discover_root) { popupClose(&s_discover_root); } s_discover_feed = s_discover_status = s_discover_btn_lbl = s_disc_footer = nullptr; s_disc_row_n = 0; s_discover_scanning = true; s_disc_sweep_n = 0; // a fresh look starts fast, then backs off if (s_apppage_close == closeDiscoverPage) { s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void openDiscoverPage() { closeDiscoverPage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const int H = sh - STATUSBAR_H; s_discover_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_discover_root); lv_obj_set_size(s_discover_root, sw, H); lv_obj_set_pos(s_discover_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_discover_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_discover_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_discover_root, LV_OBJ_FLAG_SCROLLABLE); s_apppage_title = "Discover"; s_apppage_close = closeDiscoverPage; statusBarSetTall(true); updateGlobalStatusBar(); const int top = STATUSBAR_H + 8; s_discover_status = lv_label_create(s_discover_root); lv_label_set_text(s_discover_status, TR("Scanning\xE2\x80\xA6")); lv_obj_set_style_text_font(s_discover_status, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_discover_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_discover_status, 10, top); lv_obj_set_width(s_discover_status, sw - 130); // keep clear of the two header buttons lv_label_set_long_mode(s_discover_status, LV_LABEL_LONG_DOT); lv_obj_t* btn = lv_btn_create(s_discover_root); // Stop / Scan lv_obj_set_size(btn, 56, 30); lv_obj_set_pos(btn, sw - 62, top - 4); styleButton(btn); lv_obj_add_event_cb(btn, discoverScanToggleCb, LV_EVENT_CLICKED, nullptr); s_discover_btn_lbl = lv_label_create(btn); useChainedFont(s_discover_btn_lbl); lv_label_set_text(s_discover_btn_lbl, TR("Stop")); lv_obj_center(s_discover_btn_lbl); lv_obj_t* mbtn = lv_btn_create(s_discover_root); // Show on map lv_obj_set_size(mbtn, 56, 30); lv_obj_set_pos(mbtn, sw - 122, top - 4); styleButton(mbtn); lv_obj_add_event_cb(mbtn, discoverShowMapCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* mlbl = lv_label_create(mbtn); useChainedFont(mlbl); lv_label_set_text(mlbl, TR("Map")); lv_obj_center(mlbl); lv_obj_t* sc = lv_obj_create(s_discover_root); lv_obj_remove_style_all(sc); lv_obj_set_size(sc, sw, H - (top - STATUSBAR_H) - 34 - 20); // leave a row for the wardrive footer lv_obj_set_pos(sc, 0, top + 30); lv_obj_set_style_bg_opa(sc, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_pad_all(sc, 8, LV_PART_MAIN); lv_obj_set_scroll_dir(sc, LV_DIR_VER); s_discover_feed = lv_label_create(sc); lv_label_set_recolor(s_discover_feed, true); lv_obj_set_width(s_discover_feed, sw - 20); lv_label_set_long_mode(s_discover_feed, LV_LABEL_LONG_CLIP); // one line per row -> tap-to-add maps Y->row lv_obj_set_style_text_line_space(s_discover_feed, 3, LV_PART_MAIN); // fixed row pitch (font line-height + 3) lv_obj_set_style_text_font(s_discover_feed, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_discover_feed, lv_color_hex(0xC8CDD2), LV_PART_MAIN); lv_obj_add_flag(s_discover_feed, LV_OBJ_FLAG_CLICKABLE); // tap a node row -> add to contacts lv_obj_add_event_cb(s_discover_feed, discoverFeedTapCb, LV_EVENT_CLICKED, nullptr); lv_label_set_text(s_discover_feed, ""); // wardriving footer (GPS + logged-count status), pinned at the bottom s_disc_footer = lv_label_create(s_discover_root); lv_label_set_recolor(s_disc_footer, true); lv_obj_set_style_text_font(s_disc_footer, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(s_disc_footer, 10, STATUSBAR_H + H - 18); lv_label_set_text(s_disc_footer, TR("#7A7F87 Wardrive: \xE2\x80\xA6#")); the_mesh.discoverClear(); s_discover_scanning = true; s_disc_sweep_n = 0; // a fresh look starts fast, then backs off s_discover_last_scan_ms = 0; // fire the first sweep on the first tick discoverBuildFeed(); s_discover_timer = lv_timer_create(discoverTimerCb, 1000, nullptr); if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root); } // ============================================================ // Spectrum app (app-drawer tile -> APPACT_SPECTRUM) // ============================================================ // A handheld swept-tuned spectrum analyzer built on the mesh radio (SX126x on // most boards, LR1110 on the ThinkNode M9). While open it BORROWS the radio // from the mesh: main.cpp stops calling the_mesh.loop() (it // checks spectrumOwnsRadio()) so the mesh never re-arms RX on the home channel // while we are hopping the modem across the band. On close we re-apply the // mesh's saved radio params and clear the flag, so the next the_mesh.loop() // re-arms RX correctly. (See main.cpp loop() + the restore in closeSpectrumPage.) // // Sweep mechanics: we tune the modem to each bin's center frequency with a // narrow RBW bandwidth, arm RX there (recvRaw re-issues startReceive() at the // new freq — the only public way to get into RX, since startRecv()/idle() are // protected on RadioLibWrapper), let it settle a few hundred µs, then read the // instantaneous channel RSSI register via getCurrentRSSI() (= getRSSI(false) = // GET_RSSI_INST, which is only meaningful in RX). To keep LVGL responsive we // sweep only a CHUNK of bins per timer tick (SPEC_CHUNK), spreading one full // sweep over several frames. The waterfall pushes a ROLLING row every quarter // sweep (the row is fresh up to the sweep cursor, the rest at most one sweep // old — the standard SDR-waterfall trade), so rows land every ~250 ms instead // of once per ~1 s sweep; a completed sweep additionally rescales + relabels. This is a single-task design: // ui_task.loop() (where this timer runs) and the_mesh.loop() never overlap, so // there is no SPI contention — and with the mesh paused, nothing else touches // the radio while we own it. static const int SPEC_BINS = 160; // frequency bins across the span static const int SPEC_WF_ROWS = 48; // waterfall height (rows of history; zoomed to fit width) static const int SPEC_CHUNK = 8; // bins swept per timer tick (~5.5 ms/bin -> ~44 ms UI block) // Honest-coverage note: 24 MHz / 160 bins = 150 kHz of bin pitch, but each bin // listens through a 62.5 kHz channel filter — only ~42% of the span is inside a // filter on any sweep, so a narrowband carrier sitting between bin centers is // attenuated or invisible. That is the deliberate resolution-vs-coverage trade // of a swept analyzer at these constants: 62.5 kHz is the narrowest LoRa RBW the // chips offer (adjacent bins resolve crisply); an RBW >= the 150 kHz pitch would // close the gap but blur every peak across neighbouring bins. static const float SPEC_RBW_KHZ = 62.5f; // resolution bandwidth used for each bin read static const float SPEC_SPAN_MHZ = 24.0f; // total swept span (center ±12 MHz) // 150-960 is what BOTH in-tree drivers actually accept: SX1262::setFrequency // and LR1110::setFrequency each range-check exactly that (the old 137/1020 // pair was the SX126x SILICON range, which the driver refuses anyway — bins // outside 150-960 silently kept the previous bin's tune and mis-attributed // its RSSI). static const float SPEC_FMIN_MHZ = 150.0f; // driver-enforced tuning floor (SX126x + LR11x0) static const float SPEC_FMAX_MHZ = 960.0f; // driver-enforced tuning ceiling static const int SPEC_DBM_MIN = -130; // chart Y floor (dBm) static const int SPEC_DBM_MAX = -50; // chart Y ceiling (dBm) static const int SPEC_DWELL = 6; // RSSI reads per bin (peak-hold over the settle window) static const int SPEC_SETTLE_US = 1500; // wait after startReceive before the first RSSI read static const int SPEC_READ_GAP_US = 400; // gap between the peak-hold reads (out to ~4 ms total) static const int SPEC_WF_HEADROOM = 24; // waterfall colour span above the live noise floor (dB) // Dynamic trace Y range: track the live data min/max instead of a fixed window // above the noise floor (a strong signal used to clip flat at floor+42). The // range EXPANDS instantly (a new peak is never clipped, even mid-sweep) and // CONTRACTS a few dB per completed sweep, so the scale never pumps/jitters. static const int SPEC_Y_PAD_LO = 4; // dB of margin below the weakest bin static const int SPEC_Y_PAD_HI = 6; // dB of headroom above the strongest bin static const int SPEC_Y_MIN_SPAN = 30; // never zoom tighter than this (noise wiggle stays wiggle) static const int SPEC_Y_DECAY = 3; // max dB the range may contract per completed sweep static lv_obj_t* s_spec_root = nullptr; static lv_obj_t* s_spec_chart = nullptr; // live power-vs-freq trace static lv_chart_series_t* s_spec_ser = nullptr; static lv_obj_t* s_spec_wf = nullptr; // waterfall canvas static lv_color_t* s_spec_wf_buf = nullptr; // PSRAM RGB565 backing buffer (SPEC_BINS x SPEC_WF_ROWS) static lv_obj_t* s_spec_axis_lbl = nullptr; // start/center/end MHz row static lv_obj_t* s_spec_info_lbl = nullptr; // "RBW … span" readout static lv_obj_t* s_spec_peak_lbl = nullptr; // live "peak −87 dBm @ 869.7" readout static lv_obj_t* s_spec_scale_lbl = nullptr; // vertical colour-scale: floor dBm (bottom) static lv_obj_t* s_spec_scale_hi_lbl = nullptr;// vertical colour-scale: ceiling dBm (top) static lv_timer_t* s_spec_timer = nullptr; static int16_t s_spec_rssi[SPEC_BINS]; // latest full sweep (dBm) static float s_spec_start = 0.0f; // band start (MHz) static float s_spec_step = 0.0f; // per-bin step (MHz) static int s_spec_pos = 0; // next bin to sweep this pass static uint8_t s_spec_sf = 7; // mesh SF/CR reused for the reads static uint8_t s_spec_cr = 5; static int s_spec_floor = -120; // smoothed noise-floor estimate (dBm) -> waterfall colour auto-scales to it static int s_spec_ylo = -130; // dynamic trace Y range (dBm), tracks live min/max static int s_spec_yhi = -80; // The single source of truth the main loop reads to decide whether to skip // the_mesh.loop() (so the mesh stays off the radio while we sweep). Set true on // open, cleared in closeSpectrumPage() ONLY AFTER the mesh params are restored. static bool s_spectrum_active = false; // dBm -> RGB565 colour ramp (deep blue floor -> cyan -> green -> yellow -> red). // The ramp auto-scales to the LIVE noise floor (s_spec_floor) so the floor always // sits at the dark-blue end and any real signal pops — instead of a fixed -130..-50 // window where a -120 dBm floor and a -118 dBm wiggle both render the same flat blue. static lv_color_t specHeat(int dbm) { int lo = s_spec_floor - 3; // just below the live floor -> darkest int hi = s_spec_floor + SPEC_WF_HEADROOM; // ~55 dB of signal headroom above it if (hi <= lo) hi = lo + 1; if (dbm < lo) dbm = lo; if (dbm > hi) dbm = hi; // 0..1023 position along the ramp int t = (int)(((long)(dbm - lo) * 1023) / (hi - lo)); uint8_t r, g, b; if (t < 256) { r = 0; g = (uint8_t)t; b = 255; } // blue -> cyan else if (t < 512) { r = 0; g = 255; b = (uint8_t)(511 - t); } // cyan -> green else if (t < 768) { r = (uint8_t)(t - 512); g = 255; b = 0; } // green -> yellow else { r = 255; g = (uint8_t)(1023 - t); b = 0; } // yellow-> red return lv_color_make(r, g, b); } // Re-estimate the noise floor from the freshest full sweep (the minimum bin is a // good cheap floor once RX is genuinely armed on every bin), smoothed so the // waterfall colour scale doesn't jitter. Drives specHeat()'s auto-scaling. static void spectrumUpdateFloor() { int mn = 9999; for (int i = 0; i < SPEC_BINS; i++) if (s_spec_rssi[i] < mn) mn = s_spec_rssi[i]; if (mn > -20 || mn < -190) return; // ignore a saturated/cold/garbage sweep s_spec_floor = (s_spec_floor * 3 + mn) / 4; // IIR smooth toward the new floor } // Sweep a chunk of bins. Spread across several ticks so a full sweep doesn't block // the UI. Returns true when a full sweep just completed (caller paints a row). // // On boards with a raw RadioLib handle (RADIO_CLASS defined — SX126x family and the // M9's LR1110) we drive the modem DIRECTLY — retune to the bin, startReceive() to // ARM RX, settle, then peak-hold several GET_RSSI_INST reads. // This is the fix for the "dead trace": the wrapper path (setParams + recvRaw) left a // stale STATE_RX in the wrapper after the first retune, so recvRaw() never re-armed and // every later bin read a meaningless standby RSSI. Going direct also lets us peak-hold // across a ~1 ms dwell, which actually catches the brief LoRa bursts a single 300 µs // snapshot almost always missed. (The mesh is paused — spectrumOwnsRadio() — so nothing // else touches the modem while we hop it across the band.) static bool spectrumSweepChunk() { int end = s_spec_pos + SPEC_CHUNK; if (end > SPEC_BINS) end = SPEC_BINS; #if defined(RADIO_CLASS) for (int i = s_spec_pos; i < end; i++) { const float f = s_spec_start + (float)i * s_spec_step; #if defined(HAS_THINKNODE_M9) // LR1110: skipCalibration — LR1110::setFrequency(f) recalibrates image // rejection whenever the retune jumps >= 20 MHz, which the 24 MHz wrap // from band end back to band start does EVERY sweep, stalling the sweep // for nothing. openSpectrumPage already ran one CalibImage over the whole // swept span (begin()'s own calibration only reaches mesh_freq ±4 MHz), so // every bin is inside a calibrated band without per-bin work. // If the retune itself fails, skip the bin's reads (keep its previous // value) rather than attributing the OLD frequency's RSSI to this bin. if (radio.setFrequency(f, true) != RADIOLIB_ERR_NONE) continue; // retune (chip stays in standby on the new freq) #else // Same skip-on-failure as the LR1110 branch: a rejected retune must not // attribute the old frequency's RSSI to this bin. if (radio.setFrequency(f) != RADIOLIB_ERR_NONE) continue; // retune (chip -> standby on the new freq) #endif radio.startReceive(); // ARM RX — GET_RSSI_INST is only valid in RX // The modem's instantaneous-RSSI readout needs SEVERAL ms in RX to settle after // arming (measured on the SX1262; the LR1110 behaves the same): at ~1.5 ms it // still reads the reset default, only by ~3-4 ms does it report the true channel // power. So we settle, then peak-hold a handful of reads out to ~4 ms — the late // (valid) reads dominate, the early defaults are discarded by the max(). A single // short snapshot read here was the original "dead -127" bug. delayMicroseconds(SPEC_SETTLE_US); int peak = -200; for (int k = 0; k < SPEC_DWELL; k++) { #if defined(HAS_THINKNODE_M9) // LR11x0::getRSSI(false) is NOT the SX126x's cheap register read: it // re-arms RX and drops to standby around EVERY call (startReceive + // GetRssiInst + standby) — 6x per bin that both crawled the sweep // (hundreds of extra chip commands + BUSY waits per chunk: the reported // "spectrum refreshes poorly on M9") and, worse, sampled the unsettled // post-arm default instead of live channel power, defeating the settle // above. We hold RX ourselves through the dwell: skipReceive=true. int r = (int)radio.getRSSI(false, true); #else int r = (int)radio.getRSSI(false); // instantaneous channel RSSI (dBm) #endif // Both driver families return 0 dBm when the underlying RSSI read fails // (an SPI/BUSY hiccup on this shared bus) — and 0 would win the peak-hold, // paint a full-scale spike, and blow the auto-scaled Y range out for tens // of sweeps (it only contracts SPEC_Y_DECAY dB per sweep). Real channel // power can never plausibly reach -5 dBm at this RBW, so discard anything // outside (-180, -5) instead of letting one glitch squash the trace. if (r <= -5 && r >= -180 && r > peak) peak = r; delayMicroseconds(SPEC_READ_GAP_US); } #if defined(HAS_THINKNODE_M9) // Park in STDBY_XOSC (raw mode byte 0x01), NOT the default standby(): the // plain call selects STDBY_RC, which stops the TCXO on this board (it is // powered from the chip's DIO3 rail), so the next bin's startReceive would // re-pay the ~5 ms TCXO startup RadioLib programmed — roughly doubling the // per-bin cost. XOSC standby keeps the TCXO running between bins (~5.5 ms // per bin expected; re-verify with a micros() log on hardware). The raw // byte is deliberate: this RadioLib defines RADIOLIB_LR11X0_STANDBY_XOSC // with the SAME value as STANDBY_RC (both 0x00), so the named constant // would silently select RC again. closeSpectrumPage's restore drops the // chip back to a true STDBY_RC before handing it to the mesh. radio.standby(0x01); #endif if (peak > -200) s_spec_rssi[i] = (int16_t)peak; // every read discarded -> keep the old bin } #else // No raw radio handle (e.g. the Tanmatsu LoRa bridge): wrapper single read. uint8_t scratch[8]; for (int i = s_spec_pos; i < end; i++) { const float f = s_spec_start + (float)i * s_spec_step; radio_driver.setParams(f, SPEC_RBW_KHZ, s_spec_sf, s_spec_cr); radio_driver.recvRaw(scratch, sizeof scratch); delayMicroseconds(300); int v = (int)radio_driver.getCurrentRSSI(); if (v < -200) v = -200; s_spec_rssi[i] = (int16_t)v; } #endif s_spec_pos = end; if (s_spec_pos >= SPEC_BINS) { s_spec_pos = 0; spectrumUpdateFloor(); return true; } return false; } // Scroll the waterfall down one row (memmove the PSRAM buffer) and paint the new // top row from the freshest sweep, then invalidate so LVGL re-flushes the canvas. static void spectrumPushWaterfall() { if (!s_spec_wf_buf || !s_spec_wf) return; // shift every row down by one (row r <- row r-1); drop the bottom row memmove(&s_spec_wf_buf[SPEC_BINS], &s_spec_wf_buf[0], (size_t)SPEC_BINS * (SPEC_WF_ROWS - 1) * sizeof(lv_color_t)); for (int x = 0; x < SPEC_BINS; x++) s_spec_wf_buf[x] = specHeat(s_spec_rssi[x]); lv_obj_invalidate(s_spec_wf); } // Repaint the live power-vs-frequency trace from the freshest sweep. The Y range // auto-scales to the live noise floor so the floor sits ~20% up the chart (its real // wiggle visible) and signals climb prominently — instead of a flat line crushed // against the bottom of a fixed -130..-50 window. // Cheap per-tick trace update: clamp the sweep buffer into the chart series and // invalidate. Bins ahead of the sweep cursor still hold the PREVIOUS sweep, so the // trace advances live across the span exactly like a bench analyzer — this is what // makes the app feel monitor-fast instead of repainting once per ~0.9 s sweep. // Range and legend stay on the completed-sweep path so nothing flaps mid-sweep. static void spectrumDrawTraceLive() { if (!s_spec_chart || !s_spec_ser) return; // Expand the range IMMEDIATELY when the data outgrows it (never clip a fresh // peak, even mid-sweep); contraction only happens in spectrumDrawTrace(). int mn = 999, mx = -999; for (int i = 0; i < SPEC_BINS; i++) { const int v = s_spec_rssi[i]; if (v < mn) mn = v; if (v > mx) mx = v; } bool grew = false; if (mx + SPEC_Y_PAD_HI > s_spec_yhi) { s_spec_yhi = mx + SPEC_Y_PAD_HI; grew = true; } if (mn - SPEC_Y_PAD_LO < s_spec_ylo) { s_spec_ylo = mn - SPEC_Y_PAD_LO; grew = true; } if (grew) lv_chart_set_range(s_spec_chart, LV_CHART_AXIS_PRIMARY_Y, s_spec_ylo, s_spec_yhi); for (int i = 0; i < SPEC_BINS; i++) { int v = s_spec_rssi[i]; if (v < s_spec_ylo) v = s_spec_ylo; if (v > s_spec_yhi) v = s_spec_yhi; s_spec_ser->y_points[i] = (lv_coord_t)v; } lv_chart_refresh(s_spec_chart); } static void spectrumDrawTrace() { if (!s_spec_chart || !s_spec_ser) return; int mn = 999, pk = -200, pki = 0; for (int i = 0; i < SPEC_BINS; i++) { const int v = s_spec_rssi[i]; if (v < mn) mn = v; if (v > pk) { pk = v; pki = i; } } // Dynamic Y range from this sweep's min/max: jump out instantly, ease back in // by at most SPEC_Y_DECAY dB per sweep, and never tighter than the min span. int lo_t = mn - SPEC_Y_PAD_LO; int hi_t = pk + SPEC_Y_PAD_HI; if (hi_t - lo_t < SPEC_Y_MIN_SPAN) { const int grow = SPEC_Y_MIN_SPAN - (hi_t - lo_t); lo_t -= grow / 2; hi_t += grow - grow / 2; } if (lo_t < s_spec_ylo) s_spec_ylo = lo_t; // expand down instantly else if (lo_t - s_spec_ylo > SPEC_Y_DECAY) s_spec_ylo += SPEC_Y_DECAY; else s_spec_ylo = lo_t; if (hi_t > s_spec_yhi) s_spec_yhi = hi_t; // expand up instantly else if (s_spec_yhi - hi_t > SPEC_Y_DECAY) s_spec_yhi -= SPEC_Y_DECAY; else s_spec_yhi = hi_t; lv_chart_set_range(s_spec_chart, LV_CHART_AXIS_PRIMARY_Y, s_spec_ylo, s_spec_yhi); spectrumDrawTraceLive(); // live legend: peak dBm @ its frequency, and the colour-ramp's current dBm endpoints if (s_spec_peak_lbl) { char pb[40]; snprintf(pb, sizeof pb, "peak %d @ %.1f", pk, (double)(s_spec_start + (float)pki * s_spec_step)); lv_label_set_text(s_spec_peak_lbl, pb); } if (s_spec_scale_hi_lbl) { char sb[12]; snprintf(sb, sizeof sb, "%d", s_spec_floor + SPEC_WF_HEADROOM); lv_label_set_text(s_spec_scale_hi_lbl, sb); } if (s_spec_scale_lbl) { char sb[12]; snprintf(sb, sizeof sb, "%d", s_spec_floor - 3); lv_label_set_text(s_spec_scale_lbl, sb); } } static void spectrumTimerCb(lv_timer_t* t) { (void)t; if (!s_spec_root) return; static int s_spec_last_row_pos = 0; // sweep cursor at the last waterfall row push if (spectrumSweepChunk()) { // a full sweep just finished s_spec_last_row_pos = 0; spectrumPushWaterfall(); spectrumDrawTrace(); // full: floor rescale + legend + trace } else if (s_spec_pos - s_spec_last_row_pos >= SPEC_BINS / 4) { // Quarter-sweep rolling update: one new waterfall row + a live trace advance // every ~250 ms — matches the Monitor app's felt refresh instead of waiting // out the ~1 s full sweep. Row content: fresh bins up to the cursor, the // remainder from the previous sweep (never older than one sweep). s_spec_last_row_pos = s_spec_pos; spectrumPushWaterfall(); spectrumDrawTraceLive(); } } // Restore the radio to the mesh config, THEN release ownership (order matters: // clearing the flag first would let the_mesh.loop() touch the radio mid-restore). // Called from EVERY exit path (close button, back key, leaving Home). static void spectrumRestoreRadio() { if (!s_spectrum_active) return; // idempotent — never restore/clear twice NodePrefs* p = the_mesh.getNodePrefs(); #if defined(HAS_THINKNODE_M9) // The sweep left the chip parked in STDBY_XOSC; drop to a true STDBY_RC first // so the mesh gets the chip back in the standby mode RadioLib's own flow uses // (and because CalibImage is only legal in RC standby). Then re-run image // calibration over the mesh channel ±4 MHz — the exact band begin() had // calibrated — so the mesh RX returns to its begin()-time image rejection // instead of the sweep's span-wide compromise cal. setParams below normally // stays under the driver's 20 MHz auto-recal trigger, so nothing else would // restore it (a mesh channel within ~8 MHz of the 150/960 band edge clamps // the span asymmetrically and CAN leave the last bin >20 MHz out — the // auto-recal then fires redundantly before this explicit one; harmless). radio.standby(); if (p) radio.calibrateImageRejection(p->freq - 4.0f, p->freq + 4.0f); #endif if (p) { radio_driver.setParams(p->freq, p->bw, p->sf, p->cr); radio_driver.setTxPower(p->tx_power_dbm); radio_driver.setRxBoostedGainMode(p->rx_boosted_gain != 0); } #if defined(RADIO_CLASS) // CRITICAL: the sweep drove the modem directly, so the wrapper's internal RX-state // is stale (it still thinks STATE_RX) while the chip is actually parked in standby on // the last swept frequency. If we just hand back, the mesh's recvRaw() sees STATE_RX, // skips startReceive(), and the node goes DEAF. Re-arm RX here on the mesh channel so // the chip's reality matches the wrapper state and packets flow again immediately. radio.startReceive(); #endif s_spectrum_active = false; // hand the radio back; the mesh keeps RX armed } static void closeSpectrumPage() { spectrumRestoreRadio(); // ALWAYS restore before tearing the page down radio_driver.rxQueueSuspend(false); // resume the buffered-receive drain task (no-op if off) if (s_spec_timer) { lv_timer_del(s_spec_timer); s_spec_timer = nullptr; } if (s_spec_root) { popupClose(&s_spec_root); } s_spec_chart = nullptr; s_spec_ser = nullptr; s_spec_wf = nullptr; s_spec_axis_lbl = nullptr; s_spec_info_lbl = nullptr; s_spec_peak_lbl = nullptr; s_spec_scale_lbl = nullptr; s_spec_scale_hi_lbl = nullptr; // The canvas no longer references the buffer once its root is queued for // deletion; free the PSRAM backing store (allocated in openSpectrumPage). if (s_spec_wf_buf) { heap_caps_free(s_spec_wf_buf); s_spec_wf_buf = nullptr; } if (s_apppage_close == closeSpectrumPage) { // release the tall title bar back to normal s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void spectrumDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Close only on the backdrop (root) or the X badge; child taps stay open. if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeSpectrumPage(); } #if !defined(HAS_TANMATSU) // ============================================================================ // VNC app (app-drawer tile -> APPACT_VNC): serve the live UI to a phone browser // over the WebSocket server (WebMirror) and inject taps back. This screen is the // explanation + on/off switch + the reachable URL + live connection status. // ============================================================================ static lv_obj_t* s_vnc_root = nullptr; static lv_obj_t* s_vnc_url_lbl = nullptr; static lv_obj_t* s_vnc_status_lbl = nullptr; static lv_timer_t* s_vnc_timer = nullptr; static void vncRefresh() { if (!s_vnc_root) return; const bool on = touchPrefsGetWebMirror(); const bool wifi = (WiFi.status() == WL_CONNECTED) && ((uint32_t)WiFi.localIP() != 0); if (s_vnc_url_lbl) { if (wifi) { char u[64]; snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR "/", WiFi.localIP().toString().c_str()); lv_label_set_text(s_vnc_url_lbl, u); lv_obj_set_style_text_color(s_vnc_url_lbl, lv_color_hex(on ? COLOR_ACCENT : COLOR_SUB), LV_PART_MAIN); } else { lv_label_set_text(s_vnc_url_lbl, TR("Connect to Wi-Fi to get a browser address.")); lv_obj_set_style_text_color(s_vnc_url_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } } if (s_vnc_status_lbl) { const char* st; if (!on) st = TR("Off"); else if (!wifi) st = TR("Waiting for Wi-Fi..."); else if (g_web_mirror.clients() > 0) st = TR("Connected - a browser is viewing this screen."); else st = TR("On - open the address above in a phone browser."); lv_label_set_text(s_vnc_status_lbl, st); } } static void closeVncPage() { if (s_vnc_timer) { lv_timer_del(s_vnc_timer); s_vnc_timer = nullptr; } if (s_vnc_root) { popupClose(&s_vnc_root); } s_vnc_url_lbl = nullptr; s_vnc_status_lbl = nullptr; if (s_apppage_close == closeVncPage) { // release the tall title bar back to normal s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void vncToggleCb(lv_event_t* e) { const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetWebMirror(on); g_web_mirror.setEnabled(on); if (on && touchPrefsGetWebTerminal()) { // VNC and the web terminal both serve "/" -> mutually exclusive touchPrefsSetWebTerminal(false); g_web_mirror.setTerminalOn(false); if (s_term_log_box == nullptr) MyMesh::setTerminalSink(nullptr); } vncRefresh(); } static void vncTimerCb(lv_timer_t*) { vncRefresh(); } static void openVncPage() { closeVncPage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_vnc_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_vnc_root); lv_obj_set_size(s_vnc_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_vnc_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_vnc_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_vnc_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_top(s_vnc_root, STATUSBAR_H + 10, LV_PART_MAIN); // clear the tall title bar lv_obj_set_style_pad_left(s_vnc_root, 14, LV_PART_MAIN); lv_obj_set_style_pad_right(s_vnc_root, 14, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_vnc_root, 14, LV_PART_MAIN); lv_obj_set_flex_flow(s_vnc_root, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(s_vnc_root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_set_style_pad_row(s_vnc_root, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(s_vnc_root, LV_DIR_VER); // Make it obvious the page scrolls: a prominent, always-visible accent scrollbar. lv_obj_set_scrollbar_mode(s_vnc_root, LV_SCROLLBAR_MODE_ON); lv_obj_set_style_bg_color(s_vnc_root, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(s_vnc_root, LV_OPA_80, LV_PART_SCROLLBAR); lv_obj_set_style_width(s_vnc_root, 6, LV_PART_SCROLLBAR); lv_obj_set_style_radius(s_vnc_root, 3, LV_PART_SCROLLBAR); s_apppage_title = "VNC"; s_apppage_close = closeVncPage; statusBarSetTall(true); updateGlobalStatusBar(); const lv_coord_t cw = sw - 28; lv_obj_t* head = lv_label_create(s_vnc_root); lv_label_set_text(head, TR("Control this device from a browser")); lv_obj_set_style_text_font(head, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(head, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(head, LV_LABEL_LONG_WRAP); lv_obj_set_width(head, cw); lv_obj_t* expl = lv_label_create(s_vnc_root); lv_label_set_text(expl, TR("Turn this on, then open the address below in any web browser on the same Wi-Fi - no app needed. This device's screen appears live in the browser and you can tap to control it.")); lv_obj_set_style_text_font(expl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(expl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(expl, LV_LABEL_LONG_WRAP); lv_obj_set_width(expl, cw); lv_obj_t* row = lv_obj_create(s_vnc_root); lv_obj_remove_style_all(row); lv_obj_set_size(row, cw, SC(42)); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(row, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(row, 12, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* rl = lv_label_create(row); lv_label_set_text(rl, TR("Enable web access")); lv_obj_set_style_text_font(rl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(rl, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* sw2 = lv_switch_create(row); lv_obj_align(sw2, LV_ALIGN_RIGHT_MID, 0, 0); if (touchPrefsGetWebMirror()) lv_obj_add_state(sw2, LV_STATE_CHECKED); lv_obj_add_event_cb(sw2, vncToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); s_vnc_url_lbl = lv_label_create(s_vnc_root); lv_obj_set_style_text_font(s_vnc_url_lbl, &g_font_16, LV_PART_MAIN); lv_label_set_long_mode(s_vnc_url_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_vnc_url_lbl, cw); s_vnc_status_lbl = lv_label_create(s_vnc_root); lv_obj_set_style_text_font(s_vnc_status_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_vnc_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(s_vnc_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_vnc_status_lbl, cw); lv_obj_t* warn = lv_label_create(s_vnc_root); lv_label_set_text(warn, TR("While this is on, anyone on your Wi-Fi can view and control this device. Uses plain HTTP (local network only).")); lv_obj_set_style_text_font(warn, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(warn, lv_color_hex(0x8A8F98), LV_PART_MAIN); lv_label_set_long_mode(warn, LV_LABEL_LONG_WRAP); lv_obj_set_width(warn, cw); vncRefresh(); s_vnc_timer = lv_timer_create(vncTimerCb, 1000, nullptr); } // ============================================================================ // REMOTE app (app-drawer tile -> APPACT_REMOTE): reboot into "remote mode" — the // full UI renders to an off-screen 480x800 display served to a browser, and the // physical panel drops to a placeholder. A boot mode (the UI is built once per // resolution), so toggling it reboots. This is the path to screenless devices. // ============================================================================ static lv_obj_t* s_remote_root = nullptr; static lv_obj_t* s_remote_ipcard = nullptr; // the "open in a browser" address card; only shown while a mode is ON static void closeRemotePage() { s_remote_ipcard = nullptr; if (s_remote_root) { popupClose(&s_remote_root); } if (s_apppage_close == closeRemotePage) { s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void remoteToggleCb(lv_event_t* e) { const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetRemoteMode(on); if (g_lv.task) { g_lv.task->showAlert(on ? TR("Entering remote mode - rebooting...") : TR("Leaving remote mode - rebooting..."), 1600); g_lv.task->rebootDevice(); // saves chat history, then reboots into the new mode } } #if CAP_CONSOLE // ---- console bridges: unread state ------------------------------------------ // The console shows messages as they arrive but must NOT mark them read; these // let it report what is waiting and clear a thread deliberately. int consoleHostUnreadTotal() { return g_lv.task ? g_lv.task->getUnreadTotal() : 0; } int consoleHostThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel) { if (!g_lv.task) return 0; return g_lv.task->consoleThreadAt(idx, name, cap, unread, is_channel); } bool consoleHostMarkRead(const char* name) { return g_lv.task ? g_lv.task->consoleMarkThreadRead(name) : false; } int consoleHostHistoryAt(const char* thread, int back, char* sender, size_t sc, char* text, size_t tc, uint32_t* ts, bool* outgoing) { return g_lv.task ? g_lv.task->consoleHistoryAt(thread, back, sender, sc, text, tc, ts, outgoing) : 0; } #endif static void remoteLandscapeCb(lv_event_t* e) { const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetRemoteLandscape(on); if (!g_lv.task) return; if (touchPrefsGetRemoteMode()) { // already in remote mode -> reboot to apply the new resolution g_lv.task->showAlert(TR("Changing orientation - rebooting..."), 1600); g_lv.task->rebootDevice(); } else { g_lv.task->showAlert(on ? TR("Landscape set - applies when remote mode starts.") : TR("Portrait set - applies when remote mode starts."), 2400); } } // "Remote terminal": a runtime web mesh-CLI terminal on the device IP. No reboot; turning // it on disables VNC (they both serve the landing page). See WebMirror term* + the WS server. static void webTerminalToggleCb(lv_event_t* e) { const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetWebTerminal(on); g_web_mirror.setTerminalOn(on); if (on) { if (touchPrefsGetWebMirror()) { touchPrefsSetWebMirror(false); g_web_mirror.setEnabled(false); } // exclusive with VNC } else if (s_term_log_box == nullptr) { MyMesh::setTerminalSink(nullptr); // release the CLI sink (on-device console isn't using it) } // Reveal / hide the "open in a browser" address card as a mode goes on/off (this page stays up). if (s_remote_ipcard) { if (on || touchPrefsGetRemoteMode()) lv_obj_clear_flag(s_remote_ipcard, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(s_remote_ipcard, LV_OBJ_FLAG_HIDDEN); } if (!g_lv.task) return; if (on) { char msg[96]; if (WiFi.status() == WL_CONNECTED) snprintf(msg, sizeof msg, "Terminal on: http://%s:" WEB_UI_PORT_STR "/", WiFi.localIP().toString().c_str()); else snprintf(msg, sizeof msg, "%s", "Terminal on (connect Wi-Fi to reach it)"); g_lv.task->showAlert(msg, 3200); } else { g_lv.task->showAlert(TR("Remote terminal off"), 1400); } } static void openRemotePage() { closeRemotePage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_remote_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_remote_root); lv_obj_set_size(s_remote_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_remote_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_remote_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_remote_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_top(s_remote_root, STATUSBAR_H + 10, LV_PART_MAIN); lv_obj_set_style_pad_left(s_remote_root, 14, LV_PART_MAIN); lv_obj_set_style_pad_right(s_remote_root, 14, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_remote_root, 14, LV_PART_MAIN); lv_obj_set_flex_flow(s_remote_root, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(s_remote_root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_set_style_pad_row(s_remote_root, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(s_remote_root, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_remote_root, LV_SCROLLBAR_MODE_ON); lv_obj_set_style_bg_color(s_remote_root, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(s_remote_root, LV_OPA_80, LV_PART_SCROLLBAR); lv_obj_set_style_width(s_remote_root, 6, LV_PART_SCROLLBAR); lv_obj_set_style_radius(s_remote_root, 3, LV_PART_SCROLLBAR); s_apppage_title = "Remote"; s_apppage_close = closeRemotePage; statusBarSetTall(true); updateGlobalStatusBar(); const lv_coord_t cw = sw - 28; const bool on = touchPrefsGetRemoteMode(); lv_obj_t* head = lv_label_create(s_remote_root); lv_label_set_text(head, TR("Remote access")); lv_obj_set_style_text_font(head, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(head, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(head, LV_LABEL_LONG_WRAP); lv_obj_set_width(head, cw); // Connection address up top — the key info for BOTH modes. lv_obj_t* ipcard = lv_obj_create(s_remote_root); lv_obj_remove_style_all(ipcard); lv_obj_set_size(ipcard, cw, LV_SIZE_CONTENT); lv_obj_set_style_bg_color(ipcard, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(ipcard, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(ipcard, 8, LV_PART_MAIN); lv_obj_set_style_pad_all(ipcard, 10, LV_PART_MAIN); lv_obj_set_style_pad_row(ipcard, 2, LV_PART_MAIN); lv_obj_clear_flag(ipcard, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(ipcard, LV_FLEX_FLOW_COLUMN); lv_obj_t* ipcap = lv_label_create(ipcard); lv_label_set_text(ipcap, TR("Open in a browser (same Wi-Fi):")); lv_obj_set_style_text_font(ipcap, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(ipcap, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_t* ipval = lv_label_create(ipcard); lv_label_set_long_mode(ipval, LV_LABEL_LONG_WRAP); lv_obj_set_width(ipval, cw - 20); lv_obj_set_style_text_font(ipval, &g_font_16, LV_PART_MAIN); if (WiFi.status() == WL_CONNECTED && (uint32_t)WiFi.localIP() != 0) { char u[48]; snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); lv_label_set_text(ipval, u); lv_obj_set_style_text_color(ipval, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); } else { lv_label_set_text(ipval, TR("Connect to Wi-Fi first")); lv_obj_set_style_text_color(ipval, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } s_remote_ipcard = ipcard; // Only show the address once a mode is actually serving it — hide it while both are off. if (!(touchPrefsGetWebTerminal() || on)) lv_obj_add_flag(ipcard, LV_OBJ_FLAG_HIDDEN); lv_obj_t* intro = lv_label_create(s_remote_root); lv_label_set_text(intro, TR("Then pick a mode:")); lv_obj_set_style_text_font(intro, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(intro, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(intro, cw); // ---- 1. Remote terminal (recommended) ---- lv_obj_t* th = lv_label_create(s_remote_root); lv_label_set_text(th, TR("Remote terminal (recommended)")); lv_obj_set_style_text_font(th, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(th, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_width(th, cw); lv_obj_t* texpl = lv_label_create(s_remote_root); lv_label_set_text(texpl, TR("Chats, contacts, settings and a mesh terminal - all in the browser. No reboot; the device keeps its normal screen.")); lv_obj_set_style_text_font(texpl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(texpl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(texpl, LV_LABEL_LONG_WRAP); lv_obj_set_width(texpl, cw); lv_obj_t* trow = lv_obj_create(s_remote_root); lv_obj_remove_style_all(trow); lv_obj_set_size(trow, cw, SC(42)); lv_obj_set_style_bg_color(trow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(trow, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(trow, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(trow, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(trow, 12, LV_PART_MAIN); lv_obj_clear_flag(trow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* tl = lv_label_create(trow); lv_label_set_text(tl, TR("Remote terminal")); lv_obj_set_style_text_font(tl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(tl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(tl, LV_LABEL_LONG_DOT); lv_obj_set_width(tl, cw - 84); // leave room for the switch (V4 240px: labels were overlapping) lv_obj_align(tl, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* tsw = lv_switch_create(trow); lv_obj_align(tsw, LV_ALIGN_RIGHT_MID, 0, 0); if (touchPrefsGetWebTerminal()) lv_obj_add_state(tsw, LV_STATE_CHECKED); lv_obj_add_event_cb(tsw, webTerminalToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_move_to_index(trow, lv_obj_get_index(texpl)); // toggle sits ABOVE its explanation // ---- 2. Remote UI (off-screen mirror; reboots) ---- lv_obj_t* uh = lv_label_create(s_remote_root); lv_label_set_text(uh, TR("Remote UI")); lv_obj_set_style_text_font(uh, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(uh, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_width(uh, cw); lv_obj_t* expl = lv_label_create(s_remote_root); lv_label_set_text(expl, TR("Streams the device screen as a live picture you tap to control. Heavier; the panel shows a placeholder. Toggling reboots the device.")); lv_obj_set_style_text_font(expl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(expl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(expl, LV_LABEL_LONG_WRAP); lv_obj_set_width(expl, cw); lv_obj_t* row = lv_obj_create(s_remote_root); lv_obj_remove_style_all(row); lv_obj_set_size(row, cw, SC(42)); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(row, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(row, 12, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* rl = lv_label_create(row); lv_label_set_text(rl, on ? TR("Remote UI (on)") : TR("Remote UI")); lv_obj_set_style_text_font(rl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(rl, LV_LABEL_LONG_DOT); lv_obj_set_width(rl, cw - 84); lv_obj_align(rl, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* sw2 = lv_switch_create(row); lv_obj_align(sw2, LV_ALIGN_RIGHT_MID, 0, 0); if (on) lv_obj_add_state(sw2, LV_STATE_CHECKED); lv_obj_add_event_cb(sw2, remoteToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_move_to_index(row, lv_obj_get_index(expl)); // toggle sits ABOVE its explanation #if CAP_CONSOLE // ---- 2b. Console mode (no LVGL at all; reboots) ---- lv_obj_t* ch = lv_label_create(s_remote_root); lv_label_set_text(ch, TR("Console mode (experimental)")); lv_obj_set_style_text_font(ch, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(ch, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_width(ch, cw); lv_obj_t* cexpl = lv_label_create(s_remote_root); lv_label_set_text(cexpl, TR("EXPERIMENTAL. Boots into a text console with no graphical interface: " "type commands, read replies. Frees the memory and processor time the " "interface uses. Type 'ui' in the console to come back. Toggling reboots.")); lv_obj_set_style_text_font(cexpl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(cexpl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(cexpl, LV_LABEL_LONG_WRAP); lv_obj_set_width(cexpl, cw); lv_obj_t* crow = lv_obj_create(s_remote_root); lv_obj_remove_style_all(crow); lv_obj_set_size(crow, cw, SC(42)); lv_obj_set_style_bg_color(crow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(crow, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(crow, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(crow, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(crow, 12, LV_PART_MAIN); lv_obj_clear_flag(crow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* cll = lv_label_create(crow); lv_label_set_text(cll, TR("Console mode")); lv_obj_set_style_text_font(cll, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(cll, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(cll, LV_LABEL_LONG_DOT); lv_obj_set_width(cll, cw - 84); lv_obj_align(cll, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* csw = lv_switch_create(crow); lv_obj_align(csw, LV_ALIGN_RIGHT_MID, 0, 0); if (touchPrefsGetConsoleMode()) lv_obj_add_state(csw, LV_STATE_CHECKED); lv_obj_add_event_cb(csw, consoleModeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_move_to_index(crow, lv_obj_get_index(cexpl)); #endif lv_obj_t* lrow = lv_obj_create(s_remote_root); lv_obj_remove_style_all(lrow); lv_obj_set_size(lrow, cw, SC(42)); lv_obj_set_style_bg_color(lrow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(lrow, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(lrow, 8, LV_PART_MAIN); lv_obj_set_style_pad_left(lrow, 12, LV_PART_MAIN); lv_obj_set_style_pad_right(lrow, 12, LV_PART_MAIN); lv_obj_clear_flag(lrow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* ll = lv_label_create(lrow); lv_label_set_text(ll, TR("Landscape (desktop)")); lv_obj_set_style_text_font(ll, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(ll, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(ll, LV_LABEL_LONG_DOT); lv_obj_set_width(ll, cw - 84); lv_obj_align(ll, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* lsw = lv_switch_create(lrow); lv_obj_align(lsw, LV_ALIGN_RIGHT_MID, 0, 0); if (touchPrefsGetRemoteLandscape()) lv_obj_add_state(lsw, LV_STATE_CHECKED); lv_obj_add_event_cb(lsw, remoteLandscapeCb, LV_EVENT_VALUE_CHANGED, nullptr); } #endif // !HAS_TANMATSU static void openSpectrumPage() { closeSpectrumPage(); #if defined(HAS_THINKNODE_M9) // A mesh transmit may be ON AIR right now: sends are asynchronous (startSendRaw // arms the chip and returns while the packet is still transmitting), and this // callback runs right after the_mesh.loop() in the same task. Seizing the radio // immediately would cut that packet off mid-air — lost packet plus a partial // burst splattered across the channel. The mesh and UI share one task, so at // this boundary the wrapper is in RX whenever no send is pending; if it is NOT, // wait (bounded) for the transmit to finish before taking over. isSendComplete() // consumes the TX-done event, so the dispatcher will later expire this packet as // a timed-out send — a stats/log blemish, versus truncating it on the air (the // packet double-counts: n_sent++ from the consumed TX-done plus the timeout // path's logTxFail; that timeout's finishTransmit also blips the restored RX // for one mesh-loop pass — all transient). The cap means a stuck flag can only // delay the sweep, never hang it. Two accepted costs: this wait runs on the // shared UI/mesh task, so LVGL and the keys freeze for its duration (~2.3 s at // the shipping SF8/BW62.5 config, 6 s absolute cap) — tolerable because it only // triggers while a packet is genuinely on air; and in the rare one-loop-pass // window where a radio-error path left the wrapper IDLE (not RX, nothing // pending), the wait burns the full cap for nothing — telling IDLE apart from // TX-in-flight needs a wrapper accessor that doesn't exist yet; bounded and // self-recovering, so tolerated. (The same truncation exists on every board, // but only this port's dispatcher-recovery path has been re-validated, so the // wait stays M9-only for now.) if (!radio_driver.isInRecvMode()) { uint32_t cap = radio_driver.getEstAirtimeFor(MAX_TRANS_UNIT) * 3 / 2; // dispatcher's own worst-case send budget if (cap > 6000) cap = 6000; const uint32_t t0 = millis(); while (millis() - t0 < cap) { if (radio_driver.isSendComplete() || radio_driver.isInRecvMode()) break; delay(5); } } #endif // Park the buffered-receive drain task while this page drives the raw radio. // The acquire/release pair guarantees no drain is mid-flight when we take over. radio_driver.radioAcquire(); radio_driver.rxQueueSuspend(true); radio_driver.radioRelease(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const int H = sh - STATUSBAR_H; // ---- claim the radio + compute the band BEFORE building UI ---- NodePrefs* pr = the_mesh.getNodePrefs(); float center = pr ? pr->freq : 915.0f; s_spec_sf = pr ? pr->sf : 7; s_spec_cr = pr ? pr->cr : 5; float span = SPEC_SPAN_MHZ; // Centre the sweep on the node's HOME frequency for ANY region (US915 / EU868 / 433 / // AU915 …) — never pin it to a US-ISM window, or non-US users would sweep an empty band // that never includes their channel. Clamp only to the radio's physical tuning range. float start = center - span * 0.5f; float stop = center + span * 0.5f; if (start < SPEC_FMIN_MHZ) start = SPEC_FMIN_MHZ; if (stop > SPEC_FMAX_MHZ) stop = SPEC_FMAX_MHZ; s_spec_start = start; s_spec_step = (stop - start) / (float)SPEC_BINS; s_spec_pos = 0; s_spec_floor = -120; // reset the auto-scale floor for a fresh session s_spec_ylo = -130; // reset the dynamic trace range too (expands to the live data) s_spec_yhi = -80; for (int i = 0; i < SPEC_BINS; i++) s_spec_rssi[i] = SPEC_DBM_MIN; s_spectrum_active = true; // from here the mesh is OFF the radio (main loop skips the_mesh.loop()) #if defined(RADIO_CLASS) // Configure the modem ONCE for the scan: a narrow resolution bandwidth so adjacent // bins resolve, and boosted RX gain for sensitivity. The per-bin sweep then only // changes frequency, which keeps each bin fast. (Restored to mesh config on close.) // Drop to standby FIRST: the chip is still in mesh RX at this point, and the // datasheets describe these config commands as standby-mode commands. Whether the // silicon actually rejects them from RX is unverified — but one SPI command is // cheap hardening against the first sweep running on stale modem config. radio.standby(); #if defined(HAS_THINKNODE_M9) // LR1110: image rejection is only calibrated where the chip was told to // calibrate it — begin() covered mesh_freq ±4 MHz, NOT this ±12 MHz span. One // CalibImage over the whole span (legal from the RC standby above, a few ms, // once per open) makes every bin's skipCalibration retune within spec; the // close path re-runs the mesh's own ±4 MHz calibration on the way out. radio.calibrateImageRejection(start, stop); #endif radio.setBandwidth(SPEC_RBW_KHZ); radio.setRxBoostedGainMode(true); #endif s_spec_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_spec_root); lv_obj_set_size(s_spec_root, sw, H); lv_obj_set_pos(s_spec_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_spec_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_spec_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_spec_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_spec_root, spectrumDismissCb, LV_EVENT_CLICKED, nullptr); // Keyboard/d-pad nav (M9, T-Deck, Tanmatsu): nothing on this page is actionable — the // chart and the waterfall's scale box are plain containers, so the nav collector was // highlighting them for no reason. Skip the whole subtree (same as the lock screen): // the focus group stays empty and the only way out is Back (hardware key / bar chevron / // Esc — all routed through the page ladder, none of which needs focus). lv_obj_add_flag(s_spec_root, NAV_SKIP_FLAG); // Settings-subpage chrome: the GLOBAL status bar goes tall and shows "‹ Spectrum" // (tap the bar = Back). Content insets below the bar's lower row — no second header. s_apppage_title = "Spectrum"; s_apppage_close = closeSpectrumPage; statusBarSetTall(true); updateGlobalStatusBar(); const int top = STATUSBAR_H + 8; // ---- readout line: RBW / span (left) + live peak (right). The centre frequency // lives on the frequency axis below, so it's dropped here to clear the peak. ---- s_spec_info_lbl = lv_label_create(s_spec_root); char info[96]; snprintf(info, sizeof info, "RBW %.0fk span %.0fM", (double)SPEC_RBW_KHZ, (double)(stop - start)); lv_label_set_text(s_spec_info_lbl, info); lv_obj_set_style_text_font(s_spec_info_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_spec_info_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_spec_info_lbl, 10, top + 3); // live peak readout (right-aligned on the same row; updated each sweep) s_spec_peak_lbl = lv_label_create(s_spec_root); lv_label_set_text(s_spec_peak_lbl, TR("peak --")); lv_obj_set_style_text_font(s_spec_peak_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_spec_peak_lbl, lv_color_hex(0xF0D020), LV_PART_MAIN); lv_obj_set_width(s_spec_peak_lbl, 150); lv_obj_set_style_text_align(s_spec_peak_lbl, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); lv_obj_set_pos(s_spec_peak_lbl, sw - 8 - 150, top + 3); // ---- live power-vs-frequency trace (lv_chart, LINE), like the Monitor ---- const int chart_x = 40; const int chart_y = top + 21; const int chart_w = sw - chart_x - 10; const int chart_h = H * 22 / 100; // sized so the waterfall + axis clear the screen s_spec_chart = lv_chart_create(s_spec_root); lv_obj_set_size(s_spec_chart, chart_w, chart_h); lv_obj_set_pos(s_spec_chart, chart_x, chart_y); lv_obj_clear_flag(s_spec_chart, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_bg_color(s_spec_chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_spec_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_spec_chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_spec_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_spec_chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(s_spec_chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); lv_obj_set_style_size(s_spec_chart, 0, LV_PART_INDICATOR); // hide point dots lv_obj_set_style_line_width(s_spec_chart, 1, LV_PART_ITEMS); // thin: 160 points lv_chart_set_type(s_spec_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_spec_chart, SPEC_BINS); lv_chart_set_update_mode(s_spec_chart, LV_CHART_UPDATE_MODE_CIRCULAR); lv_chart_set_range(s_spec_chart, LV_CHART_AXIS_PRIMARY_Y, SPEC_DBM_MIN, SPEC_DBM_MAX); lv_chart_set_div_line_count(s_spec_chart, 3, 0); lv_obj_set_style_pad_top(s_spec_chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_spec_chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_left(s_spec_chart, 4, LV_PART_TICKS); lv_obj_set_style_text_font(s_spec_chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(s_spec_chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(s_spec_chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_chart_set_axis_tick(s_spec_chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 3, 1, true, 40); s_spec_ser = lv_chart_add_series(s_spec_chart, lv_color_hex(0x35C9C9), LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_all_value(s_spec_chart, s_spec_ser, SPEC_DBM_MIN); // ---- waterfall canvas (RGB565, PSRAM-backed), stretched to the chart width ---- const int wf_y = chart_y + chart_h + 6; const int wf_w = chart_w; const int wf_disp_h = SPEC_WF_ROWS * wf_w / SPEC_BINS; // on-screen height after the uniform zoom s_spec_wf_buf = (lv_color_t*)heap_caps_malloc( (size_t)SPEC_BINS * SPEC_WF_ROWS * sizeof(lv_color_t), MALLOC_CAP_SPIRAM); if (!s_spec_wf_buf) // PSRAM exhausted: fall back to internal DRAM so the app still runs s_spec_wf_buf = (lv_color_t*)heap_caps_malloc( (size_t)SPEC_BINS * SPEC_WF_ROWS * sizeof(lv_color_t), MALLOC_CAP_8BIT); if (s_spec_wf_buf) { lv_color_t floor_c = specHeat(SPEC_DBM_MIN); for (int i = 0; i < SPEC_BINS * SPEC_WF_ROWS; i++) s_spec_wf_buf[i] = floor_c; s_spec_wf = lv_canvas_create(s_spec_root); lv_canvas_set_buffer(s_spec_wf, s_spec_wf_buf, SPEC_BINS, SPEC_WF_ROWS, LV_IMG_CF_TRUE_COLOR); lv_obj_set_pos(s_spec_wf, chart_x, wf_y); lv_img_set_zoom(s_spec_wf, (uint16_t)((wf_w * 256) / SPEC_BINS)); // 256 = 1.0x lv_img_set_antialias(s_spec_wf, false); // crisp bins, no zoom smoothing lv_img_set_pivot(s_spec_wf, 0, 0); } // ---- vertical colour-scale legend in the empty left margin, beside the waterfall ---- // red/strong at top -> blue/weak at bottom, with the live dBm endpoints flanking it. { const int lg_x = 5, lg_w = 13; const int lg_top = wf_y + 11, lg_bot = wf_y + wf_disp_h - 9; const int lg_h = lg_bot - lg_top; static const uint32_t kRampStops[5] = // matches specHeat(): blue..cyan..green..yellow..red { 0x0000FF, 0x00FFFF, 0x00FF00, 0xFFFF00, 0xFF0000 }; for (int s = 0; s < 5; s++) { lv_obj_t* sw_box = lv_obj_create(s_spec_root); lv_obj_remove_style_all(sw_box); lv_obj_set_size(sw_box, lg_w, lg_h / 5 + 1); lv_obj_set_pos(sw_box, lg_x, lg_top + (4 - s) * lg_h / 5); // s=4 (red) on top lv_obj_set_style_bg_color(sw_box, lv_color_hex(kRampStops[s]), LV_PART_MAIN); lv_obj_set_style_bg_opa(sw_box, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(sw_box, LV_OBJ_FLAG_SCROLLABLE); } s_spec_scale_hi_lbl = lv_label_create(s_spec_root); // ceiling dBm (top, strong) lv_label_set_text(s_spec_scale_hi_lbl, "--"); lv_obj_set_style_text_font(s_spec_scale_hi_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_spec_scale_hi_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_spec_scale_hi_lbl, lg_x - 1, wf_y - 2); s_spec_scale_lbl = lv_label_create(s_spec_root); // floor dBm (bottom, weak) lv_label_set_text(s_spec_scale_lbl, "--"); lv_obj_set_style_text_font(s_spec_scale_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_spec_scale_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_spec_scale_lbl, lg_x - 1, wf_y + wf_disp_h - 8); } // ---- frequency axis labels: start / center / end (just under the waterfall) ---- s_spec_axis_lbl = lv_label_create(s_spec_root); lv_label_set_recolor(s_spec_axis_lbl, true); char ax[96]; snprintf(ax, sizeof ax, "%.1f %.1f MHz %.1f", (double)start, (double)center, (double)stop); lv_label_set_text(s_spec_axis_lbl, ax); lv_obj_set_width(s_spec_axis_lbl, chart_w); lv_obj_set_style_text_align(s_spec_axis_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_font(s_spec_axis_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_spec_axis_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_spec_axis_lbl, chart_x, wf_y + wf_disp_h + 3); // Each bin costs ~5.5 ms (1.5 ms settle + ~2.4 ms of peak-hold reads + retune/RX // command overhead), so SPEC_CHUNK=8 blocks the UI ~44 ms per tick; the 8 ms timer // makes the ticks run essentially back-to-back, so a full 160-bin sweep is 20 // ticks ≈ ~1 s and the quarter-sweep waterfall rows land ~every 250 ms. (Numbers // assume the between-bin XOSC standby holds the TCXO up — re-time on hardware.) s_spec_timer = lv_timer_create(spectrumTimerCb, 8, nullptr); // ticks back-to-back with the sweep chunks; waterfall + trace repaint every quarter sweep (~250 ms) lv_obj_move_foreground(s_spec_root); lv_obj_move_foreground(g_statusbar.root); // keep the tall title bar above this page } // Read by main.cpp's loop(): true while the Spectrum app owns the radio, so the // mesh is paused (the_mesh.loop() skipped) and never re-tunes mid-sweep. bool spectrumOwnsRadio() { return s_spectrum_active; } static void homeChartClickedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openSignalInfoPopup(); } // Tap the home "Unread" line -> jump straight to the Chats inbox. static void homeUnreadClickedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; goToTab(CHAT_INBOX_TAB_INDEX); } // Status-bar unread badge (✉ N) → Chats inbox. Wired on the bar's left_label; // only fires while updateGlobalStatusBar has flagged the badge state CLICKABLE, // so non-badge left-zone states keep the bar's control-center / Back tap. static void statusBarUnreadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_apppage_close) { s_apppage_close(); return; } // tool-page title bar: tap/Enter = back if (s_settings_sheet) { closeSettingsCategory(); return; } // settings detail "‹ Title": tap/Enter = back goToTab(CHAT_INBOX_TAB_INDEX); } // ---- App drawer (full-screen grid launcher) ---- // A toggle-in alternative to the command-centre home. The full implementation // lives below openControlCenter (it links to the tool openers defined there); // the home's Apps button and the back-key handler reach it through these. static lv_obj_t* s_appdrawer_root = nullptr; static void openAppDrawer(); static void closeAppDrawer(); static void homeAppsBtnCb(lv_event_t* e) { // "Apps" launcher on the command centre -> open the drawer if (lv_event_get_code(e) == LV_EVENT_CLICKED) setHomeDrawer(true); } // Fwd decl (the one at the top of the file is behind the keyboard-nav guard, so it's // invisible to the non-nav touch builds like the Heltec V4 that still compile this cb). static void toggleControlCenter(); static void homeControlPanelCb(lv_event_t* e) { // "Control panel" launcher -> open the top-bar control-center dropdown if (lv_event_get_code(e) == LV_EVENT_CLICKED) toggleControlCenter(); } static void makeHome(lv_obj_t* tab) { // Layout (240 wide × 282 tall): title + heartbeat + battery at top, status // lines, TX/RX chart in the middle, Send Advert button at the bottom. // No tile grid — bottom tab bar already gives quick access to Chats / // Contacts / Settings. styleSurface(tab, COLOR_BG); lv_obj_set_style_pad_all(tab, 10, LV_PART_MAIN); const bool home_land = chatLandscape(); if (home_land) { lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); } else { // The live Expansion-Kit summary can add enough lines to push the charts // and launcher row below the short portrait viewport. Let the page scroll // instead of clipping those controls at the tab bar. lv_obj_set_scroll_dir(tab, LV_DIR_VER); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_flag(tab, LV_OBJ_FLAG_SCROLLABLE); } // Content width = screen width minus the 10-px tab padding on each side. // Tracks rotation (220 portrait / 300 landscape). const int cw = tabContentW() - 20; // Landscape is short and wide: park the Send-advert button in the empty // right-hand strip (top-right) instead of a full-width bottom row, so the // TX/RX chart can use the freed vertical space and be ~2x taller. // Right-hand button column. Scales with the UI size so bigger labels ("Terminal") don't clip; // RSTRIP is the strip the left-hand content (status text + chart + info) must stay clear of. #if defined(TLORA_PAGER) const uint8_t pager_size = touchPrefsGetUiScale(); const int BTNW = pager_size >= 2 ? 136 : pager_size == 1 ? 120 : 100; const int home_line_h = lv_font_get_line_height(&g_font_14); const int home_state_y = pager_size ? 2 : 4; const int home_unread_y = pager_size ? home_state_y + home_line_h + 2 : 22; const int home_stats_y = pager_size ? home_unread_y + home_line_h + 2 : 40; #else const int BTNW = SC(100); const int home_state_y = SC(4); const int home_unread_y = SC(22); const int home_stats_y = SC(40); #endif const int RSTRIP = BTNW + 10; // The previous in-tab status row (heartbeat dot, MESHCOMOD title, clock, // battery %, battery icon) is now replaced by the always-visible global // status bar on lv_layer_sys. Home body content starts at y=0 inside // the tab; the tabview's own y-offset already keeps everything below // the status bar. s_home_clock = nullptr; s_home_heartbeat = nullptr; s_home_batt_pct = nullptr; s_home_batt_icon = nullptr; s_home_chart_sig = nullptr; g_lv.home_apps = nullptr; // set below iff the right-hand launcher column is built (landscape) #if defined(HAS_EXPANSION_KIT) s_home_env_chart = nullptr; s_home_adv_btn = nullptr; g_lv.home_env = nullptr; g_lv.home_env_chart = nullptr; #endif #if CAP_LARGE_SCREEN s_home_info = nullptr; #endif g_lv.home_state = lv_label_create(tab); lv_label_set_text(g_lv.home_state, TR("Connecting...")); lv_obj_set_style_text_color(g_lv.home_state, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.home_state, &g_font_14, LV_PART_MAIN); // match the other status lines (scales with UI size) lv_obj_align(g_lv.home_state, LV_ALIGN_TOP_LEFT, 0, home_state_y); // Unread line — its own tappable row (mail icon + live count) that jumps to // the Chats inbox. Kept separate from the memory line below so only the unread // text is the touch target. g_lv.home_unread = lv_label_create(tab); lv_label_set_text(g_lv.home_unread, ""); lv_obj_set_style_text_color(g_lv.home_unread, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.home_unread, &g_font_14, LV_PART_MAIN); lv_obj_set_width(g_lv.home_unread, home_land ? (cw - RSTRIP) : cw); lv_obj_align(g_lv.home_unread, LV_ALIGN_TOP_LEFT, 0, home_unread_y); lv_obj_add_flag(g_lv.home_unread, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_lv.home_unread, 8); lv_obj_add_event_cb(g_lv.home_unread, homeUnreadClickedCb, LV_EVENT_CLICKED, nullptr); g_lv.home_stats = lv_label_create(tab); lv_label_set_text(g_lv.home_stats, ""); lv_obj_set_style_text_color(g_lv.home_stats, lv_color_hex(COLOR_SUB), LV_PART_MAIN); // Use the extras-fallback font so the "·" separator renders (the default font // lacks U+00B7 and drew it as a tofu box). lv_obj_set_style_text_font(g_lv.home_stats, &g_font_14, LV_PART_MAIN); // In landscape, keep the status text clear of the top-right button column. lv_obj_set_width(g_lv.home_stats, home_land ? (cw - RSTRIP) : cw); lv_label_set_long_mode(g_lv.home_stats, LV_LABEL_LONG_WRAP); lv_obj_align(g_lv.home_stats, LV_ALIGN_TOP_LEFT, 0, home_stats_y); // home_dc_label/bar reserved for a future bar widget; meter for now is // appended into home_stats so it lives in the same scarce vertical band // and doesn't collide with the TX/RX chart legend at y=64. g_lv.home_dc_label = nullptr; g_lv.home_dc_bar = nullptr; #if defined(HAS_EXPANSION_KIT) // Expansion Kit: tappable local-env summary line + a tiny batt/temp/hum // history chart on Home. Tapping either opens the detailed Local-Sensors page. // Only built when an env sensor is present and the pref is on; otherwise the // env label/chart stay null (reset above) and the TX/RX chart below uses the // normal non-Expansion chart_y, so Home looks like a plain build. if (sensorsUiWanted()) { g_lv.home_env = lv_label_create(tab); lv_label_set_text(g_lv.home_env, ""); lv_obj_set_style_text_color(g_lv.home_env, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.home_env, &g_font_12, LV_PART_MAIN); lv_obj_set_width(g_lv.home_env, home_land ? (cw - RSTRIP) : cw); lv_label_set_long_mode(g_lv.home_env, LV_LABEL_LONG_WRAP); lv_obj_align(g_lv.home_env, LV_ALIGN_TOP_LEFT, 0, SC(58)); lv_obj_add_flag(g_lv.home_env, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_lv.home_env, 8); lv_obj_add_event_cb(g_lv.home_env, openLocalSensorsPageCb, LV_EVENT_CLICKED, nullptr); s_home_env_chart = lv_chart_create(tab); g_lv.home_env_chart = s_home_env_chart; lv_obj_set_size(s_home_env_chart, home_land ? (cw - RSTRIP) : cw, SC(34)); lv_obj_align(s_home_env_chart, LV_ALIGN_TOP_LEFT, 0, SC(92)); // Keep the graph passive so a portrait drag beginning here scrolls Home. // The summary text directly above remains the explicit Sensors link. lv_obj_clear_flag(s_home_env_chart, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_chart_set_type(s_home_env_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_home_env_chart, kHomeEnvHistoryPoints); lv_chart_set_update_mode(s_home_env_chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_range(s_home_env_chart, LV_CHART_AXIS_PRIMARY_Y, 3400, 4600); lv_chart_set_range(s_home_env_chart, LV_CHART_AXIS_SECONDARY_Y, -10, 100); lv_chart_set_div_line_count(s_home_env_chart, 0, 0); lv_obj_set_style_bg_color(s_home_env_chart, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_home_env_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(s_home_env_chart, 0, LV_PART_MAIN); lv_obj_set_style_size(s_home_env_chart, 0, LV_PART_INDICATOR); s_home_env_batt = lv_chart_add_series(s_home_env_chart, lv_color_hex(0x4F9DF7), LV_CHART_AXIS_PRIMARY_Y); s_home_env_temp = lv_chart_add_series(s_home_env_chart, lv_color_hex(0xF5A623), LV_CHART_AXIS_SECONDARY_Y); s_home_env_hum = lv_chart_add_series(s_home_env_chart, lv_color_hex(0x35C9C9), LV_CHART_AXIS_SECONDARY_Y); lv_chart_set_all_value(s_home_env_chart, s_home_env_batt, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_home_env_chart, s_home_env_temp, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_home_env_chart, s_home_env_hum, LV_CHART_POINT_NONE); } // sensorsUiWanted() #endif // HAS_EXPANSION_KIT // TX / RX rolling chart. 60 samples, two series (TX green, RX blue). // Compact legend strip above the chart shows live totals — updated on every // refreshStatusLabels tick. With home_state now at y=36 and home_stats // (two-line WRAP) anchored at y=54, the bottom of the DC line sits around // y=82. Chart legend therefore starts at y=94 to keep a small gap below // the duty-cycle text. // home_state at y=4, home_stats (two-line WRAP) at y=22 reaches roughly // y=50; chart group starts at y=60 with breathing room. Used to be y=94 // when the title/clock/battery row lived inside the tab. // Expansion Kit boards push this down to make room for the env line + chart // (env label at SC(58), env chart at SC(92)); relayoutHomeCharts() then // re-flows the TX/RX legend + chart + advert button to the measured bottom. #if defined(TLORA_PAGER) const int chart_y = pager_size ? home_stats_y + home_line_h + 4 : 60; #elif defined(HAS_EXPANSION_KIT) // With the env widget present, push the TX/RX chart down to clear the env // label (SC(58)) + env chart (SC(92)). With it hidden, sit at the normal // non-Expansion offset so Home matches a plain build. (relayoutHomeCharts() // re-flows everything when the env widget exists, and no-ops when it doesn't.) const int chart_y = sensorsUiWanted() ? SC(138) : SC(60); #else const int chart_y = SC(60); #endif #if defined(TLORA_PAGER) const int chart_head_h = pager_size ? lv_font_get_line_height(&g_font_12) + 2 : 16; #else const int chart_head_h = 16; #endif const int chart_body_y = chart_y + chart_head_h; s_home_chart_legend = lv_label_create(tab); lv_label_set_text(s_home_chart_legend, "TX 0 / RX 0"); lv_obj_set_style_text_color(s_home_chart_legend, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(s_home_chart_legend, &g_font_12, LV_PART_MAIN); lv_obj_align(s_home_chart_legend, LV_ALIGN_TOP_LEFT, 0, chart_y); lv_obj_add_flag(s_home_chart_legend, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(s_home_chart_legend, 8); lv_obj_add_event_cb(s_home_chart_legend, homeChartClickedCb, LV_EVENT_CLICKED, nullptr); #if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) || defined(ATTAKY_MESH_SERIES) // Landscape boards keep the chart clear of the right-hand button strip. const int chart_w = home_land ? (cw - RSTRIP) : cw; #else const int chart_w = cw; #endif // Fit the chart in the remaining vertical space: content height minus the // tab padding, the chart's top offset, and the Send-advert button + gaps. // Portrait keeps the full 96 px; landscape (short screen) shrinks it so the // button doesn't run off the bottom. const int home_avail = tabContentH() - 20; // inside 10-px pad // Landscape: button sits in the right column, so the chart runs to the // bottom (no reserved button row). Portrait: reserve the button row below. int chart_h = home_avail - chart_body_y - 4 - (home_land ? 0 : (8 + 36)); if (chart_h > 96) chart_h = 96; if (chart_h < 28) chart_h = 28; #if CAP_LARGE_SCREEN // Big screen: spread the four right-column buttons evenly down the FULL height. chart_h = 96; const int tan_btn_gap = 12; const int tan_btn_h = (home_avail - 3 * tan_btn_gap) / 4; // 4 buttons fill the right column auto tanBtnY = [&](int slot) { return slot * (tan_btn_h + tan_btn_gap); }; // At Large/Huge there isn't room for both the TX/RX chart AND the 8-row info panel — drop the // chart (TX/RX totals still show in the legend above) so the info panel gets the freed height. #if defined(HAS_TDISPLAY_P4) // P4: the tall portrait home has room for the chart at EVERY UI scale — and the fscale>100 skip // was leaving an 84-px hole (chart_h stayed reserved below while the chart was never created; // this unit inherited fscale>100 from the adopted T-Deck prefs, so home showed blank space). const bool home_scaled = false; #else const bool home_scaled = (s_ui_fscale > 100); #endif if (home_scaled) chart_h = 0; #if defined(HAS_TDISPLAY_P4) // T-Display P4: LARGE but PORTRAIT (284x616) — the Tanmatsu right-strip layout doesn't apply. // Column flow instead: chart (taller, full width) → a 2-col launcher grid (Advert/Apps, // Terminal/Files, Control full-width) → the info card fills the rest, full width. The old code // put the half-width Advert row at chart-bottom AND the (needlessly RSTRIP-narrowed) info card // 4 px below it — they overlapped, and portrait boards never got the launcher buttons at all. chart_h = 84; // was 110: with TX/RX idle the chart is near-invisible, and the freed height // lets the info card below fit all 8 rows (Battery/Uptime were clipping off) const int p4_btn_h = 40; const int p4_btn_gap = 8; const int p4_grid_y = chart_body_y + chart_h + 10; const int p4_grid_bottom = p4_grid_y + 3 * p4_btn_h + 2 * p4_btn_gap; #endif #else const bool home_scaled = false; // Non-Tanmatsu landscape (T-Deck) right-hand column: Advert + launchers spread // evenly down the FULL content height so a 4th launcher ("Control panel") fits the // short 168-px T-Deck strip without overflowing. Slot count tracks the build: // GT911 (T-Deck) = Advert+Terminal+Files+Apps+Control = 5; otherwise 4. #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) const int td_btn_n = 5; #else const int td_btn_n = 4; #endif const int td_btn_gap = 6; int td_btn_h = (home_avail - (td_btn_n - 1) * td_btn_gap) / td_btn_n; if (td_btn_h > 46) td_btn_h = 46; // don't grow past the old Advert height if (td_btn_h < 22) td_btn_h = 22; auto tdBtnY = [&](int slot) { return slot * (td_btn_h + td_btn_gap); }; #endif s_home_chart = home_scaled ? nullptr : lv_chart_create(tab); if (s_home_chart) { lv_obj_set_size(s_home_chart, chart_w, chart_h); lv_obj_align(s_home_chart, LV_ALIGN_TOP_LEFT, 0, chart_body_y); lv_chart_set_type(s_home_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_home_chart, 60); lv_chart_set_update_mode(s_home_chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_range(s_home_chart, LV_CHART_AXIS_PRIMARY_Y, 0, s_home_chart_max_y); // No gridlines — keep the chart visually clean. lv_chart_set_div_line_count(s_home_chart, 0, 0); // Chart sits on pure BG (was COLOR_PANEL which still rendered as a // perceptible lighter rectangle on the home tab). Faint 1-px border // hints at the chart bounds without painting a brighter rectangle. lv_obj_set_style_bg_color(s_home_chart, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_home_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_home_chart, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(s_home_chart, LV_OPA_30, LV_PART_MAIN); lv_obj_set_style_border_width(s_home_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_home_chart, 6, LV_PART_MAIN); lv_obj_set_style_size(s_home_chart, 0, LV_PART_INDICATOR); s_home_chart_tx = lv_chart_add_series(s_home_chart, lv_color_hex(COLOR_STATUS_OK), LV_CHART_AXIS_PRIMARY_Y); s_home_chart_rx = lv_chart_add_series(s_home_chart, lv_color_hex(0x4F94CD), LV_CHART_AXIS_PRIMARY_Y); // Tap the graph for the signal & traffic detail popup. lv_obj_add_flag(s_home_chart, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(s_home_chart, homeChartClickedCb, LV_EVENT_CLICKED, nullptr); // In-box overlays: live signal (top-left) + a clear tap cue (bottom-right). // Both sit on a faint chip so they read over the TX/RX lines, and neither is // clickable, so a tap anywhere on the box falls through to the chart handler. s_home_chart_sig = lv_label_create(s_home_chart); lv_label_set_text(s_home_chart_sig, "Sig --"); lv_obj_set_style_text_font(s_home_chart_sig, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_home_chart_sig, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_color(s_home_chart_sig, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_home_chart_sig, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_home_chart_sig, 3, LV_PART_MAIN); lv_obj_set_style_radius(s_home_chart_sig, 3, LV_PART_MAIN); lv_obj_align(s_home_chart_sig, LV_ALIGN_TOP_LEFT, 2, 2); lv_obj_t* hint = lv_label_create(s_home_chart); lv_label_set_text(hint, TR("tap for details")); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_color(hint, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(hint, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_pad_hor(hint, 3, LV_PART_MAIN); lv_obj_set_style_radius(hint, 3, LV_PART_MAIN); // Top-right (by request), opposite the top-left "Sig --" chip. On a longer // translation it sits close to the Sig chip, but keeping the cue at the top of the // box is preferred. lv_obj_align(hint, LV_ALIGN_TOP_RIGHT, -2, 2); } #if CAP_LARGE_SCREEN // Commander info panel — fills the space below the chart on the big screen. Two columns: // static keys (left) + live values (right, s_home_info; refreshed in refreshStatusLabels). { #if defined(HAS_TDISPLAY_P4) const int info_y = p4_grid_bottom + 12; // below the portrait launcher grid const int info_w = cw; // full width — no right button strip in portrait #else const int info_y = chart_body_y + chart_h + 12; const int info_w = cw - RSTRIP; // left/centre column, clear of the right button strip #endif lv_obj_t* card = lv_obj_create(tab); lv_obj_remove_style_all(card); #if defined(HAS_TDISPLAY_P4) // Content-fit height with tight spacing, CLAMPED to the space left above the tab bar — at // Large/Huge UI scale the rows are taller and a fixed-spacing card ran past the bottom // (Uptime clipped). Spacing adapts down (3 → 0) before the card ever exceeds the screen; // if even 0-spacing overflows (extreme scale) the card's AUTO scrollbar takes over. const int p4_info_fh = lv_font_get_line_height(&g_font_12); // Clamp against the tab's MEASURED content height, not the derived home_avail — the // round-corner status-bar pad stack makes the real space smaller than the formula // (Uptime kept clipping under the tab bar at Large scale). lv_obj_update_layout(tab); const int p4_tab_h = lv_obj_get_content_height(tab); const int p4_max_h = (p4_tab_h > 0 ? p4_tab_h : home_avail) - info_y - 4; int p4_info_ls = (p4_max_h - 16 - 8 * p4_info_fh) / 7; if (p4_info_ls > 3) p4_info_ls = 3; // tight, not airy if (p4_info_ls < 0) p4_info_ls = 0; int p4_card_h = 16 + 8 * p4_info_fh + 7 * p4_info_ls; if (p4_card_h > p4_max_h) p4_card_h = p4_max_h; lv_obj_set_size(card, info_w, p4_card_h); #else lv_obj_set_size(card, info_w, home_avail - info_y - SC(8)); // small bottom margin so the last row (Uptime) clears #endif lv_obj_align(card, LV_ALIGN_TOP_LEFT, 0, info_y); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(card, LV_OPA_30, LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); // scroll if the 8 rows still overflow at Huge // Use the denser body font (one step below the headline) and DERIVE the line // spacing from the real card height so all 8 rows always fit — the last rows // (Battery/Uptime) were clipping off the bottom at Huge, and a no-touch device // can't reach the AUTO scrollbar. Spread the leftover over the 7 inter-row gaps. const lv_font_t* info_font = &g_font_12; #if defined(HAS_TDISPLAY_P4) const int info_ls = p4_info_ls; // fixed tight spacing (card is content-fit) #else const int info_card_h = home_avail - info_y - SC(8); const int info_fh = lv_font_get_line_height(info_font); int info_ls = (info_card_h - 16 - 8 * info_fh) / 7; // pad_all=8 → -16 if (info_ls < 1) info_ls = 1; if (info_ls > 8) info_ls = 8; #endif lv_obj_t* keys = lv_label_create(card); lv_label_set_text(keys, TR("Node\nRegion\nRadio\nSignal\nContacts\nChannels\nBattery\nUptime")); lv_obj_set_style_text_color(keys, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(keys, info_font, LV_PART_MAIN); lv_obj_set_style_text_line_space(keys, info_ls, LV_PART_MAIN); lv_obj_align(keys, LV_ALIGN_TOP_LEFT, 0, 0); s_home_info = lv_label_create(card); lv_label_set_text(s_home_info, "..."); lv_obj_set_style_text_color(s_home_info, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_home_info, info_font, LV_PART_MAIN); lv_obj_set_style_text_line_space(s_home_info, info_ls, LV_PART_MAIN); // Constrain + clip the values column at the card's padding edge — a long node name or // radio string otherwise ran to the card border and was cut mid-glyph against it. lv_obj_set_width(s_home_info, info_w - 16 - SC(100)); lv_label_set_long_mode(s_home_info, LV_LABEL_LONG_CLIP); lv_obj_align(s_home_info, LV_ALIGN_TOP_LEFT, SC(100), 0); // values column clears the keys } #endif // Send Advert button. Portrait: full-width row below the chart. Landscape: // a compact button parked in the top-right strip (next to the status text), // which is why the chart above was allowed to run full-height. const int adv_y = chart_body_y + chart_h + 8; #if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN (void)adv_y; // P4 portrait-large uses the grid Ys (p4_grid_y); adv_y feeds the other layouts #endif lv_obj_t* adv = lv_btn_create(tab); #if defined(HAS_EXPANSION_KIT) s_home_adv_btn = adv; #endif styleButton(adv); lv_obj_set_style_bg_color(adv, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(adv, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_text_color(adv, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); if (home_land) { #if CAP_LARGE_SCREEN lv_obj_set_size(adv, BTNW, tan_btn_h); // slot 0 of the spread right column lv_obj_align(adv, LV_ALIGN_TOP_LEFT, cw - BTNW, tanBtnY(0)); #else lv_obj_set_size(adv, BTNW, td_btn_h); // slot 0 of the evenly-spread T-Deck column lv_obj_align(adv, LV_ALIGN_TOP_LEFT, cw - BTNW, tdBtnY(0)); #endif } else { #if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN // Portrait-large (T-Display P4): Advert = grid slot row 0 left (grid laid out above). lv_obj_set_size(adv, (cw - p4_btn_gap) / 2, p4_btn_h); lv_obj_align(adv, LV_ALIGN_TOP_LEFT, 0, p4_grid_y); #else // Portrait (V4): share the row with an "Apps" button — Advert on the left half, // Apps on the right half. (Landscape boards get Apps in the launcher column below.) lv_obj_set_size(adv, (cw - 8) / 2, 36); lv_obj_align(adv, LV_ALIGN_TOP_LEFT, 0, adv_y); #endif } lv_obj_add_event_cb(adv, openAdvertModalCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* adv_l = lv_label_create(adv); // Shorter label in the narrow landscape button + the half-width portrait button so it doesn't clip. lv_label_set_text(adv_l, TR(LV_SYMBOL_UPLOAD " Advert")); lv_obj_set_style_text_font(adv_l, &g_font_14, LV_PART_MAIN); lv_obj_center(adv_l); #if defined(HAS_TOUCH_UI) // Portrait "Apps" launcher (right half of the advert row). Landscape boards build // Apps in the right-hand launcher column instead, so this is portrait-only. if (!home_land) { #if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN const int half = (cw - p4_btn_gap) / 2; const int btn_h = p4_btn_h; const int apps_x = half + p4_btn_gap; const int apps_y = p4_grid_y; #else const int half = (cw - 8) / 2; const int btn_h = 36; const int apps_x = half + 8; const int apps_y = adv_y; #endif lv_obj_t* apb = lv_btn_create(tab); styleButton(apb); lv_obj_set_size(apb, half, btn_h); lv_obj_align(apb, LV_ALIGN_TOP_LEFT, apps_x, apps_y); const uint32_t inv_accent = 0xFFFFFFu ^ (COLOR_ACCENT & 0xFFFFFFu); lv_obj_set_style_bg_color(apb, lv_color_hex(inv_accent), LV_PART_MAIN); lv_obj_set_style_bg_opa(apb, LV_OPA_COVER, LV_PART_MAIN); lv_obj_add_event_cb(apb, homeAppsBtnCb, LV_EVENT_CLICKED, nullptr); g_lv.home_apps = apb; lv_obj_t* apl = lv_label_create(apb); lv_label_set_text(apl, TR(LV_SYMBOL_LIST " Apps")); lv_obj_set_style_text_font(apl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(apl, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_center(apl); #if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN // Rows 1–2 of the portrait-large launcher grid: Terminal | Files, then Control full-width. // (The tall screen has the room the other portrait board lacks; Files is live now that the // file manager browses SD_MMC/FFat on this board.) auto p4_launcher = [&](const char* label, int x, int y, int w, lv_event_cb_t cb) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(tab); styleButton(b); lv_obj_set_size(b, w, p4_btn_h); lv_obj_align(b, LV_ALIGN_TOP_LEFT, x, y); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); return b; }; const int row1 = p4_grid_y + p4_btn_h + p4_btn_gap; const int row2 = row1 + p4_btn_h + p4_btn_gap; p4_launcher(">_ Terminal", 0, row1, half, homeTerminalCb); p4_launcher(LV_SYMBOL_DIRECTORY " Files", apps_x, row1, half, homeFilesCb); p4_launcher(LV_SYMBOL_BARS " Control", 0, row2, cw, homeControlPanelCb); #endif } #endif #if defined(HAS_EXPANSION_KIT) relayoutHomeCharts(); #endif #if defined(HAS_TOUCH_UI) // Terminal / Files / Apps / Control-panel launchers, stacked under Advert in the // right column and spread to fill the full content height. Files is SD-backed so it // stays T-Deck-only; Terminal + Apps + Control panel are on every touch board. if (home_land) { auto make_launcher = [&](const char* label, int ly, lv_event_cb_t cb, uint32_t bg, int bh) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(tab); styleButton(b); lv_obj_set_size(b, BTNW, bh); lv_obj_align(b, LV_ALIGN_TOP_LEFT, cw - BTNW, ly); if (bg) { lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, LV_PART_MAIN); } lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, bh >= 44 ? &g_font_14 : &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(bg ? 0xFFFFFF : COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); return b; }; // The accent's inverse — used for the "Apps" pop colour and the Control-panel button. const uint32_t inv_accent = 0xFFFFFFu ^ (COLOR_ACCENT & 0xFFFFFFu); #if CAP_LARGE_SCREEN make_launcher(TR(">_ Terminal"), tanBtnY(1), homeTerminalCb, 0, tan_btn_h); #if defined(HAS_TDECK_GT911) make_launcher(TR(LV_SYMBOL_DIRECTORY " Files"), tanBtnY(2), homeFilesCb, 0, tan_btn_h); // "Apps" (opens the app drawer) pops with the negative / inverse of the theme accent. g_lv.home_apps = make_launcher(TR(LV_SYMBOL_LIST " Apps"), tanBtnY(3), homeAppsBtnCb, inv_accent, tan_btn_h); #else // Real Tanmatsu (no GT911 / no Files): Apps in slot 2, Control panel fills slot 3. g_lv.home_apps = make_launcher(TR(LV_SYMBOL_LIST " Apps"), tanBtnY(2), homeAppsBtnCb, inv_accent, tan_btn_h); make_launcher(TR(LV_SYMBOL_BARS " Control"), tanBtnY(3), homeControlPanelCb, 0, tan_btn_h); // ☰ (mirrors the CC sliders), not the settings gear #endif #else // T-Deck (and any other non-Tanmatsu landscape board): evenly-spread slots. make_launcher(TR(">_ Terminal"), tdBtnY(1), homeTerminalCb, 0, td_btn_h); #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) make_launcher(TR(LV_SYMBOL_DIRECTORY " Files"), tdBtnY(2), homeFilesCb, 0, td_btn_h); // "Apps" (opens the app drawer) pops with the negative / inverse of the theme accent. g_lv.home_apps = make_launcher(TR(LV_SYMBOL_LIST " Apps"), tdBtnY(3), homeAppsBtnCb, inv_accent, td_btn_h); make_launcher(TR(LV_SYMBOL_SETTINGS " Control"), tdBtnY(4), homeControlPanelCb, 0, td_btn_h); #else g_lv.home_apps = make_launcher(TR(LV_SYMBOL_LIST " Apps"), tdBtnY(2), homeAppsBtnCb, inv_accent, td_btn_h); make_launcher(TR(LV_SYMBOL_SETTINGS " Control"), tdBtnY(3), homeControlPanelCb, 0, td_btn_h); #endif #endif } #endif } // ----- Chat list (full-page thread chooser inside a tab) ----- static void makeChatList(lv_obj_t* tab, LvChatPanel& p, bool channel_mode, bool inbox_combined = false) { p.channel_mode = channel_mode; p.inbox_combined = inbox_combined; p.detail_open = false; p.overlay = nullptr; p.header_name = nullptr; p.msgs = nullptr; p.jump_btn = nullptr; p.jump_oldest_btn = nullptr; p.composer_row = nullptr; p.composer_ta = nullptr; // Disable tab-level scrolling; the list handles its own scroll. lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); // Swiping vertically used to call lv_obj_scroll_by on this tab; that still applied // raw scroll even with DIR_NONE. Drop SCROLLABLE so the tab never acts as a scroll // container, and reset any stale offset from older builds. lv_obj_scroll_to(tab, 0, 0, LV_ANIM_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); styleSurface(tab, COLOR_BG, 0); lv_obj_set_style_pad_all(tab, 0, LV_PART_MAIN); // Inbox/overview tab: the [+ add | ✓ mark-read | QR share] actions normally live in // a second status-bar row, so the list needs one row of top inset. Pager puts those // compact actions in the regular status row instead; reserving the old second row // there leaves a full chat-row-sized hole above the first thread. p.list_cont = lv_list_create(tab); lv_obj_set_size(p.list_cont, tabContentW(), tabContentH()); lv_obj_set_pos(p.list_cont, 0, 0); styleSurface(p.list_cont, COLOR_BG, 0); lv_obj_set_style_border_width(p.list_cont, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(p.list_cont, 0, LV_PART_MAIN); // Inset the top so the FIRST row rests just below the tall bar (the inbox opens at the // top, unlike the chat which opens at the bottom). Older rows still scroll UP under the // glass lower row — and now read through it because the rows are a visible grey. #if !defined(TLORA_PAGER) if (inbox_combined) lv_obj_set_style_pad_top(p.list_cont, STATUSBAR_H, LV_PART_MAIN); #endif lv_obj_set_style_pad_row(p.list_cont, 1, LV_PART_MAIN); lv_obj_add_event_cb(p.list_cont, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); } // ----- Contacts header: segmented filter + overflow menu ----- // // The four filter segments map to g_lv.contacts_filter values: // index 0 "All" -> 0, 1 "★" -> 3, 2 "RPT" -> 1, 3 "Peer" -> 2. // Tapping a segment sets that filter directly (no toggle-to-clear — "All" // is its own segment). The active segment is filled; the rest read as // faint chips. Search / Found / + moved into the "⋯" overflow sheet so // the filter row stays uncluttered and the list gets maximum height. static const uint8_t k_contacts_seg_filter[4] = { 0, 3, 1, 2 }; static lv_obj_t* s_contacts_seg_btns[4] = { nullptr, nullptr, nullptr, nullptr }; static lv_obj_t* s_contacts_overflow_root = nullptr; static void styleContactsSegment(lv_obj_t* b, bool active) { if (active) { // Filled, brighter — clearly the current view. lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_border_opa(b, LV_OPA_0, LV_PART_MAIN); } else { // Faint — etched out of the bezel like the other chips. lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_10, LV_PART_MAIN); lv_obj_set_style_border_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_width(b, 1, LV_PART_MAIN); lv_obj_set_style_border_opa(b, LV_OPA_40, LV_PART_MAIN); } } static void updateContactsFilterSegments() { for (int i = 0; i < 4; ++i) { if (!s_contacts_seg_btns[i]) continue; styleContactsSegment(s_contacts_seg_btns[i], g_lv.contacts_filter == k_contacts_seg_filter[i]); } } static void contactsSegmentCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const uintptr_t f = reinterpret_cast<uintptr_t>(lv_event_get_user_data(e)); if (f > 3) return; g_lv.contacts_filter = static_cast<uint8_t>(f); updateContactsFilterSegments(); refreshContactsList(); if (g_lv.contacts_list) lv_obj_scroll_to(g_lv.contacts_list, 0, 0, LV_ANIM_OFF); } // ---- Overflow ("⋯") sheet: Search / Found / + ---- static void closeContactsOverflowSheet() { if (s_contacts_overflow_root) { popupClose(&s_contacts_overflow_root); } } static void contactsOverflowDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); closeContactsOverflowSheet(); } static void contactsOverflowSearchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); openContactsSearchSheetCb(e); } static void contactsOverflowFoundCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); openDiscoveredModalCb(e); } static void contactsOverflowAddCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); openAddContactModalCb(e); } static void contactsOverflowAutoAddCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); openSettingsCategory(CAT_AUTOADD); // jump straight to the dedicated Auto-add page } static void contactsOverflowBlockedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); openBlockedUsersModal(); // ignore-list manager (unblock people) } static void openContactsOverflowSheetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeContactsOverflowSheet(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_contacts_overflow_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_contacts_overflow_root); lv_obj_set_size(s_contacts_overflow_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_contacts_overflow_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_contacts_overflow_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_contacts_overflow_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_contacts_overflow_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_contacts_overflow_root); lv_obj_add_event_cb(s_contacts_overflow_root, contactsOverflowDismissCb, LV_EVENT_CLICKED, nullptr); // Bigger on the 800-px Tanmatsu panel. The Pager also has spare horizontal // room, so give its longest action ("Auto-add settings") enough width at // the larger text presets; compact boards retain the historical width. // Compact rows so all five buttons + title fit the T-Deck's short 240px landscape // screen (the 5th "Blocked list" item pushed the old 36px rows off-screen). #72. #if defined(TLORA_PAGER) const int card_w = sw / 2; #else const int card_w = PCW(200); #endif const int btn_h = SC(30), btn_gap = SC(4), title_h = SC(22), padding = SC(8); // SC not PSC: the 1.7x boost clipped the lower rows (Auto-add / Blocked) off-screen at Large scale const int rows = 5; int card_h = 2 * padding + title_h + rows * btn_h + (rows - 1) * btn_gap; // reserve BOTH pad_all paddings (top+bottom) or the last row clips if (card_h > sh - STATUSBAR_H - 8) card_h = sh - STATUSBAR_H - 8; // never taller than the visible area lv_obj_t* card = lv_obj_create(s_contacts_overflow_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); // Anchor near the top-right under the header where the "⋯" lives. lv_obj_align(card, LV_ALIGN_TOP_RIGHT, -6, 8); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, padding, LV_PART_MAIN); lv_obj_add_flag(card, LV_OBJ_FLAG_SCROLLABLE); // safety net: if capped below content (e.g. Huge scale), the lower rows stay reachable lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Contacts")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); // Saved-contacts count — restored from the old system-info line (#72), shown // right-aligned on the menu's title row. lv_obj_t* cnt_lbl = lv_label_create(card); char cnt_buf[24]; snprintf(cnt_buf, sizeof(cnt_buf), "%d / %d", the_mesh.getNumContacts(), (int)MAX_CONTACTS); lv_label_set_text(cnt_lbl, cnt_buf); lv_obj_set_style_text_color(cnt_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(cnt_lbl, &g_font_12, LV_PART_MAIN); lv_obj_align(cnt_lbl, LV_ALIGN_TOP_RIGHT, 0, 0); int y = title_h; auto mk = [&](const char* label, lv_event_cb_t cb, uint32_t bg) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * padding, btn_h); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 10, LV_PART_MAIN); if (bg) lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_size(l, card_w - 2 * padding - 20, lv_font_get_line_height(&g_font_14)); lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); y += btn_h + btn_gap; }; mk(TR(LV_SYMBOL_EYE_OPEN " Search"), contactsOverflowSearchCb, 0); mk(TR(LV_SYMBOL_LIST " Discovered"), contactsOverflowFoundCb, 0); mk(TR(LV_SYMBOL_PLUS " Add contact"), contactsOverflowAddCb, 0); mk(TR(LV_SYMBOL_DOWNLOAD " Auto-add settings"), contactsOverflowAutoAddCb, 0); mk(TR(LV_SYMBOL_CLOSE " Blocked list"), contactsOverflowBlockedCb, 0); } // ============================================================ // Contacts — Sort & filter sheet + multi-select delete // ============================================================ static bool s_ct_select_mode = false; static uint8_t (*s_ct_sel)[6] = (uint8_t(*)[6])psAlloc(128 * 6); // pub_key prefix of currently-selected (deletable) contacts — PSRAM static int s_ct_sel_n = 0; static bool s_ct_list_force = false; // force refreshContactsList past its no-change cache static volatile bool s_ct_contacts_dirty = false; // a contact was discovered/added (set from the mesh callback); UITask::loop rebuilds the visible Contacts list — issue #73 static void contactsListForceRefresh() { s_ct_list_force = true; refreshContactsList(); } static lv_obj_t* s_ct_sort_sheet = nullptr; // Sort & filter overlay static lv_obj_t* s_ct_normal_bar = nullptr; // header toolbar shown in normal mode static lv_obj_t* s_ct_select_bar = nullptr; // header toolbar shown in select mode static lv_obj_t* s_ct_del_btn = nullptr; // "Delete (N)" button in the select bar static lv_obj_t* s_ct_sort_btns[CONTACTS_SORT_COUNT] = { nullptr, nullptr, nullptr, nullptr }; static lv_obj_t* s_ct_filter_btns[6] = { nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }; static lv_obj_t* s_ct_filter_btn_lbl = nullptr; // header Filter button label (shows the active filter) static lv_obj_t* s_ct_disc_badge = nullptr; // count badge on the header Discovered button static const char* contactsFilterShortLabel() { switch (g_lv.contacts_filter) { case 1u: return "RPT"; case 2u: return "Peer"; case 3u: return "Fav"; case 4u: return "Loc"; case 5u: return "Dir"; default: return "All"; } } // Bulk-delete runs chunked across loop ticks (each uiRemoveContact rewrites the // contacts file to flash; doing 100+ synchronously froze the UI / tripped the WDT). static bool s_ctd_active = false; static int s_ctd_total = 0, s_ctd_done = 0; static ContactInfo* s_ctd_list = nullptr; // psAlloc'd snapshot of what to delete static lv_obj_t* s_ctd_overlay = nullptr; // progress modal static lv_obj_t* s_ctd_bar = nullptr; static lv_obj_t* s_ctd_lbl = nullptr; static bool ctSelHas(const uint8_t* k){ for(int i=0;i<s_ct_sel_n;++i) if(!memcmp(s_ct_sel[i],k,6)) return true; return false; } static void ctSelAdd(const uint8_t* k){ if(s_ct_sel_n<128 && !ctSelHas(k)) memcpy(s_ct_sel[s_ct_sel_n++],k,6); } static void ctSelDel(const uint8_t* k){ for(int i=0;i<s_ct_sel_n;++i) if(!memcmp(s_ct_sel[i],k,6)){ for(int j=i;j+1<s_ct_sel_n;++j) memcpy(s_ct_sel[j],s_ct_sel[j+1],6); --s_ct_sel_n; return; } } // Lower-case the active name-search needle into out[]; true if non-empty. static bool ctSearchNeedle(char* out, int outsz){ out[0]='\0'; if(!g_lv.contacts_search[0]) return false; int n=(int)strlen(g_lv.contacts_search); if(n>outsz-1) n=outsz-1; for(int i=0;i<n;++i){ char c=g_lv.contacts_search[i]; if(c>='A'&&c<='Z') c=(char)(c-'A'+'a'); out[i]=c; } out[n]='\0'; return true; } // Shared predicate so the rendered list and Select-all use IDENTICAL rules. static bool ctPassesFilter(const ContactInfo& c, bool is_fav, int fav_count, const char* needle){ const bool is_rep = (c.type == ADV_TYPE_REPEATER); switch(g_lv.contacts_filter){ case 1u: if(!is_rep) return false; break; // repeaters case 2u: if(is_rep) return false; break; // peers case 3u: if(fav_count>0 && !is_fav) return false; break; // favorites case 4u: if(!(c.gps_lat!=0 || c.gps_lon!=0)) return false; break; // has location case 5u: if(c.out_path_len != 0) return false; break; // 0-hop / direct neighbors only default: break; // 0 = all } if(needle && needle[0]){ char nl[40]; int nn=(int)strlen(c.name); if(nn>(int)sizeof(nl)-1) nn=(int)sizeof(nl)-1; for(int j=0;j<nn;++j){ char ch=c.name[j]; if(ch>='A'&&ch<='Z') ch=(char)(ch-'A'+'a'); nl[j]=ch; } nl[nn]='\0'; if(!strstr(nl,needle)) return false; } return true; } // In-place checkbox repaint (avoids a full list rebuild + scroll reset on tap). static void ctUpdateRowCheck(lv_obj_t* row, bool checked){ if(!row) return; lv_obj_t* box = lv_obj_get_child(row, 0); // checkbox is child 0 in select mode if(!box) return; lv_obj_set_style_bg_opa(box, checked ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_t* chk = lv_obj_get_child(box, 0); if(chk){ if(checked) lv_obj_clear_flag(chk, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(chk, LV_OBJ_FLAG_HIDDEN); } } static void ctUpdateDelLabel(){ if(!s_ct_del_btn) return; lv_obj_t* l = lv_obj_get_child(s_ct_del_btn, 0); if(!l) return; char b[28]; if(s_ct_sel_n>0) snprintf(b,sizeof b,"%s %d", TR("Delete"), s_ct_sel_n); else snprintf(b,sizeof b,"%s", TR("Delete")); lv_label_set_text(l, b); } // Row tap: in select mode toggles the checkbox (favorites refuse); otherwise the // normal action sheet (unchanged behaviour). static void contactRowTapCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; lv_indev_t* act = lv_indev_get_act(); if(act){ if(lv_indev_get_scroll_obj(act)) return; if(lv_indev_get_scroll_dir(act)!=LV_DIR_NONE) return; } auto* ctx = static_cast<LvContactButtonCtx*>(lv_event_get_user_data(e)); if(!ctx || !g_lv.task) return; if(s_ct_select_mode){ if(ctx->is_fav){ g_lv.task->showAlert(TR("Unfavorite it first"), 1300); return; } if(ctSelHas(ctx->key6)) ctSelDel(ctx->key6); else ctSelAdd(ctx->key6); ctUpdateRowCheck(lv_event_get_current_target(e), ctSelHas(ctx->key6)); ctUpdateDelLabel(); return; } ContactInfo c; if(!the_mesh.getContactByIdx(ctx->mesh_idx, c)){ g_lv.task->showAlert(TR("Contact gone"), 1200); return; } openContactActionSheet(ctx->mesh_idx, ctx->is_repeater, c.name); } static void ctSetSelectMode(bool on){ s_ct_select_mode = on; s_ct_sel_n = 0; if(s_ct_normal_bar){ if(on) lv_obj_add_flag(s_ct_normal_bar,LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(s_ct_normal_bar,LV_OBJ_FLAG_HIDDEN); } if(s_ct_select_bar){ if(on) lv_obj_clear_flag(s_ct_select_bar,LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(s_ct_select_bar,LV_OBJ_FLAG_HIDDEN); } ctUpdateDelLabel(); s_ct_list_force = true; refreshContactsList(); } // Select EVERY contact passing the current filter + search (favorites skipped). static void ctSelectAllFiltered(){ if(!g_lv.task) return; s_ct_sel_n = 0; #if defined(ESP32) uint8_t fav_buf[TOUCH_FAVORITES_MAX*TOUCH_FAVORITE_KEY_BYTES]; const int fav_count = touchPrefsCopyFavorites(fav_buf); #else const int fav_count = 0; uint8_t* const fav_buf = nullptr; (void)fav_buf; #endif char needle[24]; ctSearchNeedle(needle, sizeof needle); const int cnt = the_mesh.getNumContacts(); for(int i=0;i<cnt && s_ct_sel_n<128;++i){ ContactInfo c; if(!the_mesh.getContactByIdx((uint32_t)i,c) || !c.name[0]) continue; #if defined(ESP32) const bool is_fav = touchPrefsFavoritesSnapshotContains(fav_buf, fav_count, c.id.pub_key); #else const bool is_fav = false; #endif if(is_fav) continue; // favorites aren't deletable here if(!ctPassesFilter(c, is_fav, fav_count, needle)) continue; ctSelAdd(c.id.pub_key); } ctUpdateDelLabel(); s_ct_list_force = true; refreshContactsList(); } static void ctDeleteProgressClose(){ if(s_ctd_overlay){ lv_obj_del(s_ctd_overlay); s_ctd_overlay=nullptr; s_ctd_bar=nullptr; s_ctd_lbl=nullptr; } } static void ctDeleteProgressOpen(){ ctDeleteProgressClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_ctd_overlay = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_ctd_overlay); lv_obj_set_size(s_ctd_overlay, sw, sh); lv_obj_set_pos(s_ctd_overlay, 0, 0); lv_obj_set_style_bg_color(s_ctd_overlay, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ctd_overlay, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_ctd_overlay, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_ctd_overlay, LV_OBJ_FLAG_CLICKABLE); // swallow taps during the delete lv_obj_t* card = lv_obj_create(s_ctd_overlay); lv_obj_remove_style_all(card); lv_obj_set_size(card, sw - 60, 94); lv_obj_center(card); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* ttl = lv_label_create(card); lv_label_set_text(ttl, TR("Deleting contacts")); lv_obj_set_style_text_color(ttl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ttl, &g_font_14, LV_PART_MAIN); lv_obj_align(ttl, LV_ALIGN_TOP_LEFT, 0, 0); s_ctd_bar = lv_bar_create(card); lv_obj_set_size(s_ctd_bar, sw - 60 - 28, 10); lv_obj_align(s_ctd_bar, LV_ALIGN_TOP_LEFT, 0, 26); lv_obj_set_style_bg_color(s_ctd_bar, lv_color_hex(0x2A2D31), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ctd_bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(s_ctd_bar, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_opa(s_ctd_bar, LV_OPA_COVER, LV_PART_INDICATOR); lv_bar_set_range(s_ctd_bar, 0, 100); lv_bar_set_value(s_ctd_bar, 0, LV_ANIM_OFF); s_ctd_lbl = lv_label_create(card); lv_label_set_text(s_ctd_lbl, "0 / 0"); lv_obj_set_style_text_color(s_ctd_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(s_ctd_lbl, &g_font_12, LV_PART_MAIN); lv_obj_align(s_ctd_lbl, LV_ALIGN_TOP_LEFT, 0, 44); } // Called every loop tick: delete a small batch, advance the bar. Spreading the // work keeps the loop responsive (UI repaints, watchdog fed) between flash writes. static void ctDeleteServiceTick(){ if(!s_ctd_active) return; const int BATCH = 4; for(int b=0; b<BATCH && s_ctd_done < s_ctd_total; ++b){ if(s_ctd_list) the_mesh.uiRemoveContact(s_ctd_list[s_ctd_done]); // lookup-by-pubkey: shift-safe ++s_ctd_done; } g_lv.dirty_threads = false; // don't let the bulk delete trigger per-tick list rebuilds if(s_ctd_bar) lv_bar_set_value(s_ctd_bar, s_ctd_total ? (s_ctd_done*100/s_ctd_total) : 100, LV_ANIM_OFF); if(s_ctd_lbl){ char b[40]; snprintf(b,sizeof b,"%d / %d", s_ctd_done, s_ctd_total); lv_label_set_text(s_ctd_lbl, b); } if(s_ctd_done >= s_ctd_total){ const int total = s_ctd_total; s_ctd_active = false; ctDeleteProgressClose(); if(g_lv.task){ char msg[32]; snprintf(msg,sizeof msg, TR("Deleted %d"), total); g_lv.task->showAlert(msg, 1200); } g_lv.dirty_threads = true; // one refresh now that we're done ctSetSelectMode(false); // exit select mode + rebuild the (now shorter) list } } static void ctDoDelete(){ if(!g_lv.task) return; if(!s_ctd_list) s_ctd_list = (ContactInfo*)psAlloc(sizeof(ContactInfo)*128); if(!s_ctd_list){ ctSetSelectMode(false); return; } int n = 0; const int cnt = the_mesh.getNumContacts(); for(int i=0;i<cnt && n<128;++i){ ContactInfo c; if(!the_mesh.getContactByIdx((uint32_t)i,c)) continue; if(ctSelHas(c.id.pub_key)) s_ctd_list[n++] = c; // snapshot first (delete shifts indices) } if(n==0){ ctSetSelectMode(false); return; } s_ctd_total = n; s_ctd_done = 0; s_ctd_active = true; ctDeleteProgressOpen(); // the loop tick does the actual deletion + advances the bar } static void ctCancelSelCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) ctSetSelectMode(false); } static void ctSelectAllCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) ctSelectAllFiltered(); } static void ctDeleteSelCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; if(s_ct_sel_n==0){ if(g_lv.task) g_lv.task->showAlert(TR("Nothing selected"), 1100); return; } char m[40]; snprintf(m,sizeof m, TR("Delete %d contact(s)?"), s_ct_sel_n); showConfirm(m, TR("Delete"), ctDoDelete); } static void ctSortSheetClose(){ if(s_ct_sort_sheet){ popupClose(&s_ct_sort_sheet); } } // X badge handler: close UNCONDITIONALLY. (The backdrop cb below only closes when // the tap target is the backdrop itself — correct for the backdrop, but that guard // made the close-X a no-op when its tap registered on the badge's glyph child.) static void ctSortSheetCloseCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) ctSortSheetClose(); } static void ctSortSheetBackdropCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; if(lv_event_get_target(e)==lv_event_get_current_target(e)) ctSortSheetClose(); // backdrop tap only } static void ctSortSheetGestureCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_GESTURE) return; lv_indev_t* indev = lv_indev_get_act(); if(indev && lv_indev_get_gesture_dir(indev)==LV_DIR_RIGHT) ctSortSheetClose(); // swipe left->right to dismiss } static void ctPaintOpt(lv_obj_t* b, bool active){ if(!b) return; lv_obj_set_style_bg_color(b, lv_color_hex(active ? COLOR_ACCENT : 0x1A1D20), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, active ? LV_OPA_40 : LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(b, active ? 1 : 0, LV_PART_MAIN); } static void ctSheetRepaint(){ for(int i=0;i<CONTACTS_SORT_COUNT;++i) ctPaintOpt(s_ct_sort_btns[i], g_contacts_sort==(uint8_t)i); for(int i=0;i<6;++i) ctPaintOpt(s_ct_filter_btns[i], g_lv.contacts_filter==(uint8_t)i); } // Repaint the Sort sheet rows: highlight the active mode and show its direction // arrow (▼ = the mode's natural order, ▲ = reversed). Labels are pulled from // contactsSortOptName so the active row gains/loses its trailing arrow live. static void ctSortRowsRepaint(){ for(int i=0;i<CONTACTS_SORT_COUNT;++i){ lv_obj_t* b = s_ct_sort_btns[i]; if(!b) continue; const bool active = (g_contacts_sort==(uint8_t)i); ctPaintOpt(b, active); lv_obj_t* l = lv_obj_get_child(b, 0); if(l){ if(active) lv_label_set_text_fmt(l, "%s %s", TR(contactsSortOptName((uint8_t)i)), g_contacts_sort_desc ? LV_SYMBOL_UP : LV_SYMBOL_DOWN); else lv_label_set_text(l, TR(contactsSortOptName((uint8_t)i))); } } } static void ctSortOptCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; const uint8_t m = (uint8_t)(uintptr_t)lv_event_get_user_data(e); if(m == g_contacts_sort) g_contacts_sort_desc = !g_contacts_sort_desc; // re-tap the active mode = flip asc/desc else { g_contacts_sort = m; g_contacts_sort_desc = false; } // new mode = its natural order s_ct_list_force = true; refreshContactsList(); ctSortSheetClose(); // apply + dismiss; re-open and tap the active row again to flip its direction } static void ctFilterOptCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; g_lv.contacts_filter = (uint8_t)(uintptr_t)lv_event_get_user_data(e); updateContactsFilterSegments(); if(s_ct_filter_btn_lbl) lv_label_set_text_fmt(s_ct_filter_btn_lbl, "%s %s", LV_SYMBOL_DOWN, contactsFilterShortLabel()); s_ct_list_force = true; refreshContactsList(); ctSortSheetClose(); // apply and dismiss, like sort does } static void ctSelectFromSheetCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; ctSortSheetClose(); ctSetSelectMode(true); } static lv_obj_t* ctSheetOption(lv_obj_t* parent, const char* label, lv_event_cb_t cb, int ud, bool active){ lv_obj_t* b = lv_btn_create(parent); lv_obj_remove_style_all(b); lv_obj_set_width(b, lv_pct(100)); lv_obj_set_height(b, 34); lv_obj_set_style_radius(b, 6, LV_PART_MAIN); lv_obj_set_style_border_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); ctPaintOpt(b, active); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)(intptr_t)ud); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(label)); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_center(l); return b; } // Shared shell for the Sort / Filter option sheets (only one is ever open). static lv_obj_t* ctOpenOptionSheet(const char* title){ ctSortSheetClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_ct_sort_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_ct_sort_sheet); lv_obj_set_size(s_ct_sort_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_ct_sort_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_ct_sort_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ct_sort_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_ct_sort_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_ct_sort_sheet, ctSortSheetBackdropCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(s_ct_sort_sheet, ctSortSheetGestureCb, LV_EVENT_GESTURE, nullptr); // swipe-to-close lv_obj_t* card = lv_obj_create(s_ct_sort_sheet); lv_obj_remove_style_all(card); lv_obj_set_width(card, sw - 24); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_set_style_max_height(card, sh - STATUSBAR_H - 16, LV_PART_MAIN); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 8); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_set_style_pad_row(card, 6, LV_PART_MAIN); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_add_flag(card, LV_OBJ_FLAG_GESTURE_BUBBLE); // swipes on the card reach the sheet's swipe-to-close addCloseXBadge(card, ctSortSheetCloseCb); // X always closes (not the backdrop's tap-target guard) lv_obj_t* ttl = lv_label_create(card); lv_label_set_text(ttl, TR(title)); lv_obj_set_style_text_color(ttl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ttl, &g_font_16, LV_PART_MAIN); return card; } static void openContactsSortSheet(){ for(int i=0;i<6;++i) s_ct_filter_btns[i]=nullptr; // the filter sheet's buttons are gone now lv_obj_t* card = ctOpenOptionSheet(TR("Sort by")); // active=false here; ctSortRowsRepaint() below sets the real highlight + arrow. s_ct_sort_btns[0] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_AZ), ctSortOptCb, CONTACTS_SORT_AZ, false); s_ct_sort_btns[1] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_LAST_HEARD), ctSortOptCb, CONTACTS_SORT_LAST_HEARD, false); s_ct_sort_btns[2] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_LAST_MSG), ctSortOptCb, CONTACTS_SORT_LAST_MSG, false); s_ct_sort_btns[3] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_DISTANCE), ctSortOptCb, CONTACTS_SORT_DISTANCE, false); ctSortRowsRepaint(); // tap the active row again to flip ascending/descending lv_obj_t* sp = lv_obj_create(card); lv_obj_remove_style_all(sp); lv_obj_set_size(sp, 1, 4); ctSheetOption(card, LV_SYMBOL_TRASH " Select to delete", ctSelectFromSheetCb, 0, false); } static void openContactsSortSheetCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) openContactsSortSheet(); } // ---- Discovered list sort sheet (reuses the Contacts option-sheet shell) ---- static void discSortOptCb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; const uint8_t m=(uint8_t)(uintptr_t)lv_event_get_user_data(e); if(m==g_disc_sort) g_disc_sort_desc=!g_disc_sort_desc; // re-tap the active mode = flip asc/desc else { g_disc_sort=m; g_disc_sort_desc=false; } // new mode = natural order ctSortSheetClose(); closeSettingsModal(); lv_event_t synth{}; synth.code=LV_EVENT_CLICKED; openDiscoveredModalCb(&synth); // rebuild the Discovered list in the new order } static void openDiscSortSheet(){ lv_obj_t* card = ctOpenOptionSheet(TR("Sort discovered")); for(int i=0;i<DISC_SORT_COUNT;++i){ const bool active=((uint8_t)i==g_disc_sort); // Pass the English name (ctSheetOption translates it); add the direction arrow // to the active row afterwards so the arrow survives translation. lv_obj_t* b = ctSheetOption(card, discSortOptName((uint8_t)i), discSortOptCb, i, active); if(active){ lv_obj_t* l=lv_obj_get_child(b,0); if(l) lv_label_set_text_fmt(l, "%s %s", TR(discSortOptName((uint8_t)i)), g_disc_sort_desc?LV_SYMBOL_UP:LV_SYMBOL_DOWN); } } } static void openDiscSortSheetCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) openDiscSortSheet(); } static void openContactsFilterSheet(){ for(int i=0;i<CONTACTS_SORT_COUNT;++i) s_ct_sort_btns[i]=nullptr; // the sort sheet's buttons are gone now lv_obj_t* card = ctOpenOptionSheet("Filter"); s_ct_filter_btns[0] = ctSheetOption(card, "All", ctFilterOptCb, 0, g_lv.contacts_filter==0); s_ct_filter_btns[1] = ctSheetOption(card, "Repeaters", ctFilterOptCb, 1, g_lv.contacts_filter==1); s_ct_filter_btns[2] = ctSheetOption(card, TR("Peers"), ctFilterOptCb, 2, g_lv.contacts_filter==2); s_ct_filter_btns[3] = ctSheetOption(card, TR("Favorites"), ctFilterOptCb, 3, g_lv.contacts_filter==3); s_ct_filter_btns[4] = ctSheetOption(card, TR("Has location"), ctFilterOptCb, 4, g_lv.contacts_filter==4); s_ct_filter_btns[5] = ctSheetOption(card, TR("Direct (0-hop)"), ctFilterOptCb, 5, g_lv.contacts_filter==5); } static void openContactsFilterSheetCb(lv_event_t* e){ if(lv_event_get_code(e)==LV_EVENT_CLICKED) openContactsFilterSheet(); } static void makeContactsTab(lv_obj_t* tab) { g_lv.contacts_list = nullptr; // Default to the "All" segment — show every contact, ordered by most // recently heard (favorites still pin to the top via the sort // comparator). The ★ segment is one tap away for the favorites-only view. g_lv.contacts_filter = 0; g_lv.contacts_search[0] = '\0'; g_lv.contacts_search_indicator = nullptr; for (int i = 0; i < 4; ++i) s_contacts_seg_btns[i] = nullptr; s_contacts_overflow_root = nullptr; s_ct_select_mode = false; s_ct_sel_n = 0; s_ct_sort_sheet = nullptr; s_ct_select_bar = nullptr; s_ct_normal_bar = nullptr; s_ct_del_btn = nullptr; s_ct_disc_badge = nullptr; s_ct_filter_btn_lbl = nullptr; lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); lv_obj_scroll_to(tab, 0, 0, LV_ANIM_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); styleSurface(tab, COLOR_BG, 0); lv_obj_set_style_pad_all(tab, 0, LV_PART_MAIN); lv_obj_t* row = lv_obj_create(tab); s_ct_normal_bar = row; // hidden while in multi-select mode (select bar shown instead) lv_obj_remove_style_all(row); // Row now matches the Chat tab's combined-inbox header (32 px tall, with // a 28-tall "+" button inset). Was 44 px previously, which made the // Contacts tab feel chunkier than the Chats tab. Full screen width so the // SPACE_BETWEEN flex pins the overflow button to the right edge in either // orientation. lv_obj_set_size(row, tabContentW(), 32); lv_obj_set_pos(row, 0, 0); lv_obj_set_style_pad_hor(row, 4, LV_PART_MAIN); lv_obj_set_style_pad_ver(row, 2, LV_PART_MAIN); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(row, 4, LV_PART_MAIN); // Row layout: a wide segmented filter on the left, a "⋯" overflow button // on the right. SPACE_BETWEEN pins them to the two edges. lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); constexpr lv_coord_t kChipH = 28; // ---- Big "Discovered" button (→ discovered list) with a count badge ---- { lv_obj_t* b = lv_btn_create(row); lv_obj_set_height(b, kChipH); lv_obj_set_flex_grow(b, 1); // takes the width freed by the removed filter segments styleButton(b); lv_obj_set_style_pad_hor(b, 8, LV_PART_MAIN); lv_obj_set_style_pad_ver(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, contactsOverflowFoundCb, LV_EVENT_CLICKED, nullptr); // opens the Discovered list lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(LV_SYMBOL_EYE_OPEN " Discovered")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); // Reserve the right ~quarter of the chip for the count badge: LONG_DOT only ellipsizes a // FIXED-width label, and the content-sized label ran under the badge on narrow chips // ("Disc[48]er" — the pill painted mid-word on the 284-px P4). lv_obj_set_width(l, lv_pct(76)); lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); s_ct_disc_badge = lv_label_create(b); // red count pill, right side (updated by the loop) lv_obj_set_size(s_ct_disc_badge, LV_SIZE_CONTENT, 16); lv_obj_set_style_radius(s_ct_disc_badge, 8, LV_PART_MAIN); lv_obj_set_style_bg_color(s_ct_disc_badge, lv_color_hex(0xE0533D), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ct_disc_badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_text_color(s_ct_disc_badge, lv_color_white(), LV_PART_MAIN); // This is fixed-size chrome inside a 16-px pill, not semantic body text. // Pager Large/Jumbo gives g_font_12 an oversized fallback line box, which // clips the digits below the pill instead of centring them. #if defined(TLORA_PAGER) lv_obj_set_style_text_font(s_ct_disc_badge, &lv_font_montserrat_12, LV_PART_MAIN); #else lv_obj_set_style_text_font(s_ct_disc_badge, &g_font_12, LV_PART_MAIN); #endif lv_obj_set_style_text_align(s_ct_disc_badge, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_ct_disc_badge, 3, LV_PART_MAIN); lv_obj_align(s_ct_disc_badge, LV_ALIGN_RIGHT_MID, -2, 0); lv_obj_add_flag(s_ct_disc_badge, LV_OBJ_FLAG_HIDDEN); } // ---- Filter button (replaces the All/★/RPT/Peer segments; shows the active filter) ---- { lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, 46, kChipH); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, openContactsFilterSheetCb, LV_EVENT_CLICKED, nullptr); s_ct_filter_btn_lbl = lv_label_create(b); lv_label_set_text_fmt(s_ct_filter_btn_lbl, "%s %s", LV_SYMBOL_DOWN, contactsFilterShortLabel()); lv_obj_set_style_text_font(s_ct_filter_btn_lbl, &g_font_12, LV_PART_MAIN); lv_obj_center(s_ct_filter_btn_lbl); } // ---- Sort button (opens the Sort sheet) ---- { lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, 42, kChipH); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, openContactsSortSheetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR("Sort")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } // ---- Trash button: jump straight into multi-select delete mode ---- { lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, 32, kChipH); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, ctSelectFromSheetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, LV_SYMBOL_TRASH); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_center(l); } // ---- "⋯" overflow: Search / Found / Add contact ---- { lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, 34, kChipH); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, openContactsOverflowSheetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, LV_SYMBOL_LIST); // "⋯"-style list/overflow glyph lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); lv_obj_center(l); // "search active" indicator — a small accent line under the overflow // button, since the search control now lives inside this menu. Stays // visible whenever a name filter is applied so the operator isn't // confused by a short list. g_lv.contacts_search_indicator = lv_obj_create(b); lv_obj_remove_style_all(g_lv.contacts_search_indicator); lv_obj_set_size(g_lv.contacts_search_indicator, 18, 3); lv_obj_align(g_lv.contacts_search_indicator, LV_ALIGN_BOTTOM_MID, 0, -2); lv_obj_set_style_bg_color(g_lv.contacts_search_indicator, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_opa(g_lv.contacts_search_indicator, LV_OPA_COVER, LV_PART_MAIN); lv_obj_add_flag(g_lv.contacts_search_indicator, LV_OBJ_FLAG_HIDDEN); } g_lv.contacts_list = lv_list_create(tab); // Row shrunk from 44 to 32 px; bump the list up 12 px to recover the // vertical real estate. (Also keeps the contacts list height in sync // with the Chats list, which sits below a 32-px header.) Sized from the // live content area so it fills the screen in either orientation. lv_obj_set_size(g_lv.contacts_list, tabContentW(), tabContentH() - 34); lv_obj_set_pos(g_lv.contacts_list, 0, 34); styleSurface(g_lv.contacts_list, COLOR_BG, 0); lv_obj_set_style_border_width(g_lv.contacts_list, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(g_lv.contacts_list, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(g_lv.contacts_list, 1, LV_PART_MAIN); lv_obj_add_event_cb(g_lv.contacts_list, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); // ---- Select-mode toolbar (hidden until "Select to delete"): Cancel / Select all / Delete (N) ---- s_ct_select_bar = lv_obj_create(tab); lv_obj_remove_style_all(s_ct_select_bar); lv_obj_set_size(s_ct_select_bar, tabContentW(), 32); lv_obj_set_pos(s_ct_select_bar, 0, 0); lv_obj_set_style_bg_color(s_ct_select_bar, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ct_select_bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_ct_select_bar, 4, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_ct_select_bar, 2, LV_PART_MAIN); lv_obj_set_flex_flow(s_ct_select_bar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(s_ct_select_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(s_ct_select_bar, 4, LV_PART_MAIN); lv_obj_clear_flag(s_ct_select_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_ct_select_bar, LV_OBJ_FLAG_HIDDEN); { lv_obj_t* b = lv_btn_create(s_ct_select_bar); lv_obj_set_size(b, 60, kChipH); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, ctCancelSelCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR("Cancel")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } { lv_obj_t* b = lv_btn_create(s_ct_select_bar); lv_obj_set_height(b, kChipH); lv_obj_set_flex_grow(b, 1); styleButton(b); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, ctSelectAllCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR("Select all")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } { s_ct_del_btn = lv_btn_create(s_ct_select_bar); lv_obj_set_size(s_ct_del_btn, 90, kChipH); lv_obj_set_style_radius(s_ct_del_btn, 6, LV_PART_MAIN); lv_obj_set_style_bg_color(s_ct_del_btn, lv_color_hex(0xC0392B), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_ct_del_btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(s_ct_del_btn, 0, LV_PART_MAIN); lv_obj_add_event_cb(s_ct_del_btn, ctDeleteSelCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(s_ct_del_btn); lv_label_set_text(l, TR("Delete")); lv_obj_set_style_text_color(l, lv_color_white(), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } } // ============================================================ // Map tab (Phase 4.1) // ============================================================ // MVP skeleton: shows the self GPS + contact-with-position count + a // placeholder where the tile grid will land. The tile renderer, pan/zoom // and marker layer follow in subsequent steps. // // Layout (240 wide × 260 tall above the 38-px tabbar minus 22-px status bar // = 240×260 usable): // - Map canvas: 240×226, anchored to (0,0) // - Bottom info strip: 240×34, anchored bottom — shows lat/lon, zoom, and // a "no tile pack" badge until /tiles/ exists on SPIFFS. static lv_obj_t* s_map_canvas = nullptr; // full-screen tile surface (behind the tabview) static lv_obj_t* s_map_page = nullptr; // the Map tab page (holds touch catcher + overlay UI) static lv_obj_t* s_map_touch = nullptr; // transparent full-page touch catcher for pan/drag static lv_obj_t* s_map_info_lbl = nullptr; // coords read-out (bottom-left corner) static lv_obj_t* s_map_count_lbl = nullptr; // marker / download count (bottom-right corner) static lv_obj_t* s_map_status_lbl = nullptr; static lv_obj_t* s_map_zoom_lbl = nullptr; // zoom + tile path at center (top-left, under © OSM) static lv_obj_t* s_map_zoom_slider = nullptr; // zoom slider overlay (toggled by the zoom button) static lv_obj_t* s_map_zoom_val = nullptr; // live "z<level>" readout centred above the slider // TEMP tile diagnostic: free KB in the tiles partition, refreshed ~5 s by // tilesFsLowSpace() and shown on the zoom label. 0xFFFF == not yet measured. static volatile uint16_t s_tiles_free_kb = 0xFFFF; #if defined(ESP32) // Dedicated LittleFS instance for the map tile pack — mounts the "tiles" // partition defined in variants/heltec_v4/partitions_tft_touch.csv. // SEPARATE from the global LittleFS / SPIFFS instances so refreshing the // tile pack via scripts/build/upload-tiles.py doesn't touch the SPIFFS // partition that holds /new_prefs (Profile + Radio + Auto-add settings), // /contacts3, /channels2, and the chat history blob. // // formatOnFail=true on first mount because the freshly-flashed partition // is filled with 0xFF and LittleFS would otherwise refuse to come up. // After format, subsequent boots find a valid (empty) FS and mount fast. static fs::LittleFSFS s_tiles_fs; static bool s_tiles_fs_ready = false; // Active tile-cache backend + path prefix. Normally the dedicated "tiles" // LittleFS partition above (prefix ""). When that partition is ABSENT — e.g. // running under bmorcelli's Launcher, whose partition table has no "tiles" // partition — we fall back to the SD card ROOT /tiles (alongside the offline map // library) so Wi-Fi tiles still cache + display (see the mount logic in begin()). // All cache access goes // through the tileCache* helpers, which apply s_tile_root and route to the // active fs. Concurrency is fine on either backend: esp_littlefs locks the // partition internally, and FATFS is built FF_FS_REENTRANT=1 (per-volume mutex), // so the core-0 fetch task and the core-1 render thread can't corrupt the SD. static fs::FS* s_tile_fs = nullptr; static char s_tile_root[16] = ""; // The cache backend for NON-microSD-tile mode (the LittleFS "tiles" partition, or // the SD /tiles fallback when that partition is absent) — captured at boot so // toggling microSD-tile mode off restores it. In microSD-tile mode the cache is // likewise re-pointed at the card ROOT // (/tiles/<z>/<x>/<y>.jpg, where loadTileJpeg's SD branch reads) so Wi-Fi-fetched // tiles MERGE with the user's SD library instead of the tiny internal partition (#20). static fs::FS* s_tile_fs_default = nullptr; static char s_tile_root_default[16] = ""; #if defined(MULTI_TRANSPORT_COMPANION) // The fetch queue may contain dozens of requests, but only the request currently // executing can dereference s_tile_fs or own a File. A backend swap requests a // boundary pause, waits only for that one request, then lets the queued backlog // resume against the new backend. The critical section closes both races: // worker-start vs swap-request, and worker-finish vs the next queued request. static portMUX_TYPE s_tile_backend_mux = portMUX_INITIALIZER_UNLOCKED; static volatile bool s_tile_worker_active = false; static volatile bool s_tile_backend_swap_requested = false; static bool tileBackendSwapTryBegin() { bool ready; portENTER_CRITICAL(&s_tile_backend_mux); s_tile_backend_swap_requested = true; ready = !s_tile_worker_active; portEXIT_CRITICAL(&s_tile_backend_mux); return ready; } static void tileBackendSwapFinish() { portENTER_CRITICAL(&s_tile_backend_mux); s_tile_backend_swap_requested = false; portEXIT_CRITICAL(&s_tile_backend_mux); } static bool tileBackendSwapRequested() { bool requested; portENTER_CRITICAL(&s_tile_backend_mux); requested = s_tile_backend_swap_requested; portEXIT_CRITICAL(&s_tile_backend_mux); return requested; } static bool tileFetchWorkerActive() { bool active; portENTER_CRITICAL(&s_tile_backend_mux); active = s_tile_worker_active; portEXIT_CRITICAL(&s_tile_backend_mux); return active; } class TileBackendWorkerLease { public: TileBackendWorkerLease() { for (;;) { portENTER_CRITICAL(&s_tile_backend_mux); if (!s_tile_backend_swap_requested) { s_tile_worker_active = true; _held = true; } portEXIT_CRITICAL(&s_tile_backend_mux); if (_held) break; vTaskDelay(1); } } ~TileBackendWorkerLease() { release(); } void release() { if (!_held) return; portENTER_CRITICAL(&s_tile_backend_mux); s_tile_worker_active = false; portEXIT_CRITICAL(&s_tile_backend_mux); _held = false; } TileBackendWorkerLease(const TileBackendWorkerLease&) = delete; TileBackendWorkerLease& operator=(const TileBackendWorkerLease&) = delete; private: bool _held = false; }; #endif // When the tile cache is on the SD fallback (s_tile_fs != the LittleFS partition), // every access is real microSD I/O -> light the activity LED. On the flash // partition this is a no-op (the LED tracks SD only). static inline void tileCacheMarkSdIo() { if (s_tile_fs && s_tile_fs != &s_tiles_fs) markSdIo(); } static inline bool tileCacheExists(const char* rel) { if (!s_tile_fs) return false; tileCacheMarkSdIo(); char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); return s_tile_fs->exists(p); } static inline File tileCacheOpen(const char* rel, const char* mode) { if (!s_tile_fs) return File(); tileCacheMarkSdIo(); char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); return s_tile_fs->open(p, mode); } static inline void tileCacheRemove(const char* rel) { if (!s_tile_fs) return; tileCacheMarkSdIo(); char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); s_tile_fs->remove(p); } static inline void tileCacheMkdir(const char* rel) { if (!s_tile_fs) return; tileCacheMarkSdIo(); char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); s_tile_fs->mkdir(p); } // True when the LittleFS tile cache is nearly full. A FULL/fragmented LittleFS // FAULTS inside lfs_alloc during mkdir (coredump 2026-06-14: reboot while a // route replay re-centred the map onto an un-cached area and the fetch task // wrote new z16 tiles) — it does not return a clean NOSPC. So we must STOP // writing tiles before the partition fills. usedBytes()/totalBytes() traverse // the whole FS, so the result is cached ~5 s; this is only ever called on the // core-0 fetch task, never the UI thread. Reads keep working, so already-cached // areas still display — only NEW tiles stop being cached. // #tiles: bytes of tiles written to FFat this session — the Tanmatsu FFat-fallback budget (the SD path // isn't space-constrained). Reset each boot; a genuinely-full FAT then just fails writes cleanly. static volatile uint32_t s_ffat_tile_bytes = 0; static bool tilesFsLowSpace() { if (!s_tiles_fs_ready || !s_tile_fs) return false; // no cache backend -> nothing to guard #if defined(HAS_TANMATSU) // Tanmatsu: the tile cache rides the SHARED FFat 'locfd' partition (~3.9 MB, also home to // DataStore / chat history). The SD + LittleFS guards below don't cover FFat, so cap it here: // stop caching tiles while < ~1.5 MB is free, leaving chat data plenty of room to grow. if (s_tile_fs == &FFat) { // Self-tracked byte budget — do NOT use FFat.freeBytes()/usedBytes() here: f_getfree misreports // 0 free clusters on the P4's locfd even when the partition is near-empty, which pinned this guard // permanently ON and blocked EVERY tile (ok 0, wr 'S', before any HTTP). Cap tiles at ~2 MB of the // 3.9 MB partition, leaving the rest for chat/DataStore. A genuinely-full FAT still fails the write // cleanly (open-fail), no crash — unlike LittleFS. Counter resets each boot; the on-disk cache // persists, so worst case across reboots the FAT fills and writes fail cleanly (TODO: persist/scan). const uint32_t budget = 2048u * 1024u; return s_ffat_tile_bytes >= budget; } #endif // This guard exists ONLY for the small (4.75 MB) LittleFS "tiles" partition, // where a full FS faults inside lfs_alloc during the dir mkdir. It must inspect // the ACTIVE backend, not s_tiles_fs unconditionally. On the SD fallback // (s_tile_fs == &SD, when the tiles partition didn't mount) the LittleFS object // is UNMOUNTED, so s_tiles_fs.totalBytes() reads 0 -> "0 free < 320 KB" -> the // gate wedged permanently ON and blocked EVERY tile download (the fetcher bailed // before the HTTP GET: ok 0, fail N, http 0, wr 'S'). The SD card has gigabytes // free, so it is never the constraint here — only guard the LittleFS partition. if (s_tile_fs != &s_tiles_fs) return false; // SD-backed cache: not space-constrained static uint32_t last_ms = 0; static bool low = false; const uint32_t now = millis(); if (last_ms == 0 || (uint32_t)(now - last_ms) >= 5000) { last_ms = now ? now : 1; const size_t tot = s_tiles_fs.totalBytes(); const size_t use = s_tiles_fs.usedBytes(); const size_t freeb = (tot > use) ? (tot - use) : 0; s_tiles_free_kb = (uint16_t)((freeb / 1024 > 0xFFFE) ? 0xFFFE : freeb / 1024); // for the on-screen readout // tot == 0 means the partition isn't actually mounted — don't read that as "full". low = (tot > 0) && (freeb < 320 * 1024); // keep >= 320 KB headroom (tile + dir blocks + GC) } return low; } // ----- Wi-Fi tile fetcher (Phase 4.1c) ----- // // When the user is on the Map tab AND Wi-Fi is up, any tile that fails // to load from the LittleFS cache is queued for download from OSM. A // FreeRTOS task pinned to core 0 (the Wi-Fi core) drains the queue, // HTTP-GETs each tile from https://tile.openstreetmap.org/<z>/<x>/<y>.png, // writes it to LittleFS, and sets s_tile_fetch_dirty. The LVGL refresh // loop notices the dirty flag and re-renders the map, so freshly- // downloaded tiles appear without the user having to interact. // // OSM tile-usage policy compliance: identifying User-Agent header, // ~2 req/sec rate cap, no bulk downloads. Each tile is ~10-30 KB; the // 4.75 MB partition holds a couple hundred. #if defined(MULTI_TRANSPORT_COMPANION) struct TileFetchReq { uint8_t z; int32_t x; int32_t y; }; static constexpr int k_tile_fetch_queue_size = 64; // holds a full zoomed-out prefetch pyramid + the visible grid in one pass static QueueHandle_t s_tile_fetch_queue = nullptr; static TaskHandle_t s_tile_fetch_task = nullptr; static volatile bool s_tile_fetch_dirty = false; #if defined(TLORA_PAGER) // Card-detect sets this before the VFS can be unmounted. It stops producers and // requests a worker pause at the next request boundary, so the current SD File // can close while the queued backlog survives the fallback/remount handoff. static volatile bool s_pager_sd_removal_pending = false; #endif static volatile uint16_t s_tile_fetch_ok = 0; static volatile uint16_t s_tile_fetch_failed = 0; // On-screen heartbeat: bumped each loop iteration of the fetch task so we // can tell whether it's alive at all (USB serial debug is unreadable on // this build because the companion binary protocol shares the UART). // `step` is a single-letter code for *what the task was last doing*: // '-' before entry, 'q' waiting for queue, 'g' before http.GET, // 'r' reading body, 'w' wrote file, 'x' task spawned but not yet looped, // 'd' DNS/connect fail seen, '!' WiFi went down mid-loop. static volatile uint16_t s_tile_fetch_iters = 0; static volatile char s_tile_fetch_step = '-'; static volatile bool s_tile_fetch_spawn_ok = false; // Last HTTP response code from http.GET(). Helps tell apart -1 (TCP/TLS // failure), 4xx (no such tile), 5xx (server), 200 (ok) when serial is // unreadable. Negative values come from HTTPClient itself. static volatile int16_t s_tile_fetch_last_code = 0; // Cache-write diagnostics. Serial is unreadable on this build, and a tile that // HTTP-200s but fails to land on disk (SD bus contention, full/write-protected // card, missing dir) is invisible otherwise — the map just sits on "Downloading…". // s_tile_fetch_last_wr is the last write outcome (shown on the map tab): // 'w' wrote ok 'O' open("w") failed (dir missing / SD write-protect / bus) // 'P' short/failed disk write (card full or SD error mid-write) // 'Z' bad/oversized content-length 'e' HTTP != 200 // 'S' cache low-space gate 'H' internal-heap gate 'W' Wi-Fi down static volatile char s_tile_fetch_last_wr = '-'; static volatile uint16_t s_tile_fetch_open_fail = 0; // tileCacheOpen("w") returned an invalid File static volatile uint16_t s_tile_fetch_short_wr = 0; // f.write() committed fewer bytes than received // Recently-queued dedup ring — prevents the same (z,x,y) being enqueued // dozens of times on rapid pan. Sized to cover one full pass: the 9 visible // tiles + the ~20-tile zoomed-out prefetch pyramid + a little history. static constexpr int k_tile_fetch_dedup_size = 48; // wide prefetch queues ~30 tiles/location; keep them all deduped static uint32_t s_tile_fetch_dedup[k_tile_fetch_dedup_size] = {0}; static int s_tile_fetch_dedup_head = 0; static inline uint32_t tileFetchDedupKey(uint8_t z, int32_t x, int32_t y) { // Hash, NOT a bit-pack: the old 14-bit x/y fields overflowed at z15+ (coords up // to 32767 masked to 14 bits → guaranteed collisions), which silently suppressed // re-fetches of corrupt z13-15 tiles (the reported "reload doesn't work" zooms). // A multiplicative hash makes collisions rare across the 48-slot ring. return ((uint32_t)z * 2654435761u) ^ ((uint32_t)x * 40503u) ^ (uint32_t)y; } static bool tileFetchSeen(uint8_t z, int32_t x, int32_t y) { const uint32_t k = tileFetchDedupKey(z, x, y); for (int i = 0; i < k_tile_fetch_dedup_size; ++i) if (s_tile_fetch_dedup[i] == k) return true; return false; } // Remember a tile as in-flight ONLY after it's actually been queued. Marking it // "seen" before the queue send (the old behaviour) meant a tile dropped by a // full queue — or one whose fetch later failed — was never retried, so the map // sat on "downloading" forever and re-opening did nothing. static void tileFetchMarkSeen(uint8_t z, int32_t x, int32_t y) { s_tile_fetch_dedup[s_tile_fetch_dedup_head] = tileFetchDedupKey(z, x, y); s_tile_fetch_dedup_head = (s_tile_fetch_dedup_head + 1) % k_tile_fetch_dedup_size; } // Drop a tile's "seen" mark so the next render miss re-queues it. The fetch task // marks a tile seen the instant it's queued; if the download then fails for a // TRANSIENT reason (heap gate while the render thread is decoding — the common // case mid-pan — or Wi-Fi blip / cache full / proxy 5xx / timeout), the stale // mark would otherwise pin the tile "downloading" forever and leave the visible // edges/corners blank until a manual "reload tiles". Forgetting it re-arms the // retry. (#20) static void tileFetchForget(uint8_t z, int32_t x, int32_t y) { const uint32_t k = tileFetchDedupKey(z, x, y); for (int i = 0; i < k_tile_fetch_dedup_size; ++i) if (s_tile_fetch_dedup[i] == k) s_tile_fetch_dedup[i] = 0; } #endif // MULTI_TRANSPORT_COMPANION #endif // ESP32 // ----- Mercator helpers + tile cache ----- // // Standard Web-Mercator slippy tiles. World is laid out as a square of // 2^zoom × 2^zoom tiles, each tile 256×256 px. A coordinate's "world pixel" // is its position in the full virtual canvas (e.g. at zoom 14 the world is // 4_194_304 px on a side). Tile (tx, ty) covers world px (tx*256, ty*256) // → (tx*256+255, ty*256+255). // // All transforms below use doubles since 1e-7 degrees of longitude already // exceeds float precision at high zoom. constexpr int k_map_tile_size = 256; // Map viewport size. Not constexpr: set to the real tab content area in // makeMapTab() so the map fills the screen in either orientation (240x226 // portrait / full-width landscape). The tile projection + marker math read // these, so they must reflect the actual canvas. The 3x3 tile grid below // covers 768 px and is generous enough for either viewport. static int k_map_canvas_w = 240; static int k_map_canvas_h = 226; // Manual zoom-button range. Wide on purpose: the zoom in/out buttons are gated by // mapZoomReachable() (a tile exists in the SD /maps/osm pack or LittleFS cache, OR // Wi-Fi can fetch it), so the buttons stop at the edge of the tiles you actually // have — these are just the hard floor/ceiling. Previously capped at 12..16, which // stranded anyone whose pack went wider. 19 is OSM's max zoom; 3 is continent-scale. constexpr uint8_t k_map_zoom_min = 3; constexpr uint8_t k_map_zoom_max = 19; constexpr uint8_t k_map_zoom_default = 14; // 3×3 grid covers a 768×768-px slab — ~130 px of pan buffer on each side // of the 240×226 viewport. We tried 5×5 (25 tiles, 3.2 MB PSRAM) for the // bigger buffer but it pushed PSRAM usage to the edge on the S3 boards — // some tile decodes silently failed alloc and stayed black. So the radius // is now sized PER AXIS from the actual canvas (mapComputeGridRadius): // small screens (V4/T-Deck 240/320) stay at radius 1 (3 tiles/axis = 9 // total, ~1.15 MB), while the wide Tanmatsu (800×480) needs a horizontal // radius of 2 so the 3-tile/768-px span doesn't leave an uncovered strip // at one edge of the 800-px width. The static slot array is sized to the // worst case (k_map_grid_radius_max); only tiles actually loaded allocate // their 128 KB PSRAM buffer, so the extra slots cost only pointer-sized // struct entries in .bss, not PSRAM, on the small boards. constexpr int k_map_grid_radius_max = 2; // max tiles/axis we ever request (Tanmatsu) constexpr int k_map_visible_tiles_max = (2 * k_map_grid_radius_max + 1) * (2 * k_map_grid_radius_max + 1); // Runtime per-axis radius (recomputed from k_map_canvas_w/h when the map // tab is built — see mapComputeGridRadius). Default to 1 for the common // small-screen case before the map tab exists. static int s_map_grid_rx = 1; // tiles each side, horizontal static int s_map_grid_ry = 1; // tiles each side, vertical // Per-tile: we hold the *decoded* RGB565 in PSRAM, not the JPEG. Decoding // happens ONCE at load time (in our control). The lv_img widget is then // CF_TRUE_COLOR, so every subsequent redraw is a pure blit — no decoder // call. Crucial for live-pan, where LVGL otherwise re-decodes all 9 JPEGs // on every PRESSING tick (~67 Hz). // // Memory cost: 256 × 256 × 2 B = 128 KB per tile × 9 visible = ~1.15 MB // from PSRAM (we have 8 MB, so this is fine). struct MapTile { uint8_t z; int32_t x, y; uint8_t* rgb565; // PSRAM, 256×256×2 B int w, h; lv_img_dsc_t dsc; lv_obj_t* img; bool in_use; // true = slot's (z,x,y) is valid; false = empty slot }; static MapTile* s_map_tiles = (MapTile*)psAlloc(sizeof(MapTile) * k_map_visible_tiles_max); // PSRAM (1.1 KB off internal .bss) // Recompute the per-axis tile radius from the current canvas size. Enough // tiles must straddle the center so the grid covers the FULL viewport even // when the center coordinate sits at the very edge of its center tile: // worst case the center is 256 px from one side of its tile, so each side // needs ceil((canvas/2) / 256) tiles → ceil(canvas / 512). Clamped to // [1, k_map_grid_radius_max]. Small screens (≤512 px) stay at 1; the // 800-px-wide Tanmatsu lands on rx = 2 (5 tiles = 1280 px ≥ 800). static void mapComputeGridRadius() { auto r = [](int dim) -> int { int n = (dim + 511) / 512; // ceil(dim / 512) if (n < 1) n = 1; if (n > k_map_grid_radius_max) n = k_map_grid_radius_max; return n; }; s_map_grid_rx = r(k_map_canvas_w); s_map_grid_ry = r(k_map_canvas_h); } // Pan layer: a single transparent lv_obj that holds tile widgets + marker // widgets. Live-pan just translates THIS — one set_pos = one invalidation, // regardless of how many children sit on top. static lv_obj_t* s_map_pan_layer = nullptr; // Last renderMapTiles() gap count (visible tiles that weren't on disk). // Drives the compact download/Wi-Fi hint in the bottom info bar. static int s_map_last_missing = 0; // --- Render-side tile diagnostics (serial is unreadable on the companion build). // The MTC fetch counters cover DOWNLOAD; these cover READ-BACK + DECODE + PLACE, // so the always-visible zoom label can pinpoint where the pipeline breaks: // fr = free KB in the tiles partition (0 == cache full, no room to write) // d = tiles that ended up placed (kept or freshly decoded) / tiles wanted // Declared unconditionally so renderMapTiles/refreshMapInfoLabel reference them // on every board; only the DOWNLOAD counters are MTC-gated. (s_tiles_free_kb is // declared earlier, before tilesFsLowSpace which writes it.) static int s_tile_dec_ok = 0; // tiles placed in the last render pass static int s_tile_dec_want = 0; // tiles wanted (grid size) in the last pass // Release a slot's WIDGET but KEEP its 128 KB PSRAM buffer for the next tile that // lands here. Per-render tile churn used to malloc/free 128 KB up to nine times a // frame, which fragmented the 2 MB-PSRAM V4 down to <128 KB blocks so fresh tiles // couldn't allocate (only the top rows rendered). Reusing the buffer in place = zero // churn = no fragmentation. Full teardown (freeMapTileSlot) still frees the buffer. static void releaseMapTileSlot(MapTile& t) { if (t.img) { lv_obj_del(t.img); t.img = nullptr; } lv_img_cache_invalidate_src(&t.dsc); // this slot's dsc content is now stale t.in_use = false; // t.rgb565 intentionally kept for reuse } static void freeMapTileSlot(MapTile& t) { if (t.img) { lv_obj_del(t.img); t.img = nullptr; } // CRITICAL: invalidate LVGL's image cache entry for this dsc before // freeing the underlying buffer. The cache is keyed by lv_img_dsc_t* and // each tile slot's dsc lives at a STABLE address in s_map_tiles[]. When // we reuse the slot for a different (z, x, y), the dsc address is the // same so LVGL's cache hits and returns the old decoder result whose // img_data still points to the buffer we're about to free below — a // classic use-after-free that surfaced as RGB565 noise in tile bottoms // and "tiles in the wrong place" (new widget rendering old content). lv_img_cache_invalidate_src(&t.dsc); if (t.rgb565) { lvglPsramFree(t.rgb565); t.rgb565 = nullptr; } t.w = t.h = 0; t.in_use = false; } // Decode a JPEG (in-memory) into a freshly-PSRAM-alloced RGB565 buffer. // Returns NULL on failure. Caller owns the buffer; lvglPsramFree() it. // The decode is one-shot via LVGL's image decoder API (which routes to // SJPG's tinyjpeg backend since data starts with 0xFFD8). static uint8_t* decodeJpegToRgb565(const uint8_t* jpeg, size_t jpeg_len, int* out_w, int* out_h) { lv_img_dsc_t src; memset(&src, 0, sizeof(src)); src.header.cf = LV_IMG_CF_RAW; src.data = jpeg; src.data_size = jpeg_len; lv_img_decoder_dsc_t dec; memset(&dec, 0, sizeof(dec)); // Color arg is only used by the alpha-only-image decoders to colorize // the alpha channel. We're decoding RGB JPEGs; it's ignored. if (lv_img_decoder_open(&dec, &src, lv_color_make(0, 0, 0), 0) != LV_RES_OK) { return nullptr; } const int w = dec.header.w; const int h = dec.header.h; if (w <= 0 || h <= 0 || w > 1024 || h > 1024) { lv_img_decoder_close(&dec); return nullptr; } const size_t buf_size = (size_t)w * h * sizeof(lv_color_t); // RGB565 out uint8_t* buf = (uint8_t*)lvglPsramAlloc(buf_size); if (!buf) { lv_img_decoder_close(&dec); return nullptr; } // The decoder output format depends on the codec: // • SJPG (JPEG) → LV_IMG_CF_TRUE_COLOR = 2 B/px (pure RGB565) // • lv_png (PNG) → LV_IMG_CF_TRUE_COLOR_ALPHA = 3 B/px at depth 16 // (RGB565 low, RGB565 high, alpha) // Our output buffer is pure RGB565 (2 B/px) and the lv_img widget is // tagged LV_IMG_CF_TRUE_COLOR, so for PNG we must drop the per-pixel // alpha byte. Copying the raw 3 B/px stream as if it were 2 B/px is // exactly what produced the RGB565 "static / noise" tiles. if (dec.img_data) { if (dec.header.cf == LV_IMG_CF_TRUE_COLOR_ALPHA) { const uint8_t* s = (const uint8_t*)dec.img_data; const size_t px = (size_t)w * h; for (size_t i = 0; i < px; ++i) { buf[i * 2 + 0] = s[i * 3 + 0]; // RGB565 low buf[i * 2 + 1] = s[i * 3 + 1]; // RGB565 high (alpha s[i*3+2] dropped) } } else { memcpy(buf, dec.img_data, buf_size); // already RGB565 } } else { // Streaming fallback (unused by SJPG for standalone JPEGs, but // harmless to keep). for (int y = 0; y < h; ++y) { if (lv_img_decoder_read_line(&dec, 0, y, w, buf + (size_t)y * w * sizeof(lv_color_t)) != LV_RES_OK) { lvglPsramFree(buf); lv_img_decoder_close(&dec); return nullptr; } } } lv_img_decoder_close(&dec); *out_w = w; *out_h = h; return buf; } // Downscaling JPEG decode straight through tjpgd (LVGL's SJPG wrapper decodes at // full resolution only). Picks a 1/1..1/8 scale so the output fits within max_dim, // then converts tjpgd's RGB888 blocks to LVGL RGB565. Lets the file-manager viewer // and the lock wallpaper accept images bigger than 1024 px without a multi-MB // full-res buffer. #include "src/extra/libs/sjpg/tjpgd.h" struct TjpgIo { const uint8_t* data; size_t len; size_t pos; uint16_t* out; int ow; int oh; }; extern "C" { static size_t tjpgInCb(JDEC* jd, uint8_t* buf, size_t n) { TjpgIo* io = (TjpgIo*)jd->device; size_t avail = io->len - io->pos; if (n > avail) n = avail; if (buf) memcpy(buf, io->data + io->pos, n); // buf==NULL: skip n bytes io->pos += n; return n; } static int tjpgOutCb(JDEC* jd, void* bitmap, JRECT* r) { TjpgIo* io = (TjpgIo*)jd->device; const uint8_t* s = (const uint8_t*)bitmap; // RGB888, row-major within the rect for (int y = (int)r->top; y <= (int)r->bottom; ++y) { uint16_t* d = (y < io->oh) ? (io->out + (size_t)y * io->ow + (int)r->left) : nullptr; for (int x = (int)r->left; x <= (int)r->right; ++x) { if (d && x < io->ow) { lv_color_t c = lv_color_make(s[0], s[1], s[2]); *d++ = c.full; } s += 3; } } return 1; // continue } } // extern "C" static uint8_t* decodeJpegScaledToRgb565(const uint8_t* jpeg, size_t jpeg_len, int* out_w, int* out_h, int max_dim, uint8_t* dst_buf, size_t dst_cap) { s_jpgScaleErr[0] = '\0'; // tjpgd work area (~3.1 KB used). Lazy PSRAM (internal fallback), cached — keeps // this ~4 KB out of scarce internal DRAM. JD_FASTDECODE=0; matches LVGL's TJPGD_WORKBUFF_SIZE. static const size_t kJpgWork = 4096; static uint8_t* pool = nullptr; if (!pool) { pool = (uint8_t*)heap_caps_malloc(kJpgWork, MALLOC_CAP_SPIRAM); if (!pool) pool = (uint8_t*)heap_caps_malloc(kJpgWork, MALLOC_CAP_8BIT); } if (!pool) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "Out of memory."); return nullptr; } TjpgIo io; io.data = jpeg; io.len = jpeg_len; io.pos = 0; io.out = nullptr; io.ow = 0; io.oh = 0; JDEC jd; JRESULT rp = jd_prepare(&jd, tjpgInCb, pool, kJpgWork, &io); if (rp != JDR_OK) { if (rp == JDR_FMT3 || rp == JDR_FMT2) // tjpgd can't decode progressive / unusual-sampling JPEGs snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "Progressive JPEG.\nRe-save as a standard JPEG."); else snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "Decode error %d.", (int)rp); WIRE_DBG("[JPG] jd_prepare rc=%d\n", (int)rp); return nullptr; } int scale = 0; while (scale < 3 && (((int)jd.width >> scale) > max_dim || ((int)jd.height >> scale) > max_dim)) scale++; const int ow = (int)jd.width >> scale, oh = (int)jd.height >> scale; WIRE_DBG("[JPG] %ux%u -> 1/%d -> %dx%d (cap %d)\n", jd.width, jd.height, 1 << scale, ow, oh, max_dim); if (ow <= 0 || oh <= 0 || ow > max_dim || oh > max_dim) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "%ux%u too big", jd.width, jd.height); return nullptr; } const size_t need = (size_t)ow * oh * sizeof(uint16_t); uint16_t* buf; bool owns; if (dst_buf) { // caller-provided output (map tile pool) if (need > dst_cap) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "%dx%d > buf", ow, oh); return nullptr; } buf = (uint16_t*)dst_buf; owns = false; } else { buf = (uint16_t*)lvglPsramAlloc(need); owns = true; if (!buf) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "no memory (%dx%d)", ow, oh); return nullptr; } } io.out = buf; io.ow = ow; io.oh = oh; JRESULT rd = jd_decomp(&jd, tjpgOutCb, (uint8_t)scale); if (rd != JDR_OK) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "decomp err %d", (int)rd); WIRE_DBG("[JPG] jd_decomp rc=%d\n", (int)rd); if (owns) lvglPsramFree(buf); return nullptr; } *out_w = ow; *out_h = oh; return (uint8_t*)buf; } static double s_map_center_lat = 0.0; static double s_map_center_lon = 0.0; static uint8_t s_map_zoom = k_map_zoom_default; static bool s_map_view_inited = false; // first map open did the recenter+zoom-snap; after that, remember the user's view (issue #5) static bool s_map_follow = false; // auto-follow: recenter on self whenever the GPS coords change static lv_obj_t* s_map_follow_btn = nullptr; #if defined(HAS_M9_KEYBOARD) // Map pan mode (M9): the Map key on the Map tab toggles it — arrows then pan // via mapNudge, Map/Back exits. Lives here with the map state because // mapAutoFollowTick must pause while it's active: auto-follow compares the GPS // fix against the MAP CENTER, so the pan itself creates the delta and follow // snapped the view back within one 250 ms tick of every nudge — no GPS // movement needed. Follow (if on) resumes, by design, the moment pan exits. static bool s_m9_map_pan = false; static bool s_m9_pan_gap_hinted = false; // one "Wi-Fi off" hint per pan session #endif // Map zoom control style: false = slider (default, toggled by the on-map button), // true = a +/- button pair (issue #26). Loaded from prefs at boot; toggled in the // Map options popup. mapZoomControlsApply() positions/shows the right controls. static bool s_map_zoom_buttons = false; static lv_obj_t* s_map_btn_zoomtoggle = nullptr; // "+/-" slider-toggle (slider mode) static lv_obj_t* s_map_btn_zoomin = nullptr; // "+" (buttons mode) static lv_obj_t* s_map_btn_zoomout = nullptr; // "-" (buttons mode) static lv_obj_t* s_map_btn_recenter = nullptr; // GPS recenter (shifts down in buttons mode) static lv_obj_t* s_map_btn_contacts = nullptr; // contacts list (shifts down in buttons mode) static bool s_map_has_pack = false; // toggles placeholder visibility // lat/lon → world pixel at given zoom (Web Mercator). static void latLonToWorldPx(double lat, double lon, uint8_t zoom, double* world_x, double* world_y) { const double n = (double)(1u << zoom); *world_x = (lon + 180.0) / 360.0 * 256.0 * n; const double lat_rad = lat * M_PI / 180.0; const double sinlat = sin(lat_rad); // Clamp sinlat to avoid log(0) at the poles. const double s = sinlat > 0.9999 ? 0.9999 : (sinlat < -0.9999 ? -0.9999 : sinlat); *world_y = (0.5 - log((1 + s) / (1 - s)) / (4 * M_PI)) * 256.0 * n; } // (kept for the pan/zoom step — converts a touch position back to lat/lon.) static void __attribute__((unused)) worldPxToLatLon(double world_x, double world_y, uint8_t zoom, double* lat, double* lon) { const double n = (double)(1u << zoom); *lon = world_x / (256.0 * n) * 360.0 - 180.0; const double y_norm = world_y / (256.0 * n); const double lat_rad = atan(sinh(M_PI * (1 - 2 * y_norm))); *lat = lat_rad * 180.0 / M_PI; } // Background fetch task — drains s_tile_fetch_queue, downloads each // tile from OSM, writes it to LittleFS. Pinned to core 0 (Wi-Fi core) // so the LVGL thread on core 1 isn't blocked by network I/O. Self- // terminates after a 5 s idle period to release the stack. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static void ensureTilesDirPath(uint8_t z, int32_t x, const char* root) { // LittleFS requires explicit mkdir; opening a deep path won't auto-create the // parents. `root` is /tiles (OSM) or /tiles/topo (OpenTopoMap) — for topo the // /tiles parent must exist before /tiles/topo can be made. char p[48]; snprintf(p, sizeof(p), "/tiles"); tileCacheMkdir(p); snprintf(p, sizeof(p), "%s", root); tileCacheMkdir(p); snprintf(p, sizeof(p), "%s/%u", root, (unsigned)z); tileCacheMkdir(p); snprintf(p, sizeof(p), "%s/%u/%ld", root, (unsigned)z, (long)x); tileCacheMkdir(p); } // ----- Firmware version check (reuses the core-0 fetch worker) ----- // The web flasher lists releases under prebuilt/releases/TOUCH/ via the GitHub // API. The device can't do HTTPS, but the meshcomod proxy exposes that same API // over plain HTTP at firmware.wadamesh.com/releases/. We GET the TOUCH listing, // scan for the highest pre-alpha_<N>, and compare to our embedded release tag. // (s_verchk_request / s_verchk_done / s_verchk_latest_n are declared globally // near the version-check UI helpers above.) // Update channel selector: stable firmware lives under releases/TOUCH, the opt-in // test/beta channel under releases/BETA. The "Get test builds (beta)" toggle on the // About page flips this for BOTH the version check and the Install-update download, // so an opted-in device sees beta "update available" and installs beta bins. Both // archives use the same beta_<N> tag-dir naming, so only the archive root changes. static inline const char* updChannelArchive() { return touchPrefsGetBetaUpdates() ? "BETA" : "TOUCH"; } // Stream the releases listing for the active channel and return the highest // beta_<N> dir number, or -1 on failure. Char-by-char scan (the body is a few KB) // so we never buffer the whole JSON; entries are alphabetical so the max isn't last. // Reuses the worker's own WiFiClient/HTTPClient (passed in) rather than putting // a second pair on the ~8 KB worker stack — nesting them overflowed it. static int verchkFetchLatest(WiFiClient& client, HTTPClient& http) { http.setReuse(false); http.setConnectTimeout(8000); http.setTimeout(12000); http.setUserAgent("wadamesh-touch"); char listurl[72]; snprintf(listurl, sizeof listurl, "http://firmware.wadamesh.com/releases/%s", updChannelArchive()); if (!http.begin(client, listurl)) { return -1; } const int code = http.GET(); if (code != 200) { http.end(); return -1; } auto* st = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) static const char PAT[] = "beta_"; const int patlen = (int)sizeof(PAT) - 1; int mp = 0, best = -1, curnum = -1; bool innum = false; uint32_t total = 0; const uint32_t LIMIT = 32768; const unsigned long t0 = millis(); while (http.connected() && total < LIMIT && (millis() - t0) < 12000) { int avail = st ? st->available() : 0; if (avail <= 0) { if (!st || !st->connected()) break; vTaskDelay(pdMS_TO_TICKS(5)); continue; } while (avail-- > 0 && total < LIMIT) { const int c = st->read(); if (c < 0) break; ++total; if (innum) { if (c >= '0' && c <= '9') { curnum = (curnum < 0 ? 0 : curnum) * 10 + (c - '0'); continue; } if (curnum > best) best = curnum; innum = false; curnum = -1; mp = 0; } if (c == PAT[mp]) { if (++mp == patlen) { mp = 0; innum = true; curnum = -1; } } else { mp = (c == (int)PAT[0]) ? 1 : 0; } } } if (innum && curnum > best) best = curnum; http.end(); return best; } #if CAP_LUA_APPS // ===== Lua app store: state + network side (LUA_APPS.md Phase 2) ============ // Catalog + installs ride the SAME net worker as the version check (flag in, // worker services with its shared client/http, done-flag out, UI polls). fs::FS* luaHostAppFs(); // bridges (defined later in this TU) void luaHostAppPath(char* out, size_t cap, const char* rel); struct LuaCatApp { char id[20]; char name[28]; char ver[12]; char desc[72]; }; // `icon` is the manifest's optional icon NAME (not a glyph): the device's JSON // scanner only reads quoted strings, and a name is reviewable in a store // submission where a raw private-use codepoint would not be. Mapped to a glyph // by luaAppIconGlyph(); empty = the generic app symbol. struct LuaInstApp { char id[20]; char name[28]; char ver[12]; char icon[12]; }; static const int kLuaCatMax = 16, kLuaInstMax = 16, kLangCatMax = LANG_COUNT + 2, kLangInstMax = 12; static LuaCatApp* s_lua_cat = nullptr; // PSRAM, allocated on first store use static int s_lua_cat_n = 0; // -1 = last fetch failed static LuaInstApp* s_lua_inst = nullptr; // PSRAM (see luaStoreTablesReady) static int s_lua_inst_n = 0; static char* s_luacat_buf = nullptr; // catalog JSON (PSRAM, worker-filled) static const size_t kLuaCatBufMax = 8192; static volatile bool s_luacat_request = false, s_luacat_done = false; static volatile bool s_luainst_request = false, s_luainst_done = false; static bool s_luainst_ok = false; static char s_luainst_id[20], s_luainst_ver[12]; // minimal "key":"value" extractor for our own flat-JSON manifests + catalog static bool luaJsonField(const char* buf, const char* key, char* out, size_t cap) { char pat[28]; snprintf(pat, sizeof pat, "\"%s\"", key); const char* q = strstr(buf, pat); if (!q) return false; q = strchr(q + strlen(pat), ':'); if (!q) return false; q++; while (*q == ' ' || *q == '\t') q++; if (*q != '"') return false; q++; size_t o = 0; while (*q && *q != '"' && o + 1 < cap) out[o++] = *q++; out[o] = 0; return o > 0; } // Fetch url into a PSRAM buffer (cap'd). Returns bytes of response read, -1 on // failure. `body` non-null makes it a POST with that payload; null is a GET. int luaStoreHttpReq(WiFiClient& client, HTTPClient& http, const char* url, char* buf, size_t cap, const uint8_t* body, size_t body_len, const char* ctype) { http.setReuse(false); http.setConnectTimeout(8000); http.setTimeout(12000); http.setUserAgent("wadamesh-touch"); if (!http.begin(client, url)) return -1; int code; if (body) { http.addHeader("Content-Type", (ctype && *ctype) ? ctype : "application/octet-stream"); code = http.POST((uint8_t*)body, body_len); } else { code = http.GET(); } // Any 2xx counts: a POST that a server answers 201 or 204 succeeded, and // demanding exactly 200 would fail every correctly-built upload endpoint. if (code < 200 || code > 299) { http.end(); return -1; } // Read by Content-Length. The old loop trusted connected() to mean "stream // done" and could quit after the first TCP window (~16 KB) with the rest // still in flight — a 49 KB language file came back one-third read. Now the // only clean exit for a known length is receiving ALL of it; stalls get a // progress-based 4 s grace (30 s absolute cap), and a short read FAILS // instead of handing back a truncated buffer. const int want = http.getSize(); // -1 when the server sent no length auto* st = http.getStreamPtr(); size_t total = 0; const unsigned long t0 = millis(); unsigned long last_progress = millis(); while (total < cap - 1 && (millis() - t0) < 30000) { if (want > 0 && total >= (size_t)want) break; int avail = st ? st->available() : 0; if (avail <= 0) { if (want <= 0 && (!st || (!st->connected() && !st->available()))) break; if (millis() - last_progress > 4000) break; vTaskDelay(pdMS_TO_TICKS(5)); continue; } int rd = st->read((uint8_t*)buf + total, min((size_t)avail, cap - 1 - total)); if (rd > 0) { total += rd; last_progress = millis(); } else vTaskDelay(pdMS_TO_TICKS(5)); } http.end(); buf[total] = 0; if (want > 0 && total < (size_t)want) return -1; // truncated = failure, not data return (int)total; } int luaStoreHttpGet(WiFiClient& client, HTTPClient& http, const char* url, char* buf, size_t cap) { return luaStoreHttpReq(client, http, url, buf, cap, nullptr, 0, nullptr); } // Opaque bridge for LuaAppHost.cpp — see the long note at the top of that file. It // must not name the client type (WiFiClient is a real class here, an alias of // NetworkClient on the Tanmatsu, and a #define to C6Client on the T-Display P4), so // it hands us void* and this side, which owns the networking, casts them back. int luaStoreHttpGetOpaque(void* client, void* http, const char* url, char* buf, size_t cap) { return luaStoreHttpGet(*static_cast<WiFiClient*>(client), *static_cast<HTTPClient*>(http), url, buf, cap); } int luaStoreHttpPostOpaque(void* client, void* http, const char* url, char* buf, size_t cap, const void* body, size_t body_len, const char* ctype) { return luaStoreHttpReq(*static_cast<WiFiClient*>(client), *static_cast<HTTPClient*>(http), url, buf, cap, (const uint8_t*)body, body_len, ctype); } static void luaStoreFetchCatalogWorker(WiFiClient& client, HTTPClient& http) { if (!s_luacat_buf) s_luacat_buf = (char*)heap_caps_malloc(kLuaCatBufMax, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_luacat_buf) { return; } s_luacat_buf[0] = 0; luaStoreHttpGet(client, http, "http://firmware.wadamesh.com/apps/apps.json", s_luacat_buf, kLuaCatBufMax); } // Download <id>.lua + <id>.json (immutable per-version path) into /apps/. static bool luaStoreDownloadWorker(WiFiClient& client, HTTPClient& http, const char* id, const char* ver) { fs::FS* fs = luaHostAppFs(); if (!fs) return false; char appsdir[48]; luaHostAppPath(appsdir, sizeof appsdir, "/apps"); fs->mkdir(appsdir); static const char* const kExt[2] = { "lua", "json" }; const size_t kCap[2] = { 64 * 1024, 1024 }; for (int e = 0; e < 2; e++) { char* buf = (char*)heap_caps_malloc(kCap[e], MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) return false; char url[128]; snprintf(url, sizeof url, "http://firmware.wadamesh.com/apps/%s/%s/%s.%s", id, ver, id, kExt[e]); int n = luaStoreHttpGet(client, http, url, buf, kCap[e]); if (n <= 0) { heap_caps_free(buf); return false; } char rel[40], path[64]; snprintf(rel, sizeof rel, "/apps/%s.%s", id, kExt[e]); luaHostAppPath(path, sizeof path, rel); File f = fs->open(path, "w"); if (!f) { heap_caps_free(buf); return false; } size_t wr = f.write((const uint8_t*)buf, n); f.close(); heap_caps_free(buf); if (wr != (size_t)n) return false; } return true; } // ---- Language files (Store -> Languages tab): catalog + download ---------- // Same worker/flag/poll pattern as the app catalog. A .lang file is the export // of one i18n table column (scripts/build/gen-lang-files.py) — or anything a // user writes themselves — living at <data>/lang/<code>.lang. struct LangCatEntry { char code[12]; char name[28]; char ver[8]; }; static LangCatEntry* s_langcat = nullptr; // PSRAM (see luaStoreTablesReady) static int s_langcat_n = 0; // 0 = not fetched, -1 = fetch failed static char* s_langcat_buf = nullptr; // langs.json (PSRAM, worker-filled) static volatile bool s_langcat_request = false, s_langcat_done = false; static volatile bool s_langdl_request = false, s_langdl_done = false; static volatile bool s_langdl_ok = false; // volatile: written on the worker, read at poll // Why the last language download failed, for the toast. "Download failed" on its own // produced a bug report with nothing to go on (#274): every failure path here is silent, // so the reporter could only say it never works. Written on the worker, read at poll. static const char* volatile s_langdl_err = nullptr; static char s_langdl_code[12]; static char s_langdl_ver[8]; // catalog ver -> immutable /lang/<ver>/ URL ("" = resolve on the worker) static bool s_langdl_reboot = false; // an explicit Update of the ACTIVE language reboots on success static bool s_langdl_silent = false; // boot self-heal: no user-facing toast on its result // Installed-apps rescan, moved off the UI thread (SD listing caused open lag). static volatile bool s_luascan_request = false, s_luascan_done = false; static void luaStoreScanInstalled(bool force = false); // defined with the Store page static void luaStoreParseLangCatalog(); // defined with the Store page static bool luaStoreTablesReady(); // PSRAM tables (defined with the Store page) static void luaStoreFetchLangCatalogWorker(WiFiClient& client, HTTPClient& http) { const size_t kCap = 2048; if (!s_langcat_buf) s_langcat_buf = (char*)heap_caps_malloc(kCap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_langcat_buf) return; s_langcat_buf[0] = 0; luaStoreHttpGet(client, http, "http://firmware.wadamesh.com/apps/langs.json", s_langcat_buf, kCap); } // Chunked writer through an internal bounce buffer, tolerant of transient // short/zero returns (retries with backoff; four dead attempts = give up). static size_t langWriteAll(File& f, const uint8_t* src, size_t n) { uint8_t* bounce = (uint8_t*)heap_caps_malloc(4096, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (!bounce) return 0; size_t wr = 0; int stalls = 0; while (wr < n) { size_t step = n - wr; if (step > 4096) step = 4096; memcpy(bounce, src + wr, step); size_t w = f.write(bounce, step); if (!w) { if (++stalls > 3) break; vTaskDelay(pdMS_TO_TICKS(50)); continue; } stalls = 0; wr += w; } heap_caps_free(bounce); return wr; } // Verify the temp file's on-card size, then atomically replace the target. static bool langVerifyInstall(fs::FS* fs, const char* tmp, const char* path, size_t n) { File chk = fs->open(tmp, "r"); const size_t on_card = chk ? (size_t)chk.size() : 0; if (chk) chk.close(); if (on_card != n) return false; fs->remove(path); return fs->rename(tmp, path); } // Download a language file into <data>/lang/<code>.lang. With a known catalog // ver the URL is the immutable /apps/lang/<ver>/<code>.lang copy — the flat path // sits behind a day-long edge cache, which would happily serve a stale file // right after a translation update (the exact trap the flasher once hit). static bool luaStoreLangDownloadWorker(WiFiClient& client, HTTPClient& http, const char* code, const char* ver) { fs::FS* fs = luaHostAppFs(); if (!fs) return false; char dir[48]; luaHostAppPath(dir, sizeof dir, "/lang"); fs->mkdir(dir); // A full 700+ row language is ~60 KB, so 128 KB was the comfortable size. But it is a // CONTIGUOUS PSRAM request made while the Store page is up, and app downloads ask for // only 64 KB — which is exactly why a device can install apps all day and never once // get a language (#274, reported on a pager: /apps healthy and populated, /lang empty // forever). Step down rather than give up, and fall back to internal heap, the same // ladder the reader uses for its own big buffer. size_t kCap = 128 * 1024; char* buf = (char*)heap_caps_malloc(kCap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) { kCap = 96 * 1024; buf = (char*)heap_caps_malloc(kCap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } if (!buf) { kCap = 72 * 1024; buf = (char*)heap_caps_malloc(kCap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } if (!buf) { kCap = 72 * 1024; buf = (char*)heap_caps_malloc(kCap, MALLOC_CAP_8BIT); } if (!buf) { s_langdl_err = "Not enough memory"; return false; } char url[112]; if (ver && ver[0]) snprintf(url, sizeof url, "http://firmware.wadamesh.com/apps/lang/%s/%s.lang", ver, code); else snprintf(url, sizeof url, "http://firmware.wadamesh.com/apps/lang/%s.lang", code); int n = luaStoreHttpGet(client, http, url, buf, kCap); bool ok = false; bool opened = false; // -1 is "no 200, or the body stopped short of Content-Length". A language file is ~60 KB // where an app is a few KB, so a link that drops mid-transfer fails here while app // installs keep succeeding — indistinguishable from the memory case without saying so. if (n <= 16) s_langdl_err = (n < 0) ? "Server or connection failed" : "Empty reply"; if (n > 16) { // Write to a TEMP file, verify every byte landed, then rename over the real // one. A truncated direct write used to leave a file whose '# ver:' header // (at the TOP) already claimed the new version while the appended rows (at // the END) were gone — "v4 installed", no Update offered, translations // missing. Now a failed write changes nothing and Update stays available. char rel[32], path[64], tmp[64]; snprintf(rel, sizeof rel, "/lang/%s.tmp", code); luaHostAppPath(tmp, sizeof tmp, rel); snprintf(rel, sizeof rel, "/lang/%s.lang", code); luaHostAppPath(path, sizeof path, rel); File f = fs->open(tmp, "w"); opened = (bool)f; if (f) { langWriteAll(f, (const uint8_t*)buf, (size_t)n); f.close(); ok = langVerifyInstall(fs, tmp, path, (size_t)n); } if (!ok && opened) { fs->remove(tmp); // The write dies at the first cluster boundary while app installs to // /apps succeed — the signature of a FAT chain corrupted under today's // mid-write hard resets, localized to /lang. Prove it with an identical // control write in /apps, then rebuild /lang from scratch and retry. char ctl[64]; luaHostAppPath(ctl, sizeof ctl, "/apps/langtest.tmp"); size_t wr2 = 0; File cf = fs->open(ctl, "w"); if (cf) { wr2 = langWriteAll(cf, (const uint8_t*)buf, (size_t)n); cf.close(); } fs->remove(ctl); if (wr2 == (size_t)n) { // Healthy elsewhere -> nuke and recreate the /lang directory. char ldir[48]; luaHostAppPath(ldir, sizeof ldir, "/lang"); File d = fs->open(ldir); if (d && d.isDirectory()) { File e; while ((e = d.openNextFile())) { char victim[80]; const char* nm = e.name(); e.close(); if (nm[0] == '/') snprintf(victim, sizeof victim, "%s", nm); else snprintf(victim, sizeof victim, "%s/%s", ldir, nm); fs->remove(victim); } } if (d) d.close(); fs->rmdir(ldir); fs->mkdir(ldir); File rf = fs->open(tmp, "w"); if (rf) { langWriteAll(rf, (const uint8_t*)buf, (size_t)n); rf.close(); } ok = langVerifyInstall(fs, tmp, path, (size_t)n); if (!ok) fs->remove(tmp); } } } heap_caps_free(buf); return ok; } // ---- Boot loader for the active .lang file -------------------------------- // Reads the file into PSRAM once, parses `key<TAB>translation` lines in place // (\n, \t, \\ escapes; # lines are headers), sorts, and hands the table to // i18n. Allocations live for the session — switching languages reboots. static bool s_langfile_tried = false; static void uiLangFileBootLoad() { if (s_langfile_tried) return; char code[12]; touchPrefsGetLangFile(code, sizeof code); if (!code[0]) { // Migration: a built-in language picked before translations became files // claims its file code now; the download self-heals below once Wi-Fi is up. const uint8_t l = touchPrefsGetUiLang(); if (l > 0 && l < LANG_COUNT) { touchPrefsSetLangFile(kUiLangCodes[l]); touchPrefsGetLangFile(code, sizeof code); } } if (!code[0]) { s_langfile_tried = true; return; } fs::FS* fs = luaHostAppFs(); if (!fs) return; // storage not up yet — a later call retries s_langfile_tried = true; char rel[28], path[64]; snprintf(rel, sizeof rel, "/lang/%s.lang", code); luaHostAppPath(path, sizeof path, rel); File f = fs->open(path, "r"); if (!f) { // Language chosen but its file is missing (fresh migration, or the store // never finished the download): fetch it in the background. This boot runs // English; the file applies from the next boot. Retries every boot. snprintf(s_langdl_code, sizeof s_langdl_code, "%s", code); s_langdl_ver[0] = 0; // no catalog yet — the worker resolves the ver s_langdl_reboot = false; // background heal: never reboot under the user s_langdl_silent = true; // and never toast — it just retries next boot s_langdl_done = false; s_langdl_request = true; ensureTileFetchTaskRunning(); return; } const size_t sz = f.size(); if (sz < 8 || sz > 256 * 1024) { f.close(); return; } char* buf = (char*)heap_caps_malloc(sz + 1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) { f.close(); return; } size_t rd = f.read((uint8_t*)buf, sz); f.close(); buf[rd] = 0; int lines = 1; for (size_t i = 0; i < rd; i++) if (buf[i] == '\n') lines++; I18nPair* pairs = (I18nPair*)heap_caps_malloc(sizeof(I18nPair) * lines, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!pairs) { heap_caps_free(buf); return; } int n = 0; char* p = buf; while (p && *p) { char* nl = strchr(p, '\n'); if (nl) { if (nl > p && nl[-1] == '\r') nl[-1] = 0; *nl = 0; } { // A line is a header/comment only when it has NO tab. Testing for a // leading '#' instead silently dropped every key that starts with LVGL // recolor markup ("#7A7F87 Wardrive: …#") — those could never translate. char* tab = strchr(p, '\t'); if (tab) { *tab = 0; auto unesc = [](char* s) { // \n \t \\ -> the real bytes, in place char* w = s; for (char* r = s; *r; r++) { if (*r == '\\' && r[1]) { r++; *w++ = (*r == 'n') ? '\n' : (*r == 't') ? '\t' : *r; } else *w++ = *r; } *w = 0; }; unesc(p); unesc(tab + 1); if (*p && tab[1]) { pairs[n].key = p; pairs[n].val = tab + 1; n++; } } } p = nl ? nl + 1 : nullptr; } if (!n) { heap_caps_free(pairs); heap_caps_free(buf); return; } qsort(pairs, n, sizeof(I18nPair), [](const void* a, const void* b) { return strcmp(((const I18nPair*)a)->key, ((const I18nPair*)b)->key); }); i18nSetFileOverlay(pairs, n); } #endif // CAP_LUA_APPS (store network side) // Watchdog-safe Wi-Fi scan. Starts an ASYNC scan and polls to completion with // vTaskDelay yields + a hard time cap, so the calling task never blocks long // enough to starve an idle task / the 5 s task watchdog. Replaces the old // pattern of two back-to-back *synchronous* scans (~8 s total) with a // WiFi.disconnect() wedged between them — which, with no AP present (Wi-Fi on // but no SSID connected), always ran BOTH passes and tripped the task watchdog // -> panic reboot. Returns the AP count (0 on none/failure/timeout); read // results with WiFi.SSID(i). static int wifiScanWatchdogSafe(uint32_t cap_ms, uint16_t per_chan_ms = 300) { s_swd_status = (uint8_t)WiFi.status(); #if defined(HAS_TANMATSU) // esp-hosted C6: a real scan takes ~8 s but it ALSO fires a spurious early // "done, 0 items" event the Arduino layer latches. Wait for the real completion; // reject a 0 that came back < 4 s (spurious) and re-scan. (Tanmatsu-only — the S3 // native path below is the original, known-good single pass.) for (int attempt = 0; attempt < 5; ++attempt) { WiFi.scanDelete(); const uint32_t t0 = millis(); const int16_t kick = WiFi.scanNetworks(true, true, false, per_chan_ms, 0); s_swd_kick = kick; if (kick == WIFI_SCAN_FAILED) { s_swd_st = WIFI_SCAN_FAILED; s_swd_dur = millis() - t0; vTaskDelay(pdMS_TO_TICKS(300)); continue; } int16_t st; while ((st = WiFi.scanComplete()) == WIFI_SCAN_RUNNING && (millis() - t0) < cap_ms) vTaskDelay(pdMS_TO_TICKS(100)); const uint32_t dur = millis() - t0; s_swd_st = st; s_swd_dur = dur; if (st > 0) return st; if (dur >= 4000) return 0; // genuine empty after a full sweep vTaskDelay(pdMS_TO_TICKS(200)); // spurious fast-0 -> rescan } return 0; #else // Native S3 wifi: the FIRST scan after the page opens reliably comes back empty or // fails (-2 ~6 s) on a cold radio — confirmed on STOCK beta_19 too ("first scan // nothing, rescan shows everything"). The radio itself is fine; it just needs a // warm retry. So loop a few attempts until APs appear; cap_ms bounds EACH attempt. // Returns on the first attempt that finds anything, so a hot scan is still ~3 s. for (int attempt = 0; attempt < 4; ++attempt) { WiFi.scanDelete(); const int16_t kick = WiFi.scanNetworks(true, true, false, per_chan_ms, 0); s_swd_kick = kick; if (kick == WIFI_SCAN_FAILED) { s_swd_st = WIFI_SCAN_FAILED; vTaskDelay(pdMS_TO_TICKS(400)); continue; } const uint32_t t0 = millis(); int16_t st; while ((st = WiFi.scanComplete()) == WIFI_SCAN_RUNNING && (millis() - t0) < cap_ms) vTaskDelay(pdMS_TO_TICKS(50)); // yield -> feeds the task watchdog s_swd_st = st; s_swd_dur = millis() - t0; if (st > 0) return st; // found APs — done vTaskDelay(pdMS_TO_TICKS(400)); // empty / -2 / timeout -> warm up + rescan } return 0; // genuinely nothing after several tries #endif } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // Per-board OTA download bin name (the app-only <name>.bin under releases/<ch>/beta_<N>/). // MUST match the release artifact names exactly, or the self-update 404s. Every touch board is // dual-slot OTA-capable (CAP_OTA=1 + app0/app1 partitions + FIRMWARE_OTA_ENV) EXCEPT the Tanmatsu // (AppFS/launcher, CAP_OTA=0, never reaches this file). Keep this chain in sync when adding a board. #if defined(HAS_TDECK_GT911) static const char* const OTA_BIN_NAME = "wadamesh-tdeck"; #elif defined(HAS_TDISPLAY_P4) #if defined(HAS_TDP4_LCD) static const char* const OTA_BIN_NAME = "wadamesh-tdisplay-p4-lcd"; // T-Display P4 TFT-LCD SKU #else static const char* const OTA_BIN_NAME = "wadamesh-tdisplay-p4"; // T-Display P4 AMOLED SKU #endif #elif defined(HELTEC_LORA_V4_R8) static const char* const OTA_BIN_NAME = "wadamesh-heltec-v4-r8-tft"; // must precede the V4-TFT fallback #elif defined(HAS_THINKNODE_M9) static const char* const OTA_BIN_NAME = "wadamesh-thinknode-m9"; #elif defined(HAS_RAK_TAP_V2) static const char* const OTA_BIN_NAME = "wadamesh-rak-tap-v2"; #elif defined(TLORA_PAGER) #if defined(USE_LR1121) static const char* const OTA_BIN_NAME = "wadamesh-tlora-pager-lr1121"; #else static const char* const OTA_BIN_NAME = "wadamesh-tlora-pager-sx1262"; #endif #elif defined(ATTAKY_MESH_SERIES) static const char* const OTA_BIN_NAME = "wadamesh-attaky"; #else static const char* const OTA_BIN_NAME = "wadamesh-heltec-v4-tft"; // Heltec V4 TFT (Tanmatsu excluded above) #endif // Download the latest published app-only bin over plain HTTP and flash it into the spare A/B slot // via the Arduino Update writer. Runs on the tile-fetcher worker (off the UI thread); reports // progress through s_ota_state / s_ota_pct / s_ota_msg for the UI poll timer. We fetch the // IMMUTABLE versioned path (releases/TOUCH/beta_<N>/...) rather than latest/* to dodge the CDN // cache on the mutable bin. esp_https_ota isn't usable here (no on-device TLS — see tile notes). static void otaWorkerRun(WiFiClient& client, HTTPClient& http) { s_ota_pct = 0; char url[176]; snprintf(url, sizeof url, "http://firmware.wadamesh.com/releases/%s/beta_%d/%s.bin", updChannelArchive(), s_ota_target_n, OTA_BIN_NAME); http.setReuse(false); http.setConnectTimeout(8000); http.setTimeout(20000); http.setUserAgent("wadamesh-touch"); if (!http.begin(client, url)) { snprintf(s_ota_msg, sizeof s_ota_msg, "connect failed"); s_ota_state = 3; return; } const int code = http.GET(); if (code != 200) { snprintf(s_ota_msg, sizeof s_ota_msg, "HTTP %d", code); http.end(); s_ota_state = 3; return; } const int len = http.getSize(); if (len <= 0) { snprintf(s_ota_msg, sizeof s_ota_msg, "bad length"); http.end(); s_ota_state = 3; return; } // Update.begin() picks the spare OTA slot AND verifies the image fits it -> the size guard. A // legacy too-small slot (old partition table, only app-OTA'd since) fails here, cleanly. if (!Update.begin((size_t)len)) { snprintf(s_ota_msg, sizeof s_ota_msg, "won't fit: %s", Update.errorString()); http.end(); s_ota_state = 3; return; } Update.onProgress([](size_t done, size_t total) { s_ota_pct = total ? (int)((uint64_t)done * 100 / total) : 0; }); auto* stream = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) const size_t written = stream ? Update.writeStream(*stream) : 0; http.end(); if (written != (size_t)len) { snprintf(s_ota_msg, sizeof s_ota_msg, "short read %u/%d", (unsigned)written, len); Update.abort(); s_ota_state = 3; return; } if (!Update.end(true)) { // finalize, verify, mark the new slot bootable snprintf(s_ota_msg, sizeof s_ota_msg, "verify: %s", Update.errorString()); s_ota_state = 3; return; } s_ota_pct = 100; s_ota_state = 2; // success -> the UI poll timer reboots into the new slot } #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // Stream the app-only bin for the active update channel onto the SD card // (/BINS/wadamesh-beta_<N>-<stable|beta>.bin) so the Launcher can flash it — // the no-A/B-slot counterpart to otaWorkerRun above. Same immutable versioned // URL, same worker, but the bytes go to SD instead of the spare OTA slot. // // This feature is spread over SIX #if regions (state+callbacks, this worker, // the tile-worker dispatch below, the About button, the settings-close teardown, // and the sdHealthTick bail-out). They are character-for-character identical on // purpose. A board admitted to some but not all of them still COMPILES — the // worker's only caller sits behind the same gate, so nothing link-errors — and // then hangs at "Saving to SD… 0%" with a poll timer writing to a label the // teardown never nulled. Change all six or none. static void sdFwWorkerRun(WiFiClient& client, HTTPClient& http) { s_sdfw_pct = 0; if (SD.cardType() == CARD_NONE) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "no SD card"); s_sdfw_state = 3; return; } if (!SD.exists("/BINS") && !SD.mkdir("/BINS")) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "can't create /BINS"); s_sdfw_state = 3; return; } char path[56]; snprintf(path, sizeof path, "/BINS/wadamesh-beta_%d-%s.bin", s_sdfw_target_n, touchPrefsGetBetaUpdates() ? "beta" : "stable"); char url[176]; snprintf(url, sizeof url, "http://firmware.wadamesh.com/releases/%s/beta_%d/%s.bin", updChannelArchive(), s_sdfw_target_n, OTA_BIN_NAME); http.setReuse(false); http.setConnectTimeout(8000); http.setTimeout(20000); http.setUserAgent("wadamesh-touch"); if (!http.begin(client, url)) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "connect failed"); s_sdfw_state = 3; return; } const int code = http.GET(); if (code != 200) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "HTTP %d", code); http.end(); s_sdfw_state = 3; return; } const int len = http.getSize(); if (len <= 0) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "bad length"); http.end(); s_sdfw_state = 3; return; } // Free space, BEFORE writing a byte. Without this a full card fails partway // through with a bare "SD write failed" — indistinguishable from the DMA-buffer // short-write this path used to have, and the reported failure percentage is // just however far the remaining space stretched. The tile cache is the usual // culprit: it fills the card happily, and each tile is small enough to keep // succeeding long after a 2.8 MB firmware image cannot fit. SD.remove(path); // refresh any stale copy of the same tag (frees its space first) { const uint64_t total = SD.totalBytes(), used = SD.usedBytes(); const uint64_t freeb = (total > used) ? (total - used) : 0; if (freeb < (uint64_t)len + (256u * 1024u)) { // headroom for FAT metadata snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "need %u MB, %u MB free", (unsigned)(((uint64_t)len + 1048575u) / 1048576u), (unsigned)(freeb / 1048576u)); http.end(); s_sdfw_state = 3; return; } } File f = SD.open(path, FILE_WRITE); if (!f) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "SD open failed"); http.end(); s_sdfw_state = 3; return; } // The copy buffer MUST be internal DMA-capable RAM, NOT PSRAM. f.write() hands // the pointer down to SD/SPI, which DMAs from it; a PSRAM source is not reliably // DMA-capable, so the write commits fewer bytes than asked and the transfer dies // at a random offset — this is the "SD write failed" at 1-7% that three T-Decks // reported, and it has been broken since the feature landed in beta_36. The tile // cache never hit it because it copies through a 1 KB *stack* buffer; the .lang // installer did hit it and is fixed the same way (see langWriteAll). // Do NOT "optimise" this back onto PSRAM to save internal heap. size_t bufsz = 4096; uint8_t* buf = (uint8_t*)heap_caps_malloc(bufsz, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!buf) { bufsz = 1024; buf = (uint8_t*)heap_caps_malloc(bufsz, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); } if (!buf) { f.close(); http.end(); snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "out of memory"); s_sdfw_state = 3; return; } auto* st = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) int received = 0; unsigned long last_data = millis(); bool ok = true; while (received < len) { int avail = st ? st->available() : 0; if (avail <= 0) { if (!http.connected() || (millis() - last_data) > 15000) { ok = false; break; } vTaskDelay(pdMS_TO_TICKS(10)); continue; } int n = st->read(buf, ((size_t)avail > bufsz) ? bufsz : (size_t)avail); if (n <= 0) { if ((millis() - last_data) > 15000) { ok = false; break; } vTaskDelay(pdMS_TO_TICKS(5)); continue; } last_data = millis(); // A busy card can legitimately commit a short write; push the remainder // rather than declaring failure on the first one (four dead attempts in a // row means the card really is gone). int put = 0, stalls = 0; while (put < n) { const size_t w = f.write(buf + put, (size_t)(n - put)); if (!w) { if (++stalls > 3) break; vTaskDelay(pdMS_TO_TICKS(50)); continue; } stalls = 0; put += (int)w; } if (put != n) { snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "SD write failed"); ok = false; break; } received += n; s_sdfw_pct = (int)((uint64_t)received * 100 / (uint64_t)len); // Feed the IDLE task every chunk. read()'s internal yield only runs equal- // priority tasks, and a ~3 MB download over a fast link spins this loop hot // for long enough to starve the task watchdog — the same trap the tile // fetcher documents a few hundred lines down. vTaskDelay(1); } free(buf); f.close(); http.end(); if (!ok || received != len) { SD.remove(path); // never leave a truncated bin for the Launcher if (!s_sdfw_msg[0]) snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "short read %d/%d", received, len); s_sdfw_state = 3; return; } strncpy(s_sdfw_msg, path, sizeof s_sdfw_msg - 1); s_sdfw_msg[sizeof s_sdfw_msg - 1] = 0; s_sdfw_pct = 100; s_sdfw_state = 2; } #endif #endif static void tileFetchTaskFn(void* arg) { (void)arg; // Plain HTTP, not HTTPS. We can't do HTTPS on this device: mbedTLS // wants ~30 KB internal heap during the handshake and after Wi-Fi // associates only ~5 KB is free. OSM itself only serves HTTPS, but a // tiny user-side proxy (Render / Fly / nginx — ~30 lines) forwards // http://tiles.wadamesh.com/{z}/{x}/{y}.png → https://tile.osm.org. // Default base URL is hard-coded; user can override via Settings. WiFiClient client; HTTPClient http; // setReuse(false): close the TCP socket after every tile. Keeping it // open (reuse=true) held ~6 KB of lwip RX/TX buffers in internal DMA // RAM the whole time the task ran — and with only ~16 KB internal free // when the Map tab is open, that left ~2 KB. When the render thread // (core 1) then decoded a freshly-arrived tile *while* the socket was // still open, the Wi-Fi driver couldn't allocate a DMA RX buffer and // the device rebooted. Closing between tiles gives the render window // the socket's memory back. http.setReuse(false); http.setConnectTimeout(8000); http.setTimeout(15000); WIRE_DBG("[TILE] fetcher task started"); s_tile_fetch_step = 'x'; TileFetchReq req; for (;;) { // User-initiated line-of-sight comes FIRST — it must never wait behind a // background history write. Reuses this worker's stack (no second task). if (s_los_request) { s_los_request = false; // Retry up to 3×. The proxy caches /elev (nginx, 30 days, keyed on the // exact query) and rides the backend's ~1 req/s limit with its own 2 // server-side retries — so attempt 1 warms the cache and a quick retry // hits it, while the inter-attempt gap also steps across the upstream // rate limit when a cold path 502s. losRepairElevations() fills any // null/missing samples so a single no-data point no longer fails the // whole analysis. Bounded (3 × 9 s read + gaps) under the UI watchdog. int parsed = 0; bool ok = false; for (int attempt = 1; attempt <= 3 && !ok; ++attempt) { s_los_attempt = attempt; parsed = losFetchElevations(s_los_slat, s_los_slon, k_los_samples, s_los_elev); // Count real (non-null) samples BEFORE repair fills them in — this is // what the diag line reports so we can tell a proxy error (parsed=0) // from a no-data path (parsed=24 but few valid). int v = 0; for (int i = 0; i < parsed; ++i) if (s_los_elev[i] == s_los_elev[i]) ++v; s_los_dbg_parsed = parsed; s_los_dbg_valid = v; ok = losRepairElevations(s_los_elev, k_los_samples, parsed); if (!ok && attempt < 3) vTaskDelay(pdMS_TO_TICKS(900)); } s_los_got = ok ? k_los_samples : 0; s_los_busy = false; s_los_result_ready = true; continue; } // (Chat-history flush moved to its own core-1 task — histFlushTaskFn. On // THIS worker it shared core 0 with the Wi-Fi driver, whose prio-23 tasks // starve a prio-1 task for hundreds of ms; sd_diskio's busy-waits measure // wall time, so healthy SD writes surfaced as timeouts/EIO here while the // identical write from core 1 succeeded.) // Firmware update check (one-shot, infrequent). Reuses this worker's stack. if (s_verchk_request) { s_verchk_request = false; s_verchk_latest_n = verchkFetchLatest(client, http); // reuse the worker's client/http s_verchk_done = true; continue; } #if CAP_LUA_APPS // Lua app store: catalog fetch + app install (LUA_APPS.md Phase 2). if (s_luacat_request) { s_luacat_request = false; luaStoreFetchCatalogWorker(client, http); s_luacat_done = true; continue; } if (s_luainst_request) { s_luainst_request = false; s_luainst_ok = luaStoreDownloadWorker(client, http, s_luainst_id, s_luainst_ver); s_luainst_done = true; continue; } if (s_luascan_request) { s_luascan_request = false; luaStoreScanInstalled(true); // SD listing off the UI thread s_luascan_done = true; continue; } if (s_langcat_request) { s_langcat_request = false; luaStoreFetchLangCatalogWorker(client, http); s_langcat_done = true; continue; } if (s_langdl_request) { s_langdl_request = false; if (!s_langdl_ver[0]) { // Boot self-heal has no catalog yet — fetch it here so the download can // use the immutable versioned URL (falls back to the flat path if not). luaStoreFetchLangCatalogWorker(client, http); luaStoreParseLangCatalog(); for (int i = 0; i < s_langcat_n; i++) if (strcmp(s_langcat[i].code, s_langdl_code) == 0) { snprintf(s_langdl_ver, sizeof s_langdl_ver, "%s", s_langcat[i].ver); break; } } s_langdl_err = nullptr; // fresh attempt: do not report the previous one's reason s_langdl_ok = luaStoreLangDownloadWorker(client, http, s_langdl_code, s_langdl_ver); if (!s_langdl_ok && !s_langdl_err) s_langdl_err = "Could not write to storage"; s_langdl_done = true; continue; } { extern bool luaNetWorkerPending(); extern void luaNetWorkerService(void* client, void* http); if (luaNetWorkerPending()) { luaNetWorkerService(&client, &http); continue; } } #endif #if CAP_SD || defined(TLORA_PAGER) // microSD usage for the About page — the FAT scan runs here, off the UI thread. if (s_sdinfo_request) { // Publish busy before clearing request so the loop task never observes a // false/false gap and tears down the VFS underneath totalBytes/usedBytes. s_sdinfo_busy = true; __sync_synchronize(); s_sdinfo_request = false; if (SD.cardType() != CARD_NONE) { const uint64_t t = SD.totalBytes(), u = SD.usedBytes(); s_sdinfo_tot = t; s_sdinfo_free = (t > u) ? (t - u) : 0; s_sdinfo_ok = (t > 0); } else { s_sdinfo_ok = false; } s_sdinfo_done = true; s_sdinfo_busy = false; continue; } #endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // Wi-Fi OTA self-update (user-initiated from About). Reuses this worker's stack + client. if (s_ota_request) { s_ota_request = false; otaWorkerRun(client, http); continue; } #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // SD firmware download for Launcher installs (user-initiated from About). if (s_sdfw_request) { s_sdfw_request = false; sdFwWorkerRun(client, http); continue; } #endif #endif // Wi-Fi scan (user-initiated from the Network tab / setup wizard). Blocking // here on the Wi-Fi core, not the UI thread. Mirrors the CLI `wifi scan`. if (__atomic_exchange_n(&s_wifiscan_request, false, __ATOMIC_ACQ_REL)) { s_wifiscan_count = 0; // Never bring the Wi-Fi driver up from here when BLE owns the radio (fresh device // on BLE => Bluedroid holds the internal heap). WiFi.mode(STA)/esp_wifi_init would // then OOM-panic (BLE-vs-Wi-Fi mutex, see main.cpp). wantsWifi() mirrors the boot // transport choice: true only when Wi-Fi is the active transport. if (!wifiConfigWantsWifi()) { s_wifiscan_done = true; continue; } #if defined(TLORA_PAGER) // Cold STA allocation is order-sensitive with NimBLE on the Pager and is // owned exclusively by main.cpp. If the driver disappeared after the UI // released this request, fail the sweep instead of initializing it here. if ((WiFi.getMode() & WIFI_MODE_STA) == 0 || wifiConfigPagerWifiBlocksBle()) { s_wifiscan_done = true; continue; } vTaskDelay(pdMS_TO_TICKS(40)); #else if ((WiFi.getMode() & WIFI_MODE_STA) == 0) { WiFi.mode(WIFI_STA); vTaskDelay(pdMS_TO_TICKS(180)); } else { vTaskDelay(pdMS_TO_TICKS(40)); } #endif // One bounded, yielding async pass — the stable beta_19 scan. NEVER touch WiFi // state (disconnect/begin/eraseap) from this worker: it races main.cpp's wifi // state machine and wedges the radio (a hard lesson). Scanning while associated // is unreliable on this chip; the main task briefly drops the link for the sweep // (see wifiScanMainService) so by the time we get here we're disconnected. int found = wifiScanWatchdogSafe(8000); int n = 0; for (int idx = 0; idx < found && n < kWifiScanMax; ++idx) { String s = WiFi.SSID(idx); if (s.length() == 0) continue; bool dup = false; for (int k = 0; k < n; ++k) if (strcmp(s_wifiscan_ssids[k], s.c_str()) == 0) { dup = true; break; } if (dup) continue; strncpy(s_wifiscan_ssids[n], s.c_str(), WIFI_CONFIG_SSID_MAX - 1); s_wifiscan_ssids[n][WIFI_CONFIG_SSID_MAX - 1] = '\0'; ++n; } WiFi.scanDelete(); s_wifiscan_count = n; s_wifiscan_done = true; continue; } s_tile_fetch_step = 'q'; // Finite timeout (not portMAX_DELAY) so the loop wakes periodically to // pick up LOS requests even when no tiles are queued. if (xQueueReceive(s_tile_fetch_queue, &req, pdMS_TO_TICKS(250)) != pdTRUE) { continue; } ++s_tile_fetch_iters; // Yield once per dequeued item: a burst of queued tiles that are already on // disk (the prefetch now enqueues without stat'ing) would otherwise be a // tight no-yield loop of tileCacheExists() and could starve core-0 IDLE. vTaskDelay(1); if (WiFi.status() != WL_CONNECTED) { WIRE_DBG("[TILE] skip z=%u x=%ld y=%ld: WiFi down\n", (unsigned)req.z, (long)req.x, (long)req.y); s_tile_fetch_step = '!'; s_tile_fetch_last_wr = 'W'; ++s_tile_fetch_failed; tileFetchForget(req.z, req.x, req.y); // transient — re-arm for retry once Wi-Fi is back (#20) tileFetchPendingDec(); continue; } // Serialize only the request that can actually touch the cache backend. // A swap request pauses here between Files; queued requests remain intact // and resume against the newly-selected filesystem after the pointer moves. TileBackendWorkerLease backend_lease; // Capture the active map style ONCE per fetch so the cache path and the // upstream URL can't disagree if the user toggles topo mid-download (both // pointers are static string literals, so they stay valid). const char* froot = mapTileRoot(); // /tiles or /tiles/topo const char* fseg = mapTileUrlPath(); // "" or /opentopo // Skip if we already have the JPEG. A stale .png (from an earlier // build that fetched PNGs) is deleted and re-fetched as .jpg, since // the device can't decode PNG. char path_jpg[48], path_png[48]; snprintf(path_jpg, sizeof(path_jpg), "%s/%u/%ld/%ld.jpg", froot, (unsigned)req.z, (long)req.x, (long)req.y); snprintf(path_png, sizeof(path_png), "%s/%u/%ld/%ld.png", froot, (unsigned)req.z, (long)req.x, (long)req.y); // Already cached + valid? OPEN directly and read the 3-byte JPEG SOI — do NOT use tileCacheExists() // or vf.size() here: FFat f_stat misreports on the P4 (exists()->false, size()->0) so those made the // fetcher re-download + re-write every already-cached tile forever, churning open-fails (#tiles). // open() returning an invalid File is the real "not cached" signal. { File vf = tileCacheOpen(path_jpg, "r"); if (vf) { uint8_t m[3] = {0, 0, 0}; const int mr = vf.read(m, 3); vf.close(); if (mr == 3 && m[0] == 0xFF && m[1] == 0xD8 && m[2] == 0xFF) { ++s_tile_fetch_ok; // already on disk + valid -> skip the re-download #if defined(HAS_THINKNODE_M9) // A visible tile gets queued when its READ was transiently blocked // (SD fail-note window, PSRAM pressure) even though it's on disk. // Skipping WITHOUT arming the repaint left it blank until the next // manual pan ("one pan behind"). Visible-zoom only, so draining a // zoom-pack backlog doesn't fire spurious re-renders. if (req.z == s_map_zoom) s_tile_fetch_dirty = true; #endif tileFetchPendingDec(); continue; } tileCacheRemove(path_jpg); // present but unreadable/short/bad -> fall through and re-download } } tileCacheRemove(path_png); // drop any stale .png from an older build — remove is a no-op if absent (don't gate on the broken exists()) // Heap-safety gate: opening a TCP socket costs ~6 KB of internal DMA // RAM, and the Wi-Fi driver needs more on top for RX. If internal // heap is critically low (render thread mid-decode, etc.) wait it // out rather than push the system into an OOM reboot. Bail after a // few tries so we don't spin forever on a genuinely starved heap. // Threshold raised (14→18 KB) because the immersive full-screen map // composites two transparent bars over the tiles each redraw, which // transiently needs more internal DMA RAM than the old opaque chrome. { int waits = 0; while (heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) < 18 * 1024 && waits < 24) { s_tile_fetch_step = 'h'; // 'h' = heap-wait vTaskDelay(pdMS_TO_TICKS(150)); ++waits; } if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) < 12 * 1024) { // Still too tight — skip this tile. Forget it so the next render miss // actually re-queues it (the dedup mark would otherwise pin it blank // until a manual reload — the #20 edges-don't-load-on-pan symptom, since // the heap gate trips most often while the render thread decodes a pan // burst). Keep counters coherent. s_tile_fetch_last_wr = 'H'; ++s_tile_fetch_failed; tileFetchForget(req.z, req.x, req.y); tileFetchPendingDec(); continue; } } // Tile-cache full? Stop writing — a full LittleFS faults inside lfs_alloc // during the dir mkdir below (reboot), instead of failing cleanly. if (tilesFsLowSpace()) { s_tile_fetch_last_wr = 'S'; ++s_tile_fetch_failed; tileFetchForget(req.z, req.x, req.y); // transient — retry after space frees (#20) tileFetchPendingDec(); continue; } ensureTilesDirPath(req.z, req.x, froot); char base[TOUCH_TILE_SERVER_MAXLEN]; touchPrefsGetTileServer(base, sizeof(base)); // Strip trailing slash so we don't emit `//{z}` paths some servers // mis-handle. size_t blen = strlen(base); while (blen > 0 && base[blen - 1] == '/') base[--blen] = '\0'; // Request .jpg: the meshcomod proxy transcodes OSM's PNG → JPEG so the // device decodes via the light SJPG/TJpgDec path (stripe-based, RGB565 // direct) instead of lodepng (full 256 KB ARGB8888 decode per tile, // which both rendered as noise and bogged the UI down). char url[160]; snprintf(url, sizeof(url), "%s%s/%u/%ld/%ld.jpg", base, fseg, (unsigned)req.z, (long)req.x, (long)req.y); http.begin(client, url); // Bound the network waits BELOW the 5 s task-watchdog timeout. HTTPClient's // default TCP timeout is 5 s — identical to the WDT — so a single stalled // connect or socket read on a slow Wi-Fi tile fetch blocks this task right up // to the watchdog limit and the device abort()s (a panic while panning the // map). 3 s connect / 2 s per-read keeps every blocking call safely under it. http.setConnectTimeout(3000); http.setTimeout(2000); // OSM policy: identifying User-Agent required; vague UAs get blocked. http.addHeader("User-Agent", "wadamesh-touch/0.4 (https://github.com/ALLFATHER-BV/wadamesh)"); WIRE_DBG("[TILE] GET %s\n", url); s_tile_fetch_step = 'g'; int code = http.GET(); s_tile_fetch_last_code = (int16_t)code; WIRE_DBG("[TILE] -> HTTP %d (size=%d)\n", code, http.getSize()); bool wrote = false; if (code != HTTP_CODE_OK) { s_tile_fetch_step = 'd'; s_tile_fetch_last_wr = 'e'; // HTTP error (code shown as "http" on the map) } bool sd_cache_available = true; #if defined(TLORA_PAGER) sd_cache_available = !(s_pager_sd_removal_pending && s_tile_fs == &SD); #endif if (code == HTTP_CODE_OK && sd_cache_available) { s_tile_fetch_step = 'r'; int content_len = http.getSize(); // Sanity cap — refuse anything > 100 KB. Tiles are typically 10-30 KB. if (content_len > 0 && content_len <= 100 * 1024) { File f = tileCacheOpen(path_jpg, "w"); if (f) { auto* stream = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) uint8_t buf[1024]; int remaining = content_len; size_t disk_written = 0; // bytes actually committed to the FS/card bool disk_err = false; bool bad_content = false; // first bytes weren't a JPEG (proxy error/blank body) uint32_t dl_deadline = millis() + 12000; // whole-tile cap: a half-dead socket can't hang the task while (remaining > 0 && http.connected()) { const size_t want = (size_t)(remaining > (int)sizeof(buf) ? sizeof(buf) : remaining); const int n = stream->readBytes(buf, want); if (n <= 0) break; // First bytes MUST be a JPEG SOI marker (0xFF 0xD8). The proxy serves // .jpg; a 200-with-error-page or blank body would otherwise be written // to cache and render as a black quadrant. Reject up front. if (disk_written == 0 && (n < 2 || buf[0] != 0xFF || buf[1] != 0xD8)) { bad_content = true; break; } // Verify the FS write. A full / flaky / write-protected SD accepts the // open() but silently short-writes; judging success by network bytes // read alone marked these "cached" when the file was empty/truncated — // the map then sat on "Downloading…" because the read-back found nothing. const size_t wn = f.write(buf, n); disk_written += wn; if (wn != (size_t)n) { disk_err = true; break; } remaining -= n; // Yield to the IDLE task each chunk: readBytes' internal yield() only // runs equal-priority tasks, not IDLE — and IDLE is what feeds the // task watchdog. Without this a slow tile download starves the WDT and // panics (the crash this fixes). Bail too if the whole tile drags on. vTaskDelay(1); if ((int32_t)(millis() - dl_deadline) > 0) break; } f.close(); if (!disk_err && !bad_content && remaining == 0 && disk_written == (size_t)content_len) { wrote = true; s_ffat_tile_bytes += (uint32_t)disk_written; // #tiles: track FFat tile-cache growth for the budget guard (Tanmatsu; unused elsewhere) } else { tileCacheRemove(path_jpg); // partial / bad / failed write — discard if (bad_content) { s_tile_fetch_last_wr = 'J'; // not a JPEG (proxy error page / blank body) } else { ++s_tile_fetch_short_wr; s_tile_fetch_last_wr = 'P'; // short/failed disk write (card full or SD error) #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) #endif } } } else { ++s_tile_fetch_open_fail; s_tile_fetch_last_wr = 'O'; // open("w") failed: dir missing / write-protect / SD bus #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) #endif } } else { s_tile_fetch_last_wr = 'Z'; // bad / oversized content-length } } // Close the socket and free its lwip buffers BEFORE we signal the // render thread. The render of a freshly-downloaded tile must happen // with the socket DOWN — otherwise core 1's decode + the Wi-Fi // driver's DMA RX buffers collide in the ~2 KB of internal heap that // remains and the device reboots. http.end(); client.stop(); tileFetchPendingDec(); // No cache/File access follows. Release before the 500 ms rate-limit delay // so a lifecycle handoff waits for I/O, not an unrelated pacing sleep. backend_lease.release(); if (wrote) { WIRE_DBG("[TILE] -> wrote %s\n", path_jpg); s_tile_fetch_step = 'w'; s_tile_fetch_last_wr = 'w'; ++s_tile_fetch_ok; s_tile_fetch_dirty = true; // render thread picks this up } else { WIRE_DBG("[TILE] -> FAILED %s\n", path_jpg); ++s_tile_fetch_failed; // Re-arm a failed fetch for retry UNLESS the server gave a permanent // rejection (a 4xx — the tile genuinely isn't there at this z/x/y). Every // other failure here is transient — a negative HTTPClient code (connect/ // read timeout), a proxy 5xx, a rate-limit (408/429), or a disk write // hiccup at HTTP 200 — and must drop the "seen" mark or the visible tile // stays blank until a manual reload (#20). 408/429 are 4xx but retryable. const bool permanent = (code >= 400 && code < 500 && code != 408 && code != 429); if (!permanent) tileFetchForget(req.z, req.x, req.y); } // Give core 1 a clear window (socket closed = ~8 KB more internal // heap) to decode+blit the tile we just wrote before we reopen a // socket for the next one. 500 ms also keeps us at OSM's ≤2/sec // policy on cache-misses (the proxy forwards our rate upstream; most // hits are served from the proxy's cache so this rarely reaches OSM). vTaskDelay(pdMS_TO_TICKS(500)); } // Never exit. The task sits forever on xQueueReceive; idle sleep on a // FreeRTOS queue is free, and re-creating a STATIC task is risky — // the FreeRTOS idle reaper may still hold a reference to the TCB // when we try to recreate it, and the stack alloc is the bottleneck // we don't want to repeat anyway. } // Stack allocated at UITask::begin() time (see reserveTileFetchStack // below). Pre-reserving in BSS bootloops because it competes with // Bluedroid's late-init DMA-DRAM allocations; PSRAM stacks crash mbedTLS. // Runtime alloc in the small window after Bluedroid is up but before // Wi-Fi associates hits the sweet spot: heap is still ~150 KB free then, // and once we hold the 14 KB it stays ours. static size_t s_tile_fetch_stack_bytes = 0; static StackType_t* s_tile_fetch_stack = nullptr; static StaticTask_t s_tile_fetch_tcb; void reserveTileFetchStack() { if (s_tile_fetch_stack) return; // Plain-HTTP tile fetcher (mbedTLS gone) needs only ~5 KB, but this worker // also runs the version check and the Wi-Fi scan (deep scanNetworks path). // 8 KB gives headroom — a too-small stack here overflowed into adjacent // globals and zeroed the chat-history buffer (empty chats after a scan). static const size_t k_try[] = { 8*1024, 7*1024, 6*1024 }; for (size_t s : k_try) { s_tile_fetch_stack = (StackType_t*)heap_caps_malloc( s, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (s_tile_fetch_stack) { s_tile_fetch_stack_bytes = s; break; } } WIRE_DBG("[TILE] reserve stack %p sz=%u (free_int now %u)\n", s_tile_fetch_stack, (unsigned)s_tile_fetch_stack_bytes, (unsigned)ESP.getFreeHeap()); } static bool ensureTileFetchTaskRunning() { if (s_tile_fetch_queue == nullptr) { s_tile_fetch_queue = xQueueCreate(k_tile_fetch_queue_size, sizeof(TileFetchReq)); if (!s_tile_fetch_queue) return false; } if (s_tile_fetch_task != nullptr) return true; // Stack was reserved early (begin()); if that failed, give up cleanly // rather than crash inside xTaskCreateStatic. if (s_tile_fetch_stack == nullptr) { reserveTileFetchStack(); if (s_tile_fetch_stack == nullptr) return false; } s_tile_fetch_step = '-'; s_tile_fetch_spawn_ok = false; s_tile_fetch_task = xTaskCreateStaticPinnedToCore( tileFetchTaskFn, "tile_fetch", s_tile_fetch_stack_bytes / sizeof(StackType_t), nullptr, 1, s_tile_fetch_stack, &s_tile_fetch_tcb, 0 /*core 0 = wifi core*/); s_tile_fetch_spawn_ok = (s_tile_fetch_task != nullptr); WIRE_DBG("[TILE] fetch task spawned %p free_int=%u free_psram=%u\n", s_tile_fetch_task, (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getFreePsram()); return s_tile_fetch_task != nullptr; } // ---- Chat-history flush worker (core 1) -------------------------------------- // The flush used to ride the core-0 tile_fetch worker — the Wi-Fi core. Under // Wi-Fi load its prio-23 driver tasks starve a prio-1 task for hundreds of ms // at a time, and sd_diskio's busy-waits measure WALL time: the timeout expires // while the task simply wasn't scheduled, so perfectly good SD writes surface // as EIO ("card I/O error") — while the identical write from the core-1 loop // task (the reboot-time persist) succeeds every time. Chat persistence has no // Wi-Fi affinity, so it gets its own tiny task pinned to core 1 at the loop // task's priority (equal-priority time-slicing keeps the UI serviced during a // write). The task is persistent; the 100 ms poll of the volatile req flag // costs nothing measurable and mirrors the old worker's pickup latency. static TaskHandle_t s_hist_flush_task = nullptr; static StaticTask_t s_hist_flush_tcb; static StackType_t* s_hist_flush_stack = nullptr; static size_t s_hist_flush_stack_bytes = 0; static void histFlushTaskFn(void*) { for (;;) { // busy is raised BEFORE req is cleared so the loop-side gate (busy || req) // never sees a gap in which it could overwrite the snapshot mid-write. if (s_hist_flush_req) { s_hist_flush_busy = true; s_hist_flush_req = false; s_hist_flush_ok = uiSegRunArmedJob(); // segmented store: one append/compact per job s_hist_flush_busy = false; } vTaskDelay(pdMS_TO_TICKS(100)); } } static bool ensureHistFlushTaskRunning() { if (s_hist_flush_task != nullptr) return true; if (!s_hist_flush_stack) { // The segment writers keep their bulk buffers on the heap; stack use is // File objects + FatFs path work. 6 KB leaves ample headroom (the tile // worker's overflow-into-globals history earned the caution). static const size_t k_sz = 6 * 1024; s_hist_flush_stack = (StackType_t*)heap_caps_malloc(k_sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (!s_hist_flush_stack) return false; s_hist_flush_stack_bytes = k_sz; } s_hist_flush_task = xTaskCreateStaticPinnedToCore( histFlushTaskFn, "hist_flush", s_hist_flush_stack_bytes / sizeof(StackType_t), nullptr, 1, s_hist_flush_stack, &s_hist_flush_tcb, 1 /*core 1 — NOT the Wi-Fi core; see block comment*/); return s_hist_flush_task != nullptr; } // Queue a missing tile for download. No-op when Wi-Fi is down or the // tile was queued recently. Called from renderMapTiles after a SPIFFS // miss; the actual fetch happens off-thread. static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { // Boards with a microSD backend fetch whenever SOME cache is mounted, and the // destination follows whatever s_tile_fs currently points at: the card in // microSD-tile mode (downloads merge into the SD library, #20), or the // internal cache while that card is absent, so maps stay online through the // fallback until it returns. Boards without one keep the plain offline guard: // no SD packs exist there, so microSD-tile mode can only mean "don't fetch". #if CAP_SD || defined(TLORA_PAGER) if (!s_tiles_fs_ready || !s_tile_fs) return; #else if (s_map_style == 0 && s_tiles_from_sd) return; #endif #if defined(TLORA_PAGER) if (s_pager_sd_removal_pending && s_tile_fs == &SD) return; #endif if (WiFi.status() != WL_CONNECTED) return; if (tileFetchSeen(z, x, y)) return; // HONOR the result: ensureTileFetchTaskRunning() creates the QUEUE first and can // still fail afterwards on the worker's 8 KB contiguous-DRAM stack. Ignoring it left // a queue with no consumer, so every send below incremented s_tile_fetch_pending with // nothing to ever drain it. That counter is only ever ++/--, never reset, and it gates // ALL notification sounds (tdeckPlayNotifySlot and friends bail while it is > 0), so a // single failed spawn silently killed the chime for the rest of the boot — the "lost // sound until I restart" report (issue #184). No tile is lost by returning here: with // no worker the fetch could never have happened anyway. if (!ensureTileFetchTaskRunning()) return; if (!s_tile_fetch_queue) return; TileFetchReq req = {z, x, y}; // Publish the lifecycle count before the queue item. Otherwise the worker can // dequeue and open an SD File before the producer increments the count. tileFetchPendingInc(); if (xQueueSend(s_tile_fetch_queue, &req, 0) == pdTRUE) { tileFetchMarkSeen(z, x, y); // only remember it once it's truly queued } else { tileFetchPendingDec(); } } // "Zoom packs": for the settled map center, opportunistically cache a zoomed-out // pyramid AROUND the center through the same background fetch path as the live map // — so every place you browse online stays usable offline across a wide range of // zooms, not just the one level you happened to be viewing. Low-zoom tiles each // cover a huge area, so a 3x3 grid at z7/z8/z9 is only a handful of tiles yet // covers a province → the whole country → across the borders. Cheap because // queueTileForFetch's Wi-Fi guard + dedup ring + the on-disk checks here skip // anything already cached, and because it runs *after* the visible tiles are // queued, FIFO ordering means the tiles you're actually looking at download first. static void queueZoomPackForCenter() { if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) return; if (!s_tiles_fs_ready) return; // This runs on the UI thread (core 0), so it must do NO filesystem work. The // old version stat'd ~58 tiles (tileCacheExists) + a usedBytes() traversal here // every render; a zoom-out into an un-cached area fires renders back-to-back as // tiles stream in, and that starved core-0 IDLE -> task-watchdog reboot // (confirmed by coredump: loopTask stuck in tileCacheExists). We now ONLY // enqueue (in-memory dedup + queue send); the background fetch task skips tiles // already on disk. Throttle by area too: the prefetch covers a wide region, so // only re-enqueue when the center crosses into a new z9 tile (~50 km). static int32_t last_tx = INT32_MIN, last_ty = INT32_MIN; double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, 9, &cwx, &cwy); const int32_t cz9x = (int32_t)floor(cwx / 256.0); const int32_t cz9y = (int32_t)floor(cwy / 256.0); if (cz9x == last_tx && cz9y == last_ty) return; // same area — already enqueued last_tx = cz9x; last_ty = cz9y; // 3x3 grids at Belgium's latitude (~50.8N): z7 ~590 km (country + all borders), // z8 ~295 km (whole country), z9 ~145 km (a province). Plus a single close // overview (z12, town) + detail (z16, street). r = tiles each side of center. struct ZoomPack { uint8_t z; int8_t r; }; static const ZoomPack packs[] = { { 7, 1 }, { 8, 1 }, { 9, 1 }, { (uint8_t)(k_map_zoom_default - 2), 0 }, { (uint8_t)(k_map_zoom_default + 2), 0 }, }; for (uint8_t p = 0; p < sizeof(packs) / sizeof(packs[0]); ++p) { const uint8_t z = packs[p].z; if (z == s_map_zoom) continue; // current zoom: renderMapTiles already queues the visible grid const int8_t r = packs[p].r; const int32_t span = (int32_t)1 << z; // tiles per axis at this zoom (wrap guard) double wx, wy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, z, &wx, &wy); const int32_t ctx = (int32_t)floor(wx / 256.0); const int32_t cty = (int32_t)floor(wy / 256.0); for (int8_t dy = -r; dy <= r; ++dy) { for (int8_t dx = -r; dx <= r; ++dx) { const int32_t tx = ctx + dx, ty = cty + dy; if (tx < 0 || ty < 0 || tx >= span || ty >= span) continue; // off the world edge queueTileForFetch(z, tx, ty); // in-memory dedup + enqueue; fetch task skips on-disk } } } } #endif // ESP32 && MULTI_TRANSPORT_COMPANION #if defined(ESP32) // Decode a PNG to a PSRAM RGB565 buffer via lodepng DIRECTLY. LVGL's lv_png // decoder produces RGB565 noise on this board, so we bypass it: lodepng -> // RGBA8888 (its malloc lands in PSRAM thanks to the >4 KB SPIRAM-malloc // threshold), then a manual RGBA->RGB565 using lv_color_make() so the display's // colour format (incl. byte-swap) is honoured. This is what lets the SD map use // standard /maps/osm/{z}/{x}/{y}.png tiles (the Meshtastic/MeshCore convention). extern "C" unsigned lodepng_decode32(unsigned char** out, unsigned* w, unsigned* h, const unsigned char* in, size_t insize); static uint8_t* decodePngToRgb565(const uint8_t* png, size_t png_len, int* out_w, int* out_h, uint8_t* dst_buf, size_t dst_cap) { unsigned char* rgba = nullptr; unsigned w = 0, h = 0; if (lodepng_decode32(&rgba, &w, &h, png, png_len) != 0 || !rgba) { if (rgba) free(rgba); return nullptr; } if (w == 0 || h == 0 || w > 1024 || h > 1024) { free(rgba); return nullptr; } const size_t npx = (size_t)w * h; uint16_t* rgb; bool owns; if (dst_buf) { // caller-provided output (map tile pool) if (npx * sizeof(uint16_t) > dst_cap) { free(rgba); return nullptr; } rgb = (uint16_t*)dst_buf; owns = false; } else { rgb = (uint16_t*)lvglPsramAlloc(npx * sizeof(uint16_t)); owns = true; if (!rgb) { free(rgba); return nullptr; } } (void)owns; for (size_t i = 0; i < npx; i++) rgb[i] = lv_color_make(rgba[i*4+0], rgba[i*4+1], rgba[i*4+2]).full; free(rgba); // lodepng allocates with the system malloc/free *out_w = (int)w; *out_h = (int)h; return (uint8_t*)rgb; } #endif // Read /tiles/<z>/<x>/<y>.{jpg,png} from the tiles LittleFS partition // into a freshly-PSRAM-alloced buffer. Returns false when neither file // exists or alloc fails. Tries .jpg first (offline packs from tile-pack // .py) and falls back to .png (Wi-Fi-fetched tiles from OSM). // // The LVGL decoder pipeline is format-agnostic: src.header.cf = // LV_IMG_CF_RAW + data starting with 0xFFD8 (JPEG) or 0x89 0x50 0x4E // 0x47 (PNG) is auto-routed to SJPG or lv_png respectively. So this // function just reads bytes — decode happens later in // decodeJpegToRgb565 (which is a misnomer now; it handles both). static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, uint8_t** out_data, size_t* out_len, bool* out_repairable_cache) { #if defined(ESP32) if (out_repairable_cache) *out_repairable_cache = false; // Format support: // • JPEG (.jpg) — decoded by SJPG/TJpgDec straight to RGB565 in stripes (cheap). // The LittleFS online cache stores ONLY .jpg (the tiles.wadamesh.com // proxy transcodes OSM's PNG → JPEG, and the device fetches .jpg). // • PNG (.png) — decoded directly via lodepng (decodePngToRgb565), bypassing LVGL's // lv_png (which renders noise on this board). Used by SD packs, which // are OSM's native PNG. // renderMapTiles sniffs the magic bytes and routes to the right decoder, so both formats // render on the SD path; the LittleFS cache is JPEG-only by design (see above). char path[48]; snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, (long)x, (long)y); #if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) // Mounted check only — a render loop must never walk the multi-second // mount ladder per tile (with the card out that read as recurring UI // freezes). Mounting is owned by boot adoption, sdHealthTick's reinsert // watch, the FM poll, and the SD-tiles toggle (mapOptTilesSdCb mounts). if (!s_sd_mounted) return false; markSdIo(); // SD read activity -> status-bar LED // Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png // (decoded via lodepng); fall back to the legacy /tiles/{z}/{x}/{y}.jpg. char ppath[56]; // Directory case matters here even though FAT is nominally case-insensitive: what // resolves depends on the card's long-filename entries and the FATFS build, and we // were only ever asking for the lower-case spelling. Meanwhile our OWN card // bootstrap creates "/MAPS" and the README we write onto the card says "MAPS/", so // a user who followed our instructions to the letter could end up with a directory // the loader never looked in. Reported by w7aaf via pisti87 (#286), who could not // reproduce it on his own card — which is exactly the shape of a case-resolution // difference rather than a wrong path. // // Ask for both spellings, upper first since that is the one we create. File fsd; static const char* const kMapRoots[2] = { "/MAPS", "/maps" }; static const char* const kPngExt[2] = { "png", "PNG" }; for (int r = 0; r < 2 && !fsd; ++r) { for (int e = 0; e < 2 && !fsd; ++e) { snprintf(ppath, sizeof(ppath), "%s/osm/%u/%ld/%ld.%s", kMapRoots[r], (unsigned)z, (long)x, (long)y, kPngExt[e]); fsd = SD.open(ppath, FILE_READ); } } // Plain /tiles/<z>/<x>/<y>.{png,PNG} — a PNG pack dropped straight into /tiles/ // (OSM's native format), not the /maps/osm layout. Decoded via lodepng like the // /maps/osm PNGs above; the .jpg fallback below covers JPEG /tiles/ packs. if (!fsd) { snprintf(ppath, sizeof(ppath), "/tiles/%u/%ld/%ld.png", (unsigned)z, (long)x, (long)y); fsd = SD.open(ppath, FILE_READ); } if (!fsd) { snprintf(ppath, sizeof(ppath), "/tiles/%u/%ld/%ld.PNG", (unsigned)z, (long)x, (long)y); fsd = SD.open(ppath, FILE_READ); } // /tiles/<z>/<x>/<y>.jpg is the WRITABLE download cache (where the fetcher // merges Wi-Fi tiles into the library), not a read-only pack — track that so // a corrupt one can be dropped and re-fetched below. The /maps/osm and PNG // legs above are user-supplied packs and must never be removed. bool sd_writable_cache = false; if (!fsd) { fsd = SD.open(path, FILE_READ); sd_writable_cache = (bool)fsd; } if (!fsd) { sdReadFailedCardDead(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit) const size_t szsd = fsd.size(); if (szsd == 0 || szsd > 256 * 1024) { fsd.close(); return false; } // PNG tiles run larger than JPEG uint8_t* bufsd = (uint8_t*)lvglPsramAlloc(szsd); if (!bufsd) { fsd.close(); return false; } const size_t nsd = fsd.read(bufsd, szsd); fsd.close(); if (nsd != szsd) { lvglPsramFree(bufsd); sdReadFailedCardDead(); return false; } // short read mid-tile = card died under us // A garbled/partial download in the writable cache decodes to a blank/half // tile and, because the decode failure path does not re-queue, stuck there // until a manual "Reload visible tiles". Drop it so the render miss below // re-queues a fresh fetch — same contract as the generic cache path, and // still never applied to the read-only packs. if (sd_writable_cache && (nsd < 4 || bufsd[0] != 0xFF || bufsd[1] != 0xD8 || bufsd[2] != 0xFF || bufsd[nsd - 2] != 0xFF || bufsd[nsd - 1] != 0xD9)) { lvglPsramFree(bufsd); SD.remove(path); #if defined(MULTI_TRANSPORT_COMPANION) tileFetchForget(z, x, y); #endif return false; } if (out_repairable_cache) *out_repairable_cache = sd_writable_cache; *out_data = bufsd; *out_len = szsd; return true; } #endif if (!s_tiles_fs_ready) return false; // Open directly — do NOT gate on tileCacheExists() and do NOT trust f.size(): on the P4's FFat the // f_stat metadata layer misreports (exists()->false for present files, size()->0/huge), the same // broken layer as f_getfree's 0-free. That made every cached tile look absent, so the map // re-downloaded forever and rendered nothing (#tiles). open() is the real existence test; read up to // the 100 KB writer cap and use the ACTUAL bytes read. (S3 boards: f.size() works there, but this is // equally correct — a transient 100 KB PSRAM buffer per tile, freed right after decode.) #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Launcher installs cache tiles on the raw SD (s_tile_fs == &SD) — same // dead-card short-circuit as the SD-pack path above. if (s_tile_fs == &SD && s_sd_fail_note_ms) return false; #endif File f = tileCacheOpen(path, "r"); if (!f) { #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (s_tile_fs == &SD) sdReadFailedCardDead(); #endif return false; } const size_t CAP = 100 * 1024; uint8_t* buf = (uint8_t*)lvglPsramAlloc(CAP); if (!buf) { f.close(); return false; } size_t n = 0; while (n < CAP) { const int r = f.read(buf + n, CAP - n); if (r <= 0) break; n += (size_t)r; } f.close(); // Reject a cached tile without both JPEG framing markers — a garbled/partial // download can retain its SOI while losing the EOI and otherwise decode to a // blank/half "twilight zone" tile. This generic path is the writable /tiles // download cache; standard read-only /maps/osm packs return above. if (n < 4 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF || buf[n - 2] != 0xFF || buf[n - 1] != 0xD9) { lvglPsramFree(buf); tileCacheRemove(path); #if defined(MULTI_TRANSPORT_COMPANION) tileFetchForget(z, x, y); #endif return false; } if (out_repairable_cache) *out_repairable_cache = true; *out_data = buf; *out_len = n; return true; #else (void)z; (void)x; (void)y; (void)out_data; (void)out_len; (void)out_repairable_cache; return false; #endif } // Free everything currently in the tile cache. static void freeMapTiles() { for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) freeMapTileSlot(s_map_tiles[_ti]); } // The tile storage changed underneath us: a card was remounted (wedge recovery, // reinsert watch, file-manager insert poll, post-format) or lost. The map keeps // decoded tiles in s_map_tiles across renders and only renders on pan / zoom / // tab-open, so without this a card that came back showed a permanently blank // map — the chat store re-landed (flushHistorySoon) while the map was never // told anything had happened. // // * adopts the card as the tile cache when this boot had no backend at all // (booted card-less, or the "tiles" partition is absent — Launcher installs) // * drops the decoded tiles, since a swapped card holds different content // * clears the fetch dedup ring so tiles whose download failed during the // outage are retried instead of being remembered as in-flight forever // * re-renders immediately when the map is the visible tab static void mapNoteStorageChanged() { #if CAP_SD || defined(TLORA_PAGER) // A fetch snapshots its paths but still dereferences the global backend for // every filesystem operation of the request, so the pointer may only move at // a worker request boundary. The ownership handshake pauses dequeueing there; // queued downloads survive and resume on the new backend. When the current // request is still active, sdHealthTick retries the swap at the first safe // boundary — but the invalidation below still has to run now, or a swapped // card keeps showing the previous card's tiles with the dedup ring suppressing // the re-fetch until that retry lands. // Whether the backend actually has to move. Also decides whether a deferral // leaves the pointer stale: most callers (a remount that changed nothing, a // reinsert while already on the card) need no swap at all, and those must // still repaint below even while a fetch is in flight. const bool wants_teardown = !s_sd_mounted && s_tile_fs == &SD; const bool wants_adopt = s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready); const bool wants_swap = wants_teardown || (wants_adopt && s_tile_fs != &SD); const bool may_swap = !wants_swap || tileBackendSwapTryBegin(); const bool swap_deferred = !may_swap && wants_swap; if (may_swap && wants_teardown) { if (s_tile_fs_default && s_tile_fs_default != &SD) { // SD tile mode lost its card, but the dedicated LittleFS cache is still // mounted. Keep maps online instead of disabling that healthy fallback. s_tile_fs = s_tile_fs_default; strlcpy(s_tile_root, s_tile_root_default, sizeof(s_tile_root)); s_tiles_fs_ready = true; } else { // Launcher-style partition table: SD was the only tile backend. s_tile_fs = nullptr; s_tile_root[0] = '\0'; s_tile_fs_default = nullptr; s_tile_root_default[0] = '\0'; s_tiles_fs_ready = false; } } else if (may_swap && wants_adopt) { // Reinserted while SD mode is selected, or no internal backend exists. // Preserve an existing LittleFS default so toggling SD mode off remains a // valid fallback after any number of remove/reinsert cycles. s_tile_fs = &SD; s_tile_root[0] = '\0'; if (!s_tile_fs_default) { s_tile_fs_default = &SD; s_tile_root_default[0] = '\0'; } s_tiles_fs_ready = true; } // Only a real swap request acquired (or extended) the pause above. A no-op // notification must not clear a pause owned by card removal/remount or the // health probe while that lifecycle operation is still draining consumers. if (wants_swap && may_swap) tileBackendSwapFinish(); #endif for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; s_tile_fetch_dedup_head = 0; freeMapTiles(); #if CAP_SD || defined(TLORA_PAGER) // Drop the stale tiles above either way, but do not paint through a pointer // we already know is wrong — that would probe &SD for a card that is gone and // pay a full SPI timeout per tile on the loop task. Only skip when a swap was // genuinely wanted and deferred; sdHealthTick repaints once it lands. When no // swap was needed the backend is correct, so fall through and repaint now. if (swap_deferred) return; #endif if (g_lv.tabview && lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX) renderMapTiles(); } #if defined(ESP32) // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { #if CAP_SD || defined(TLORA_PAGER) // Mirror loadTileJpeg's source selection: SD packs are OSM-only, so topo // reads the generic /tiles/topo cache and its readiness is s_tiles_fs_ready. if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) return s_sd_mounted; #endif return s_tiles_fs_ready; } // Does a tile exist at z/x/y in whatever source the loader will actually read? // MUST mirror loadTileJpeg's source selection, or the zoom guard blocks levels the // loader could load. The old guards only probed the LittleFS /tiles partition (the // online cache), so an SD /maps/osm offline pack was invisible to zoom — the loader // drew its tiles but the buttons reported "Max/Min zoom for this pack" offline. static bool tileExistsAt(uint8_t z, long x, long y) { #if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg) char p[56]; // Both directory spellings, upper first: our own card bootstrap creates "/MAPS" // while this only ever probed "/maps" (#286). Must stay in step with the loader in // tileFromSd(), or zoom levels would advertise as reachable and then not draw. for (const char* root : { "/MAPS", "/maps" }) { for (const char* ext : { "png", "PNG" }) { snprintf(p, sizeof p, "%s/osm/%u/%ld/%ld.%s", root, (unsigned)z, x, y, ext); if (SD.exists(p)) return true; } } snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.png", (unsigned)z, x, y); // PNG pack in /tiles/ if (SD.exists(p)) return true; snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.PNG", (unsigned)z, x, y); if (SD.exists(p)) return true; snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.jpg", (unsigned)z, x, y); if (SD.exists(p)) return true; sdReadFailedCardDead(); // all five missing: either past the pack edge (cheap, silent) or the card died return false; } #endif if (!s_tiles_fs_ready) return false; char p[48]; // open() not tileCacheExists(): FFat exists()/f_stat lies on the P4 (present files read as missing), // which would make the zoom guard think whole levels are un-cached. A successful open == it's there. snprintf(p, sizeof p, "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, x, y); { File f = tileCacheOpen(p, "r"); if (f) { f.close(); return true; } } snprintf(p, sizeof p, "%s/%u/%ld/%ld.png", mapTileRoot(), (unsigned)z, x, y); { File f = tileCacheOpen(p, "r"); if (f) { f.close(); return true; } } return false; } #endif // ESP32 // Probe the pack for the highest zoom level that has a tile near `lat,lon`. // Used as an auto-fallback so a missing top zoom (e.g. the operator packed // z12+z13 but s_map_zoom defaults to z14) doesn't make the whole tab look // empty. Returns 0 when no tile is found at any zoom in the supported range. static uint8_t bestAvailableZoom(double lat, double lon) { #if defined(ESP32) if (!mapTileSourceReady()) return 0; for (int z = (int)k_map_zoom_max; z >= (int)k_map_zoom_min; --z) { double wx, wy; latLonToWorldPx(lat, lon, (uint8_t)z, &wx, &wy); const int32_t tx = (int32_t)floor(wx / 256.0); const int32_t ty = (int32_t)floor(wy / 256.0); if (tileExistsAt((uint8_t)z, (long)tx, (long)ty)) return (uint8_t)z; } return 0; #else (void)lat; (void)lon; return 0; #endif } // Build the visible tile grid for the current center+zoom. Cheap: only // runs on tab activation or pan/zoom (not on every refresh tick). static void renderMapTiles() { if (!s_map_canvas) return; // Fall back to placeholder when no usable GPS center. if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) { freeMapTiles(); s_map_has_pack = false; if (s_map_status_lbl) { lv_label_set_text(s_map_status_lbl, TR("Map — set your location\nin Settings \xe2\x86\x92 Profile to\nshow the map here.")); lv_obj_clear_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); } return; } // (Auto-snap to best available zoom moved to onMapTabActivated. Here we // honour whatever the user picked via the zoom buttons — even if the // requested zoom has no tiles, we show the empty grid + "no tile pack" // hint so the user can tap − to go back, instead of silently fighting // the buttons.) // World pixel of the center coordinate. double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); const int32_t ctx = (int32_t)floor(cwx / 256.0); const int32_t cty = (int32_t)floor(cwy / 256.0); // Slot reuse: panning by less than a tile shares 6-9 of the 9 visible // tiles between renders. Re-creating those widgets (SPIFFS read + JPEG // decode each) is what made the v1 unusable — ~3 s every release. We // now MATCH wanted tiles against the existing slots and only reload the // newcomers. A full pan still loads 9 tiles, but small pans cost ~1 new // tile (~300 ms). // // Algorithm: // 1. Build the wanted set: 9 (z, tx, ty) coords. // 2. For each slot in s_map_tiles, decide: // - matches a wanted coord → keep, reposition. // - doesn't match → free the slot (becomes "empty", available below). // 3. For each wanted coord not yet present in any slot, load into an // empty slot. mapComputeGridRadius(); // size the grid to the current canvas (covers full width on the Tanmatsu) // Viewport cull + pool cap: each held tile costs a 128 KB PSRAM buffer, and a // full 3x3 grid (1.15 MB) can't coexist with contacts + LVGL + web buffers on // the 2 MB V4. Only decode tiles whose 256 px square actually touches the // viewport (+margin); then, if that still exceeds the pool cap, keep the ones // NEAREST the screen centre (the most-visible; the dropped ones sit under the // status/tab bars or off-screen). Drag-release re-centers + re-renders, so // panned-in edges fill on release. >=4 MB boards keep the full grid + cushion. static const size_t kPsramTotalB = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); const bool kLowPsram = (kPsramTotalB && kPsramTotalB < 4u * 1024 * 1024); const int tile_margin = kLowPsram ? 0 : 256; const int pool_cap = kLowPsram ? 4 : k_map_visible_tiles_max; struct Wanted { int32_t tx; int32_t ty; bool placed; long d2; }; Wanted wanted[k_map_visible_tiles_max]; int n_wanted = 0; for (int dy = -s_map_grid_ry; dy <= s_map_grid_ry; ++dy) { for (int dx = -s_map_grid_rx; dx <= s_map_grid_rx; ++dx) { if (n_wanted >= k_map_visible_tiles_max) break; const int32_t tx = ctx + dx, ty = cty + dy; const int sx = (int)((double)tx * 256.0 - cwx + k_map_canvas_w / 2); const int sy = (int)((double)ty * 256.0 - cwy + k_map_canvas_h / 2); if (sx + 256 <= -tile_margin || sx >= k_map_canvas_w + tile_margin || sy + 256 <= -tile_margin || sy >= k_map_canvas_h + tile_margin) continue; // fully off-screen // Squared distance from the tile centre to the screen centre — used to keep // the most-visible tiles when culling to the pool cap. const long ddx = (long)(sx + 128 - k_map_canvas_w / 2); const long ddy = (long)(sy + 128 - k_map_canvas_h / 2); wanted[n_wanted++] = { tx, ty, false, ddx * ddx + ddy * ddy }; } } // Cap to the pool: if more tiles touch the viewport than we can hold, keep the // ones nearest the screen centre (selection sort front — n_wanted is tiny). The // dropped tiles are the farthest out (under the bars / screen edge). if (n_wanted > pool_cap) { for (int a = 0; a < pool_cap; ++a) { int best = a; for (int b = a + 1; b < n_wanted; ++b) if (wanted[b].d2 < wanted[best].d2) best = b; if (best != a) { Wanted tmp = wanted[a]; wanted[a] = wanted[best]; wanted[best] = tmp; } } n_wanted = pool_cap; } s_tile_dec_want = n_wanted; // diagnostics: visible tiles this pass (see the zoom label) // Whenever we end up here, the pan layer must be reset to (0,0) — its // offset only exists during a live drag. if (s_map_pan_layer) lv_obj_set_pos(s_map_pan_layer, 0, 0); // Re-plot the contact markers at the NEW zoom/center BEFORE the tile loop. // Tiles below decode one-by-one with a progressive lv_refr_now() between // them (~2 s for a full 9-tile zoom), and each tile sinks to the background // via lv_obj_move_background — so the dots ride on top from the very first // paint instead of lingering at their stale previous-zoom positions until the // last tile lands (the "dots update ~2 s after the zoom" report). Callers // still invoke renderMapMarkers() afterwards to rebuild links/route overlays; // that trailing pass is now a cheap no-visual-change refresh. renderMapMarkers(); // Pass 1 — keep matching slots, RELEASE non-matching ones (keep their 128 KB // buffer for reuse below — see releaseMapTileSlot; freeing per-render is what // fragmented PSRAM). for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) { MapTile& t = s_map_tiles[_ti]; if (!t.in_use) continue; if (t.z != s_map_zoom) { releaseMapTileSlot(t); continue; } bool kept = false; for (int i = 0; i < n_wanted; ++i) { if (!wanted[i].placed && wanted[i].tx == t.x && wanted[i].ty == t.y) { // Re-anchor the existing img widget to its new screen position. const int sx = (int)((double)t.x * 256.0 - cwx + k_map_canvas_w / 2); const int sy = (int)((double)t.y * 256.0 - cwy + k_map_canvas_h / 2); lv_obj_set_pos(t.img, sx, sy); wanted[i].placed = true; kept = true; break; } } if (!kept) releaseMapTileSlot(t); } // Pass 2 — fill remaining wanted tiles into empty slots, decoding into each // slot's PERSISTENT 128 KB buffer (allocated once, reused every render — the // fragmentation fix). Cap the pool so the map's PSRAM stays bounded: the tiny // 2 MB-PSRAM V4 can't spare more than ~4 buffers alongside contacts+LVGL+web. static const size_t kTileBufBytes = 256u * 256u * sizeof(lv_color_t); // 128 KB, fixed tile size lv_obj_t* parent = s_map_pan_layer ? s_map_pan_layer : s_map_canvas; bool any_loaded = false; int n_missing = 0; // visible tiles we couldn't load from disk for (int i = 0; i < n_wanted; ++i) { if (wanted[i].placed) { any_loaded = true; continue; } // Pick an empty slot, PREFERRING one that already owns a reusable 128 KB // buffer (decode in place → no malloc → no fragmentation); else any empty slot. MapTile* dst = nullptr; for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) { MapTile& t = s_map_tiles[_ti]; if (!t.in_use && t.rgb565) { dst = &t; break; } } if (!dst) for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) { MapTile& t = s_map_tiles[_ti]; if (!t.in_use) { dst = &t; break; } } if (!dst) break; // shouldn't happen — wanted count <= slot count // Ensure the slot has its persistent buffer. Allocate up to pool_cap buffers // (bounds the map's PSRAM to pool_cap*128 KB); beyond that, steal a buffer from // another released slot so the pool never grows past the cap. If neither works // (fragmented/OOM on a cold cache) skip this tile — it retries next render, and // once a buffer is grabbed it sticks (reused forever), so coverage heals. if (!dst->rgb565) { int bufs = 0; for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) if (s_map_tiles[_ti].rgb565) ++bufs; if (bufs < pool_cap) { dst->rgb565 = (uint8_t*)lvglPsramAlloc(kTileBufBytes); } else { for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) { MapTile& s = s_map_tiles[_ti]; if (!s.in_use && s.rgb565 && &s != dst) { lv_img_cache_invalidate_src(&s.dsc); dst->rgb565 = s.rgb565; s.rgb565 = nullptr; break; } } } } if (!dst->rgb565) { ++n_missing; continue; } // no buffer available this pass uint8_t* jpeg = nullptr; size_t jlen = 0; bool repairable_cache = false; if (!loadTileJpeg(s_map_zoom, wanted[i].tx, wanted[i].ty, &jpeg, &jlen, &repairable_cache)) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Tile not on disk — queue an OSM download if Wi-Fi is up. No-op // when offline; if/when Wi-Fi comes up later, the next render // pass will queue again. queueTileForFetch(s_map_zoom, wanted[i].tx, wanted[i].ty); #endif ++n_missing; continue; } int dw = 0, dh = 0; // Decode straight INTO the slot's persistent 128 KB buffer (dst->rgb565) — no // per-tile malloc, so PSRAM never fragments. PNG (0x89 'P' 'N' 'G') → lodepng // (LVGL's lv_png is broken here); else the DIRECT tjpgd path (streams MCUs into // the buffer through a shared 4 KB work pool). 256 px cap == native tile size. uint8_t* rgb = (jlen >= 4 && jpeg[0] == 0x89 && jpeg[1] == 'P' && jpeg[2] == 'N' && jpeg[3] == 'G') ? decodePngToRgb565(jpeg, jlen, &dw, &dh, dst->rgb565, kTileBufBytes) : decodeJpegScaledToRgb565(jpeg, jlen, &dw, &dh, 256, dst->rgb565, kTileBufBytes); lvglPsramFree(jpeg); if (!rgb) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) if (repairable_cache) { char bad_path[48]; snprintf(bad_path, sizeof(bad_path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)s_map_zoom, (long)wanted[i].tx, (long)wanted[i].ty); tileCacheRemove(bad_path); tileFetchForget(s_map_zoom, wanted[i].tx, wanted[i].ty); queueTileForFetch(s_map_zoom, wanted[i].tx, wanted[i].ty); } #endif ++n_missing; continue; // decode failed; buffer kept for reuse } // Night mode: invert the decoded RGB565 in place (render-only — the on-disk // tile is untouched). ~p flips all three channels; light maps go dark. if (s_map_night) { uint16_t* px = (uint16_t*)rgb; const int cnt = dw * dh; for (int p = 0; p < cnt; ++p) px[p] = (uint16_t)~px[p]; } dst->z = s_map_zoom; dst->x = wanted[i].tx; dst->y = wanted[i].ty; dst->rgb565 = rgb; // == the slot's persistent buffer (decoded in place) dst->w = dw; dst->h = dh; memset(&dst->dsc, 0, sizeof(dst->dsc)); dst->dsc.header.cf = LV_IMG_CF_TRUE_COLOR; dst->dsc.header.w = (uint32_t)dw; dst->dsc.header.h = (uint32_t)dh; dst->dsc.data = rgb; dst->dsc.data_size = (uint32_t)((size_t)dw * dh * sizeof(lv_color_t)); // Reused buffer at a stable dsc address → drop any stale cached decode keyed // to this dsc pointer before the new widget reads it. lv_img_cache_invalidate_src(&dst->dsc); dst->img = lv_img_create(parent); lv_img_set_src(dst->img, &dst->dsc); const int sx = (int)((double)wanted[i].tx * 256.0 - cwx + k_map_canvas_w / 2); const int sy = (int)((double)wanted[i].ty * 256.0 - cwy + k_map_canvas_h / 2); lv_obj_set_pos(dst->img, sx, sy); // Sink the tile to the back of the pan layer. JPEG tiles are opaque, // so without this a tile created in a later (dirty) render pass would // paint over the contact / self markers that were created earlier on // the same layer — they'd vanish behind the map. Tiles never overlap // each other (grid), so their relative order doesn't matter; what // matters is that ALL tiles stay below ALL markers. lv_obj_move_background(dst->img); dst->in_use = true; wanted[i].placed = true; any_loaded = true; // Progressive render: paint this tile before loading the next one, so the // map fills in tile-by-tile instead of all at once. BUT skip it while a // download is in flight: on the immersive full-screen map each lv_refr_now // also alpha-composites the (transparent) status + tab bars over the whole // screen, which is heavy on internal DMA RAM — doing that per-tile WHILE the // Wi-Fi worker holds a socket pushed the heap into an OOM reboot. When tiles // are arriving from the network, let LVGL batch one redraw at the end. if (tileFetchPendingLoad() == 0) lv_refr_now(NULL); // Yield to the IDLE task after each tile's read + decode + paint. This loop // runs on loopTask, which is what the task watchdog watches via IDLE1; a full // 9-tile load (or a few slow SD reads) can otherwise pin the core long enough // to starve IDLE1 and panic (the loopTask Task-WDT reboot this fixes — the // download worker already does the same vTaskDelay(1) on its side). vTaskDelay(1); } // Diagnostics: how many of the wanted tiles ended up with a live img widget // (kept from a prior pass OR freshly decoded). placed == want but a blank // screen ⇒ paint/z-order bug; placed < want ⇒ tiles missing from disk/decode. { int placed = 0; for (int i = 0; i < n_wanted; ++i) if (wanted[i].placed) ++placed; s_tile_dec_ok = placed; } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Warm the min/max zoom cache for this location (overview + detail) in the // background. Queued after the visible tiles above so they keep priority. queueZoomPackForCenter(); #endif // ---- Status overlay ---- // Cases: // • some tiles rendered (any_loaded) → map is usable; hide the overlay. // Missing edge tiles just fill in as they download. // • nothing rendered (panned into an un-tiled area) → show a clear, // human message that depends on whether we can actually fetch. s_map_has_pack = any_loaded; // Remember the gap count so the bottom info bar can show a compact // "downloading" / "Wi-Fi off" hint even when the map is partially loaded // (any_loaded true) — that's the common "panned toward the edge of my // saved area" case where a big centered overlay would be too intrusive. s_map_last_missing = n_missing; bool wifi_up = false; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) wifi_up = (WiFi.status() == WL_CONNECTED); #endif if (!s_map_status_lbl) return; if (any_loaded) { lv_obj_add_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); return; } #if defined(ESP32) #if CAP_SD || defined(TLORA_PAGER) // s_map_style == 0: SD packs are OSM-only. In topo the loader reads the // online /tiles/topo cache, so pointing the user at /maps/osm would be wrong // and would hide the download progress/diagnostics below. if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { lv_label_set_text(s_map_status_lbl, TR("Map tiles: microSD\n\n" "/maps/osm/z/x/y.png\n" "(or /tiles/z/x/y.jpg)\n\n" "Map appears when a tile\nfor this area is found.")); } else #endif if (!s_tiles_fs_ready) { #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) // Pager included: under the launcher there's no "tiles" partition, so point the user at the // microSD fallback rather than the (launcher-wrong) "reflash the tiles partition" advice. // M9 included: its cache PREFERS the built-in 16 GB microSD (every unit ships with one), so // this state almost always means the card didn't mount — reflash advice would be the wrong fix. if (SD.cardType() != CARD_NONE) lv_label_set_text(s_map_status_lbl, TR("Map storage error.\n\nSD card detected but the\ntile cache didn't mount.\nReboot to retry.")); else lv_label_set_text(s_map_status_lbl, TR("No map storage.\n\nInsert an SD card to cache\nWi-Fi tiles (or reflash to\nrestore the tiles partition).")); #elif defined(HAS_TANMATSU) // Tanmatsu has no tiles partition by design (see the FFat fallback in begin()). // Never tell the user to "reflash the partition" — the map runs network-only. if (wifi_up) lv_label_set_text(s_map_status_lbl, TR("Loading map tiles\xe2\x80\xa6\n\nKeep Wi-Fi connected.\nTiles appear as they arrive.")); else lv_label_set_text(s_map_status_lbl, TR("No map tiles here.\n\nConnect to Wi-Fi (Settings \xe2\x86\x92 Wi-Fi)\nto download this area.")); #else lv_label_set_text(s_map_status_lbl, TR("Map storage error.\nReflash the tiles partition.")); #endif } else if (wifi_up) { // Wi-Fi up: the missing tiles were just queued for download. Reassure // the user it's working — the screen repaints (s_tile_fetch_dirty) as // tiles land. We also surface the fetch counters + last HTTP code here: // serial is unreadable on this build (companion protocol owns the UART), // so this on-screen line is the only window into whether fetches actually // SUCCEED. "ok" climbing = tiles are being written; "fail" climbing with a // non-200 "http" (e.g. 301 = HTTP→HTTPS redirect the device can't follow, // 403/-1 = blocked/connect fail) means the server/proxy path is the problem, // not the on-device renderer. #if defined(MULTI_TRANSPORT_COMPANION) char dl[288]; snprintf(dl, sizeof dl, "Downloading map tiles\xe2\x80\xa6 (%s)\n" "ok %u fail %u http %d wr %c\n" "open-fail %u short-wr %u\n\n" "Keep Wi-Fi connected.\nTiles appear as they arrive.", #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) (s_tile_fs == &SD_MMC ? "SD cache" : "flash cache"), #else #if CAP_SD || defined(TLORA_PAGER) (s_tile_fs == &SD ? "SD cache" : "flash cache"), #else (s_tile_root[0] ? "SD cache" : "flash cache"), #endif #endif (unsigned)s_tile_fetch_ok, (unsigned)s_tile_fetch_failed, (int)s_tile_fetch_last_code, (char)s_tile_fetch_last_wr, (unsigned)s_tile_fetch_open_fail, (unsigned)s_tile_fetch_short_wr); lv_label_set_text(s_map_status_lbl, dl); #else lv_label_set_text(s_map_status_lbl, TR("Downloading map tiles\xe2\x80\xa6\n\n" "Keep Wi-Fi connected.\nTiles appear as they arrive\n" "and are saved for offline use.")); #endif } else { // No Wi-Fi and no saved tiles here — be explicit about the fix. lv_label_set_text(s_map_status_lbl, TR("No saved map tiles here.\n\n" "Connect to Wi-Fi (Settings \xe2\x86\x92 Wi-Fi)\n" "to download this area.\n" "Saved tiles stay available offline.")); } #else lv_label_set_text(s_map_status_lbl, TR("No tiles (non-ESP32)")); #endif lv_obj_clear_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); } // ----- Markers ----- // // Self marker = small white crosshair; contact markers = colored dots // sized for finger taps. Plotted as children of s_map_canvas so they // stack on top of the tile layer. The cache below is flat (linear scan // when handling marker taps) — fine for at-most-32 visible markers. struct MapMarker { int mesh_idx; // ContactInfo index for real contacts; -1 for self lv_obj_t* obj; }; // Was 32, which with slot 0 reserved for self meant only 31 contact dots could ever // render while the bottom-right label counted every positioned contact with no cap at // all. Past that the map silently stopped drawing and the label kept counting, so the // two disagreed with nothing on screen to say why (reported as "new contacts never // appear on the map, but the number is right"). Markers are only built for contacts // INSIDE the viewport (see the cull in the fill loop), so this is the number visible // at once, not the contact count. // Hard ceiling on simultaneously-drawn contact dots. Was 32, which with slot 0 reserved // for self meant only 31 could ever render while the bottom-right label counted every // positioned contact with no cap at all — so past 31 the map silently stopped drawing // and the label kept counting, with nothing on screen to explain the difference // (reported as "new contacts never appear on the map, but the number is right"). // Markers are only built for contacts INSIDE the viewport, so this is how many are // visible at once, not the contact count. Costs 8 bytes of static RAM per slot. #if CAP_LUA_SDK_EXT // --------------------------------------------------------------------------- // App map views (wada.map) -- a real slippy map an app can put on its page. // // Apps could previously draw geography only onto their own canvas, from // scratch, with no basemap. This gives them the firmware's actual tiles, // projection and cache. // // It does NOT share the map tab's tile pool. That pool is sized and culled for // a full-screen viewport and is rebuilt on pan/zoom; borrowing slots from it // would mean an app and the map tab evicting each other's tiles at whatever // rate they happened to redraw. A view owns a small pool of its own instead, // capped at kAppMapTilesMax so the cost is bounded and predictable: 128 KB per // tile, exactly like the map tab, and freed the moment the view is collected. static const int kAppMapTilesMax = 4; static const size_t kAppMapTileBytes = 256u * 256u * sizeof(lv_color_t); struct AppMapTile { uint8_t z; int32_t x, y; uint8_t* rgb = nullptr; int w = 0, h = 0; lv_img_dsc_t dsc; lv_obj_t* img = nullptr; bool in_use = false; }; struct AppMapView { lv_obj_t* cont = nullptr; // clips to the app's requested rectangle lv_obj_t* tiles = nullptr; // tile images lv_obj_t* ovl = nullptr; // markers + lines, always above the tiles int w = 0, h = 0; double lat = 0, lon = 0; uint8_t zoom = 13; int placed = 0; // tiles actually drawn at the last render AppMapTile pool[kAppMapTilesMax]; }; static void appMapFreeTile(AppMapTile& t) { if (t.img) { lv_obj_del(t.img); t.img = nullptr; } if (t.rgb) { lvglPsramFree(t.rgb); t.rgb = nullptr; } t.in_use = false; } void* luaHostMapCreate(int x, int y, int w, int h) { if (!g_lv.task) return nullptr; AppMapView* v = new (std::nothrow) AppMapView(); if (!v) return nullptr; v->w = w; v->h = h; extern lv_obj_t* luaHostAppBody(); lv_obj_t* body = luaHostAppBody(); if (!body) { delete v; return nullptr; } v->cont = lv_obj_create(body); lv_obj_remove_style_all(v->cont); lv_obj_set_pos(v->cont, (lv_coord_t)x, (lv_coord_t)y); lv_obj_set_size(v->cont, (lv_coord_t)w, (lv_coord_t)h); lv_obj_set_style_bg_color(v->cont, lv_color_hex(0x11161B), LV_PART_MAIN); lv_obj_set_style_bg_opa(v->cont, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(v->cont, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(v->cont, LV_OBJ_FLAG_CLICKABLE); // Two layers so overlays can never be painted over by a tile that happens to // be created later -- the exact bug the map tab fixes with move_background. v->tiles = lv_obj_create(v->cont); lv_obj_remove_style_all(v->tiles); lv_obj_set_pos(v->tiles, 0, 0); lv_obj_set_size(v->tiles, (lv_coord_t)w, (lv_coord_t)h); lv_obj_clear_flag(v->tiles, LV_OBJ_FLAG_SCROLLABLE); v->ovl = lv_obj_create(v->cont); lv_obj_remove_style_all(v->ovl); lv_obj_set_pos(v->ovl, 0, 0); lv_obj_set_size(v->ovl, (lv_coord_t)w, (lv_coord_t)h); lv_obj_clear_flag(v->ovl, LV_OBJ_FLAG_SCROLLABLE); return v; } void luaHostMapDestroy(void* vp) { AppMapView* v = (AppMapView*)vp; if (!v) return; for (int i = 0; i < kAppMapTilesMax; i++) appMapFreeTile(v->pool[i]); if (v->cont) lv_obj_del(v->cont); // takes the two layers and every overlay with it delete v; } void luaHostMapSet(void* vp, double lat, double lon, int zoom) { AppMapView* v = (AppMapView*)vp; if (!v) return; v->lat = lat; v->lon = lon; if (zoom >= 1 && zoom <= 19) v->zoom = (uint8_t)zoom; } // Zoom alone. Separate from the call above because 0,0 is a real coordinate, // so there is no lat/lon value that can mean "leave the centre alone". void luaHostMapSetZoom(void* vp, int zoom) { AppMapView* v = (AppMapView*)vp; if (v && zoom >= 1 && zoom <= 19) v->zoom = (uint8_t)zoom; } int luaHostMapZoom(void* vp) { AppMapView* v = (AppMapView*)vp; return v ? v->zoom : 0; } int luaHostMapPlaced(void* vp) { AppMapView* v = (AppMapView*)vp; return v ? v->placed : 0; } // lat/lon -> pixel within the view. Deliberately NOT clamped: an app placing // its own labels needs to know a point is off-view, and a clamped coordinate // would silently pile everything onto the edge. void luaHostMapToScreen(void* vp, double lat, double lon, int* px, int* py) { AppMapView* v = (AppMapView*)vp; if (!v) { *px = *py = 0; return; } double cwx, cwy, wx, wy; latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy); latLonToWorldPx(lat, lon, v->zoom, &wx, &wy); *px = (int)lround(wx - cwx + v->w / 2.0); *py = (int)lround(wy - cwy + v->h / 2.0); } void luaHostMapToLatLon(void* vp, int px, int py, double* lat, double* lon) { AppMapView* v = (AppMapView*)vp; if (!v) { *lat = *lon = 0; return; } double cwx, cwy; latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy); worldPxToLatLon(cwx + (px - v->w / 2.0), cwy + (py - v->h / 2.0), v->zoom, lat, lon); } void luaHostMapClearOverlay(void* vp) { AppMapView* v = (AppMapView*)vp; if (v && v->ovl) lv_obj_clean(v->ovl); } int luaHostMapMarker(void* vp, double lat, double lon, uint32_t color, int size) { AppMapView* v = (AppMapView*)vp; if (!v || !v->ovl) return 0; int px, py; luaHostMapToScreen(vp, lat, lon, &px, &py); if (size < 3) size = 3; if (size > 24) size = 24; // Off-view markers are dropped rather than created off-screen: they would be // invisible LVGL objects accumulating on every redraw of a panning map. if (px < -size || py < -size || px > v->w + size || py > v->h + size) return 0; lv_obj_t* d = lv_obj_create(v->ovl); lv_obj_remove_style_all(d); lv_obj_set_size(d, size, size); lv_obj_set_pos(d, px - size / 2, py - size / 2); lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(d, lv_color_hex(color), LV_PART_MAIN); lv_obj_set_style_bg_opa(d, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(d, 1, LV_PART_MAIN); lv_obj_set_style_border_color(d, lv_color_hex(0x0E1216), LV_PART_MAIN); lv_obj_clear_flag(d, LV_OBJ_FLAG_SCROLLABLE); return 1; } int luaHostMapLine(void* vp, double la1, double lo1, double la2, double lo2, uint32_t color, int width) { AppMapView* v = (AppMapView*)vp; if (!v || !v->ovl) return 0; int x1, y1, x2, y2; luaHostMapToScreen(vp, la1, lo1, &x1, &y1); luaHostMapToScreen(vp, la2, lo2, &x2, &y2); // Both ends off the same side means the segment cannot cross the view. if ((x1 < 0 && x2 < 0) || (y1 < 0 && y2 < 0) || (x1 > v->w && x2 > v->w) || (y1 > v->h && y2 > v->h)) return 0; // LVGL does not copy a line's point array, so it has to outlive the object. // Attaching it as the object's user_data and freeing it on DELETE keeps the // lifetime tied to the widget instead of to a fixed slot table. lv_point_t* pts = (lv_point_t*)lv_mem_alloc(sizeof(lv_point_t) * 2); if (!pts) return 0; pts[0].x = (lv_coord_t)x1; pts[0].y = (lv_coord_t)y1; pts[1].x = (lv_coord_t)x2; pts[1].y = (lv_coord_t)y2; lv_obj_t* ln = lv_line_create(v->ovl); lv_line_set_points(ln, pts, 2); lv_obj_set_user_data(ln, pts); lv_obj_add_event_cb(ln, [](lv_event_t* e) { void* p = lv_obj_get_user_data(lv_event_get_target(e)); if (p) lv_mem_free(p); }, LV_EVENT_DELETE, nullptr); lv_obj_set_style_line_color(ln, lv_color_hex(color), LV_PART_MAIN); lv_obj_set_style_line_width(ln, width < 1 ? 1 : (width > 8 ? 8 : width), LV_PART_MAIN); lv_obj_set_style_line_opa(ln, LV_OPA_COVER, LV_PART_MAIN); return 1; } // Render the tiles covering the view. Same slot-reuse idea as the map tab: a // small pan shares most tiles, so only newcomers are read and decoded. void luaHostMapRender(void* vp) { AppMapView* v = (AppMapView*)vp; if (!v || !v->tiles) return; double cwx, cwy; latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy); const int32_t ctx = (int32_t)floor(cwx / 256.0); const int32_t cty = (int32_t)floor(cwy / 256.0); const int rx = (v->w / 2 + 255) / 256; // tiles needed each side to cover the view const int ry = (v->h / 2 + 255) / 256; struct Want { int32_t tx, ty; double d2; bool placed; }; Want cand[25]; int nc = 0; for (int dy = -ry; dy <= ry; dy++) { for (int dx = -rx; dx <= rx; dx++) { if (nc >= (int)(sizeof cand / sizeof cand[0])) break; const int32_t tx = ctx + dx, ty = cty + dy; // Cull to what actually touches the view, so the pool is spent on tiles // the user can see rather than on the corners of an oversized grid. const double sx = (double)tx * 256.0 - cwx + v->w / 2.0; const double sy = (double)ty * 256.0 - cwy + v->h / 2.0; if (sx > v->w || sy > v->h || sx + 256 < 0 || sy + 256 < 0) continue; // Distance from the tile's centre to the view's, so that when a view is // bigger than the pool can cover we keep the MOST VISIBLE tiles rather // than whichever the scan happened to reach first -- that ordering put // the gap in the middle of the view instead of at its edge. const double mx = sx + 128.0 - v->w / 2.0, my = sy + 128.0 - v->h / 2.0; cand[nc++] = { tx, ty, mx * mx + my * my, false }; } } for (int a = 1; a < nc; a++) { // insertion sort; nc is at most 25 Want key = cand[a]; int b = a - 1; while (b >= 0 && cand[b].d2 > key.d2) { cand[b + 1] = cand[b]; b--; } cand[b + 1] = key; } const int nw = nc < kAppMapTilesMax ? nc : kAppMapTilesMax; Want* wanted = cand; for (int i = 0; i < kAppMapTilesMax; i++) { // keep matches, free the rest AppMapTile& t = v->pool[i]; if (!t.in_use) continue; bool keep = false; for (int k = 0; k < nw; k++) { if (t.z == v->zoom && t.x == wanted[k].tx && t.y == wanted[k].ty) { keep = true; wanted[k].placed = true; lv_obj_set_pos(t.img, (lv_coord_t)((double)t.x * 256.0 - cwx + v->w / 2.0), (lv_coord_t)((double)t.y * 256.0 - cwy + v->h / 2.0)); break; } } if (!keep) appMapFreeTile(t); } v->placed = 0; for (int k = 0; k < nw; k++) { if (wanted[k].placed) { v->placed++; continue; } AppMapTile* dst = nullptr; for (int i = 0; i < kAppMapTilesMax; i++) if (!v->pool[i].in_use) { dst = &v->pool[i]; break; } if (!dst) break; uint8_t* enc = nullptr; size_t elen = 0; bool repairable = false; if (!loadTileJpeg(v->zoom, wanted[k].tx, wanted[k].ty, &enc, &elen, &repairable) || !enc) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) queueTileForFetch(v->zoom, wanted[k].tx, wanted[k].ty); // fills in if Wi-Fi is up #endif continue; } if (!dst->rgb) dst->rgb = (uint8_t*)lvglPsramAlloc(kAppMapTileBytes); if (!dst->rgb) { lvglPsramFree(enc); break; } int dw = 0, dh = 0; uint8_t* rgb = (elen >= 4 && enc[0] == 0x89 && enc[1] == 'P' && enc[2] == 'N' && enc[3] == 'G') ? decodePngToRgb565(enc, elen, &dw, &dh, dst->rgb, kAppMapTileBytes) : decodeJpegScaledToRgb565(enc, elen, &dw, &dh, 256, dst->rgb, kAppMapTileBytes); lvglPsramFree(enc); if (!rgb) continue; if (s_map_night) { // follow the user's map setting rather than inventing a second one uint16_t* px = (uint16_t*)rgb; const int cnt = dw * dh; for (int p = 0; p < cnt; ++p) px[p] = (uint16_t)~px[p]; } dst->z = v->zoom; dst->x = wanted[k].tx; dst->y = wanted[k].ty; dst->w = dw; dst->h = dh; memset(&dst->dsc, 0, sizeof(dst->dsc)); dst->dsc.header.cf = LV_IMG_CF_TRUE_COLOR; dst->dsc.header.w = (uint32_t)dw; dst->dsc.header.h = (uint32_t)dh; dst->dsc.data = rgb; dst->dsc.data_size = (uint32_t)((size_t)dw * dh * sizeof(lv_color_t)); lv_img_cache_invalidate_src(&dst->dsc); // the buffer is reused at a stable address dst->img = lv_img_create(v->tiles); lv_img_set_src(dst->img, &dst->dsc); lv_obj_set_pos(dst->img, (lv_coord_t)((double)dst->x * 256.0 - cwx + v->w / 2.0), (lv_coord_t)((double)dst->y * 256.0 - cwy + v->h / 2.0)); dst->in_use = true; v->placed++; // Same watchdog courtesy as the map tab: read + decode on the loop task can // pin the core long enough to starve IDLE if several tiles land at once. vTaskDelay(1); } if (v->ovl) lv_obj_move_foreground(v->ovl); } #endif // CAP_LUA_SDK_EXT constexpr int k_map_markers_max = 256; // The self->contact link LINES keep their own, much smaller ceiling: each one holds a // persistent 2-point array LVGL does not copy, so they cost 12 bytes a slot rather than // 8, and a screen with hundreds of dotted lines on it is unreadable long before it is // slow. Raising the dot ceiling deliberately does not drag this up with it. constexpr int k_map_links_max = 32; // How many contact dots the last rebuild actually drew, and how many wanted to be // drawn. The status label reports the shortfall instead of hiding it. static int s_map_markers_drawn = 0; static int s_map_markers_wanted = 0; static MapMarker s_map_markers[k_map_markers_max] = {}; // Effective dot limit: the user's setting (Map settings), clamped to the array. 0 in // the setting means "no limit", i.e. everything in view up to the firmware ceiling. static int mapMarkerCap() { #if defined(ESP32) const uint16_t want = touchPrefsGetMapMarkerCap(); if (want == 0 || want > (uint16_t)(k_map_markers_max - 1)) return k_map_markers_max - 1; return (int)want; #else return k_map_markers_max - 1; #endif } // Dotted self->contact link lines (toggled by the on-map button). Each line // keeps its own persistent 2-point array — LVGL does not copy the points. static bool s_map_show_links = true; static lv_obj_t* s_map_link_objs[k_map_links_max] = {}; static lv_point_t s_map_link_pts[k_map_links_max][2]; // Per-element visibility of the map's on-screen text/markers (persisted; coords + // contacts default shown, the tile line default hidden since prefs v50). Coords = // bottom-left read-out, TileXYZ = the zoom + tile path line, Contacts = the contact markers. static bool s_map_show_coords = true; static bool s_map_show_tilexyz = false; static bool s_map_show_contacts = true; static bool s_map_tile_debug = false; // developer: tile-pipeline diagnostic overlay on the zoom line (off by default) static bool s_map_direct_only = false; // when true, only 0-hop (directly-heard) contacts appear // Apply the coords/tile-line visibility flags to the two corner labels. Safe to // call before the labels exist (null-guarded); invoked at map build + on toggle. static void applyMapTextVis() { auto vis = [](lv_obj_t* o, bool show) { if (!o) return; if (show) lv_obj_clear_flag(o, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN); }; vis(s_map_info_lbl, s_map_show_coords); vis(s_map_zoom_lbl, s_map_show_tilexyz); } // ----- Route replay overlay ----- // "Replay" in a message's Info popup resolves that message's repeater hops to // their advertised positions and animates them onto the map as a path, one node // at a time. CHAIN mode (received floods) links consecutive hops sender->us; // STAR mode (our own sent floods) links each repeater that echoed us back to // "me". All overlay widgets live on the map pan layer and are freed by // freeMapMarkers() at the start of the next render. struct RouteNode { double lat, lon; bool has_pos; char tag[6]; char name[36]; char id[9]; }; static constexpr int k_route_max = 10; static RouteNode* s_route = (RouteNode*)psAlloc(sizeof(RouteNode) * k_route_max); // PSRAM (0.7 KB off internal .bss) static int s_route_n = 0; // nodes captured static int s_route_reveal = 0; // nodes shown so far (animation cursor) static bool s_route_active = false; // overlay currently shown static uint8_t s_route_mode = 0; // 0 = chain, 1 = star static lv_timer_t* s_route_timer = nullptr; static lv_obj_t* s_route_objs[k_route_max * 3] = {nullptr}; // lines + dots + tags static int s_route_obj_n = 0; static lv_point_t s_route_seg_pts[k_route_max][2]; // persistent (lv_line keeps the ptr) static void drawRouteOverlay(lv_obj_t* parent, double cwx, double cwy); // Route-replay HUD: top banner with the current hop's repeater name + identifier, // plus a replay button. Lives on lv_layer_top while a route is shown on the map. static lv_obj_t* s_route_hud = nullptr; static lv_obj_t* s_route_hud_lbl = nullptr; static void routeReplayTick(lv_timer_t* t); static void routeHudUpdate(); static void showRouteHud(); static void hideRouteHud(); // ---- Discover wardriving coverage overlay (drawn on the map pan layer) ---- // Plots the Discover app's logged coverage samples (s_disc_track[], populated in discoverWardriveTick) // as signal-coloured dots — a personal RF-coverage map: a 0-hop hit means "reachable from that point", // so green = strong contact, red = weak. Object pool freed alongside the other map markers. static lv_obj_t* s_disc_map_objs[k_disc_track_max] = {nullptr}; static int s_disc_map_obj_n = 0; static void discoverFreeMapObjs() { for (int i = 0; i < s_disc_map_obj_n; ++i) if (s_disc_map_objs[i]) { lv_obj_del(s_disc_map_objs[i]); s_disc_map_objs[i] = nullptr; } s_disc_map_obj_n = 0; } static void discoverDrawCoverage(lv_obj_t* parent, double cwx, double cwy) { const int cap = (int)(sizeof(s_disc_map_objs) / sizeof(s_disc_map_objs[0])); const int total = s_disc_track_n < k_disc_track_max ? s_disc_track_n : k_disc_track_max; for (int k = 0; k < total && s_disc_map_obj_n < cap; ++k) { const DiscTrackPt& p = s_disc_track[k]; double mwx, mwy; latLonToWorldPx((double)p.lat_e6 / 1e6, (double)p.lon_e6 / 1e6, s_map_zoom, &mwx, &mwy); const int sx = (int)(mwx - cwx + k_map_canvas_w / 2); const int sy = (int)(mwy - cwy + k_map_canvas_h / 2); if (sx < -4 || sx >= k_map_canvas_w + 4 || sy < -4 || sy >= k_map_canvas_h + 4) continue; int r = p.best_rssi; if (r > -50) r = -50; if (r < -110) r = -110; // clamp to the colour range int g = (r + 110) * 255 / 60; // 0 (weak) .. 255 (strong) uint32_t col = ((uint32_t)(255 - g) << 16) | ((uint32_t)g << 8); // red -> green lv_obj_t* d = lv_obj_create(parent); lv_obj_remove_style_all(d); lv_obj_set_size(d, 8, 8); lv_obj_set_pos(d, sx - 4, sy - 4); lv_obj_set_style_bg_color(d, lv_color_hex(col), LV_PART_MAIN); lv_obj_set_style_bg_opa(d, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_border_color(d, lv_color_hex(0x101010), LV_PART_MAIN); lv_obj_set_style_border_width(d, 1, LV_PART_MAIN); lv_obj_set_style_radius(d, 4, LV_PART_MAIN); lv_obj_clear_flag(d, LV_OBJ_FLAG_CLICKABLE); s_disc_map_objs[s_disc_map_obj_n++] = d; } } // Discover page's "Show on map": centre the map on our current position + open the Map tab, where // the coverage overlay draws. Keeps the centre (s_map_view_inited) so onMapTabActivated won't snap back. static void discoverJumpToMapHere() { UITask* task = g_lv.task; if (task && task->getGpsFix()) { s_map_center_lat = task->getNodeLat(); s_map_center_lon = task->getNodeLon(); s_map_view_inited = true; } closeDiscoverPage(); goToTab(MAP_TAB_INDEX); } static void freeMapMarkers() { for (auto& m : s_map_markers) { if (m.obj) { lv_obj_del(m.obj); m.obj = nullptr; } m.mesh_idx = -2; // "slot empty" sentinel } for (auto& ln : s_map_link_objs) { if (ln) { lv_obj_del(ln); ln = nullptr; } } for (int i = 0; i < s_route_obj_n; ++i) { if (s_route_objs[i]) { lv_obj_del(s_route_objs[i]); s_route_objs[i] = nullptr; } } s_route_obj_n = 0; discoverFreeMapObjs(); } static void openMarkerPopupForContact(int mesh_idx); static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const char* name, bool from_map); static void openMapPicker(const int* idxs, int n); // (onMapMarkerClickedCb removed — marker taps are now dispatched centrally // from the canvas's RELEASED handler in mapCanvasEventCb. See the marker // scan in the tap branch below.) // Plot self + contacts onto the canvas at their lat/lon. Called every time // tiles are re-rendered (pan, zoom, recenter, tab open) so markers stay // pinned to the right pixel for the current center. static void renderMapMarkers() { freeMapMarkers(); if (!s_map_canvas) return; if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) return; // Markers share the pan layer with tiles so live-pan slides everything // together with a single set_pos. lv_obj_t* parent = s_map_pan_layer ? s_map_pan_layer : s_map_canvas; double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); // Self screen position (computed even if off-canvas, so link lines from an // off-screen self still point the right way; clipped to the canvas by LVGL). bool self_has = false; int self_sx = 0, self_sy = 0; if (g_lv.task) { const double self_lat = g_lv.task->getNodeLat(); const double self_lon = g_lv.task->getNodeLon(); if (self_lat != 0.0 || self_lon != 0.0) { double swx, swy; latLonToWorldPx(self_lat, self_lon, s_map_zoom, &swx, &swy); self_sx = (int)(swx - cwx + k_map_canvas_w / 2); self_sy = (int)(swy - cwy + k_map_canvas_h / 2); self_has = true; } } // ---- Dotted links from self to each on-screen contact (drawn first so the // markers sit on top). Toggled by the on-map links button. if (s_map_show_links && s_map_show_contacts && self_has) { // links only make sense when contacts are shown auto clampc = [](int v) -> lv_coord_t { if (v < -2000) v = -2000; if (v > 2000) v = 2000; return (lv_coord_t)v; }; int link_n = 0; for (uint32_t i = 0; i < the_mesh.getNumContacts() && link_n < k_map_links_max; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (c.gps_lat == 0 && c.gps_lon == 0) continue; if (s_map_direct_only && c.out_path_len != 0) continue; double mwx, mwy; latLonToWorldPx((double)c.gps_lat / 1.0e6, (double)c.gps_lon / 1.0e6, s_map_zoom, &mwx, &mwy); const int sx = (int)(mwx - cwx + k_map_canvas_w / 2); const int sy = (int)(mwy - cwy + k_map_canvas_h / 2); if (sx < -8 || sx >= k_map_canvas_w + 8 || sy < -8 || sy >= k_map_canvas_h + 8) continue; s_map_link_pts[link_n][0].x = clampc(self_sx); s_map_link_pts[link_n][0].y = clampc(self_sy); s_map_link_pts[link_n][1].x = clampc(sx); s_map_link_pts[link_n][1].y = clampc(sy); lv_obj_t* ln = lv_line_create(parent); lv_line_set_points(ln, s_map_link_pts[link_n], 2); // Solid (not dashed): LVGL 8.3's SW renderer doesn't reliably dash // diagonal lines — a thin, semi-transparent line reads as a "link". lv_obj_set_style_line_width(ln, 2, LV_PART_MAIN); lv_obj_set_style_line_color(ln, lv_color_hex(0x6FA8DA), LV_PART_MAIN); lv_obj_set_style_line_opa(ln, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_line_rounded(ln, true, LV_PART_MAIN); lv_obj_clear_flag(ln, LV_OBJ_FLAG_CLICKABLE); s_map_link_objs[link_n] = ln; ++link_n; } } // ---- Self marker — crosshair (on top of the links). if (self_has && self_sx >= -10 && self_sx < k_map_canvas_w + 10 && self_sy >= -10 && self_sy < k_map_canvas_h + 10) { MapMarker& m = s_map_markers[0]; m.mesh_idx = -1; m.obj = lv_label_create(parent); lv_label_set_text(m.obj, LV_SYMBOL_GPS); lv_obj_set_style_text_font(m.obj, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(m.obj, lv_color_hex(0xFFFFFF), LV_PART_MAIN); // The glyph's optical center isn't at its bbox center — fudge the // align slightly so the crosshair lines up with the tile pixel. lv_obj_set_pos(m.obj, self_sx - 8, self_sy - 11); } // ---- Contact markers — colored circles sized for taps (14×14 with a // 2-px black border so they read on any tile background). int slot = 1; // slot 0 reserved for self s_map_markers_wanted = 0; // Contact markers — skipped entirely when "Contacts on map" is off (self stays). const int marker_cap = mapMarkerCap(); for (uint32_t i = 0; s_map_show_contacts && i < the_mesh.getNumContacts(); ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (c.gps_lat == 0 && c.gps_lon == 0) continue; if (s_map_direct_only && c.out_path_len != 0) continue; const double lat = (double)c.gps_lat / 1.0e6; const double lon = (double)c.gps_lon / 1.0e6; double mwx, mwy; latLonToWorldPx(lat, lon, s_map_zoom, &mwx, &mwy); const int sx = (int)(mwx - cwx + k_map_canvas_w / 2); const int sy = (int)(mwy - cwy + k_map_canvas_h / 2); // Cull off-screen markers — they'd just allocate widgets we never see. if (sx < -8 || sx >= k_map_canvas_w + 8 || sy < -8 || sy >= k_map_canvas_h + 8) continue; // Past the cull this contact is positioned AND on screen, so it wanted a dot. // Count it either way — the status label reports the shortfall rather than // letting the map and the number disagree in silence. s_map_markers_wanted++; if (slot >= marker_cap + 1) continue; // +1: slot 0 is self // Color by contact type so a glance at the map maps to the chip // colors on the Contacts tab. Defaults to chat-peer orange. uint32_t color = 0xFF6F4D; if (c.type == ADV_TYPE_REPEATER) color = 0x4DA8FF; else if (c.type == ADV_TYPE_ROOM) color = 0xC9A24A; MapMarker& m = s_map_markers[slot]; m.mesh_idx = (int)i; m.obj = lv_obj_create(parent); lv_obj_remove_style_all(m.obj); lv_obj_set_size(m.obj, 14, 14); lv_obj_set_pos(m.obj, sx - 7, sy - 7); lv_obj_set_style_bg_color(m.obj, lv_color_hex(color), LV_PART_MAIN); lv_obj_set_style_bg_opa(m.obj, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(m.obj, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_border_width(m.obj, 2, LV_PART_MAIN); lv_obj_set_style_radius(m.obj, 7, LV_PART_MAIN); // Markers are NOT clickable themselves — taps go to the canvas, whose // RELEASED handler scans all markers within ~16 px of the tap point. // This makes overlapping markers selectable (disambiguation sheet) and // also lets panning that starts on a marker fall through to the canvas // pan handler instead of being swallowed by the marker. lv_obj_clear_flag(m.obj, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(m.obj, LV_OBJ_FLAG_SCROLLABLE); ++slot; } s_map_markers_drawn = slot - 1; // slot 0 is self // Discover wardriving coverage dots (my logged signal samples), under the route overlay. if (s_disc_track_n > 0) discoverDrawCoverage(parent, cwx, cwy); // Route-replay overlay — drawn last so the path + nodes sit above tiles, // link lines and contact markers. if (s_route_active) drawRouteOverlay(parent, cwx, cwy); } // ===== Route replay overlay ================================================= // Paint the revealed portion of s_route[] onto the pan layer: amber segments // plus a numbered dot at each repeater. The "ME" node is left to the existing // GPS crosshair. Objects are tracked in s_route_objs[] and freed by the next // freeMapMarkers(). static void drawRouteOverlay(lv_obj_t* parent, double cwx, double cwy) { if (!s_route_active || s_route_n <= 0 || !parent) return; const int reveal = (s_route_reveal < s_route_n) ? s_route_reveal : s_route_n; int nsx[k_route_max], nsy[k_route_max]; bool nhas[k_route_max]; for (int i = 0; i < s_route_n; ++i) { nhas[i] = s_route[i].has_pos; if (!nhas[i]) continue; double wx, wy; latLonToWorldPx(s_route[i].lat, s_route[i].lon, s_map_zoom, &wx, &wy); nsx[i] = (int)(wx - cwx + k_map_canvas_w / 2); nsy[i] = (int)(wy - cwy + k_map_canvas_h / 2); } auto clampc = [](int v) -> lv_coord_t { if (v < -3000) v = -3000; if (v > 3000) v = 3000; return (lv_coord_t)v; }; auto track = [&](lv_obj_t* o) { if (o && s_route_obj_n < (int)(sizeof(s_route_objs) / sizeof(s_route_objs[0]))) s_route_objs[s_route_obj_n++] = o; }; // Segments. chain: prev->cur; star: hub(node 0)->cur. int seg = 0; for (int i = 1; i < reveal && seg < k_route_max; ++i) { const int a = (s_route_mode == 1) ? 0 : (i - 1); const int b = i; if (!nhas[a] || !nhas[b]) continue; s_route_seg_pts[seg][0].x = clampc(nsx[a]); s_route_seg_pts[seg][0].y = clampc(nsy[a]); s_route_seg_pts[seg][1].x = clampc(nsx[b]); s_route_seg_pts[seg][1].y = clampc(nsy[b]); lv_obj_t* ln = lv_line_create(parent); lv_line_set_points(ln, s_route_seg_pts[seg], 2); lv_obj_set_style_line_width(ln, 3, LV_PART_MAIN); lv_obj_set_style_line_color(ln, lv_color_hex(0xFFC233), LV_PART_MAIN); lv_obj_set_style_line_opa(ln, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_line_rounded(ln, true, LV_PART_MAIN); lv_obj_clear_flag(ln, LV_OBJ_FLAG_CLICKABLE); track(ln); ++seg; } // Numbered nodes (skip the "ME" hub — the GPS crosshair already marks self). for (int i = 0; i < reveal && i < s_route_n; ++i) { if (!nhas[i]) continue; if (s_route[i].tag[0] == 'M' && s_route[i].tag[1] == 'E') continue; if (nsx[i] < -24 || nsx[i] >= k_map_canvas_w + 24 || nsy[i] < -24 || nsy[i] >= k_map_canvas_h + 24) continue; const bool latest = (i == reveal - 1); const int sz = latest ? 18 : 14; lv_obj_t* d = lv_obj_create(parent); lv_obj_remove_style_all(d); lv_obj_set_size(d, sz, sz); lv_obj_set_pos(d, nsx[i] - sz / 2, nsy[i] - sz / 2); lv_obj_set_style_bg_color(d, lv_color_hex(latest ? 0xFFE08A : 0xFFC233), LV_PART_MAIN); lv_obj_set_style_bg_opa(d, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(d, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_border_width(d, 2, LV_PART_MAIN); lv_obj_set_style_radius(d, sz / 2, LV_PART_MAIN); lv_obj_clear_flag(d, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(d, LV_OBJ_FLAG_SCROLLABLE); track(d); lv_obj_t* tl = lv_label_create(parent); lv_label_set_text(tl, s_route[i].tag); lv_obj_set_style_text_font(tl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(tl, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_pos(tl, nsx[i] - 5, nsy[i] - 8); track(tl); } } // Center + zoom the map so every positioned route node fits the canvas — on a wide // OVERVIEW so you can see where the hops start + the whole path. Fits the hops into // ~55% of the canvas (lots of surrounding context) and caps the zoom so even a tight // cluster opens area-level, never fully zoomed in. You can pan/zoom freely after. static void fitMapToRoute() { constexpr uint8_t k_route_overview_max = 12; // never open the replay more zoomed-in than this double minlat = 90.0, maxlat = -90.0, minlon = 180.0, maxlon = -180.0; int n = 0; for (int i = 0; i < s_route_n; ++i) { if (!s_route[i].has_pos) continue; if (s_route[i].lat < minlat) minlat = s_route[i].lat; if (s_route[i].lat > maxlat) maxlat = s_route[i].lat; if (s_route[i].lon < minlon) minlon = s_route[i].lon; if (s_route[i].lon > maxlon) maxlon = s_route[i].lon; ++n; } if (n == 0) return; s_map_center_lat = (minlat + maxlat) / 2.0; s_map_center_lon = (minlon + maxlon) / 2.0; if (n == 1) { s_map_zoom = k_route_overview_max; return; } for (uint8_t z = k_map_zoom_max; ; --z) { double ax, ay, bx, by; latLonToWorldPx(maxlat, minlon, z, &ax, &ay); // NW corner latLonToWorldPx(minlat, maxlon, z, &bx, &by); // SE corner const double spanx = fabs(bx - ax), spany = fabs(by - ay); if ((spanx <= k_map_canvas_w * 0.55 && spany <= k_map_canvas_h * 0.55) || z <= k_map_zoom_min) { s_map_zoom = z; break; } } if (s_map_zoom > k_route_overview_max) s_map_zoom = k_route_overview_max; // keep a wide overview } // Reveal one more node each tick; stop (and keep the full path drawn) at the end. static void routeReplayTick(lv_timer_t* t) { (void)t; if (!s_route_active) { if (s_route_timer) { lv_timer_del(s_route_timer); s_route_timer = nullptr; } return; } if (s_route_reveal < s_route_n) { ++s_route_reveal; if (getActiveTab() == MAP_TAB_INDEX) renderMapMarkers(); routeHudUpdate(); // banner shows the hop we just revealed } if (s_route_reveal >= s_route_n) { if (s_route_timer) { lv_timer_del(s_route_timer); s_route_timer = nullptr; } } } static void clearRouteReplay() { if (s_route_timer) { lv_timer_del(s_route_timer); s_route_timer = nullptr; } const bool was = s_route_active; s_route_active = false; s_route_reveal = 0; s_route_n = 0; hideRouteHud(); if (was && getActiveTab() == MAP_TAB_INDEX) renderMapMarkers(); // drop the overlay } // Resolve a hop's hash to a repeater name (when known) + its hex identifier, for // the on-screen replay banner. Read-only lookup -> safe mid-RX. static void routeFillId(RouteNode& nd, const uint8_t* hash, uint8_t hsz) { nd.id[0] = '\0'; for (uint8_t b = 0; b < hsz && b < 4; ++b) snprintf(nd.id + b * 2, 3, "%02X", hash[b]); char nm[36]; if (the_mesh.uiHopName(hash, hsz, nm, sizeof nm)) { strncpy(nd.name, nm, sizeof(nd.name) - 1); nd.name[sizeof(nd.name) - 1] = '\0'; } else { nd.name[0] = '\0'; } } // Build s_route[] from a message's repeater hops. Returns how many nodes have a // known position (the button is only offered when >= 2). static int buildRouteFromMessage(const UITask::UIMessage& m) { s_route_n = 0; s_route_mode = 0; const double self_lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0; const double self_lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0; const bool self_has = (self_lat != 0.0 || self_lon != 0.0); const bool has_rx = (m.meta_flags & UITask::MSG_META_HAS_RX) != 0; const bool is_flood = (m.meta_flags & UITask::MSG_META_IS_FLOOD) != 0; if (!m.outgoing && has_rx && is_flood && m.in_path_n > 0) { // Received flood: chain of repeaters (sender side -> us), ending at ME. s_route_mode = 0; const uint8_t hsz = (uint8_t)((m.path_len >> 6) + 1); const uint8_t cnt = (uint8_t)(m.path_len & 0x3F); int off = 0; for (uint8_t h = 0; h < cnt && off + (int)hsz <= m.in_path_n && s_route_n < k_route_max - 1; ++h, off += hsz) { double lat = 0, lon = 0; const bool hp = the_mesh.uiHopPos(&m.in_path[off], hsz, &lat, &lon); s_route[s_route_n].has_pos = hp; s_route[s_route_n].lat = lat; s_route[s_route_n].lon = lon; snprintf(s_route[s_route_n].tag, sizeof(s_route[0].tag), "%u", (unsigned)(h + 1)); routeFillId(s_route[s_route_n], &m.in_path[off], hsz); ++s_route_n; } if (self_has && s_route_n < k_route_max) { s_route[s_route_n].has_pos = true; s_route[s_route_n].lat = self_lat; s_route[s_route_n].lon = self_lon; strncpy(s_route[s_route_n].tag, "ME", sizeof(s_route[0].tag) - 1); s_route[s_route_n].tag[sizeof(s_route[0].tag) - 1] = '\0'; strncpy(s_route[s_route_n].name, "You", sizeof(s_route[0].name) - 1); s_route[s_route_n].name[sizeof(s_route[0].name) - 1] = '\0'; s_route[s_route_n].id[0] = '\0'; ++s_route_n; } } else if (m.outgoing && m.sent_fp && self_has) { // Our own sent flood: star from ME to each repeater that echoed it back. s_route_mode = 1; s_route[s_route_n].has_pos = true; s_route[s_route_n].lat = self_lat; s_route[s_route_n].lon = self_lon; strncpy(s_route[s_route_n].tag, "ME", sizeof(s_route[0].tag) - 1); s_route[s_route_n].tag[sizeof(s_route[0].tag) - 1] = '\0'; strncpy(s_route[s_route_n].name, "You", sizeof(s_route[0].name) - 1); s_route[s_route_n].name[sizeof(s_route[0].name) - 1] = '\0'; s_route[s_route_n].id[0] = '\0'; ++s_route_n; const uint8_t rhc = the_mesh.uiRepeatHopCount(m.sent_fp); for (uint8_t r = 0; r < rhc && s_route_n < k_route_max; ++r) { uint8_t hh[4]; const uint8_t hs = the_mesh.uiRepeatHop(m.sent_fp, r, hh, sizeof(hh)); if (hs == 0) continue; double lat = 0, lon = 0; const bool hp = the_mesh.uiHopPos(hh, hs, &lat, &lon); s_route[s_route_n].has_pos = hp; s_route[s_route_n].lat = lat; s_route[s_route_n].lon = lon; snprintf(s_route[s_route_n].tag, sizeof(s_route[0].tag), "R%u", (unsigned)(r + 1)); routeFillId(s_route[s_route_n], hh, hs); ++s_route_n; } } int plottable = 0; for (int i = 0; i < s_route_n; ++i) if (s_route[i].has_pos) ++plottable; return plottable; } // ---- Route-replay HUD: a top banner with the current hop's repeater name + // identifier, and a replay button to re-run the reveal animation. ---- static void routeBannerText(int i, char* buf, size_t n) { if (!buf || !n) return; if (i < 0 || i >= s_route_n) { buf[0] = '\0'; return; } const RouteNode& nd = s_route[i]; if (nd.name[0] && nd.id[0]) snprintf(buf, n, "%s \xC2\xB7 %s", nd.name, nd.id); // name · identifier else if (nd.name[0]) snprintf(buf, n, "%s", nd.name); // e.g. "You" else if (nd.id[0]) snprintf(buf, n, "Repeater %s", nd.id); // unknown name -> hash only else snprintf(buf, n, "%s", nd.tag); } static void routeHudUpdate() { if (!s_route_hud_lbl || s_route_n <= 0) return; int idx = s_route_reveal - 1; // the hop most recently revealed if (idx < 0) idx = 0; if (idx >= s_route_n) idx = s_route_n - 1; char buf[60]; routeBannerText(idx, buf, sizeof buf); lv_label_set_text(s_route_hud_lbl, buf); } static void hideRouteHud() { if (s_route_hud) { lv_obj_del(s_route_hud); s_route_hud = nullptr; s_route_hud_lbl = nullptr; } } // Replay button -> restart the reveal animation from the first hop. static void routeReplayRestartCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_route_n < 1) return; s_route_active = true; s_route_reveal = 1; fitMapToRoute(); if (getActiveTab() == MAP_TAB_INDEX) { renderMapTiles(); renderMapMarkers(); } routeHudUpdate(); if (s_route_timer) { lv_timer_del(s_route_timer); s_route_timer = nullptr; } if (s_route_reveal < s_route_n) s_route_timer = lv_timer_create(routeReplayTick, 650, nullptr); } static void showRouteHud() { hideRouteHud(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); s_route_hud = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_route_hud); // Keep the right-edge map button column (settings/zoom/recenter live at // x ~ sw-36) clear: anchor top-LEFT and stop the pill before that column, // so the replay button can't land under the settings gear. lv_obj_set_size(s_route_hud, sw - 48, 34); lv_obj_align(s_route_hud, LV_ALIGN_TOP_LEFT, 6, STATUSBAR_H + 4); lv_obj_set_style_bg_color(s_route_hud, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_route_hud, LV_OPA_90, LV_PART_MAIN); lv_obj_set_style_radius(s_route_hud, 12, LV_PART_MAIN); lv_obj_set_style_border_width(s_route_hud, 1, LV_PART_MAIN); lv_obj_set_style_border_color(s_route_hud, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_opa(s_route_hud, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(s_route_hud, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(s_route_hud, LV_OBJ_FLAG_CLICKABLE); // let pans pass through to the map (the replay button stays clickable) s_route_hud_lbl = lv_label_create(s_route_hud); lv_label_set_long_mode(s_route_hud_lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(s_route_hud_lbl, sw - 48 - 12 - 42); // leave room for the replay button lv_obj_align(s_route_hud_lbl, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_set_style_text_color(s_route_hud_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_route_hud_lbl, &g_font_14, LV_PART_MAIN); lv_label_set_text(s_route_hud_lbl, ""); lv_obj_t* rb = lv_btn_create(s_route_hud); lv_obj_set_size(rb, 34, 26); lv_obj_align(rb, LV_ALIGN_RIGHT_MID, -4, 0); lv_obj_set_style_radius(rb, 8, LV_PART_MAIN); lv_obj_set_style_bg_color(rb, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_color(rb, lv_color_hex(COLOR_ACCENT_PRESS), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(rb, routeReplayRestartCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* rl = lv_label_create(rb); useChainedFont(rl); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(rl); } // Switch to the map and animate the route that buildRouteFromMessage() captured. static void startRouteReplay() { if (s_route_n < 2) return; s_route_active = true; // set BEFORE goToTab so onMapTabActivated keeps our fit s_route_reveal = 1; // reveal node 1 immediately; the timer adds the rest fitMapToRoute(); goToTab(MAP_TAB_INDEX); // runs onMapTabActivated -> renders tiles + markers + overlay showRouteHud(); // top banner (current hop name + identifier) + replay button routeHudUpdate(); if (s_route_timer) { lv_timer_del(s_route_timer); s_route_timer = nullptr; } if (s_route_reveal < s_route_n) s_route_timer = lv_timer_create(routeReplayTick, 650, nullptr); } // (The on-map links toggle moved into the Map options popup below; the dotted // self->contact link lines are now controlled by the "Show link lines" switch.) // ===== Map options popup (gear button, top-right of the map) ================= // Holds the per-map settings that used to be one-off overlay buttons: the // self->contact link lines, a "reload tiles" repair action, and an info/credits // sheet. Centralising them frees the map's right edge and gives room to grow. static lv_obj_t* s_map_opts_root = nullptr; static void closeMapOptions() { if (s_map_opts_root) { popupClose(&s_map_opts_root); } } static void mapOptionsDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Only dismiss on a tap of the dim backdrop itself, not a child (card) tap. if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMapOptions(); } // "Lines" switch inside the options popup. static void mapOptLinesCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; lv_obj_t* sw = lv_event_get_target(e); s_map_show_links = lv_obj_has_state(sw, LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapShowLinks(s_map_show_links); // persist across reboots (PR #61) #endif renderMapMarkers(); // rebuild links to reflect the new state } // Topographic map style (OpenTopoMap) toggle — opt-in; default OFF (OpenStreetMap). // Switches the tile source: topo tiles cache under /tiles/topo and fetch via the // proxy's /opentopo route. Clears the (z,x,y) fetch-dedup ring — it's shared across // styles — so the newly-selected style re-queues, then re-renders. applyMapChrome // refreshes the on-map © attribution (OSM vs OpenTopoMap). static void mapOptTopoCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool topo = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); s_map_style = topo ? 1 : 0; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) touchPrefsSetMapStyle(s_map_style); memset(s_tile_fetch_dedup, 0, sizeof(s_tile_fetch_dedup)); // shared (z,x,y) ring → forget so the new style re-fetches s_tile_fetch_dedup_head = 0; #endif freeMapTiles(); // drop in-RAM decodes; next render reads the new style's cache dir renderMapTiles(); // loads /tiles/topo (cached) or queues a fetch via /opentopo renderMapMarkers(); applyMapChrome(true); // refresh the © attribution for the new style } // "Night mode" switch — invert tile colours at render time. Re-decodes the // visible tiles so the change is immediate, and persists the choice. static void mapOptNightCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_night = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapNight(s_map_night); #endif freeMapTiles(); // drop cached decodes so they re-decode with the new mode renderMapTiles(); renderMapMarkers(); applyMapChrome(true); // re-tint the status bar + tab bar for the new tile brightness } // Per-element map text/marker visibility toggles. Coords + tile line just hide // their corner label; contacts re-renders the markers. static void mapOptCoordsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_show_coords = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapShowCoords(s_map_show_coords); #endif applyMapTextVis(); } static void mapOptTileXYZCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_show_tilexyz = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapShowTileXYZ(s_map_show_tilexyz); #endif applyMapTextVis(); } static void mapOptTileDebugCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_tile_debug = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapTileDebug(s_map_tile_debug); #endif refreshMapInfoLabel(); // swap the zoom line to/from the diagnostic immediately } static void mapOptContactsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_show_contacts = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapShowContacts(s_map_show_contacts); #endif renderMapMarkers(); // add/remove the contact markers (+ their links) } static void mapOptDirectOnlyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_direct_only = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); renderMapMarkers(); } static void mapZoomControlsApply(); // fwd (defined near the map build) — reposition zoom controls // Map zoom-control style toggle (map options popup): ON = +/- buttons, OFF = slider. static void mapOptZoomButtonsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; s_map_zoom_buttons = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetMapZoomButtons(s_map_zoom_buttons); #endif mapZoomControlsApply(); } #if CAP_SD || defined(TLORA_PAGER) // Map tile source toggle (in the map options popup): ON = tiles live on the microSD // card — read the user's SD library AND cache Wi-Fi-fetched gaps there (#20), so the // library grows and downloads survive; OFF = tile server + internal LittleFS cache. static void mapOptTilesSdCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; // Pause at a request boundary before repointing the cache. A queued backlog is // harmless and resumes on the new backend; only the request currently owning // a File must finish. Do not steal a pause already owned by remount/removal. const bool pause_owned_elsewhere = tileBackendSwapRequested(); const bool pause_ready = !pause_owned_elsewhere && tileBackendSwapTryBegin(); if (!pause_ready) { // If this callback asserted the pause but found an active request, cancel // only its own request. Lifecycle-owned pauses stay asserted for their retry. if (!pause_owned_elsewhere) tileBackendSwapFinish(); if (s_tiles_from_sd) lv_obj_add_state(lv_event_get_target(e), LV_STATE_CHECKED); else lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); if (g_lv.task) g_lv.task->showAlert(TR("Current tile transfer is busy - retry"), 1800); return; } const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); touchPrefsSetTilesFromSd(on); s_tiles_from_sd = on; // Re-point the download cache: ON -> the SD card ROOT (/tiles/<z>/<x>/<y>.jpg, where // the SD reader looks), so fetched tiles merge with the library; OFF -> the boot // default backend (LittleFS partition / SD fallback). if (on) { if (fmSdTryMount() || SD.cardType() != CARD_NONE) { s_tile_fs = &SD; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; if (!s_tile_fs_default) { s_tile_fs_default = &SD; s_tile_root_default[0] = '\0'; } } } else { s_tile_fs = s_tile_fs_default; strncpy(s_tile_root, s_tile_root_default, sizeof s_tile_root - 1); } freeMapTiles(); // drop stale tile widgets → reload from the new source renderMapTiles(); tileBackendSwapFinish(); // queued requests resume against the selected backend if (g_lv.task) g_lv.task->showAlert(on ? TR("Map tiles: microSD") : TR("Map tiles: server"), 1400); } #endif // "Reload tiles" — delete the currently-visible tiles from the LittleFS cache // and re-queue them for download, so a corrupted/partial tile in view can be // repaired without wiping the whole pack. Bounded to the 9 on-screen tiles at // the current zoom (NOT a bulk area download — stays OSM-policy-friendly). #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static void mapReloadVisibleTiles() { if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) { if (g_lv.task) g_lv.task->showAlert(TR("Set your location first"), 1600); return; } double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); const int32_t ctx = (int32_t)floor(cwx / 256.0); const int32_t cty = (int32_t)floor(cwy / 256.0); // Drop the in-RAM tile widgets so the next render re-reads from disk (and // misses, since we delete the files below) → re-queues the download. freeMapTiles(); int n = 0; // Purge the on-disk cache for the visible band (current zoom ± 1) so a corrupt // tile is ALWAYS removed — even offline. This deletion used to sit behind the // Wi-Fi gate below, so the button did nothing exactly when you were staring at // a bad cached tile with no connection; the re-download just happens on the next // render once Wi-Fi is back. for (int dz = -1; dz <= 1; ++dz) { const int z = (int)s_map_zoom + dz; if (z < (int)k_map_zoom_min || z > (int)k_map_zoom_max) continue; double zwx, zwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, (uint8_t)z, &zwx, &zwy); const int32_t zctx = (int32_t)floor(zwx / 256.0), zcty = (int32_t)floor(zwy / 256.0); for (int dy = -s_map_grid_ry; dy <= s_map_grid_ry; ++dy) { for (int dx = -s_map_grid_rx; dx <= s_map_grid_rx; ++dx) { const int32_t tx = zctx + dx, ty = zcty + dy; char path[48]; snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, (long)tx, (long)ty); if (s_tiles_fs_ready && tileCacheExists(path)) { tileCacheRemove(path); ++n; } const uint32_t k = tileFetchDedupKey((uint8_t)z, tx, ty); // clear dedup so it re-queues for (int i = 0; i < k_tile_fetch_dedup_size; ++i) if (s_tile_fetch_dedup[i] == k) s_tile_fetch_dedup[i] = 0; } } } if (g_lv.task) { if (WiFi.status() == WL_CONNECTED) { char msg[44]; snprintf(msg, sizeof(msg), "Reloading %d tiles\xE2\x80\xA6", n); g_lv.task->showAlert(msg, 1600); } else { g_lv.task->showAlert(TR("Tiles cleared \xE2\x80\x94 reconnect Wi-Fi to re-download"), 2400); } } renderMapTiles(); // misses on the just-deleted files → queues fresh fetches renderMapMarkers(); } #endif // Max contact dots drawn at once. Cycles All -> 200 -> 100 -> 50 -> 25 -> All, in // place, so the card keeps its compact switch-row shape instead of growing a dropdown. // "All" is the default and means every positioned contact in view, up to the firmware // ceiling; the lower steps exist for a board that struggles with a crowded viewport, // not because the map should be quietly limiting itself. static void mapOptMarkerCapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; #if defined(ESP32) static const uint16_t kSteps[] = { 0, 200, 100, 50, 25 }; const uint16_t cur = touchPrefsGetMapMarkerCap(); int idx = 0; for (int i = 0; i < (int)(sizeof kSteps / sizeof kSteps[0]); ++i) if (kSteps[i] == cur) { idx = i; break; } const uint16_t next = kSteps[(idx + 1) % (int)(sizeof kSteps / sizeof kSteps[0])]; touchPrefsSetMapMarkerCap(next); // Relabel the button we were tapped on, so the card reflects it without a rebuild. lv_obj_t* b = lv_event_get_target(e); lv_obj_t* l = lv_obj_get_child(b, 0); char txt[48]; if (next == 0) snprintf(txt, sizeof txt, LV_SYMBOL_GPS " %s: %s", TR("Max dots"), TR("All")); else snprintf(txt, sizeof txt, LV_SYMBOL_GPS " %s: %u", TR("Max dots"), (unsigned)next); if (l) lv_label_set_text(l, txt); renderMapMarkers(); // redraw with the new limit #endif } static void mapOptReloadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeMapOptions(); #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) mapReloadVisibleTiles(); #else if (g_lv.task) g_lv.task->showAlert(TR("Tile reload needs Wi-Fi build"), 1800); #endif } // Map info / credits sheet — OSM attribution (policy requirement) plus a short // explanation of how the tile fetching/caching works. static void mapOptInfoCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeMapOptions(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_map_opts_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_map_opts_root); lv_obj_set_size(s_map_opts_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_map_opts_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_map_opts_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_opts_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_map_opts_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_map_opts_root, mapOptionsDismissCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* card = lv_obj_create(s_map_opts_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, sw - 24, (sh - STATUSBAR_H) - 24); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); addCloseXBadge(card, mapOptionsDismissCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("About the map")); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 0, 2); lv_obj_t* lbl = lv_label_create(card); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, sw - 24 - 20); lv_obj_set_pos(lbl, 0, 28); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // Attribution header is style-dependent (legal requirement). OpenTopoMap's map // style is CC-BY-SA and must be credited; its underlying data is still OSM+SRTM. const char* attrib = (s_map_style == 1) ? TR("Map style \xC2\xA9 OpenTopoMap (CC-BY-SA) \xE2\x80\x94 opentopomap.org\n" "Map data \xC2\xA9 OpenStreetMap contributors (ODbL) + SRTM.\n\n") : TR("Map data \xC2\xA9 OpenStreetMap contributors.\n" "openstreetmap.org/copyright\n" "Licensed under the Open Database License (ODbL).\n\n"); // NOTE: the literal break after \x97 is REQUIRED — "\xC3\x97256" would parse the // trailing 256 as part of the hex escape (all hex digits) and emit one garbage byte. // Kept as ONE translatable block: it is continuous prose, and splitting it per // paragraph would hand translators fragments that only make sense together. const char* body = TR( "How tiles work:\n" "The map is built from 256\xC3\x97" "256 \"slippy\" tiles. Only the tiles for the " "area you're viewing are fetched \xE2\x80\x94 there is no bulk pre-download.\n\n" "Because this device can't do HTTPS (not enough heap after Wi-Fi starts) " "and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh " "proxy: it fetches the upstream PNG over HTTPS with an identifying " "User-Agent, re-encodes it as JPEG, and caches it. Your device then caches " "each tile to its own flash, so a tile is only downloaded once.\n\n" "Use Options \xE2\x86\x92 Reload tiles to re-download the tiles currently in " "view if one looks corrupted."); // Sized for the longest translation, not for English: Hungarian runs ~1.5x and // the Cyrillic/Greek files are two bytes per letter in UTF-8. static const size_t CREDITS_SZ = 2048; static char* credits = (char*)psAlloc(CREDITS_SZ); // lazy-PSRAM (nothing in .bss) if (!credits) return; // OOM only: skip the credits text snprintf(credits, CREDITS_SZ, "%s%s", attrib, body); lv_label_set_text(lbl, credits); } // Open the options popup: a compact bottom-anchored card with the Lines switch // + Reload + Info rows. static void openMapOptions() { closeMapOptions(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_map_opts_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_map_opts_root); lv_obj_set_size(s_map_opts_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_map_opts_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_map_opts_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_opts_root, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(s_map_opts_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_map_opts_root, mapOptionsDismissCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t cardw = sw - 24; lv_obj_t* card = lv_obj_create(s_map_opts_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, cardw, LV_SIZE_CONTENT); // grow to the rows... lv_obj_set_style_max_height(card, sh - STATUSBAR_H - 16, LV_PART_MAIN); // ...scroll if past the screen lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 8); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Map options")); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); int y = 26; #if CAP_SD || defined(TLORA_PAGER) // Row: tile source — microSD (offline) vs the tile server. The important one, // so it sits at the very top. { lv_obj_t* tl = lv_label_create(card); lv_label_set_text(tl, TR("Tiles from SD card")); lv_obj_set_style_text_color(tl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(tl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(tl, 2, y + 4); lv_obj_t* sw_sd = lv_switch_create(card); lv_obj_align(sw_sd, LV_ALIGN_TOP_RIGHT, 0, y); if (s_tiles_from_sd) lv_obj_add_state(sw_sd, LV_STATE_CHECKED); lv_obj_add_event_cb(sw_sd, mapOptTilesSdCb, LV_EVENT_VALUE_CHANGED, nullptr); y += 40; } #endif // Row: Topographic map (OpenTopoMap) — opt-in alternate tile style; default OSM. { lv_obj_t* tpl = lv_label_create(card); lv_label_set_text(tpl, TR("Topographic map")); lv_obj_set_style_text_color(tpl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(tpl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(tpl, 2, y + 4); lv_obj_t* sw_topo = lv_switch_create(card); lv_obj_align(sw_topo, LV_ALIGN_TOP_RIGHT, 0, y); if (s_map_style == 1) lv_obj_add_state(sw_topo, LV_STATE_CHECKED); lv_obj_add_event_cb(sw_topo, mapOptTopoCb, LV_EVENT_VALUE_CHANGED, nullptr); y += 40; } // Row: Night mode (invert tile colours) — a tile/display setting, kept up top. { lv_obj_t* nl = lv_label_create(card); lv_label_set_text(nl, TR("Night mode (invert)")); lv_obj_set_style_text_color(nl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(nl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(nl, 2, y + 4); lv_obj_t* sw_night = lv_switch_create(card); lv_obj_align(sw_night, LV_ALIGN_TOP_RIGHT, 0, y); if (s_map_night) lv_obj_add_state(sw_night, LV_STATE_CHECKED); lv_obj_add_event_cb(sw_night, mapOptNightCb, LV_EVENT_VALUE_CHANGED, nullptr); y += 40; } // Row: Zoom controls — slider (default, off) vs a +/- button pair (on, issue #26). { lv_obj_t* zl = lv_label_create(card); lv_label_set_text(zl, TR("Zoom: +/- buttons")); lv_obj_set_style_text_color(zl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(zl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(zl, 2, y + 4); lv_obj_t* sw_zoom = lv_switch_create(card); lv_obj_align(sw_zoom, LV_ALIGN_TOP_RIGHT, 0, y); if (s_map_zoom_buttons) lv_obj_add_state(sw_zoom, LV_STATE_CHECKED); lv_obj_add_event_cb(sw_zoom, mapOptZoomButtonsCb, LV_EVENT_VALUE_CHANGED, nullptr); y += 40; } // ---- Section separator + label: the on-map visibility toggles below are a // distinct, lower-stakes group from the tile settings above. { lv_obj_t* sep = lv_obj_create(card); lv_obj_remove_style_all(sep); lv_obj_set_size(sep, cardw - 24, 1); lv_obj_set_pos(sep, 0, y + 4); lv_obj_set_style_bg_color(sep, lv_color_hex(0x303438), LV_PART_MAIN); lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* sl = lv_label_create(card); lv_label_set_text(sl, TR("Show on map")); lv_obj_set_style_text_color(sl, lv_color_hex(0x8A929B), LV_PART_MAIN); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(sl, 2, y + 10); y += 34; } // Rows: per-element on-map visibility (link lines / coords / tile z-x-y / contacts). { struct { const char* label; bool state; lv_event_cb_t cb; } rows[] = { { "Show link lines", s_map_show_links, mapOptLinesCb }, { "Show coordinates", s_map_show_coords, mapOptCoordsCb }, { "Show tile z/x/y", s_map_show_tilexyz, mapOptTileXYZCb }, { "Show contacts", s_map_show_contacts, mapOptContactsCb }, { "Direct (0-hop) only", s_map_direct_only, mapOptDirectOnlyCb }, { "Tile debug overlay", s_map_tile_debug, mapOptTileDebugCb }, }; for (auto& r : rows) { lv_obj_t* l = lv_label_create(card); lv_label_set_text(l, TR(r.label)); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(l, 2, y + 4); lv_obj_t* sw = lv_switch_create(card); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); if (r.state) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, r.cb, LV_EVENT_VALUE_CHANGED, nullptr); y += 40; } } auto mk_row_btn = [&](const char* txt, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, cardw - 24, 38); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(txt)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_LEFT_MID, 8, 0); y += 40; }; { char capbuf[48]; const uint16_t cap_now = #if defined(ESP32) touchPrefsGetMapMarkerCap(); #else 0; #endif if (cap_now == 0) snprintf(capbuf, sizeof capbuf, LV_SYMBOL_GPS " %s: %s", TR("Max dots"), TR("All")); else snprintf(capbuf, sizeof capbuf, LV_SYMBOL_GPS " %s: %u", TR("Max dots"), (unsigned)cap_now); // mk_row_btn TR()s its argument; this string is already assembled + translated. lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, cardw - 24, 38); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_add_event_cb(b, mapOptMarkerCapCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, capbuf); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_LEFT_MID, 8, 0); y += 40; } mk_row_btn(LV_SYMBOL_REFRESH " Reload tiles in view", mapOptReloadCb); mk_row_btn(LV_SYMBOL_EYE_OPEN " About / credits", mapOptInfoCb); // Close X (top-right of the card). Added last so move_foreground() keeps it // above the title/switch/rows and reliably tappable. Same dismiss path as the // backdrop tap. The 32×32 hit area only grazes the link-lines switch (y=30) // by ~2px, which is imperceptible. // This card scrolls (many rows), and the close badge floats fixed at the // top-right. Give it an opaque chip + a faint ring so toggles scrolling under // it are cleanly hidden instead of bleeding through the bare glyph. lv_obj_t* xb = addCloseXBadge(card, mapOptionsDismissCb); lv_obj_set_style_bg_color(xb, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(xb, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(xb, lv_color_hex(0x303438), LV_PART_MAIN); lv_obj_set_style_border_width(xb, 1, LV_PART_MAIN); } static void mapOpenOptionsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openMapOptions(); } // ----- Marker tap popup ----- // // Reuses openContactActionSheet (the same sheet you get tapping a row in // the Contacts tab) so the user gets the full action set — send DM, // telemetry, range test, etc. — without us duplicating UI. static void openMarkerPopupForContact(int mesh_idx) { if (mesh_idx < 0) { // Self marker — show a small toast with our coords. Cheaper than a // full popup; the user already has their own profile screen. if (g_lv.task) { char buf[40]; snprintf(buf, sizeof(buf), "Self %.4f, %.4f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); g_lv.task->showAlert(buf, 1500); } return; } ContactInfo c; if (!the_mesh.getContactByIdx((uint32_t)mesh_idx, c)) return; const bool is_repeater = (c.type == ADV_TYPE_REPEATER); openContactActionSheet((uint32_t)mesh_idx, is_repeater, c.name, /*from_map=*/true); } // ----- Overlapping-markers picker ----- // // When 2+ markers fall within the tap-forgiveness radius, the canvas // dispatcher calls this with the list of hits. We show a vertical list of // contact rows; tapping one closes the picker and routes through the // normal openMarkerPopupForContact path. Self (mesh_idx == -1) is shown // as "(you)" so it's pickable even when other markers sit on top of it. static lv_obj_t* s_map_picker_root = nullptr; static void closeMapPicker() { if (s_map_picker_root) { popupClose(&s_map_picker_root); } } static void mapPickerBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMapPicker(); } static void mapPickerRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMapPicker(); openMarkerPopupForContact(idx); } static void openMapPicker(const int* idxs, int n) { closeMapPicker(); if (n <= 0) return; if (n > 6) n = 6; // cap card height; in practice rare to overlap > a few lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_map_picker_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_map_picker_root); lv_obj_set_size(s_map_picker_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_map_picker_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_map_picker_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_picker_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_map_picker_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_map_picker_root, mapPickerBackdropCb, LV_EVENT_CLICKED, nullptr); // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards (PSC no-op). const int card_w = PCW(220); const int btn_h = PSC(34); const int gap = PSC(4); const int hdr_h = PSC(30); const int pad = PSC(10); const int card_h = hdr_h + n * (btn_h + gap) + pad; lv_obj_t* card = lv_obj_create(s_map_picker_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, mapPickerBackdropCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Nearby on map")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, card_w - 2 * pad - 32); // leave room for X lv_obj_set_pos(title, 0, 0); int y = hdr_h; for (int i = 0; i < n; ++i) { const int midx = idxs[i]; char row_label[40]; if (midx < 0) { snprintf(row_label, sizeof(row_label), LV_SYMBOL_GPS " (you)"); } else { ContactInfo c; if (!the_mesh.getContactByIdx((uint32_t)midx, c)) continue; const char* icon = (c.type == ADV_TYPE_REPEATER) ? LV_SYMBOL_CHARGE : (c.type == ADV_TYPE_ROOM) ? LV_SYMBOL_LOOP : LV_SYMBOL_ENVELOPE; char nm[24]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), c.name); snprintf(row_label, sizeof(row_label), "%s %s", icon, nm[0] ? nm : "(unnamed)"); } lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, y); styleButton(b); lv_obj_add_event_cb(b, mapPickerRowCb, LV_EVENT_CLICKED, reinterpret_cast<void*>((intptr_t)midx)); lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, row_label); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, card_w - 2 * pad - 16); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); y += btn_h + gap; } } // ===== "Show contact on map" — list every contact that has GPS coords, tap one // to recenter the map on it. ================================================= static lv_obj_t* s_map_contacts_root = nullptr; static lv_obj_t* s_map_contacts_list = nullptr; // the scrollable rows container (rebuilt on sort) // Sort order for the "contacts on map" list. Persists across opens this session. enum MapContactsSort : uint8_t { MC_SORT_NAME = 0, MC_SORT_DIST, MC_SORT_HEARD, MC_SORT_COUNT }; static uint8_t s_map_contacts_sort = MC_SORT_DIST; // distance is the most useful default static const char* mapContactsSortName(uint8_t s) { switch (s) { case MC_SORT_NAME: return "Name"; case MC_SORT_DIST: return "Distance"; case MC_SORT_HEARD: return "Heard"; default: return "?"; } } static void closeMapContacts() { if (s_map_contacts_root) { popupClose(&s_map_contacts_root); } s_map_contacts_list = nullptr; } static void mapContactsBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // backdrop only lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMapContacts(); } // Tap a row → recenter the map on that contact + zoom in a touch + close. static void mapContactsRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int midx = (int)(intptr_t)lv_event_get_user_data(e); lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMapContacts(); ContactInfo c; if (!the_mesh.getContactByIdx((uint32_t)midx, c)) return; if (c.gps_lat == 0 && c.gps_lon == 0) return; s_map_center_lat = (double)c.gps_lat / 1.0e6; s_map_center_lon = (double)c.gps_lon / 1.0e6; // Zoom in a step (capped) so "show on map" frames the contact closely. if (s_map_zoom < k_map_zoom_max) s_map_zoom = (uint8_t)(s_map_zoom + 1); renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); if (g_lv.task) { char nm[24]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), c.name); char msg[40]; snprintf(msg, sizeof(msg), TR("Centered on %s"), nm[0] ? nm : "contact"); g_lv.task->showAlert(msg, 1200); } } // "Show on map" from the contact action sheet: centre the map on the contact's // last-known GPS and switch to the Map tab. Defined here (not with the sheet) // because it touches the map statics declared in this region. static void actionSheetShowOnMapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; ContactInfo c; const bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { if (g_lv.task) g_lv.task->showAlert(TR("Contact gone"), 1200); return; } if (c.gps_lat == 0 && c.gps_lon == 0) { if (g_lv.task) g_lv.task->showAlert(TR("No location for this contact"), 1500); return; } s_map_center_lat = (double)c.gps_lat / 1.0e6; s_map_center_lon = (double)c.gps_lon / 1.0e6; s_map_view_inited = true; // keep this centre — don't recentre on self on open if (s_map_zoom < k_map_zoom_max) s_map_zoom = (uint8_t)(s_map_zoom + 1); goToTab(MAP_TAB_INDEX); // onMapTabActivated renders tiles + markers + overlay } // One GPS-bearing contact, with the derived sort keys precomputed. struct MapContactEntry { int midx; char name[24]; uint8_t type; int32_t lat_e6, lon_e6; double dist_km; // -1 if self has no fix uint32_t age_secs; // 0 = unknown }; // (Re)build the scrollable row list from the current sort. Separated from the // popup shell so the Sort button can re-list without rebuilding the card. static void mapContactsFillList() { if (!s_map_contacts_list) return; lv_indev_reset(nullptr, nullptr); // #27: abort any scroll-throw before freeing these rows (UAF on telemetry/position rebuild mid-flick) lv_obj_clean(s_map_contacts_list); // Collect GPS-bearing contacts + their distance/age keys. static MapContactEntry* ents = (MapContactEntry*)psAlloc(sizeof(MapContactEntry) * 64); int n = 0; const double self_lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0; const double self_lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0; uint32_t now_secs = 0; { mesh::RTCClock* rtc = the_mesh.getRTCClock(); if (rtc) now_secs = rtc->getCurrentTime(); } const uint32_t total = the_mesh.getNumContacts(); for (uint32_t i = 0; i < total && n < 64; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (c.gps_lat == 0 && c.gps_lon == 0) continue; MapContactEntry& e = ents[n]; e.midx = (int)i; e.type = c.type; e.lat_e6 = c.gps_lat; e.lon_e6 = c.gps_lon; copyUtf8ReplacingMissingGlyphs(&g_font_14, e.name, sizeof(e.name), c.name); e.dist_km = (self_lat == 0.0 && self_lon == 0.0) ? -1.0 : contactDistanceKm(self_lat, self_lon, (double)c.gps_lat / 1.0e6, (double)c.gps_lon / 1.0e6); e.age_secs = (now_secs > c.last_advert_timestamp && c.last_advert_timestamp != 0) ? (now_secs - c.last_advert_timestamp) : 0; ++n; } // Sort by the chosen key. (Stable enough for this small list.) qsort(ents, n, sizeof(MapContactEntry), [](const void* a, const void* b) -> int { const MapContactEntry* ea = static_cast<const MapContactEntry*>(a); const MapContactEntry* eb = static_cast<const MapContactEntry*>(b); switch (s_map_contacts_sort) { case MC_SORT_DIST: { // Unknown distance (-1) sorts last. const double da = ea->dist_km < 0 ? 1e12 : ea->dist_km; const double db = eb->dist_km < 0 ? 1e12 : eb->dist_km; if (da < db) return -1; if (da > db) return 1; break; } case MC_SORT_HEARD: { // Most recently heard first; unknown (0) last. const uint32_t aa = ea->age_secs ? ea->age_secs : 0xFFFFFFFFu; const uint32_t ab = eb->age_secs ? eb->age_secs : 0xFFFFFFFFu; if (aa < ab) return -1; if (aa > ab) return 1; break; } default: break; // MC_SORT_NAME → fall through to name compare } return strcasecmp(ea->name, eb->name); }); if (n == 0) { lv_obj_t* empty = lv_label_create(s_map_contacts_list); lv_label_set_text(empty, TR("No contacts have shared\na GPS location yet.")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_14, LV_PART_MAIN); return; } // Row width from the display (the list's own width may not be resolved yet on // the first fill, right after creation). const lv_coord_t rw = (lv_disp_get_hor_res(nullptr) - 24) - 2 * 10 - 8; for (int i = 0; i < n; ++i) { const MapContactEntry& e = ents[i]; const char* icon = (e.type == ADV_TYPE_REPEATER) ? LV_SYMBOL_CHARGE : (e.type == ADV_TYPE_ROOM) ? LV_SYMBOL_LOOP : LV_SYMBOL_GPS; lv_obj_t* b = lv_btn_create(s_map_contacts_list); lv_obj_set_size(b, rw, 46); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, mapContactsRowCb, LV_EVENT_CLICKED, reinterpret_cast<void*>((intptr_t)e.midx)); // Line 1 (left): icon + name. Line 1 (right): distance · age. Line 2: coords. char l1[40]; snprintf(l1, sizeof(l1), "%s %s", icon, e.name[0] ? e.name : "(unnamed)"); lv_obj_t* nl = lv_label_create(b); lv_label_set_text(nl, l1); lv_label_set_long_mode(nl, LV_LABEL_LONG_DOT); // Constrain BOTH width and height to one line: LONG_DOT otherwise wraps to a // second line (dotting only the overflow), which a long name then pushed // down onto the coords row. A 1-line height forces single-line + ellipsis. lv_obj_set_width(nl, rw - 96); // leave room for the dist/age badge on the right lv_obj_set_height(nl, lv_font_get_line_height(&g_font_14)); lv_obj_set_style_text_font(nl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(nl, LV_ALIGN_TOP_LEFT, 8, 5); char dist[16] = ""; formatDistanceBadge(dist, sizeof(dist), self_lat, self_lon, e.lat_e6, e.lon_e6); char age[12]; formatAgeBadge(age, sizeof(age), e.age_secs); char meta[28]; if (dist[0]) snprintf(meta, sizeof(meta), "%s \xC2\xB7 %s", dist, age); else snprintf(meta, sizeof(meta), "%s", age); lv_obj_t* ml = lv_label_create(b); lv_label_set_text(ml, meta); lv_obj_set_style_text_font(ml, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(ml, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(ml, LV_ALIGN_TOP_RIGHT, -8, 6); char co[32]; snprintf(co, sizeof(co), "%.5f, %.5f", (double)e.lat_e6 / 1.0e6, (double)e.lon_e6 / 1.0e6); lv_obj_t* cl = lv_label_create(b); lv_label_set_text(cl, co); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(cl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(cl, LV_ALIGN_BOTTOM_LEFT, 8, -4); } } // Header Sort button label (shows the CURRENT key) — updated on cycle. static lv_obj_t* s_map_contacts_sort_lbl = nullptr; static void mapContactsSortCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_map_contacts_sort = (uint8_t)((s_map_contacts_sort + 1) % MC_SORT_COUNT); if (s_map_contacts_sort_lbl) lv_label_set_text_fmt(s_map_contacts_sort_lbl, LV_SYMBOL_SHUFFLE " %s", mapContactsSortName(s_map_contacts_sort)); mapContactsFillList(); } static void openMapContactsList() { closeMapContacts(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_map_contacts_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_map_contacts_root); lv_obj_set_size(s_map_contacts_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_map_contacts_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_map_contacts_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_contacts_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_map_contacts_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_map_contacts_root, mapContactsBackdropCb, LV_EVENT_CLICKED, nullptr); const int pad = 10; const lv_coord_t card_w = sw - 24; const lv_coord_t card_h = sh - STATUSBAR_H - 20; lv_obj_t* card = lv_obj_create(s_map_contacts_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, mapContactsBackdropCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR(LV_SYMBOL_GPS " On map")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 2); // Sort button (header, right of the title; left of the close X). lv_obj_t* sort_b = lv_btn_create(card); lv_obj_set_size(sort_b, 108, 26); lv_obj_align(sort_b, LV_ALIGN_TOP_RIGHT, -28, 0); styleButton(sort_b); lv_obj_set_style_bg_color(sort_b, lv_color_hex(0x1A1B1C), LV_PART_MAIN); lv_obj_add_event_cb(sort_b, mapContactsSortCb, LV_EVENT_CLICKED, nullptr); s_map_contacts_sort_lbl = lv_label_create(sort_b); lv_label_set_text_fmt(s_map_contacts_sort_lbl, LV_SYMBOL_SHUFFLE " %s", mapContactsSortName(s_map_contacts_sort)); lv_obj_set_style_text_font(s_map_contacts_sort_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_map_contacts_sort_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(s_map_contacts_sort_lbl); // Scrollable list (filled + re-filled by mapContactsFillList). s_map_contacts_list = lv_obj_create(card); lv_obj_remove_style_all(s_map_contacts_list); lv_obj_set_size(s_map_contacts_list, card_w - 2 * pad, card_h - 2 * pad - 34); lv_obj_set_pos(s_map_contacts_list, 0, 34); lv_obj_set_flex_flow(s_map_contacts_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(s_map_contacts_list, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(s_map_contacts_list, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_map_contacts_list, LV_SCROLLBAR_MODE_AUTO); mapContactsFillList(); } static void mapOpenContactsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openMapContactsList(); } // ----- Pan ----- // // Touch-and-drag on the canvas with LIVE preview. PRESSING fires every // 15 ms (LV_INDEV_DEF_READ_PERIOD); we just translate every existing tile // and marker by the incremental touch delta. lv_obj_set_pos is cheap // (style change + invalidation, no decode), so the map slides under the // finger in real time. // // On RELEASED we compute the total delta, convert to lat/lon, and call // renderMapTiles — which now reuses tile slots whose (z,x,y) is still // wanted, so only newly-visible tiles get loaded from SPIFFS. // // Movement under 6 px on release is treated as a tap; we re-snap any // pixels of jitter back to the proper grid. static bool s_map_panning = false; static lv_point_t s_map_pan_anchor = {0, 0}; static lv_point_t s_map_pan_last = {0, 0}; static double s_map_pan_start_lat = 0.0; static double s_map_pan_start_lon = 0.0; // Translate the pan layer by (dx, dy). All tiles + markers ride on the // layer, so this is a SINGLE set_pos regardless of how many children sit // on top. Compare to the previous per-child loop which made LVGL emit ~38 // dirty rects per PRESSING tick and merge them into a whole-canvas redraw. static void shiftMapChildren(int dx, int dy) { if (!s_map_pan_layer || (dx == 0 && dy == 0)) return; lv_obj_set_pos(s_map_pan_layer, lv_obj_get_x(s_map_pan_layer) + dx, lv_obj_get_y(s_map_pan_layer) + dy); } static void mapCanvasEventCb(lv_event_t* e) { const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_PRESSED && code != LV_EVENT_PRESSING && code != LV_EVENT_RELEASED && code != LV_EVENT_PRESS_LOST) return; lv_indev_t* indev = lv_indev_get_act(); if (!indev) return; lv_point_t p; lv_indev_get_point(indev, &p); if (code == LV_EVENT_PRESSED) { s_map_pan_anchor = p; s_map_pan_last = p; s_map_pan_start_lat = s_map_center_lat; s_map_pan_start_lon = s_map_center_lon; s_map_panning = true; return; } if (!s_map_panning) return; if (code == LV_EVENT_PRESSING) { // Incremental delta since the last PRESSING tick — slide children. const int idx = p.x - s_map_pan_last.x; const int idy = p.y - s_map_pan_last.y; if (idx == 0 && idy == 0) return; shiftMapChildren(idx, idy); s_map_pan_last = p; return; } // RELEASED or PRESS_LOST — finalize. s_map_panning = false; const int tdx = p.x - s_map_pan_anchor.x; const int tdy = p.y - s_map_pan_anchor.y; // 6-px deadzone: treat as a tap. Snap children back so any sub-pixel // jitter we introduced during PRESSING goes away. if (tdx > -6 && tdx < 6 && tdy > -6 && tdy < 6) { if (tdx != 0 || tdy != 0) shiftMapChildren(-tdx, -tdy); // Centralized marker hit-test: find every marker whose center is // within 16 px of the tap. One hit → open that contact. Multiple // hits (overlapping markers) → open a picker so the user can choose. // 16 px is a finger-forgiveness radius, larger than the 14-px marker // diameter so an off-center tap still scores. int hits[k_map_markers_max]; int n_hits = 0; const int R2 = 16 * 16; for (auto& m : s_map_markers) { if (!m.obj) continue; lv_area_t a; lv_obj_get_coords(m.obj, &a); const int mx = (a.x1 + a.x2) / 2; const int my = (a.y1 + a.y2) / 2; const int ddx = mx - p.x; const int ddy = my - p.y; if (ddx * ddx + ddy * ddy <= R2) { if (n_hits < k_map_markers_max) hits[n_hits++] = m.mesh_idx; } } if (n_hits == 1) { openMarkerPopupForContact(hits[0]); } else if (n_hits > 1) { openMapPicker(hits, n_hits); } return; } double start_wx, start_wy; latLonToWorldPx(s_map_pan_start_lat, s_map_pan_start_lon, s_map_zoom, &start_wx, &start_wy); double new_lat, new_lon; // Finger right = content shifts right under finger = center shifts LEFT, // so we subtract the touch delta from the start world-px center. worldPxToLatLon(start_wx - tdx, start_wy - tdy, s_map_zoom, &new_lat, &new_lon); s_map_center_lat = new_lat; s_map_center_lon = new_lon; // Slot reuse means existing tiles get repositioned (not re-decoded); // only the newly-visible 1-3 tiles trigger a SPIFFS read. renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } #if defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) // Keyboard pan (Ctrl+Arrow on Tanmatsu / WAXD on the pager / the M9's Map-key // pan mode, all on the Map tab). Mirrors the drag-release math in // mapCanvasEventCb: synthesize a pixel delta of ~1/4 the visible span in the // arrow direction, convert it through the same world-px ↔ lat/lon helpers (so // the lon step automatically scales with the current zoom's degrees-per-pixel) // and re-render. dir: 0=up(north, +lat) 1=down(south,−lat) 2=left(west,−lon) // 3=right(east,+lon). static void mapNudge(int dir) { if (!s_map_canvas) return; // No center yet (no GPS / location) → nothing to pan around. if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) return; double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); const double step_x = k_map_canvas_w / 4.0; // quarter of the visible span const double step_y = k_map_canvas_h / 4.0; switch (dir) { case 0: cwy -= step_y; break; // up = north = smaller world_y case 1: cwy += step_y; break; // down = south case 2: cwx -= step_x; break; // left = west = smaller world_x case 3: cwx += step_x; break; // right = east default: return; } worldPxToLatLon(cwx, cwy, s_map_zoom, &s_map_center_lat, &s_map_center_lon); // Match the touch-drag: it does NOT clear s_map_follow. NB auto-follow // recenters on the CENTER-vs-fix delta — the pan itself trips it, no GPS // movement needed — so mapAutoFollowTick pauses while the M9's pan mode is // active (s_m9_map_pan); on touch boards a drag away simply snaps back on // the next 250 ms tick while follow is on, which is that button's contract. renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } #endif // HAS_TANMATSU || TLORA_PAGER || HAS_THINKNODE_M9 (mapNudge) // ----- Zoom + recenter ----- // // Zoom PROBES whether the requested level is usable at the current center // before committing: the center tile must already be cached (offline .jpg // pack OR Wi-Fi-fetched .png), or Wi-Fi must be up so renderMapTiles can // download it on the fly. With "zoom packs" we only cache the min + max levels // per location, so the buttons JUMP to the nearest usable level (skipping the // uncached levels in between) rather than dead-ending — e.g. one zoom-out from // z14 lands on the cached z12 overview instead of refusing at the empty z13. #if defined(ESP32) static bool mapZoomReachable(uint8_t z) { if (!mapTileSourceReady()) return true; // can't probe — don't block the user double wx, wy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, z, &wx, &wy); const long tx = (long)floor(wx / 256.0); const long ty = (long)floor(wy / 256.0); if (tileExistsAt(z, tx, ty)) return true; // SD /maps/osm pack OR LittleFS /tiles #if defined(MULTI_TRANSPORT_COMPANION) if (WiFi.status() == WL_CONNECTED) return true; // renderMapTiles will fetch it #endif return false; } #endif // One zoom step, shared by the +/- buttons and the keyboard-nav scroll keys. // Skips levels the current tile source can't show (same walk the buttons did). // In slider mode the zoom number lives above the slider, so it is on screen the // whole time the slider is. Buttons mode hides the slider, and the readout was // hidden with it -- which left the +/- pair with no indication of the level at // all. Reported by a user who had switched to buttons and could no longer see // the zoom anywhere. So in buttons mode the readout is flashed beside the // buttons on each step instead. static lv_timer_t* s_map_zoomval_hide = nullptr; static void mapZoomValHideCb(lv_timer_t* t) { if (s_map_zoom_val && lv_obj_is_valid(s_map_zoom_val)) lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); if (t) lv_timer_del(t); s_map_zoomval_hide = nullptr; } static void mapZoomValFlash() { if (!s_map_zoom_val || !lv_obj_is_valid(s_map_zoom_val)) return; lv_label_set_text_fmt(s_map_zoom_val, TR("zoom %d"), (int)s_map_zoom); // Anchored to the slider at build time, which is hidden here. Re-anchor to the // "+" button so it tracks the column: the buttons are placed with plain // set_pos and no status-bar offset, so a hand-computed y would sit low. if (s_map_btn_zoomin && lv_obj_is_valid(s_map_btn_zoomin)) lv_obj_align_to(s_map_zoom_val, s_map_btn_zoomin, LV_ALIGN_OUT_LEFT_MID, -6, 0); else lv_obj_align(s_map_zoom_val, LV_ALIGN_TOP_RIGHT, -(32 + 10), 4 + 32); lv_obj_clear_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_map_zoom_val); if (s_map_zoomval_hide) { lv_timer_del(s_map_zoomval_hide); s_map_zoomval_hide = nullptr; } s_map_zoomval_hide = lv_timer_create(mapZoomValHideCb, 1400, nullptr); if (s_map_zoomval_hide) lv_timer_set_repeat_count(s_map_zoomval_hide, 1); } static void mapZoomStep(bool zoom_in) { if (zoom_in && s_map_zoom >= k_map_zoom_max) return; if (!zoom_in && s_map_zoom <= k_map_zoom_min) return; #if defined(ESP32) uint8_t want = 0; if (zoom_in) { for (uint8_t z = s_map_zoom + 1; z <= k_map_zoom_max; ++z) { if (mapZoomReachable(z)) { want = z; break; } } } else { for (uint8_t z = s_map_zoom - 1; z >= k_map_zoom_min; --z) { if (mapZoomReachable(z)) { want = z; break; } if (z == k_map_zoom_min) break; // guard: z is unsigned, don't wrap below min } } if (!want) { if (g_lv.task) g_lv.task->showAlert(zoom_in ? TR("Max zoom for this pack") : TR("Min zoom for this pack"), 1200); return; } #else const uint8_t want = zoom_in ? (uint8_t)(s_map_zoom + 1) : (uint8_t)(s_map_zoom - 1); #endif s_map_zoom = want; #if defined(ESP32) touchPrefsSetMapZoom(s_map_zoom); // persist like the slider does #endif renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); if (s_map_zoom_buttons) mapZoomValFlash(); // slider mode already shows it permanently } static void mapZoomInCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; mapZoomStep(true); } static void mapZoomOutCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; mapZoomStep(false); } #if CAP_KEYPAD_NAV // Keyboard-nav hook (called from navScrollFocused): with the Map tab front-most // and nothing modal on top, the scroll keys step the zoom. Returns true when the // key was consumed as a zoom. static bool navMapZoomIfActive(bool zoom_in) { if (getActiveTab() != MAP_TAB_INDEX) return false; if (anyPopupOpen()) return false; // a sheet/popup over the map keeps normal scrolling mapZoomStep(zoom_in); return true; } #endif // Zoom slider overlay. The zoom button toggles it; dragging updates the live // readout, and releasing applies + persists the chosen level. static void mapZoomSliderCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const int z = (int)lv_slider_get_value(lv_event_get_target(e)); if (s_map_zoom_val) lv_label_set_text_fmt(s_map_zoom_val, TR("zoom %d"), z); // live target level while dragging } static void mapZoomSliderReleaseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_RELEASED) return; int z = (int)lv_slider_get_value(lv_event_get_target(e)); if (z < (int)k_map_zoom_min) z = k_map_zoom_min; if (z > (int)k_map_zoom_max) z = k_map_zoom_max; if ((uint8_t)z == s_map_zoom) return; s_map_zoom = (uint8_t)z; #if defined(ESP32) touchPrefsSetMapZoom(s_map_zoom); // persist the user's choice across reboots #endif renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } static void mapZoomToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_map_zoom_slider) return; if (lv_obj_has_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN)) { lv_slider_set_value(s_map_zoom_slider, s_map_zoom, LV_ANIM_OFF); lv_obj_clear_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_map_zoom_slider); if (s_map_zoom_val) { lv_label_set_text_fmt(s_map_zoom_val, TR("zoom %d"), (int)s_map_zoom); lv_obj_clear_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_map_zoom_val); } } else { lv_obj_add_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN); if (s_map_zoom_val) lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); } } // Position + show/hide the map zoom controls per s_map_zoom_buttons. The right-edge // button column is gear(4), [zoom], recenter, contacts, follow. The zoom section is // one slot (the "+/-" slider toggle) in slider mode or two (+ / -) in buttons mode, // so the buttons below shift down accordingly. static void mapZoomControlsApply() { const int X = k_map_canvas_w - 32 - 4; const int H = 32; auto show = [&](lv_obj_t* b, int yy) { if (b) { lv_obj_set_pos(b, X, yy); lv_obj_clear_flag(b, LV_OBJ_FLAG_HIDDEN); } }; auto hide = [](lv_obj_t* b) { if (b) lv_obj_add_flag(b, LV_OBJ_FLAG_HIDDEN); }; int y = 4 + H; // below the gear if (s_map_zoom_buttons) { show(s_map_btn_zoomin, y); y += H; show(s_map_btn_zoomout, y); y += H; hide(s_map_btn_zoomtoggle); if (s_map_zoom_slider) lv_obj_add_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN); // no slider in buttons mode if (s_map_zoom_val) lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); // Starts hidden; mapZoomStep flashes it on each +/- press. if (s_map_zoomval_hide) { lv_timer_del(s_map_zoomval_hide); s_map_zoomval_hide = nullptr; } } else { show(s_map_btn_zoomtoggle, y); y += H; hide(s_map_btn_zoomin); hide(s_map_btn_zoomout); } show(s_map_btn_recenter, y); y += H; show(s_map_btn_contacts, y); y += H; if (s_map_follow_btn) lv_obj_set_pos(s_map_follow_btn, X, y); } static void mapRecenterCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!g_lv.task) return; s_map_center_lat = g_lv.task->getNodeLat(); s_map_center_lon = g_lv.task->getNodeLon(); renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } // Auto-follow: while enabled, recenter the map on self whenever the GPS position // moves meaningfully (or the view was panned away). Called from the map tick. static void mapAutoFollowTick() { if (!s_map_follow || !g_lv.task) return; #if defined(HAS_M9_KEYBOARD) if (s_m9_map_pan) return; // Map-key pan mode owns the center; follow resumes when pan exits #endif const double lat = g_lv.task->getNodeLat(); const double lon = g_lv.task->getNodeLon(); if (lat == 0.0 && lon == 0.0) return; // no fix yet if (fabs(lat - s_map_center_lat) < 5e-5 && fabs(lon - s_map_center_lon) < 5e-5) return; // ~5 m: ignore GPS jitter s_map_center_lat = lat; s_map_center_lon = lon; renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } // Auto-follow toggle button (bottom of the right-edge column). Highlights when on // and recenters immediately so the map jumps to your position the moment you enable it. static void mapFollowToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_map_follow = !s_map_follow; if (s_map_follow_btn) { if (s_map_follow) lv_obj_add_state(s_map_follow_btn, LV_STATE_CHECKED); else lv_obj_clear_state(s_map_follow_btn, LV_STATE_CHECKED); } if (g_lv.task) g_lv.task->showAlert(s_map_follow ? TR("Auto-refresh on") : TR("Auto-refresh off"), 900); if (s_map_follow && g_lv.task) { const double lat = g_lv.task->getNodeLat(); const double lon = g_lv.task->getNodeLon(); if (lat != 0.0 || lon != 0.0) { s_map_center_lat = lat; s_map_center_lon = lon; renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } } } // Recenters on self GPS and rebuilds the tile grid. Called from tabChangedCb // every time the user switches TO the Map tab. static void onMapTabActivated() { #if defined(MULTI_TRANSPORT_COMPANION) // Fresh fetch slate each map open: forget the "recently queued" dedup ring so // tiles that failed or were dropped on a previous visit get another chance // (the fetch task still skips anything already on disk). Without this a level // that showed "downloading" once would never re-attempt on re-open. memset(s_tile_fetch_dedup, 0, sizeof(s_tile_fetch_dedup)); s_tile_fetch_dedup_head = 0; #endif // The entire UI is now clocked at 160 MHz from UITask::begin, so there's // no per-tab boost dance here anymore. (Tried 240 MHz briefly — the // PSRAM bus tightens enough at that clock for the SJPG decoder to // emit occasional RGB565 noise. 160 MHz is the sweet spot.) // Recenter on self + auto-snap zoom only on the FIRST map open this session // (or until we actually have a location to center on). After that, the user's // pan + zoom are remembered across tab switches (issue #5) — the center/zoom // statics already survive leaving the tab; this was the only thing wiping // them. A route replay sets its own fitted view (s_route_active). const bool have_center = !(s_map_center_lat == 0.0 && s_map_center_lon == 0.0); if (!s_route_active && (!s_map_view_inited || !have_center)) { if (g_lv.task) { const double la = g_lv.task->getNodeLat(); const double lo = g_lv.task->getNodeLon(); if (!(la == 0.0 && lo == 0.0)) { s_map_center_lat = la; s_map_center_lon = lo; } } // One-time auto-snap to the highest zoom level the pack actually contains // around this center. After this, the user's zoom-in/out taps are honoured // verbatim — they can pop above the pack and see "no tile pack" (a clear // cue to tap − to go back). // A user-saved zoom (persisted across reboots) wins over the auto-snap. bool have_saved_zoom = false; #if defined(ESP32) have_saved_zoom = (touchPrefsGetMapZoom() != 0); #endif const uint8_t best = bestAvailableZoom(s_map_center_lat, s_map_center_lon); if (best != 0 && !have_saved_zoom) s_map_zoom = best; if (!(s_map_center_lat == 0.0 && s_map_center_lon == 0.0)) s_map_view_inited = true; } // 9 SPIFFS reads + JPEG decodes take ~1-3 seconds on first paint. Show a // visible "loading" hint and force an LVGL render pass BEFORE we block on // the actual tile reads, so the user doesn't think the device froze. if (s_map_status_lbl) { lv_label_set_text(s_map_status_lbl, TR("Loading map\xe2\x80\xa6")); lv_obj_clear_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); } lv_refr_now(NULL); // drain one frame so the label paints before we block renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); } // Idle power-save indicator (iPhone Low-Power-Mode style, T-Deck only): instead of a separate moon // glyph, the status-bar battery turns amber while idle power-save is enabled. s_batt_base holds the // colour the theme/map chrome wants (off-white off-map, black/white over light tiles); applyBattColor // overlays the amber when power-save is on, so the map-chrome setter and the per-tick refresh share // one writer and never fight over the battery colour. static lv_color_t s_batt_base = lv_color_hex(COLOR_TEXT); static void applyBattColor() { if (!g_statusbar.batt_icon) return; lv_color_t c = s_batt_base; #if defined(HAS_TDECK_GT911) if (touchSleep::enabled()) c = lv_color_hex(0xFFD60A); // iOS systemYellow ≈ Low Power Mode #endif lv_obj_set_style_text_color(g_statusbar.batt_icon, c, LV_PART_MAIN); } // Immersive map chrome: on the Map tab the status bar, bottom info strip and // tab bar all go transparent so the (full-screen) map shows through behind them. // Because OSM tiles are LIGHT, the status-bar text/icons are switched to BLACK // for legibility, and reverted to the normal off-white when leaving the tab. static void applyMapChrome(bool on) { // ---- Background tile canvas: show it + make the tabview see-through so it // shows behind the (transparent) chrome. Hidden + opaque off-map. ---- if (s_map_canvas) { if (on) { lv_obj_clear_flag(s_map_canvas, LV_OBJ_FLAG_HIDDEN); lv_obj_move_background(s_map_canvas); } else { lv_obj_add_flag(s_map_canvas, LV_OBJ_FLAG_HIDDEN); } } if (g_lv.tabview) { // The tabview + its content + the Map page must be transparent on the map // tab so the background canvas shows through; opaque otherwise. lv_obj_set_style_bg_opa(g_lv.tabview, on ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* tvc = lv_tabview_get_content(g_lv.tabview); if (tvc) lv_obj_set_style_bg_opa(tvc, on ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); } if (s_map_page) lv_obj_set_style_bg_opa(s_map_page, on ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); // ---- Status bar (lv_layer_sys, floats above the map) ---- if (g_statusbar.root) { lv_obj_set_style_bg_opa(g_statusbar.root, on ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_opa(g_statusbar.root, on ? LV_OPA_TRANSP : LV_OPA_30, LV_PART_MAIN); // Night mode inverts the tiles to DARK, so the on-map chrome flips from black // (legible over light tiles) to off-white (legible over the dark inverted tiles). const bool night = on && s_map_night; const lv_color_t fg = lv_color_hex(!on ? COLOR_TEXT : (night ? 0xF0F0F0 : 0x000000)); const lv_color_t fg_sub = lv_color_hex(!on ? COLOR_SUB : (night ? 0xC8CCD0 : 0x000000)); if (g_statusbar.left_label) lv_obj_set_style_text_color(g_statusbar.left_label, fg, LV_PART_MAIN); s_batt_base = fg; // theme/map-driven battery colour... applyBattColor(); // ...with the power-save amber overlaid if enabled if (g_statusbar.batt_pct) lv_obj_set_style_text_color(g_statusbar.batt_pct, fg_sub, LV_PART_MAIN); if (g_statusbar.clock) lv_obj_set_style_text_color(g_statusbar.clock, fg_sub, LV_PART_MAIN); // The radio glyphs keep their accent tint off-map (matching the signal bars); on-map they // join the rest of the chrome in black/off-white, which the light OSM tiles need for legibility. const lv_color_t fg_radio = on ? fg_sub : lv_color_hex(COLOR_ACCENT); if (g_statusbar.conn_icon) lv_obj_set_style_text_color(g_statusbar.conn_icon, fg_radio, LV_PART_MAIN); if (g_statusbar.ble_icon) lv_obj_set_style_text_color(g_statusbar.ble_icon, fg_radio, LV_PART_MAIN); #if defined(HAS_TDECK_GT911) if (g_statusbar.sleep_icon) lv_obj_set_style_text_color(g_statusbar.sleep_icon, fg_sub, LV_PART_MAIN); #endif } // ---- Tab bar (bottom menu) — translucent so the map shows through ---- if (g_lv.tabview) { lv_obj_t* btns = lv_tabview_get_tab_btns(g_lv.tabview); if (btns) { // Fully transparent on the map — the black icons read directly over the // map tiles, no grey bar behind them. lv_obj_set_style_bg_opa(btns, on ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); // Day map: black icons over light tiles. Night map: off-white over dark tiles. const bool night_tabs = on && s_map_night; lv_obj_set_style_text_color(btns, lv_color_hex(!on ? COLOR_SUB : (night_tabs ? 0xE6EAEE : 0x101010)), LV_PART_ITEMS); // Off-map: active icon back to the ACCENT colour (not white). On-map: high- // contrast icon for the tile brightness. (The accent indicator bar is hidden // on the map separately by updateTabIndicator().) lv_obj_set_style_text_color(btns, lv_color_hex(!on ? COLOR_ACCENT : (night_tabs ? 0xFFFFFF : 0x000000)), LV_PART_ITEMS | LV_STATE_CHECKED); } } } static void makeMapTab(lv_obj_t* tab) { #if defined(TLORA_PAGER) // Diagnostic/map chrome stays compact at every Pager UI-size preset so the // overlays do not hide the map itself. const lv_font_t* map_info_font = &lv_font_montserrat_14; const lv_font_t* map_control_font = &lv_font_montserrat_20; #else const lv_font_t* map_info_font = &g_font_12; const lv_font_t* map_control_font = &g_font_16; #endif lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); styleSurface(tab, COLOR_BG, 0); lv_obj_set_style_pad_all(tab, 0, LV_PART_MAIN); s_map_page = tab; // Full-screen map. The TILES live on s_map_canvas — a full-screen surface // parented to the SCREEN ROOT and pushed to the background, so it sits BEHIND // the tabview (and thus paints behind the transparent status bar + tab bar on // the Map tab). Touch can't reach it there (the tabview's transparent map page // is on top), so panning is driven by a transparent touch-catcher in this // page (below). All tile/marker projection reads k_map_canvas_w/h → a // full-screen canvas projects to the full screen. k_map_canvas_w = lv_disp_get_hor_res(nullptr); k_map_canvas_h = lv_disp_get_ver_res(nullptr); mapComputeGridRadius(); // size the tile grid to cover this canvas edge-to-edge constexpr int kMapInfoH = 34; // bottom info strip height (floats over the map) s_map_canvas = lv_obj_create(lv_scr_act()); lv_obj_remove_style_all(s_map_canvas); lv_obj_set_size(s_map_canvas, k_map_canvas_w, k_map_canvas_h); lv_obj_set_pos(s_map_canvas, 0, 0); lv_obj_set_style_bg_color(s_map_canvas, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_canvas, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_map_canvas, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(s_map_canvas, LV_OBJ_FLAG_CLICKABLE); lv_obj_move_background(s_map_canvas); // behind the tabview lv_obj_add_flag(s_map_canvas, LV_OBJ_FLAG_HIDDEN); // shown only on the Map tab // Pan layer — a transparent container that holds tile widgets + marker // widgets. Sliding the pan layer's position during a finger drag moves // everything as a unit (one invalidation per frame instead of 19). s_map_pan_layer = lv_obj_create(s_map_canvas); lv_obj_remove_style_all(s_map_pan_layer); lv_obj_set_size(s_map_pan_layer, k_map_canvas_w, k_map_canvas_h); lv_obj_set_pos(s_map_pan_layer, 0, 0); lv_obj_set_style_bg_opa(s_map_pan_layer, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_clear_flag(s_map_pan_layer, LV_OBJ_FLAG_SCROLLABLE); // Touch events must still reach the canvas — make the pan layer's tap // events bubble (LV_OBJ_FLAG_EVENT_BUBBLE) but also let the canvas catch // its own PRESSED/PRESSING directly by NOT marking the layer clickable. lv_obj_clear_flag(s_map_pan_layer, LV_OBJ_FLAG_CLICKABLE); // Placeholder text until tiles render here. s_map_status_lbl = lv_label_create(s_map_canvas); lv_label_set_long_mode(s_map_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_map_status_lbl, k_map_canvas_w - 20); lv_obj_set_style_text_color(s_map_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(s_map_status_lbl, map_info_font, LV_PART_MAIN); lv_obj_set_style_text_align(s_map_status_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_label_set_text(s_map_status_lbl, TR("Map — no tile pack on SPIFFS yet.\n\n" "Upload a tile pack to /tiles/<z>/<x>/<y>.jpg " "with the host-side generator.")); lv_obj_center(s_map_status_lbl); // Bottom corner read-outs — transparent black text over the map, tucked into // the bottom corners UNDER the tab-bar icons: coords bottom-LEFT, marker / // download count bottom-RIGHT. A subtle light halo keeps them legible over // dark map patches. auto style_corner = [&](lv_obj_t* l) { lv_obj_set_style_text_color(l, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_text_font(l, map_info_font, LV_PART_MAIN); lv_obj_set_style_bg_color(l, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_bg_opa(l, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_pad_hor(l, 4, LV_PART_MAIN); lv_obj_set_style_pad_ver(l, 1, LV_PART_MAIN); lv_obj_set_style_radius(l, 3, LV_PART_MAIN); }; s_map_info_lbl = lv_label_create(tab); // coords (bottom-left) useChainedFont(s_map_info_lbl); style_corner(s_map_info_lbl); lv_label_set_text(s_map_info_lbl, "—"); lv_obj_align(s_map_info_lbl, LV_ALIGN_BOTTOM_LEFT, 2, -2); s_map_count_lbl = lv_label_create(tab); // marker / status count (bottom-right) useChainedFont(s_map_count_lbl); style_corner(s_map_count_lbl); lv_label_set_text(s_map_count_lbl, ""); lv_obj_align(s_map_count_lbl, LV_ALIGN_BOTTOM_RIGHT, -2, -2); // Zoom + tile path at the current center — a second line just under the // "© OpenStreetMap" status-bar attribution (top-left). s_map_zoom_lbl = lv_label_create(tab); useChainedFont(s_map_zoom_lbl); style_corner(s_map_zoom_lbl); lv_label_set_text(s_map_zoom_lbl, ""); lv_obj_align(s_map_zoom_lbl, LV_ALIGN_TOP_LEFT, 2, STATUSBAR_H + 2); applyMapTextVis(); // honour the coords / tile-line visibility toggles (void)kMapInfoH; // Touch catcher: the tiles live on the background canvas (behind the tabview) // and can't receive touch, so a full-page transparent layer in THIS tab page // drives pan/drag. It's the FIRST child (created before the overlay buttons + // info text below) so those sit on top and still catch their own taps. The // catcher and the canvas are both anchored at screen (0,0), so the touch // coordinates mapCanvasEventCb reads line up with the tile projection. s_map_touch = lv_obj_create(tab); lv_obj_remove_style_all(s_map_touch); lv_obj_set_size(s_map_touch, lv_pct(100), lv_pct(100)); lv_obj_set_pos(s_map_touch, 0, 0); lv_obj_set_style_bg_opa(s_map_touch, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_clear_flag(s_map_touch, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_map_touch, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(s_map_touch, NAV_SKIP_FLAG); // pan/drag catcher — never a keyboard-nav focus target (else the focus highlight paints it white) lv_obj_add_event_cb(s_map_touch, mapCanvasEventCb, LV_EVENT_PRESSED, nullptr); lv_obj_add_event_cb(s_map_touch, mapCanvasEventCb, LV_EVENT_PRESSING, nullptr); lv_obj_add_event_cb(s_map_touch, mapCanvasEventCb, LV_EVENT_RELEASED, nullptr); lv_obj_add_event_cb(s_map_touch, mapCanvasEventCb, LV_EVENT_PRESS_LOST, nullptr); lv_obj_move_to_index(s_map_touch, 0); // keep it beneath the buttons/labels // Overlay controls — siblings of the canvas inside the tab, so they // float on top of the tile grid and don't get freed when tiles refresh. // Layout: right-edge column [zoom+ / zoom- / recenter], 28-px buttons // with 4-px gutters. auto make_overlay_btn = [&](const char* sym, int y, lv_event_cb_t cb) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(tab); lv_obj_set_size(b, 32, 28); lv_obj_set_pos(b, k_map_canvas_w - 32 - 4, y); styleButton(b); lv_obj_set_style_bg_opa(b, LV_OPA_70, LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, sym); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, map_control_font, LV_PART_MAIN); lv_obj_center(l); return b; }; // Options gear sits at the TOP of the right-edge column; zoom/recenter below, // then a "contacts on map" picker (list of GPS-bearing contacts → recenter). make_overlay_btn(LV_SYMBOL_SETTINGS, 4, mapOpenOptionsCb); // gear (fixed top) // Zoom controls — the "+/-" slider toggle OR a +/- button pair, chosen by // s_map_zoom_buttons. All created here; mapZoomControlsApply() (below) positions // + shows the right set and shifts recenter/contacts/follow down accordingly. s_map_btn_zoomtoggle = make_overlay_btn("+/-", 4 + 32, mapZoomToggleCb); // toggles the zoom slider s_map_btn_zoomin = make_overlay_btn("+", 4 + 32, mapZoomInCb); s_map_btn_zoomout = make_overlay_btn("-", 4 + 32*2, mapZoomOutCb); s_map_btn_recenter = make_overlay_btn(LV_SYMBOL_GPS, 4 + 32*2, mapRecenterCb); s_map_btn_contacts = make_overlay_btn(LV_SYMBOL_LIST, 4 + 32*3, mapOpenContactsCb); // Auto-follow toggle: recenters on self whenever the GPS coords change. Lit // (accent) while active via the CHECKED state. s_map_follow_btn = make_overlay_btn(LV_SYMBOL_REFRESH, 4 + 32*4, mapFollowToggleCb); lv_obj_set_style_bg_color(s_map_follow_btn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_CHECKED); lv_obj_set_style_bg_opa(s_map_follow_btn, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_CHECKED); if (s_map_follow) lv_obj_add_state(s_map_follow_btn, LV_STATE_CHECKED); // Zoom slider — a full-width overlay along the bottom of the map, hidden until // the zoom button is tapped. Reuses the control-centre brightness-slider look. s_map_zoom_slider = lv_slider_create(tab); lv_obj_set_size(s_map_zoom_slider, k_map_canvas_w - 24, 8); lv_obj_align(s_map_zoom_slider, LV_ALIGN_BOTTOM_MID, 0, -(TABBAR_H + 8)); lv_slider_set_range(s_map_zoom_slider, k_map_zoom_min, k_map_zoom_max); lv_slider_set_value(s_map_zoom_slider, s_map_zoom, LV_ANIM_OFF); lv_obj_set_style_bg_color(s_map_zoom_slider, lv_color_hex(0x202428), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_zoom_slider, LV_OPA_80, LV_PART_MAIN); lv_obj_set_style_bg_color(s_map_zoom_slider, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_color(s_map_zoom_slider, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); lv_obj_set_style_pad_all(s_map_zoom_slider, 6, LV_PART_KNOB); lv_obj_add_event_cb(s_map_zoom_slider, mapZoomSliderCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(s_map_zoom_slider, mapZoomSliderReleaseCb, LV_EVENT_RELEASED, nullptr); lv_obj_add_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN); // Live zoom-level readout, centred above the whole slider; shown with it. s_map_zoom_val = lv_label_create(tab); lv_label_set_text(s_map_zoom_val, TR("zoom")); lv_obj_set_style_text_font(s_map_zoom_val, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(s_map_zoom_val, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_bg_color(s_map_zoom_val, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_map_zoom_val, LV_OPA_60, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_map_zoom_val, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_map_zoom_val, 2, LV_PART_MAIN); lv_obj_set_style_radius(s_map_zoom_val, 4, LV_PART_MAIN); lv_obj_align_to(s_map_zoom_val, s_map_zoom_slider, LV_ALIGN_OUT_TOP_MID, 0, -6); lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN); mapZoomControlsApply(); // position + show/hide slider-toggle vs +/- buttons per the pref // (OSM attribution now lives in the status bar's left zone on the map tab — // see updateGlobalStatusBar.) } // Refresh the bottom info strip — called from the periodic refresh tick // once the Map tab is active. Cheap (one snprintf + label set). static void refreshMapInfoLabel() { if (!s_map_info_lbl || !g_lv.task) return; const double lat = g_lv.task->getNodeLat(); const double lon = g_lv.task->getNodeLon(); // Count contacts with non-zero GPS — the actual map will plot these as // markers once tile rendering is in. int with_gps = 0; for (uint32_t i = 0; i < the_mesh.getNumContacts(); ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (c.gps_lat != 0 || c.gps_lon != 0) ++with_gps; } // Compact map-coverage hint. When the current view has gaps (tiles not // yet on disk), say what's happening instead of just the marker count: // • tiles queued/downloading -> "downloading N" // • gaps but Wi-Fi is down -> "Wi-Fi off" // Otherwise show the usual marker count. char tail[28]; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool wifi_up = (WiFi.status() == WL_CONNECTED); if (tileFetchPendingLoad() > 0) { snprintf(tail, sizeof(tail), "\xe2\x86\x93 %u downloading", (unsigned)tileFetchPendingLoad()); } else if (s_map_last_missing > 0 && !wifi_up) { snprintf(tail, sizeof(tail), "Wi-Fi off \xe2\x80\x94 gaps"); } else if (s_map_markers_wanted > s_map_markers_drawn) { // More positioned contacts are on screen than we drew dots for. Say so: the old // label counted every positioned contact and the map quietly stopped at its cap, // so the two disagreed with no explanation anywhere. snprintf(tail, sizeof(tail), TR("%d of %d on map"), s_map_markers_drawn, with_gps); } else { snprintf(tail, sizeof(tail), TR("%d on map"), with_gps); } #else if (s_map_markers_wanted > s_map_markers_drawn) snprintf(tail, sizeof(tail), TR("%d of %d on map"), s_map_markers_drawn, with_gps); else snprintf(tail, sizeof(tail), TR("%d on map"), with_gps); #endif // Coords → bottom-left corner; count/status → bottom-right corner. char buf[40]; if (lat == 0.0 && lon == 0.0) snprintf(buf, sizeof(buf), TR("GPS unset")); else snprintf(buf, sizeof(buf), "%.4f, %.4f", lat, lon); lv_label_set_text(s_map_info_lbl, buf); if (s_map_count_lbl) lv_label_set_text(s_map_count_lbl, tail); // Zoom + tile path (z / x / y) at the current map center — the "second line" // under the © OpenStreetMap attribution. Uses the map center (post-pan), not // the self GPS fix. if (s_map_zoom_lbl) { if (s_map_center_lat == 0.0 && s_map_center_lon == 0.0) { lv_label_set_text(s_map_zoom_lbl, ""); } else { double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); const long tx = (long)floor(cwx / 256.0); const long ty = (long)floor(cwy / 256.0); char zbuf[120]; bool force_show = false; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) if (s_map_tile_debug) { // Developer tile-pipeline diagnostic (Map options → "Tile debug overlay"). // Serial is unreadable on the companion build, so this two-line overlay is // the only window into the tile pipeline: d = tiles placed/wanted this pass, // p = live 128 KB pool buffers, wr = last cache-write outcome (w=ok S=cache // full H=heap O=open Z=len e=http J=notjpeg P=short), h = last HTTP code, // ok/f = downloads ok/failed, ps = free PSRAM KB, blk = largest contiguous // block KB (a tile needs 128 KB contiguous; blk<128 while ps high == frag). const int psf = (int)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024); const int psb = (int)(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); int pbufs = 0; for (int _t = 0; _t < k_map_visible_tiles_max; ++_t) if (s_map_tiles[_t].rgb565) ++pbufs; snprintf(zbuf, sizeof zbuf, "z%u d%d/%d p%d wr%c h%d ok%u f%u\nps%dk blk%dk", (unsigned)s_map_zoom, s_tile_dec_ok, s_tile_dec_want, pbufs, (char)s_tile_fetch_last_wr, (int)s_tile_fetch_last_code, (unsigned)s_tile_fetch_ok, (unsigned)s_tile_fetch_failed, psf, psb); force_show = true; // diagnostic overrides the "Show tile z/x/y" pref } else #endif { snprintf(zbuf, sizeof zbuf, "z%u %u/%ld/%ld", (unsigned)s_map_zoom, (unsigned)s_map_zoom, tx, ty); } lv_label_set_text(s_map_zoom_lbl, zbuf); // Visibility: the debug overlay forces it on; otherwise follow the // "Show tile z/x/y" pref (so turning debug off restores normal behaviour). if (force_show || s_map_show_tilexyz) lv_obj_clear_flag(s_map_zoom_lbl, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(s_map_zoom_lbl, LV_OBJ_FLAG_HIDDEN); } } } // ---- Discord-style unread divider + jump-to-latest (state declared earlier; // see s_unread_at_open / s_chat_just_opened above backBtnCb) ---- static void jumpToLatestCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->msgs) return; chatVirtJumpToLatest(p); } static void jumpToOldestCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->msgs) return; chatVirtJumpToOldest(p); } // Show the jump-to-latest button whenever the list is scrolled up away from the // newest message; hide it when at (or near) the bottom. static void chatVirtSyncBubblePositions(LvChatPanel* p); static void chatVirtOnScrollEnd(LvChatPanel* p); static void chatVirtRemap1To1Scroll(LvChatPanel* p); static bool chatVirtAwayFromBottom(LvChatPanel* p); // Jump-arrow fade: fully visible while the list is scrolling, 50% one second // after the last scroll activity (UITask::loop does the dimming; every // chatUpdateJumpButtons call — which msgsScrollCb fires per scroll tick — // re-brightens and re-arms the timer). They stay tappable either way. static uint32_t s_jump_active_ms = 0; static bool s_jump_dimmed = false; static void jumpBtnsSetDim(LvChatPanel* p, bool dim) { const lv_opa_t o = dim ? LV_OPA_50 : LV_OPA_COVER; if (p->jump_oldest_btn) lv_obj_set_style_opa(p->jump_oldest_btn, o, LV_PART_MAIN); if (p->jump_btn) lv_obj_set_style_opa(p->jump_btn, o, LV_PART_MAIN); s_jump_dimmed = dim; } static void chatUpdateJumpButtons(LvChatPanel* p) { if (!p || !p->msgs) return; s_jump_active_ms = millis(); if (s_jump_dimmed) jumpBtnsSetDim(p, false); // Always evaluated, even with no jump buttons to toggle (the pager builds // neither): chatVirtAwayFromBottom -> chatVirtMaxScrollY has the LOAD-BEARING // side effect of sizing the virt spacer (chatVirtRefreshScrollArea), and the // opening refreshChatDetail relies on this call to establish the scroll // extent BEFORE the async render applies the queued open-to-divider/bottom // scroll. With the call skipped behind the (pager-null) jump_btn guard, that // scroll_to_y clamped against a ~0-height content area and every chat opened // pinned to the TOP (oldest) instead of the NEW divider / newest message. const bool away = chatVirtAwayFromBottom(p); if (p->jump_oldest_btn) { if (lv_obj_get_scroll_y(p->msgs) > 30) lv_obj_clear_flag(p->jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(p->jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); } if (p->jump_btn) { if (away) lv_obj_clear_flag(p->jump_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(p->jump_btn, LV_OBJ_FLAG_HIDDEN); } } #if TRACE_MESSAGE_SCROLL_ACTIVITY static void chatVirtLogTopAnchor(const char* tag, LvChatPanel* p, lv_coord_t scroll_y, int touch_x = -1, int touch_y = -1); static void chatMsgsTouchDbgCb(lv_event_t* e) { auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->msgs || s_chat_msgs_scroll_obj != p->msgs) return; const lv_event_code_t code = lv_event_get_code(e); if (code != LV_EVENT_PRESSED && code != LV_EVENT_PRESSING && code != LV_EVENT_RELEASED) return; lv_indev_t* indev = lv_indev_get_act(); lv_point_t pt = {0, 0}; if (indev) lv_indev_get_point(indev, &pt); const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); if (code == LV_EVENT_PRESSED) { s_chat_touch_on_msgs = true; s_chat_dbg_last_scroll_logged = scroll_y; chatVirtLogTopAnchor("touch_begin", p, scroll_y, pt.x, pt.y); } else if (code == LV_EVENT_PRESSING) { if (scroll_y != s_chat_dbg_last_scroll_logged) { s_chat_dbg_last_scroll_logged = scroll_y; chatVirtLogTopAnchor("touch_move", p, scroll_y, pt.x, pt.y); } } else { s_chat_touch_on_msgs = false; chatVirtLogTopAnchor("touch_end", p, scroll_y, pt.x, pt.y); s_chat_dbg_last_scroll_logged = -9999; } } #endif static void chatMsgsScrollEndCb(lv_event_t* e) { scrollClampOnEndCb(e); if (lv_event_get_code(e) != LV_EVENT_SCROLL_END) return; chatVirtOnScrollEnd(static_cast<LvChatPanel*>(lv_event_get_user_data(e))); } static void msgsScrollCb(lv_event_t* e) { auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e)); if (!p || !p->msgs) return; chatVirtRemap1To1Scroll(p); chatUpdateJumpButtons(p); chatVirtSyncBubblePositions(p); #if TRACE_MESSAGE_SCROLL_ACTIVITY if (s_chat_touch_on_msgs && s_chat_msgs_scroll_obj == p->msgs) { const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); if (scroll_y != s_chat_dbg_last_scroll_logged) { s_chat_dbg_last_scroll_logged = scroll_y; chatVirtLogTopAnchor("scroll_move", p, scroll_y); } } #endif } #if defined(HAS_TANMATSU) // Draw one coloured F-key OUTLINE shape (△ □ ○ ⏢ ◇) into a fresh SZ×SZ canvas child of `parent`, // centred. Mirrors the bottom tab-bar key hints (navBuildTabKeyHints) so the in-chat chips read as // "press the same-coloured hardware key". Returns the canvas (non-clickable). static lv_obj_t* makeFkeyShape(lv_obj_t* parent, int shape, uint32_t rgb, int SZ) { lv_color_t col = lv_color_hex(rgb); lv_obj_t* cv = lv_canvas_create(parent); lv_obj_clear_flag(cv, LV_OBJ_FLAG_CLICKABLE); uint8_t* buf = (uint8_t*)heap_caps_malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR_ALPHA(SZ, SZ), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) buf = (uint8_t*)malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR_ALPHA(SZ, SZ)); if (!buf) return cv; lv_canvas_set_buffer(cv, buf, SZ, SZ, LV_IMG_CF_TRUE_COLOR_ALPHA); lv_canvas_fill_bg(cv, lv_color_black(), LV_OPA_TRANSP); lv_draw_line_dsc_t ld; lv_draw_line_dsc_init(&ld); ld.color = col; ld.width = 2; ld.round_start = ld.round_end = 1; lv_draw_rect_dsc_t rd; lv_draw_rect_dsc_init(&rd); rd.bg_opa = LV_OPA_TRANSP; rd.border_color = col; rd.border_width = 2; rd.border_opa = LV_OPA_COVER; const lv_coord_t hh = (lv_coord_t)(SZ / 2); const lv_coord_t ed = (lv_coord_t)(SZ - 2); const lv_coord_t q1 = (lv_coord_t)(SZ / 4); const lv_coord_t q3 = (lv_coord_t)(SZ - SZ / 4); const lv_coord_t e3 = (lv_coord_t)(SZ - 3); switch (shape) { case 0: { lv_point_t p[4] = {{hh,1},{1,ed},{ed,ed},{hh,1}}; lv_canvas_draw_line(cv, p, 4, &ld); } break; // △ case 1: rd.radius = 4; lv_canvas_draw_rect(cv, 1, 1, SZ-2, SZ-2, &rd); break; // □ case 2: rd.radius = LV_RADIUS_CIRCLE; lv_canvas_draw_rect(cv, 1, 1, SZ-2, SZ-2, &rd); break; // ○ case 3: { lv_point_t p[5] = {{q1,2},{q3,2},{ed,e3},{2,e3},{q1,2}}; lv_canvas_draw_line(cv, p, 5, &ld); } break; // ⏢ trapezoid default:{ lv_point_t p[5] = {{hh,1},{ed,hh},{hh,ed},{1,hh},{hh,1}}; lv_canvas_draw_line(cv, p, 5, &ld); } break; // ◇ } lv_obj_align(cv, LV_ALIGN_CENTER, 0, 0); return cv; } // Re-skin an existing composer chip / floating button as a coloured F-key shape: strip its round // fill, draw the shape behind the icon, and tint a monochrome icon to match (a colour-emoji icon // keeps its own colours → pass tint_icon=false). static void styleChipAsFkey(lv_obj_t* btn, lv_obj_t* icon, int shape, uint32_t rgb, int SZ, bool tint_icon) { if (!btn) return; lv_obj_set_style_bg_opa(btn, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); lv_obj_set_style_shadow_width(btn, 0, LV_PART_MAIN); makeFkeyShape(btn, shape, rgb, SZ); if (icon) { lv_obj_move_foreground(icon); // keep the glyph above the shape if (shape == 0) lv_obj_align(icon, LV_ALIGN_CENTER, 0, SZ / 6); // △: drop the glyph into the wider lower body so it fits if (tint_icon) lv_obj_set_style_text_color(icon, lv_color_hex(rgb), LV_PART_MAIN); } } #endif // HAS_TANMATSU // ----- Chat detail (full-screen overlay on lv_scr_act) ------- static void makeChatDetail(LvChatPanel& p) { p.overlay = lv_obj_create(lv_scr_act()); lv_obj_set_size(p.overlay, chatScreenW(), chatScreenH()); // Overlay starts under the SOLID top row of the status bar; the bar's translucent // glass lower row floats over the top of the message list (which insets for it). lv_obj_set_pos(p.overlay, 0, STATUSBAR_H); styleSurface(p.overlay, COLOR_BG, 0); lv_obj_set_style_pad_all(p.overlay, 0, LV_PART_MAIN); // prevent child-position offset lv_obj_clear_flag(p.overlay, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(p.overlay, LV_OBJ_FLAG_HIDDEN); // ---- No header bar ---- // The old back-button + thread-name bar ate ~44 px of every conversation. The // thread name now lives in the global status bar (set on open via // setChatStatusTitle), and a small floating HOME button (created last, at the // end of this fn so it sits on top) handles exit — mirroring the Terminal/Files // fullscreen views. Gives the conversation the full height. p.header_name = nullptr; // Fresh panel starts at the single-line composer height; it grows as the user // types (chatComposerAutoGrow). Reset here so a previous chat's grown height // doesn't leak into this one's initial layout math below. s_comp_h = chatComposerBaseH(); // ---- Message area (scrollable container of speech-bubble children) ---- // Each message gets its own lv_obj bubble inside p.msgs so we can style // outgoing/incoming differently and align left/right WhatsApp-style. p.msgs = lv_obj_create(p.overlay); lv_obj_set_size(p.msgs, chatScreenW(), chatMsgHOpen()); lv_obj_set_pos(p.msgs, 0, CHAT_HDR_H); styleSurface(p.msgs, COLOR_BG, 0); lv_obj_set_style_border_width(p.msgs, 0, LV_PART_MAIN); lv_obj_set_style_radius(p.msgs, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(p.msgs, 6, LV_PART_MAIN); // The status bar's glass lower row floats over the TOP of the list on boards // with a two-row chat header. Pager keeps the chat header in one regular row, // so retaining that old row-sized inset only wastes message space. #if defined(TLORA_PAGER) lv_obj_set_style_pad_top(p.msgs, 6, LV_PART_MAIN); #else lv_obj_set_style_pad_top(p.msgs, STATUSBAR_H + 6, LV_PART_MAIN); #endif // The composer floats over the list's lower edge (transparent row), so reserve a // bottom inset == the composer height: the newest bubble rests just above it and // older ones scroll UNDER it. Kept in sync with s_comp_h by chatComposerAutoGrow. lv_obj_set_style_pad_bottom(p.msgs, s_comp_h + 6, LV_PART_MAIN); lv_obj_set_style_text_color(p.msgs, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(p.msgs, chatMessageFont(), LV_PART_MAIN); lv_obj_set_scrollbar_mode(p.msgs, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_scroll_dir(p.msgs, LV_DIR_VER); lv_obj_set_layout(p.msgs, 0); // no flex/grid — bubbles use absolute Y; spacer sets scroll height lv_obj_add_event_cb(p.msgs, chatMsgsScrollEndCb, LV_EVENT_SCROLL_END, &p); lv_obj_add_event_cb(p.msgs, msgsScrollCb, LV_EVENT_SCROLL, &p); // toggle jump-to-latest button #if TRACE_MESSAGE_SCROLL_ACTIVITY lv_obj_add_event_cb(p.msgs, chatMsgsTouchDbgCb, LV_EVENT_PRESSED, &p); lv_obj_add_event_cb(p.msgs, chatMsgsTouchDbgCb, LV_EVENT_PRESSING, &p); lv_obj_add_event_cb(p.msgs, chatMsgsTouchDbgCb, LV_EVENT_RELEASED, &p); #endif // ---- Jump buttons (Discord-style): floating circles, right edge, symmetric // near the top/bottom of the message list. Hidden unless there is // history beyond the viewport. // Bare arrows, flush against the right edge — no button chrome. The 28x36 // transparent button keeps a usable touch target (plus ext_click_area) while // only the glyph is visible; UITask::loop dims them to 50% one second after // the last scroll (jumpBtnsSetDim above). // Both floating jump arrows are touch-only affordances with no purpose on // the T-LoRa Pager (no touchscreen), so skip creating them on that board // and leave the pointers null like every consumer already handles safely // (chatUpdateJumpButtons, jumpBtnsSetDim, the LvChatPanel reset — all // null-guarded). The pager's own Backspace-tap "jump to latest" shortcut // below no longer depends on jump_btn's existence; it checks // chatVirtAwayFromBottom() directly and calls chatVirtJumpToLatest(), the // same virtualization-aware jump this button's own click handler uses. #if !defined(TLORA_PAGER) p.jump_oldest_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_oldest_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_oldest_btn, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_shadow_width(p.jump_oldest_btn, 0, LV_PART_MAIN); lv_obj_set_style_border_width(p.jump_oldest_btn, 0, LV_PART_MAIN); lv_obj_set_style_outline_width(p.jump_oldest_btn, 0, LV_PART_MAIN); lv_obj_set_ext_click_area(p.jump_oldest_btn, 6); lv_obj_set_pos(p.jump_oldest_btn, chatScreenW() - 28, CHAT_HDR_H + STATUSBAR_H + 2); lv_obj_t* jolbl = lv_label_create(p.jump_oldest_btn); lv_label_set_text(jolbl, LV_SYMBOL_UP); lv_obj_set_style_text_font(jolbl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(jolbl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_center(jolbl); #if defined(HAS_TANMATSU) // Purple F-key chip (matches the hardware key hints) — keep its own geometry. lv_obj_set_size(p.jump_oldest_btn, 40, 40); lv_obj_set_pos(p.jump_oldest_btn, chatScreenW() - 42, CHAT_HDR_H + STATUSBAR_H + 2); styleChipAsFkey(p.jump_oldest_btn, jolbl, 4, 0xC724B1, 40, true); lv_obj_set_style_bg_color(p.jump_oldest_btn, lv_color_hex(0x101113), LV_PART_MAIN); lv_obj_set_style_bg_opa(p.jump_oldest_btn, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_radius(p.jump_oldest_btn, 8, LV_PART_MAIN); #endif lv_obj_add_event_cb(p.jump_oldest_btn, jumpToOldestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); #endif // !TLORA_PAGER (jump_oldest_btn) #if !defined(TLORA_PAGER) p.jump_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_btn, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_shadow_width(p.jump_btn, 0, LV_PART_MAIN); lv_obj_set_style_border_width(p.jump_btn, 0, LV_PART_MAIN); lv_obj_set_style_outline_width(p.jump_btn, 0, LV_PART_MAIN); lv_obj_set_ext_click_area(p.jump_btn, 6); lv_obj_set_pos(p.jump_btn, chatScreenW() - 28, chatCompYOpen() - 40); lv_obj_t* jlbl = lv_label_create(p.jump_btn); lv_label_set_text(jlbl, LV_SYMBOL_DOWN); lv_obj_set_style_text_font(jlbl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(jlbl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_center(jlbl); #if defined(HAS_TANMATSU) // Purple ⏢ trapeze (matches the F6 hardware key). Keep a subtle dark backing so the // outline + arrow stay legible while it floats over the message bubbles. lv_obj_set_size(p.jump_btn, 40, 40); lv_obj_set_pos(p.jump_btn, chatScreenW() - 42, chatCompYOpen() - 50); styleChipAsFkey(p.jump_btn, jlbl, 4, 0xC724B1, 40, true); // ◇ — same shape as the menubar's purple key lv_obj_set_style_bg_color(p.jump_btn, lv_color_hex(0x101113), LV_PART_MAIN); lv_obj_set_style_bg_opa(p.jump_btn, LV_OPA_70, LV_PART_MAIN); lv_obj_set_style_radius(p.jump_btn, 8, LV_PART_MAIN); #endif lv_obj_add_event_cb(p.jump_btn, jumpToLatestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_btn, LV_OBJ_FLAG_HIDDEN); #endif // !TLORA_PAGER (jump_btn) // ---- Composer row ---- const lv_coord_t composer_h = chatComposerBaseH(); p.composer_row = lv_obj_create(p.overlay); lv_obj_set_size(p.composer_row, chatScreenW(), composer_h); lv_obj_set_pos(p.composer_row, 0, chatCompYOpen()); // Transparent row + no border: only the rounded chips / textarea / send button show, // and the chat bubbles scroll UNDER them (p.msgs extends to full height behind it). styleSurface(p.composer_row, COLOR_PANEL, 0); lv_obj_set_style_bg_opa(p.composer_row, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_clear_flag(p.composer_row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_border_width(p.composer_row, 0, LV_PART_MAIN); lv_obj_set_style_pad_hor(p.composer_row, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(p.composer_row, 2, LV_PART_MAIN); // Layout math (row = 240 px, pad_hor = 4 → content area = 232): // QR(30) + gap(6) + Emoji(30) + gap(6) + TA + gap(6) + Send(34) // The QR + Emoji chips share the left edge; the textarea fills the middle. lv_obj_set_style_pad_hor(p.composer_row, 4, LV_PART_MAIN); // Composer chip size: ~2x on the large screen (Tanmatsu) so the QR/emoji chips // aren't tiny on the 800-px panel. The 64-px composer row (CHAT_COMP_H on // CAP_LARGE_SCREEN) has room; 30 px elsewhere keeps the small boards unchanged. const lv_coord_t chip_sz = chatComposerChipSz(); const lv_coord_t chip_gap = 6; // Macro picker button: a compact chip taps to a popup grid of user- // defined quick-reply presets. Saves typing for stock phrases ("ok", // "on the way", ...) — same idea as MCterm's preset macros. Editable // from Settings → Quick replies. lv_obj_t* qr_btn = lv_btn_create(p.composer_row); lv_obj_set_size(qr_btn, chip_sz, chip_sz); lv_obj_align(qr_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); styleButton(qr_btn); lv_obj_set_style_radius(qr_btn, chip_sz / 2, LV_PART_MAIN); // Glass chip: dark + translucent so the chat bubbles show through faintly. lv_obj_set_style_bg_color(qr_btn, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(qr_btn, LV_OPA_50, LV_PART_MAIN); lv_obj_add_event_cb(qr_btn, openQuickReplyPickerCb, LV_EVENT_CLICKED, &p); lv_obj_t* ql = lv_label_create(qr_btn); lv_label_set_text(ql, LV_SYMBOL_LIST); lv_obj_set_style_text_font(ql, &g_font_14, LV_PART_MAIN); lv_obj_center(ql); #if defined(HAS_TANMATSU) styleChipAsFkey(qr_btn, ql, 0, 0xF5A623, chip_sz, true); // orange △ — quick replies (F2) // styleChipAsFkey clears the bg to draw the shape — restore the glass backing behind it. lv_obj_set_style_bg_color(qr_btn, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(qr_btn, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_radius(qr_btn, chip_sz / 2, LV_PART_MAIN); // ~40% smaller list glyph (fixed size, not the UI-scaled g_font_14) so it sits neatly in // the narrow lower body of the △ instead of overflowing it. lv_obj_set_style_text_font(ql, &lv_font_montserrat_14, LV_PART_MAIN); #endif // Emoji / special-character picker button (smiley). Opens the insert grid. lv_obj_t* emoji_btn = lv_btn_create(p.composer_row); lv_obj_set_size(emoji_btn, chip_sz, chip_sz); lv_obj_align(emoji_btn, LV_ALIGN_BOTTOM_LEFT, chip_sz + chip_gap, 0); // past the QR chip + gap styleButton(emoji_btn); lv_obj_set_style_radius(emoji_btn, chip_sz / 2, LV_PART_MAIN); // Glass chip: dark + translucent so the chat bubbles show through faintly. lv_obj_set_style_bg_color(emoji_btn, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(emoji_btn, LV_OPA_50, LV_PART_MAIN); lv_obj_add_event_cb(emoji_btn, openEmojiPickerCb, LV_EVENT_CLICKED, &p); lv_obj_t* el = lv_label_create(emoji_btn); lv_label_set_text(el, TR("\xF0\x9F\x98\x8A")); // 😊 lv_obj_set_style_text_font(el, &g_font_16, LV_PART_MAIN); lv_obj_center(el); #if defined(HAS_TANMATSU) styleChipAsFkey(emoji_btn, el, 1, 0xFFD400, chip_sz, false); // yellow □ — emoji picker (F3); keep the colour glyph // styleChipAsFkey clears the bg to draw the shape — restore the glass backing behind it. lv_obj_set_style_bg_color(emoji_btn, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(emoji_btn, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_radius(emoji_btn, chip_sz / 2, LV_PART_MAIN); #if LV_USE_IMGFONT // The colour emoji is a fixed ~16-px baked image (it doesn't scale with the font), so it // looks tiny in the big □. Hide the label glyph and draw the same emoji as a 2x-zoomed // image so it fills the shape. if (const lv_img_dsc_t* sm = emojiGlyphLookup(0x1F60A)) { // 😊 lv_obj_add_flag(el, LV_OBJ_FLAG_HIDDEN); lv_obj_t* eimg = lv_img_create(emoji_btn); lv_img_set_src(eimg, sm); lv_img_set_antialias(eimg, true); lv_img_set_zoom(eimg, 512); // 256 = 1x -> 512 = 2x lv_obj_center(eimg); lv_obj_move_foreground(eimg); } #endif #endif // Channel settings lives on the TOP status-bar gear (styled as the green ○ key), not a // composer chip — so the left chip row stays QR + emoji and the textarea geometry is fixed. const lv_coord_t comp_ta_x = 2 * chip_sz + 2 * chip_gap; // start past both left chips + gaps const lv_coord_t send_sz = chatComposerSendSz(); const lv_coord_t comp_ta_w = chatComposerTaW(); p.composer_ta = lv_textarea_create(p.composer_row); // Fill the row between the two left chips and Send (right): content width is // screen - 8 (pad), minus QR(30)+gap(6)+Emoji(30)+gap(6) on the left and // gap(6)+Send(34) on the right => screen - 120. Widens with the screen. lv_obj_set_size(p.composer_ta, comp_ta_w, composer_h - 4); // 30 (QR) + 6 + 30 (Emoji) + 6 = 72 offset from content-left. Bottom-aligned so // the box grows UPWARD as the message wraps to more lines (chatComposerAutoGrow). lv_obj_align(p.composer_ta, LV_ALIGN_BOTTOM_LEFT, comp_ta_x, 0); styleCard(p.composer_ta); lv_obj_set_style_radius(p.composer_ta, 15, LV_PART_MAIN); // pill shape // Keep a few px of vertical slack in the textarea CONTENT so it never sits at // exactly one line-height (that made the internal scroll oscillate ±1px per // keystroke — the text bobbed up/down). The text is centred via the inner // label's pad_top below instead. lv_obj_set_style_pad_ver(p.composer_ta, 3, LV_PART_MAIN); // Multi-line: wrap long text onto extra lines (the row grows to fit, then // scrolls down) instead of scrolling a single line sideways. Enter still sends // — the physical-keyboard CR and the on-screen OK are handled before any // newline can be inserted, so the composer never actually holds a '\n'. lv_textarea_set_one_line(p.composer_ta, false); // Cap input at the LoRa text limit so the user can't type more than can be sent (GH #119). // (LVGL caps by codepoint; for ASCII that's exactly MAX_MSG_TEXT bytes. The send buffer, // _compose_buf, is sized MAX_MSG_TEXT+1 to match — it used to be 128, silently chopping // messages at 127 bytes, shorter than this limit.) lv_textarea_set_max_length(p.composer_ta, UITask::MAX_MSG_TEXT); lv_obj_set_scrollbar_mode(p.composer_ta, LV_SCROLLBAR_MODE_OFF); taSetPlaceholder(p.composer_ta, TR("Type a message...")); lv_obj_set_style_text_color(p.composer_ta, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(p.composer_ta, &g_font_14, LV_PART_MAIN); // Readable selection highlight: the label draws selected text using its OWN // LV_PART_SELECTED text+bg colour, and the theme sets neither — so without // this the default pair makes a highlighted word unreadable. Inverse video // (near-black text on off-white) reads cleanly in the dark UI. if (lv_obj_t* comp_lbl = lv_textarea_get_label(p.composer_ta)) { lv_obj_set_style_bg_color(comp_lbl, lv_color_hex(COLOR_TEXT), LV_PART_SELECTED); lv_obj_set_style_bg_opa(comp_lbl, LV_OPA_COVER, LV_PART_SELECTED); lv_obj_set_style_text_color(comp_lbl, lv_color_hex(COLOR_PANEL), LV_PART_SELECTED); // Push the text down inside the (roomy) content so it sits centred — 3 px // textarea pad + 4 px here = the line at y=7 of the 30 px box. The content keeps // its slack, so no exact-fit scroll jitter, and the auto-grow centres every line count. lv_obj_set_style_pad_top(comp_lbl, 4, LV_PART_MAIN); } // Tap on composer → show keyboard lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_FOCUSED, &p); #if CAP_TOUCH && !CAP_KEYBOARD // A composer may remain group-focused after the keyboard is dismissed, so // a later tap emits CLICKED without another FOCUSED event. lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_CLICKED, &p); #endif lv_obj_add_event_cb(p.composer_ta, kbActivityPressCb, LV_EVENT_PRESSED, nullptr); // Grow / shrink the composer row as the message wraps (every text change). lv_obj_add_event_cb(p.composer_ta, composerAutoGrowCb, LV_EVENT_VALUE_CHANGED, &p); lv_obj_add_event_cb(p.composer_ta, composerSuggestChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); // Double-tap a word to select it; long-press for Cut/Copy/Paste/Select-All. lv_obj_add_event_cb(p.composer_ta, composerEditClickedCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(p.composer_ta, composerEditLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); lv_obj_t* send = lv_btn_create(p.composer_row); lv_obj_set_size(send, send_sz, #if defined(TLORA_PAGER) send_sz #else 30 #endif ); lv_obj_align(send, LV_ALIGN_BOTTOM_RIGHT, 0, 0); styleButton(send); lv_obj_set_style_radius(send, 15, LV_PART_MAIN); lv_obj_add_event_cb(send, sendFromPanelCb, LV_EVENT_CLICKED, &p); lv_obj_t* sl = lv_label_create(send); lv_label_set_text(sl, LV_SYMBOL_RIGHT); lv_obj_set_style_text_font(sl, &g_font_16, LV_PART_MAIN); lv_obj_center(sl); // No floating HOME button — exit is the Back chevron in the (double-height) status // bar: tapping the bar closes the chat (statusBarTapCb), with a "‹" affordance + the // settings cog on its left. Swipe-back + the hardware keys still close it too. } static int append_settings_section(lv_obj_t* tab, int y, const char* title, lv_event_cb_t cb, int sub_idx) { // Row width tracks the screen (settings tab has 8-px padding each side) so // sections fill the width in both portrait and landscape. const lv_coord_t row_w = tabContentW() - 16; lv_obj_t* row = lv_btn_create(tab); /* `styleSettingsRow` calls `remove_style_all` — must run *before* align/size or position is lost * and every row stacks at (0,0), leaving only the last sibling visible. */ styleSettingsRow(row); lv_obj_set_size(row, row_w, k_settings_row_h); lv_obj_align(row, LV_ALIGN_TOP_MID, 0, y); lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* tit = lv_label_create(row); lv_label_set_text(tit, title); lv_obj_set_style_text_font(tit, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(tit, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_opa(tit, LV_OPA_COVER, LV_PART_MAIN); lv_obj_align(tit, LV_ALIGN_TOP_LEFT, 14, 8); lv_obj_t* sub = lv_label_create(row); lv_label_set_long_mode(sub, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_set_width(sub, row_w - 48); lv_obj_set_height(sub, 18); lv_obj_set_style_text_font(sub, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_anim_speed(sub, 28, LV_PART_MAIN); lv_obj_align(sub, LV_ALIGN_TOP_LEFT, 14, 29); lv_label_set_text(sub, "…"); lv_obj_t* chev = lv_label_create(row); lv_label_set_text(chev, LV_SYMBOL_RIGHT); lv_obj_set_style_text_font(chev, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(chev, lv_color_hex(0x4A5D70), LV_PART_MAIN); lv_obj_align(chev, LV_ALIGN_RIGHT_MID, -12, 0); if (sub_idx >= 0 && sub_idx < SEC_COUNT) g_set_sec_sub[sub_idx] = sub; return y + k_settings_row_h + k_settings_row_gap; } // Always-on, accent-coloured scrollbar so it's obvious a settings list scrolls. static void styleSettingsScrollbar(lv_obj_t* p) { lv_obj_set_scrollbar_mode(p, LV_SCROLLBAR_MODE_ON); lv_obj_set_style_width(p, 6, LV_PART_SCROLLBAR); lv_obj_set_style_pad_right(p, 2, LV_PART_SCROLLBAR); lv_obj_set_style_radius(p, 3, LV_PART_SCROLLBAR); lv_obj_set_style_bg_color(p, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(p, LV_OPA_80, LV_PART_SCROLLBAR); } // Configure one settings page: small padding, vertical flex (sections stack), // vertical scroll for overflow with a clearly-visible scrollbar. static void prepSettingsPage(lv_obj_t* p) { styleSurface(p, COLOR_BG, 0); lv_obj_set_style_pad_all(p, 8, LV_PART_MAIN); lv_obj_set_style_pad_row(p, 2, LV_PART_MAIN); lv_obj_set_flex_flow(p, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(p, LV_DIR_VER); styleSettingsScrollbar(p); } // UI language picker (the Language category). Lists native language names; a tap // persists the choice and reboots so the whole UI re-renders in that language. static void langChosenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; uint8_t lang = (uint8_t)(intptr_t)lv_event_get_user_data(e); #if defined(ESP32) touchPrefsSetUiLang(lang); #if CAP_LUA_APPS touchPrefsSetLangFile(""); // picking a built-in deactivates any .lang file overlay #endif #endif i18nSetLang(lang); kbApplyUiLangDefault(lang); // auto-enable + select the matching keyboard layout for this UI language if (g_lv.task) g_lv.task->rebootDevice(); } static void buildLanguageSettings() { lv_obj_t* body = createSettingsModal("", SettingsModalKind::Device); int y = 0; lv_obj_t* hint = lv_label_create(body); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lv_pct(100)); lv_label_set_text(hint, TR("Tap a language; the device reboots to apply.")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hint, 2, y); y += SC(30); const uint8_t cur = i18nGetLang(); for (uint8_t l = 0; l < LANG_COUNT; ++l) { lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100), SC(34)); lv_obj_set_pos(b, 2, y); styleButton(b); if (l == cur) lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_add_event_cb(b, langChosenCb, LV_EVENT_CLICKED, (void*)(intptr_t)l); lv_obj_t* lb = lv_label_create(b); lv_label_set_text(lb, kUiLangNames[l]); // native name (renders via the font fallback chain) lv_obj_set_style_text_font(lb, &g_font_14, LV_PART_MAIN); lv_obj_center(lb); y += SC(38); } } // Build one category's controls into the current inline parent (the open detail // sheet's scroll page). Only one category is ever built at a time (the sheet is // destroyed on close), keeping DRAM low. About builds its live status labels as // direct children of the page, toggling the inline parent like the old sub-tab. #if defined(ESP32) // ---- Crash report (coredump) export --------------------------------------- // On a panic the IDF writes an ELF coredump to the 'coredump' flash partition // (0xFF0000, 64 KB). Surface it so users can hand a crash to the devs: detect it // at boot (crashDumpCheck) and let them save it to the SD card / SPIFFS from // Settings → About. Decode later with xtensa-esp32s3-elf-gdb against the matching // firmware.elf — see the tdeck-coredump-decode notes. static size_t s_crash_dump_size = 0; // bytes of a pending dump; 0 = none // ---- Task-WDT self-record: name the culprit task in the crash report -------- // A coredump can't tell us WHICH task starved the watchdog — that only prints to // the serial console (field users don't have it), and a panic-while-dumping can // truncate the ELF (the 'elf_save_task' store-to-0x0 we saw). So at the instant the // Task-WDT trips, record the task running on BOTH cores into a .noinit buffer that // survives the panic reboot; the export then writes it as a sidecar .txt. For the // SPIFFS-GC stall this chases that's CPU0=ipc0 (stalled idle/IPC core) + CPU1=<the // flash writer> — which finally names the unguarded heavy write. #if !defined(HAS_TANMATSU) struct CrashWdtCapture { uint32_t magic; char cpu0[16]; char cpu1[16]; uint32_t tick; }; static const uint32_t kCrashWdtMagic = 0x57445431; // 'WDT1' __NOINIT_ATTR static CrashWdtCapture s_crash_wdt; // .noinit: not zeroed at startup, survives a panic reboot // IDF weak hook, invoked from task_wdt_isr the moment the watchdog trips, BEFORE the // panic abort. ISR context — TCB pointer reads only, no malloc / no blocking. extern "C" void esp_task_wdt_isr_user_handler(void) { TaskHandle_t t0 = xTaskGetCurrentTaskHandleForCPU(0); TaskHandle_t t1 = xTaskGetCurrentTaskHandleForCPU(1); const char* n0 = t0 ? pcTaskGetName(t0) : "?"; const char* n1 = t1 ? pcTaskGetName(t1) : "?"; strlcpy(s_crash_wdt.cpu0, n0 ? n0 : "?", sizeof s_crash_wdt.cpu0); strlcpy(s_crash_wdt.cpu1, n1 ? n1 : "?", sizeof s_crash_wdt.cpu1); s_crash_wdt.tick = xTaskGetTickCountFromISR(); s_crash_wdt.magic = kCrashWdtMagic; } static char s_crash_wdt_str[96] = {0}; // human-readable, empty unless a capture survived static void crashWdtCaptureRead() { if (s_crash_wdt.magic != kCrashWdtMagic) return; s_crash_wdt.magic = 0; // consume: don't re-report on the next clean boot s_crash_wdt.cpu0[sizeof s_crash_wdt.cpu0 - 1] = '\0'; s_crash_wdt.cpu1[sizeof s_crash_wdt.cpu1 - 1] = '\0'; snprintf(s_crash_wdt_str, sizeof s_crash_wdt_str, "Task watchdog: CPU0=%s CPU1=%s", s_crash_wdt.cpu0, s_crash_wdt.cpu1); Serial.printf("[CRASH] %s\n", s_crash_wdt_str); } #else static char s_crash_wdt_str[1] = {0}; static inline void crashWdtCaptureRead() {} #endif static void crashDumpCheck() { // The crash-report UI (boot prompt + the About-page export button) is only useful where the // saved file can actually be retrieved and sent to the devs: the T-Deck (SD card) and the // Tanmatsu (FFat, reachable via the launcher's file manager). On the Heltec V4 there's no SD // and no user-reachable internal storage, so the prompt is a dead end — skip detection there // so the whole crash-report UI stays off (everything downstream gates on s_crash_dump_size, // which then stays 0). The panic coredump still sits in its flash partition for a USB/esptool // pull if we ever need it. #if CAP_FILESYSTEM || defined(TLORA_PAGER) crashWdtCaptureRead(); // pull the Task-WDT culprit (if any) before checking for a dump size_t addr = 0, size = 0; if (esp_core_dump_image_check() == ESP_OK && esp_core_dump_image_get(&addr, &size) == ESP_OK && size > 0 && size < (1u << 20)) { s_crash_dump_size = size; } #endif } // Copy the raw ELF coredump to a file. Prefers SD (T-Deck), falls back to SPIFFS. // On success fills out_path ("SD /…" or "Internal /…"), erases the flash dump (the // file is now the durable copy) and clears s_crash_dump_size. False on any failure // (dump left intact so it can be retried). static bool crashDumpExport(char* out_path, size_t out_cap) { if (s_crash_dump_size == 0) return false; const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_COREDUMP, nullptr); if (!part) return false; size_t size = s_crash_dump_size; if (size > part->size) size = part->size; bool used_sd = false; fs::FS* dst = nullptr; const char* path = "/wadamesh-crash.elf"; #if CAP_SD || defined(TLORA_PAGER) if (fmSdTryMount()) { dst = &SD; used_sd = true; } #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) if (tanSdTryMount()) { dst = &SD_MMC; used_sd = true; } // microSD on SDMMC slot 0 #endif #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) if (!dst) dst = &FFat; // no SPIFFS on these boards; internal = the FAT data partition #else if (!dst) { SPIFFS.begin(false); dst = &SPIFFS; } #endif File f = dst->open(path, "w"); if (!f) return false; // Internal DMA-capable RAM, not PSRAM — the dump lands on SD/SD_MMC, which // DMAs from this buffer (see the SD firmware download for the full write-up). uint8_t* buf = (uint8_t*)heap_caps_malloc(4096, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!buf) { f.close(); return false; } bool ok = true; size_t off = 0; while (off < size) { size_t n = (size - off > 4096) ? 4096 : (size - off); if (esp_partition_read(part, off, buf, n) != ESP_OK || f.write(buf, n) != n) { ok = false; break; } off += n; } heap_caps_free(buf); f.close(); if (!ok) { dst->remove(path); return false; } // Sidecar text: makes the report self-describing even when the ELF is truncated // (panic-while-dumping) — names the Task-WDT culprit, reset reason, and build. { char tpath[96]; snprintf(tpath, sizeof tpath, "%s.txt", path); File tf = dst->open(tpath, "w"); if (tf) { tf.printf("wadamesh crash report\n"); #ifdef FIRMWARE_RELEASE_TAG tf.printf("firmware: %s\n", FIRMWARE_RELEASE_TAG); #endif tf.printf("reset: %s\n", resetReasonString(esp_reset_reason())); if (s_crash_wdt_str[0]) tf.printf("%s\n", s_crash_wdt_str); tf.close(); } } esp_core_dump_image_erase(); // the file is the durable copy now; clears the boot warning s_crash_dump_size = 0; snprintf(out_path, out_cap, "%s%s", used_sd ? "SD " : "Internal ", path); return true; } static void crashDumpExportCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; char path[80] = {0}; if (crashDumpExport(path, sizeof path)) { char msg[140]; snprintf(msg, sizeof msg, "Crash report saved:\n%s\nShare it with the devs", path); g_lv.task->showAlert(msg, 3500); lv_obj_add_flag((lv_obj_t*)lv_event_get_target(e), LV_OBJ_FLAG_HIDDEN); // done — hide the button } else { g_lv.task->showAlert(TR("Crash export failed"), 2000); } } // Boot-time crash prompt: when a panic coredump is waiting, proactively offer to save // + share it (most users never open Settings → About). On confirm, export it and tell // the user where it landed + how to send it. Dismiss keeps the dump (the About button // and the next boot still offer it). static void crashReportConfirmCb() { char path[80] = {0}; if (crashDumpExport(path, sizeof path)) { char msg[200]; snprintf(msg, sizeof msg, "Crash report saved:\n%s\n\nShare this file with the developers\n" "(open a GitHub issue at ALLFATHER-BV/wadamesh)\nso the bug can be fixed. Thank you!", path); if (g_lv.task) g_lv.task->showAlert(msg, 6000); } else if (g_lv.task) { g_lv.task->showAlert(TR("Could not save the crash report"), 2500); } } static void crashReportMaybePrompt() { if (s_crash_dump_size == 0) return; // Only prompt on the boot that ACTUALLY followed a crash. The coredump persists in its flash // partition across resets, so prompting whenever a dump merely exists re-showed "restarted after a // crash" on every manual reset / power-cycle until it was exported. Gate on this boot's reset reason; // the dump is still listed + exportable from Settings -> About (and via esptool) regardless. { esp_reset_reason_t rr = esp_reset_reason(); if (rr != ESP_RST_PANIC && rr != ESP_RST_TASK_WDT && rr != ESP_RST_INT_WDT && rr != ESP_RST_WDT) return; } static char m[280]; snprintf(m, sizeof m, LV_SYMBOL_WARNING " The device restarted after a crash.\n\n%s%s" "Sending the crash report helps us find and fix the bug. Save it now?", s_crash_wdt_str[0] ? s_crash_wdt_str : "", s_crash_wdt_str[0] ? "\n\n" : ""); showConfirm(m, TR("Save report"), crashReportConfirmCb); } #endif // ESP32 static void settingsCatBuild(int cat) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) s_wifi_list_cont = nullptr; // previous page's content was cleaned; buildWifiSettings re-sets it #endif lv_obj_t* page = s_settings_inline_parent; const lv_coord_t lblw = lv_disp_get_hor_res(nullptr) - 16; switch (cat) { case CAT_PROFILE: buildProfileSettings(); break; case CAT_RADIO: buildRadioSettings(); buildExperimentalSettings(); break; case CAT_AUTOADD: buildAutoAddSettings(); break; case CAT_WIFI: buildWifiSettings(); break; case CAT_BLUETOOTH: buildBluetoothSettings(); break; case CAT_GPS: buildDeviceSettings(DSEC_GPS); break; case CAT_CLOCK: buildDeviceSettings(DSEC_CLOCK); break; case CAT_BATTERY: buildDeviceSettings(DSEC_BATTERY); break; case CAT_SENSORS: buildDeviceSettings(DSEC_SENSORS); break; case CAT_DISPLAY: buildDeviceSettings(DSEC_DISPLAY); break; case CAT_KEYBOARD: buildDeviceSettings(DSEC_KEYBOARD); break; case CAT_QUICKREPLIES: buildQuickReplySettings(); break; case CAT_SOUND: buildDeviceSettings(DSEC_SOUND); break; case CAT_LOCK: buildDeviceSettings(DSEC_LOCK); break; case CAT_GENERAL: buildDeviceSettings(DSEC_GENERAL); break; case CAT_BACKUPS: buildBackupsSettings(); break; case CAT_LANGUAGE: buildLanguageSettings(); break; case CAT_MQTT: buildMqttSettings(); break; case CAT_APPPERMS: #if CAP_LUA_SDK_EXT s_settings_inline_parent = nullptr; buildAppPermsSettings(page, lblw); #endif break; case CAT_ABOUT: { s_settings_inline_parent = nullptr; s_update_about_lbl = lv_label_create(page); // update/firmware status (top) lv_label_set_long_mode(s_update_about_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_update_about_lbl, lblw); lv_obj_set_style_text_font(s_update_about_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_text(s_update_about_lbl, ""); // "Install update" button — over-the-air update to the latest release. // Only meaningful on a tagged build with OTA support; the callback // re-validates (Wi-Fi up, update known) and reports errors inline. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) if (FIRMWARE_OTA_ENV[0] && firmwareReleaseN() >= 0 && touchHasOtaUpdateSlot()) { s_ota_btn = lv_btn_create(page); lv_obj_set_size(s_ota_btn, lblw, 38); styleButton(s_ota_btn); lv_obj_set_style_bg_color(s_ota_btn, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(s_ota_btn, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(s_ota_btn, otaInstallLatestCb, LV_EVENT_CLICKED, nullptr); s_ota_btn_lbl = lv_label_create(s_ota_btn); lv_label_set_text(s_ota_btn_lbl, TR(LV_SYMBOL_DOWNLOAD " Install update")); lv_obj_set_style_text_font(s_ota_btn_lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(s_ota_btn_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(s_ota_btn_lbl); otaButtonRefreshState(); // grey immediately if we already know we're current #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // "Install a previous version" — a single secondary button that opens a // version-picker popup (then an are-you-sure confirm). Subtler than the // green Install button so it reads as secondary. Starts HIDDEN; revealed // by otaButtonRefreshState() once the version check yields a latest_n with // at least one earlier beta below it. { s_ota_prev_btn = lv_btn_create(page); lv_obj_set_size(s_ota_prev_btn, lblw, 34); styleButton(s_ota_prev_btn); lv_obj_set_style_bg_color(s_ota_prev_btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_add_event_cb(s_ota_prev_btn, otaPrevVersionsCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(s_ota_prev_btn); lv_label_set_text(l, TR("Install a previous version")); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); lv_obj_add_flag(s_ota_prev_btn, LV_OBJ_FLAG_HIDDEN); // shown by otaButtonRefreshState() when an earlier beta exists } otaButtonRefreshState(); // reveal the button if latest_n is already known #endif s_ota_status_lbl = lv_label_create(page); lv_label_set_long_mode(s_ota_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_ota_status_lbl, lblw); lv_obj_set_style_text_font(s_ota_status_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_ota_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_text(s_ota_status_lbl, ""); } // "Get test builds (beta)" — opt into the BETA update channel. Affects the // "update available" check, the Install-update download AND the save-to-SD // download below. Deliberately OUTSIDE the touchHasOtaUpdateSlot() gate: // Launcher installs have no Install button but still need channel-aware // update notifications and the SD download. if (FIRMWARE_OTA_ENV[0] && firmwareReleaseN() >= 0) { { lv_obj_t* beta_row = lv_obj_create(page); lv_obj_set_size(beta_row, lblw, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(beta_row, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(beta_row, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(beta_row, 0, LV_PART_MAIN); lv_obj_set_style_pad_top(beta_row, SC(8), LV_PART_MAIN); lv_obj_clear_flag(beta_row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(beta_row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(beta_row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_t* beta_lbl = lv_label_create(beta_row); lv_label_set_text(beta_lbl, TR("Get test builds (beta)")); lv_obj_set_style_text_font(beta_lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(beta_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_t* beta_sw = lv_switch_create(beta_row); if (touchPrefsGetBetaUpdates()) lv_obj_add_state(beta_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(beta_sw, betaUpdatesToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_t* beta_note = lv_label_create(page); lv_label_set_long_mode(beta_note, LV_LABEL_LONG_WRAP); lv_obj_set_width(beta_note, lblw); lv_label_set_text(beta_note, TR("Receive unreleased test firmware — newer features, but less tested. The update check and Install update both follow the beta channel.")); lv_obj_set_style_text_font(beta_note, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(beta_note, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } // Gated on the CAPABILITY, not a list of board names: this needs a writable Arduino-SD // card and OTA, which is exactly CAP_SD && CAP_OTA. The board list had grown to three // entries and still left the T-Lora Pager out despite it having both (#289) — every new // board with a card had to remember to add itself here, which is the failure mode // device_caps.h exists to prevent. #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA // "Save update bin to SD" — the Launcher-install update path: downloads // the app-only bin for the active channel into /BINS/ on the SD card, // named wadamesh-beta_<N>-<stable|beta>.bin, ready for the Launcher to // flash. Also handy on standard installs as an offline spare. { lv_obj_t* sd_btn = lv_btn_create(page); lv_obj_set_size(sd_btn, lblw, 38); styleButton(sd_btn); lv_obj_add_event_cb(sd_btn, sdFwSaveToSdCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(sd_btn); lv_label_set_text(sl, TR(LV_SYMBOL_SD_CARD " Save update bin to SD")); lv_obj_set_style_text_font(sl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(sl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(sl); s_sdfw_status_lbl = lv_label_create(page); lv_label_set_long_mode(s_sdfw_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_sdfw_status_lbl, lblw); lv_obj_set_style_text_font(s_sdfw_status_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_sdfw_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_text(s_sdfw_status_lbl, SDFW_HINT_TEXT); } #endif } #endif #if defined(ESP32) // Crash-report export — only when a panic coredump is waiting in flash. if (s_crash_dump_size > 0) { lv_obj_t* crash_btn = lv_btn_create(page); lv_obj_set_size(crash_btn, lblw, 38); styleButton(crash_btn); lv_obj_set_style_bg_color(crash_btn, lv_color_hex(0x8A3B2E), LV_PART_MAIN); // warning red-brown lv_obj_add_event_cb(crash_btn, crashDumpExportCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cbl = lv_label_create(crash_btn); char ct[96]; // room for a translated label snprintf(ct, sizeof ct, TR(LV_SYMBOL_WARNING " Export crash report (%uK)"), (unsigned)(s_crash_dump_size / 1024)); lv_label_set_text(cbl, ct); lv_obj_set_style_text_font(cbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(cbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(cbl); } #endif s_settings_inline_parent = page; buildSystemInfoSettings(); s_settings_inline_parent = nullptr; g_lv.settings_status = lv_label_create(page); lv_label_set_long_mode(g_lv.settings_status, LV_LABEL_LONG_WRAP); lv_obj_set_width(g_lv.settings_status, lblw); lv_obj_set_style_text_color(g_lv.settings_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.settings_status, &g_font_12, LV_PART_MAIN); lv_label_set_text(g_lv.settings_status, ""); g_lv.diag_id_label = lv_label_create(page); lv_label_set_long_mode(g_lv.diag_id_label, LV_LABEL_LONG_WRAP); lv_obj_set_width(g_lv.diag_id_label, lblw); lv_obj_set_style_text_color(g_lv.diag_id_label, lv_color_hex(0xA8C8FF), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.diag_id_label, &g_font_12, LV_PART_MAIN); lv_label_set_text(g_lv.diag_id_label, s_diag_id_pinned[0] ? s_diag_id_pinned : "ID …"); g_lv.diag_label = lv_label_create(page); lv_label_set_long_mode(g_lv.diag_label, LV_LABEL_LONG_WRAP); lv_obj_set_width(g_lv.diag_label, lblw); lv_obj_set_style_text_color(g_lv.diag_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_lv.diag_label, &g_font_12, LV_PART_MAIN); lv_label_set_text(g_lv.diag_label, TR("Diagnostics")); versionCheckUpdateUi(); break; } } s_settings_inline_parent = nullptr; } // Tear down the open category detail sheet and return to the landing. Safe to // call when nothing is open (tab change / key dismiss call it unconditionally). static void closeSettingsCategory() { if (!s_settings_sheet && s_settings_open_cat < 0) return; hideKb(); if (s_settings_open_cat == CAT_ABOUT) { // null the live-label ptrs (freed with the sheet) s_sysinfo_lbl = nullptr; s_sysinfo_rest_lbl = nullptr; s_update_about_lbl = nullptr; s_ota_status_lbl = nullptr; #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA s_sdfw_status_lbl = nullptr; #endif s_ota_btn = nullptr; s_ota_btn_lbl = nullptr; s_ota_prev_btn = nullptr; g_lv.settings_status = nullptr; g_lv.diag_id_label = nullptr; g_lv.diag_label = nullptr; } #if defined(HAS_TDECK_GT911) if (s_settings_open_cat == CAT_LOCK) { // null the sleep-diag live-label ptrs s_sleep_diag_lbl = nullptr; s_sleep_diag_lbl2 = nullptr; } #endif s_settings_inline_parent = nullptr; // del_async, NOT del: the sheet holds the scrollable page, and this close can // fire mid-gesture (swipe-back / tab change while a settings page has scroll // momentum). A synchronous delete frees the scroll_obj while LVGL is still // walking the touch event chain -> use-after-free in indev_proc_release on the // following release/scroll-throw. Deferring frees it after the event finishes. if (s_settings_sheet) { popupClose(&s_settings_sheet); } s_settings_open_cat = -1; s_settings_from_cc = false; resetSettingsModalState(); statusBarSetTall(false); updateGlobalStatusBar(); // apply the height change AND the content shift in the SAME // frame, so the clock/icons don't jump up for a tick on the way // back to the landing (the chat overview already does this via // updateGlobalStatusBar's driver). } // Open a category as a detail sheet (on layer_top, below the status bar). The // status bar itself carries the Back chevron + the page title (tapping the bar = // Back), so the sheet needs no header and the page uses the full height. static void settingsSheetCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) closeSettingsCategory(); } static void openSettingsCategory(int cat) { if (cat < 0 || cat >= CAT_COUNT) return; if (settingsModalIsOpen()) closeSettingsModal(); closeSettingsCategory(); // close any prior sheet first const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // On the BASE screen layer (like the chat overlay), so the status bar — which lives on // lv_layer_top above it — paints its glass lower row OVER this sheet and the page // content scrolls UNDER the bar. (On lv_layer_top the sheet would sit above the bar and // the glass would reveal the settings landing behind the bar instead of this page.) lv_obj_t* root = lv_obj_create(lv_scr_act()); lv_obj_remove_style_all(root); // Settings detail pages use a DOUBLE-height status bar (its back chevron + title become // a tall title bar). Set the category first so the bar paints the title + goes tall. // The sheet starts under the SOLID top row (STATUSBAR_H); the page insets its top by // the glass lower-row height so content rests below the bar but scrolls under it. s_settings_open_cat = cat; statusBarSetTall(true); lv_obj_set_size(root, sw, sh - STATUSBAR_H); lv_obj_set_pos(root, 0, STATUSBAR_H); styleSurface(root, COLOR_BG, 0); lv_obj_set_style_pad_all(root, 0, LV_PART_MAIN); lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(root); s_settings_sheet = root; lv_obj_t* page = lv_obj_create(root); lv_obj_set_pos(page, 0, 0); lv_obj_set_size(page, sw, sh - STATUSBAR_H); prepSettingsPage(page); lv_obj_set_style_pad_top(page, STATUSBAR_H + 8, LV_PART_MAIN); // clear the glass lower bar row resetSettingsModalState(); s_settings_inline_parent = page; settingsCatBuild(cat); s_settings_inline_parent = nullptr; // Let the last section card grow to fill the page so a SHORT category (e.g. // Sound) reads as a full-screen panel instead of a small floating box — but // ONLY when the content actually fits. Growing the last card on a TALL page // (Wi-Fi, Keyboard, System, Quick replies, Lock screen, …) collapses the // scroll bounds and hides the lower content, so lay out first and skip the // grow if the page already overflows (it needs to stay scrollable). lv_obj_update_layout(page); if (lv_obj_get_scroll_bottom(page) <= 0) { const int nch = lv_obj_get_child_cnt(page); if (nch >= 1) { lv_obj_t* wrap = lv_obj_get_child(page, nch - 1); if (wrap && lv_obj_get_child_cnt(wrap) >= 1) { // a grouped-card wrap (skip bare labels, e.g. About) lv_obj_set_flex_grow(wrap, 1); lv_obj_t* card = lv_obj_get_child(wrap, lv_obj_get_child_cnt(wrap) - 1); if (card) lv_obj_set_flex_grow(card, 1); } } } // No floating X over the page (it reserved an ugly black band at the top and // overlapped the scrollbar). The close affordance lives in the status bar's left // zone — "‹ Category" (tap the bar = Back) — which reads as a title bar's back // button. Swipe-right also closes. See updateGlobalStatusBar. updateGlobalStatusBar(); // show the Back chevron + title in the bar immediately } #if CAP_LUA_APPS static void luaStoreOpenLanguages(); // fwd (defined with the Store page) #endif static void settingsCatOpenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int cat = (int)(intptr_t)lv_event_get_user_data(e); #if CAP_LUA_APPS if (cat == CAT_LANGUAGE) { // languages live in the Lua Store now (files + picker) luaStoreOpenLanguages(); return; } #endif openSettingsCategory(cat); } #if CAP_LUA_APPS // Apply the Lua Store's built-in hide bits to the settings landing WITHOUT // rebuilding it: the page is constructed once at boot, so a toggle taken later // has to flip the existing card (flex layout skips hidden children, so the grid // closes up on its own). void settingsApplyHiddenCats() { const uint32_t hide = touchPrefsGetAppHide(); if (s_settings_cat_card[CAT_MQTT] && lv_obj_is_valid(s_settings_cat_card[CAT_MQTT])) { if (hide & APPHIDE_MQTT) lv_obj_add_flag(s_settings_cat_card[CAT_MQTT], LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(s_settings_cat_card[CAT_MQTT], LV_OBJ_FLAG_HIDDEN); } } #endif static void makeSettings(lv_obj_t* tab) { styleSurface(tab, COLOR_BG); lv_obj_set_style_pad_all(tab, 0, LV_PART_MAIN); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); // Builders SHARE the g_set_modal widget-pointer struct: reset once; each inline // builder writes its own (distinct) fields and the save callbacks read them. resetSettingsModalState(); s_settings_sheet = nullptr; s_settings_open_cat = -1; // Category landing: a single-column list in portrait, a 2-column grid in // landscape (uses the extra width). Each card opens a focused detail sheet. const bool landscape = lv_disp_get_hor_res(nullptr) > lv_disp_get_ver_res(nullptr); const lv_coord_t hor = lv_disp_get_hor_res(nullptr); lv_obj_t* land = lv_obj_create(tab); s_settings_landing = land; lv_obj_remove_style_all(land); lv_obj_set_size(land, lv_pct(100), lv_pct(100)); styleSurface(land, COLOR_BG, 0); lv_obj_set_style_pad_all(land, 8, LV_PART_MAIN); lv_obj_set_style_pad_row(land, 8, LV_PART_MAIN); lv_obj_set_style_pad_column(land, 8, LV_PART_MAIN); lv_obj_set_scroll_dir(land, LV_DIR_VER); styleSettingsScrollbar(land); lv_obj_set_flex_flow(land, landscape ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_COLUMN); // hor - 16 (page pad) - 8 (right gutter so the rightmost card clears the scrollbar); // landscape subtracts another 8 for the inter-column gap, then halves. const lv_coord_t card_w = landscape ? (lv_coord_t)((hor - 24 - 8) / 2) : (lv_coord_t)(hor - 24); const lv_coord_t card_h = landscape ? 54 : 46; for (int c = 0; c < CAT_COUNT; ++c) { #if !CAP_LOCK_SCREEN if (c == CAT_LOCK) continue; // lock screen is a T-Deck / Tanmatsu feature #endif #if !defined(HAS_EXPANSION_KIT) if (c == CAT_SENSORS) continue; // Sensors page only exists with the V4 Expansion Kit #if !CAP_LUA_SDK_EXT if (c == CAT_APPPERMS) continue; // nothing on this board can request a permission #endif #endif lv_obj_t* card = lv_btn_create(land); s_settings_cat_card[c] = card; // remembered so hiding can apply live lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_set_style_bg_color(card, lv_color_hex(0x121417), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x2A2E34), LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(card, settingsCatOpenCb, LV_EVENT_CLICKED, (void*)(intptr_t)c); lv_obj_t* icon = lv_label_create(card); lv_label_set_text(icon, kSettingsCats[c].icon); lv_obj_set_style_text_font(icon, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(icon, LV_ALIGN_LEFT_MID, 12, 0); lv_obj_t* lbl = lv_label_create(card); lv_label_set_text(lbl, TR(kSettingsCats[c].label)); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // Category name sits visually CENTRED in the card (icon stays a left accent). // The label spans the card minus a symmetric margin (so the left accent icon // never overlaps the text) and centre-aligns within that box; wrap keeps a long // translated name (e.g. "Πληκτρολόγιο", "Schnellantworten") inside the card. // No right chevron — it fought the centred text and pulled the eye off-centre. // Fill the card to the RIGHT of the left accent icon and centre within that // region. The old symmetric 40-px margins left only ~64 px on the T-Deck's // 2-column landscape cards, so a single word ("Bluetooth") split mid-word and // "Radio & Mesh" wrapped to two lines. Using the full post-icon width (~100 px) // keeps every English category name on one line, unsplit; long translations // still wrap at word boundaries. lv_obj_set_width(lbl, card_w - 34 - 8); // start just past the icon, small right margin lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 34, 0); if (c == CAT_ABOUT) { // update-available dot rides on the About card s_update_subtab_badge = lv_obj_create(card); lv_obj_remove_style_all(s_update_subtab_badge); // Pure decoration: a bare lv_obj is CLICKABLE by default, and keypad-nav's // leaf rule then harvests the dot INSTEAD of the About button it sits on // (the button became "a container with a clickable child"), making About // unreachable by keyboard whenever the update badge shows. lv_obj_clear_flag(s_update_subtab_badge, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_size(s_update_subtab_badge, 9, 9); lv_obj_set_style_radius(s_update_subtab_badge, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(s_update_subtab_badge, lv_color_hex(0xE2403A), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_update_subtab_badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_align(s_update_subtab_badge, LV_ALIGN_RIGHT_MID, landscape ? -12 : -32, 0); lv_obj_add_flag(s_update_subtab_badge, LV_OBJ_FLAG_HIDDEN); } } versionCheckUpdateUi(); // reflect any completed check in the gear/About badges #if CAP_LUA_APPS settingsApplyHiddenCats(); #endif } // ============================================================ // Refresh functions // ============================================================ // Rebuild message history in the open chat detail. // ---- Bubble timestamp + long-press menu (Copy/Info) --------------------- // // Timestamp helpers: m.ts is stored as RTC epoch seconds when available // (set in appendMessage), else uptime seconds. The Info popup wants the // full date+time; the bubble itself only shows HH:MM, with "—" when the // RTC has never been bootstrapped on this device. static constexpr uint32_t k_ts_epoch_min = 1700000000u; // Nov 2023 — sanity floor static void formatBubbleHhMm(uint32_t ts, char* out, int cap) { if (cap <= 0 || !out) return; if (ts < k_ts_epoch_min) { out[0] = '\0'; return; } time_t t = (time_t)ts; struct tm tm_loc{}; localtime_r(&t, &tm_loc); fmtClockHM(out, (size_t)cap, &tm_loc); // honor the 12/24-hour pref } // Bubble footer: time-only today, short date + time on earlier days. static void formatBubbleTs(uint32_t ts, char* out, int cap) { if (cap <= 0 || !out) return; out[0] = '\0'; if (ts < k_ts_epoch_min) return; time_t t = (time_t)ts; time_t now = time(nullptr); struct tm tmv{}, tmn{}; localtime_r(&t, &tmv); localtime_r(&now, &tmn); char time_part[12]; fmtClockHM(time_part, sizeof(time_part), &tmv); if (tmv.tm_year == tmn.tm_year && tmv.tm_yday == tmn.tm_yday) { snprintf(out, (size_t)cap, "%s", time_part); } else if (tmv.tm_year == tmn.tm_year) { char date_part[12]; strftime(date_part, sizeof(date_part), "%d %b", &tmv); snprintf(out, (size_t)cap, "%s %s", date_part, time_part); } else { char date_part[12]; strftime(date_part, sizeof(date_part), "%d/%m/%y", &tmv); snprintf(out, (size_t)cap, "%s %s", date_part, time_part); } } static void formatFullTimestamp(uint32_t ts, char* out, int cap) { if (cap <= 0 || !out) return; if (ts < k_ts_epoch_min) { snprintf(out, cap, TR("(RTC unset)")); return; } time_t t = (time_t)ts; struct tm tm_loc{}; localtime_r(&t, &tm_loc); snprintf(out, cap, "%04d-%02d-%02d %02d:%02d:%02d", tm_loc.tm_year + 1900, tm_loc.tm_mon + 1, tm_loc.tm_mday, tm_loc.tm_hour, tm_loc.tm_min, tm_loc.tm_sec); } // Module state for the per-message action menu + Info popup. Only one of // each can be open at a time; opening a new one closes the old. // True if `text` contains "@<my node name>" (case-insensitive, with a word // boundary after) — i.e. this message @mentions me. static bool textMentionsMe(const char* text) { if (!text || !text[0]) return false; NodePrefs* pr = the_mesh.getNodePrefs(); const char* me = pr ? pr->node_name : nullptr; if (!me || !me[0]) return false; const size_t nl = strlen(me); for (const char* p = text; *p; ++p) { if (*p != '@') continue; // Accept both "@name" (bare) and "@[name]" (bracketed) mention syntax. const bool bracket = (p[1] == '['); const char* q = bracket ? p + 2 : p + 1; if (strncasecmp(q, me, nl) != 0) continue; const char a = q[nl]; if (bracket) { if (a == ']') return true; } else if (a == '\0' || a == ' ' || a == '\n' || a == ',' || a == '.' || a == '!' || a == '?' || a == ':' || a == ';') { return true; } } return false; } static lv_obj_t* s_msg_menu_root = nullptr; static lv_obj_t* s_msg_info_root = nullptr; static int s_msg_menu_idx = -1; // absolute index into _ui_msgs static char s_msg_menu_sender[UITask::MAX_SENDER_NAME + 1] = {0}; // Buffer the body text so the Copy button doesn't reach back into the ring // (the bubble might get redrawn between menu-open and copy-tap). static char s_msg_menu_text[UITask::MAX_MSG_TEXT + 1] = {0}; // Pre-composed one-tap "ack" line (mention + link quality + route of the message), // built when the menu opens so the Ack button never reaches back into the (possibly // rotated) ring. s_msg_menu_channel picks which composer to drop it into. static char s_msg_menu_ack[UITask::MAX_MSG_TEXT + 1] = {0}; static bool s_msg_menu_channel = false; static void closeMsgActionMenu() { if (s_msg_menu_root) { popupClose(&s_msg_menu_root); } } static void closeMsgInfoPopup() { if (s_msg_info_root) { // Hide this frame so it vanishes instantly (e.g. when tapping "Replay route" // the popup shouldn't linger over the map transition); the async del frees it // on the next tick, which is safe to call from a child button's own callback. lv_obj_add_flag(s_msg_info_root, LV_OBJ_FLAG_HIDDEN); popupClose(&s_msg_info_root); } } // Full-route trace, triggered from the message Info popup. s_trace_route_pending // tells onTracePingResult to render the multi-hop result in this dismissable // card (vs the compact "Trace SNR" toast used by the repeater action-sheet probe). static lv_obj_t* s_trace_result_root = nullptr; static bool s_trace_route_pending = false; static void closeTraceResultPopup() { if (s_trace_result_root) { popupClose(&s_trace_result_root); } } static void traceResultBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeTraceResultPopup(); } static void openTraceResultPopup(const char* title, const char* body) { closeTraceResultPopup(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_trace_result_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_trace_result_root); lv_obj_set_size(s_trace_result_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_trace_result_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_trace_result_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_trace_result_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_trace_result_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_trace_result_root, traceResultBackdropCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN const int card_w = PCW(220); const int card_h = PSC(236); #else const int card_w = 220; const int card_h = 236; #endif lv_obj_t* card = lv_obj_create(s_trace_result_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); addCloseXBadge(card, traceResultBackdropCb); lv_obj_t* ttl = lv_label_create(card); lv_label_set_text(ttl, title); lv_obj_set_style_text_color(ttl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ttl, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(ttl, LV_LABEL_LONG_DOT); lv_obj_set_width(ttl, card_w - 20 - 32); lv_obj_set_pos(ttl, 0, 4); lv_obj_t* lbl = lv_label_create(card); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, card_w - 20); lv_label_set_text(lbl, body); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(lbl, 0, PSC(32)); lv_obj_add_flag(lbl, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(lbl, copyLabelLongPressCb, LV_EVENT_LONG_PRESSED, const_cast<char*>("info")); } static void msgMenuCopyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); clipboardSet(s_msg_menu_text, "message"); closeMsgActionMenu(); } // Forward decl so the menu's Info button can fire the popup. static void openMessageInfoPopup(int msg_idx); static void msgMenuInfoCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); const int idx = s_msg_menu_idx; closeMsgActionMenu(); openMessageInfoPopup(idx); } // Insert "@[<sender>] " into the channel composer so you can @mention them. The // bracketed form is the mention syntax the other clients use (textMentionsMe // also accepts a bare "@name" for backward-compat). static void msgMenuMentionCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMsgActionMenu(); LvChatPanel& p = g_lv.ch; // mentions are channel-only if (!p.composer_ta || !s_msg_menu_sender[0]) return; char ins[UITask::MAX_SENDER_NAME + 8]; snprintf(ins, sizeof ins, "@[%s] ", s_msg_menu_sender); taClearSelection(p.composer_ta); lv_textarea_add_text(p.composer_ta, ins); lv_textarea_set_cursor_pos(p.composer_ta, LV_TEXTAREA_CURSOR_LAST); showKb(&p); // focus composer so typing continues there } static void msgMenuBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMsgActionMenu(); } static void msgInfoBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMsgInfoPopup(); } static void msgMenuBlockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const bool ok = g_lv.task->ignoreSenderInActiveThread(s_msg_menu_sender); closeMsgActionMenu(); g_lv.task->showAlert(ok ? TR("Sender blocked") : TR("Block list full"), 1500); } // "Resend" (outgoing messages only): transmit the same text again to the open // thread — for when a DM never got its ✓✓ ack, or a channel post may not have // propagated. Sends the snapshotted body (s_msg_menu_text) via the normal composer // send path, which adds a fresh outgoing bubble; the composer draft is left intact. static void msgMenuResendCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMsgActionMenu(); if (!s_msg_menu_text[0]) return; if (!g_lv.task->composerSend(s_msg_menu_text)) { g_lv.task->showAlert(TR("Resend failed"), 1400); return; } // The composer's send button refreshes the open detail itself; this // programmatic path must too, or the new bubble only appears when something // else redraws the thread (#248). if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); g_lv.dirty_threads = true; } static void msgMenuDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); const int idx = s_msg_menu_idx; closeMsgActionMenu(); if (g_lv.task->deleteMessageBySlot(idx)) { refreshThreadLists(); // message vanishes instantly if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); // Persist synchronously so the delete survives an immediate hard reset (an async // flush can't — the reset beats the worker). The write is now compact (only the // used records), so this no longer hitches the UI. The 30 s lazy window was why a // deleted message "came back" on beta_38. g_lv.task->persistHistoryNow(); g_lv.task->showAlert(TR("Message deleted"), 1100); } } // One-tap retry (wyvern.red): a FAILED outgoing message is single-tappable — a quick // confirm, then the same resend engine as the long-press menu's Resend. The text is // snapshotted at tap (the ring may rotate before the confirm lands). static char s_retry_text[UITask::MAX_MSG_TEXT + 1] = ""; static void retryConfirmedApply() { if (!g_lv.task || !s_retry_text[0]) return; if (!g_lv.task->composerSend(s_retry_text)) { g_lv.task->showAlert(TR("Resend failed"), 1400); return; } if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); // see msgMenuResendCb (#248) if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); g_lv.dirty_threads = true; } static void bubbleRetryTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(idx, m) || !m.outgoing || m.deliv_state != UITask::DELIV_FAILED || !m.text[0]) return; strncpy(s_retry_text, m.text, sizeof(s_retry_text) - 1); s_retry_text[sizeof(s_retry_text) - 1] = '\0'; showConfirm(TR("Message didn't get through.\nResend it?"), TR("Resend"), retryConfirmedApply); } // Compose a one-tap "ack" for an incoming message: @mentions the sender (on a // channel) and reports the link quality + route of THAT message, so you can // confirm to them their (test) message arrived and how strong it came in. Built // once when the menu opens; dropped into the composer for review, never auto-sent. static void buildAckText(UITask::UIMessage& m, char* out, size_t cap) { if (!out || cap == 0) return; out[0] = '\0'; int n = 0; if (m.channel && m.sender[0]) n += snprintf(out + n, cap - (size_t)n, "@[%s], ack", m.sender); else n += snprintf(out + n, cap - (size_t)n, "Ack"); if (m.meta_flags & UITask::MSG_META_HAS_RX) { n += snprintf(out + n, cap - (size_t)n, ": SNR %.1f dB, RSSI %d dBm", (double)m.snr_q4 / 4.0, (int)m.rssi); if (m.meta_flags & UITask::MSG_META_IS_FLOOD) { const uint8_t hops = (uint8_t)(m.path_len & 0x3F); n += snprintf(out + n, cap - (size_t)n, ", %u hop%s", (unsigned)hops, hops == 1 ? "" : "s"); // Append the hop codes in hex ("via 3A, F1, …") — the raw path-hash bytes // each repeater is keyed by, matching what the mesh shows on the wire. const uint8_t hsz = (uint8_t)((m.path_len >> 6) + 1); int off = 0, listed = 0; for (uint8_t h = 0; h < hops && off + (int)hsz <= m.in_path_n && listed < 8; ++h, off += hsz) { if (n >= (int)cap - 24) break; // leave room; never overflow out[] n += snprintf(out + n, cap - (size_t)n, "%s", listed == 0 ? " via " : ", "); for (uint8_t b = 0; b < hsz && b < 4 && n < (int)cap - 3; ++b) n += snprintf(out + n, cap - (size_t)n, "%02X", m.in_path[off + b]); ++listed; } } else { n += snprintf(out + n, cap - (size_t)n, ", direct"); } } if (n < 0 || n >= (int)cap) out[cap - 1] = '\0'; } // "Ack" button: drop the pre-composed ack line into the active chat's composer // (channel or DM) at the cursor, for the operator to review and send. static void msgMenuAckCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeMsgActionMenu(); LvChatPanel& p = s_msg_menu_channel ? g_lv.ch : g_lv.dm; if (!p.composer_ta || !s_msg_menu_ack[0]) return; taClearSelection(p.composer_ta); // never replace a stray selection lv_textarea_add_text(p.composer_ta, s_msg_menu_ack); lv_textarea_set_cursor_pos(p.composer_ta, LV_TEXTAREA_CURSOR_LAST); showKb(&p); // focus composer so you can tweak/send } static void openMessageActionMenu(int msg_idx) { if (!g_lv.task) return; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(msg_idx, m)) return; closeMsgActionMenu(); closeMsgInfoPopup(); s_msg_menu_idx = msg_idx; // Snapshot the body text + sender — the ring may rotate between now and tap. strncpy(s_msg_menu_text, m.text, sizeof(s_msg_menu_text) - 1); s_msg_menu_text[sizeof(s_msg_menu_text) - 1] = '\0'; strncpy(s_msg_menu_sender, m.sender, sizeof(s_msg_menu_sender) - 1); s_msg_menu_sender[sizeof(s_msg_menu_sender) - 1] = '\0'; // Pre-compose the ack line now (RX metadata is on `m` here; the ring may rotate // before the button is tapped). s_msg_menu_channel picks the composer to fill. s_msg_menu_channel = m.channel; buildAckText(m, s_msg_menu_ack, sizeof s_msg_menu_ack); // "Ack" (quick link-quality confirmation) + "Mention" are for incoming messages. const bool can_ack = !m.outgoing && m.sender[0]; // ack its sender (channel or DM) const bool can_mention = m.channel && !m.outgoing && m.sender[0]; const bool can_block = !m.outgoing; // block the sender — never for our own messages const bool can_resend = m.outgoing && m.text[0]; // re-send one of OUR messages (DM or channel) lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_msg_menu_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_msg_menu_root); // Sit below the global status bar so the centered card lines up with the // visible area (matches every other modal in the UI). lv_obj_set_size(s_msg_menu_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_msg_menu_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_msg_menu_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_msg_menu_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_msg_menu_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_msg_menu_root, msgMenuBackdropCb, LV_EVENT_CLICKED, nullptr); // The 800×480 Tanmatsu dwarfs this menu, so widen it and grow the rows; the // smaller boards keep plain integers (PSC is a no-op there). Buttons sit TWO // per row — with Delete the menu carries up to 7 actions, and a single column // no longer fit the 240-px screens without scrolling. #if defined(TLORA_PAGER) // Spend the Pager's horizontal room instead of its scarce vertical room. const int card_w = sw - 80; const int btn_h = 30; const int gap = 6; const int pad = 10; const int hdr_h = 32; // keep the first row below the full close-X target #elif CAP_LARGE_SCREEN const int card_w = PCW(220); const int btn_h = PSC(30); const int gap = PSC(4); const int pad = PSC(10); const int hdr_h = PSC(24); #else const int card_w = 200; const int btn_h = 30; const int gap = 4; const int pad = 10; // Header row reserves space for the close-X badge so it doesn't sit on a button. const int hdr_h = 24; #endif const int nbtn = (can_ack ? 1 : 0) + (can_mention ? 1 : 0) + 3 /*Copy+Info+Delete*/ + (can_block ? 1 : 0) + (can_resend ? 1 : 0); const int nrows = (nbtn + 1) / 2; int card_h = hdr_h + nrows * btn_h + (nrows - 1) * gap + 2 * pad; // Never exceed the visible area under the status bar; scroll if it ever would // (e.g. an even taller menu, or a shorter display). The close-X floats, so it // stays pinned while the buttons scroll. const int avail_h = (int)(sh - STATUSBAR_H) - 8; const bool scroll_menu = (card_h > avail_h); if (scroll_menu) card_h = avail_h; lv_obj_t* card = lv_obj_create(s_msg_menu_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); if (scroll_menu) { lv_obj_set_scroll_dir(card, LV_DIR_VER); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); } else { lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); } addCloseXBadge(card, msgMenuBackdropCb); // Two buttons per row, filled in reading order; an odd last button gets its // own row (left cell). bi advances per button; the grid math places it. const int bw = (card_w - 2 * pad - gap) / 2; int bi = 0; auto mk_btn = [&](const char* text, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, bw, btn_h); lv_obj_set_pos(b, (bi % 2) * (bw + gap), hdr_h + (bi / 2) * (btn_h + gap)); styleButton(b); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, TR(text)); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); uiFitLabelWidth(lbl, bw - 8); // shrink a long translation (e.g. FR "Supprimer") to fit the fixed-width button lv_obj_center(lbl); ++bi; }; if (can_ack) mk_btn(TR(LV_SYMBOL_OK " Ack"), msgMenuAckCb); if (can_mention) { char ml[UITask::MAX_SENDER_NAME + 16]; #if defined(TLORA_PAGER) snprintf(ml, sizeof ml, "@%.16s", m.sender); // wide Pager cells can keep more of the name #else snprintf(ml, sizeof ml, "@%.10s", m.sender); // "Mention" is implied by the @; half-width cell #endif mk_btn(ml, msgMenuMentionCb); } mk_btn(TR(LV_SYMBOL_COPY " Copy"), msgMenuCopyCb); mk_btn(TR(LV_SYMBOL_LIST " Info"), msgMenuInfoCb); if (can_block) mk_btn(TR(LV_SYMBOL_CLOSE " Block"), msgMenuBlockCb); if (can_resend) mk_btn(TR(LV_SYMBOL_REFRESH " Resend"), msgMenuResendCb); mk_btn(TR(LV_SYMBOL_TRASH " Delete"), msgMenuDeleteCb); } // "Trace route" from the message Info popup: run a full multi-hop trace to the // conversation peer (each repeater on the path reports its SNR). Result lands // asynchronously in onTracePingResult, which renders the hop list. static void msgInfoTraceCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; ContactInfo c; if (!g_lv.task->lookupActiveContact(c)) { g_lv.task->showAlert(TR("No contact to trace"), 1500); return; } closeMsgInfoPopup(); s_trace_route_pending = true; uint32_t tag = the_mesh.uiSendTraceRoute(c); if (tag == 0) { s_trace_route_pending = false; g_lv.task->showAlert(TR("Trace failed"), 1400); } else { g_lv.task->showAlert(TR("Tracing route\xe2\x80\xa6"), 1600); } } // "Replay" from the message Info popup: draw the message's repeater path on the // map and animate it. s_route[] was already filled by buildRouteFromMessage() // when the popup opened (that's how we decided to show the button). static void msgInfoReplayCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_route_n < 2) { if (g_lv.task) g_lv.task->showAlert(TR("No mapped repeaters in this route"), 1600); return; } closeMsgInfoPopup(); startRouteReplay(); if (g_lv.task) g_lv.task->showAlert(TR("Replaying route\xe2\x80\xa6"), 1200); } // The signature colour a node would carry as a chat bubble (same FNV-1a hue as // usernameBubbleColors), as 0xRRGGBB for an LVGL "#RRGGBB ..#" recolor token. static uint32_t nodeSigColorHex(const char* s) { if (!s || !s[0]) return 0xFFFFFFu; lv_color_t bg, nc; usernameBubbleColors(s, &bg, &nc); return lv_color_to32(nc) & 0xFFFFFFu; } static void openMessageInfoPopup(int msg_idx) { if (!g_lv.task) return; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(msg_idx, m)) return; closeMsgInfoPopup(); lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_msg_info_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_msg_info_root); lv_obj_set_size(s_msg_info_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_msg_info_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_msg_info_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_msg_info_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_msg_info_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_msg_info_root, msgInfoBackdropCb, LV_EVENT_CLICKED, nullptr); // DM messages get a "Trace route" button (live multi-hop SNR trace to the // peer). Any message whose repeater path we can place on the map also gets a // "Replay" button. Build the route now so we know whether to offer it. const bool show_trace = !m.channel; const int route_pts = buildRouteFromMessage(m); const bool show_route = (route_pts >= 2); #if defined(TLORA_PAGER) // Reduce wrapping across the wide, short Pager. The independently scrollable // body handles routes and ACK details that exceed the available height. const int card_w = sw - 28; int card_h = sh - STATUSBAR_H - 8; #elif CAP_LARGE_SCREEN const int card_w = PCW(220); int card_h = (show_trace || show_route) ? PSC(290) : PSC(250); #else const int card_w = 220; int card_h = (show_trace || show_route) ? 290 : 250; #endif // Never taller than the screen; the body inside scrolls if it doesn't fit // (matters on the short 320x240 T-Deck in landscape). const int avail_h = sh - STATUSBAR_H - 8; if (card_h > avail_h) card_h = avail_h; lv_obj_t* card = lv_obj_create(s_msg_info_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, msgInfoBackdropCb); // Build a single multi-line label — keeps layout dead simple, and the // user can long-press it (clipboardSet) to copy the whole metadata // dump if they ever need it. Body text: // Message info // ──────────── // Time 2026-05-27 22:34:01 // From <sender> (only when incoming) // Path flood, 2 hops (or "direct (routed)" or "—") // SNR -3.5 dB (or "—") // RSSI -101 dBm (or "—") // Status sent / delivered / failed (only when outgoing DM) char body[1400]; // headroom for the FULL per-hop route list + recolor markers (#RRGGBB ... #) int blen = 0; // Title is rendered as a separate label below — don't repeat it in body. // Time char tbuf[40]; formatFullTimestamp(m.ts, tbuf, sizeof(tbuf)); blen += snprintf(body + blen, sizeof(body) - blen, TR("Time %s"), tbuf); // From (incoming only — outgoing is obviously us) if (!m.outgoing && m.sender[0]) { blen += snprintf(body + blen, sizeof(body) - blen, "\nFrom %s", m.sender); } // Direction / channel/dm blen += snprintf(body + blen, sizeof(body) - blen, "\nDir %s%s", m.outgoing ? "outgoing" : "incoming", m.channel ? " (channel)" : " (DM)"); // RX metadata: only meaningful for incoming messages received post-v4. const bool has_rx = (m.meta_flags & UITask::MSG_META_HAS_RX) != 0; if (has_rx) { const bool is_flood = (m.meta_flags & UITask::MSG_META_IS_FLOOD) != 0; if (is_flood) { blen += snprintf(body + blen, sizeof(body) - blen, "\nPath flood, %u hops, %u-byte hash", (unsigned)(m.path_len & 0x3F), (unsigned)((m.path_len >> 6) + 1)); } else { blen += snprintf(body + blen, sizeof(body) - blen, "\nPath direct (routed)"); } blen += snprintf(body + blen, sizeof(body) - blen, "\nSNR %.2f dB", (double)m.snr_q4 / 4.0); blen += snprintf(body + blen, sizeof(body) - blen, "\nRSSI %d dBm", (int)m.rssi); if (m.meta_flags & UITask::MSG_META_HAS_SCOPE) { // The raw code is an HMAC of this packet under the sender's region key, so // it is different on every message and means nothing on its own — showing // the bare hex read as "the region", and people reasonably asked why it kept // changing (#259). Report what was actually determined: whether it verified // against our own region key. The hex stays, in parentheses, for anyone // cross-checking a capture. // #271: when the code verified against one of the registered regions we can // name it outright. Fall back to the older my-region / another-region // answer when nothing matched, which is still all we can honestly say. char region[40]; const uint8_t rslot = UITask::metaScopeSlot(m.meta_flags); const char* rname = (rslot && rslot != REGION_SLOT_AMBIGUOUS) ? the_mesh.regionRegistry().nameForSlot(rslot) : nullptr; if (rslot == REGION_SLOT_AMBIGUOUS) { // Several registered regions produced this same 16-bit code. Naming any // one of them would be a guess, and a confident wrong region reads worse // than an honest "cannot tell". The tag is only 16 bits, so with N keys // live a collision runs at about N/65534 per packet. snprintf(region, sizeof region, "%s", TR("ambiguous (several regions matched)")); } else if (rname) { snprintf(region, sizeof region, "%s", rname); } else if (m.meta_flags & UITask::MSG_META_SCOPE_HOME) { char home[24] = {0}; touchPrefsGetRegionScope(home, sizeof home); snprintf(region, sizeof region, "%s", home[0] ? home : TR("my region")); } else { snprintf(region, sizeof region, "%s", TR("another region")); } // Key is "Scope" with no trailing space: TR() strips icon prefixes, NOT // trailing whitespace, so "Scope " would never match its .lang row. blen += snprintf(body + blen, sizeof(body) - blen, "\n%s %s (%04X)", TR("Scope"), region, (unsigned)m.in_scope); } // Full inbound route — the repeaters this flood traversed. Resolve each hop's // hash to its repeater name when that contact is known. EVERY hop is listed // (the popup body scrolls); a hard buffer guard is the only bound. When a hop // resolves to a name we show just the name — appending the raw hash hex made // long names wrap so the trailing hash ran into the next hop's number // ("…Toenis 31" + "3." read as "313."). The hash is only shown as a fallback // when the name is unknown. The name lookup is read-only so it's safe mid-RX. if (is_flood && m.in_path_n > 0) { const uint8_t hsz = (uint8_t)((m.path_len >> 6) + 1); const uint8_t cnt = (uint8_t)(m.path_len & 0x3F); blen += snprintf(body + blen, sizeof(body) - blen, "\nRoute"); int off = 0; for (uint8_t h = 0; h < cnt && off + (int)hsz <= m.in_path_n; ++h, off += hsz) { if (blen >= (int)sizeof(body) - 56) break; // hard guard: never overflow body[] char nm[33]; // ContactInfo.name is 32B; hold the full name (emoji eat 4B each) if (the_mesh.uiHopName(&m.in_path[off], hsz, nm, sizeof(nm))) { blen += snprintf(body + blen, sizeof(body) - blen, "\n %u. #%06X %s#", (unsigned)(h + 1), (unsigned)nodeSigColorHex(nm), nm); } else { char hashstr[9] = {0}; for (uint8_t b = 0; b < hsz && b < 4; ++b) snprintf(hashstr + b * 2, sizeof(hashstr) - b * 2, "%02X", m.in_path[off + b]); blen += snprintf(body + blen, sizeof(body) - blen, "\n %u. #%06X %s#", (unsigned)(h + 1), (unsigned)nodeSigColorHex(hashstr), hashstr); } } } } else if (!m.outgoing) { blen += snprintf(body + blen, sizeof(body) - blen, "\nPath \xe2\x80\x94" "\nSNR \xe2\x80\x94" "\nRSSI \xe2\x80\x94"); } // Delivery state (outgoing DMs only) if (m.outgoing && !m.channel) { const char* ds = "—"; switch (m.deliv_state) { case UITask::DELIV_NONE: ds = "queued"; break; case UITask::DELIV_SENT: ds = "sent"; break; case UITask::DELIV_DELIVERED: ds = "delivered"; break; case UITask::DELIV_FAILED: ds = "failed"; break; } blen += snprintf(body + blen, sizeof(body) - blen, "\nStatus %s", ds); } // "Repeats heard": echoes of our sent flood re-broadcast by nearby repeaters, // counted live this session. Shown for any tracked outgoing flood. if (m.outgoing && m.sent_fp) { blen += snprintf(body + blen, sizeof(body) - blen, "\nRepeats heard: %u", (unsigned)the_mesh.uiRepeatsForFp(m.sent_fp)); // List the repeaters we caught echoing it (name where known, else the hash). // Bounded (<= ECHO_MAX_HOPS) + hard buffer guard + read-only lookup -> no risk. uint8_t rhc = the_mesh.uiRepeatHopCount(m.sent_fp); for (uint8_t r = 0; r < rhc; r++) { if (blen >= (int)sizeof(body) - 96) break; // escaped name (<=64B) + markers must fit whole uint8_t hh[4]; uint8_t hsz = the_mesh.uiRepeatHop(m.sent_fp, r, hh, sizeof(hh)); if (hsz == 0) continue; char hashstr[9] = {0}; for (uint8_t b = 0; b < hsz && b < 4; ++b) snprintf(hashstr + b * 2, sizeof(hashstr) - b * 2, "%02X", hh[b]); char nm[33]; // ContactInfo.name is 32B; hold the full name (emoji eat 4B each) if (the_mesh.uiHopName(hh, hsz, nm, sizeof(nm))) { // #223 class: this name sits inside a '#RRGGBB …#' run — a literal '#' or a // broken UTF-8 tail in it desyncs every colour block after this line. char nm_c[33], nm_esc[66]; copyUtf8ReplacingMissingGlyphs(nullptr, nm_c, sizeof nm_c, nm); recolorEscape(nm_esc, sizeof nm_esc, nm_c); blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", (unsigned)nodeSigColorHex(nm), nm_esc); // name resolved -> no redundant hash } else blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", (unsigned)nodeSigColorHex(hashstr), hashstr); } } if (blen >= (int)sizeof(body)) blen = sizeof(body) - 1; body[blen] = '\0'; // Title row at top-left, sized to clear the close-X (top-right 32 px). Pinned. lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Message info")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, card_w - 20 - 32); lv_obj_set_pos(title, 0, 4); // Action row pinned just below the title (ABOVE the route info), then the // scrollable body below it. "Replay" (animate this message's repeater path on // the map) and/or "Trace route" (live multi-hop SNR trace, DM only) — shown // side-by-side when both apply, else full width. const int content_h = card_h - 20; // card has pad_all = 10 const bool has_btns = (show_route || show_trace); const int btn_h = PSC(32); const int btn_row_y = PSC(30); // just below the title const int body_top = has_btns ? (btn_row_y + btn_h + PSC(8)) : PSC(30); if (has_btns) { if (show_route && show_trace) { const int bw = (card_w - 20 - 6) / 2; lv_obj_t* rb = lv_btn_create(card); lv_obj_set_size(rb, bw, btn_h); lv_obj_set_pos(rb, 0, btn_row_y); styleButton(rb); lv_obj_add_event_cb(rb, msgInfoReplayCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* rl = lv_label_create(rb); lv_label_set_text_fmt(rl, LV_SYMBOL_GPS " %s", TR("Replay")); lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_center(rl); lv_obj_t* tb = lv_btn_create(card); lv_obj_set_size(tb, bw, btn_h); lv_obj_set_pos(tb, bw + 6, btn_row_y); styleButton(tb); lv_obj_add_event_cb(tb, msgInfoTraceCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* tl = lv_label_create(tb); lv_label_set_text(tl, TR("Trace")); lv_obj_set_style_text_font(tl, &g_font_12, LV_PART_MAIN); lv_obj_center(tl); } else { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 20, btn_h); lv_obj_set_pos(b, 0, btn_row_y); styleButton(b); lv_obj_add_event_cb(b, show_route ? msgInfoReplayCb : msgInfoTraceCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text_fmt(l, LV_SYMBOL_GPS " %s", show_route ? TR("Replay route on map") : TR("Trace route")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } } // Scrollable body below the action row. recolor is on so each repeater's // identifier is tinted with its chat-bubble colour (markers built into `body`). lv_obj_t* bodywrap = lv_obj_create(card); lv_obj_remove_style_all(bodywrap); lv_obj_set_size(bodywrap, card_w - 20, content_h - body_top); lv_obj_set_pos(bodywrap, 0, body_top); lv_obj_set_style_bg_opa(bodywrap, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_scroll_dir(bodywrap, LV_DIR_VER); lv_obj_set_scrollbar_mode(bodywrap, #if defined(TLORA_PAGER) LV_SCROLLBAR_MODE_ON #else LV_SCROLLBAR_MODE_AUTO #endif ); lv_obj_add_event_cb(bodywrap, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); // Thin visible scrollbar (remove_style_all wiped the theme's). lv_obj_set_style_width(bodywrap, 4, LV_PART_SCROLLBAR); lv_obj_set_style_bg_color(bodywrap, lv_color_hex(0x6FA8DA), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(bodywrap, LV_OPA_70, LV_PART_SCROLLBAR); lv_obj_set_style_radius(bodywrap, 2, LV_PART_SCROLLBAR); lv_obj_t* lbl = lv_label_create(bodywrap); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_label_set_recolor(lbl, true); lv_obj_set_width(lbl, card_w - 20 - 8); // leave room for the scrollbar lv_label_set_text(lbl, body); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(lbl, 0, 0); // Long-press the body to copy the full dump (a drag scrolls, a hold copies; // recolour markers are stripped on copy). lv_obj_add_flag(lbl, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(lbl, copyLabelLongPressCb, LV_EVENT_LONG_PRESSED, const_cast<char*>("info")); // Bottom-right Close button removed — the X badge at the top-right is // the standard dismiss affordance now (same as every other popup). } // Long-press handler attached to each chat bubble. User-data is the // absolute msg index (as an intptr_t). static void bubbleLongPressMenuCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); const int idx = (int)(intptr_t)lv_event_get_user_data(e); openMessageActionMenu(idx); } // Helper: render a centered placeholder label into the (cleaned) msgs panel. static void chatDetailShowPlaceholder(LvChatPanel& p, const char* msg) { lv_obj_t* lbl = lv_label_create(p.msgs); useChainedFont(lbl); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, 200); lv_label_set_text(lbl, TR(msg)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(lbl); } // Deterministic per-username bubble colours for the "colourful chat bubbles" // option. The same display name always maps to the same hue (FNV-1a hash), so // each participant in a group keeps a stable colour. Readability is by // construction: the bubble background is a DARK tint (off-white text stays // legible) and the sender-name line is a VIVID version of the same hue. static void usernameBubbleColors(const char* name, lv_color_t* bubble_bg, lv_color_t* name_col) { uint32_t h = 2166136261u; // FNV-1a offset basis for (const char* p = name; p && *p; ++p) { h ^= (uint8_t)(*p); h *= 16777619u; } const uint16_t hue = (uint16_t)(h % 360u); if (bubble_bg) *bubble_bg = lv_color_hsv_to_rgb(hue, 55, 26); // dark, off-white text readable if (name_col) *name_col = lv_color_hsv_to_rgb(hue, 85, 95); // vivid sender-name line } static void formatDaySeparator(char* buf, size_t cap, const struct tm* tv) { time_t nowt = time(nullptr); struct tm nowv; localtime_r(&nowt, &nowv); time_t yt = nowt - 86400; struct tm yv; localtime_r(&yt, &yv); if (tv->tm_year == nowv.tm_year && tv->tm_yday == nowv.tm_yday) snprintf(buf, cap, "%s", TR("Today")); else if (tv->tm_year == yv.tm_year && tv->tm_yday == yv.tm_yday) snprintf(buf, cap, "%s", TR("Yesterday")); else if (tv->tm_year == nowv.tm_year) strftime(buf, cap, "%a %d %b", tv); else strftime(buf, cap, "%d %b %Y", tv); } static lv_coord_t chatMeasureDaySepHeight() { return lv_font_get_line_height(&g_font_12) + 6; } // Calendar day key for day separators; false if timestamp is unsynced (pre-2020). static bool chatMsgDayKey(const UITask::UIMessage& m, long* out_key) { if (!out_key || m.ts < 1577836800UL) return false; time_t tt = (time_t)m.ts; struct tm tv; localtime_r(&tt, &tv); *out_key = (long)tv.tm_year * 512L + tv.tm_yday; return true; } // Escape '#' for a recolor-enabled label — LVGL renders "##" as a literal '#', so // user text can't accidentally open/close a "#RRGGBB …#" colour run. static void recolorEscape(char* dst, size_t cap, const char* src) { size_t o = 0; for (const char* s = src; *s && o + 2 < cap; ++s) { dst[o++] = *s; if (*s == '#') dst[o++] = '#'; } dst[o] = '\0'; } // ---- Virtualized chat bubbles ------------------------------------------------ // Message data lives in the _ui_msgs ring (up to MAX_UI_MESSAGES). Only a small // window of LVGL bubble widgets is materialised for the visible scroll range. static constexpr int kChatVirtOverscanPx = 120; static constexpr lv_coord_t kChatBubblePadH = 8; static constexpr lv_coord_t kChatBubblePadV = 5; static constexpr lv_coord_t kChatSideGutter = 2; static constexpr lv_coord_t kChatRowGap = 4; static constexpr lv_coord_t kChatCompactRowGap = 2; static constexpr lv_coord_t kChatDividerH = 16; static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, bool thread_is_room, lv_coord_t bubble_max_w); static lv_coord_t chatMeasureMessageRowHeight(const UITask::UIMessage& m, LvChatPanel* p, int logical_i); static lv_coord_t chatVirtCreateMessageRow(LvChatPanel* p, int logical_i, int ring_idx, lv_coord_t vp_y, lv_coord_t* out_jump_y); static lv_coord_t chatVirtMsgContentY(int logical_i); static lv_coord_t chatVirtMsgContentBottom(int logical_i); static lv_coord_t chatVirtMsgViewportY(int logical_i, int32_t virt_top); static void chatVirtRefreshScrollArea(LvChatPanel* p); struct ChatBubbleDisplay { const char* show_sender = nullptr; const char* show_text = nullptr; char retro_sender[UITask::MAX_SENDER_NAME + 1]; char san_sender[UITask::MAX_SENDER_NAME + 8]; char san_text[UITask::MAX_MSG_TEXT + 8]; }; struct ChatVirtLayout { LvChatPanel* panel = nullptr; int n = 0; int divider_i = -1; int32_t divider_y = -1; int last_i0 = -1; int last_i1 = -1; bool thread_is_room = false; bool compact_chat = false; char compact_thread_name[UITask::MAX_THREAD_NAME + 1] = ""; lv_coord_t content_w = 0; lv_coord_t bubble_max_w = 0; int* msg_idx = nullptr; int32_t* offsets = nullptr; // virt Y per message; offsets[n] = virt total height int32_t* day_sep_y = nullptr; // virt Y of day label before message i, or -1 // Ring slots of the first/last laid-out message. Content-generation guard: the // ring evicting or rotating (n unchanged at capacity) and thread switches both // move these, so refreshChatDetail can tell "same count, different content" // apart from "nothing changed" (two threads can never share a ring slot). int first_ring = -1; int last_ring = -1; int32_t virt_total_h = 0; lv_coord_t lv_total_h = 0; lv_obj_t* spacer = nullptr; lv_obj_t* divider = nullptr; bool pending_scroll = false; bool pending_scroll_bottom = false; lv_coord_t pending_scroll_y = 0; // When LVGL scroll coords are compressed, finger drag still moves content 1:1 in // layout (virt) pixels; scroll_y only tracks scrollbar thumb position. int32_t scroll_virt_top = 0; lv_coord_t scroll_lv_anchor = 0; bool scroll_virt_valid = false; }; static ChatVirtLayout s_chat_virt; static int* s_chat_msg_idx = nullptr; static int s_chat_msg_idx_cap = 0; static lv_timer_t* s_chat_virt_render_timer = nullptr; static LvChatPanel* s_chat_virt_render_panel = nullptr; // Layout offsets are int32; LVGL scroll coords are int16 (~8191 max). When virt // height exceeds the cap, scroll Y is compressed for the spacer/scrollbar but bubble // viewport positions use virt-space 1:1 (offsets[i] - virt_top). Finger drags also // advance virt_top 1:1 (see chatVirtRemap1To1Scroll) so scroll speed matches touch. static constexpr int32_t kChatVirtLvScrollMax = 7500; static bool chatVirtCompressCoords() { return s_chat_virt.virt_total_h > kChatVirtLvScrollMax; } static lv_coord_t chatVirtMsgsViewH(LvChatPanel* p) { if (!p || !p->msgs) return 0; const lv_coord_t h = lv_obj_get_height(p->msgs); const lv_coord_t pad_t = lv_obj_get_style_pad_top(p->msgs, LV_PART_MAIN); const lv_coord_t pad_b = lv_obj_get_style_pad_bottom(p->msgs, LV_PART_MAIN); const lv_coord_t inner = h - pad_t - pad_b; return inner > 0 ? inner : h; } // Max scroll Y from layout metadata — reliable before LVGL has recomputed // scroll_bottom after the virt spacer is first sized (open-to-bottom landed at 0). static lv_coord_t chatVirtMaxScrollY(LvChatPanel* p) { if (!p || !p->msgs || s_chat_virt.lv_total_h <= 0) return 0; chatVirtRefreshScrollArea(p); const lv_coord_t view_h = chatVirtMsgsViewH(p); lv_coord_t max_y = s_chat_virt.lv_total_h - view_h; return max_y > 0 ? max_y : 0; } static bool chatVirtAwayFromBottom(LvChatPanel* p) { if (!p || !p->msgs) return false; if (lv_obj_get_scroll_bottom(p->msgs) > 30) return true; const lv_coord_t max_y = chatVirtMaxScrollY(p); return max_y > 30 && lv_obj_get_scroll_y(p->msgs) < max_y - 30; } static lv_coord_t chatVirtLastBubbleHeight(LvChatPanel* p, int n) { if (!p || n <= 0 || !s_chat_msg_idx || !g_lv.task) return 40; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(s_chat_msg_idx[n - 1], m)) return 40; return chatMeasureMessageRowHeight(m, p, n - 1); } static void chatVirtUpdateLvScale(LvChatPanel* p, int n, int32_t virt_total) { s_chat_virt.virt_total_h = virt_total; if (!chatVirtCompressCoords()) { s_chat_virt.lv_total_h = static_cast<lv_coord_t>(virt_total); return; } (void)n; // Make LVGL's max scroll map back to the real layout bottom. Bubble viewport // positions are offsets[i] - lvToVirt(scroll_y), so the bottom state must be // virt_total - view_h rather than a special bottom-pinned layout. const lv_coord_t view_h = chatVirtMsgsViewH(p); const int32_t max_virt_top = (virt_total > view_h) ? (virt_total - static_cast<int32_t>(view_h)) : 0; lv_coord_t need = static_cast<lv_coord_t>( (static_cast<int64_t>(max_virt_top) * kChatVirtLvScrollMax) / virt_total) + view_h; if (need > static_cast<lv_coord_t>(LV_COORD_MAX - 8)) need = static_cast<lv_coord_t>(LV_COORD_MAX - 8); s_chat_virt.lv_total_h = need; } static lv_coord_t chatVirtVirtToLv(int32_t virt_y) { if (s_chat_virt.virt_total_h <= 0) return 0; if (!chatVirtCompressCoords()) return static_cast<lv_coord_t>(virt_y); return static_cast<lv_coord_t>( (static_cast<int64_t>(virt_y) * kChatVirtLvScrollMax) / s_chat_virt.virt_total_h); } static int32_t chatVirtLvToVirt(lv_coord_t lv_y) { if (s_chat_virt.virt_total_h <= 0) return 0; if (!chatVirtCompressCoords()) return static_cast<int32_t>(lv_y); return static_cast<int32_t>( (static_cast<int64_t>(lv_y) * s_chat_virt.virt_total_h) / kChatVirtLvScrollMax); } static int32_t chatVirtLvViewToVirt(lv_coord_t lv_view_h) { if (s_chat_virt.virt_total_h <= 0) return 0; if (!chatVirtCompressCoords()) return static_cast<int32_t>(lv_view_h); return static_cast<int32_t>( (static_cast<int64_t>(lv_view_h) * s_chat_virt.virt_total_h) / kChatVirtLvScrollMax); } static int32_t chatVirtMaxVirtTop(LvChatPanel* p) { if (s_chat_virt.virt_total_h <= 0) return 0; const lv_coord_t view_h = p ? chatVirtMsgsViewH(p) : 0; const int32_t vh = static_cast<int32_t>(view_h); return (s_chat_virt.virt_total_h > vh) ? (s_chat_virt.virt_total_h - vh) : 0; } static int32_t chatVirtEffectiveVirtTop(LvChatPanel* p) { if (!p || !p->msgs) return 0; const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); if (!chatVirtCompressCoords()) return static_cast<int32_t>(scroll_y); if (s_chat_virt.scroll_virt_valid) return s_chat_virt.scroll_virt_top; return chatVirtLvToVirt(scroll_y); } static void chatVirtSyncScrollState(LvChatPanel* p, lv_coord_t lv_y, int32_t virt_override = -1) { if (!p || !chatVirtCompressCoords()) { s_chat_virt.scroll_virt_valid = false; return; } s_chat_virt.scroll_virt_valid = true; s_chat_virt.scroll_virt_top = (virt_override >= 0) ? virt_override : chatVirtLvToVirt(lv_y); const int32_t max_top = chatVirtMaxVirtTop(p); if (s_chat_virt.scroll_virt_top < 0) s_chat_virt.scroll_virt_top = 0; else if (s_chat_virt.scroll_virt_top > max_top) s_chat_virt.scroll_virt_top = max_top; s_chat_virt.scroll_lv_anchor = chatVirtVirtToLv(s_chat_virt.scroll_virt_top); } // Remap LVGL's 1:1 finger→scroll_y delta into a 1:1 finger→virt_top delta when coords // are compressed (otherwise a small drag jumps through a huge history chunk). static void chatVirtRemap1To1Scroll(LvChatPanel* p) { if (!p || !p->msgs || !chatVirtCompressCoords()) return; const lv_coord_t lv_y = lv_obj_get_scroll_y(p->msgs); if (!s_chat_virt.scroll_virt_valid) { chatVirtSyncScrollState(p, lv_y); return; } const lv_coord_t delta = lv_y - s_chat_virt.scroll_lv_anchor; if (delta == 0) return; s_chat_virt.scroll_virt_top += static_cast<int32_t>(delta); const int32_t max_top = chatVirtMaxVirtTop(p); if (s_chat_virt.scroll_virt_top < 0) s_chat_virt.scroll_virt_top = 0; else if (s_chat_virt.scroll_virt_top > max_top) s_chat_virt.scroll_virt_top = max_top; const lv_coord_t corrected = chatVirtVirtToLv(s_chat_virt.scroll_virt_top); s_chat_virt.scroll_lv_anchor = corrected; if (corrected != lv_y) lv_obj_scroll_to_y(p->msgs, corrected, LV_ANIM_OFF); } static bool chatVirtNearStoreBottom(LvChatPanel* p, lv_coord_t scroll_y) { (void)scroll_y; if (!p || !p->msgs || s_chat_virt.n <= 0) return false; return lv_obj_get_scroll_bottom(p->msgs) <= 24; } // Message index whose virt extent contains virt_top (viewport top in layout space). static int chatVirtFindMsgAtVirtTop(int32_t virt_top) { if (!s_chat_virt.offsets || s_chat_virt.n <= 0) return 0; if (virt_top <= 0) return 0; int i = 0; for (int j = 1; j < s_chat_virt.n; ++j) { if (s_chat_virt.offsets[j] <= virt_top) i = j; else break; } return i; } static lv_coord_t chatVirtMeasuredHeightAt(LvChatPanel* p, int logical_i) { if (!p || !g_lv.task || !s_chat_msg_idx || logical_i < 0 || logical_i >= s_chat_virt.n) return 40; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(s_chat_msg_idx[logical_i], m)) return 40; return chatMeasureMessageRowHeight(m, p, logical_i); } #if TRACE_MESSAGE_SCROLL_ACTIVITY static void chatVirtLogTopAnchor(const char* tag, LvChatPanel* p, lv_coord_t scroll_y, int touch_x, int touch_y) { if (!tag || !p || s_chat_virt.n <= 0 || !s_chat_virt.offsets) return; const int32_t virt_top = chatVirtLvToVirt(scroll_y); const int msg_i = chatVirtFindMsgAtVirtTop(virt_top); const int virt_off = (int)(virt_top - s_chat_virt.offsets[msg_i]); const int vp_y = (int)chatVirtMsgViewportY(msg_i, virt_top); if (touch_x >= 0) CHAT_SCROLL_TRACE_PRINTF("[CHAT] %s touch=(%d,%d) scroll_y=%d top=Msg#%d @Y%+d virt_off=%d\n", tag, touch_x, touch_y, (int)scroll_y, msg_i + 1, vp_y, virt_off); else CHAT_SCROLL_TRACE_PRINTF("[CHAT] %s scroll_y=%d top=Msg#%d @Y%+d virt_off=%d\n", tag, (int)scroll_y, msg_i + 1, vp_y, virt_off); } #endif // Only materialise new bubbles when the visible range extends past what we already have. static bool chatVirtNeedReflow(int new_i0, int new_i1) { if (s_chat_virt.last_i0 < 0 || s_chat_virt.last_i1 < 0) return true; return new_i0 < s_chat_virt.last_i0 || new_i1 > s_chat_virt.last_i1; } static void chatVirtCancelRenderTimer() { s_chat_virt_render_panel = nullptr; // Keep one timer allocation for the lifetime of the UI. This path is hit // repeatedly while a fast scroll crosses virtual-window boundaries. Two // matching dumps reached lv_timer_exec through an invalid callback (#251), so // do not churn this timer's list node in the reproducing path. Pausing keeps // its callback allocation stable while still cancelling the pending work. if (s_chat_virt_render_timer) lv_timer_pause(s_chat_virt_render_timer); } static void chatVirtResetInputForMsgs(LvChatPanel* p) { lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); if (p && p->msgs) lv_indev_reset(nullptr, p->msgs); } static bool chatVirtIndevStillScrolling(LvChatPanel* p) { if (!p || !p->msgs) return false; for (lv_indev_t* in = lv_indev_get_next(nullptr); in; in = lv_indev_get_next(in)) { if (lv_indev_get_scroll_dir(in) == LV_DIR_NONE) continue; lv_obj_t* scr = lv_indev_get_scroll_obj(in); for (lv_obj_t* o = scr; o; o = lv_obj_get_parent(o)) { if (o == p->msgs) return true; } } return false; } static void chatVirtFreeOffsets() { if (s_chat_virt.offsets) { heap_caps_free(s_chat_virt.offsets); s_chat_virt.offsets = nullptr; } if (s_chat_virt.day_sep_y) { heap_caps_free(s_chat_virt.day_sep_y); s_chat_virt.day_sep_y = nullptr; } } #if defined(TLORA_PAGER) // Encoder-nav focus survival across the virtualized re-render. The render // deletes and recreates every bubble row, so a row pointer cannot preserve // focus across the rebuild. The pending logical index below is the message the // encoder is steering toward: chatVirtRenderWindow safely detaches the group // before destruction, then re-aims focus at the matching recreated row via the // existing one-shot s_nav_focus_hint mechanism. pagerEncoderChatEdgeScroll sets // the target (an edge detent selects the neighboring logical index) and folds // further detents into it while a render is in flight — fast turning otherwise // stepped focus out from the transiently wrong focus position before the load // landed. -1 = idle. The timestamp expires a stale pending target (see the // clamp) so a render that never fires can't wedge encoder nav. static int s_pager_chat_focus_i = -1; static uint32_t s_pager_chat_focus_ms = 0; #endif static void chatVirtReset(LvChatPanel* p) { chatVirtCancelRenderTimer(); (void)p; #if defined(TLORA_PAGER) s_pager_chat_focus_i = -1; #endif // Null the divider pointer WITHOUT queueing a delete. It is always a child of // p->msgs, and every path that follows a reset (lv_obj_clean in the empty-thread // branches, chatVirtPurgeMsgsChildrenSync, chatVirtClearBubbleWidgets) deletes // the widget itself. Queueing lv_obj_del_async here handed LVGL a raw pointer // that those synchronous cleans freed FIRST, so the deferred lv_obj_del then ran // on freed memory (and with both panels open, on the OTHER panel's divider). s_chat_virt.divider = nullptr; s_chat_virt.spacer = nullptr; s_chat_virt.panel = nullptr; s_chat_virt.n = 0; s_chat_virt.divider_i = -1; s_chat_virt.divider_y = -1; s_chat_virt.first_ring = -1; s_chat_virt.last_ring = -1; s_chat_virt.compact_chat = false; s_chat_virt.compact_thread_name[0] = '\0'; s_chat_virt.virt_total_h = 0; s_chat_virt.lv_total_h = 0; s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; s_chat_virt.pending_scroll = false; s_chat_virt.pending_scroll_bottom = false; s_chat_virt.pending_scroll_y = 0; s_chat_virt.scroll_virt_top = 0; s_chat_virt.scroll_lv_anchor = 0; s_chat_virt.scroll_virt_valid = false; s_chat_msgs_scroll_obj = nullptr; #if TRACE_MESSAGE_SCROLL_ACTIVITY s_chat_touch_on_msgs = false; s_chat_dbg_last_scroll_logged = -9999; #endif chatVirtFreeOffsets(); } static void chatVirtEnsureMsgIdx() { const int need = (g_lv.task) ? g_lv.task->msgCap() : UITask::MAX_UI_MESSAGES; if (need <= 0) return; if (s_chat_msg_idx && s_chat_msg_idx_cap == need) return; if (s_chat_msg_idx) { heap_caps_free(s_chat_msg_idx); s_chat_msg_idx = nullptr; } s_chat_msg_idx_cap = 0; s_chat_msg_idx = (int*)heap_caps_malloc(sizeof(int) * (size_t)need, MALLOC_CAP_SPIRAM); if (!s_chat_msg_idx) s_chat_msg_idx = (int*)heap_caps_malloc(sizeof(int) * (size_t)need, MALLOC_CAP_8BIT); if (s_chat_msg_idx) s_chat_msg_idx_cap = need; } static void chatParseMessageDisplay(const UITask::UIMessage& m, bool channel_mode, bool thread_is_room, ChatBubbleDisplay& d) { d.show_sender = m.sender; d.show_text = m.text; d.retro_sender[0] = '\0'; if (!m.outgoing && (thread_is_room || (channel_mode && (m.sender[0] == '\0' || (m.sender[0] == 'r' && m.sender[1] == 'x' && m.sender[2] == '\0'))))) { const char* colon = strstr(m.text, ": "); if (colon) { const int slen = static_cast<int>(colon - m.text); if (slen > 0 && slen <= UITask::MAX_SENDER_NAME) { strncpy(d.retro_sender, m.text, slen); d.retro_sender[slen] = '\0'; d.show_sender = d.retro_sender; d.show_text = colon + 2; } } } copyUtf8ReplacingMissingGlyphs(&g_font_12, d.san_sender, sizeof(d.san_sender), d.show_sender); copyUtf8ReplacingMissingGlyphs(chatMessageFont(), d.san_text, sizeof(d.san_text), d.show_text); } // ---- Chat virt serial diagnostics ------------------------------------------- #if TRACE_MESSAGE_SCROLL_ACTIVITY static bool s_chat_virt_at_store_top = false; static bool s_chat_virt_at_store_bottom = false; static void chatVirtGetThreadName(char* name, size_t len) { if (!name || len == 0) return; name[0] = '\0'; if (!g_lv.task) return; const int idx = g_lv.task->activeThreadIdx(); if (idx < 0) return; bool ch = false; uint16_t un = 0; uint32_t ts = 0; g_lv.task->getThreadInfo(idx, ch, un, ts, name, len); } static const char* chatVirtSenderLabel(const UITask::UIMessage& m, bool channel_mode, ChatBubbleDisplay& d) { chatParseMessageDisplay(m, channel_mode, s_chat_virt.thread_is_room, d); if (d.show_sender && d.show_sender[0]) return d.show_sender; if (m.outgoing) return "(me)"; return "?"; } static void chatVirtLogMsg(const char* tag, int ordinal_1based, int ring_idx, bool channel_mode) { if (!tag || ordinal_1based <= 0) return; UITask::UIMessage m{}; if (!g_lv.task || !g_lv.task->getMessageByIndex(ring_idx, m)) { CHAT_SCROLL_TRACE_PRINTF("[CHAT] %s #%d fetch_failed ring_idx=%d\n", tag, ordinal_1based, ring_idx); return; } char ts[24]; formatBubbleTs(m.ts, ts, sizeof(ts)); ChatBubbleDisplay d{}; const char* sender = chatVirtSenderLabel(m, channel_mode, d); CHAT_SCROLL_TRACE_PRINTF("[CHAT] %s #%d %s %s\n", tag, ordinal_1based, ts[0] ? ts : "--:--", sender); } static void chatVirtLogVisibleRange(LvChatPanel* p, int i0, int i1) { if (!p) return; CHAT_SCROLL_TRACE_PRINTF("[CHAT] on_screen [%d..%d] of %d\n", i0 + 1, i1 + 1, s_chat_virt.n); for (int i = i0; i <= i1; ++i) chatVirtLogMsg("visible", i + 1, s_chat_virt.msg_idx[i], p->channel_mode); } static void chatVirtLogScrollTransition(LvChatPanel* p, int old_i0, int old_i1, int new_i0, int new_i1) { if (!p || old_i0 < 0 || old_i1 < 0) return; if (old_i0 == new_i0 && old_i1 == new_i1) return; if (new_i1 > old_i1 || new_i0 > old_i0) CHAT_SCROLL_TRACE_PRINTF("[CHAT] vertical scroll up — newer messages coming on screen\n"); else if (new_i1 < old_i1 || new_i0 < old_i0) CHAT_SCROLL_TRACE_PRINTF("[CHAT] vertical scroll down — older messages coming on screen\n"); CHAT_SCROLL_TRACE_PRINTF("[CHAT] prefetching messages [%d..%d]\n", new_i0 + 1, new_i1 + 1); for (int i = old_i0; i <= old_i1; ++i) { if (i < new_i0 || i > new_i1) chatVirtLogMsg("leaving", i + 1, s_chat_virt.msg_idx[i], p->channel_mode); } for (int i = new_i0; i <= new_i1; ++i) { if (i < old_i0 || i > old_i1) chatVirtLogMsg("entering", i + 1, s_chat_virt.msg_idx[i], p->channel_mode); } } static void chatVirtCheckStoreEdges(LvChatPanel* p) { if (!p || !p->msgs || s_chat_virt.panel != p || s_chat_virt.n <= 0) return; const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); const lv_coord_t sb = lv_obj_get_scroll_bottom(p->msgs); const bool at_top = (s_chat_virt.last_i0 == 0 && scroll_y < 16); const bool at_bot = (s_chat_virt.last_i1 >= s_chat_virt.n - 1 && sb <= 24); if (at_top && !s_chat_virt_at_store_top) { s_chat_virt_at_store_top = true; CHAT_SCROLL_TRACE_PRINTF("[CHAT] reached store top (oldest message #1)\n"); } else if (!at_top) { s_chat_virt_at_store_top = false; } if (at_bot && !s_chat_virt_at_store_bottom) { s_chat_virt_at_store_bottom = true; CHAT_SCROLL_TRACE_PRINTF("[CHAT] reached store bottom (newest message #%d)\n", s_chat_virt.n); } else if (!at_bot) { s_chat_virt_at_store_bottom = false; } } #endif static void chatBuildBubbleMeta(const UITask::UIMessage& m, bool channel_mode, char* out, size_t out_len, uint32_t* out_fg) { if (out && out_len > 0) out[0] = '\0'; if (out_fg) *out_fg = COLOR_SUB; if (!out || out_len == 0) return; char ts_buf[20]; formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf)); const char* deliv_glyph = ""; uint32_t deliv_fg = COLOR_SUB; if (m.outgoing && !channel_mode && m.deliv_state != UITask::DELIV_NONE) { switch (m.deliv_state) { case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break; case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break; case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break; default: break; } } char rep_buf[12] = ""; if (m.outgoing && m.sent_fp) { const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp); if (reps > 0) snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_REFRESH "%u", (unsigned)reps); } else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX) && (m.meta_flags & UITask::MSG_META_IS_FLOOD)) { const uint8_t hops = static_cast<uint8_t>(m.path_len & 0x3F); snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); } snprintf(out, out_len, "%s%s%s", ts_buf, deliv_glyph, rep_buf); if (out_fg) *out_fg = deliv_glyph[0] ? deliv_fg : COLOR_SUB; } static lv_coord_t chatTextWidth(const char* s) { if (!s || !s[0]) return 0; lv_point_t size; lv_txt_get_size(&size, s, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); return size.x; } static bool chatUtf8Continuation(char c) { return (static_cast<uint8_t>(c) & 0xC0) == 0x80; } static void chatFitLeadingEllipsis(const char* src, lv_coord_t max_w, char* out, size_t out_len) { if (!out || out_len == 0) return; out[0] = '\0'; if (!src || !src[0] || max_w <= 0) return; if (chatTextWidth(src) <= max_w) { snprintf(out, out_len, "%s", src); return; } const char* ell = "..."; if (chatTextWidth(ell) > max_w) return; const size_t len = strlen(src); for (size_t i = 0; i < len; ++i) { if (chatUtf8Continuation(src[i])) continue; char cand[48]; snprintf(cand, sizeof(cand), "%s%s", ell, src + i); if (chatTextWidth(cand) <= max_w) { snprintf(out, out_len, "%s", cand); return; } } snprintf(out, out_len, "%s", ell); } static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, bool thread_is_room, lv_coord_t bubble_max_w) { ChatBubbleDisplay d{}; chatParseMessageDisplay(m, channel_mode, thread_is_room, d); const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH; lv_coord_t inner_y = 0; char meta_buf[48]; chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); // Bubble layout: timestamp/meta always share the top row (channels, DMs, rooms). const bool show_sender = (channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]; if (show_sender || meta_buf[0]) inner_y += lv_font_get_line_height(&g_font_12); const lv_font_t* msg_font = chatMessageFont(); lv_point_t txt_size; lv_txt_get_size(&txt_size, d.san_text, msg_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w; lv_point_t wrapped_size; lv_txt_get_size(&wrapped_size, d.san_text, msg_font, 0, 0, txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); return kChatBubblePadV * 2 + inner_y + wrapped_size.y; } static void chatBuildCompactLine(const UITask::UIMessage& m, LvChatPanel* p, int logical_i, const ChatBubbleDisplay& d, char* line, size_t line_cap) { if (!line || line_cap == 0) return; line[0] = '\0'; const bool colorful_bubbles = touchPrefsGetColorfulBubbles(); lv_color_t bg_ignored = lv_color_hex(COLOR_RECV_BG); lv_color_t sender_col = lv_color_hex(COLOR_ACCENT); const char* color_name = m.outgoing ? the_mesh.getNodePrefs()->node_name : d.show_sender; if (colorful_bubbles && color_name && color_name[0]) usernameBubbleColors(color_name, &bg_ignored, &sender_col); const char* row_name = m.outgoing ? the_mesh.getNodePrefs()->node_name : (d.san_sender[0] && !(d.san_sender[0] == 'r' && d.san_sender[1] == 'x' && d.san_sender[2] == '\0')) ? d.san_sender : s_chat_virt.compact_thread_name; char name_san[48]; copyUtf8ReplacingMissingGlyphs(&g_font_12, name_san, sizeof(name_san), row_name); char esc_name[100]; char esc_text[2 * sizeof(d.san_text)]; recolorEscape(esc_name, sizeof(esc_name), name_san); recolorEscape(esc_text, sizeof(esc_text), d.san_text); char ts_c[12]; formatBubbleHhMm(m.ts, ts_c, sizeof(ts_c)); const char* dglyph = ""; uint32_t dfg = COLOR_SUB; if (m.outgoing && !p->channel_mode && m.deliv_state != UITask::DELIV_NONE) { switch (m.deliv_state) { case UITask::DELIV_SENT: dglyph = LV_SYMBOL_UPLOAD; dfg = COLOR_SUB; break; case UITask::DELIV_DELIVERED: dglyph = LV_SYMBOL_OK LV_SYMBOL_OK; dfg = COLOR_ACCENT; break; case UITask::DELIV_FAILED: dglyph = LV_SYMBOL_CLOSE " tap to resend"; dfg = 0xE08080; break; } } char reps[12] = ""; if (m.outgoing && m.sent_fp) { const uint8_t r = the_mesh.uiRepeatsForFp(m.sent_fp); if (r > 0) snprintf(reps, sizeof(reps), LV_SYMBOL_REFRESH "%u", (unsigned)r); } else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX) && (m.meta_flags & UITask::MSG_META_IS_FLOOD)) { const uint8_t hops = (uint8_t)(m.path_len & 0x3F); snprintf(reps, sizeof(reps), LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); } const unsigned sc_hex = lv_color_to32(sender_col) & 0xFFFFFFu; int off = 0; if (ts_c[0]) off += snprintf(line + off, line_cap - off, "#%06X %s# ", (unsigned)(COLOR_SUB & 0xFFFFFFu), ts_c); off += snprintf(line + off, line_cap - off, "#%06X %s:# %s", sc_hex, esc_name, esc_text); if (off > (int)line_cap - 1) off = (int)line_cap - 1; if ((dglyph[0] || reps[0]) && off < (int)line_cap - 32) snprintf(line + off, line_cap - off, " #%06X %s%s#", (unsigned)(dfg & 0xFFFFFFu), dglyph, reps); } // Plain-text line for height measurement — must match the glyphs/wrap of the recolor // label (lv_txt_get_size mishandles #RRGGBB markup and full widget width). static void chatBuildCompactPlainLine(const UITask::UIMessage& m, LvChatPanel* p, const ChatBubbleDisplay& d, char* line, size_t line_cap) { if (!line || line_cap == 0) return; line[0] = '\0'; const char* row_name = m.outgoing ? the_mesh.getNodePrefs()->node_name : (d.san_sender[0] && !(d.san_sender[0] == 'r' && d.san_sender[1] == 'x' && d.san_sender[2] == '\0')) ? d.san_sender : s_chat_virt.compact_thread_name; char name_san[48]; copyUtf8ReplacingMissingGlyphs(&g_font_12, name_san, sizeof(name_san), row_name); char ts_c[12]; formatBubbleHhMm(m.ts, ts_c, sizeof(ts_c)); const char* dglyph = ""; if (m.outgoing && !p->channel_mode && m.deliv_state != UITask::DELIV_NONE) { switch (m.deliv_state) { case UITask::DELIV_SENT: dglyph = LV_SYMBOL_UPLOAD; break; case UITask::DELIV_DELIVERED: dglyph = LV_SYMBOL_OK LV_SYMBOL_OK; break; case UITask::DELIV_FAILED: dglyph = LV_SYMBOL_CLOSE " tap to resend"; break; default: break; } } char reps[12] = ""; if (m.outgoing && m.sent_fp) { const uint8_t r = the_mesh.uiRepeatsForFp(m.sent_fp); if (r > 0) snprintf(reps, sizeof(reps), LV_SYMBOL_REFRESH "%u", (unsigned)r); } else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX) && (m.meta_flags & UITask::MSG_META_IS_FLOOD)) { const uint8_t hops = (uint8_t)(m.path_len & 0x3F); snprintf(reps, sizeof(reps), LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); } int off = 0; if (ts_c[0]) off += snprintf(line + off, line_cap - off, "%s ", ts_c); off += snprintf(line + off, line_cap - off, "%s: %s", name_san, d.san_text); if (off > (int)line_cap - 1) off = (int)line_cap - 1; if ((dglyph[0] || reps[0]) && off < (int)line_cap - 32) snprintf(line + off, line_cap - off, " %s%s", dglyph, reps); } static lv_coord_t chatMeasureCompactRowHeight(const UITask::UIMessage& m, LvChatPanel* p, int logical_i, const ChatBubbleDisplay& d) { (void)logical_i; char line[640]; chatBuildCompactPlainLine(m, p, d, line, sizeof(line)); static constexpr lv_coord_t kPadH = 3; static constexpr lv_coord_t kPadV = 1; const lv_coord_t inner_w = (s_chat_virt.content_w > kPadH * 2) ? s_chat_virt.content_w - kPadH * 2 : s_chat_virt.content_w; lv_point_t wrapped; lv_txt_get_size(&wrapped, line, chatMessageFont(), 0, 0, inner_w > 0 ? inner_w : LV_COORD_MAX, LV_TEXT_FLAG_NONE); return wrapped.y + kPadV * 2; } static lv_coord_t chatMeasureMessageRowHeight(const UITask::UIMessage& m, LvChatPanel* p, int logical_i) { if (s_chat_virt.compact_chat) { ChatBubbleDisplay d{}; chatParseMessageDisplay(m, p->channel_mode, s_chat_virt.thread_is_room, d); return chatMeasureCompactRowHeight(m, p, logical_i, d); } return chatMeasureBubbleHeight(m, p->channel_mode, s_chat_virt.thread_is_room, s_chat_virt.bubble_max_w); } // Safe teardown for floating chat widgets — same pattern as popupClose: if the // msgs scroller is (or was) the active scroll object, synchronous lv_obj_del // during the indev/draw tick can leave LVGL drawing freed memory (heap assert in // lv_mem_buf_get / draw_shadow on open of a large channel). static void chatVirtBeforeMassDelete() { lv_indev_t* act = lv_indev_get_act(); if (act) lv_indev_wait_release(act); } // Remove bubble/divider widgets only; keep the spacer so scroll height stays valid. // Deletes are SYNCHRONOUS: the only caller is chatVirtRenderWindow, which runs in // lv_async_call context (after the display refresh) — lv_obj_del_async here would // leave stale children in spec_attr until the next tick, and layout_update_core // then walks freed memory. Never call this from an indev/event handler. static void chatVirtClearBubbleWidgets(LvChatPanel* p) { if (!p || !p->msgs) return; if (!chatVirtIndevStillScrolling(p)) chatVirtResetInputForMsgs(p); chatVirtBeforeMassDelete(); s_chat_virt.divider = nullptr; for (int i = static_cast<int>(lv_obj_get_child_cnt(p->msgs)) - 1; i >= 0; --i) { lv_obj_t* ch = lv_obj_get_child(p->msgs, i); if (!ch) continue; if (s_chat_virt.spacer && ch == s_chat_virt.spacer && lv_obj_is_valid(s_chat_virt.spacer)) continue; lv_obj_del(ch); } } // Sync purge — only call from lv_async_call / timer context, not indev handlers. // Returns true when keypad navigation was detached and must be rebuilt after the // replacement tree (normally a placeholder) has been created. static bool chatVirtPurgeMsgsChildrenSync(LvChatPanel* p) { if (!p || !p->msgs) return false; chatVirtCancelRenderTimer(); #if CAP_KEYPAD_NAV const bool nav_detached = navDetachBeforeTreeMutation(); #else constexpr bool nav_detached = false; #endif chatVirtResetInputForMsgs(p); chatVirtBeforeMassDelete(); for (int i = static_cast<int>(lv_obj_get_child_cnt(p->msgs)) - 1; i >= 0; --i) { lv_obj_t* ch = lv_obj_get_child(p->msgs, i); if (ch) lv_obj_del(ch); } s_chat_virt.spacer = nullptr; s_chat_virt.divider = nullptr; return nav_detached; } static void chatVirtResetToPlaceholder(LvChatPanel& p, const char* text) { lv_indev_reset(nullptr, nullptr); chatVirtReset(&p); const bool nav_detached = chatVirtPurgeMsgsChildrenSync(&p); chatDetailShowPlaceholder(p, text); #if CAP_KEYPAD_NAV if (nav_detached) navMaybeRebuild(); #else (void)nav_detached; #endif } static void chatVirtEnsureSpacer(LvChatPanel* p, lv_coord_t total_h) { if (!p || !p->msgs) return; if (!s_chat_virt.spacer || !lv_obj_is_valid(s_chat_virt.spacer)) { s_chat_virt.spacer = lv_obj_create(p->msgs); lv_obj_remove_style_all(s_chat_virt.spacer); lv_obj_clear_flag(s_chat_virt.spacer, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_obj_set_width(s_chat_virt.spacer, s_chat_virt.content_w > 0 ? s_chat_virt.content_w : 1); lv_obj_move_background(s_chat_virt.spacer); } lv_obj_set_size(s_chat_virt.spacer, s_chat_virt.content_w > 0 ? s_chat_virt.content_w : 1, total_h > 0 ? total_h : 1); lv_obj_set_pos(s_chat_virt.spacer, 0, 0); } static void chatVirtRefreshScrollArea(LvChatPanel* p) { if (!p || !p->msgs || !s_chat_virt.offsets || s_chat_virt.n <= 0) return; chatVirtEnsureSpacer(p, s_chat_virt.lv_total_h); } // Reposition floating bubble/divider children in viewport coords (virt px 1:1). // LVGL scroll_y may be compressed; virt_top comes from chatVirtEffectiveVirtTop(). // Only the spacer contributes to scroll extent; bubbles must not expand it. static void chatVirtSyncBubblePositions(LvChatPanel* p) { if (!p || !p->msgs || s_chat_virt.panel != p || s_chat_virt.n <= 0 || !s_chat_virt.offsets) return; struct BubbleEntry { lv_obj_t* obj; int logical_i; lv_coord_t h; }; BubbleEntry entries[64]; int cnt = 0; for (uint32_t ci = 0; ci < lv_obj_get_child_cnt(p->msgs) && cnt < 64; ++ci) { lv_obj_t* ch = lv_obj_get_child(p->msgs, ci); if (!ch || ch == s_chat_virt.spacer) continue; if (s_chat_virt.divider && ch == s_chat_virt.divider) continue; const intptr_t ud = reinterpret_cast<intptr_t>(lv_obj_get_user_data(ch)); if (ud < 0) { const int logical_i = static_cast<int>(-(ud + 1)); if (logical_i >= 0 && logical_i < s_chat_virt.n && s_chat_virt.day_sep_y && s_chat_virt.day_sep_y[logical_i] >= 0) { const int32_t virt_top = chatVirtEffectiveVirtTop(p); const lv_coord_t vp = static_cast<lv_coord_t>(s_chat_virt.day_sep_y[logical_i] - virt_top); lv_obj_set_pos(ch, 0, vp + 2); } continue; } if (ud >= s_chat_virt.n) continue; entries[cnt].obj = ch; entries[cnt].logical_i = static_cast<int>(ud); entries[cnt].h = lv_obj_get_height(ch); ++cnt; } if (cnt == 0) { if (s_chat_virt.divider && lv_obj_is_valid(s_chat_virt.divider) && s_chat_virt.divider_y >= 0) { const int32_t virt_top = chatVirtEffectiveVirtTop(p); const lv_coord_t div_vp = static_cast<lv_coord_t>(s_chat_virt.divider_y - virt_top); lv_obj_set_pos(s_chat_virt.divider, 0, div_vp); } return; } for (int i = 1; i < cnt; ++i) { BubbleEntry tmp = entries[i]; int j = i - 1; while (j >= 0 && entries[j].logical_i > tmp.logical_i) { entries[j + 1] = entries[j]; --j; } entries[j + 1] = tmp; } const int32_t virt_top = chatVirtEffectiveVirtTop(p); for (int i = 0; i < cnt; ++i) { const lv_coord_t vp = chatVirtMsgViewportY(entries[i].logical_i, virt_top); // Re-assert the correct left/right x on every pass instead of preserving lv_obj_get_x(): // a bubble recreated mid-scroll (e.g. right after a send) can briefly carry a wrong x, and // the old preserve-x logic then locked it in until the next full rebuild — that was the // "just-sent outgoing bubble stuck on the LEFT" bug. Compact rows stay full-width. lv_coord_t x = lv_obj_get_x(entries[i].obj); if (!s_chat_virt.compact_chat && g_lv.task && s_chat_virt.msg_idx && entries[i].logical_i >= 0 && entries[i].logical_i < s_chat_virt.n) { UITask::UIMessage mm; if (g_lv.task->getMessageByIndex(s_chat_virt.msg_idx[entries[i].logical_i], mm)) { lv_coord_t w = lv_obj_get_width(entries[i].obj); if (w > s_chat_virt.bubble_max_w) w = s_chat_virt.bubble_max_w; x = mm.outgoing ? (s_chat_virt.content_w - w - kChatSideGutter) : kChatSideGutter; } } lv_obj_set_pos(entries[i].obj, x, vp); } if (s_chat_virt.divider && lv_obj_is_valid(s_chat_virt.divider) && s_chat_virt.divider_y >= 0) { const lv_coord_t div_vp = static_cast<lv_coord_t>(s_chat_virt.divider_y - virt_top); lv_obj_set_pos(s_chat_virt.divider, 0, div_vp); } } // Fast path for the by-far most common relayout trigger: new messages appended to // the SAME thread with no divider in play. Extends offsets[]/day_sep_y[] for the // new tail only instead of re-measuring the whole thread — the full measure is // 2-3 lv_txt_get_size calls per message, which on the 5000-message SD ring costs // north of 100 ms of UI-thread stall PER RECEIVED MESSAGE with the chat open. // Endpoint ring slots prove "pure append": if the first n_old entries were evicted // or rotated, first_ring/last_ring moved and we fall back to the full rebuild. static bool chatVirtTryAppendLayout(LvChatPanel* p, int n, int divider_i) { const int n_old = s_chat_virt.n; if (s_chat_virt.panel != p || !s_chat_virt.offsets || !s_chat_virt.day_sep_y) return false; if (n_old <= 0 || n <= n_old) return false; if (divider_i >= 0 || s_chat_virt.divider_i >= 0) return false; // divider math: full rebuild if (s_chat_virt.compact_chat != touchPrefsGetCompactChat()) return false; if (s_chat_msg_idx[0] != s_chat_virt.first_ring || s_chat_msg_idx[n_old - 1] != s_chat_virt.last_ring) return false; int32_t* offs = (int32_t*)heap_caps_malloc(sizeof(int32_t) * (size_t)(n + 1), MALLOC_CAP_SPIRAM); if (!offs) offs = (int32_t*)malloc(sizeof(int32_t) * (size_t)(n + 1)); int32_t* seps = (int32_t*)heap_caps_malloc(sizeof(int32_t) * (size_t)n, MALLOC_CAP_SPIRAM); if (!seps) seps = (int32_t*)malloc(sizeof(int32_t) * (size_t)n); if (!offs || !seps) { if (offs) free(offs); if (seps) free(seps); return false; } memcpy(offs, s_chat_virt.offsets, sizeof(int32_t) * (size_t)(n_old + 1)); memcpy(seps, s_chat_virt.day_sep_y, sizeof(int32_t) * (size_t)n_old); // Day-key continuity: the appended range needs the day of the last old message. long last_day_key = -1; { UITask::UIMessage m; long dk = 0; if (g_lv.task->getMessageByIndex(s_chat_msg_idx[n_old - 1], m) && chatMsgDayKey(m, &dk)) last_day_key = dk; } const lv_coord_t row_gap = s_chat_virt.compact_chat ? kChatCompactRowGap : kChatRowGap; const lv_coord_t day_sep_h = chatMeasureDaySepHeight(); int32_t y = offs[n_old]; // continue exactly where the old layout ended for (int i = n_old; i < n; ++i) { seps[i] = -1; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(s_chat_msg_idx[i], m)) { offs[i] = y; y += 20 + row_gap; continue; } long dk = 0; if (chatMsgDayKey(m, &dk) && dk != last_day_key) { last_day_key = dk; seps[i] = y; y += day_sep_h; } offs[i] = y; y += chatMeasureMessageRowHeight(m, p, i) + row_gap; } offs[n] = y; int32_t* old_offs = s_chat_virt.offsets; int32_t* old_seps = s_chat_virt.day_sep_y; s_chat_virt.offsets = offs; s_chat_virt.day_sep_y = seps; free(old_offs); free(old_seps); s_chat_virt.n = n; s_chat_virt.msg_idx = s_chat_msg_idx; s_chat_virt.first_ring = s_chat_msg_idx[0]; s_chat_virt.last_ring = s_chat_msg_idx[n - 1]; chatVirtUpdateLvScale(p, n, y); // total grew: recompute the compression return true; } static bool chatVirtRebuildLayout(LvChatPanel* p, int n, int divider_i) { if (!p || !g_lv.task || n <= 0 || !s_chat_msg_idx) return false; if (chatVirtTryAppendLayout(p, n, divider_i)) return true; chatVirtFreeOffsets(); s_chat_virt.offsets = (int32_t*)heap_caps_malloc(sizeof(int32_t) * (size_t)(n + 1), MALLOC_CAP_SPIRAM); if (!s_chat_virt.offsets) s_chat_virt.offsets = (int32_t*)malloc(sizeof(int32_t) * (size_t)(n + 1)); s_chat_virt.day_sep_y = (int32_t*)heap_caps_malloc(sizeof(int32_t) * (size_t)n, MALLOC_CAP_SPIRAM); if (!s_chat_virt.day_sep_y) s_chat_virt.day_sep_y = (int32_t*)malloc(sizeof(int32_t) * (size_t)n); if (!s_chat_virt.offsets || !s_chat_virt.day_sep_y) { chatVirtFreeOffsets(); return false; } for (int i = 0; i < n; ++i) s_chat_virt.day_sep_y[i] = -1; s_chat_virt.panel = p; s_chat_virt.n = n; s_chat_virt.divider_i = divider_i; s_chat_virt.divider_y = -1; s_chat_virt.msg_idx = s_chat_msg_idx; s_chat_virt.first_ring = s_chat_msg_idx[0]; s_chat_virt.last_ring = s_chat_msg_idx[n - 1]; s_chat_virt.content_w = chatScreenW() - 12; s_chat_virt.bubble_max_w = (s_chat_virt.content_w * 80) / 100; s_chat_virt.thread_is_room = false; if (!p->channel_mode) { ContactInfo rc; if (g_lv.task->lookupActiveContact(rc)) s_chat_virt.thread_is_room = (rc.type == ADV_TYPE_ROOM); } s_chat_virt.compact_chat = touchPrefsGetCompactChat(); s_chat_virt.compact_thread_name[0] = '\0'; if (s_chat_virt.compact_chat) { bool ch_ = false; uint16_t un_ = 0; uint32_t ts_ = 0; g_lv.task->getThreadInfo(g_lv.task->activeThreadIdx(), ch_, un_, ts_, s_chat_virt.compact_thread_name, sizeof(s_chat_virt.compact_thread_name)); } const lv_coord_t row_gap = s_chat_virt.compact_chat ? kChatCompactRowGap : kChatRowGap; const lv_coord_t day_sep_h = chatMeasureDaySepHeight(); int32_t y = 0; long last_day_key = -1; #if TRACE_MESSAGE_SCROLL_ACTIVITY int measure_fail = 0; CHAT_SCROLL_TRACE_PRINTF("[CHAT] measuring layout for %d messages\n", n); #endif for (int i = 0; i < n; ++i) { if (divider_i >= 0 && i == divider_i) { s_chat_virt.divider_y = y; y += kChatDividerH + row_gap; } UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(s_chat_msg_idx[i], m)) { s_chat_virt.offsets[i] = y; y += 20 + row_gap; #if TRACE_MESSAGE_SCROLL_ACTIVITY ++measure_fail; #endif continue; } long dk = 0; if (chatMsgDayKey(m, &dk) && dk != last_day_key) { last_day_key = dk; s_chat_virt.day_sep_y[i] = y; y += day_sep_h; } s_chat_virt.offsets[i] = y; y += chatMeasureMessageRowHeight(m, p, i) + row_gap; } s_chat_virt.offsets[n] = y; chatVirtUpdateLvScale(p, n, y); #if TRACE_MESSAGE_SCROLL_ACTIVITY CHAT_SCROLL_TRACE_PRINTF("[CHAT] layout total_height=%d px lv_scroll_h=%d max_scroll~=%d%s\n", (int)y, (int)s_chat_virt.lv_total_h, (int)(s_chat_virt.lv_total_h > chatVirtMsgsViewH(p) ? s_chat_virt.lv_total_h - chatVirtMsgsViewH(p) : 0), chatVirtCompressCoords() ? " (compressed)" : ""); if (measure_fail > 0) CHAT_SCROLL_TRACE_PRINTF("[CHAT] layout measure failures=%d\n", measure_fail); #endif s_chat_msgs_scroll_obj = p->msgs; return true; } static void chatVirtCreateDivider(LvChatPanel* p, lv_coord_t vp_y) { if (!p || !p->msgs) return; if (s_chat_virt.divider && lv_obj_is_valid(s_chat_virt.divider)) return; const lv_coord_t kContentW = s_chat_virt.content_w; lv_obj_t* div = lv_obj_create(p->msgs); lv_obj_remove_style_all(div); lv_obj_clear_flag(div, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(div, LV_OBJ_FLAG_FLOATING); lv_obj_set_size(div, kContentW, kChatDividerH); lv_obj_set_pos(div, 0, vp_y); lv_obj_t* dline = lv_obj_create(div); lv_obj_remove_style_all(dline); lv_obj_set_size(dline, kContentW, 1); lv_obj_set_pos(dline, 0, 8); lv_obj_set_style_bg_color(dline, lv_color_hex(0xE0533D), LV_PART_MAIN); lv_obj_set_style_bg_opa(dline, LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* dlbl = lv_label_create(div); lv_label_set_text(dlbl, TR("New")); lv_obj_set_style_text_font(dlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(dlbl, lv_color_hex(0xE0533D), LV_PART_MAIN); lv_obj_set_style_bg_color(dlbl, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(dlbl, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_hor(dlbl, 4, LV_PART_MAIN); lv_obj_align(dlbl, LV_ALIGN_TOP_LEFT, 0, 0); s_chat_virt.divider = div; } static void chatVirtCreateDaySeparator(LvChatPanel* p, int logical_i, lv_coord_t vp_y) { if (!p || !p->msgs || !g_lv.task || !s_chat_virt.msg_idx) return; if (logical_i < 0 || logical_i >= s_chat_virt.n) return; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(s_chat_virt.msg_idx[logical_i], m)) return; if (m.ts < 1577836800UL) return; time_t tt = (time_t)m.ts; struct tm tv; localtime_r(&tt, &tv); char dbuf[32]; formatDaySeparator(dbuf, sizeof(dbuf), &tv); lv_obj_t* dl = lv_label_create(p->msgs); lv_label_set_text(dl, dbuf); lv_obj_set_style_text_font(dl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(dl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_align(dl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_add_flag(dl, LV_OBJ_FLAG_FLOATING); lv_obj_set_width(dl, s_chat_virt.content_w); lv_obj_set_pos(dl, 0, vp_y + 2); lv_obj_set_user_data(dl, reinterpret_cast<void*>(static_cast<intptr_t>(-(logical_i + 1)))); } // ============================ Clickable URLs in chat ================================ // Detect URLs in a message, tint them blue, and on a short tap offer "Open in web" (the // on-device reader) or "Create QR" (an on-screen QR so a phone can grab the link). // UNGATED (QR works on every board); the "Open in web" button compiles only where the // reader exists. static bool urlCiHas(const char* s, const char* pfx, int n) { for (int i = 0; i < n; i++) { char a = s[i]; if (!a) return false; if (a >= 'A' && a <= 'Z') a += 32; if (a != pfx[i]) return false; } return true; } // First URL span [*a,*b) at/after `from`. Recognises http(s):// and a bare www.<host>. static bool chatUrlSpan(const char* s, int from, int* a, int* b) { for (int i = from; s[i]; i++) { int len = 0; if (urlCiHas(s + i, "https://", 8)) len = 8; else if (urlCiHas(s + i, "http://", 7)) len = 7; else if (urlCiHas(s + i, "www.", 4) && (i == 0 || s[i-1] == ' ' || s[i-1] == '\n')) len = 4; else continue; int j = i + len; while (s[j] && (unsigned char)s[j] > ' ') j++; // extend to whitespace while (j > i + len) { char c = s[j-1]; // trim trailing punctuation if (c=='.'||c==','||c==')'||c==';'||c=='!'||c=='?'||c=='\''||c=='"'||c==':') j--; else break; } if (j <= i + len) continue; // scheme, no host if (!memchr(s + i + len, '.', j - (i + len))) continue; // host needs a dot *a = i; *b = j; return true; } return false; } static bool chatFirstUrl(const char* s, char* out, int cap) { int a, b; if (!s || !chatUrlSpan(s, 0, &a, &b)) return false; int n = b - a; if (n > cap - 1) n = cap - 1; memcpy(out, s + a, n); out[n] = 0; return true; } // Copy `in` -> `out`, wrapping each URL in a blue recolor tag. Bails (false) if `in` // already has a '#' (the recolor parser would choke on it) — caller then shows plain // text and the tap still works. static bool chatRecolorUrls(const char* in, char* out, int cap) { if (!in || strchr(in, '#')) return false; int a, b; if (!chatUrlSpan(in, 0, &a, &b)) return false; int o = 0, i = 0; while (in[i] && o < cap - 12) { if (i == a) { o += snprintf(out + o, cap - o, "#4EA1FF "); while (i < b && o < cap - 2) out[o++] = in[i++]; if (o < cap - 1) out[o++] = '#'; if (!chatUrlSpan(in, i, &a, &b)) a = -1; } else out[o++] = in[i++]; } out[o] = 0; return true; } // ---- QR popup: a scannable QR of a URL ---- static lv_obj_t* s_urlqr_root = nullptr; static void closeUrlQr() { if (s_urlqr_root) popupClose(&s_urlqr_root); } static void urlQrBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeUrlQr(); } static void openUrlQrPopup(const char* url) { closeUrlQr(); lv_coord_t sw = lv_disp_get_hor_res(nullptr), sh = lv_disp_get_ver_res(nullptr); s_urlqr_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_urlqr_root); lv_obj_set_size(s_urlqr_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_urlqr_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_urlqr_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_urlqr_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_urlqr_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_urlqr_root, urlQrBackdropCb, LV_EVENT_CLICKED, nullptr); int card_w = PCW(230); if (card_w > modalAvailW()) card_w = modalAvailW(); int card_h = PSC(250); if (card_h > modalAvailH()) card_h = modalAvailH(); int qr_size = card_h - 34 - 34 - 20; if (qr_size > PSC(160)) qr_size = PSC(160); if (qr_size > card_w - 20) qr_size = card_w - 20; if (qr_size < 96) qr_size = 96; lv_obj_t* card = lv_obj_create(s_urlqr_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, urlQrBackdropCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Scan to open on phone")); lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_width(title, card_w - 20 - 32); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 4); char full[260]; // a bare www. host gets https:// so the phone opens it if (!urlCiHas(url, "http", 4)) snprintf(full, sizeof full, "https://%s", url); else snprintf(full, sizeof full, "%s", url); lv_obj_t* qr = lv_qrcode_create(card, qr_size, lv_color_hex(0x000000), lv_color_hex(0xFFFFFF)); lv_qrcode_update(qr, full, strlen(full)); lv_obj_align(qr, LV_ALIGN_TOP_MID, 0, 32); lv_obj_set_style_border_color(qr, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_border_width(qr, 4, LV_PART_MAIN); lv_obj_t* u = lv_label_create(card); lv_label_set_long_mode(u, LV_LABEL_LONG_DOT); lv_obj_set_width(u, card_w - 20); lv_label_set_text(u, url); lv_obj_set_style_text_font(u, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(u, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(u, LV_ALIGN_BOTTOM_MID, 0, -2); } // ---- URL action menu (short tap on a chat URL) ---- static lv_obj_t* s_urlmenu_root = nullptr; static char s_urlmenu_url[240] = ""; static void closeUrlMenu() { if (s_urlmenu_root) popupClose(&s_urlmenu_root); } static void urlMenuBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); closeUrlMenu(); } static void urlMenuQrCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; char u[240]; snprintf(u, sizeof u, "%s", s_urlmenu_url); closeUrlMenu(); openUrlQrPopup(u); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER static void urlMenuOpenWebCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; char u[240]; snprintf(u, sizeof u, "%s", s_urlmenu_url); hideKb(); // Delete the URL menu with a PLAIN async delete — NOT closeUrlMenu(), which calls // lv_indev_wait_release() (that wedges the fresh reader's first taps). if (s_urlmenu_root) { lv_obj_del_async(s_urlmenu_root); s_urlmenu_root = nullptr; } // Leave the chat so the reader opens from a CLEAN state — the exact same state as the // app-drawer path, which loads pages and closes with the top ‹ correctly. It also // frees the RAM the on-device HTTPS/TLS handshake needs on the 2 MB V4. if (g_lv.dm.detail_open) closeChatPanel(&g_lv.dm); else if (g_lv.ch.detail_open) closeChatPanel(&g_lv.ch); // Fresh navigation: drop any stale busy flag (with no worker running it would skip the // new fetch) and the previous page so openReaderPage doesn't re-render the last URL. if (!s_reader_task) s_reader_busy = false; s_reader_ok = false; openReaderPage(u); } #endif static void openUrlMenu(const char* url) { if (!url || !url[0]) return; closeUrlMenu(); snprintf(s_urlmenu_url, sizeof s_urlmenu_url, "%s", url); lv_coord_t sw = lv_disp_get_hor_res(nullptr), sh = lv_disp_get_ver_res(nullptr); s_urlmenu_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_urlmenu_root); lv_obj_set_size(s_urlmenu_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_urlmenu_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_urlmenu_root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_urlmenu_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_urlmenu_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_urlmenu_root, urlMenuBackdropCb, LV_EVENT_CLICKED, nullptr); const int card_w = PCW(230), btn_h = PSC(34), pad = PSC(12), gap = PSC(8), url_h = PSC(18); #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const int nbtn = 2; #else const int nbtn = 1; #endif const int card_h = pad + url_h + gap + nbtn * btn_h + (nbtn - 1) * gap + pad; lv_obj_t* card = lv_obj_create(s_urlmenu_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* u = lv_label_create(card); // URL preview line at the top lv_label_set_long_mode(u, LV_LABEL_LONG_DOT); lv_obj_set_width(u, card_w - 2 * pad); lv_label_set_text(u, url); lv_obj_set_style_text_font(u, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(u, lv_color_hex(0x6FB7FF), LV_PART_MAIN); lv_obj_set_pos(u, 0, 0); int by = url_h + gap; auto mk = [&](const char* txt, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, by); styleButton(b); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(txt)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); by += btn_h + gap; }; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER mk(TR(LV_SYMBOL_WIFI " Open in web"), urlMenuOpenWebCb); #endif mk(TR(LV_SYMBOL_IMAGE " Create QR"), urlMenuQrCb); } // Short tap on a chat bubble that contains a URL -> the action menu (re-extract the URL // from the live message; the ring may have rotated since the bubble was built). static void bubbleUrlTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_SHORT_CLICKED || !g_lv.task) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(idx, m)) return; char url[240]; if (chatFirstUrl(m.text, url, sizeof url)) openUrlMenu(url); } static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_idx, lv_coord_t vp_y, lv_coord_t* out_jump_y) { if (!p || !g_lv.task) return 0; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(ring_idx, m)) return 0; ChatBubbleDisplay d{}; chatParseMessageDisplay(m, p->channel_mode, s_chat_virt.thread_is_room, d); const lv_coord_t kContentW = s_chat_virt.content_w; const lv_coord_t kBubbleMaxW = s_chat_virt.bubble_max_w; const bool colorful_bubbles = touchPrefsGetColorfulBubbles(); lv_obj_t* bubble = lv_obj_create(p->msgs); lv_obj_remove_style_all(bubble); lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(bubble, LV_OBJ_FLAG_FLOATING); lv_obj_set_style_radius(bubble, 10, LV_PART_MAIN); const bool mentions_me = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && textMentionsMe(d.show_text); const char* color_name = m.outgoing ? the_mesh.getNodePrefs()->node_name : d.show_sender; lv_color_t bubble_bg = lv_color_hex(m.outgoing ? COLOR_SENT_BG : COLOR_RECV_BG); lv_color_t sender_col = lv_color_hex(COLOR_ACCENT); if (colorful_bubbles && color_name && color_name[0]) usernameBubbleColors(color_name, &bubble_bg, &sender_col); if (mentions_me) bubble_bg = lv_color_hex(COLOR_MENTION_BG); lv_obj_set_style_bg_color(bubble, bubble_bg, LV_PART_MAIN); lv_obj_set_style_bg_opa(bubble, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_hor(bubble, kChatBubblePadH, LV_PART_MAIN); lv_obj_set_style_pad_ver(bubble, kChatBubblePadV, LV_PART_MAIN); lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH; const lv_font_t* msg_font = chatMessageFont(); lv_point_t txt_size; lv_txt_get_size(&txt_size, d.san_text, msg_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW; char meta_buf[48]; uint32_t meta_fg = COLOR_SUB; chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); // All bubble-style threads (channel / DM / room): timestamp + delivery meta on the top row. const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]; const lv_coord_t sender_w = show_sender_line ? chatTextWidth(d.san_sender) : 0; const lv_coord_t meta_w = meta_buf[0] ? chatTextWidth(meta_buf) : 0; lv_coord_t inner_w = txt_w_used > 0 ? txt_w_used : 1; { lv_coord_t header_w = (show_sender_line ? sender_w : 0) + meta_w; if (show_sender_line && meta_w) header_w += 6; if (header_w > kInnerMaxW) header_w = kInnerMaxW; if (header_w > inner_w) inner_w = header_w; } int inner_y = 0; // Analytic bubble width for x-alignment (widest of sender/text/meta) — do not use // lv_obj_get_width() right after create; unsettled layout can mis-place outgoing bubbles. if (show_sender_line || meta_buf[0]) { const lv_coord_t line_h = lv_font_get_line_height(&g_font_12); char meta_fit[48] = ""; lv_coord_t meta_fit_w = 0; lv_coord_t sender_label_w = sender_w; if (meta_buf[0]) { lv_coord_t available_meta_w = inner_w - (show_sender_line ? (sender_w + 6) : 0); const lv_coord_t ell_w = chatTextWidth("..."); if (show_sender_line && available_meta_w < ell_w && inner_w > ell_w + 6) { sender_label_w = inner_w - ell_w - 6; available_meta_w = ell_w; } chatFitLeadingEllipsis(meta_buf, available_meta_w, meta_fit, sizeof(meta_fit)); meta_fit_w = chatTextWidth(meta_fit); } if (show_sender_line && sender_label_w > 0) { lv_obj_t* slbl = lv_label_create(bubble); lv_label_set_text(slbl, d.san_sender); lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); if (sender_label_w < sender_w) { lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT); lv_obj_set_width(slbl, sender_label_w); } lv_obj_set_pos(slbl, 0, inner_y); } if (meta_fit[0]) { lv_obj_t* mlbl = lv_label_create(bubble); lv_label_set_text(mlbl, meta_fit); lv_obj_set_style_text_font(mlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(mlbl, lv_color_hex(meta_fg), LV_PART_MAIN); lv_obj_set_pos(mlbl, inner_w - meta_fit_w, inner_y); } inner_y += line_h; } lv_obj_t* tlbl = lv_label_create(bubble); lv_obj_set_style_text_font(tlbl, msg_font, LV_PART_MAIN); lv_obj_set_style_text_color(tlbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_text(tlbl, d.san_text); // Clickable URLs: tint any link blue (recolor tags are zero-width, so wrapping/height // below still measure from the plain d.san_text and stay correct). int _ua, _ub; const bool has_url = chatUrlSpan(d.san_text, 0, &_ua, &_ub); if (has_url) { char rc[UITask::MAX_MSG_TEXT + 40]; if (chatRecolorUrls(d.san_text, rc, sizeof rc)) { lv_label_set_recolor(tlbl, true); lv_label_set_text(tlbl, rc); } } if (txt_size.x > kInnerMaxW) lv_label_set_long_mode(tlbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(tlbl, txt_w_used); if (txt_w_used > inner_w) inner_w = txt_w_used; lv_obj_set_pos(tlbl, 0, inner_y); lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(bubble, bubbleLongPressMenuCb, LV_EVENT_LONG_PRESSED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); // A short tap on a bubble that carries a URL opens the Open-in-web / Create-QR menu // (SHORT_CLICKED so it never double-fires with the long-press action menu). Failed // outgoing sends keep their tap-to-resend. if (has_url && !(m.outgoing && m.deliv_state == UITask::DELIV_FAILED)) lv_obj_add_event_cb(bubble, bubbleUrlTapCb, LV_EVENT_SHORT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); // Failed sends keep the pre-virtualization one-tap resend (the compact path // already has it); delivery status on the top meta row spells the affordance out. if (m.outgoing && m.deliv_state == UITask::DELIV_FAILED) lv_obj_add_event_cb(bubble, bubbleRetryTapCb, LV_EVENT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); lv_obj_update_layout(bubble); lv_coord_t bh = lv_obj_get_height(bubble); // Width for x-alignment is computed analytically from the widest child (text/sender/meta), // NOT lv_obj_get_width(): right after a send the layout isn't settled and get_width reads wide, // which put the (actually narrow) outgoing bubble on the LEFT until the next rebuild. lv_coord_t bw = inner_w + 2 * kChatBubblePadH; if (bw > kBubbleMaxW) bw = kBubbleMaxW; const lv_coord_t x_pos = m.outgoing ? (kContentW - bw - kChatSideGutter) : kChatSideGutter; lv_obj_set_pos(bubble, x_pos, vp_y); if (out_jump_y && s_chat_jump_msg_idx >= 0 && ring_idx == s_chat_jump_msg_idx) *out_jump_y = vp_y; lv_obj_set_user_data(bubble, reinterpret_cast<void*>(static_cast<intptr_t>(logical_i))); return bh; } static lv_coord_t chatVirtCreateCompactRow(LvChatPanel* p, int logical_i, int ring_idx, lv_coord_t vp_y, lv_coord_t* out_jump_y) { if (!p || !g_lv.task) return 0; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(ring_idx, m)) return 0; ChatBubbleDisplay d{}; chatParseMessageDisplay(m, p->channel_mode, s_chat_virt.thread_is_room, d); const bool mentions_me = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && textMentionsMe(d.show_text); char line[640]; chatBuildCompactLine(m, p, logical_i, d, line, sizeof(line)); lv_obj_t* row = lv_label_create(p->msgs); lv_label_set_recolor(row, true); lv_obj_add_flag(row, LV_OBJ_FLAG_FLOATING); lv_obj_set_style_text_font(row, chatMessageFont(), LV_PART_MAIN); lv_obj_set_style_text_color(row, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(row, LV_LABEL_LONG_WRAP); lv_obj_set_width(row, s_chat_virt.content_w); lv_label_set_text(row, line); lv_obj_set_style_pad_hor(row, 3, LV_PART_MAIN); lv_obj_set_style_pad_ver(row, 1, LV_PART_MAIN); lv_obj_set_style_radius(row, 3, LV_PART_MAIN); if (mentions_me) { lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_MENTION_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); } else if ((logical_i & 1) == 0) { lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_RECV_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); } lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); // The menu callback acts ONLY on LV_EVENT_LONG_PRESSED (its first line filters // everything else) — registering it for PRESSED/RELEASED/PRESS_LOST/DELETE left // the compact-row menu completely dead. Register the one event it handles, // exactly like the bubble path. lv_obj_add_event_cb(row, bubbleLongPressMenuCb, LV_EVENT_LONG_PRESSED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); if (m.outgoing && m.deliv_state == UITask::DELIV_FAILED) lv_obj_add_event_cb(row, bubbleRetryTapCb, LV_EVENT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); lv_obj_set_pos(row, 0, vp_y); lv_obj_update_layout(row); const lv_coord_t rh = lv_obj_get_height(row); if (out_jump_y && s_chat_jump_msg_idx >= 0 && ring_idx == s_chat_jump_msg_idx) *out_jump_y = vp_y; lv_obj_set_user_data(row, reinterpret_cast<void*>(static_cast<intptr_t>(logical_i))); return rh; } static lv_coord_t chatVirtCreateMessageRow(LvChatPanel* p, int logical_i, int ring_idx, lv_coord_t vp_y, lv_coord_t* out_jump_y) { if (s_chat_virt.compact_chat) return chatVirtCreateCompactRow(p, logical_i, ring_idx, vp_y, out_jump_y); return chatVirtCreateBubble(p, logical_i, ring_idx, vp_y, out_jump_y); } static lv_coord_t chatVirtMsgContentY(int logical_i) { if (logical_i < 0 || logical_i >= s_chat_virt.n || !s_chat_virt.offsets) return 0; if (chatVirtCompressCoords()) return chatVirtVirtToLv(s_chat_virt.offsets[logical_i]); return static_cast<lv_coord_t>(s_chat_virt.offsets[logical_i]); } // Viewport Y for a message top: layout-space px relative to virt_top (1:1). Smooth // sub-message scroll even when LVGL scroll_y is compressed; spacing stays correct // because offsets[] use real measured bubble heights. static lv_coord_t chatVirtMsgViewportY(int logical_i, int32_t virt_top) { if (logical_i < 0 || logical_i >= s_chat_virt.n || !s_chat_virt.offsets) return 0; const int64_t vp = static_cast<int64_t>(s_chat_virt.offsets[logical_i]) - virt_top; if (vp < -32768) return -32768; if (vp > 32767) return 32767; return static_cast<lv_coord_t>(vp); } static int32_t chatVirtMsgVirtBottom(int logical_i) { if (logical_i < 0 || logical_i >= s_chat_virt.n || !s_chat_virt.offsets) return 0; if (logical_i + 1 < s_chat_virt.n) return s_chat_virt.offsets[logical_i + 1]; return s_chat_virt.virt_total_h; } static lv_coord_t chatVirtMsgContentBottom(int logical_i) { if (chatVirtCompressCoords()) return chatVirtVirtToLv(chatVirtMsgVirtBottom(logical_i)); return static_cast<lv_coord_t>(chatVirtMsgVirtBottom(logical_i)); } static void chatVirtFindVisibleRange(LvChatPanel* p, lv_coord_t lv_scroll_y, lv_coord_t lv_view_h, int& out_i0, int& out_i1) { const int n = s_chat_virt.n; if (n <= 0) { out_i0 = out_i1 = 0; return; } if (!chatVirtCompressCoords()) { const lv_coord_t y0 = (lv_scroll_y > kChatVirtOverscanPx) ? (lv_scroll_y - kChatVirtOverscanPx) : 0; const lv_coord_t y1 = lv_scroll_y + lv_view_h + kChatVirtOverscanPx; out_i0 = n - 1; for (int i = 0; i < n; ++i) { if (chatVirtMsgContentBottom(i) > y0) { out_i0 = i; break; } } out_i1 = out_i0; for (int i = out_i0; i < n; ++i) { if (chatVirtMsgContentY(i) < y1) out_i1 = i; else break; } return; } // Compressed scroll coords pack message tops tightly (~15 px apart) but bubbles // are stacked at full measured heights. Use virt offsets for visibility instead. const int32_t overscan_virt = kChatVirtOverscanPx; const int32_t virt_top = chatVirtEffectiveVirtTop(p) - overscan_virt; const int32_t virt_bot = chatVirtEffectiveVirtTop(p) + lv_view_h + overscan_virt; out_i0 = n - 1; for (int i = 0; i < n; ++i) { if (chatVirtMsgVirtBottom(i) > virt_top) { out_i0 = i; break; } } out_i1 = out_i0; for (int i = out_i0; i < n; ++i) { if (s_chat_virt.offsets[i] < virt_bot) out_i1 = i; else break; } } static void chatVirtRenderWindow(LvChatPanel* p, lv_coord_t scroll_y, lv_coord_t* out_jump_y) { if (!p || !p->msgs || s_chat_virt.panel != p || s_chat_virt.n <= 0 || !s_chat_virt.offsets) return; if (out_jump_y) *out_jump_y = -1; const lv_coord_t view_h = chatVirtMsgsViewH(p); #if TRACE_MESSAGE_SCROLL_ACTIVITY const int old_i0 = s_chat_virt.last_i0; const int old_i1 = s_chat_virt.last_i1; #endif int i0 = 0, i1 = 0; chatVirtFindVisibleRange(p, scroll_y, view_h, i0, i1); #if TRACE_MESSAGE_SCROLL_ACTIVITY if (old_i0 >= 0 && (old_i0 != i0 || old_i1 != i1)) chatVirtLogScrollTransition(p, old_i0, old_i1, i0, i1); #endif #if CAP_KEYPAD_NAV // Preserve focus without letting lv_obj_del() remove a focused group member. // Rows are recreated, so remember them by logical index; header/composer // controls survive the rebuild and can be retained by pointer. int refocus_i = -1; #if defined(TLORA_PAGER) // An explicit pending target from the Pager encoder edge clamp wins. refocus_i = s_pager_chat_focus_i; #endif lv_obj_t* stable_focus = nullptr; if (s_nav_group) { lv_obj_t* foc = lv_group_get_focused(s_nav_group); if (foc && lv_obj_get_parent(foc) == p->msgs) { const intptr_t ud = reinterpret_cast<intptr_t>(lv_obj_get_user_data(foc)); if (refocus_i < 0 && ud >= 0) refocus_i = (int)ud; } else if (foc && lv_obj_is_valid(foc)) { stable_focus = foc; } } const bool nav_detached = navDetachBeforeTreeMutation(); #endif const lv_coord_t saved_scroll_y = scroll_y; chatVirtClearBubbleWidgets(p); chatVirtEnsureSpacer(p, s_chat_virt.lv_total_h); if (s_chat_virt.divider_i >= 0 && s_chat_virt.divider_y >= 0 && s_chat_virt.divider_i >= i0 && s_chat_virt.divider_i <= i1) { const int32_t virt_top = chatVirtEffectiveVirtTop(p); const lv_coord_t div_vp = static_cast<lv_coord_t>(s_chat_virt.divider_y - virt_top); chatVirtCreateDivider(p, div_vp); } const int32_t virt_top = chatVirtEffectiveVirtTop(p); for (int i = i0; i <= i1; ++i) { if (s_chat_virt.day_sep_y && s_chat_virt.day_sep_y[i] >= 0) { const lv_coord_t sep_vp = static_cast<lv_coord_t>(s_chat_virt.day_sep_y[i] - virt_top); chatVirtCreateDaySeparator(p, i, sep_vp); } chatVirtCreateMessageRow(p, i, s_chat_virt.msg_idx[i], chatVirtMsgViewportY(i, virt_top), out_jump_y); } s_chat_virt.last_i0 = i0; s_chat_virt.last_i1 = i1; lv_obj_scroll_to_y(p->msgs, saved_scroll_y, LV_ANIM_OFF); chatVirtSyncBubblePositions(p); CHAT_SCROLL_TRACE_DO(chatVirtCheckStoreEdges(p)); #if CAP_KEYPAD_NAV // Re-aim focus at the recreated row for the captured logical index. A fast // Pager encoder target can briefly run past the materialized window, so clamp // it to the nearest fresh row and let the next reflow continue from there. if (refocus_i >= 0) { if (refocus_i < i0) refocus_i = i0; if (refocus_i > i1) refocus_i = i1; const uint32_t nch2 = lv_obj_get_child_cnt(p->msgs); for (uint32_t c = 0; c < nch2; c++) { lv_obj_t* row = lv_obj_get_child(p->msgs, c); if (!row || !lv_obj_has_flag(row, LV_OBJ_FLAG_CLICKABLE)) continue; if (reinterpret_cast<intptr_t>(lv_obj_get_user_data(row)) == (intptr_t)refocus_i) { s_nav_focus_hint = row; break; } } #if defined(TLORA_PAGER) s_pager_chat_focus_i = -1; // consumed (whether or not the row was found) #endif } else if (stable_focus && lv_obj_is_valid(stable_focus)) { s_nav_focus_hint = stable_focus; } // The group was deliberately empty throughout destruction. Recollect once, // consume the focus hint, and land the final focus before LVGL paints. if (nav_detached) { navMarkDirty(); navMaybeRebuild(); } #endif } static LvChatPanel* s_chat_virt_render_async_panel = nullptr; static bool s_chat_virt_render_async_busy = false; // Scroll/reflow/materialise — must NOT run inside the LVGL render timer: that // tick is immediately followed by _lv_disp_refr_timer walking the same tree. // Deleting/recreating floating bubbles there (or even sync lv_obj_del) races // layout_update_core → LoadProhibited. lv_async_call runs after the refresh. static void chatVirtRenderAsyncCb(void*) { s_chat_virt_render_async_busy = false; LvChatPanel* p = s_chat_virt_render_async_panel; s_chat_virt_render_async_panel = nullptr; if (!p || !p->detail_open || s_chat_virt.panel != p || s_chat_virt.n <= 0) return; chatVirtApplyPendingScroll(p); const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); const lv_coord_t view_h = chatVirtMsgsViewH(p); int i0 = 0, i1 = 0; chatVirtFindVisibleRange(p, scroll_y, view_h, i0, i1); if (!chatVirtNeedReflow(i0, i1)) { chatVirtSyncBubblePositions(p); chatUpdateJumpButtons(p); return; } CHAT_SCROLL_TRACE_PRINTF("[CHAT] re_layout on scroll_end [%d..%d] was [%d..%d]\n", i0 + 1, i1 + 1, s_chat_virt.last_i0 + 1, s_chat_virt.last_i1 + 1); CHAT_SCROLL_TRACE_DO(chatVirtLogTopAnchor("re_layout_before", p, scroll_y)); chatVirtRenderWindow(p, scroll_y, nullptr); CHAT_SCROLL_TRACE_DO(chatVirtLogTopAnchor("re_layout_after", p, lv_obj_get_scroll_y(p->msgs))); chatUpdateJumpButtons(p); } static void chatVirtRenderTimerCb(lv_timer_t* t) { LvChatPanel* p = s_chat_virt_render_panel; if (!p || !p->detail_open || s_chat_virt.panel != p || s_chat_virt.n <= 0) { s_chat_virt_render_panel = nullptr; lv_timer_pause(t); return; } if (chatVirtIndevStillScrolling(p)) { lv_timer_set_period(t, 16); lv_timer_reset(t); return; } s_chat_virt_render_panel = nullptr; lv_timer_pause(t); if (s_chat_virt_render_async_busy) return; s_chat_virt_render_async_panel = p; s_chat_virt_render_async_busy = true; if (lv_async_call(chatVirtRenderAsyncCb, nullptr) != LV_RES_OK) { // OOM: the call never queued, so nothing will ever clear the busy flag — // without this the chat would stop re-rendering until reboot. s_chat_virt_render_async_busy = false; s_chat_virt_render_async_panel = nullptr; } } static void chatVirtScheduleRender(LvChatPanel* p) { if (!p || !p->detail_open || s_chat_virt.panel != p || s_chat_virt.n <= 0) return; s_chat_virt_render_panel = p; if (!s_chat_virt_render_timer) { s_chat_virt_render_timer = lv_timer_create(chatVirtRenderTimerCb, 1, nullptr); if (!s_chat_virt_render_timer) { s_chat_virt_render_panel = nullptr; return; } } else { lv_timer_set_period(s_chat_virt_render_timer, 1); lv_timer_reset(s_chat_virt_render_timer); lv_timer_resume(s_chat_virt_render_timer); } } static void chatVirtOnScrollEnd(LvChatPanel* p) { if (!p || !p->detail_open || s_chat_virt.panel != p || s_chat_virt.n <= 0) return; #if TRACE_MESSAGE_SCROLL_ACTIVITY const lv_coord_t scroll_y = lv_obj_get_scroll_y(p->msgs); chatVirtLogTopAnchor("scroll_end", p, scroll_y); #endif if (chatVirtCompressCoords() && s_chat_virt.scroll_virt_valid) { const lv_coord_t y = lv_obj_get_scroll_y(p->msgs); if (y <= 0) s_chat_virt.scroll_virt_top = 0; else if (lv_obj_get_scroll_bottom(p->msgs) <= 0) s_chat_virt.scroll_virt_top = chatVirtMaxVirtTop(p); const lv_coord_t anchored = chatVirtVirtToLv(s_chat_virt.scroll_virt_top); s_chat_virt.scroll_lv_anchor = anchored; if (anchored != y) lv_obj_scroll_to_y(p->msgs, anchored, LV_ANIM_OFF); } chatVirtSyncBubblePositions(p); chatVirtScheduleRender(p); } static lv_coord_t chatVirtBottomScrollY(LvChatPanel* p) { if (!p || !p->msgs) return 0; lv_obj_update_layout(p->msgs); return chatVirtMaxScrollY(p); } static void chatVirtApplyPendingScroll(LvChatPanel* p) { if (!p || !p->msgs || !s_chat_virt.pending_scroll) return; const bool to_bottom = s_chat_virt.pending_scroll_bottom; s_chat_virt.pending_scroll = false; s_chat_virt.pending_scroll_bottom = false; lv_coord_t y = to_bottom ? chatVirtBottomScrollY(p) : s_chat_virt.pending_scroll_y; if (y < 0) y = 0; lv_obj_scroll_to_y(p->msgs, y, LV_ANIM_OFF); lv_obj_update_layout(p->msgs); chatVirtRefreshScrollArea(p); if (to_bottom) chatVirtSyncScrollState(p, y, chatVirtMaxVirtTop(p)); else chatVirtSyncScrollState(p, y); s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; } static void chatVirtQueueScroll(LvChatPanel* p, lv_coord_t target) { if (!p) return; s_chat_virt.pending_scroll = true; if (target == LV_COORD_MAX) { s_chat_virt.pending_scroll_bottom = true; } else { s_chat_virt.pending_scroll_bottom = false; s_chat_virt.pending_scroll_y = target; } chatVirtScheduleRender(p); } static void chatVirtJumpToOldest(LvChatPanel* p) { if (!p || !p->msgs) return; CHAT_SCROLL_TRACE_PRINTF("[CHAT] jump_to_oldest (n=%d virt_h=%d lv_h=%d)\n", s_chat_virt.n, (int)s_chat_virt.virt_total_h, (int)s_chat_virt.lv_total_h); chatVirtResetInputForMsgs(p); chatVirtCancelRenderTimer(); s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; chatVirtQueueScroll(p, 0); CHAT_SCROLL_TRACE_DO(chatVirtLogTopAnchor("jump_to_oldest_after", p, lv_obj_get_scroll_y(p->msgs))); chatUpdateJumpButtons(p); } static void chatVirtJumpToLatest(LvChatPanel* p) { if (!p || !p->msgs) return; CHAT_SCROLL_TRACE_PRINTF("[CHAT] jump_to_latest (n=%d virt_h=%d lv_h=%d)\n", s_chat_virt.n, (int)s_chat_virt.virt_total_h, (int)s_chat_virt.lv_total_h); chatVirtResetInputForMsgs(p); chatVirtCancelRenderTimer(); CHAT_SCROLL_TRACE_PRINTF("[CHAT] jump_to_latest target scroll_y=%d scroll_bottom=%d\n", (int)lv_obj_get_scroll_y(p->msgs), (int)lv_obj_get_scroll_bottom(p->msgs)); s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; chatVirtQueueScroll(p, LV_COORD_MAX); chatUpdateJumpButtons(p); } static void refreshChatDetail(LvChatPanel& p) { if (!g_lv.task || !p.msgs) return; const bool opening = s_chat_just_opened; const lv_coord_t prev_scroll_y = lv_obj_get_scroll_y(p.msgs); const bool was_at_bottom = lv_obj_get_scroll_bottom(p.msgs) <= 8; if (!g_lv.task->hasActiveThread() || g_lv.task->activeThreadIsChannel() != p.channel_mode) { chatVirtResetToPlaceholder(p, "No thread selected.\n\nTap a chat to open it."); return; } if (p.detail_open) g_lv.task->markActiveThreadRead(); chatVirtEnsureMsgIdx(); if (!s_chat_msg_idx || s_chat_msg_idx_cap <= 0) { chatVirtResetToPlaceholder(p, "Low memory"); return; } const int n = g_lv.task->getActiveThreadMessageCount(s_chat_msg_idx, s_chat_msg_idx_cap, false); #if TRACE_MESSAGE_SCROLL_ACTIVITY if (opening) { char tname[UITask::MAX_THREAD_NAME + 1]; chatVirtGetThreadName(tname, sizeof(tname)); CHAT_SCROLL_TRACE_PRINTF("[CHAT] open channel=\"%s\" stored_messages=%d\n", tname[0] ? tname : "?", n); s_chat_virt_at_store_top = false; s_chat_virt_at_store_bottom = false; } #endif if (n <= 0) { chatVirtResetToPlaceholder(p, "No messages yet.\nSay hello!"); return; } int divider_i = -1; if (s_unread_at_open > 0) { divider_i = n - static_cast<int>(s_unread_at_open); if (divider_i < 0) divider_i = 0; if (divider_i >= n) divider_i = -1; } const bool compact_chat = touchPrefsGetCompactChat(); // Content-generation guard: at ring capacity a new message evicts the oldest, // so n stays CONSTANT while every logical index shifts one slot — and a thread // switch on an already-open panel can land on an equal count too. Comparing the // endpoint ring slots catches both (the ring never reorders interior slots // while the endpoints hold, and two threads cannot share a slot). Without this // the old offsets kept describing the previous content and every row rendered // the next message's text at the previous message's position. const bool ring_changed = (s_chat_virt.n > 0) && (s_chat_msg_idx[0] != s_chat_virt.first_ring || s_chat_msg_idx[n - 1] != s_chat_virt.last_ring); const bool need_layout = (s_chat_virt.panel != &p) || (s_chat_virt.n != n) || !s_chat_virt.offsets || ring_changed || s_chat_virt.compact_chat != compact_chat; const bool divider_rebuild = !need_layout && divider_i >= 0 && s_chat_virt.divider_y < 0; const bool changing_panel = s_chat_virt.panel != &p; if (opening || need_layout || divider_rebuild) chatVirtCancelRenderTimer(); if (opening || changing_panel) { // The next protected render clears every stale child. Drop these aliases so // it cannot preserve/reuse a spacer owned by a previous panel or thread. s_chat_virt.spacer = nullptr; s_chat_virt.divider = nullptr; s_chat_virt.scroll_virt_valid = false; } if (need_layout) { lv_indev_reset(nullptr, nullptr); s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; if (!chatVirtRebuildLayout(&p, n, divider_i)) { chatVirtResetToPlaceholder(p, "Low memory"); return; } } else { s_chat_virt.divider_i = divider_i; if (divider_rebuild) { lv_indev_reset(nullptr, nullptr); s_chat_virt.last_i0 = -1; s_chat_virt.last_i1 = -1; if (!chatVirtRebuildLayout(&p, n, divider_i)) { chatVirtResetToPlaceholder(p, "Low memory"); return; } } // (An `else if (g_lv.dirty_timeline)` rescue used to sit here — it was dead: // UITask::loop clears the flag right after SCHEDULING the async refresh, so // by the time this code ran it always read false. The ring_changed term in // need_layout above covers the cases it was meant to catch.) } lv_coord_t scroll_target = prev_scroll_y; if (s_chat_virt.scroll_virt_valid && chatVirtCompressCoords() && !s_chat_just_opened && !was_at_bottom) { scroll_target = chatVirtVirtToLv(s_chat_virt.scroll_virt_top); } if (s_chat_just_opened) { if (s_chat_jump_msg_idx >= 0 && s_chat_virt.offsets) { for (int i = 0; i < n; ++i) { if (s_chat_msg_idx[i] == s_chat_jump_msg_idx) { const int32_t virt = (s_chat_virt.offsets[i] > 8) ? (s_chat_virt.offsets[i] - 8) : 0; scroll_target = chatVirtVirtToLv(virt); break; } } } else if (divider_i >= 0 && s_chat_virt.divider_y >= 0) { const int32_t virt = (s_chat_virt.divider_y > 8) ? (s_chat_virt.divider_y - 8) : 0; scroll_target = chatVirtVirtToLv(virt); } else { scroll_target = LV_COORD_MAX; } } else if (was_at_bottom) { scroll_target = LV_COORD_MAX; } chatVirtResetInputForMsgs(&p); chatVirtCancelRenderTimer(); chatVirtQueueScroll(&p, scroll_target); #if TRACE_MESSAGE_SCROLL_ACTIVITY if (opening) { const lv_coord_t vh = lv_obj_get_height(p.msgs); const lv_coord_t sy = lv_obj_get_scroll_y(p.msgs); const lv_coord_t sb = lv_obj_get_scroll_bottom(p.msgs); const int32_t layout_h = s_chat_virt.virt_total_h; CHAT_SCROLL_TRACE_PRINTF("[CHAT] open scroll_y=%d scroll_bottom=%d view_h=%d layout_h=%d\n", (int)sy, (int)sb, (int)vh, (int)layout_h); chatVirtLogVisibleRange(&p, s_chat_virt.last_i0, s_chat_virt.last_i1); } #endif s_chat_just_opened = false; s_chat_jump_msg_idx = -1; chatUpdateJumpButtons(&p); } static uint8_t s_chat_detail_async_mask = 0; static void refreshChatDetailAsyncCb(void*) { const uint8_t m = s_chat_detail_async_mask; s_chat_detail_async_mask = 0; if ((m & 1) && g_lv.dm.detail_open) refreshChatDetail(g_lv.dm); if ((m & 2) && g_lv.ch.detail_open) refreshChatDetail(g_lv.ch); } static void refreshChatDetailAsync(LvChatPanel& p) { if (&p == &g_lv.dm) s_chat_detail_async_mask |= 1; else if (&p == &g_lv.ch) s_chat_detail_async_mask |= 2; else return; lv_async_call(refreshChatDetailAsyncCb, nullptr); } // Format hour:minute honoring the 12/24-hour clock preference. 12h drops the // leading zero and appends AM/PM ("2:05 PM"); 24h is plain "%H:%M". Needs a // buffer of at least 9 bytes for the 12h form. // Format hour:minute honoring the 12/24-hour clock preference. 12h drops the // leading zero and appends AM/PM ("2:05 PM"); 24h is plain "%H:%M". Needs a // buffer of at least 9 bytes for the 12h form. static void fmtClockHM(char* buf, size_t cap, const struct tm* t) { if (!buf || cap < 1) return; #if defined(ESP32) if (touchPrefsGetClock12h()) { int h = t->tm_hour % 12; if (h == 0) h = 12; snprintf(buf, cap, "%d:%02d %s", h, t->tm_min, t->tm_hour < 12 ? "AM" : "PM"); return; } #endif strftime(buf, cap, "%H:%M", t); } // Rebuild the thread list inside a tab. // Compact last-message time for a chat-list row: HH:MM today, "DD Mon" earlier // this year, "DD/MM/YY" older. Empty when there's no timestamp (ts == 0). static void formatChatRowTime(char* buf, size_t cap, uint32_t ts) { if (!buf || cap < 1) return; buf[0] = '\0'; // Blank for "no real timestamp": 0 (empty/imported channel with no messages), // or a pre-2020 value left by an unsynced/garbage RTC — showing a bogus // 1969/1970/1902 date is worse than showing nothing (Ricky Leong). if (ts < 1577836800UL) return; // 2020-01-01 UTC time_t t = (time_t)ts; time_t now = time(nullptr); struct tm tmv, tmn; localtime_r(&t, &tmv); localtime_r(&now, &tmn); if (tmv.tm_year == tmn.tm_year && tmv.tm_yday == tmn.tm_yday) fmtClockHM(buf, cap, &tmv); else if (tmv.tm_year == tmn.tm_year) strftime(buf, cap, "%d %b", &tmv); else strftime(buf, cap, "%d/%m/%y", &tmv); } static void refreshChatList(LvChatPanel& p) { if (!g_lv.task || !p.list_cont) return; int idxs[UITask::MAX_UI_THREADS]; int count = 0; if (p.inbox_combined) { count = g_lv.task->getCombinedInboxCount(idxs, UITask::MAX_UI_THREADS); } else { count = g_lv.task->getThreadCount(p.channel_mode, idxs, UITask::MAX_UI_THREADS); } // Change signature over everything the list shows (order, names, unread counts, // last-message time, channel/mention flags). This list is rebuilt from scratch on // every periodic refresh, which resets the scroll to the top — so skip the rebuild // entirely when nothing changed (the common case while you're just scrolling), and // otherwise preserve the scroll position across it. const bool compact_rows = touchPrefsGetCompactChat(); // compact = today's dense contact-style rows; off = WhatsApp-style uint32_t sig = 2166136261u; auto mix = [&sig](uint32_t v) { sig = (sig ^ v) * 16777619u; }; mix((uint32_t)count); mix(compact_rows ? 0xC0FFEEu : 1u); for (int i = 0; i < count; ++i) { bool ch = false; uint16_t unread = 0; uint32_t ts = 0; char nm[UITask::MAX_THREAD_NAME + 1]; if (!g_lv.task->getThreadInfo(idxs[i], ch, unread, ts, nm, sizeof(nm))) continue; mix((uint32_t)idxs[i]); mix(unread); mix(ts); mix((ch ? 2u : 0u) | (g_lv.task->threadHasMention(idxs[i]) ? 1u : 0u)); for (const char* s = nm; *s; ++s) mix((uint8_t)*s); if (ch && !compact_rows) { // avatar-emoji change must re-render the row char eb[20]; if (touchPrefsGetChannelEmoji(nm, eb, sizeof eb)) for (const char* s2 = eb; *s2; ++s2) mix((uint8_t)*s2); } } if (sig == p.list_sig && lv_obj_get_child_cnt(p.list_cont) > 0) return; // nothing changed p.list_sig = sig; const lv_coord_t saved_scroll = lv_obj_get_scroll_y(p.list_cont); lv_indev_reset(nullptr, nullptr); // #27: abort any scroll-throw before freeing these rows (UAF on flood) lv_obj_clean(p.list_cont); if (count <= 0) { const char* empty = p.inbox_combined ? "No channels or chats yet" : (p.channel_mode ? "No channels yet" : "No chats yet"); lv_obj_t* l = lv_list_add_text(p.list_cont, empty); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_pad_all(l, 20, LV_PART_MAIN); return; } for (int i = 0; i < count; ++i) { bool ch = false; uint16_t unread = 0; uint32_t ts = 0; char name[UITask::MAX_THREAD_NAME + 1]; if (!g_lv.task->getThreadInfo(idxs[i], ch, unread, ts, name, sizeof(name))) continue; char san_name[UITask::MAX_THREAD_NAME + 8]; copyUtf8ReplacingMissingGlyphs(&g_font_14, san_name, sizeof(san_name), name); lv_obj_t* btn = nullptr; if (compact_rows) { // ---- Compact rows (the dense contact-style list) ---- // Name only — the unread count is shown as a right-aligned badge below. // DM = single person; channel = group of people (renders via the person_font // splice on g_font_14). Replaces the old envelope / loop-arrow glyphs. const char* icon = ch ? TOUCH_SYM_GROUP : TOUCH_SYM_PERSON; btn = lv_list_add_btn(p.list_cont, icon, san_name); // Match the Contacts-tab row recipe exactly (Kaj: one list design across the // tabs, and the same 34 px height so more threads fit per screen): panel fill, // 1 px bottom hairline, square corners, 16 px type icon + 14 px name. lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_text_color(btn, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(btn, &g_font_14, LV_PART_MAIN); // Contacts rows are a FIXED 34 px; mirror that instead of min-height + fat // vertical padding, and centre the icon/name on the row's cross axis. lv_obj_set_style_pad_ver(btn, 0, LV_PART_MAIN); lv_obj_set_style_min_height(btn, 34, LV_PART_MAIN); lv_obj_set_height(btn, 34); lv_obj_set_style_pad_left(btn, 8, LV_PART_MAIN); // contacts icon_x lv_obj_set_flex_align(btn, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // Type icon in the contacts style: 16 px glyph, muted — the name carries the // unread accent, the icon stays neutral like the person/antenna icons do. if (lv_obj_t* icl = lv_obj_get_child(btn, 0)) { lv_obj_set_style_text_font(icl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(icl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } // Per-row settings gear on the far right — opens the thread-settings sheet (same as a long-press // and the in-chat cog). Tapping it swallows the gesture so the row's CLICKED can't open the chat. const lv_coord_t gear_w = 28; { lv_obj_t* gear = lv_btn_create(btn); lv_obj_remove_style_all(gear); lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_add_flag(gear, NAV_HMOVE_FLAG); // keyboard nav: reach the gear with RIGHT (not UP/DOWN); keeps the row itself focusable so the chat opens lv_obj_set_size(gear, gear_w, 30); lv_obj_align(gear, LV_ALIGN_RIGHT_MID, -2, 0); lv_obj_add_event_cb(gear, threadGearCb, LV_EVENT_CLICKED, &p.ctx_store[i]); lv_obj_t* gl = lv_label_create(gear); lv_label_set_text(gl, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_font(gl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(gl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_center(gl); } // Last-message time, just left of the gear; the unread badge + @ sit to its left. const lv_coord_t time_x = (lv_coord_t)(-(10 + gear_w)); char tbuf[16]; formatChatRowTime(tbuf, sizeof(tbuf), ts); lv_coord_t time_w = 0; if (tbuf[0]) { lv_point_t tsz; lv_txt_get_size(&tsz, tbuf, &g_font_12, 0, 0, LV_COORD_MAX, 0); time_w = tsz.x; lv_obj_t* tlbl = lv_label_create(btn); lv_obj_add_flag(tlbl, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(tlbl, tbuf); lv_obj_set_style_text_font(tlbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(tlbl, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(tlbl, LV_ALIGN_RIGHT_MID, time_x, 0); } // Right edge for the unread / mention badges — just left of the time. const lv_coord_t r_edge = (lv_coord_t)((time_w > 0) ? (time_x - time_w - 8) : time_x); // Make long names scroll horizontally instead of being clipped. // lv_list_add_btn creates: child[0]=icon label, child[1]=text label. lv_obj_t* text_lbl = lv_obj_get_child(btn, 1); if (text_lbl) { lv_label_set_long_mode(text_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); // Leave room on the right for the gear + time + unread badge. lv_obj_set_width(text_lbl, lv_disp_get_hor_res(nullptr) - 116 - time_w - gear_w); } // Right-aligned unread badge (pill with the count), left of the time. if (unread > 0) { lv_obj_t* badge = lv_label_create(btn); lv_obj_add_flag(badge, LV_OBJ_FLAG_IGNORE_LAYOUT); // float, not in the row flex char cnt[8]; // Cap the badge like every chat app: `unread` is a per-thread accumulator that // keeps climbing while you're NOT viewing the thread (even as the 500-msg ring // evicts the old messages), so a busy channel legitimately hits the hundreds — // showing a raw "328" reads as a bug. 99+ is the expected, non-alarming cap. if (unread > 99) snprintf(cnt, sizeof cnt, "99+"); else snprintf(cnt, sizeof cnt, "%u", (unsigned)unread); lv_label_set_text(badge, cnt); lv_obj_set_style_text_font(badge, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(badge, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_bg_color(badge, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(badge, 9, LV_PART_MAIN); lv_obj_set_style_pad_hor(badge, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(badge, 1, LV_PART_MAIN); lv_obj_align(badge, LV_ALIGN_RIGHT_MID, r_edge, 0); } // Blue "@" to the left of the count when an unread message here @mentions me. if (g_lv.task->threadHasMention(idxs[i])) { lv_obj_t* at = lv_label_create(btn); lv_obj_add_flag(at, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(at, "@"); lv_obj_set_style_text_font(at, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(at, lv_color_hex(COLOR_MENTION), LV_PART_MAIN); lv_obj_align(at, LV_ALIGN_RIGHT_MID, (lv_coord_t)(unread > 0 ? r_edge - 38 : r_edge), 0); } } else { // ---- WhatsApp-style rows (default, compact chat OFF) ---- // Round avatar in the thread's signature colour with a stable per-name emoji, // name + last-message preview stacked next to it, time top-right, unread // pill + @ below the time, gear on the far edge. btn = lv_list_add_btn(p.list_cont, nullptr, nullptr); lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); #if defined(TLORA_PAGER) // Small stays at 48 px. Medium/Large gain only the pixels their two live // line boxes require, so name + preview cannot overlap without returning // to the oversized rows that made the short Pager list cumbersome. const lv_coord_t threadTextH = lv_font_get_line_height(&g_font_14) + lv_font_get_line_height(&g_font_12); const lv_coord_t kThreadRowH = LV_MAX((lv_coord_t)48, (lv_coord_t)(threadTextH + 2)); static constexpr lv_coord_t kThreadAvatar = 34; const lv_font_t* rowMetaFont = touchPrefsGetUiScale() ? &lv_font_montserrat_14 : &g_font_12; #else static constexpr lv_coord_t kThreadRowH = 56; static constexpr lv_coord_t kThreadAvatar = 40; const lv_font_t* rowMetaFont = &g_font_12; #endif lv_obj_set_style_min_height(btn, kThreadRowH, LV_PART_MAIN); lv_obj_set_height(btn, kThreadRowH); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // Avatar: same FNV-1a hue family as the chat-bubble colours (see // usernameBubbleColors), lifted in value so the disc reads on the dark panel. uint32_t hh = 2166136261u; for (const char* s2 = name; *s2; ++s2) { hh ^= (uint8_t)*s2; hh *= 16777619u; } lv_obj_t* av = lv_obj_create(btn); lv_obj_remove_style_all(av); lv_obj_add_flag(av, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(av, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); // taps fall through to the row lv_obj_set_size(av, kThreadAvatar, kThreadAvatar); lv_obj_set_style_radius(av, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(av, lv_color_hsv_to_rgb((uint16_t)(hh % 360u), 55, 42), LV_PART_MAIN); lv_obj_set_style_bg_opa(av, LV_OPA_COVER, LV_PART_MAIN); lv_obj_align(av, LV_ALIGN_LEFT_MID, 8, 0); // Avatar content: a user-chosen emoji for channels (thread sheet -> Chat icon), // otherwise the first two letters of the name (a leading '#' skipped, ASCII // uppercased) — the classic initials avatar. const lv_img_dsc_t* eg = nullptr; char av_glyph[20] = ""; if (ch && touchPrefsGetChannelEmoji(name, av_glyph, sizeof av_glyph)) { uint32_t goff = 0; eg = emojiGlyphLookup(_lv_txt_encoded_next(av_glyph, &goff)); // ZWJ glyphs are keyed on their lead codepoint } if (eg) { lv_obj_t* im = lv_img_create(av); lv_img_set_src(im, eg); lv_img_set_zoom(im, 384); // 16 px baked glyph -> ~24 px in the 40 px disc lv_img_set_antialias(im, true); lv_obj_center(im); } else { char initials[12]; int o = 0, glyphs = 0; const char* q = name; while (*q == '#' || *q == ' ') ++q; while (*q && glyphs < 2 && o < 8) { const uint8_t c = (uint8_t)*q; int len = 1; if (c >= 0xF0) len = 4; else if (c >= 0xE0) len = 3; else if (c >= 0xC0) len = 2; for (int b = 0; b < len && *q; ++b) initials[o++] = *q++; ++glyphs; } initials[o] = '\0'; for (char* u = initials; *u; ++u) if ((uint8_t)*u < 0x80) *u = (char)toupper((unsigned char)*u); char av_txt[16]; copyUtf8ReplacingMissingGlyphs(&g_font_16, av_txt, sizeof av_txt, initials[0] ? initials : "?"); lv_obj_t* fl = lv_label_create(av); lv_label_set_text(fl, av_txt); lv_obj_set_style_text_font(fl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(fl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(fl); } // Per-row settings gear on the far right (same behaviour as the compact rows). const lv_coord_t gear_w = 28; { lv_obj_t* gear = lv_btn_create(btn); lv_obj_remove_style_all(gear); lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_add_flag(gear, NAV_HMOVE_FLAG); lv_obj_set_size(gear, gear_w, kThreadRowH - 8); lv_obj_align(gear, LV_ALIGN_RIGHT_MID, -2, 0); lv_obj_add_event_cb(gear, threadGearCb, LV_EVENT_CLICKED, &p.ctx_store[i]); lv_obj_t* gl = lv_label_create(gear); lv_label_set_text(gl, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_font(gl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(gl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_center(gl); } // Time, top-right (left of the gear). const lv_coord_t time_x = (lv_coord_t)(-(10 + gear_w)); char tbuf[16]; formatChatRowTime(tbuf, sizeof(tbuf), ts); lv_coord_t time_w = 0; if (tbuf[0]) { lv_point_t tsz; lv_txt_get_size(&tsz, tbuf, rowMetaFont, 0, 0, LV_COORD_MAX, 0); time_w = tsz.x; lv_obj_t* tlbl = lv_label_create(btn); lv_obj_add_flag(tlbl, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(tlbl, tbuf); lv_obj_set_style_text_font(tlbl, rowMetaFont, LV_PART_MAIN); lv_obj_set_style_text_color(tlbl, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(tlbl, LV_ALIGN_TOP_RIGHT, time_x, 8); } // Name (top line) + last-message preview (bottom line), right of the avatar. const lv_coord_t text_x = 8 + kThreadAvatar + 8; const lv_coord_t name_w = (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - time_w - 24); lv_obj_t* nm2 = lv_label_create(btn); lv_obj_add_flag(nm2, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(nm2, san_name); lv_obj_set_style_text_font(nm2, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm2, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(nm2, LV_LABEL_LONG_DOT); // Fixed ONE-LINE height: with only a width, LONG_DOT lets a long name wrap to // a second line (never truncating) and it overlapped the preview underneath. lv_obj_set_size(nm2, name_w, #if defined(TLORA_PAGER) lv_font_get_line_height(&g_font_14) #else 18 #endif ); lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, #if defined(TLORA_PAGER) touchPrefsGetUiScale() == 0 ? 3 : 1 #else 9 #endif ); // Preview: "sender: text" for channels, "You: text" for own DMs, plain text otherwise. char psender[UITask::MAX_SENDER_NAME + 1] = ""; char ptext[80] = ""; bool pout = false; char preview[120] = ""; if (g_lv.task->getThreadLastMessage(idxs[i], psender, sizeof psender, ptext, sizeof ptext, &pout)) { char raw[112]; if (ch && psender[0] && !pout) snprintf(raw, sizeof raw, "%s: %s", psender, ptext); else if (pout) snprintf(raw, sizeof raw, "%s: %s", "You", ptext); else snprintf(raw, sizeof raw, "%s", ptext); // Previews render in a plain 12 px label: strip newlines, replace unbaked // glyphs, and let LONG_DOT ellipsize the rest. for (char* q = raw; *q; ++q) if (*q == '\n' || *q == '\r') *q = ' '; copyUtf8ReplacingMissingGlyphs(&g_font_12, preview, sizeof preview, raw); } lv_obj_t* pv = lv_label_create(btn); lv_obj_add_flag(pv, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(pv, preview[0] ? preview : ""); lv_obj_set_style_text_font(pv, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(pv, LV_LABEL_LONG_DOT); lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), #if defined(TLORA_PAGER) lv_font_get_line_height(&g_font_12) #else 16 #endif ); // one line, ellipsized lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, #if defined(TLORA_PAGER) touchPrefsGetUiScale() == 0 ? -3 : -1 #else -8 #endif ); // Unread pill bottom-right (under the time), @ to its left on a mention. if (unread > 0) { lv_obj_t* badge = lv_label_create(btn); lv_obj_add_flag(badge, LV_OBJ_FLAG_IGNORE_LAYOUT); char cnt[8]; if (unread > 99) snprintf(cnt, sizeof cnt, "99+"); else snprintf(cnt, sizeof cnt, "%u", (unsigned)unread); lv_label_set_text(badge, cnt); lv_obj_set_style_text_font(badge, rowMetaFont, LV_PART_MAIN); lv_obj_set_style_text_color(badge, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_bg_color(badge, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(badge, 9, LV_PART_MAIN); lv_obj_set_style_pad_hor(badge, 6, LV_PART_MAIN); lv_obj_set_style_pad_ver(badge, 1, LV_PART_MAIN); lv_obj_align(badge, LV_ALIGN_BOTTOM_RIGHT, time_x, -6); } if (g_lv.task->threadHasMention(idxs[i])) { lv_obj_t* at = lv_label_create(btn); lv_obj_add_flag(at, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_label_set_text(at, "@"); lv_obj_set_style_text_font(at, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(at, lv_color_hex(COLOR_MENTION), LV_PART_MAIN); lv_obj_align(at, LV_ALIGN_BOTTOM_RIGHT, (lv_coord_t)(time_x - (unread > 0 ? 34 : 0)), -6); } } if (!btn) continue; p.ctx_store[i].idx = idxs[i]; p.ctx_store[i].channel = ch; lv_obj_add_event_cb(btn, threadSelectCb, LV_EVENT_CLICKED, &p.ctx_store[i]); lv_obj_add_event_cb(btn, threadLongPressCb, LV_EVENT_LONG_PRESSED, &p.ctx_store[i]); } // Put the scroll back where it was so a data-driven rebuild doesn't snap to the top. lv_obj_update_layout(p.list_cont); lv_obj_scroll_to_y(p.list_cont, saved_scroll, LV_ANIM_OFF); } // Format a duration in seconds as a short badge like "12s" / "13m" / "2h" / // "5d" / ">1y" / "?". Returns "?" for zero or absurd ages so we don't show // 50-year-old timestamps from a never-synced RTC. static void formatAgeBadge(char* buf, size_t cap, uint32_t age_secs) { if (cap == 0) return; if (age_secs == 0 || age_secs > (uint32_t)400 * 24u * 3600u) { snprintf(buf, cap, "?"); return; } if (age_secs < 60u) snprintf(buf, cap, "%us", (unsigned)age_secs); else if (age_secs < 3600u) snprintf(buf, cap, "%um", (unsigned)(age_secs / 60u)); else if (age_secs < 24u*3600u) snprintf(buf, cap, "%uh", (unsigned)(age_secs / 3600u)); else snprintf(buf, cap, "%ud", (unsigned)(age_secs / (24u*3600u))); } // Append a tiny pill-shaped status chip to the row. Used for RPT/USR/SRV/SNS // type tags + DIR / GPS state. static lv_obj_t* makeContactChip(lv_obj_t* parent, const char* text, uint32_t bg, uint32_t fg, lv_coord_t w) { lv_obj_t* chip = lv_obj_create(parent); lv_obj_remove_style_all(chip); lv_obj_set_size(chip, w, 16); lv_obj_set_style_bg_color(chip, lv_color_hex(bg), LV_PART_MAIN); lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(chip, 3, LV_PART_MAIN); lv_obj_set_style_pad_all(chip, 0, LV_PART_MAIN); lv_obj_clear_flag(chip, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* lbl = lv_label_create(chip); lv_label_set_text(lbl, TR(text)); lv_obj_set_style_text_color(lbl, lv_color_hex(fg), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_center(lbl); return chip; } // Two-byte chip labels for advert types. Colors echo MCterm's palette. static void contactTypeChipStyle(uint8_t type, const char*& label, uint32_t& bg, uint32_t& fg) { switch (type) { case ADV_TYPE_REPEATER: label = "RPT"; bg = 0xC59F00; fg = 0x171a0e; break; // yellow case ADV_TYPE_ROOM: label = "SRV"; bg = 0x6A4FC8; fg = 0xF1ECFF; break; // purple case ADV_TYPE_SENSOR: label = "SNS"; bg = 0x2A6BD0; fg = 0xE6F2FF; break; // blue case ADV_TYPE_CHAT: default: label = "USR"; bg = 0x2A3848; fg = 0xCFE0F4; break; // muted } } // Great-circle distance in km between two lat/lon points (haversine). static double contactDistanceKm(double lat1, double lon1, double lat2, double lon2) { const double R = 6371.0; // mean Earth radius, km const double dlat = (lat2 - lat1) * M_PI / 180.0; const double dlon = (lon2 - lon1) * M_PI / 180.0; const double a = sin(dlat / 2) * sin(dlat / 2) + cos(lat1 * M_PI / 180.0) * cos(lat2 * M_PI / 180.0) * sin(dlon / 2) * sin(dlon / 2); return R * 2.0 * atan2(sqrt(a), sqrt(1.0 - a)); } // Format a compact distance badge, honouring the km/miles preference: // metric: "120m", "1.4km", "37km" // imperial: "390ft", "0.9mi", "23mi" // `out` is left empty when either fix is unknown (caller falls back to a // plain "GPS" tag). static void formatDistanceBadge(char* out, size_t out_cap, double self_lat, double self_lon, int32_t c_lat_e6, int32_t c_lon_e6) { out[0] = '\0'; if (self_lat == 0.0 && self_lon == 0.0) return; // no self fix if (c_lat_e6 == 0 && c_lon_e6 == 0) return; // no contact fix const double km = contactDistanceKm(self_lat, self_lon, (double)c_lat_e6 / 1.0e6, (double)c_lon_e6 / 1.0e6); #if defined(ESP32) const bool miles = touchPrefsGetUseMiles(); #else const bool miles = false; #endif if (miles) { const double mi = km * 0.621371; if (mi < 0.19) { // under ~1000 ft → show feet snprintf(out, out_cap, "%dft", (int)(mi * 5280.0 + 0.5)); } else if (mi < 10.0) { snprintf(out, out_cap, "%.1fmi", mi); } else { snprintf(out, out_cap, "%dmi", (int)(mi + 0.5)); } } else { if (km < 1.0) { snprintf(out, out_cap, "%dm", (int)(km * 1000.0 + 0.5)); } else if (km < 10.0) { snprintf(out, out_cap, "%.1fkm", km); } else { snprintf(out, out_cap, "%dkm", (int)(km + 0.5)); } } } // Clock snapshot for the contacts sort's "?" test — set before qsort so the // non-capturing comparator can read it (mirrors formatAgeBadge's condition). static uint32_t s_ct_sort_now = 0; // Self GPS snapshot for the distance sort, captured before qsort (the comparator // is non-capturing). Degrees. static double s_ct_sort_self_lat = 0.0, s_ct_sort_self_lon = 0.0; // #82 perf: the 60 s "2h -> 3h" age refresh used to tear the whole list down and // rebuild it (~1.3 s at ~570 contacts, measured on the loop thread) just to // re-render a handful of small labels. When NOTHING else changed, walk the // existing rows and set the Heard text in place instead. Returns false when a // stored label is stale (list mutated outside a build) so the caller falls back // to the full rebuild — never a wrong display, worst case the old cost. static int s_contacts_rows_built = 0; // rows rendered by the last full build static bool ctRefreshAgeLabelsInPlace() { if (!g_lv.contacts_list) return false; const uint32_t now_secs = the_mesh.getRTCClock()->getCurrentTime(); for (int k = 0; k < s_contacts_rows_built; ++k) { lv_obj_t* lbl = s_contacts_ctx[k].age_lbl; if (!lbl || !lv_obj_is_valid(lbl)) return false; ContactInfo* c = the_mesh.lookupContactByPubKey(s_contacts_ctx[k].key6, 6); if (!c) continue; // deleted mid-window — the count change triggers a full rebuild right after char age_buf[12]; uint32_t age_secs = 0; if (now_secs > c->last_advert_timestamp && c->last_advert_timestamp != 0) age_secs = now_secs - c->last_advert_timestamp; formatAgeBadge(age_buf, sizeof age_buf, age_secs); lv_label_set_text(lbl, age_buf); } return true; } static void refreshContactsList() { if (!g_lv.contacts_list || !g_lv.task) return; if (s_ctd_active) return; // mid bulk-delete: don't rebuild rows under the progress modal // Cache: skip the rebuild unless the count / filter / sort / search // changed, or 30 seconds have elapsed (so age labels re-render). static int s_last_count = -1; static uint8_t s_last_filter = 0xFF; static uint8_t s_last_sort = 0xFF; static char s_last_search[24] = {0}; static unsigned long s_last_age_refresh_ms = 0; static bool s_last_use_miles = false; const int curr_count = the_mesh.getNumContacts(); const uint8_t curr_filter = g_lv.contacts_filter; const unsigned long now_ms = millis(); #if defined(ESP32) const bool curr_use_miles = touchPrefsGetUseMiles(); #else const bool curr_use_miles = false; #endif // 60 s between forced rebuilds — only matters for the age labels // (e.g. "2h" → "3h"). Earlier 30 s wasn't necessary and doubled the // rebuild rate on the contacts tab. const bool age_refresh_due = (now_ms - s_last_age_refresh_ms) > 60000UL; const bool search_changed = strncmp(s_last_search, g_lv.contacts_search, sizeof(s_last_search)) != 0; if (curr_count == s_last_count && curr_filter == s_last_filter && g_contacts_sort == s_last_sort && !search_changed && curr_use_miles == s_last_use_miles && !s_ct_list_force && lv_obj_get_child_cnt(g_lv.contacts_list) > 0) { if (!age_refresh_due) return; // Only the age labels are stale — update them in place instead of the ~1.3s // full teardown+rebuild (#82). Falls through to the rebuild if a row pointer // went stale. if (ctRefreshAgeLabelsInPlace()) { s_last_age_refresh_ms = now_ms; return; } } s_ct_list_force = false; s_last_count = curr_count; s_last_filter = curr_filter; s_last_sort = g_contacts_sort; s_last_use_miles = curr_use_miles; strncpy(s_last_search, g_lv.contacts_search, sizeof(s_last_search) - 1); s_last_search[sizeof(s_last_search) - 1] = '\0'; s_last_age_refresh_ms = now_ms; lv_indev_reset(nullptr, nullptr); // #27: abort any scroll-throw before freeing these rows (UAF on advert flood) lv_obj_clean(g_lv.contacts_list); s_contacts_rows_built = 0; // rows are gone; re-counted below (a 0-row build must not leave stale ctx) // Sync the search-active indicator chip with the current search state. if (g_lv.contacts_search_indicator) { if (g_lv.contacts_search[0]) lv_obj_clear_flag(g_lv.contacts_search_indicator, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_lv.contacts_search_indicator, LV_OBJ_FLAG_HIDDEN); } // Snapshot matching contacts so we can sort them. File-static array on // .bss — no heap allocation per refresh. struct Entry { int mesh_idx; uint8_t type; bool has_dir; bool has_gps; bool is_fav; bool is_blocked; // on the ignore list -> red person icon uint32_t last_heard; int32_t gps_lat; // microdegrees (0 = unknown) int32_t gps_lon; uint8_t key6[6]; // pub_key prefix — stable identity for multi-select char name[40]; }; // Snapshot EVERY matching contact (not just the first 128) so the sort below // decides which make the render cut. MAX_CONTACTS can be up to 2000, and the old // 128-cap-in-storage-order silently dropped everything past the 128th — incl. // active zero-hop neighbours that then "vanished" from the list (#73). static const int CT_SNAP_MAX = (int)MAX_CONTACTS; static Entry* s_entries = (Entry*)psAlloc(sizeof(Entry) * (size_t)CT_SNAP_MAX); if (!s_entries) { lv_obj_t* l = lv_list_add_text(g_lv.contacts_list, "Contacts unavailable (low memory)"); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); return; } int n_entries = 0; // Snapshot the favorites blob once per refresh. touchPrefsIsFavorite // hits NVS on every call, and with N contacts the contact-list rebuild // was doing N NVS reads — noticeably laggy past ~30 contacts. One read // here + an in-memory memcmp scan per contact is several ms faster. #if defined(ESP32) uint8_t fav_buf[TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES]; const int fav_count = touchPrefsCopyFavorites(fav_buf); // Same one-read-then-scan trick for the ignore list (touchPrefsIsIgnored // re-reads the blob on every call). uint8_t ign_buf[TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES]; const int ign_count = touchPrefsCopyIgnored(ign_buf); #else const int fav_count = 0; uint8_t* const fav_buf = nullptr; const int ign_count = 0; #endif // Lower-cased search needle, computed once per refresh, so the inner // strcasestr-style search doesn't re-lower-case the operator's input N // times per character. char search_lc[24] = {0}; bool have_search = (g_lv.contacts_search[0] != '\0'); if (have_search) { int n = (int)strlen(g_lv.contacts_search); if (n > (int)sizeof(search_lc) - 1) n = (int)sizeof(search_lc) - 1; for (int i = 0; i < n; ++i) { char ch = g_lv.contacts_search[i]; if (ch >= 'A' && ch <= 'Z') ch = (char)(ch - 'A' + 'a'); search_lc[i] = ch; } search_lc[n] = '\0'; } // One pass over all contacts: the qsort (favourites first, then the active sort // key) decides the 128-row render cut, so the rows shown are the most relevant // rather than the first 128 in storage order. Favourites are guaranteed visible // because the comparator floats them to the top (#17, #73). for (int i = 0; i < curr_count && n_entries < CT_SNAP_MAX; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(static_cast<uint32_t>(i), c) || !c.name[0]) continue; const bool is_rep = (c.type == ADV_TYPE_REPEATER); #if defined(ESP32) const bool is_fav = touchPrefsFavoritesSnapshotContains(fav_buf, fav_count, c.id.pub_key); bool is_blocked = false; for (int b = 0; b < ign_count; ++b) if (memcmp(&ign_buf[b * TOUCH_IGNORE_KEY_BYTES], c.id.pub_key, TOUCH_IGNORE_KEY_BYTES) == 0) { is_blocked = true; break; } #else const bool is_fav = false; const bool is_blocked = false; #endif // Shared predicate (category filter incl. "has location" + name search) so // the rendered list and the multi-select "Select all" apply IDENTICAL rules. if (!ctPassesFilter(c, is_fav, fav_count, have_search ? search_lc : nullptr)) continue; // Distance sort needs a shared location to order by — drop rows that have none // entirely (favourites included) so the list shows only sortable entries. if (g_contacts_sort == CONTACTS_SORT_DISTANCE && c.gps_lat == 0 && c.gps_lon == 0) continue; Entry& e = s_entries[n_entries++]; e.mesh_idx = i; e.type = c.type; e.has_dir = (c.out_path_len != OUT_PATH_UNKNOWN && c.out_path_len > 0); e.has_gps = (c.gps_lat != 0 || c.gps_lon != 0); e.gps_lat = c.gps_lat; e.gps_lon = c.gps_lon; e.is_fav = is_fav; e.is_blocked = is_blocked; e.last_heard = c.last_advert_timestamp; memcpy(e.key6, c.id.pub_key, 6); strncpy(e.name, c.name, sizeof(e.name) - 1); e.name[sizeof(e.name) - 1] = '\0'; } // Capture the clock + self GPS before sorting (the qsort comparator is non-capturing). { mesh::RTCClock* rtc = the_mesh.getRTCClock(); s_ct_sort_now = rtc ? rtc->getCurrentTime() : 0; } s_ct_sort_self_lat = g_lv.task->getNodeLat(); s_ct_sort_self_lon = g_lv.task->getNodeLon(); qsort(s_entries, n_entries, sizeof(Entry), [](const void* a, const void* b) -> int { const Entry* ea = static_cast<const Entry*>(a); const Entry* eb = static_cast<const Entry*>(b); // Favorites always sort above non-favorites regardless of the category filter // OR the asc/desc flip. Operator's starred contacts should be the first thing // visible whether they're filtering by RPT, Peer, or anything. if (ea->is_fav != eb->is_fav) { return ea->is_fav ? -1 : 1; } // `prim` < 0 means a comes before b in the mode's NATURAL order; the asc/desc // toggle (g_contacts_sort_desc) flips just this primary key at the very end. int prim = 0; if (g_contacts_sort == CONTACTS_SORT_DISTANCE) { // Nearest first. No-location rows are excluded upstream, so both have a fix. const double da = contactDistanceKm(s_ct_sort_self_lat, s_ct_sort_self_lon, ea->gps_lat / 1.0e6, ea->gps_lon / 1.0e6); const double db = contactDistanceKm(s_ct_sort_self_lat, s_ct_sort_self_lon, eb->gps_lat / 1.0e6, eb->gps_lon / 1.0e6); if (da < db) prim = -1; else if (da > db) prim = 1; } else { // Contacts whose last-heard renders as "?" sink to the end in every non-distance // mode — structurally, NOT affected by the asc/desc flip. "?" is NOT just // last_heard==0: formatAgeBadge also shows it for a future/garbage timestamp // (RTC unset -> now <= last_heard) or one over 400 days old. Mirror that exact // condition using the captured clock so the sort matches what the row displays. const uint32_t now = s_ct_sort_now; const uint32_t a_age = (now > ea->last_heard && ea->last_heard != 0) ? (now - ea->last_heard) : 0; const uint32_t b_age = (now > eb->last_heard && eb->last_heard != 0) ? (now - eb->last_heard) : 0; const bool a_unknown = (a_age == 0 || a_age > (uint32_t)400 * 24u * 3600u); const bool b_unknown = (b_age == 0 || b_age > (uint32_t)400 * 24u * 3600u); if (a_unknown != b_unknown) return a_unknown ? 1 : -1; if (g_contacts_sort == CONTACTS_SORT_LAST_HEARD || g_contacts_sort == CONTACTS_SORT_LAST_MSG) { if (ea->last_heard != eb->last_heard) prim = (ea->last_heard > eb->last_heard) ? -1 : 1; // newer first (natural) } } if (prim == 0) prim = strcasecmp(ea->name, eb->name); // A-Z natural order / tiebreak if (prim == 0) prim = memcmp(ea->key6, eb->key6, 6); // stable final tiebreak → deterministic 128-row cut (#73) return g_contacts_sort_desc ? -prim : prim; }); const uint32_t now_secs = the_mesh.getRTCClock()->getCurrentTime(); // Self GPS for the per-row distance badge — fetched once per refresh. const double self_lat = g_lv.task->getNodeLat(); const double self_lon = g_lv.task->getNodeLon(); // ---- Table rows: custom column layout (checkbox | icon | Name | Heard | Location | ★) // Custom lv_obj rows (NOT lv_list_add_btn) with absolute-positioned children // give aligned columns + a checkbox, and sidestep the lv_list_add_btn flex // chain that bootlooped pre-alpha when siblings were added inside it. const int row_w = tabContentW(); const bool sel_md = s_ct_select_mode; const int star_x = row_w - 18; // ★ favorite marker (reserved on every row) // Heard (age) + Location (distance) columns. On the narrow 240/320 boards the // compact 32/46-px widths just fit; on the wide Tanmatsu (≥400 px) the values // ("1.4km" / "390ft" / longer ages) looked cramped and ran together, so widen // both columns and add a gap there. Labels below also get an explicit width so // the text right-aligns within its column instead of bleeding into the next. const bool wide_cols = (row_w >= 400); const bool mid_cols = (!wide_cols && row_w >= 280); // T-Display P4 (284): two-line rows (below) const int loc_w = wide_cols ? 80 : (mid_cols ? 70 : 46); const int hrd_w = wide_cols ? 64 : (mid_cols ? 48 : 32); const int col_gap = wide_cols ? 10 : (mid_cols ? 8 : 2); // narrow 2-px gap read as one glued value ("46m33km") const int loc_x = star_x - (wide_cols ? 8 : (mid_cols ? 6 : 4)) - loc_w; // Location column (pushed right) const int heard_x = loc_x - col_gap - hrd_w; // Heard column (left of Location) const int icon_x = sel_md ? 34 : 8; const int name_x = icon_x + (mid_cols ? 24 : 20); // the font-16 type glyph grazed the name's first letter // mid_cols (P4 284px): a single 34-px line can't hold name + age + distance without either // scrolling the name (rejected) or gluing the value columns. TWO-LINE rows instead: // line 1: the full name (one line, dot-ellipsized — never scrolls) // line 2: heard-age and distance, small + dim under the name const int name_line_h_row = lv_font_get_line_height(&g_font_14); // The Pager is in the wide, single-line column layout. Keep its original // compact row geometry even when the UI-size preset selects larger fonts; // stacking the name and metadata line heights here reserved a second line // that the wide layout never draws. const int ROW_H = mid_cols ? 46 : 34; const int row2_y = 5 + name_line_h_row + 2; // line 2 top (below the name line) int name_w = heard_x - name_x - 6; if (name_w < 50) name_w = 50; for (int k = 0; k < n_entries; ++k) { if (k >= CONTACTS_CTX_MAX) break; const Entry& e = s_entries[k]; const bool is_rep = (e.type == ADV_TYPE_REPEATER); // Heard (age) + Location (distance / GPS / —) column strings. char age_buf[12]; uint32_t age_secs = 0; if (now_secs > e.last_heard && e.last_heard != 0) age_secs = now_secs - e.last_heard; formatAgeBadge(age_buf, sizeof(age_buf), age_secs); char loc_buf[16]; if (e.has_gps) { char dist[12]; formatDistanceBadge(dist, sizeof(dist), self_lat, self_lon, e.gps_lat, e.gps_lon); snprintf(loc_buf, sizeof(loc_buf), "%s", dist[0] ? dist : "GPS"); } else { loc_buf[0] = '-'; loc_buf[1] = '\0'; } lv_obj_t* rb = lv_obj_create(g_lv.contacts_list); lv_obj_remove_style_all(rb); lv_obj_set_size(rb, row_w, ROW_H); lv_obj_set_style_bg_color(rb, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(rb, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(rb, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_border_color(rb, lv_color_hex(0x141516), LV_PART_MAIN); lv_obj_set_style_border_width(rb, 1, LV_PART_MAIN); lv_obj_set_style_border_side(rb, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_radius(rb, 0, LV_PART_MAIN); lv_obj_clear_flag(rb, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(rb, LV_OBJ_FLAG_CLICKABLE); s_contacts_ctx[k].mesh_idx = static_cast<uint32_t>(e.mesh_idx); s_contacts_ctx[k].is_repeater = is_rep; s_contacts_ctx[k].is_fav = e.is_fav; memcpy(s_contacts_ctx[k].key6, e.key6, 6); lv_obj_add_event_cb(rb, contactRowTapCb, LV_EVENT_CLICKED, &s_contacts_ctx[k]); // Checkbox (select mode) — MUST be child 0 (ctUpdateRowCheck assumes it). if (sel_md) { lv_obj_t* box = lv_obj_create(rb); lv_obj_remove_style_all(box); lv_obj_set_size(box, 20, 20); lv_obj_align(box, LV_ALIGN_LEFT_MID, 6, 0); lv_obj_set_style_radius(box, 4, LV_PART_MAIN); lv_obj_set_style_border_width(box, 2, LV_PART_MAIN); lv_obj_set_style_border_color(box, lv_color_hex(e.is_fav ? 0x33383E : COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(box, ctSelHas(e.key6) ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* chk = lv_label_create(box); lv_label_set_text(chk, LV_SYMBOL_OK); lv_obj_set_style_text_font(chk, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(chk, lv_color_hex(0x062019), LV_PART_MAIN); lv_obj_center(chk); if (!ctSelHas(e.key6)) lv_obj_add_flag(chk, LV_OBJ_FLAG_HIDDEN); } // Type icon (person = peer, antenna = repeater). FontAwesome glyphs from // person_font, which lives in g_font_16's fallback chain — so the icon label // must render in g_font_16 for the PUA codepoints to resolve. Blocked // contacts get a RED person icon so they stand out at a glance. lv_obj_t* ic = lv_label_create(rb); lv_label_set_text(ic, e.is_blocked ? TOUCH_SYM_PERSON : (is_rep ? TOUCH_SYM_ANTENNA : (e.type == ADV_TYPE_ROOM ? LV_SYMBOL_LOOP : TOUCH_SYM_PERSON))); // rooms discernible at a glance (#106) lv_obj_set_style_text_font(ic, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(ic, lv_color_hex(e.is_blocked ? 0xD7574E : COLOR_SUB), LV_PART_MAIN); lv_obj_align(ic, LV_ALIGN_LEFT_MID, icon_x, 0); // Name: wrap up to 2 lines; if the name would need a 3rd line (which // overflows the fixed-height row), scroll it horizontally instead. // (mid_cols/P4: exactly ONE line, full row width, dot-ellipsized — no scrolling.) char san[40]; copyUtf8ReplacingMissingGlyphs(&g_font_14, san, sizeof(san), e.name); lv_obj_t* nm = lv_label_create(rb); useChainedFont(nm); if (mid_cols) { lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); // BOTH dimensions constrained: with free height LONG_DOT doesn't ellipsize — the name // wrapped to a 2nd line and collided with the age/distance line below it. lv_obj_set_size(nm, star_x - 6 - name_x, name_line_h_row); } else { lv_point_t nsz; lv_txt_get_size(&nsz, san, &g_font_14, 0, 0, name_w, LV_TEXT_FLAG_NONE); const int name_line_h = lv_font_get_line_height(&g_font_14); lv_label_set_long_mode(nm, (nsz.y > 2 * name_line_h) ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_DOT); lv_obj_set_width(nm, name_w); } lv_label_set_text(nm, san); lv_obj_set_style_text_font(nm, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); if (mid_cols) lv_obj_align(nm, LV_ALIGN_TOP_LEFT, name_x, 5); else lv_obj_align(nm, LV_ALIGN_LEFT_MID, name_x, 0); // Heard column (12 px — smaller than the 14 px name — and pushed right). // Explicit column width so a longer age never bleeds into the Location // column; left-aligned within it like the rest of the row. // mid_cols: line 2, left-aligned under the name instead of a right column. lv_obj_t* hl = lv_label_create(rb); lv_label_set_text(hl, age_buf); lv_obj_set_style_text_font(hl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(hl, hrd_w); lv_label_set_long_mode(hl, LV_LABEL_LONG_CLIP); if (mid_cols) lv_obj_align(hl, LV_ALIGN_TOP_LEFT, name_x, row2_y); else lv_obj_align(hl, LV_ALIGN_LEFT_MID, heard_x, 0); s_contacts_ctx[k].age_lbl = hl; // in-place 60s age updates (#82) s_contacts_rows_built = k + 1; // Location column — explicit width so "1.4km" / "390ft" fit and don't // overrun the favourite-star slot on the wide layout. // mid_cols: line 2, right after the age. lv_obj_t* ll = lv_label_create(rb); lv_label_set_text(ll, loc_buf); lv_obj_set_style_text_font(ll, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(ll, lv_color_hex(e.has_gps ? COLOR_SUB : 0x4A4E54), LV_PART_MAIN); lv_obj_set_width(ll, loc_w); lv_label_set_long_mode(ll, LV_LABEL_LONG_CLIP); if (mid_cols) lv_obj_align(ll, LV_ALIGN_TOP_LEFT, name_x + hrd_w + col_gap, row2_y); else lv_obj_align(ll, LV_ALIGN_LEFT_MID, loc_x, 0); // Favorite star if (e.is_fav) { lv_obj_t* star = lv_label_create(rb); lv_label_set_text(star, TOUCH_SYM_STAR_BIG); lv_obj_set_style_text_font(star, &star_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(star, lv_color_hex(0xC9A24A), LV_PART_MAIN); lv_obj_align(star, LV_ALIGN_LEFT_MID, star_x, 0); } } // Overflow notice: more contacts matched than the 128-row render budget — surface // it so the tail isn't silently hidden. Search narrows the list to any of them, // and changing the sort floats different ones to the top. #73. { const int render_cap = CONTACTS_CTX_MAX; if (n_entries > render_cap) { char more[56]; snprintf(more, sizeof(more), "+%d more — search to narrow the list", n_entries - render_cap); lv_obj_t* lm = lv_list_add_text(g_lv.contacts_list, more); lv_obj_set_style_text_color(lm, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_align(lm, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_pad_all(lm, 8, LV_PART_MAIN); } } if (n_entries == 0) { lv_obj_t* l = lv_list_add_text(g_lv.contacts_list, "No matching contacts"); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_pad_all(l, 20, LV_PART_MAIN); } } static void refreshThreadLists() { refreshChatList(g_lv.dm); } // Rolling diagnostic log static void pushDiagLine(const char* message) { if (!message || !message[0]) return; // Identity / radio boot line: pin to a dedicated label so it cannot scroll // out from under the operator. Without this, the most important diagnostic // (does our pubkey prefix stay stable across flashes?) is the FIRST thing // to disappear once the diag ring fills with RX/TX chatter. if (message[0] == 'I' && message[1] == 'D' && message[2] == ' ') { strncpy(s_diag_id_pinned, message, sizeof(s_diag_id_pinned) - 1); s_diag_id_pinned[sizeof(s_diag_id_pinned) - 1] = '\0'; if (g_lv.diag_id_label) { lv_label_set_text(g_lv.diag_id_label, s_diag_id_pinned); } return; } strncpy(s_diag_ring[s_diag_line % DIAG_LINES], message, DIAG_COLS - 1); s_diag_ring[s_diag_line % DIAG_LINES][DIAG_COLS - 1] = '\0'; s_diag_line++; if (!g_lv.diag_label) return; char block[DIAG_LINES * DIAG_COLS + 32]; int p = 0; int first = (s_diag_line > DIAG_LINES) ? (s_diag_line - DIAG_LINES) : 0; for (int i = first; i < s_diag_line && p < (int)sizeof(block) - 2; i++) { p += snprintf(block + p, sizeof(block) - static_cast<size_t>(p), "%s\n", s_diag_ring[i % DIAG_LINES]); } lv_label_set_text(g_lv.diag_label, p > 0 ? block : "—"); } static void pushLogLine(char ring[][RXLOG_COLS], int& line_counter, const char* message) { if (!ring || !message || !message[0]) return; strncpy(ring[line_counter % RXLOG_LINES], message, RXLOG_COLS - 1); ring[line_counter % RXLOG_LINES][RXLOG_COLS - 1] = '\0'; ++line_counter; } static void collectLogText(const char ring[][RXLOG_COLS], int line_counter, char* out, size_t out_cap) { if (!out || out_cap == 0) return; out[0] = '\0'; if (!ring || line_counter <= 0) return; int p = 0; int first = (line_counter > RXLOG_LINES) ? (line_counter - RXLOG_LINES) : 0; for (int i = first; i < line_counter && p < static_cast<int>(out_cap) - 2; ++i) { p += snprintf(out + p, out_cap - static_cast<size_t>(p), "%s\n", ring[i % RXLOG_LINES]); } if (p < 0 || p >= static_cast<int>(out_cap)) out[out_cap - 1] = '\0'; } static void setLogModeButtonStyles() { if (!g_set_modal.log_rx_btn || !g_set_modal.log_raw_btn) return; const bool rx_selected = (g_set_modal.log_mode == 0); lv_obj_set_style_bg_color( g_set_modal.log_rx_btn, lv_color_hex(rx_selected ? COLOR_ACCENT : 0x2A3848), LV_PART_MAIN); lv_obj_set_style_text_color( g_set_modal.log_rx_btn, lv_color_hex(rx_selected ? 0x031315 : COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_color( g_set_modal.log_raw_btn, lv_color_hex(rx_selected ? 0x2A3848 : COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_color( g_set_modal.log_raw_btn, lv_color_hex(rx_selected ? COLOR_TEXT : 0x031315), LV_PART_MAIN); } static void refreshLogModalView() { if (!g_set_modal.log_view_ta || g_set_modal.kind != SettingsModalKind::Log) return; static const size_t kLogTextSz = (RXLOG_LINES * RXLOG_COLS) + 64; static char* text = nullptr; // log-view scratch — lazy PSRAM (internal fallback): ~2.3 KB off internal DRAM if (!text) { text = (char*)heap_caps_malloc(kLogTextSz, MALLOC_CAP_SPIRAM); if (!text) text = (char*)heap_caps_malloc(kLogTextSz, MALLOC_CAP_8BIT); } if (!text) return; if (g_set_modal.log_mode == 0) { collectLogText(s_rxlog_ring, s_rxlog_line, text, kLogTextSz); } else { collectLogText(s_rawlog_ring, s_rawlog_line, text, kLogTextSz); } lv_textarea_set_text(g_set_modal.log_view_ta, text[0] ? text : "No entries yet."); lv_textarea_set_cursor_pos(g_set_modal.log_view_ta, LV_TEXTAREA_CURSOR_LAST); setLogModeButtonStyles(); } static void logModeRxCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; g_set_modal.log_mode = 0; refreshLogModalView(); } static void logModeRawCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; g_set_modal.log_mode = 1; refreshLogModalView(); } #if CAP_TRACKBALL // Poll the trackball each UI tick: move the cursor by the accumulated motion and // show/hide it on activity. The centre-click -> touch handling lives in the // PIN_USER_BTN block of UITask::loop() (it needs screen on/off context). // ---- Trackball map panning (Map tab only) ---- // Each motion tick slides the pan layer live (cheap); when motion stops the // accumulated shift is committed to a new center + a tile/marker re-render. static int s_map_tb_tx = 0, s_map_tb_ty = 0; // accumulated screen-px shift static bool s_map_tb_pan_pending = false; static unsigned long s_map_tb_last_motion = 0; static void mapTrackballFinalizePan() { s_map_tb_pan_pending = false; if (s_map_tb_tx == 0 && s_map_tb_ty == 0) return; double cwx, cwy; latLonToWorldPx(s_map_center_lat, s_map_center_lon, s_map_zoom, &cwx, &cwy); double nlat, nlon; worldPxToLatLon(cwx + s_map_tb_tx, cwy + s_map_tb_ty, s_map_zoom, &nlat, &nlon); s_map_center_lat = nlat; s_map_center_lon = nlon; s_map_tb_tx = 0; s_map_tb_ty = 0; renderMapTiles(); // also snaps the pan layer back to (0,0) renderMapMarkers(); refreshMapInfoLabel(); } // Hit-test at (x, y) to find the deepest visible widget, then climb its ancestors // to find the first one that is scrollable and has room to move in (ox, oy). // Searches lv_layer_top() first (overlays like the app drawer live there), then // falls back to lv_scr_act() for regular tab content. static lv_obj_t* tbFindScrollableAt(lv_coord_t x, lv_coord_t y, float ox, float oy) { lv_obj_t* roots[2] = { lv_layer_top(), lv_scr_act() }; for (int r = 0; r < 2; r++) { lv_obj_t* root = roots[r]; lv_obj_t* hit = root; for (bool descend = true; descend; ) { descend = false; for (int i = (int)lv_obj_get_child_cnt(hit) - 1; i >= 0; i--) { lv_obj_t* c = lv_obj_get_child(hit, i); if (lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN)) continue; lv_area_t a; lv_obj_get_coords(c, &a); if (x >= a.x1 && x <= a.x2 && y >= a.y1 && y <= a.y2) { hit = c; descend = true; break; } } } if (hit == root) continue; // nothing visible in this layer at (x,y), try next // Something is visible in this layer — it owns the interaction. Walk up for a // scrollable but do NOT fall through to a lower layer even if none is found: // a modal/dialog should block scrolling of whatever is behind it. for (lv_obj_t* o = hit; o && o != root; o = lv_obj_get_parent(o)) { if (!lv_obj_has_flag(o, LV_OBJ_FLAG_SCROLLABLE)) continue; bool cx = (ox > 0 && lv_obj_get_scroll_right(o) > 0) || (ox < 0 && lv_obj_get_scroll_left(o) > 0); bool cy = (oy > 0 && lv_obj_get_scroll_bottom(o) > 0) || (oy < 0 && lv_obj_get_scroll_top(o) > 0); if (cx || cy) return o; } return nullptr; // layer had visible content but nothing scrollable in this direction } return nullptr; } static void updateTrackball(unsigned long now) { if (!s_tb_cursor) return; const int W = lv_disp_get_hor_res(nullptr); const int H = lv_disp_get_ver_res(nullptr); int dx = 0, dy = 0; const bool moved = tdeckTrackballReadMotion(&dx, &dy); if (s_tb_reverse) { dx = -dx; dy = -dy; } // user opted to invert scrollball direction // ---- Snake game ---- // While Snake is open the trackball steers it (no soft cursor on that page). if (SnakeGame::isOpen() || luaAppIsOpen()) { if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); s_tb_click_press = false; if (moved && (dx != 0 || dy != 0)) { if (luaAppIsOpen()) luaAppSteer(dx, dy); else SnakeGame::steer(dx, dy); if (g_lv.task) g_lv.task->noteUserInput(); } return; } // Mention picker selector. It remains a NAV_SKIP overlay so the composer's // focus/edit state is stable; trackball motion walks its private highlight. if (s_mentionnav_active && s_mentionbox) { s_tb_click_press = false; // the button path confirms; never inject a pointer tap if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); if (moved && (dx != 0 || dy != 0)) { const int adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; mentionNavMove((adx >= ady ? dx : dy) > 0 ? 1 : -1); if (g_lv.task) g_lv.task->noteUserInput(); } return; } // ---- Emoji picker selector mode ---- // While the emoji sheet is open the trackball drives a grid highlight instead // of the soft cursor: each motion step moves one cell, the centre click // inserts (handled in the PIN_USER_BTN block). The cursor is hidden so it // can't also click through to the grid. if (s_emoji_sheet) { s_tb_click_press = false; // selector consumes clicks; don't inject a tap if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); if (moved && (dx != 0 || dy != 0)) { emojiSelectorMove(dx, dy); if (g_lv.task) g_lv.task->noteUserInput(); } return; } // Flush a pending map pan if the user navigated away from the Map tab. const bool on_map = (getActiveTab() == MAP_TAB_INDEX); if (!on_map && s_map_tb_pan_pending) mapTrackballFinalizePan(); if (on_map) { // On the Map tab the trackball pans the map instead of driving the cursor. // The cursor is hidden and its click does nothing here (tap markers with a // finger); the click flag is consumed so lvglTouchRead won't inject it. s_tb_click_press = false; if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); if (moved && (dx != 0 || dy != 0)) { const int sdx = dx * kTbCursorStepPx; const int sdy = dy * kTbCursorStepPx; shiftMapChildren(-sdx, -sdy); // roll right -> view pans east s_map_tb_tx += sdx; s_map_tb_ty += sdy; s_map_tb_pan_pending = true; s_map_tb_last_motion = now; if (g_lv.task) g_lv.task->noteUserInput(); } else if (s_map_tb_pan_pending && (now - s_map_tb_last_motion) > 220) { mapTrackballFinalizePan(); } return; } // ---- D-pad focus-nav mode (trackball drives the UI selection, not a cursor) ---- // When enabled (Settings -> Keyboard -> "Trackball navigates UI", default ON) the trackball // moves the focus ring with the SAME 2D logic the Tanmatsu arrows use (navMoveDir) and the // centre click selects the focused item. The focus group is kept populated by // navMaybeRebuild() in the loop (gated on s_kbd_nav || s_tb_nav). if (s_tb_nav) { if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); // no soft cursor in D-pad mode // Throttle the high-res trackball to one focus step per short interval (a single roll // shouldn't fly across the screen); step on the dominant axis. static unsigned long s_tb_nav_last_motion = 0; if (moved && (dx != 0 || dy != 0)) { s_tb_nav_last_motion = now; static unsigned long s_tb_nav_step_ms = 0; if ((unsigned long)(now - s_tb_nav_step_ms) >= 110) { s_tb_nav_step_ms = now; const int adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; if (adx >= ady) navMoveDir(dx > 0 ? NAV_RIGHT : NAV_LEFT); else navMoveDir(dy > 0 ? NAV_DOWN : NAV_UP); if (g_lv.task) g_lv.task->noteUserInput(); } } // Centre click = select the focused element (chat-bubble menu, else activate it). Edge- // triggered so a held click fires once; lvglTouchRead is gated off below in this mode so // the click never also injects a touch at the (hidden) cursor. static bool s_tb_nav_click_prev = false; // Brief click-suppression window after the ball last moved: an accidental click mid-scroll // doesn't fire (a deliberate click ≥350 ms after you stop scrolling still does). Mirrors the // cursor mode, which doesn't deliver a tap while the ball is actively moving. if (s_tb_click_press && !s_tb_nav_click_prev && (now - s_tb_nav_last_motion) >= 350) { lv_obj_t* f = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (f && lv_obj_is_valid(f)) { // Defer the activate to the next lv_timer_handler (a shallow stack). Sending CLICKED // synchronously here runs the focused item's handler — which can build a whole // settings page / modal — nested DEEP on the already-deep loopTask stack and overflow // it (the smashed-stack panic). lv_async_call runs it in the timer context instead. lv_async_call([](void* p){ lv_obj_t* o = (lv_obj_t*)p; if (o && lv_obj_is_valid(o)) lv_event_send(o, LV_EVENT_CLICKED, nullptr); }, f); if (g_lv.task) g_lv.task->noteUserInput(); } } s_tb_nav_click_prev = s_tb_click_press; return; } // ---- Cursor mode (the trackball is a soft mouse cursor; "Trackball navigates UI" off) ---- // Accumulate raw motion into the TARGET at full sensitivity (so total travel // per roll is unchanged); the rendered cursor eases toward it below. if (moved) { s_tb_target_x += (float)(dx * kTbCursorStepPx); s_tb_target_y += (float)(dy * kTbCursorStepPx); // Clamp and record how much overshot each edge. float ox = 0.0f, oy = 0.0f; if (s_tb_target_x < 0) { ox = s_tb_target_x; s_tb_target_x = 0.0f; } else if (s_tb_target_x > W - 1) { ox = s_tb_target_x - (W - 1); s_tb_target_x = (float)(W - 1); } if (s_tb_target_y < 0) { oy = s_tb_target_y; s_tb_target_y = 0.0f; } else if (s_tb_target_y > H - 1) { oy = s_tb_target_y - (H - 1); s_tb_target_y = (float)(H - 1); } // When edge-scroll is on, hit-test inside the content area to find the scroll target. // We inset the search coordinate past the status/tab bars so we land on the content // list, not on the chrome. scroll_by(0,+dy) moves children DOWN = reveals earlier // content (scroll up), so we negate oy/ox. Cap to available room because scroll_by // has no internal bounds check. if (s_tb_edge_scroll && (ox != 0.0f || oy != 0.0f)) { lv_coord_t sx = s_tb_cursor_x; // For vertical overflow, search at the centre of the content area rather than // near the edge: the top/bottom pixel of the content area may be a header or // footer widget, not the scrollable list itself. lv_coord_t sy = (oy != 0.0f) ? (lv_coord_t)((STATUSBAR_H + (H - TABBAR_H)) / 2) : s_tb_cursor_y; lv_obj_t* sc = tbFindScrollableAt(sx, sy, ox, oy); if (sc) { lv_coord_t nsx = -(lv_coord_t)ox; lv_coord_t nsy = -(lv_coord_t)oy; if (nsy > 0) nsy = LV_MIN(nsy, lv_obj_get_scroll_top(sc)); else if (nsy < 0) nsy = LV_MAX(nsy, -lv_obj_get_scroll_bottom(sc)); if (nsx > 0) nsx = LV_MIN(nsx, lv_obj_get_scroll_left(sc)); else if (nsx < 0) nsx = LV_MAX(nsx, -lv_obj_get_scroll_right(sc)); if (nsx || nsy) lv_obj_scroll_by(sc, nsx, nsy, LV_ANIM_OFF); } } s_tb_last_active_ms = now; if (g_lv.task) g_lv.task->noteUserInput(); } if (s_tb_click_press) s_tb_last_active_ms = now; // stay visible while clicking // Framerate-independent low-pass toward the target. k = dt/tau (capped at 1), // so a diagonal roll whose two axes pulse at different ticks renders as one // smooth glide instead of a horizontal/vertical staircase. unsigned long dt = now - s_tb_prev_ms; s_tb_prev_ms = now; if (dt > 100) dt = 100; float k = (float)dt / kTbCursorSmoothMs; if (k > 1.0f) k = 1.0f; s_tb_render_x += (s_tb_target_x - s_tb_render_x) * k; s_tb_render_y += (s_tb_target_y - s_tb_render_y) * k; s_tb_cursor_x = (int)(s_tb_render_x + 0.5f); s_tb_cursor_y = (int)(s_tb_render_y + 0.5f); const bool visible = (now - s_tb_last_active_ms) < kTbCursorHideMs; if (visible) { lv_obj_set_pos(s_tb_cursor, s_tb_cursor_x - kTbCursorDiameter / 2, s_tb_cursor_y - kTbCursorDiameter / 2); lv_obj_clear_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_tb_cursor); } else if (!lv_obj_has_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN)) { lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); } } #endif #if defined(HAS_PAGER_ENCODER) || defined(HAS_PAGER_KEYBOARD) // "Back", extending the T-Deck/Tanmatsu back-key ladder: a popup/sheet on top // closes first, then a full-screen AppPage (Store, Lua apps, tools), then an // open chat/channel detail, then Home (the H mnemonic is deliberately inactive // inside those nested views), else plain ESC. Shared by the rotary encoder's // long-press (updatePagerEncoder) and the keyboard's Backspace-hold alternative // (updatePagerBackspaceHold) so both agree exactly. static void pagerNavGoBack() { if (anyPopupOpen()) hwKeyDismissTopPopup(); else if (s_apppage_close) s_apppage_close(); else if (LvChatPanel* cp = navOpenChatPanel()) closeChatPanel(cp); else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX); else navPushTap(LV_KEY_ESC); } #endif #if defined(HAS_PAGER_KEYBOARD) // Backspace press-and-hold = the same "back" as the encoder's long-press — // an alternative for users wary of wearing out the rotary encoder. Same // 1000 ms state machine as the encoder's click handling below; a short tap // still deletes a character as normal (PagerKeyboard ring-pushes '\b' on // press, unchanged). static void updatePagerBackspaceHold(unsigned long now) { if (g_lv.task && g_lv.task->isScreenOff()) return; const bool held = pagerKeyboardBackspaceHeld(); static constexpr uint32_t kLongPressMs = 1000; static bool s_was_held = false; static uint32_t s_press_start = 0; static bool s_long_fired = false; if (held && !s_was_held) { s_press_start = now; s_long_fired = false; } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { pagerNavGoBack(); if (g_lv.task) g_lv.task->noteUserInput(); s_long_fired = true; } s_was_held = held; } // Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has // no UI visibility): toggles Caps Lock while actually editing a text field // (the field is where "Caps Lock" means anything) and is a deliberate no-op // everywhere else (Home-jump duty moved to Alt+Backspace below, so there's no // longer a reason to make this chord do anything outside a field — Caps Lock // silently flipping with no field to see it in was reported as // surprising/purposeless before this split). static void updatePagerAltShiftChord() { if (!pagerKeyboardConsumeAltShiftChord()) return; lv_obj_t* ta_focused = lv_keyboard_get_textarea(g_lv.keyboard); lv_obj_t* ta = (ta_focused && s_nav_group && lv_group_get_focused(s_nav_group) == ta_focused) ? ta_focused : nullptr; if (!ta) return; pagerKeyboardToggleCaps(); if (g_lv.task) g_lv.task->noteUserInput(); } // Alt(Fn)+Backspace chord: jump straight Home, unconditionally -- unlike // Alt+Shift above, this one is NOT context-dependent (works whether or not a // field is being edited, per explicit request). Fires instead of a normal // Backspace press: PagerKeyboard.cpp suppresses the '\b' ring-push and the // hold-to-back/hold-to-unlock tracking entirely for an Alt-held Backspace // press, so there's no double-action to guard against here. static void updatePagerAltBackspaceChord() { if (!pagerKeyboardConsumeAltBackspaceChord()) return; // The accent/@-mention pickers live on lv_layer_top(), outside the tab // content navGoToMainTab() switches away from -- close them explicitly // first so a stray overlay doesn't keep floating over the Home screen. accentBoxHide(); mentionBoxHide(); navGoToMainTab(HOME_TAB_INDEX); if (g_lv.task) g_lv.task->noteUserInput(); } // ---- Spacebar hold-to-lock (mirrors the T-Deck's spacebar lock) ------------- // The T-Deck keyboard can't detect a real key-up, so it fakes a hold with a // press-then-1s-countdown; the TCA8418 here reports genuine press/release, so // this is an actual hold, timed the same as pagerNavGoBack()'s gestures, with // a live progress bar (T-Deck shows a 3-2-1 countdown instead since its lock // isn't a real hold). static lv_obj_t* s_pager_locking_popup = nullptr; static lv_obj_t* s_pager_locking_bar = nullptr; static void pagerLockingPopupHide() { if (s_pager_locking_popup) { popupClose(&s_pager_locking_popup); s_pager_locking_bar = nullptr; } } static void pagerLockingPopupShow() { if (s_pager_locking_popup) return; s_pager_locking_popup = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_pager_locking_popup); lv_obj_set_size(s_pager_locking_popup, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_set_pos(s_pager_locking_popup, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_pager_locking_popup, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_pager_locking_popup, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_pager_locking_popup, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* card = lv_obj_create(s_pager_locking_popup); lv_obj_remove_style_all(card); lv_obj_set_size(card, 180, 74); lv_obj_center(card); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 12, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, TR("Locking\xE2\x80\xA6")); // Locking… lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); s_pager_locking_bar = lv_bar_create(card); lv_obj_set_size(s_pager_locking_bar, 140, 8); lv_obj_align(s_pager_locking_bar, LV_ALIGN_BOTTOM_MID, 0, -14); lv_obj_set_style_bg_color(s_pager_locking_bar, lv_color_hex(0x2A2D31), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_pager_locking_bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(s_pager_locking_bar, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_opa(s_pager_locking_bar, LV_OPA_COVER, LV_PART_INDICATOR); lv_bar_set_range(s_pager_locking_bar, 0, 1000); lv_bar_set_value(s_pager_locking_bar, 0, LV_ANIM_OFF); } static void updatePagerSpaceHold(unsigned long now) { // Never engage mid-typing (space just types normally there) or once already // locked/off (nothing left to do -- updatePagerBackspaceUnlockHold owns the // reverse direction). if ((g_lv.task && (g_lv.task->isScreenOff() || g_lv.task->isManualLock())) || navFocusedTextarea()) { pagerLockingPopupHide(); return; } const bool held = pagerKeyboardSpaceHeld(); static constexpr uint32_t kLongPressMs = 1000; static bool s_was_held = false; static uint32_t s_press_start = 0; static bool s_long_fired = false; if (held && !s_was_held) { s_press_start = now; s_long_fired = false; } else if (held && !s_long_fired) { const uint32_t elapsed = now - s_press_start; // A bare space tap is otherwise a total no-op here (unfocused, no field) -- // hold off showing the popup for a beat so an ordinary quick tap doesn't // flash it on and off. if (elapsed >= 150) { pagerLockingPopupShow(); if (s_pager_locking_bar) lv_bar_set_value(s_pager_locking_bar, elapsed > kLongPressMs ? kLongPressMs : elapsed, LV_ANIM_OFF); } if (elapsed >= kLongPressMs) { s_long_fired = true; pagerLockingPopupHide(); if (g_lv.task) g_lv.task->lockScreen(); } } else if (!held) { pagerLockingPopupHide(); } s_was_held = held; } // ---- Backspace hold-to-unlock (mirrors the T-Deck's trackball hold-to- // unlock) -- only meaningful while hard-locked, so it never conflicts with // updatePagerBackspaceHold()'s "back" gesture above (that one already exits // early whenever the screen is off, and lockScreen() never sets _manual_lock // without also turning the screen off, so the two are mutually exclusive). // Must NOT early-return on isScreenOff() -- unlike every other pager input // poller, this one has to keep working while the screen is dark, since that's // exactly the state it's meant to end. static void updatePagerBackspaceUnlockHold(unsigned long now) { if (!g_lv.task || !g_lv.task->isManualLock()) return; const bool held = pagerKeyboardBackspaceHeld(); static constexpr uint32_t kLongPressMs = 1000; static bool s_was_held = false; static uint32_t s_press_start = 0; if (held && !s_was_held) { s_press_start = now; } else if (held && (now - s_press_start) >= kLongPressMs) { g_lv.task->unlockScreen(); s_was_held = false; // consume -- don't immediately re-trigger were this called again while still held return; } s_was_held = held; } // Keyboard backlight: off/on/auto (s_kb_bl_mode, shared with every CAP_KEYBOARD // board) applied to the physical GPIO46 LEDC PWM. Unlike the T-Deck's slider, // this board has no brightness curve to honour -- the backlight is a simple // full-on/off strip under the keys, so "on" is just max PWM duty (255). Runs // UNCONDITIONALLY, even while the screen is off/locked -- forcing it dark in // that state is exactly its job here, so (unlike updatePagerBackspaceHold, // which must NOT act while off) it can't be skipped the same way. static void updatePagerKbBacklight(unsigned long now) { uint8_t kb_bl = 0; if (s_kb_bl_mode == 1) kb_bl = 255; else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = 255; if (g_lv.task && (g_lv.task->isScreenOff() || g_lv.task->isManualLock())) kb_bl = 0; static uint8_t s_last = 0xFF; // only hit the LEDC write when the value actually changes if (kb_bl != s_last) { s_last = kb_bl; pagerKeyboardSetBacklight(kb_bl); } } #endif #if defined(HAS_PAGER_ENCODER) // A focus step normally asks LVGL to reveal the next object. On the Pager that // makes a message or popup body taller than the viewport impossible to read: // the next detent abandons it and reveals the following object. Consume detents // as page-scrolls until the focused oversized object reaches its edge. static bool pagerEncoderScrollOversizedFocused(bool up) { lv_obj_t* focused = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (!focused || !lv_obj_is_valid(focused)) return false; for (lv_obj_t* scroller = lv_obj_get_parent(focused); scroller; scroller = lv_obj_get_parent(scroller)) { if (!lv_obj_has_flag(scroller, LV_OBJ_FLAG_SCROLLABLE)) continue; lv_area_t viewport; lv_area_t item; lv_obj_get_coords(scroller, &viewport); lv_obj_get_coords(focused, &item); viewport.y1 += lv_obj_get_style_pad_top(scroller, LV_PART_MAIN); viewport.y2 -= lv_obj_get_style_pad_bottom(scroller, LV_PART_MAIN); const lv_coord_t view_h = viewport.y2 - viewport.y1 + 1; const lv_coord_t item_h = item.y2 - item.y1 + 1; if (view_h <= 0 || item_h <= view_h) return false; const bool unread_edge = up ? (item.y1 < viewport.y1) : (item.y2 > viewport.y2); const lv_coord_t room = up ? lv_obj_get_scroll_top(scroller) : lv_obj_get_scroll_bottom(scroller); if (!unread_edge || room <= 0) return false; lv_coord_t step = view_h * 2 / 3; if (step < 24) step = 24; if (step > room) step = room; lv_obj_scroll_by(scroller, 0, up ? step : -step, LV_ANIM_ON); return true; } return false; } // T-LoRa Pager rotary encoder: a single linear nav axis (not 2D like the // trackball, so none of updateTrackball()'s game/emoji-grid/cursor special // cases apply) — each detent moves focus one step via the same navFifo the // KEYPAD indev already drains (tanmatsuKeypadRead), and a click is ENTER // (short) or ESC (long), exactly as specced. 1000 ms long-press threshold // matches the existing MomentaryButton convention used elsewhere (PIN_USER_BTN). // Plain-turn clamp for the open chat's virtualized message list. The focus // group only ever mirrors the MATERIALIZED bubbles (navMaybeRebuild re-collects // on every window change), and materializing more history is scroll-driven — // so a turn on the edge bubble stepped focus clean out of the list (header // actions above, composer below) even with plenty of un-loaded history in that // direction. When that's about to happen and more messages exist, free-scroll // the list instead (same navScrollFocused the Alt+turn branch uses): the // scroll fires the virtualization render, the neighbor bubble joins the nav // group via the tree-signature rebuild, and the next detent walks onto it. // Focus only leaves the list at the TRUE oldest/newest message. // Returns true when the detent was consumed as a scroll. static bool pagerEncoderChatEdgeScroll(bool up) { LvChatPanel* cp = navOpenChatPanel(); if (!cp || !cp->msgs || s_chat_virt.panel != cp || s_chat_virt.n <= 0) return false; // A scroll-load from a previous detent is still in flight (render + focus // re-aim haven't landed yet). Focus may transiently sit OFF the list — the // clear deletes the focused row and LVGL hands focus to a survivor — so a // group step now is exactly the escape this clamp exists to prevent. Fold // the detent into the pending target instead: fast turning accumulates // steps, and the render's re-aim lands focus on the accumulated message. // The timestamp expires a stale target so a render that never fires (e.g. // the scroll had no room) can't permanently swallow encoder nav. if (s_pager_chat_focus_i >= 0) { if (millis() - s_pager_chat_focus_ms > 600) { s_pager_chat_focus_i = -1; // stale — fall through to normal handling } else { int t = s_pager_chat_focus_i + (up ? -1 : +1); if (t < 0) t = 0; if (t > s_chat_virt.n - 1) t = s_chat_virt.n - 1; s_pager_chat_focus_i = t; s_pager_chat_focus_ms = millis(); navScrollFocused(up); // keep driving the load toward the target return true; } } lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (!foc || lv_obj_get_parent(foc) != cp->msgs) return false; // focus isn't on a bubble // Edge test: any other nav-collectable sibling (clickable, visible, not // NAV_SKIP — navCollect's own harvest rule) on the turn side? Then the // normal focus step stays inside the list and no clamp is needed. The virt // spacer is non-clickable, so it never counts. const uint32_t nch = lv_obj_get_child_cnt(cp->msgs); const uint32_t fi = lv_obj_get_index(foc); const uint32_t lo = up ? 0 : fi + 1; const uint32_t hi = up ? fi : nch; for (uint32_t i = lo; i < hi; i++) { lv_obj_t* c = lv_obj_get_child(cp->msgs, i); if (c && lv_obj_has_flag(c, LV_OBJ_FLAG_CLICKABLE) && !lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_flag(c, NAV_SKIP_FLAG)) return false; } // On the edge bubble — anything more to load that way? Window-edge indices // catch un-materialized history; the scroll-room checks catch a viewport // that still has materialized-but-off-glass content. const bool more = up ? (s_chat_virt.last_i0 > 0 || lv_obj_get_scroll_y(cp->msgs) > 0) : (s_chat_virt.last_i1 < s_chat_virt.n - 1 || chatVirtAwayFromBottom(cp)); if (!more) return false; // true end of history — let focus leave the list // Aim the post-render focus at the NEIGHBOR the user is turning toward (the // row objects are about to be deleted/recreated, so an object pointer would // dangle — the logical index survives the rebuild). { const intptr_t ud = reinterpret_cast<intptr_t>(lv_obj_get_user_data(foc)); int t = (ud >= 0 ? (int)ud : (up ? s_chat_virt.last_i0 : s_chat_virt.last_i1)) + (up ? -1 : +1); if (t < 0) t = 0; if (t > s_chat_virt.n - 1) t = s_chat_virt.n - 1; s_pager_chat_focus_i = t; s_pager_chat_focus_ms = millis(); } navScrollFocused(up); // animated scroll → virtualization materializes the neighbor return true; } // Bottom-most (newest) materialized bubble row — the natural "re-enter the // message list" landing spot for the composer-boundary overrides below. static lv_obj_t* pagerChatBottomBubble(LvChatPanel* cp) { lv_obj_t* best = nullptr; intptr_t best_i = -1; const uint32_t n = lv_obj_get_child_cnt(cp->msgs); for (uint32_t i = 0; i < n; i++) { lv_obj_t* c = lv_obj_get_child(cp->msgs, i); if (!c || !lv_obj_has_flag(c, LV_OBJ_FLAG_CLICKABLE) || lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN)) continue; const intptr_t ud = reinterpret_cast<intptr_t>(lv_obj_get_user_data(c)); if (ud >= 0 && ud > best_i) { best_i = ud; best = c; } // rows carry their logical index } return best; } // Composer-cluster boundary overrides for the plain encoder turn. The chat's // focus ring should read: bubbles → quick-reply △ → emoji → textarea → send → // top-bar items — LVGL's group order already walks the interior of the // cluster correctly (creation order: △, emoji, textarea, send), so only the // two EDGES are overridden: NEXT off the send button (which otherwise WRAPS // to the top-most loaded bubble, yanking the view to the top of the window) // hops to the top-bar items, and PREV off the △ chip lands on the NEWEST // bubble explicitly. "Edge" is detected structurally — no other collectable // (clickable/visible/non-NAV_SKIP) composer_row sibling beyond the focused // one in the turn direction — NOT by index-vs-textarea, which lumped the // emoji chip in with the △ and skipped it entirely on the way out (reported: // emoji → PREV jumped straight into the bubbles instead of the △). // The chips/send are makeChatDetail() locals, hence the structural detection. // Returns true when the detent was consumed by an explicit focus. static bool pagerChatComposerNav(bool up) { LvChatPanel* cp = navOpenChatPanel(); if (!cp || !cp->composer_row || !cp->composer_ta || !cp->msgs) return false; lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (!foc || lv_obj_get_parent(foc) != cp->composer_row) return false; // Edge test: any other collectable sibling on the turn side keeps the step // inside the cluster (the natural group order handles it). const uint32_t nch = lv_obj_get_child_cnt(cp->composer_row); const uint32_t fi = lv_obj_get_index(foc); const uint32_t lo = up ? 0 : fi + 1; const uint32_t hi = up ? fi : nch; for (uint32_t i = lo; i < hi; i++) { lv_obj_t* c = lv_obj_get_child(cp->composer_row, i); if (c && lv_obj_has_flag(c, LV_OBJ_FLAG_CLICKABLE) && !lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_flag(c, NAV_SKIP_FLAG)) return false; } if (!up) { // Send (→, the cluster's right edge) + NEXT: hop to the top-bar items // (the channel-settings gear). DM chats collect no bar items, so settle // on the newest bubble instead of the default wrap-to-top. if (g_statusbar.chan_gear && lv_obj_is_valid(g_statusbar.chan_gear) && !lv_obj_has_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_HIDDEN)) { s_nav_show = true; lv_group_focus_obj(g_statusbar.chan_gear); return true; } if (lv_obj_t* b = pagerChatBottomBubble(cp)) { s_nav_show = true; lv_group_focus_obj(b); return true; } return false; } // Quick-reply △ (the cluster's left edge) + PREV: straight into the message // list at its newest visible bubble. if (lv_obj_t* b = pagerChatBottomBubble(cp)) { s_nav_show = true; lv_group_focus_obj(b); return true; } return false; } static void updatePagerEncoder(unsigned long now) { int delta = pagerEncoderReadDelta(); const bool held = pagerEncoderClickHeld(); static bool s_remote_encoder_was_held = false; // The physical Remote UI screen presents one focused action: EXIT REMOTE. // Its drawn button is activated directly by pressing the encoder knob; do // not leak encoder turns or click events into the off-screen browser UI. if (s_remote_mode) { const bool pressed = held && !s_remote_encoder_was_held; s_remote_encoder_was_held = held; if (pressed) exitRemoteModeNow(); return; } s_remote_encoder_was_held = held; // No touch and no trackball on this board: the encoder (and the keyboard, // see the HAS_PAGER_KEYBOARD drain in loop()) are the ONLY way to wake an // idle-dimmed screen. Turning it just wakes -- it's swallowed here rather // than also acting as nav, matching the trackball's edge-triggered // wake+consume pattern (T-Deck/Tanmatsu always have touch or the trackball // as a separate wake path; this board doesn't, so the gap is fatal there // and had to be closed here instead of copied from either of them). // Deliberately NOT waking on a bare click (held, no turn): BOOT already // covers a dedicated wake button, and a click landed here right as the // screen came on would fall straight into the click-release logic below // as an ordinary short click on whatever was already focused -- reported // bug: waking via the encoder button selected "Skip" on the setup // wizard's welcome screen the instant the screen lit up. if (g_lv.task && g_lv.task->isScreenOff()) { // Hard-locked: a plain turn must NOT wake/unlock -- only holding Backspace // does (updatePagerBackspaceUnlockHold). Without this gate any idle turn // of the knob bypassed the lock entirely. if (delta != 0 && !g_lv.task->isManualLock()) g_lv.task->wakeScreen(); return; } // Screen already on: turning/clicking the encoder is real activity too, same as a // keypress -- without this the idle timer kept counting down through continuous // rotary navigation (nothing else on this board resets it; see handleHwKey()'s // matching TLORA_PAGER fix) and the screen dimmed mid-use. if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode // Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing // Alt afterward does not also arm the one-shot symbol layer. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); if (s_mentionnav_active) { // @-mention contact picker (handleHwKey()'s Fn+Space entry / mentionNavConfirm()): // captures the encoder exclusively while active, same priority as the accent // picker and an open dropdown below (mention and accent never show at once — // composerMentionRefresh() -- so there's no ordering conflict between them). const bool turned = (delta != 0); if (s_mentionbox_cell_n > 0) { for (; delta > 0; delta--) s_mentionnav_idx = (s_mentionnav_idx + 1) % (int)s_mentionbox_cell_n; for (; delta < 0; delta++) s_mentionnav_idx = (s_mentionnav_idx - 1 + (int)s_mentionbox_cell_n) % (int)s_mentionbox_cell_n; } if (turned) mentionNavRestyle(); } else if (s_accentnav_active) { // Accent-variant picker (handleHwKey()'s Fn+Space entry / accentNavConfirm()): // captures the encoder exclusively while active, same priority as an open // dropdown below. const bool turned = (delta != 0); if (s_accbox_cell_n > 0) { for (; delta > 0; delta--) s_accentnav_idx = (s_accentnav_idx + 1) % (int)s_accbox_cell_n; for (; delta < 0; delta++) s_accentnav_idx = (s_accentnav_idx - 1 + (int)s_accbox_cell_n) % (int)s_accbox_cell_n; } if (turned) accentNavRestyle(); } else if (navOpenDropdown()) { // An open dropdown captures the encoder: lv_dropdown's own key handling only // understands LV_KEY_UP/DOWN to move the highlighted row (+ENTER to confirm, // already wired below via the short-click path) — it ignores LV_KEY_NEXT/PREV, // which is what a plain turn sends in the else branch below. Without this // capture, turning the encoder while a dropdown list is open fell through to // NEXT/PREV, which the focus group instead consumes to move focus OFF the // dropdown — so the highlight never moved and the list was stuck showing // whatever was already selected (reported: opens fine, but turning doesn't // scroll to a choice). Mirrors Tanmatsu's identical navOpenDropdown() capture // in navPump() — see that comment for the lv_dropdown behavior this relies on. for (; delta > 0; delta--) navPushTap(LV_KEY_DOWN); for (; delta < 0; delta++) navPushTap(LV_KEY_UP); } else if (pagerKeyboardAltHeld() && navOnMainPage()) { // Alt (the bottom-left orange key, otherwise a hold-only modifier for the // keyboard's symbol layer — free to reuse here since it types nothing on // its own) + turn jumps directly between the 5 main tabs (Chats/Contacts/ // Home/Map/Settings). This remains a sequential alternative to the direct // M/C/H/A/S mnemonics. Scoped to the main-tab level (navOnMainPage()) — // reported bug: this used to fire even inside a settings sheet/chat, // silently abandoning it to jump tabs. for (; delta > 0; delta--) navSwitchTab(+1); for (; delta < 0; delta++) navSwitchTab(-1); } else if (pagerKeyboardAltHeld()) { // Inside a nested scrollable screen (settings detail sheet, chat, modal): Alt+turn // free-scrolls the page instead, like a touch drag. The keyboard-nav scroll-into-view // path (navFocusCb's lv_obj_scroll_to_view with LV_ANIM_OFF) doesn't reliably repaint // on this board — confirmed on hardware: focus and blur-to-save side effects reach // fields far down a page, but the glass keeps showing the old scroll position. // navScrollFocused()'s ANIMATED lv_obj_scroll_by should sidestep that: LVGL's anim // timer re-invalidates every tick instead of relying on a single one-shot invalidate, // the same reason touch-drag scrolling on the other boards never showed this bug. // After the turn, snap focus to whatever's now nearest the top of the visible // viewport so plain NEXT/PREV resumes from a sane, visible spot instead of a focus // stuck off-glass. const bool scrolled = (delta != 0); lv_obj_t* container = nullptr; for (; delta > 0; delta--) container = navScrollFocused(false); for (; delta < 0; delta++) container = navScrollFocused(true); if (scrolled) { if (!container) container = navNearestScrollableAncestor(lv_group_get_focused(s_nav_group)); if (container) navRefocusFirstVisible(container); } } else { // Plain turn: one focus step per detent — except in a chat, where two // overrides shape the ring: the composer-cluster boundaries hop to the // top-bar items / back into the bubbles (pagerChatComposerNav), and the // edge bubble of a still-loading history scrolls the list instead of // stepping focus out (pagerEncoderChatEdgeScroll). for (; delta > 0; delta--) { if (!pagerEncoderScrollOversizedFocused(false) && !pagerChatComposerNav(false) && !pagerEncoderChatEdgeScroll(false)) navPushTap(LV_KEY_NEXT); } for (; delta < 0; delta++) { if (!pagerEncoderScrollOversizedFocused(true) && !pagerChatComposerNav(true) && !pagerEncoderChatEdgeScroll(true)) navPushTap(LV_KEY_PREV); } } static constexpr uint32_t kLongPressMs = 1000; static bool s_was_held = false; static uint32_t s_press_start = 0; static bool s_long_fired = false; if (held && !s_was_held) { s_press_start = now; s_long_fired = false; } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { pagerNavGoBack(); // see pagerNavGoBack() above for the ladder + rationale s_long_fired = true; } else if (!held && s_was_held && !s_long_fired) { // Released before the long-press threshold -> short click, same as a // keyboard Enter: on a focused chat bubble that means the per-message // action menu (navEnterBubble), not a plain ENTER keypress -- mirrors // handleHwKey()'s Enter branch exactly so both inputs agree. if (s_mentionnav_active) mentionNavConfirm(); // picking a mention: confirm the highlighted one else if (s_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one else if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); } s_was_held = held; } #endif // Specific popups that float OVER the app drawer / a tab. Used to swallow swipe // gestures so they don't leak to the drawer or switch tabs underneath them. // Defined OUTSIDE HAS_TDECK_KEYBOARD so the ungated gesture handlers can use it. static bool drawerPopupOpen() { return s_siginfo_root || s_spec_root || s_mentions_root || s_power_menu || s_ct_sort_sheet || s_ctd_overlay || settingsModalIsOpen() #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || s_fullscreen_view #endif ; } // ---- Unified popup registry (the table lives at EOF, where every popup's // static root + closer is already in scope; see k_popup_registry). These thin // walkers are the only consumers: anyPopupOpen(), the Esc/red-X dismisser and // the tab-swipe blocker all read the SAME ordered table, so a popup registered // once behaves consistently everywhere. Adding a popup = one registry row. static bool popupRegistryAny(); static bool popupRegistryDismissTop(); static bool popupRegistryBlocksSwipe(); // True if any popup/modal is currently up (rows flagged PF_COUNT). static bool anyPopupOpen() { return popupRegistryAny(); } #if CAP_KEYBOARD // Close the topmost popup/modal (registry order = front-to-back priority), // like tapping its X / close button. Returns true if one was dismissed. static bool hwKeyDismissTopPopup() { return popupRegistryDismissTop(); } #endif // HAS_TDECK_KEYBOARD || HAS_TANMATSU (hwKeyDismissTopPopup) #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Keys that act as "close the popup" when no text field is focused. static bool isDismissKey(int key) { #if defined(HAS_TDECK_KEYBOARD) // The user picked the easy-to-find corner keys; there's no dedicated Esc on // this keyboard. switch (key) { case 0x08: // backspace case 0x0D: case 0x0A: // enter case 'p': case 'P': case 'q': case 'Q': case 'a': case 'A': return true; default: return false; } #elif defined(HAS_M9_KEYBOARD) // The dedicated Back/Home function keys must always stay with the firmware — // a Lua app may never swallow its own exit (see the forward in handleHwKey), // and this board has no touchscreen fallback to tap its way out with. They // are sentinel bytes the keyboard controller reserves, never typed text, so // there is no typing conflict. switch (key) { case M9_KEY_HW_BACK: case M9_KEY_HOME: return true; default: return false; } #else // The pager's corner letters are real, constantly-typed QWERTY keys (unlike // the T-Deck's sparser layout) -- treating them as "dismiss" would eat normal // typing. Its dismiss key is the rotary encoder's long-press (-> LV_KEY_ESC // via the nav FIFO, see updatePagerEncoder), not a keyboard key. (void)key; return false; #endif } // drawerPopupOpen() / anyPopupOpen() are defined just above the // HAS_TDECK_KEYBOARD block so the (ungated) gesture handlers can call them too. // Bottom-tab a key jumps to (no popup, no field focused), or -1. static int tabForKey(int key) { #if defined(TLORA_PAGER) // Pager mnemonic keys are only a bottom-bar shortcut at the top level. In // an open chat, settings detail, app page, or popup they remain inert so a // letter cannot unexpectedly abandon the inner screen. if (!navOnMainPage()) return -1; switch (key) { case 'm': case 'M': return CHAT_INBOX_TAB_INDEX; case 'c': case 'C': return CONTACTS_TAB_INDEX; case 'h': case 'H': return HOME_TAB_INDEX; case 'a': case 'A': return MAP_TAB_INDEX; case 's': case 'S': return SETTINGS_TAB_INDEX; default: return -1; } #else // Old fixed letter tab-jumps (h/m/c/l/s) removed — tab jumps are now the // programmable keyboard-nav hotkeys (navTabForHotkey, default E/R/T/U/I), active // only while keyboard navigation is on. (void)key; return -1; #endif } #endif // HAS_TDECK_KEYBOARD || HAS_PAGER_KEYBOARD || HAS_M9_KEYBOARD (keyboard helpers; the lock screen below is top-level) #if CAP_LOCK_SCREEN // ---- Lock screen ------------------------------------------------------------- // A full-screen overlay (wallpaper + live clock + lock-state text) shown while // the T-Deck is hard-locked. The wallpaper is a JPEG decoded to RGB565; the // clock and the "Screen locked" / "hold to unlock" text are drawn live here so // the image stays a plain wallpaper the user can swap. Lifecycle: lockScreen() // builds it (dark); a key/trackball press reveals it (backlight on) without // unlocking; holding the trackball ~1 s unlocks (see the loop's button block). static lv_obj_t* s_lock_root = nullptr; static lv_obj_t* s_lock_clock = nullptr; static uint8_t* s_lock_wall = nullptr; // decoded RGB565 (lvglPsramAlloc) static lv_img_dsc_t s_lock_wall_dsc; static int s_lock_clock_min = -1; // last minute drawn (redraw guard) static lv_obj_t* s_lock_unread = nullptr; // envelope + unread count under the clock (issue #93) static int s_lock_unread_n = -1; // last count drawn (redraw guard) static unsigned long s_lock_unread_ms = 0; // 1 Hz poll limiter #if defined(TLORA_PAGER) static lv_obj_t* s_lock_status = nullptr; // "Screen locked" -- tracked for lockscreenHide() cleanup static lv_obj_t* s_lock_hint = nullptr; // unlock hint -- tracked for lockscreenHide() cleanup #endif // How long the trackball must be held to unlock, in ms. static const unsigned long kLockUnlockHoldMs = 1000; // "Unlocking…" countdown popup (mirrors the spacebar lock countdown) shown // while the trackball is held on the lock screen. static lv_obj_t* s_unlock_popup = nullptr; static lv_obj_t* s_unlock_count = nullptr; // Decode the configured wallpaper (internal SPIFFS path, or an "sd:"-prefixed SD // path) into a fresh RGB565 buffer; fall back to the embedded placeholder. // Caller owns the buffer (lvglPsramFree). Returns nullptr only if even the // placeholder fails to decode. static uint8_t* lockscreenDecodeWallpaper(int* out_w, int* out_h) { char path[TOUCH_LOCK_WALLPAPER_MAXLEN]; touchPrefsGetLockWallpaper(path, sizeof path); // (The built-in default is handled by lockscreenShow() via the RGB565 embed; // this path only runs for user-selected wallpapers.) fs::FS* fsp = &SPIFFS; const char* fp = path; #if CAP_SD || defined(TLORA_PAGER) if (!strncmp(path, "sd:", 3)) { fsp = &SD; fp = path + 3; fmSdTryMount(); } // mount on demand so the chosen SD wallpaper decodes (T-Deck SD only) #endif uint8_t* rgb = nullptr; if (fp && fp[0]) { File f = fsp->open(fp, "r"); if (f) { size_t sz = f.size(); if (sz > 0 && sz <= FM_IMG_MAX) { uint8_t* enc = (uint8_t*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); if (!enc) enc = (uint8_t*)malloc(sz); if (enc) { size_t rd = f.readBytes((char*)enc, sz); const bool is_png = (rd >= 4 && enc[0] == 0x89 && enc[1] == 'P'); if (!is_png) { rgb = decodeJpegToRgb565(enc, rd, out_w, out_h); if (!rgb) rgb = decodeJpegScaledToRgb565(enc, rd, out_w, out_h, 640); // big wallpaper: downscale (keeps the held buffer small) } free(enc); } } f.close(); } } if (!rgb) rgb = decodeJpegToRgb565(lockscreen_placeholder_jpg, lockscreen_placeholder_jpg_len, out_w, out_h); return rgb; } static void lockscreenUpdateClock() { if (!s_lock_clock) return; mesh::RTCClock* rtc = the_mesh.getRTCClock(); uint32_t t = rtc ? rtc->getCurrentTime() : 0; int mm = 0; char b[12]; snprintf(b, sizeof b, "00:00"); if (t > 0) { time_t tt = (time_t)t; struct tm v; localtime_r(&tt, &v); fmtClockHM(b, sizeof b, &v); mm = v.tm_min; } lv_label_set_text(s_lock_clock, b); // Anti-burn-in drift: nudge the clock a few pixels per minute so its outline never // parks on the same LCD cells — wyvern.red reported the lock layout retaining into // the panel. Deterministic from the minute, so it also moves on every reveal. const int dx = (mm % 5) * 3 - 6; // -6 … +6 px const int dy = ((mm / 5) % 3) * 4 - 4; // -4 … +4 px #if defined(TLORA_PAGER) // Same anti-burn-in drift, but around this board's top-LEFT clock position // (lockscreenShow()'s TOP_LEFT/6,30) instead of T-Deck's TOP_MID -- without // this override every periodic clock update (this function runs on every // minute rollover) silently snapped the clock back to horizontally // centered, undoing the top-left placement the moment it first ticked. // The unread badge stays in its own right-column spot (300,70) -- it // doesn't ride with the clock on this layout, so no drift needed there. lv_obj_align(s_lock_clock, LV_ALIGN_TOP_LEFT, 6 + dx, 30 + dy); #else lv_obj_align(s_lock_clock, LV_ALIGN_TOP_MID, dx, 30 + dy); // The unread badge rides along with the same drift so it never parks either. if (s_lock_unread) lv_obj_align(s_lock_unread, LV_ALIGN_TOP_MID, dx, 68 + dy); #endif s_lock_clock_min = mm; } // Envelope + unread total under the clock (issue #93) — glanceable without // unlocking. Hidden entirely at zero so the lock screen stays calm. static void lockscreenUpdateUnread() { if (!s_lock_unread || !g_lv.task) return; const int n = g_lv.task->getUnreadTotal(); if (n == s_lock_unread_n) return; s_lock_unread_n = n; if (n <= 0) { lv_obj_add_flag(s_lock_unread, LV_OBJ_FLAG_HIDDEN); return; } char b[24]; snprintf(b, sizeof b, LV_SYMBOL_ENVELOPE " %d", n); lv_label_set_text(s_lock_unread, b); lv_obj_clear_flag(s_lock_unread, LV_OBJ_FLAG_HIDDEN); } static void lockscreenUnlockPopupHide() { if (s_unlock_popup) { lv_obj_del(s_unlock_popup); s_unlock_popup = nullptr; s_unlock_count = nullptr; } } // Show / update the "Unlocking…" countdown while the trackball is held. static void lockscreenUnlockProgress(unsigned long remaining_ms) { if (!s_unlock_popup) { s_unlock_popup = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_unlock_popup); lv_obj_set_size(s_unlock_popup, 180, 104); lv_obj_center(s_unlock_popup); lv_obj_set_style_bg_color(s_unlock_popup, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_unlock_popup, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_unlock_popup, 12, LV_PART_MAIN); lv_obj_set_style_border_color(s_unlock_popup, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_unlock_popup, 1, LV_PART_MAIN); lv_obj_clear_flag(s_unlock_popup, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(s_unlock_popup); lv_label_set_text(t, TR("Unlocking\xE2\x80\xA6")); lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); s_unlock_count = lv_label_create(s_unlock_popup); lv_obj_set_style_text_color(s_unlock_count, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_text_font(s_unlock_count, &g_font_16, LV_PART_MAIN); lv_obj_align(s_unlock_count, LV_ALIGN_CENTER, 0, 6); lv_obj_t* h = lv_label_create(s_unlock_popup); lv_label_set_text(h, TR("keep holding")); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_align(h, LV_ALIGN_BOTTOM_MID, 0, -8); } lv_obj_move_foreground(s_unlock_popup); if (s_unlock_count) { char b[4]; snprintf(b, sizeof b, "%u", (unsigned)((remaining_ms + 999) / 1000)); lv_label_set_text(s_unlock_count, b); } } // Build the overlay if needed and bring it to the front. Idempotent: a second // call just re-foregrounds it (no wallpaper re-decode). static void lockscreenShow() { // The status bar sits on lv_layer_sys (above this overlay); drop its opaque // background + accent border so the wallpaper shows through behind the icons. if (g_statusbar.root) { lv_obj_set_style_bg_opa(g_statusbar.root, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_opa(g_statusbar.root, LV_OPA_TRANSP, LV_PART_MAIN); } if (s_lock_root) { lv_obj_move_foreground(s_lock_root); return; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_lock_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_lock_root); lv_obj_set_size(s_lock_root, sw, sh); lv_obj_set_pos(s_lock_root, 0, 0); lv_obj_set_style_bg_color(s_lock_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_lock_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_lock_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_lock_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps (no UI leak) #if defined(TLORA_PAGER) // Keep the lock overlay OUT of the keyboard/encoder nav focus group. The root // is CLICKABLE (to absorb taps) and it lives on lv_layer_top, so on this // no-touch board navMaybeRebuild() would otherwise collect it as the only // focusable element in the overlay and focus it -- at which point navFocusCb's // keyboard-focus highlight reverse-videos it: light bg (hidden behind the // wallpaper) + navInvertText() flipping every child label's text to COLOR_BG // (dark). That's the reported "lock text renders white then flips to black a // split second after every reveal, wallpaper stays fine" bug -- the focus // lands a tick after the reveal paint. NAV_SKIP_FLAG makes navCollect/ // navTreeSig skip the whole subtree, so it's never focused and never inverted. lv_obj_add_flag(s_lock_root, NAV_SKIP_FLAG); #endif // Wallpaper, scaled to cover the screen (crop overflow, never letterbox). int ww = 0, wh = 0; const uint8_t* wall_data = nullptr; if (s_lock_wall) { // Invalidate LVGL's image cache entry for s_lock_wall_dsc BEFORE freeing the // buffer it points at. s_lock_wall_dsc is a static var (stable address), and // LVGL's img cache is keyed by that src pointer -- without this, the next // lockscreenShow() can hit a stale cache entry pointing at freed PSRAM that // has since been reused elsewhere (e.g. the map's 128 KB tile buffers), // rendering as RGB565 noise on wake. Same bug class already fixed for map // tiles in freeMapTileSlot() -- see the comment there (issue #127). lv_img_cache_invalidate_src(&s_lock_wall_dsc); lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } char wpath[TOUCH_LOCK_WALLPAPER_MAXLEN]; touchPrefsGetLockWallpaper(wpath, sizeof wpath); if (!strcmp(wpath, "/lock/placeholder.jpg")) { // Default: the pre-dithered RGB565 embed, drawn straight from flash — crisp, // with no JPEG round-trip to re-introduce gradient banding. #if defined(TLORA_PAGER) // Native 480x222 crop of the same icon+wordmark, repositioned to the left // (see lockscreen_wallpaper_pager_rgb565.h) -- the shared 320x240 art's // cover-fill on this much wider/shorter panel zoomed it ~1.5x and center- // cropped ~70px off top+bottom, shoving it into the fixed-position text // labels below (reported/photographed). Being screen-native, this needs // no crop math at all: the cover-zoom below naturally computes ~1:1. wall_data = (const uint8_t*)lockscreen_wallpaper_pager_rgb565; ww = LOCKSCREEN_WALLPAPER_PAGER_W; wh = LOCKSCREEN_WALLPAPER_PAGER_H; #else wall_data = (const uint8_t*)lockscreen_wallpaper_rgb565; ww = LOCKSCREEN_WALLPAPER_W; wh = LOCKSCREEN_WALLPAPER_H; #endif } else { s_lock_wall = lockscreenDecodeWallpaper(&ww, &wh); // custom wallpaper (JPEG) wall_data = s_lock_wall; } if (wall_data && ww > 0 && wh > 0) { memset(&s_lock_wall_dsc, 0, sizeof s_lock_wall_dsc); s_lock_wall_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; s_lock_wall_dsc.header.w = (uint32_t)ww; s_lock_wall_dsc.header.h = (uint32_t)wh; s_lock_wall_dsc.data = wall_data; s_lock_wall_dsc.data_size = (uint32_t)ww * (uint32_t)wh * sizeof(lv_color_t); lv_obj_t* img = lv_img_create(s_lock_root); lv_img_set_src(img, &s_lock_wall_dsc); lv_img_set_antialias(img, true); lv_img_set_pivot(img, ww / 2, wh / 2); lv_obj_clear_flag(img, LV_OBJ_FLAG_CLICKABLE); // Background wallpaper: same cover-fill-and-center treatment on every // board, pager included -- only the text labels below get a pager- // specific layout (they're what was actually unreadable/colliding; the // wallpaper crop itself is unchanged from how it's always looked). uint32_t zx = (uint32_t)sw * 256u / (uint32_t)ww; uint32_t zy = (uint32_t)sh * 256u / (uint32_t)wh; uint32_t zoom = (zx > zy) ? zx : zy; // cover if (zoom < 1) zoom = 1; if (zoom > 2048) zoom = 2048; lv_img_set_zoom(img, (uint16_t)zoom); lv_obj_align(img, LV_ALIGN_CENTER, 0, 0); } #if defined(TLORA_PAGER) // Force a guaranteed-visible white here rather than the shared, user- // customizable touchPrefsGetLockTextColor() -- on this board that pref was // rendering noticeably dim/dark (reported/photographed against this panel's // background) even at the shared soft-white default (0xE6F2FF), so this // board gets pure white instead of chasing a per-device pref/storage // question for a cosmetic lock screen. const lv_color_t col = lv_color_hex(0xFFFFFFu); #else const lv_color_t col = lv_color_hex(touchPrefsGetLockTextColor()); #endif s_lock_clock = lv_label_create(s_lock_root); lv_label_set_text(s_lock_clock, "--:--"); lv_obj_set_style_text_font(s_lock_clock, &lv_font_montserrat_28, LV_PART_MAIN); lv_obj_set_style_text_color(s_lock_clock, col, LV_PART_MAIN); #if defined(TLORA_PAGER) // Fixed-width box spanning the same 192px the composited icon occupies // (lockscreen_wallpaper_pager_rgb565.h, pasted at x=6 width=192) with // centered text, instead of just left-anchoring the label -- "10:37" and // "8:05" are different pixel widths, so anchoring by the label's own LEFT // edge left the clock's actual visual center drifting with the text // instead of lining up with the icon below it. Centering within a box of // the icon's own width keeps the two centers matched regardless of what // the clock displays. lv_obj_set_width(s_lock_clock, 192); lv_obj_set_style_text_align(s_lock_clock, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_align(s_lock_clock, LV_ALIGN_TOP_LEFT, 6, 30); // top-left corner, below the 22 px status bar #else lv_obj_align(s_lock_clock, LV_ALIGN_TOP_MID, 0, 30); // below the 22 px status bar #endif s_lock_clock_min = -1; s_lock_unread = lv_label_create(s_lock_root); lv_obj_set_style_text_font(s_lock_unread, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(s_lock_unread, col, LV_PART_MAIN); #if defined(TLORA_PAGER) // Positioned once the hint label exists below -- see the bottom-right stack // built after `hint` is created. #else lv_obj_align(s_lock_unread, LV_ALIGN_TOP_MID, 0, 68); #endif lv_obj_add_flag(s_lock_unread, LV_OBJ_FLAG_HIDDEN); s_lock_unread_n = -1; lockscreenUpdateClock(); lockscreenUpdateUnread(); lv_obj_t* st = lv_label_create(s_lock_root); lv_label_set_text(st, TR("Screen locked")); lv_obj_set_style_text_font(st, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(st, col, LV_PART_MAIN); #if defined(TLORA_PAGER) s_lock_status = st; // positioned once the hint label exists below -- see the bottom-right stack #else lv_obj_align(st, LV_ALIGN_TOP_MID, 0, 190); #endif lv_obj_t* hint = lv_label_create(s_lock_root); useChainedFont(hint); #if defined(HAS_TANMATSU) lv_label_set_text(hint, TR("press Volume Down to unlock")); #elif defined(HAS_PAGER_KEYBOARD) // lockscreenShow() DOES run on this board -- lockscreenReveal() (peek on a // Backspace tap, or the new-message notify flash while locked) is gated on // CAP_LOCK_SCREEN, which is 1 for the pager, not HAS_TDECK_GT911. (An // earlier version of this comment claimed otherwise; it was wrong.) lv_label_set_text(hint, TR("hold Backspace to unlock")); #elif defined(HAS_THINKNODE_M9) lv_label_set_text(hint, TR("hold the d-pad to unlock")); #else lv_label_set_text(hint, TR("hold the trackball to unlock")); #endif lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, col, LV_PART_MAIN); lv_obj_set_style_text_opa(hint, LV_OPA_70, LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_align(hint, LV_ALIGN_BOTTOM_RIGHT, -6, -8); // bottom-right corner, clear of the icon/clock column above s_lock_hint = hint; // Message count + "Screen locked" stack right-aligned directly above the hint, // each anchored to the one below it (not a fixed y) so the group holds // together and stays right-aligned regardless of label width or whether the // unread badge is visible. lv_obj_align_to(st, hint, LV_ALIGN_OUT_TOP_RIGHT, 0, -4); lv_obj_align_to(s_lock_unread, st, LV_ALIGN_OUT_TOP_RIGHT, 0, -4); #else lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); #endif } static void lockscreenHide() { lockscreenUnlockPopupHide(); if (s_lock_root) { lv_obj_del(s_lock_root); s_lock_root = nullptr; s_lock_clock = nullptr; s_lock_unread = nullptr; #if defined(TLORA_PAGER) s_lock_status = nullptr; s_lock_hint = nullptr; #endif } if (s_lock_wall) { lv_img_cache_invalidate_src(&s_lock_wall_dsc); lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } s_lock_clock_min = -1; s_lock_unread_n = -1; // Restore the status bar's normal opaque background + accent border. if (g_statusbar.root) { lv_obj_set_style_bg_opa(g_statusbar.root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_opa(g_statusbar.root, LV_OPA_30, LV_PART_MAIN); } } // Refresh the clock when the minute rolls over (called each loop tick), and // the unread badge at 1 Hz so a message arriving while locked shows up. static void serviceLockscreen() { if (!s_lock_root || !s_lock_clock) return; mesh::RTCClock* rtc = the_mesh.getRTCClock(); uint32_t t = rtc ? rtc->getCurrentTime() : 0; int mm = 0; if (t > 0) { time_t tt = (time_t)t; struct tm v; localtime_r(&tt, &v); mm = v.tm_min; } if (mm != s_lock_clock_min) lockscreenUpdateClock(); unsigned long now = millis(); if (now - s_lock_unread_ms >= 1000) { s_lock_unread_ms = now; lockscreenUpdateUnread(); } } #endif // core lock screen (HAS_TDECK_GT911 || HAS_TANMATSU) #if CAP_SOUND_FILES // custom WAV notification sounds -- T-Deck/pager (SD or SPIFFS) // ---- Notification-sound chooser (Settings -> Sound) ------------------------ // Tapping a slot button opens a small menu: "Choose .wav from files" (opens the // File Manager, exactly like the wallpaper picker; opening a .wav there offers // "Set as notification sound"), or "Built-in (default)" to reset to the chime. static void soundDisplayName(const char* path, char* out, int cap) { if (!out || cap <= 0) return; // TR(), not a bare literal: this is the label shown for the default chime, and // it was the one path of two that never went through the translation table, so // the same word appeared translated in the menu and English on the button. if (!path || !path[0]) { snprintf(out, cap, "%s", TR("Built-in")); return; } const bool sd = !strncmp(path, "sd:", 3); const char* p = sd ? path + 3 : path; const char* base = strrchr(p, '/'); base = base ? base + 1 : p; snprintf(out, cap, sd ? "SD: %s" : "%s", base); } static lv_obj_t* s_snd_menu = nullptr; static void sndMenuClose() { if (s_snd_menu) { popupClose(&s_snd_menu); } } static void sndMenuCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) sndMenuClose(); } static void sndMenuBuiltinCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; int slot = s_snd_pending_slot; if (slot < 0 || slot > 2) slot = TOUCH_SND_MSG; touchPrefsSetSoundFile(slot, ""); if (s_snd_btn_lbl[slot] && lv_obj_is_valid(s_snd_btn_lbl[slot])) lv_label_set_text(s_snd_btn_lbl[slot], TR("Built-in")); sndMenuClose(); uiPlaySlot(slot); // preview the built-in chime } static void sndMenuFilesCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; sndMenuClose(); lv_obj_t* body = openFullscreenView("Files"); buildFileManager(body); if (g_lv.task) g_lv.task->showAlert(TR("Open a .wav, then tap 'Set as notification sound'"), 2800); } static void openSoundMenu(int slot) { s_snd_pending_slot = slot; sndMenuClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const lv_coord_t pw = (sw * 82) / 100, ph = 168; s_snd_menu = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_snd_menu); lv_obj_set_size(s_snd_menu, pw, ph); lv_obj_set_pos(s_snd_menu, (sw - pw) / 2, STATUSBAR_H + (sh - STATUSBAR_H - ph) / 2); lv_obj_set_style_bg_color(s_snd_menu, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_snd_menu, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(s_snd_menu, 8, LV_PART_MAIN); lv_obj_set_style_border_width(s_snd_menu, 1, LV_PART_MAIN); lv_obj_set_style_border_color(s_snd_menu, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_clear_flag(s_snd_menu, LV_OBJ_FLAG_SCROLLABLE); static const char* const kSlot[3] = { "Message", "Direct (DM)", "@ mention" }; lv_obj_t* title = lv_label_create(s_snd_menu); char tt[40]; snprintf(tt, sizeof tt, "%s sound", kSlot[(slot >= 0 && slot < 3) ? slot : 0]); lv_label_set_text(title, tt); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 12, 12); lv_obj_t* bf = lv_btn_create(s_snd_menu); lv_obj_set_size(bf, pw - 24, 40); lv_obj_set_pos(bf, 12, 44); styleButton(bf); lv_obj_add_event_cb(bf, sndMenuFilesCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lf = lv_label_create(bf); lv_label_set_text(lf, TR(LV_SYMBOL_DIRECTORY " Choose .wav from files")); lv_obj_center(lf); useChainedFont(lf); lv_obj_t* bb = lv_btn_create(s_snd_menu); lv_obj_set_size(bb, pw - 24, 40); lv_obj_set_pos(bb, 12, 92); styleButton(bb); lv_obj_add_event_cb(bb, sndMenuBuiltinCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* lb = lv_label_create(bb); lv_label_set_text(lb, TR("Built-in (default)")); lv_obj_center(lb); useChainedFont(lb); lv_obj_t* close = lv_btn_create(s_snd_menu); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 6); styleButton(close); lv_obj_add_event_cb(close, sndMenuCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); } static void openSoundPickerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openSoundMenu((int)(intptr_t)lv_event_get_user_data(e)); } #endif // CAP_SOUND_FILES // ---- Lock-screen wallpaper picker (lists JPEGs in internal /lock/ + SD) ---- #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) // wallpaper picker — SD/SPIFFS-backed, board-agnostic static lv_obj_t* s_lockwall_picker = nullptr; static char s_lockwall_paths[24][TOUCH_LOCK_WALLPAPER_MAXLEN]; static int s_lockwall_count = 0; static void lockwallPickerClose() { popupClose(&s_lockwall_picker); } static void lockwallPickerCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) lockwallPickerClose(); } static bool lockwallIsJpg(const char* name) { if (!name) return false; const char* d = strrchr(name, '.'); return d && (!strcasecmp(d, ".jpg") || !strcasecmp(d, ".jpeg")); } static void lockwallAddPath(const char* prefpath) { const int cap = (int)(sizeof s_lockwall_paths / sizeof s_lockwall_paths[0]); if (s_lockwall_count >= cap) return; for (int i = 0; i < s_lockwall_count; ++i) if (!strcmp(s_lockwall_paths[i], prefpath)) return; // dedup strncpy(s_lockwall_paths[s_lockwall_count], prefpath, TOUCH_LOCK_WALLPAPER_MAXLEN - 1); s_lockwall_paths[s_lockwall_count][TOUCH_LOCK_WALLPAPER_MAXLEN - 1] = '\0'; s_lockwall_count++; } // Recursively list JPEGs on the SD card into the picker array. Depth-limited (2 // levels), skips hidden + the FAT "System Volume Information" dir, and stops at // the array cap. Entries are closed before recursing so SD file handles don't // pile up past the FATFS limit. static void lockwallScanSd(const char* dirpath, int depth) { const int cap = (int)(sizeof s_lockwall_paths / sizeof s_lockwall_paths[0]); if (s_lockwall_count >= cap) return; File d = SD.open(dirpath); if (!d || !d.isDirectory()) { if (d) d.close(); return; } File e = d.openNextFile(); while (e && s_lockwall_count < cap) { const char* nm = e.name(); const char* base = strrchr(nm, '/'); base = base ? base + 1 : nm; const bool isdir = e.isDirectory(); char child[TOUCH_LOCK_WALLPAPER_MAXLEN]; if (!strcmp(dirpath, "/")) snprintf(child, sizeof child, "/%s", base); else snprintf(child, sizeof child, "%s/%s", dirpath, base); e.close(); // close before recursing if (isdir) { if (depth < 2 && base[0] != '.' && strcmp(base, "System Volume Information") != 0) lockwallScanSd(child, depth + 1); } else if (lockwallIsJpg(base)) { char pref[TOUCH_LOCK_WALLPAPER_MAXLEN]; snprintf(pref, sizeof pref, "sd:%s", child); // e.g. "/Pics/a.jpg" -> "sd:/Pics/a.jpg" lockwallAddPath(pref); } e = d.openNextFile(); } if (e) e.close(); d.close(); } static void lockwallScan() { s_lockwall_count = 0; // Internal SPIFFS is flat — match files whose full path is under /lock/. File root = SPIFFS.open("/"); if (root) { File e = root.openNextFile(); while (e) { const char* full = e.path(); if (full && !strncmp(full, "/lock/", 6) && lockwallIsJpg(full)) lockwallAddPath(full); e.close(); e = root.openNextFile(); } root.close(); } // SD: mount on demand — the lock-wallpaper page is reachable without ever // opening the file manager, so the card may not be mounted yet (the old code // checked s_sd_mounted and silently skipped the card => "no JPEGs found"). // Then walk it RECURSIVELY: users drop images in the root, /lock, /Pictures, // /DCIM/<x>, or a folder they made — not just the root + /lock. if (fmSdTryMount()) { lockwallScanSd("/", 0); } } static void lockwallChosenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* path = (const char*)lv_event_get_user_data(e); if (!path) return; touchPrefsSetLockWallpaper(path); if (s_lockwall_btn_lbl) { char disp[64]; lockwallDisplayName(path, disp, sizeof disp); lv_label_set_text(s_lockwall_btn_lbl, disp); } lockwallPickerClose(); if (g_lv.task) g_lv.task->showAlert(TR("Lock wallpaper set"), 1100); } static void openLockWallPicker() { lockwallScan(); lockwallPickerClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_lockwall_picker = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_lockwall_picker); lv_obj_set_size(s_lockwall_picker, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_lockwall_picker, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_lockwall_picker, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_lockwall_picker, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_lockwall_picker, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(s_lockwall_picker); lv_label_set_text(title, TR("Lock wallpaper")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 8, 8); lv_obj_t* close = lv_btn_create(s_lockwall_picker); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 4); styleButton(close); lv_obj_add_event_cb(close, lockwallPickerCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); lv_obj_t* list = lv_obj_create(s_lockwall_picker); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw - 12, sh - STATUSBAR_H - 42); lv_obj_set_pos(list, 6, 36); lv_obj_set_style_pad_row(list, 6, LV_PART_MAIN); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(list, LV_DIR_VER); if (s_lockwall_count == 0) { lv_obj_t* empty = lv_label_create(list); lv_label_set_text(empty, TR("No JPEGs found in /lock/.\nAdd images via the file\nmanager or an SD card.")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); } for (int i = 0; i < s_lockwall_count; ++i) { lv_obj_t* b = lv_btn_create(list); lv_obj_set_width(b, lv_pct(100)); lv_obj_set_height(b, 36); styleButton(b); lv_obj_add_event_cb(b, lockwallChosenCb, LV_EVENT_CLICKED, s_lockwall_paths[i]); lv_obj_t* l = lv_label_create(b); useChainedFont(l); char disp[64]; lockwallDisplayName(s_lockwall_paths[i], disp, sizeof disp); lv_label_set_text(l, disp); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, lv_pct(94)); lv_obj_align(l, LV_ALIGN_LEFT_MID, 4, 0); } } static void openLockWallPickerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Open the file manager and let the user navigate to their image. Far faster // than scanning the whole card (no recursive walk on the UI thread), and works // at any folder depth. Tapping a JPEG opens the viewer, which now has a // "Set as wallpaper" button. lv_obj_t* body = openFullscreenView("Files"); buildFileManager(body); if (g_lv.task) g_lv.task->showAlert(TR("Open a JPEG, then tap 'Set as wallpaper'"), 2600); } static void lockColorChosenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const uint32_t rgb = (uint32_t)(uintptr_t)lv_event_get_user_data(e); touchPrefsSetLockTextColor(rgb); if (g_lv.task) g_lv.task->showAlert(TR("Lock text colour set"), 1000); } #endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 (wallpaper picker + lock text colour) // ---- Settings backup: import file picker ------------------------------------ // Mirrors the lock-wallpaper picker UX: a full-screen list of *.json files on // internal flash + (T-Deck) the SD card root. Picking one shows a short confirm // then streams it through MyMesh::uiImportBackup and reboots so the imported // radio/identity settings take effect. Lives OUTSIDE the HAS_TDECK_GT911 guard // above because the Import button exists on both touch boards (the Heltec has // no SD slot, so it lists internal flash only). static lv_obj_t* s_backup_picker = nullptr; // Backup-picker scratch — lazy PSRAM (allocated on first scan, internal fallback): // keeps 24*(160+48) ≈ 5 KB off scarce internal DRAM. Allocated once and never freed, // so the per-row LVGL callbacks that capture &s_backup_paths[i] stay valid for life. static constexpr int kBackupMax = 24; static char (*s_backup_paths)[160] = nullptr; // "int:/foo.json" | "sd:/foo.json" static char (*s_backup_disp )[48] = nullptr; // shown label, e.g. "SD: foo.json" static bool backupBufReady() { if (!s_backup_paths) { s_backup_paths = (char(*)[160])heap_caps_malloc(kBackupMax * 160, MALLOC_CAP_SPIRAM); if (!s_backup_paths) s_backup_paths = (char(*)[160])heap_caps_malloc(kBackupMax * 160, MALLOC_CAP_8BIT); } if (!s_backup_disp) { s_backup_disp = (char(*)[48]) heap_caps_malloc(kBackupMax * 48, MALLOC_CAP_SPIRAM); if (!s_backup_disp) s_backup_disp = (char(*)[48]) heap_caps_malloc(kBackupMax * 48, MALLOC_CAP_8BIT); } return s_backup_paths && s_backup_disp; } static int s_backup_count = 0; static char s_backup_chosen[160] = {0}; static bool backupIsJson(const char* name) { if (!name) return false; const char* base = strrchr(name, '/'); base = base ? base + 1 : name; // Skip hidden / macOS AppleDouble sidecars (".DS_Store", and the "._<name>" // resource-fork file Finder writes next to every file it copies to a FAT SD // card) — otherwise "._meshcore-backup.json" shows up as a bogus duplicate. if (base[0] == '.') return false; const char* d = strrchr(base, '.'); return d && !strcasecmp(d, ".json"); } static void backupAddPath(const char* stored, const char* disp) { if (!backupBufReady()) return; // OOM — skip rather than deref a null buffer if (s_backup_count >= kBackupMax) return; for (int i = 0; i < s_backup_count; ++i) if (!strcmp(s_backup_paths[i], stored)) return; // dedup strncpy(s_backup_paths[s_backup_count], stored, 159); s_backup_paths[s_backup_count][159] = '\0'; strncpy(s_backup_disp [s_backup_count], disp, 47); s_backup_disp [s_backup_count][47] = '\0'; s_backup_count++; } // Internal filesystem that holds the .json backups: FFat on the Tanmatsu (the launcher already // mounted the 'locfd' FAT data partition — SPIFFS isn't the writable store there, and FFat.begin() // would fail on an already-mounted volume), plain SPIFFS on the T-Deck / V4. Mirrors the file // manager's "Internal" root so a backup lands where the user can actually see + restore it. static fs::FS* backupInternalFs() { #if defined(HAS_TDISPLAY_P4) // The P4 hot store moved from FFat to LittleFS('storage') in beta_54 (#167); // FFat is never mounted here anymore, so opening it failed every backup (#231). return &LittleFS; #elif defined(HAS_TANMATSU) return &FFat; // FAT data partition (locfd), mounted at boot; no SPIFFS here #else SPIFFS.begin(false); return &SPIFFS; #endif } static void backupScan() { s_backup_count = 0; // Internal flash is flat — list any *.json at the root (SPIFFS on T-Deck/V4, FFat on Tanmatsu). File root = backupInternalFs()->open("/"); if (root) { File e = root.openNextFile(); while (e) { const char* full = e.path(); if (full && backupIsJson(full)) { const char* base = strrchr(full, '/'); base = base ? base + 1 : full; char stored[160]; snprintf(stored, sizeof stored, "int:%s", full); char disp[48]; snprintf(disp, sizeof disp, "Internal: %s", base); backupAddPath(stored, disp); } e.close(); e = root.openNextFile(); } root.close(); } #if CAP_SD || defined(TLORA_PAGER) // SD card: scan the root AND the /meshcomod data folder. SD-storage builds keep // their data under /meshcomod, so users naturally drop a backup next to it — // previously only the root was scanned, so a json in /meshcomod never showed up // ("only gets recognized from the root folder"). Actively (re)mount here: the SD // is otherwise only probed while the file manager is open. fmSdTryMount() no-ops // if already mounted, else walks the mount ladder. if (fmSdTryMount()) { static const char* kSdDirs[] = { "/", "/meshcomod" }; for (const char* dir : kSdDirs) { File d = SD.open(dir); if (d && d.isDirectory()) { File e = d.openNextFile(); while (e) { if (!e.isDirectory()) { const char* nm = e.name(); const char* base = strrchr(nm, '/'); base = base ? base + 1 : nm; if (backupIsJson(base)) { char stored[160], disp[48]; if (dir[1] == '\0') { // root "/" snprintf(stored, sizeof stored, "sd:/%s", base); snprintf(disp, sizeof disp, "SD: %s", base); } else { snprintf(stored, sizeof stored, "sd:%s/%s", dir, base); snprintf(disp, sizeof disp, "SD %s/: %s", dir + 1, base); } backupAddPath(stored, disp); } } e.close(); e = d.openNextFile(); } } if (d) d.close(); } } #endif } static void backupPickerClose() { popupClose(&s_backup_picker); } static void backupPickerCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) backupPickerClose(); } // Apply the file stashed in s_backup_chosen, then reboot. static void doBackupImportChosen() { if (!g_lv.task || !s_backup_chosen[0]) return; fs::FS* fsp = nullptr; const char* path = s_backup_chosen; if (!strncmp(path, "int:", 4)) { fsp = backupInternalFs(); path += 4; } #if CAP_SD || defined(TLORA_PAGER) else if (!strncmp(path, "sd:", 3)) { fsp = &SD; path += 3; } #endif if (!fsp) { g_lv.task->showAlert(TR("Import: storage unavailable"), 2000); return; } // "Importing…" overlay, painted before the blocking parse + apply. lv_obj_t* ov = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(ov); lv_obj_set_size(ov, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr)); lv_obj_set_style_bg_color(ov, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(ov, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(ov, LV_OBJ_FLAG_SCROLLABLE); { lv_obj_t* ol = lv_label_create(ov); lv_label_set_text(ol, TR("Importing settings\xe2\x80\xa6")); lv_obj_set_style_text_color(ol, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ol, &g_font_16, LV_PART_MAIN); lv_obj_center(ol); } lv_refr_now(nullptr); File f = fsp->open(path, FILE_READ); // uiImportBackup is a long synchronous burst of SPIFFS writes — identity, // channels, and DataStore::saveContacts issues ~12 writes per contact (2000+ // for a full address book). That keeps the flash cache disabled long enough to // starve the idle task and trip the TASK watchdog; the panic surfaces as an // 'ipc0' abort inside spi_flash_op_block_func (a flash/cache stall, NOT a // partition fault). A successful import reboots immediately, so suspend the // per-core idle watchdogs for the duration and only restore them if we bail. wdtHeavyBegin(); int nch = 0, nco = 0; bool ok = f && the_mesh.uiImportBackup(f, 0x1F, true, true, &nch, &nco); if (f) f.close(); if (!ok) { wdtHeavyEnd(); lv_obj_del(ov); g_lv.task->showAlert(TR("Import failed (bad/unreadable JSON)"), 2400); return; } g_lv.task->persistHistoryNow(); // nests under the guard above (ref-counted) // Surface the result BEFORE the reboot. A silent restart that came back reading // "0 contacts" looked to users like the import "did nothing" — now the counts // are visible, and if they read 0 the problem is the file/JSON, not the apply. lv_obj_del(ov); char dmsg[80]; snprintf(dmsg, sizeof dmsg, "Imported %d contacts, %d channels.\nRebooting\xe2\x80\xa6", nco, nch); g_lv.task->showAlert(dmsg, 2600); lv_refr_now(nullptr); delay(2600); ESP.restart(); } static void backupChosenCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* stored = (const char*)lv_event_get_user_data(e); if (!stored) return; strncpy(s_backup_chosen, stored, sizeof(s_backup_chosen) - 1); s_backup_chosen[sizeof(s_backup_chosen) - 1] = '\0'; backupPickerClose(); showConfirm(TR("Import this backup?\nReplaces identity,\nchannels & contacts,\nthen reboots."), TR("Import"), doBackupImportChosen); } static void openBackupPicker() { backupScan(); backupPickerClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_backup_picker = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_backup_picker); lv_obj_set_size(s_backup_picker, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_backup_picker, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_backup_picker, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_backup_picker, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_backup_picker, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(s_backup_picker); lv_label_set_text(title, TR("Import settings")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 8, 8); lv_obj_t* close = lv_btn_create(s_backup_picker); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 4); styleButton(close); lv_obj_add_event_cb(close, backupPickerCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); lv_obj_t* list = lv_obj_create(s_backup_picker); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw - 12, sh - STATUSBAR_H - 42); lv_obj_set_pos(list, 6, 36); lv_obj_set_style_pad_row(list, 6, LV_PART_MAIN); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(list, LV_DIR_VER); if (s_backup_count == 0) { lv_obj_t* empty = lv_label_create(list); lv_label_set_text(empty, TR("No .json backups found.\nExport one first, or copy a\nmeshcore-backup.json to the\nSD card or internal flash.")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); } for (int i = 0; i < s_backup_count; ++i) { lv_obj_t* b = lv_btn_create(list); lv_obj_set_width(b, lv_pct(100)); lv_obj_set_height(b, 36); styleButton(b); lv_obj_add_event_cb(b, backupChosenCb, LV_EVENT_CLICKED, s_backup_paths[i]); lv_obj_t* l = lv_label_create(b); useChainedFont(l); lv_label_set_text(l, s_backup_disp[i]); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, lv_pct(94)); lv_obj_align(l, LV_ALIGN_LEFT_MID, 4, 0); } } // ---- Backups manager (Settings -> Backups) ---------------------------------- // Lists every .json backup (internal flash + SD, reusing backupScan above), each // with a Delete button, plus a Factory-reset action. The detail page is rebuilt // after a delete so the list stays accurate. static char s_backup_del_path[160] = {0}; // Rebuild the Backups detail page (after an export/delete). Deferred via // lv_async_call so we never tear the sheet down from inside one of its own // button callbacks (the export button would otherwise delete itself mid-event). static void backupsRebuildAsyncCb(void* /*p*/) { if (s_settings_open_cat == CAT_BACKUPS) openSettingsCategory(CAT_BACKUPS); } // Unique backup filename from the device clock (local time) when it's set, else a // uptime fallback. e.g. "meshcore-20260612-143205.json". static void backupMakeFilename(char* out, size_t cap) { uint32_t t = 0; if (mesh::RTCClock* rtc = the_mesh.getRTCClock()) t = rtc->getCurrentTime(); if (t > 1700000000UL) { time_t tt = (time_t)t; struct tm v; localtime_r(&tt, &v); snprintf(out, cap, "meshcore-%04d%02d%02d-%02d%02d%02d.json", v.tm_year + 1900, v.tm_mon + 1, v.tm_mday, v.tm_hour, v.tm_min, v.tm_sec); } else { snprintf(out, cap, "meshcore-backup-%lu.json", (unsigned long)(millis() / 1000)); } } // Write a settings backup to `/<fname>` — SD if a card mounts (so it's removable // AND shows up as an "SD:" item in the list), else internal SPIFFS. Paints a brief // "Exporting…" overlay and toasts the saved path. Shared by the Profile "Export // settings" button (fixed app-compatible name) and the Backups page (timestamped). static void doExportBackupFile(const char* fname) { if (!g_lv.task) return; lv_obj_t* ov = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(ov); lv_obj_set_size(ov, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr)); lv_obj_set_style_bg_color(ov, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(ov, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(ov, LV_OBJ_FLAG_SCROLLABLE); { lv_obj_t* ol = lv_label_create(ov); lv_label_set_text(ol, TR("Exporting settings\xe2\x80\xa6")); lv_obj_set_style_text_color(ol, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ol, &g_font_16, LV_PART_MAIN); lv_obj_center(ol); } lv_refr_now(nullptr); char path[96]; snprintf(path, sizeof path, "/%s", fname); File f; const char* where = "internal"; #if CAP_SD || defined(TLORA_PAGER) // Actually mount the card (SD.cardType alone reads CARD_NONE until something // mounts it) so a backup truly lands on — and lists from — the SD card. if (fmSdTryMount()) { f = SD.open(path, FILE_WRITE); if (f) where = "SD"; } #endif if (!f) { f = backupInternalFs()->open(path, FILE_WRITE); } if (!f) { lv_obj_del(ov); g_lv.task->showAlert(TR("Export failed (can't open file)"), 1800); return; } { WdtHeavyGuard _wg; // a 60 KB backup write to internal flash can trigger a SPIFFS GC { FileBufWriter bw(f); the_mesh.uiExportBackup(bw, g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); bw.flushBuf(); } f.close(); } lv_obj_del(ov); char msg[120]; snprintf(msg, sizeof msg, "Saved %s:%s", where, path); g_lv.task->showAlert(msg, 2600); } static void doDeleteBackup() { if (!s_backup_del_path[0]) return; const char* path = s_backup_del_path; bool ok = false; if (!strncmp(path, "int:", 4)) { ok = backupInternalFs()->remove(path + 4); } // FFat on Tanmatsu/P4, SPIFFS elsewhere #if CAP_SD || defined(TLORA_PAGER) else if (!strncmp(path, "sd:", 3)) { if (fmSdTryMount()) ok = SD.remove(path + 3); } #endif s_backup_del_path[0] = '\0'; if (g_lv.task && !ok) g_lv.task->showAlert(TR("Delete failed"), 1500); if (s_settings_open_cat == CAT_BACKUPS) lv_async_call(backupsRebuildAsyncCb, nullptr); // deferred rebuild } static void backupDeleteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* path = (const char*)lv_event_get_user_data(e); if (!path) return; strncpy(s_backup_del_path, path, sizeof s_backup_del_path - 1); s_backup_del_path[sizeof s_backup_del_path - 1] = '\0'; showConfirm(TR("Delete this backup file?\nThis cannot be undone."), TR("Delete"), doDeleteBackup); } #if CAP_SD || defined(TLORA_PAGER) // Wipe the SD data folder (/meshcomod/*) — identity, prefs, logs, telemetry, and // any orphaned pre-#20 /meshcomod/tiles cache. The live Wi-Fi tile cache + offline // packs now live at the SD ROOT (/tiles, /maps/osm), outside /meshcomod, so a // factory reset keeps the map tiles automatically. static bool factoryWipeSdData() { if (!fmSdTryMount() || !sdProbeAlive()) return false; if (!SD.exists("/meshcomod")) return sdProbeAlive() && !SD.exists("/meshcomod"); fmRmRecursive(&SD, "/meshcomod"); // A failed FAT operation can make exists() look false too, so verify the // card still answers real I/O before accepting the destructive operation. return sdProbeAlive() && !SD.exists("/meshcomod"); } #endif static void doFactoryReset() { #if defined(ESP32) // Full-screen "erasing" overlay, painted before the blocking format/erase. lv_obj_t* ov = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(ov); lv_obj_set_size(ov, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr)); lv_obj_set_style_bg_color(ov, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(ov, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(ov, LV_OBJ_FLAG_SCROLLABLE); { lv_obj_t* ol = lv_label_create(ov); lv_label_set_text(ol, TR("Erasing everything\xe2\x80\xa6")); lv_obj_set_style_text_color(ol, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ol, &g_font_16, LV_PART_MAIN); lv_obj_center(ol); } lv_refr_now(nullptr); delay(150); // let the overlay actually hit the panel wdtHeavyBegin(); // SPIFFS format is a long flash burst touchPrefsFlush(); // do not format under an open prefs worker handle uiHistWaitWorkerIdle(); // do not erase under an open history handle if (s_hist_flush_busy) { wdtHeavyEnd(); lv_obj_del(ov); if (g_lv.task) { g_lv.task->flushHistorySoon(); g_lv.task->showAlert(TR("Factory reset blocked: storage is busy"), 2600); } return; } #if CAP_SD || defined(TLORA_PAGER) #if defined(TLORA_PAGER) if (sdRuntimeLifecycleBusy()) { wdtHeavyEnd(); lv_obj_del(ov); if (g_lv.task) { g_lv.task->flushHistorySoon(); g_lv.task->showAlert(TR("Factory reset blocked: SD activity is still running; close tools and retry."), 3600); } return; } #endif s_sd_retry_after_ms = 0; // explicit reset request bypasses mount backoff const bool sd_wiped = factoryWipeSdData(); // keeps root-level /tiles and /maps #if defined(TLORA_PAGER) // Never erase internal identity/NVS unless the card data was verifiably wiped // or the slot is positively empty. UNKNOWN is not absence: a transient XL9555 // read failure can coincide with an inserted but temporarily unreadable card. const TLoraPagerBoard::SdCardState sd_state = board.sdCardState(); const bool card_may_still_hold_data = sd_state != TLoraPagerBoard::SdCardState::Absent; if (!sd_wiped && card_may_still_hold_data) { wdtHeavyEnd(); lv_obj_del(ov); if (g_lv.task) { g_lv.task->flushHistorySoon(); g_lv.task->showAlert(TR("Factory reset incomplete: SD data may already be partially erased. Retry to finish, or remove the card before resetting internal data."), 6000); } return; } #else (void)sd_wiped; #endif #endif the_mesh.uiFactoryReset(); // SPIFFS.format() + nvs_flash_erase() ESP.restart(); // fresh boot: new identity + first-boot wizard #endif } static void backupFactoryResetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; showConfirm(TR("Factory reset?\n\nErases identity, contacts,\nchannels, messages, Wi-Fi\nand every setting. This\ncannot be undone."), TR("Erase all"), doFactoryReset); } static void buildBackupsSettings() { lv_obj_t* body = createSettingsModal("", SettingsModalKind::Device); const lv_coord_t cw = s_settings_content_w; int y = 0; // Create a fresh backup right here (goes to the SD card if one is in, else // internal flash) and re-list so the new file shows up immediately. { lv_obj_t* eb = lv_btn_create(body); lv_obj_set_size(eb, cw, SC(40)); lv_obj_set_pos(eb, 0, y); styleButton(eb); lv_obj_set_style_bg_color(eb, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(eb, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(eb, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; char fn[48]; backupMakeFilename(fn, sizeof fn); doExportBackupFile(fn); lv_async_call(backupsRebuildAsyncCb, nullptr); // re-list once the event unwinds }, LV_EVENT_CLICKED, nullptr); lv_obj_t* el = lv_label_create(eb); char eblbl[56]; snprintf(eblbl, sizeof eblbl, LV_SYMBOL_SAVE " %s", TR("Export new backup")); lv_label_set_text(el, eblbl); lv_obj_set_style_text_font(el, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(el, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(el); y += SC(48); } #if defined(HAS_TANMATSU) // Import a saved backup right here. The T-Deck / V4 reach the picker via the Identity page's // "Import settings" button; the Tanmatsu surfaces it next to Export so the whole round-trip // (export a .json, then restore it) lives on one page. Opens the same internal+SD .json picker. { lv_obj_t* ib = lv_btn_create(body); lv_obj_set_size(ib, cw, SC(40)); lv_obj_set_pos(ib, 0, y); styleButton(ib); lv_obj_add_event_cb(ib, +[](lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openBackupPicker(); }, LV_EVENT_CLICKED, nullptr); lv_obj_t* il = lv_label_create(ib); char iblbl[56]; snprintf(iblbl, sizeof iblbl, LV_SYMBOL_DOWNLOAD " %s", TR("Import backup")); lv_label_set_text(il, iblbl); lv_obj_set_style_text_font(il, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(il, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(il); y += SC(48); } #endif y += settingsRowLabel(body, y, 0, TR("Saved backups"), COLOR_SUB, &g_font_12, 0) + 4; backupScan(); if (s_backup_count == 0) { y += settingsRowLabel(body, y, 0, TR("No backups yet. Tap Export new backup above to create one."), COLOR_SUB, &g_font_12, 0) + 6; } for (int i = 0; i < s_backup_count; ++i) { lv_obj_t* row = lv_obj_create(body); lv_obj_remove_style_all(row); lv_obj_set_size(row, cw, SC(38)); lv_obj_set_pos(row, 0, y); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, s_backup_disp[i]); lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_set_width(nm, cw - 48); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_align(nm, LV_ALIGN_LEFT_MID, 2, 0); lv_obj_t* del = lv_btn_create(row); lv_obj_set_size(del, SC(40), SC(32)); lv_obj_align(del, LV_ALIGN_RIGHT_MID, 0, 0); styleButton(del); lv_obj_set_style_bg_color(del, lv_color_hex(0x7A2A2A), LV_PART_MAIN); lv_obj_set_style_bg_color(del, lv_color_hex(0x5E2020), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(del, backupDeleteCb, LV_EVENT_CLICKED, s_backup_paths[i]); lv_obj_t* dl = lv_label_create(del); lv_label_set_text(dl, LV_SYMBOL_TRASH); lv_obj_set_style_text_font(dl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(dl, lv_color_hex(0xFFD8D8), LV_PART_MAIN); lv_obj_center(dl); y += SC(42); } y += SC(12); y += settingsRowLabel(body, y, 0, TR("Danger zone"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* fr = lv_btn_create(body); lv_obj_set_size(fr, cw, SC(40)); lv_obj_set_pos(fr, 0, y); styleButton(fr); lv_obj_set_style_bg_color(fr, lv_color_hex(0x8B1E1E), LV_PART_MAIN); lv_obj_set_style_bg_color(fr, lv_color_hex(0x6A1616), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(fr, backupFactoryResetCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* frl = lv_label_create(fr); char frlbl[48]; snprintf(frlbl, sizeof frlbl, LV_SYMBOL_TRASH " %s", TR("Factory reset")); lv_label_set_text(frl, frlbl); lv_obj_set_style_text_font(frl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(frl, lv_color_hex(0xFFE2E2), LV_PART_MAIN); lv_obj_center(frl); y += SC(46); y += settingsRowLabel(body, y, 0, TR("Erases identity, contacts, channels, messages, Wi-Fi and all settings, then reboots."), COLOR_SUB, &g_font_12, 0) + 4; } // Reopen the HAS_TDECK_KEYBOARD region paused above for the backup picker; it // closes at that region's original #endif further below. (The next #if is the // original spacebar-countdown guard, now nested one level deeper — harmless.) // Widened to HAS_PAGER_KEYBOARD too: handleHwKey() (defined in this region) // is exactly the routing function PagerKeyboard's char stream needs, and // every genuinely T-Deck-specific bit inside this range (the spacebar-lock // countdown, HAS_TDECK_GT911 touch bits) is already independently re-gated // on its own narrower macro, so it stays excluded for the pager regardless. #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // ---- Spacebar lock countdown ------------------------------------------------- // The spacebar locks the screen, but to avoid accidental locks it first runs a // 1 s "Locking…" countdown (tap anywhere, or press any other key, to cancel). // The T-Deck keyboard reports one byte per press with no reliable key-up, so a // literal "key held for 3 s" can't be measured — this is a press-to-start, // cancellable 3 s countdown that serves the same purpose. static lv_obj_t* s_locking_popup = nullptr; static lv_obj_t* s_locking_count = nullptr; static unsigned long s_locking_deadline = 0; static void cancelLockingCountdown() { s_locking_deadline = 0; if (s_locking_popup) { popupClose(&s_locking_popup); s_locking_count = nullptr; } } static void lockingCountdownTapCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) cancelLockingCountdown(); } static void startLockingCountdown() { if (s_locking_deadline) return; // already counting s_locking_deadline = millis() + 1000; s_locking_popup = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_locking_popup); lv_obj_set_size(s_locking_popup, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_set_pos(s_locking_popup, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_locking_popup, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_locking_popup, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_locking_popup, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_locking_popup, lockingCountdownTapCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* card = lv_obj_create(s_locking_popup); lv_obj_remove_style_all(card); lv_obj_set_size(card, 180, 104); lv_obj_center(card); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 12, LV_PART_MAIN); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, TR("Locking\xE2\x80\xA6")); // Locking… lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); s_locking_count = lv_label_create(card); lv_label_set_text(s_locking_count, "1"); lv_obj_set_style_text_color(s_locking_count, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_text_font(s_locking_count, &g_font_16, LV_PART_MAIN); lv_obj_align(s_locking_count, LV_ALIGN_CENTER, 0, 6); lv_obj_t* hint = lv_label_create(card); lv_label_set_text(hint, TR("tap to cancel")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); } // Advance / fire the countdown; called every loop tick from UITask::loop. static void serviceLockingCountdown(unsigned long now) { if (!s_locking_deadline) return; if ((long)(now - s_locking_deadline) >= 0) { cancelLockingCountdown(); if (g_lv.task) g_lv.task->lockScreen(); return; } if (s_locking_count) { const unsigned long remain = s_locking_deadline - now; char b[4]; snprintf(b, sizeof b, "%u", (unsigned)((remain + 999) / 1000)); // 3,2,1 lv_label_set_text(s_locking_count, b); } } #endif #if defined(HAS_M9_KEYBOARD) // ThinkNode M9 has no touchscreen and no navPump() (that's Tanmatsu-only) — the // d-pad through handleHwKey() is its ONLY input, including during first-boot // setup. T-Deck's handleHwKey() assumes touch drives the wizard and swallows // every non-editing key while s_setup_root is up (see the check just below // this function); M9 can't inherit that assumption, so its nav keys are // handled HERE, in their own function, called before that swallow — not // wedged into T-Deck's CAP_TRACKBALL chain as an #elif. Returns true if the // key was consumed. // (s_m9_map_pan — the Map-key pan-mode flag — is declared with the map state, // next to s_map_follow: mapAutoFollowTick pauses while it's set.) static bool m9HandleNavKey(int key) { if (!s_kbd_nav) return false; switch (key) { case M9_KEY_ENTER: if (navOnTabBar()) { navSwitchTab(+1); } else { navMarkEntered(lv_group_get_focused(s_nav_group)); navPushTap(LV_KEY_ENTER); } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_ENTER_LONG: { lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (foc && lv_obj_is_valid(foc)) lv_event_send(foc, LV_EVENT_LONG_PRESSED, nullptr); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } case M9_KEY_HW_BACK: // Restored after 0ea242c accidentally replaced this case with ENTER_LONG // (the board's only Back). Ladder mirrors pagerNavGoBack(): innermost // thing open closes first. An open dropdown LIST must take ESC before the // registry, or Back would close the enclosing modal instead of the list. // s_ct_select_mode is a flags=0 registry row (a MODE, not "a popup is // open"), so anyPopupOpen() alone would never let Back leave it. if (s_setup_root) return true; // wizard: nothing to back out of // Pan is only the innermost "thing open" while the bare map is frontmost. // A popup stacked over it (CTRL) or a tab jump that missed a clear makes // the flag stale — drop it silently so THIS press acts on what the user // sees, instead of a "Map pan off" toast reading as a dead Back key. if (s_m9_map_pan && (getActiveTab() != MAP_TAB_INDEX || anyPopupOpen())) s_m9_map_pan = false; if (s_m9_map_pan) { // pan mode is the innermost "thing open" on the map s_m9_map_pan = false; if (g_lv.task) g_lv.task->showAlert(TR("Map pan off"), 900); } else if (navOpenDropdown()) navPushTap(LV_KEY_ESC); // CTRL stacks the Control Center (and its power menu) over ANY popup, // but both sit near the BOTTOM of the popup registry — the generic // dismiss would close whatever popup is buried BENEATH them (a Files // rename prompt, typed name and all) while the screen looks unchanged. // They are always frontmost when open: close them explicitly, power // menu first (it opens over the CC). else if (s_power_menu) closePowerMenu(); else if (s_cc_root) closeControlCenter(); // An open app page covers everything EXCEPT the CC / power menu (peeled // above) and a confirm modal — the Lua send-permission ask is raised // OVER the requesting app's page, and closing the page under the // question would orphan it. The app drawer the page was launched from // stays open BENEATH it by design — the generic registry dismiss used // to eat that invisible drawer first, so Back looked dead inside every // app (reported bug). Close the page itself; the drawer is then the // next, visible Back target. else if (s_apppage_close && !s_confirm_modal) s_apppage_close(); else if (anyPopupOpen() || s_ct_select_mode) hwKeyDismissTopPopup(); else if (LvChatPanel* cp = navOpenChatPanel()) closeChatPanel(cp); else navPushTap(LV_KEY_ESC); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_GPS_LONG: // The controller's own long-press of SUB_MAP (0x84), labeled "GPS // toggle" in Elecrow's keyboard firmware — mirror the CC GPS chip. if (s_setup_root) return true; if (g_lv.task) { g_lv.task->toggleGPS(); g_lv.task->showAlert(g_lv.task->getGPSState() ? TR("GPS on") : TR("GPS off"), 800); } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_CTRL: // The controller latches ONE key and resolves layers itself, so CTRL can // never chord — bind the standalone press to the Control Center, a // one-press quick-settings key matching its label. if (s_setup_root) return true; // The CC must never stack OVER the power menu: Back's ladder peels // power first and would close it invisibly beneath the CC (the reverse // stacking is fine — openPowerMenu() closes the CC itself). if (s_power_menu) closePowerMenu(); openControlCenter(); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_HOME: if (s_setup_root) return true; s_nav_ta_editing = false; // Home is global, including while a textarea is being edited if (s_apppage_close) { // A full-screen app page (Lua app, Snake, Web…) isn't a popup-registry // row — close it and stop: one action per press, so HOME on the Home // tab doesn't also toggle the drawer underneath the closing page. // CTRL can stack the CC / power menu over the page, and the Lua // send-permission confirm opens over its requesting app — peel the // frontmost layer instead of yanking the page out from under it. if (s_power_menu) closePowerMenu(); else if (s_cc_root) closeControlCenter(); else if (s_confirm_modal) confirmDismiss(); else s_apppage_close(); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } if (getActiveTab() == HOME_TAB_INDEX) { const bool was_open = s_home_drawer_mode; // read BEFORE dismissing anything for (int i = 0; i < 8 && anyPopupOpen(); i++) hwKeyDismissTopPopup(); // still closes anything else on top (registry untouched, Back/etc. keep working) setHomeDrawer(!was_open); // decide from the snapshot, not the now-mutated flag } else { s_m9_map_pan = false; // leaving the Map: pan must not outlive the tab (a stale flag ate the next Back press) navGoToMainTab(HOME_TAB_INDEX); } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_LEFT_MESSAGE: if (s_setup_root) return true; s_m9_map_pan = false; // leaving the Map: same stale-pan clear as HOME if (s_apppage_close) s_apppage_close(); // close an open app page — else the jump lands invisibly beneath it navGoToMainTab(CHAT_INBOX_TAB_INDEX); if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_SUB_MESSAGE: if (s_setup_root) return true; s_m9_map_pan = false; // the overlay covers the map: arrows must drive the list, not pan under it // NOTHING may stay open beneath the overlay — mentions sits near the // bottom of the popup registry, so any row left open (CC, a Files // rename prompt, …) becomes Back's silent (invisible) next target. // Same bounded loop as HOME; a null-close progress row stops the walk // (blocker semantics), in which case don't stack the overlay either. for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } if (anyPopupOpen()) { if (g_lv.task) g_lv.task->noteUserInput(); return true; } if (s_apppage_close) s_apppage_close(); openMentionsScreen(); if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_MAP: if (s_setup_root) return true; if (getActiveTab() == MAP_TAB_INDEX && !anyPopupOpen() && !s_apppage_close) { // Already on the map: toggle pan mode (arrows pan, Map/Back exits). s_m9_map_pan = !s_m9_map_pan; s_m9_pan_gap_hinted = false; // fresh session: the Wi-Fi-off gap hint may fire once if (g_lv.task) g_lv.task->showAlert(s_m9_map_pan ? TR("Map pan: arrows pan, Back exits") : TR("Map pan off"), 1200); } else { s_m9_map_pan = false; // fresh entry always starts in nav mode if (s_apppage_close) s_apppage_close(); // close an open app page — else the jump lands invisibly beneath it navGoToMainTab(MAP_TAB_INDEX); } if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_SUB_MAP: if (s_setup_root) return true; s_m9_map_pan = false; // the page covers the map: same stale-pan clear as SUB_MESSAGE // Same full dismiss as SUB_MESSAGE: nothing may stay open beneath. for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } if (anyPopupOpen()) { if (g_lv.task) g_lv.task->noteUserInput(); return true; } // Close an open app page first — openAdvertPage() takes the single // s_apppage_close slot, so opening OVER a Lua app would steal its only // key-exit and strand the app unreachable behind the advert page. if (s_apppage_close) s_apppage_close(); openAdvertPage(); if (g_lv.task) g_lv.task->noteUserInput(); return true; default: return false; } } #endif #if defined(HAS_M9_KEYBOARD) static bool m9HandleArrowKey(int key, lv_obj_t* ta) { if (!s_kbd_nav) return false; // An open dropdown LIST owns the arrows (mirrors Tanmatsu's navPump capture): // navMoveDir would move group focus off the dropdown, which closes the list — // the "UP/DOWN closes the dropdown instead of moving its highlight" bug. The // FIFO'd LV_KEY_UP/DOWN reach the focused dropdown's own LV_EVENT_KEY handler, // which moves the highlighted option. if (navOpenDropdown()) { switch (key) { case M9_KEY_UP: navPushTap(LV_KEY_UP); break; case M9_KEY_DOWN: navPushTap(LV_KEY_DOWN); break; case M9_KEY_LEFT: case M9_KEY_RIGHT: break; // swallow — the list is vertical-only default: return false; } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } // Map pan mode (toggled by the Map key on the Map tab): arrows pan the map // instead of moving focus. Self-clears if the user somehow left the tab or // a popup got stacked over the map (CTRL's Control Center opens without // changing the active tab) — the arrows must drive what's frontmost, not // nudge the map buried beneath it. if (s_m9_map_pan) { if (getActiveTab() != MAP_TAB_INDEX || ta || anyPopupOpen()) { s_m9_map_pan = false; } else { switch (key) { case M9_KEY_UP: mapNudge(0); break; case M9_KEY_DOWN: mapNudge(1); break; case M9_KEY_LEFT: mapNudge(2); break; case M9_KEY_RIGHT: mapNudge(3); break; default: return false; } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Panned into an area with no cached tiles while offline: the fetch // queue silently no-ops without Wi-Fi, so the leading edge stays blank // with only the small corner label saying why. Say it once per session. if (!s_m9_pan_gap_hinted && s_map_last_missing > 0 && WiFi.status() != WL_CONNECTED) { s_m9_pan_gap_hinted = true; if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi off, new map areas can't download"), 1600); } #endif if (g_lv.task) g_lv.task->noteUserInput(); return true; } } switch (key) { case M9_KEY_UP: { lv_obj_t* const was = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; navMoveDir(NAV_UP); // Focus had nowhere to go: page-scroll instead. A display-only Lua app // scrolls its own body (RF Monitor's feed); everything else falls to // navScrollFocused — nearest scrollable ancestor, else the biggest // scrollable on screen — which is what makes sparse pages with few or // NO focusable rows (the Signal info popup's stats) scrollable at all // on a board with no touch to drag them. if (s_nav_group && lv_group_get_focused(s_nav_group) == was) { if (!(luaAppIsOpen() && luaAppScroll(true))) navScrollFocused(true); } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } case M9_KEY_DOWN: { lv_obj_t* const was = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; navMoveDir(NAV_DOWN); if (s_nav_group && lv_group_get_focused(s_nav_group) == was) { if (!(luaAppIsOpen() && luaAppScroll(false))) navScrollFocused(false); } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } case M9_KEY_LEFT: if (ta) { // Caret already at the start: fall through to focus-move so arrows // always eventually LEAVE the field (a silent boundary no-op read as // "textareas need 3-4 presses to move off"). const uint32_t p = lv_textarea_get_cursor_pos(ta); lv_textarea_cursor_left(ta); if (lv_textarea_get_cursor_pos(ta) == p) navMoveDir(NAV_LEFT); } else if (navOnTabBar()) navSwitchTab(-1); else navMoveDir(NAV_LEFT); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_RIGHT: if (ta) { const uint32_t p = lv_textarea_get_cursor_pos(ta); lv_textarea_cursor_right(ta); if (lv_textarea_get_cursor_pos(ta) == p) navMoveDir(NAV_RIGHT); // caret at end — same as LEFT } else if (navOnTabBar()) navSwitchTab(+1); else navMoveDir(NAV_RIGHT); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; default: return false; } } #endif // Route a physical-keyboard character into the textarea currently being edited // — the on-screen keyboard's target, which is the chat composer directly or the // settings mirror textarea. No field open -> the key is ignored. static void handleHwKey(int key) { if (!g_lv.keyboard) return; // A running Lua app gets the keyboard, which is what makes Lua apps usable at // all on the boards that have one (T-Deck, Pager, M9, Attaky) -- until now an // app only ever saw touch and trackball direction. // // The dismiss key is deliberately NOT forwarded: an app must never be able to // trap the user by swallowing its own exit, whether by bug or by design. if (luaAppIsOpen() && !isDismissKey(key) #if defined(HAS_M9_KEYBOARD) // Locked or dark: the lock/wake handlers below must see the key — else // ENTER_LONG (this board's only unlock) is eaten by the app and the // device can never be unlocked again without the power slider. && !(g_lv.task && (g_lv.task->isManualLock() || g_lv.task->isScreenOff())) // A confirm modal (the Lua send-permission ask) is a question for the // USER: arrows/Enter must reach its Allow/Deny buttons, not the app // that raised it — forwarded keys would make the dialog unanswerable // on a board with no touch to tap it. && !s_confirm_modal // Display-only apps (no on_input — Airtime, RF Monitor): forward nothing; // the d-pad keeps its native meaning so it can focus the page's own // buttons (Airtime's Reset), click them with Enter, and page-scroll the // body (m9HandleArrowKey's luaAppScroll fallback). luaAppKey would // otherwise eat every key into a callback that doesn't exist. && luaAppHasOnInput() #endif ) { #if defined(HAS_M9_KEYBOARD) // The M9 d-pad emits raw sentinel bytes no app understands (sendKey maps // only LV_KEY_*/printables) — feed them through the same steer/press // channel the T-Deck trackball uses, so store apps (Snake, 2048, …) are // playable here: arrows -> swipe, d-pad centre -> also a synthetic tap // (store apps' "tap to start / retry" listens for type=="down"). switch (key) { case M9_KEY_UP: luaAppSteer(0, -1); if (g_lv.task) g_lv.task->noteUserInput(); return; case M9_KEY_DOWN: luaAppSteer(0, 1); if (g_lv.task) g_lv.task->noteUserInput(); return; case M9_KEY_LEFT: luaAppSteer(-1, 0); if (g_lv.task) g_lv.task->noteUserInput(); return; case M9_KEY_RIGHT: luaAppSteer(1, 0); if (g_lv.task) g_lv.task->noteUserInput(); return; case M9_KEY_ENTER: luaAppPress(); break; // then also delivered as ev.key=="enter" below } #endif if (luaAppKey(key)) { if (g_lv.task) g_lv.task->noteUserInput(); return; } } if (g_lv.task && g_lv.task->isManualLock()) { #if defined(HAS_M9_KEYBOARD) // M9 has no trackball to hold — the keyboard controller's own long-press // detection (M9_KEY_ENTER_LONG, a distinct byte from a normal Enter tap) // stands in for "hold to unlock." No progressive countdown UI is possible // this way (we only get a single discrete "that was a long press" event, // not continuous press-state to poll), but it's a working equivalent. if (key == M9_KEY_ENTER_LONG) { g_lv.task->unlockScreen(); return; } #endif g_lv.task->lockscreenReveal(); return; } #if defined(HAS_M9_KEYBOARD) // M9 has no touch to wake the screen the way T-Deck/Heltec V4 do — mirror Tanmatsu's // navPump() pattern: any key wakes it (noteUserInput() wakes internally when the screen // is off), then THIS keypress is swallowed rather than also acted on, so waking doesn't // also navigate or type. if (g_lv.task && g_lv.task->isScreenOff()) { g_lv.task->noteUserInput(); return; } #else // Idle-dimmed (not hard-locked): ignore keys; a touch/click wakes into the UI. if (g_lv.task && g_lv.task->isScreenOff()) return; #if defined(TLORA_PAGER) // No touch and no trackball on this board: every OTHER input path that resets the // idle timer (touch taps, trackball moves) doesn't exist here, and below this point // a keypress only calls noteUserInput() when it lands in a focused field, dismisses // a popup, or matches a tab hotkey -- tabForKey() no longer maps any letters (tab // jumps moved to the trackball-only programmable hotkeys) and isDismissKey() always // returns false on this full QWERTY, so with no field focused EVERY key silently // no-op'd here and the idle timer kept counting down while the user was actively // pressing keys (reported bug: screen dims after ~30s despite keyboard input). if (g_lv.task) g_lv.task->noteUserInput(); noteKbActivity(); // any key counts as activity for the keyboard-backlight auto mode too #endif #endif // HAS_M9_KEYBOARD (wake-from-idle if/else) #if defined(HAS_TDECK_KEYBOARD) && defined(TDECK_KEYCODE_PROBE) // TEMP bring-up probe: toast the raw byte of every key so we can see what the // physical alt / mic / sym keys emit. Remove once the alt-accent key is wired. { char _pb[28]; snprintf(_pb, sizeof _pb, "key 0x%02X '%c'", key & 0xFF, (key >= 32 && key < 127) ? (char)key : '.'); if (g_lv.task) g_lv.task->showAlert(_pb, 1400); } #endif #if CAP_TRACKBALL // Remapping a tab hotkey (Settings → Keyboard): capture the next key press. if (s_navkey_capture >= 0) { navKeyCaptureApply(key); return; } #endif #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Any key other than the lock key (space) aborts a pending lock countdown. if (s_locking_deadline && key != ' ') cancelLockingCountdown(); #endif // The on-screen keyboard is never shown on the T-Deck, but a textarea is still // bound to it on focus — that binding is our target. lv_obj_t* ta_focused = lv_keyboard_get_textarea(g_lv.keyboard); // Guard a STALE binding (crash #98, decoded coredump: NULL spec_attr in lv_event_send). // lv_keyboard_get_textarea() returns the last-bound composer; if that textarea was deleted // without the keyboard being re-pointed (a chat-panel teardown that bypassed hideKb()), it's // a dangling pointer and typing into it dereferences freed memory. Validate + self-heal the // binding before anything below can insert into it. if (ta_focused && !lv_obj_is_valid(ta_focused)) { if (g_lv.keyboard) lv_keyboard_set_textarea(g_lv.keyboard, nullptr); ta_focused = nullptr; } #if CAP_KEYPAD_NAV && !defined(TLORA_PAGER) // Edit mode (keyboard-nav ON only): a focused field becomes the typing target after // select/Enter (below) — until then `ta` is null so the nav keys navigate. With // keyboard-nav OFF there is no navigation to protect (and no nav group to set the edit // flag), so a focused field always types directly — otherwise typing breaks entirely. // (Excludes the pager: it has no s_kbd_nav concept — see its own arm below.) lv_obj_t* ta = (ta_focused && (!s_kbd_nav || s_nav_ta_editing)) ? ta_focused : nullptr; #elif defined(TLORA_PAGER) // showKb() binds the composer to g_lv.keyboard as soon as a chat panel // opens and leaves it bound even after nav focus moves to another widget // in the same panel (e.g. the quick-reply/emoji icon) -- treating "a field // is bound" as "we're editing a field" then routes Enter into the // composer's submit/newline handling below instead of activating whatever // is actually focused. Only count as editing when nav focus is really ON // that field (mirrors CAP_TRACKBALL's s_kbd_nav/s_nav_ta_editing check // above). Reported bug: the rotary encoder's short click opened the // quick-reply/emoji picker (it bypasses this function entirely, sending // LV_KEY_ENTER straight into the focus group via navPushTap()), but Enter // on the keyboard did not. lv_obj_t* ta = (ta_focused && s_nav_group && lv_group_get_focused(s_nav_group) == ta_focused) ? ta_focused : nullptr; #else lv_obj_t* ta = ta_focused; #endif #if defined(HAS_M9_KEYBOARD) // Unlike directional keys and Back, the dedicated Home key is global. Route // it before the textarea split so edit mode cannot swallow it as an unknown // non-printable byte. if (key == M9_KEY_HOME && m9HandleNavKey(key)) return; if (m9HandleArrowKey(key, ta)) return; // ← runs BEFORE the if(!ta) split #endif if (!ta) { #if defined(TLORA_PAGER) // No field is bound to the on-screen keyboard, so nav focus is on a plain // widget (button/switch/list row). Enter = the same "submit/click" the // encoder's short click also sends via navPushTap(LV_KEY_ENTER) -- // works during the setup wizard too, matching encoder parity (hence // ahead of the s_setup_root check below). EXCEPT on a focused chat // bubble, which has no touch/trackball to long-press here -- Enter opens // the same Ack/Mention/Copy/Info/Block action menu instead (navEnterBubble, // shared with Tanmatsu's identical Enter-on-bubble handling, and with the // encoder's own short click in updatePagerEncoder() -- both inputs agree). if (key == 0x0D) { if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); if (g_lv.task) g_lv.task->noteUserInput(); return; } // Backspace tap in a chat = jump to the first NEW message (the one right // below the "NEW ----" unread divider) and focus it, so the user reads the // new messages chronologically with plain encoder turns from there; with // no divider (nothing unread) it jumps to the NEWEST message instead. // The former solo-Alt shortcut now latches the symbol layer; at the true // newest message, the next encoder detent leaves the list for the composer. // The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider // path uses; both cases use the pending-focus re-aim so the recreated row // for the target message ends up focused after the virtualization render. // Outside a chat, Backspace keeps its normal no-op / hold-to-back // behavior below. if (key == 0x08) { LvChatPanel* cp = navOpenChatPanel(); if (cp && cp->msgs && s_chat_virt.panel == cp && s_chat_virt.n > 0) { if (s_chat_virt.divider_i >= 0 && s_chat_virt.divider_y >= 0) { const int32_t virt = (s_chat_virt.divider_y > 8) ? (s_chat_virt.divider_y - 8) : 0; chatVirtResetInputForMsgs(cp); chatVirtCancelRenderTimer(); s_chat_virt.last_i0 = -1; // force the reflow, same as chatVirtJumpToLatest s_chat_virt.last_i1 = -1; chatVirtQueueScroll(cp, chatVirtVirtToLv(virt)); chatUpdateJumpButtons(cp); s_pager_chat_focus_i = s_chat_virt.divider_i; // land focus on the first NEW message } else { chatVirtJumpToLatest(cp); // nothing unread -> newest message s_pager_chat_focus_i = s_chat_virt.n - 1; } s_pager_chat_focus_ms = millis(); if (g_lv.task) g_lv.task->noteUserInput(); return; } } // Bottom-bar mnemonic shortcuts: active only on a bare main tab (the // navOnMainPage gate lives in tabForKey). Do this before Map panning so S // can leave Map for Settings. If the requested tab is already active, let // the key continue: A on Map remains the west-pan control. const int pager_tab = tabForKey(key); if (pager_tab >= 0 && pager_tab != getActiveTab()) { goToTab(pager_tab); if (g_lv.task) g_lv.task->noteUserInput(); return; } // Slider nudge: this board has no touch/trackball to drag a slider's // knob, so a focused lv_slider (Control Center brightness, a Settings // slider, the Map zoom bar, …) is otherwise stuck at whatever value it // opened with. Q/E reuse navMoveDir()'s existing slider-capture branch // (proportional ~20-presses-end-to-end step, live update + persist) — // the same adjustment the T-Deck trackball's LEFT/RIGHT already does — // rather than a fixed step that's wrong for every slider's range. (Was // D/F; moved to Q/E to free up the map-panning keys below.) if (key == 'q' || key == 'Q' || key == 'e' || key == 'E') { lv_obj_t* focused = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; if (focused && lv_obj_check_type(focused, &lv_slider_class)) { navMoveDir((key == 'e' || key == 'E') ? NAV_RIGHT : NAV_LEFT); return; } } // Map pan: W/A/X/D moves the map around on the Map tab (no touch/trackball // to drag it), reusing the same mapNudge() step/re-render Tanmatsu's // Ctrl+Arrow already uses. X replaces S because S is now the Settings // mnemonic. W=north A=west X=south D=east. if ((key == 'w' || key == 'W' || key == 'a' || key == 'A' || key == 'x' || key == 'X' || key == 'd' || key == 'D') && getActiveTab() == MAP_TAB_INDEX) { switch (key) { case 'w': case 'W': mapNudge(0); break; case 'x': case 'X': mapNudge(1); break; case 'a': case 'A': mapNudge(2); break; case 'd': case 'D': mapNudge(3); break; } return; } #endif #if CAP_TRACKBALL || defined(HAS_THINKNODE_M9) // A field is focused but we're in navigate mode: select/Enter starts editing it, so the // letter-nav keys keep navigating until you explicitly enter the field (matches navPump). // navFocusedTextarea() (nav-group focus), not the local ta_focused (kbd-bound field): the // two can differ, and M9 needs the nav-group's own view of what's focused here. if (navFocusedTextarea() && s_kbd_nav && (key == '\r' || key == '\n' || navDirForKey(key) == 4)) { s_nav_ta_editing = true; if (g_lv.task) g_lv.task->noteUserInput(); return; } #endif #if defined(HAS_M9_KEYBOARD) if (m9HandleNavKey(key)) return; // ← runs INSIDE the if(!ta) block only #endif if (s_setup_root) return; #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Spacebar (while NOT editing a text field) locks the screen: backlight off // + manual lock, so touch and trackball-scroll are ignored — only a // trackball CLICK unlocks it. The T-Deck's only side button is a hardware // reset (unremappable), so the spacebar is the keyboard lock key. NB this // overrides the old "space = Home tab" shortcut — use 'h' for Home. if (key == ' ') { startLockingCountdown(); // 1 s "Locking…" countdown; tap / any other key cancels return; } #endif #if CAP_TRACKBALL // Keyboard navigation (opt-in): with no text field focused, the WASDZ cluster // moves focus through s_nav_group — W up, Z down, A left, D right, S select, // Q back — and the programmable tab hotkeys (default E/R/T/U/I) jump straight to // a main tab. Lets you reach + drive the whole UI from the keyboard while the // trackball stays a mouse cursor. navMaybeRebuild() (in the loop) keeps the // focus group synced to the screen. if (s_kbd_nav) { const int act = navDirForKey(key); // 0-5 = up,down,left,right,select,back (programmable), or -1 if (act >= 0) { switch (act) { case 0: navMoveDir(NAV_UP); break; case 1: navMoveDir(NAV_DOWN); break; case 2: navMoveDir(NAV_LEFT); break; case 3: navMoveDir(NAV_RIGHT); break; case 4: if (navOnTabBar()) navSwitchTab(+1); else navPushTap(LV_KEY_ENTER); break; // select case 5: // back: popup → chat → ESC if (anyPopupOpen()) hwKeyDismissTopPopup(); else if (LvChatPanel* cp = navOpenChatPanel()) closeChatPanel(cp); // close an open chat/channel first else navPushTap(LV_KEY_ESC); break; case 6: navScrollFocused(true); break; // scroll up default: navScrollFocused(false); break; // scroll down (case 7) } s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return; // reveal the focus highlight } // Programmable tab hotkeys (default E/R/T/U/I) — jump straight to a main tab. // The Home hotkey mirrors tapping the Home tab: on Home it toggles Commander // <-> app drawer, otherwise it goes Home. BUT NOT while a text field is the // focused element — a hotkey letter must never yank you off a field you're // working in (e.g. the chat composer in navigate mode); the directional nav // above still moves focus. Other letters are swallowed here so they can't reach // the tabForKey fallback below and tab-jump either. const bool on_textfield = navFocusedTextarea() != nullptr; const int njump = navTabForHotkey(key); if (njump >= 0 && !on_textfield) { // Home hotkey mirrors tapping the Home tab: on Home toggle Commander <-> app // drawer, else go Home. (homeKeyActivate is HAS_TANMATSU-only, so inline it.) if (njump == HOME_TAB_INDEX && getActiveTab() == HOME_TAB_INDEX) setHomeDrawer(!s_home_drawer_mode); else navGoToMainTab(njump); if (g_lv.task) g_lv.task->noteUserInput(); return; } if (on_textfield) { if (g_lv.task) g_lv.task->noteUserInput(); return; } // field focused: never tab-jump on a letter } #endif // Not editing a field. If a popup is up, the dismiss keys close it; on a // bare tab, the navigation keys jump between the bottom tabs. if (anyPopupOpen()) { if (isDismissKey(key)) { hwKeyDismissTopPopup(); if (g_lv.task) g_lv.task->noteUserInput(); } } else { const int tab = tabForKey(key); if (tab >= 0 && g_lv.tabview) { lv_tabview_set_act(g_lv.tabview, (uint32_t)tab, LV_ANIM_OFF); if (g_lv.task) g_lv.task->noteUserInput(); } } return; } txtMenuHide(); // any keypress while editing dismisses an open edit menu #if CAP_KEYPAD_NAV // Accent-variant / @-mention popups (issues #22, #42) are otherwise // unreachable here: no touch, and their cells are NAV_SKIP_FLAG by design // (touch boards pick them by tap). The rotary encoder walks the highlighted // cell (updatePagerEncoder()); Enter (the encoder's own click) confirms; // Backspace cancels without touching the text that triggered it. // // The @-mention list grabs the encoder the INSTANT it appears (set in // mentionBoxMaybeShow()) -- it only shows up after the user has deliberately // typed "@partial" looking for someone, so there's no ongoing-typing to // protect. The accent box is different: it pops up after almost every // vowel/consonant typed, so stealing focus immediately would swallow normal // typing -- it stays passive until explicitly armed with Fn(Alt)+Space. if (s_mentionnav_active) { #if defined(HAS_M9_KEYBOARD) if (key == M9_KEY_UP) { mentionNavMove(-1); return; } if (key == M9_KEY_DOWN) { mentionNavMove(+1); return; } if (key == M9_KEY_LEFT || key == M9_KEY_RIGHT) { mentionBoxHide(); // normal M9 caret handling continues below } else if (key == M9_KEY_HW_BACK) { mentionBoxHide(); return; } #endif if (key == 0x08 || key == 0x7F) { mentionBoxHide(); return; } if (key == 0x0D) { mentionNavConfirm(); return; } // Printable input falls through to the textarea and re-filters the list. } #endif #if defined(TLORA_PAGER) if (s_accentnav_active) { if (key == 0x08 || key == 0x7F) { accentBoxHide(); return; } if (key == 0x0D) { accentNavConfirm(); return; } return; // swallow everything else while picking (typing, etc.) } if (key == ' ' && pagerKeyboardAltHeld() && s_accbox) { s_accentnav_active = true; s_accentnav_idx = 0; accentNavRestyle(); return; } #endif #if defined(HAS_M9_KEYBOARD) // M9 has a dedicated hardware Back key — unlike the CAP_TRACKBALL board's backspace-on- // empty-field escape below, this doesn't require emptying the field first: Back always // drops out of edit mode back to navigate mode, the same job HW_BACK does everywhere else // on this board (m9HandleNavKey). Without this, M9's d-pad bytes (0xB4-0xB7, 0x86) don't // match backspace/space/Enter/printable-ASCII below and are silently swallowed — there was // no way out of edit mode at all except an empty-field backspace, which M9 doesn't get // (that escape is CAP_TRACKBALL-gated). if (key == M9_KEY_HW_BACK) { s_nav_ta_editing = false; s_nav_show = true; accentBoxHide(); mentionBoxHide(); // leaving edit mode tears down BOTH typing popups, like Enter does if (g_lv.task) g_lv.task->noteUserInput(); return; } #endif if (key == 0x08 || key == 0x7F) { // backspace / delete uint32_t bs_s, bs_e; if (taHasSelection(ta, &bs_s, &bs_e)) { // highlighted text -> delete the whole selection taDeleteRange(ta, bs_s, bs_e); taClearSelection(ta); } else { #if CAP_TRACKBALL // Keyboard nav: backspace in an EMPTY field drops back to navigate mode — the // discoverable keyboard way OUT of the auto-focused chat composer (Enter on an // empty composer already did this, silently). Reveal the focus highlight so the // mode change is visible. (wyvern.red: "does not let me exit it with the keyboard") if (s_kbd_nav && !lv_textarea_get_text(ta)[0]) { s_nav_ta_editing = false; s_nav_show = true; accentBoxHide(); if (g_lv.task) g_lv.task->noteUserInput(); return; } #endif lv_textarea_del_char(ta); } accentBoxHide(); } else if (key == ' ') { // Double-tap SPACE within 250 ms toggles between English and the // configured secondary keyboard. If no secondary is set, it behaves // as a normal space. static unsigned long s_last_space_ms = 0; unsigned long now = millis(); bool toggle = (now - s_last_space_ms) < 250; s_last_space_ms = now; if (toggle && keyboardLayoutsAnySecondary()) { // Remove the just-inserted first space before cycling. lv_textarea_del_char(ta); KeyboardLayoutId next = keyboardLayoutsCycle(g_lv.keyboard); #if defined(ESP32) touchPrefsSetKeyboardLayout(static_cast<uint8_t>(next)); #endif if (g_lv.task) { g_lv.task->showAlert(keyboardLayoutName(next), 800); } } else { lv_textarea_add_char(ta, ' '); } } else if (key == 0x0D) { // regular ENTER (CR) // Enter is a non-typing action (send / newline / submit) — always tear down the // accent + @-mention popups. The Enter-to-send path keeps the keyboard up "for the // next message", so without this the accent box for the last letter lingers over // the now-cleared composer (the reported bug). The on-screen Send button already // goes through hideKb(); this covers the physical-keyboard Enter. accentBoxHide(); mentionBoxHide(); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_M9_KEYBOARD) if (s_editor_ta && ta == s_editor_ta) { lv_textarea_add_char(ta, '\n'); // multiline editor: Enter inserts a newline } else if (s_term_input_ta && s_kb_bind_ta == s_term_input_ta) { terminalSubmit(); // terminal: run the command, keep the field focused } else #endif if (s_kb_panel) { LvChatPanel* p = s_kb_panel; #if defined(ESP32) // Enter-to-send can be disabled (Tim: avoid accidental sends on the public // channel). When off, Enter just adds a newline so you compose multi-line // and send only via the on-screen button. if (!touchPrefsGetEnterSends()) { if (p->composer_ta) lv_textarea_add_char(p->composer_ta, '\n'); } else #endif // Chat composer: send and keep the composer focused for the next message. if (g_lv.task && p->composer_ta) { const char* text = lv_textarea_get_text(p->composer_ta); if (text && text[0]) { g_lv.task->setComposerMode(true); g_lv.task->composerReset(); for (const char* cp = text; *cp; ++cp) g_lv.task->composerAppendChar(*cp); if (g_lv.task->composerSend()) { lv_textarea_set_text(p->composer_ta, ""); refreshChatDetailAsync(*p); g_lv.dirty_threads = true; } lv_keyboard_set_textarea(g_lv.keyboard, p->composer_ta); // re-bind } #if CAP_TRACKBALL else if (s_kbd_nav) { s_nav_ta_editing = false; s_nav_show = true; } // Enter on an EMPTY composer: drop to navigate mode, highlight visible #endif } } else { #if CAP_KEYPAD_NAV && !defined(TLORA_PAGER) // Keyboard/d-pad nav: Enter in a generic field ADVANCES rather than submits — with // multiple fields on one page (e.g. the wizard's Wi-Fi step: SSID + password) there's // no single "the field" to submit from, so Enter walks the focus group forward one // step (next field, or the Next/Finish button once fields run out), exactly like // Tanmatsu's navPump() already does elsewhere in this file // (`navFifoPush(ta ? LV_KEY_NEXT : LV_KEY_ENTER, down)`). Landing on that button with // s_nav_ta_editing now false means the NEXT Enter goes through the normal focused- // button click path instead of back into this typing branch — that's what submits. if (s_kbd_nav) { hideKb(); // still confirm: sync the mirror into the real field s_nav_ta_editing = false; s_nav_show = true; navPushTap(LV_KEY_NEXT); if (g_lv.task) g_lv.task->noteUserInput(); return; } #endif hideKb(); // settings/other field: confirm (syncs into the real field) + unfocus } } else if (key >= 0x20 && key < 0x7F) { // printable ASCII bool shifted = (key >= 'A' && key <= 'Z'); const char* mapped = keyboardLayoutMapHwKey(keyboardLayoutsGetCurrent(), key, shifted); if (mapped) { lv_textarea_add_text(ta, mapped); } else { lv_textarea_add_char(ta, (uint32_t)key); } composerMentionRefresh(ta); // @mention contact picker, else the accent box } if (g_lv.task) g_lv.task->noteUserInput(); } #endif static void refreshLiveDiag(unsigned long now) { if (!s_live_diag_label) return; if (now < s_live_diag_next_ms) return; s_live_diag_next_ms = now + 250; uint16_t _rawx = 0, _rawy = 0; heltecV4CapTouchGetRaw(&_rawx, &_rawy); char line[256]; snprintf(line, sizeof(line), "L%lu R%lu P%lu T%lu %s\nraw %u,%u\n%s", static_cast<unsigned long>(s_live_diag_loops), static_cast<unsigned long>(s_live_diag_reads), static_cast<unsigned long>(s_live_diag_pressed), static_cast<unsigned long>(s_live_diag_tap_edges), g_lv.touch_inited ? "touch:ok" : "touch:wait", static_cast<unsigned>(_rawx), static_cast<unsigned>(_rawy), heltecV4CapTouchDebug()); // Dev-only overlay (k_show_live_diag_overlay): the label is created hidden in // normal builds, so this just keeps it current for when a developer flips the // flag on for touch bring-up. No effect on the shipped UI. lv_label_set_text(s_live_diag_label, line); } /** Brief banner on `lv_layer_top()` so feedback is visible over settings modals. */ static lv_obj_t* s_alert_toast = nullptr; static lv_timer_t* s_alert_toast_timer = nullptr; static void alertToastTimerCb(lv_timer_t* t) { s_alert_toast_timer = nullptr; if (s_alert_toast) lv_obj_add_flag(s_alert_toast, LV_OBJ_FLAG_HIDDEN); if (t) lv_timer_del(t); } static void showAlertToastLvgl(const char* text, uint32_t duration_ms) { if (!text || !text[0]) return; if (!lv_disp_get_default() || !g_lv.ready) return; if (duration_ms < 400u) duration_ms = 400u; if (duration_ms > 8000u) duration_ms = 8000u; if (s_alert_toast_timer) { lv_timer_del(s_alert_toast_timer); s_alert_toast_timer = nullptr; } if (!s_alert_toast) { // On lv_layer_sys (the status-bar layer), moved to front each show, so the toast // banners ABOVE the top bar instead of hiding behind it (lv_layer_top sits BELOW // lv_layer_sys, so the double-height bar was covering it). s_alert_toast = lv_obj_create(lv_layer_sys()); lv_obj_remove_style_all(s_alert_toast); lv_obj_set_width(s_alert_toast, 224); lv_obj_set_style_bg_opa(s_alert_toast, LV_OPA_90, LV_PART_MAIN); lv_obj_set_style_bg_color(s_alert_toast, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_radius(s_alert_toast, 12, LV_PART_MAIN); lv_obj_set_style_border_width(s_alert_toast, 1, LV_PART_MAIN); lv_obj_set_style_border_color(s_alert_toast, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_pad_hor(s_alert_toast, 12, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_alert_toast, 10, LV_PART_MAIN); lv_obj_clear_flag(s_alert_toast, LV_OBJ_FLAG_SCROLLABLE); // The toast is informational, not interactive — make it non-clickable // so taps pass through to whatever's behind (most importantly the map // canvas's pan handler). lv_obj_create defaults to CLICKABLE on, which // meant any tap inside the toast's 224×~44 strip was being swallowed // for the full 0.9-1.5 s display duration — UI felt "frozen" until // auto-hide. lv_obj_clear_flag(s_alert_toast, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(s_alert_toast, LV_OBJ_FLAG_FLOATING); lv_obj_t* l = lv_label_create(s_alert_toast); lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, 200); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_CENTER, 0, 0); } char san_toast[96]; copyUtf8ReplacingMissingGlyphs(&g_font_14, san_toast, sizeof(san_toast), text); lv_obj_t* lbl = lv_obj_get_child(s_alert_toast, 0); if (lbl) lv_label_set_text(lbl, san_toast); lv_obj_update_layout(s_alert_toast); { lv_obj_t* lbl2 = lv_obj_get_child(s_alert_toast, 0); lv_coord_t inner = lbl2 ? (lv_obj_get_height(lbl2) + 20) : 44; if (inner < 44) inner = 44; if (inner > 96) inner = 96; lv_obj_set_height(s_alert_toast, inner); } // Banner in from the very top, ON TOP of the bar (iOS-style) — never hidden under // the regular OR double-height bar. lv_obj_align(s_alert_toast, LV_ALIGN_TOP_MID, 0, 4); lv_obj_clear_flag(s_alert_toast, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_alert_toast); s_alert_toast_timer = lv_timer_create(alertToastTimerCb, duration_ms, nullptr); lv_timer_set_repeat_count(s_alert_toast_timer, 1); } // A deliberately understated notification chip for background traffic (incoming // DM / channel / room message / new contact). Compact, muted and borderless — // no bright accent border, smaller font, content-sized pill — so it reads as a // passing hint, not a modal. These events are also reflected in the tab badges. // Its own object so it never restyles the shared (prominent) alert toast. static lv_obj_t* s_notify_chip = nullptr; static lv_timer_t* s_notify_chip_timer = nullptr; static void notifyChipTimerCb(lv_timer_t* t) { s_notify_chip_timer = nullptr; if (s_notify_chip) lv_obj_add_flag(s_notify_chip, LV_OBJ_FLAG_HIDDEN); if (t) lv_timer_del(t); } static void showSubtleNotifyLvgl(const char* text, uint32_t duration_ms) { if (!text || !text[0]) return; if (!lv_disp_get_default() || !g_lv.ready) return; if (duration_ms < 400u) duration_ms = 400u; if (duration_ms > 8000u) duration_ms = 8000u; if (s_notify_chip_timer) { lv_timer_del(s_notify_chip_timer); s_notify_chip_timer = nullptr; } if (!s_notify_chip) { s_notify_chip = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_notify_chip); lv_obj_set_size(s_notify_chip, LV_SIZE_CONTENT, LV_SIZE_CONTENT); // hugs the text lv_obj_set_style_bg_opa(s_notify_chip, LV_OPA_90, LV_PART_MAIN); lv_obj_set_style_bg_color(s_notify_chip, lv_color_hex(0x16191C), LV_PART_MAIN); // muted dark grey, not the accent-bordered panel lv_obj_set_style_radius(s_notify_chip, 14, LV_PART_MAIN); // pill lv_obj_set_style_border_width(s_notify_chip, 1, LV_PART_MAIN); lv_obj_set_style_border_color(s_notify_chip, lv_color_hex(0x33383E), LV_PART_MAIN); // dim hairline, no bright accent lv_obj_set_style_pad_hor(s_notify_chip, 12, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_notify_chip, 5, LV_PART_MAIN); lv_obj_clear_flag(s_notify_chip, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(s_notify_chip, LV_OBJ_FLAG_CLICKABLE); // taps pass through lv_obj_add_flag(s_notify_chip, LV_OBJ_FLAG_FLOATING); lv_obj_t* l = lv_label_create(s_notify_chip); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); // smaller than the alert toast } char san[64]; copyUtf8ReplacingMissingGlyphs(&g_font_12, san, sizeof(san), text); lv_obj_t* lbl = lv_obj_get_child(s_notify_chip, 0); if (lbl) lv_label_set_text(lbl, san); lv_obj_update_layout(s_notify_chip); lv_obj_align(s_notify_chip, LV_ALIGN_TOP_MID, 0, STATUSBAR_H + 4); // tucked under the status bar lv_obj_clear_flag(s_notify_chip, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_notify_chip); s_notify_chip_timer = lv_timer_create(notifyChipTimerCb, duration_ms, nullptr); lv_timer_set_repeat_count(s_notify_chip_timer, 1); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static const char* wifiStaStatusBrief(int s) { switch (s) { case WL_CONNECTED: return "connected"; case WL_IDLE_STATUS: return "idle"; case WL_NO_SHIELD: /* Often stale while STA is coming up after BLE; firmware re-issues begin in main. */ if (WiFi.getMode() == WIFI_STA) return "joining…"; return "off"; case WL_NO_SSID_AVAIL: return "AP not found"; case WL_SCAN_COMPLETED: return "scan done"; case WL_CONNECT_FAILED: { // Append the esp_wifi disconnect reason — "auth failed" alone covers // wrong password (r15, 4-way handshake timeout), WPA3-only/H2E SAE // failures (r2/r202), and AP-side rejection alike. extern volatile uint8_t g_wifi_last_disc_reason; // defined at the foot of this file static char s_auth_fail[24]; if (g_wifi_last_disc_reason) { snprintf(s_auth_fail, sizeof s_auth_fail, "auth failed (r%u)", (unsigned)g_wifi_last_disc_reason); return s_auth_fail; } return "auth failed"; } case WL_CONNECTION_LOST: return "link lost"; case WL_DISCONNECTED: return "disconnected"; default: return "connecting…"; } } #endif static void refreshSettingsSectionSubtitles() { if (!g_lv.task) return; NodePrefs* prefs = the_mesh.getNodePrefs(); if (g_set_sec_sub[SEC_PROFILE]) { const char* nm = g_lv.task->getNodeNameCstr(); if (!nm) nm = ""; char nm_vis[48]; copyUtf8ReplacingMissingGlyphs(&g_font_12, nm_vis, sizeof(nm_vis), nm); char sub[72]; if (prefs) { snprintf(sub, sizeof(sub), "%.22s · share %s · path m%u", nm_vis, prefs->advert_loc_policy ? "on" : "off", static_cast<unsigned>(prefs->path_hash_mode > 2 ? 2 : prefs->path_hash_mode)); } else { snprintf(sub, sizeof(sub), "%.40s", nm_vis); } lv_label_set_text(g_set_sec_sub[SEC_PROFILE], sub); } if (g_set_sec_sub[SEC_RADIO] && prefs) { lv_label_set_text_fmt(g_set_sec_sub[SEC_RADIO], TR("%.3f MHz · SF%u · TX %+ddBm"), static_cast<double>(prefs->freq), static_cast<unsigned>(prefs->sf), static_cast<int>(prefs->tx_power_dbm)); } else if (g_set_sec_sub[SEC_RADIO]) { lv_label_set_text(g_set_sec_sub[SEC_RADIO], "—"); } if (g_set_sec_sub[SEC_AUTOADD] && prefs) { unsigned bits = 0; if (prefs->autoadd_config & AUTO_ADD_CHAT) ++bits; if (prefs->autoadd_config & AUTO_ADD_REPEATER) ++bits; if (prefs->autoadd_config & AUTO_ADD_ROOM_SERVER) ++bits; if (prefs->autoadd_config & AUTO_ADD_SENSOR) ++bits; lv_label_set_text_fmt(g_set_sec_sub[SEC_AUTOADD], TR("%u types · hops %u · manual %s"), bits, static_cast<unsigned>(prefs->autoadd_max_hops), prefs->manual_add_contacts ? "on" : "off"); } else if (g_set_sec_sub[SEC_AUTOADD]) { lv_label_set_text(g_set_sec_sub[SEC_AUTOADD], "—"); } if (g_set_sec_sub[SEC_BLUETOOTH]) { // BLE coexists with Wi-Fi now, so it's simply Active when enabled. if (g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled()) { lv_label_set_text(g_set_sec_sub[SEC_BLUETOOTH], TR("Active")); } else { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) lv_label_set_text(g_set_sec_sub[SEC_BLUETOOTH], (g_lv.task->hasBleCapability() && bleRequestedOrEnabled()) ? TR("Starting…") : TR("Off")); #else lv_label_set_text(g_set_sec_sub[SEC_BLUETOOTH], TR("Inactive")); #endif } } // Wi-Fi row subtitle: brief connection state (handled on its own dedicated page). if (g_set_sec_sub[SEC_WIFI]) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) char ssid[WIFI_CONFIG_SSID_MAX]; wifiConfigGetSsid(ssid, sizeof(ssid)); const int re = wifiConfigGetRadioEnabled() ? 1 : 0; if (!re) { lv_label_set_text(g_set_sec_sub[SEC_WIFI], TR("Radio off")); } else if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); lv_label_set_text_fmt(g_set_sec_sub[SEC_WIFI], "%s · %d.%d.%d.%d", ssid[0] ? ssid : "(none)", ip[0], ip[1], ip[2], ip[3]); } else { lv_label_set_text_fmt(g_set_sec_sub[SEC_WIFI], "%s · %s", ssid[0] ? ssid : "(none)", wifiStaStatusBrief(static_cast<int>(WiFi.status()))); } #else lv_label_set_text(g_set_sec_sub[SEC_WIFI], TR("n/a in this build")); #endif } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // wifi_sta_status_l is the dedicated Wi-Fi page's status line — created ONLY by // buildWifiSettings, for the old modal AND the inline Net sub-tab. The old gate // also required g_set_modal.root + kind==Wifi (both modal-only), so the inline // sub-tab was stuck on "Loading…". resetSettingsModalState() zeroes the struct on // every sub-tab switch, so a non-null pointer reliably means the page is live. if (g_set_modal.wifi_sta_status_l) { const int re2 = wifiConfigGetRadioEnabled() ? 1 : 0; if (!re2) { lv_label_set_text(g_set_modal.wifi_sta_status_l, TR("Radio: off")); } else { char line[160]; const int st = static_cast<int>(WiFi.status()); char ssid_buf[WIFI_CONFIG_SSID_MAX]; wifiConfigGetSsid(ssid_buf, sizeof(ssid_buf)); if (st == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); const int rssi = WiFi.RSSI(); // 2 lines: ssid on top, then IP + RSSI + WS on one compact line (the IP // used to be on its own line, pushing the block to 3 lines → overlap). snprintf(line, sizeof(line), "Connected: %s\nIP %u.%u.%u.%u · %d dBm · WS %d", ssid_buf[0] ? ssid_buf : "(unnamed)", (unsigned)ip[0], (unsigned)ip[1], (unsigned)ip[2], (unsigned)ip[3], rssi, g_lv.task->getWsConnectedCount()); } else { snprintf(line, sizeof(line), "%s%s\n%s", ssid_buf[0] ? "Target: " : "", ssid_buf[0] ? ssid_buf : "(no SSID set)", wifiStaStatusBrief(st)); } lv_label_set_text(g_set_modal.wifi_sta_status_l, line); } } #endif if (g_set_sec_sub[SEC_DEVICE]) { #if defined(HAS_EXPANSION_KIT) char env[192]; if (g_lv.task->getLocalEnvSummary(env, sizeof env)) { lv_label_set_text_fmt(g_set_sec_sub[SEC_DEVICE], TR("GPS %s · Buzzer %s\n%s"), onOff(g_lv.task->getGPSState()), g_lv.task->isBuzzerQuiet() ? "quiet" : "on", env); } else #endif { lv_label_set_text_fmt(g_set_sec_sub[SEC_DEVICE], TR("GPS %s · Buzzer %s"), onOff(g_lv.task->getGPSState()), g_lv.task->isBuzzerQuiet() ? "quiet" : "on"); } } // Live GPS status while the GPS page (or the legacy Device modal) is open. (The control-center // drop-down's GPS + system line are refreshed from the periodic refreshStatusLabels tick // instead — this function only runs while Settings is.) // // This used to require kind == SettingsModalKind::Device, which stopped being true when the // settings reorg moved the GPS block into its own PAGE (CAT_GPS -> buildDeviceSettings(DSEC_GPS)). // The condition then never matched, so the label was written once at build time and never again: // the page looked frozen and only "updated" when you closed and reopened it — including the // elapsed-search timer, which made a working receiver look stuck. Accept either host, and guard // with lv_obj_is_valid so a stale pointer from a closed page can never be written. if (g_set_modal.gps_status && lv_obj_is_valid(g_set_modal.gps_status) && (s_settings_open_cat == CAT_GPS || (g_set_modal.root && g_set_modal.kind == SettingsModalKind::Device))) { lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); } if (g_set_sec_sub[SEC_EXPERIMENTAL] && prefs) { lv_label_set_text_fmt(g_set_sec_sub[SEC_EXPERIMENTAL], TR("Multi-ack %s · Repeat %s · RX boost %s"), onOff(prefs->multi_acks != 0), onOff(prefs->client_repeat != 0), onOff(prefs->rx_boosted_gain != 0)); } else if (g_set_sec_sub[SEC_EXPERIMENTAL]) { lv_label_set_text(g_set_sec_sub[SEC_EXPERIMENTAL], "—"); } if (g_set_sec_sub[SEC_LOG]) { lv_label_set_text_fmt(g_set_sec_sub[SEC_LOG], TR("RX %d entries · Raw %d entries"), s_rxlog_line < RXLOG_LINES ? s_rxlog_line : RXLOG_LINES, s_rawlog_line < RXLOG_LINES ? s_rawlog_line : RXLOG_LINES); } } // ============================================================ // Status-bar control center (tap the top bar) // A small drop-down panel with date/time + battery/Wi-Fi info and quick // Wi-Fi / Bluetooth toggles, iPhone-control-center style. Resident stacks // toggle live; a genuinely cold Pager allocation may take an ordered restart. // ============================================================ static void closeControlCenter() { if (s_cc_root) { popupClose(&s_cc_root); } s_cc_gps_label = nullptr; #if defined(HAS_EXPANSION_KIT) s_cc_env_label = nullptr; #endif s_cc_sys_label = nullptr; } // Build the control-center system-info line (CPU · RAM% · PSRAM% · IP/no-IP). // Shared by openControlCenter (initial paint) and the live refresh tick so the // IP and memory figures update while the panel stays open. static void ccBuildSysInfo(char* buf, size_t n) { #if defined(ESP32) const unsigned cpu = (unsigned)ESP.getCpuFreqMHz(); const size_t dram_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t dram_tot = heap_caps_get_total_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t ps_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); const size_t ps_tot = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); const unsigned dram_pct = dram_tot ? (unsigned)(100 - (dram_free * 100 / dram_tot)) : 0; const unsigned ps_pct = ps_tot ? (unsigned)(100 - (ps_free * 100 / ps_tot)) : 0; char ipbuf[20] = "no IP"; if (WiFi.status() == WL_CONNECTED) snprintf(ipbuf, sizeof ipbuf, "%s", WiFi.localIP().toString().c_str()); snprintf(buf, n, "%uMHz \xC2\xB7 RAM %u%% \xC2\xB7 PSRAM %u%% \xC2\xB7 %s", cpu, dram_pct, ps_pct, ipbuf); #else snprintf(buf, n, "system info n/a"); #endif } static void ccBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) closeControlCenter(); } static void openControlCenter(); // fwd — toggle cbs rebuild the panel // Open/close the top-bar "control center" dropdown. On the Tanmatsu the status bar isn't tappable // (no touch), so a long-press of the green ○ Home key triggers this (see navPump). static void toggleControlCenter() { if (s_cc_root) closeControlCenter(); else openControlCenter(); } // Wi-Fi and Bluetooth coexist once allocated. Pager cold starts and // re-authentication release and recreate BLE live; no reboot is required. static void ccWifiCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(ESP32) // Existing stacks apply live in the main loop; a cold Pager temporarily // releases NimBLE so Wi-Fi can claim its allocation first. const bool on = wifiConfigGetRadioEnabled(); if (!on && !wifiPrepareEnable()) { openControlCenter(); return; } wifiConfigSetRadioEnabled(!on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi off") : TR("Wi-Fi on"), 800); openControlCenter(); #endif } static void ccBleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task || !g_lv.task->hasBleCapability()) return; #if defined(ESP32) // Live: enableBle() lazily brings NimBLE up if it wasn't started at boot. const bool requested = bleRequestedOrEnabled(); if (requested) { g_lv.task->disableBle(); } else if (!g_lv.task->enableBle()) { g_lv.task->showAlert(bleEnableFailureText(), 2600); openControlCenter(); return; } g_lv.task->showAlert(requested ? TR("Bluetooth off") : TR("Bluetooth on"), 800); openControlCenter(); #endif } static void ccGpsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->toggleGPS(); g_lv.task->showAlert(g_lv.task->getGPSState() ? TR("GPS on") : TR("GPS off"), 800); openControlCenter(); // rebuild so the toggle reflects the new state } #if !CAP_GPS // No onboard GPS on the Tanmatsu — the chip is info-only (doesn't toggle anything). static void ccGpsNoneCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->showAlert(TR("No onboard GPS"), 1200); } #endif // Theme-colour chip (both boards): close the control center, open the accent picker. static void ccThemeCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closeControlCenter(); openAccentPicker(); } #if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) static uint8_t s_cc_screenshot_delay_s = 3; static lv_timer_t* s_cc_screenshot_timer = nullptr; static const char* ccScreenshotDelayText() { return s_cc_screenshot_delay_s == 0 ? "now" : s_cc_screenshot_delay_s == 3 ? "3s" : "10s"; } static void ccScreenshotTimerCb(lv_timer_t*) { s_cc_screenshot_timer = nullptr; takeScreenshotToSd(); } static void ccScreenshotDelayCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_cc_screenshot_delay_s = s_cc_screenshot_delay_s == 0 ? 3 : s_cc_screenshot_delay_s == 3 ? 10 : 0; openControlCenter(); } static void ccScreenshotCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_cc_screenshot_timer) { lv_timer_del(s_cc_screenshot_timer); s_cc_screenshot_timer = nullptr; } closeControlCenter(); const uint32_t wait_ms = (uint32_t)s_cc_screenshot_delay_s * 1000u + 150u; s_cc_screenshot_timer = lv_timer_create(ccScreenshotTimerCb, wait_ms, nullptr); if (!s_cc_screenshot_timer) { if (g_lv.task) g_lv.task->showAlert(TR("Screenshot: low memory"), 1800); return; } lv_timer_set_repeat_count(s_cc_screenshot_timer, 1); if (s_cc_screenshot_delay_s && g_lv.task) { char msg[28]; snprintf(msg, sizeof msg, "Screenshot in %us", (unsigned)s_cc_screenshot_delay_s); g_lv.task->showAlert(msg, 1200); } } #endif #if CAP_KEYBOARD static void ccKbBacklightCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; s_kb_bl_mode = (uint8_t)((s_kb_bl_mode + 1) % 3); // off -> on -> auto -> off touchPrefsSetKbBacklight(s_kb_bl_mode); if (g_lv.task) g_lv.task->showAlert(s_kb_bl_mode == 0 ? TR("Keyboard light off") : s_kb_bl_mode == 1 ? TR("Keyboard light on") : TR("Keyboard light auto"), 800); openControlCenter(); // rebuild so the chip label reflects the new mode } #endif #if defined(HAS_TDECK_GT911) // Lock the screen from the control center (T-Deck only; the V4 has its physical // lock button). Dismiss the panel first, then turn the screen off + manual-lock. // Unlock with a trackball/BOOT press. static void ccLockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; closeControlCenter(); g_lv.task->lockScreen(); } #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) // Sound on/off chip (T-Deck I2S speaker / Heltec V4 piezo / Tanmatsu ES8156). Confirmation chime on enable. static void ccSoundCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->toggleBuzzer(); const bool quiet = g_lv.task->isBuzzerQuiet(); if (!quiet) { #if defined(HAS_TANMATSU) tanBeep(); #else uiPlayNotify(); #endif } g_lv.task->showAlert(quiet ? TR("Sound off") : TR("Sound on"), 800); openControlCenter(); // rebuild so the chip's active state updates } #endif // ---- Power menu (power off / reboot) ---- // The T-Deck has no power-management IC to cut rail power, so "power off" is a // deep sleep: the lowest-power state the chip can hold. It wakes on the // trackball centre button (GPIO0). Chat history is flushed first. static void closePowerMenu() { if (s_power_menu) { popupClose(&s_power_menu); } } static void powerMenuBackdropCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // backdrop only closePowerMenu(); } static void powerCancelCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); } static void powerRebootCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; closePowerMenu(); g_lv.task->showAlert(TR("Rebooting\xE2\x80\xA6"), 600); g_lv.task->rebootDevice(); // flushes chat history, then reboots } // Gated on PIN_USER_BTN, which is exactly what arms the deep-sleep wake below. // A power-off nobody can undo is not a power-off, it is a brick until the user // finds the hardware switch — and that is what the T-Display P4 did: no wake // source armed at all, under a toast promising a trackball it does not have // (#310). Boards without the symbol get no Power-off row (see openPowerMenu). #if defined(PIN_USER_BTN) static void powerOffCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) WdtHeavyGuard idle_wdt_guard; LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring (RAM is lost in deep sleep) touchPrefsFlush(); // and all queued A/B preference snapshots // Name the control this board actually has. "Click trackball" was shown on // every board that reached here, trackball or not. #if defined(HAS_TDECK_GT911) g_lv.task->showAlert(TR("Powering off\xE2\x80\xA6 click trackball to wake"), 1500); #else g_lv.task->showAlert(TR("Powering off\xE2\x80\xA6 press BOOT to wake"), 1500); #endif } // Let the toast paint, then enter deep sleep. lv_refr_now(NULL); delay(900); #if defined(HELTEC_LORA_V4_R8) // Park everything before sleeping — without this the SX1262 stayed in RX // with the FEM enabled (pure drain, no wake purpose: all wake sources are // cleared below except the button) and the non-RTC rail pins (VEXT=40, // GPS_EN=42, backlight=44) lost their driven levels and floated. radio_driver.powerOff(); board.loRaFEMControl.setSleepModeEnable(); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); // re-route from LEDC ch6 back to plain GPIO digitalWrite(PIN_TFT_LEDA_CTL, LOW); // backlight off pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, HIGH); // inactive (PIN_GPS_EN_ACTIVE=LOW) pinMode(PIN_VEXT_EN, OUTPUT); digitalWrite(PIN_VEXT_EN, HIGH); // inactive (active LOW) — VEXT rail off gpio_hold_en((gpio_num_t)PIN_VEXT_EN); // non-RTC pads: hold levels through deep sleep gpio_hold_en((gpio_num_t)PIN_GPS_EN); gpio_hold_en((gpio_num_t)PIN_TFT_LEDA_CTL); gpio_deep_sleep_hold_en(); // released in HeltecV4Board::begin on wake #endif #if defined(PIN_USER_BTN) const gpio_num_t wake = (gpio_num_t)PIN_USER_BTN; // GPIO0, trackball click, active-low // CRITICAL: the trackball button is held HIGH by a pull-up while idle and // pulled LOW only when pressed. Across deep sleep the normal GPIO pull is // powered down, so without holding the pull-up via the RTC domain the pad // floats LOW the instant we sleep — ext0 wake-on-LOW then fires immediately // and the device "powers off then reboots". Hold the pull-up in RTC so the // pad stays HIGH until a real press, and clear any stale wake config first. esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); rtc_gpio_init(wake); rtc_gpio_set_direction(wake, RTC_GPIO_MODE_INPUT_ONLY); rtc_gpio_pullup_en(wake); rtc_gpio_pulldown_dis(wake); esp_sleep_enable_ext0_wakeup(wake, 0); // wake when the button is pressed (LOW) #endif esp_deep_sleep_start(); // never returns; a press wakes via full reboot #endif } #endif // PIN_USER_BTN (powerOffCb) #if defined(ESP32) #if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) #include "soc/rtc_cntl_reg.h" // RTC_CNTL_FORCE_DOWNLOAD_BOOT (header-guarded) #endif // Force the ROM serial bootloader (USB download / flash mode) on the next reset, // so the device can be reflashed over USB without holding BOOT + tapping RST. The // force-download bit lives in the RTC domain (it survives the soft reset); // esptool's post-flash reset — or a power cycle — clears it back to a normal boot. // (Tanmatsu/P4 has no RTC_CNTL_OPTION1_REG and the launcher manages flashing — plain restart.) static void rebootToDownloadMode() { #if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) REG_SET_BIT(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT); #endif ESP.restart(); } #endif static void powerDownloadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) WdtHeavyGuard idle_wdt_guard; LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring touchPrefsFlush(); // and all queued A/B preference snapshots g_lv.task->showAlert(TR("Download mode\xE2\x80\xA6 reflash over USB"), 1500); } lv_refr_now(NULL); delay(900); rebootToDownloadMode(); // never returns #endif } // Open the power menu: a small centred card with Power off / Reboot / Download / Cancel. static void openPowerMenu() { closeControlCenter(); closePowerMenu(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_power_menu = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_power_menu); lv_obj_set_size(s_power_menu, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_power_menu, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_power_menu, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_power_menu, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_power_menu, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_power_menu, powerMenuBackdropCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN const int card_w = (sw - 80 > 420) ? 420 : (sw - 80); const int p_bh = 52, p_y0 = 46, p_step = 60, card_h = p_y0 + 4 * p_step + 8; // bigger on the 800×480 panel #elif defined(HAS_RAK_TAP_V2) // ROM force-download leaves a COM that esptool cannot open on HW CDC — hide the entry. const int card_w = (sw - 40 > 240) ? 240 : (sw - 40); const int p_bh = 34, p_y0 = 28, p_step = 40, card_h = p_y0 + 3 * p_step + 8; #elif defined(HAS_THINKNODE_M9) // Power-off row hidden (see below) — 3 rows: Reboot / Download / Cancel. const int card_w = (sw - 40 > 240) ? 240 : (sw - 40); const int p_bh = 34, p_y0 = 28, p_step = 40, card_h = p_y0 + 3 * p_step + 8; #else const int card_w = (sw - 40 > 240) ? 240 : (sw - 40); const int p_bh = 34, p_y0 = 28, p_step = 40, card_h = 206; #endif lv_obj_t* card = lv_obj_create(s_power_menu); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 10); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR(LV_SYMBOL_POWER " Power")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); auto mk = [&](const char* txt, lv_event_cb_t cb, uint32_t bg, int y) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 24, p_bh); lv_obj_align(b, LV_ALIGN_TOP_MID, 0, y); styleButton(b); if (bg) { lv_obj_set_style_bg_color(b, lv_color_hex(bg), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, LV_PART_MAIN); } lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(txt)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); return b; }; #if !defined(PIN_USER_BTN) // No "Power off" here: nothing on this board can wake it from deep sleep. // The M9 has a power-cut slider and reset, neither a wakeable GPIO; the // T-Display P4 and Tanmatsu have no user button either. On those the physical // switch IS the power-off, and offering a software one that cannot be undone // reads as a freeze (#310). Remaining rows shift up one slot. const int p_y = p_y0; #else mk(TR(LV_SYMBOL_POWER " Power off"), powerOffCb, 0xC44B55, p_y0); const int p_y = p_y0 + p_step; #endif mk(TR(LV_SYMBOL_REFRESH " Reboot"), powerRebootCb, 0, p_y); #if !defined(HAS_RAK_TAP_V2) mk(TR(LV_SYMBOL_DOWNLOAD " Download mode"), powerDownloadCb, 0, p_y + p_step); mk(TR("Cancel"), powerCancelCb, 0, p_y + 2 * p_step); #else mk(TR("Cancel"), powerCancelCb, 0, p_y + p_step); #endif } static void ccPowerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openPowerMenu(); } // Long-press a control-center chip -> close the panel and open that feature's // full settings page. lv_indev_wait_release swallows the release's CLICKED so // the chip's normal tap-to-toggle action doesn't also fire after a long-press. static void ccLongNavCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return; lv_indev_t* indev = lv_indev_get_act(); if (indev) lv_indev_wait_release(indev); const int cat = (int)(intptr_t)lv_event_get_user_data(e); closeControlCenter(); openSettingsCategory(cat); s_settings_from_cc = true; // Back from here reopens the dropdown, not Home } // Do Not Disturb quick-toggle (control center). Flips the scheduled-silence master switch // (the time window lives in Settings > Sound, added with the DND feature); the chip's bell // gains a slash while DND is on. All boards — DND itself isn't board-gated. static void ccDndCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const bool now_on = !touchPrefsGetDndEnabled(); touchPrefsSetDndEnabled(now_on); if (g_lv.task) g_lv.task->showAlert(now_on ? TR("Do Not Disturb on") : TR("Do Not Disturb off"), 800); refreshStatusLabels(); // update the status-bar DND bell right away openControlCenter(); // rebuild so the chip's icon + highlight flip } static void ccToggle(lv_obj_t* parent, const char* sym, const char* label, bool active, lv_event_cb_t cb, int width = 66, int height = 54, int long_cat = -1, const char* sub_text = nullptr) { (void)label; // icon-only round chips now — the standalone caption under each icon was dropped by request // A true circle needs a SQUARE footprint (LV_RADIUS_CIRCLE on a non-square gives a stadium/pill), // so the visible button is d x d. But a bare d-wide circle is much narrower than its intended // "width" slot, so the flex row would greedily pack too many per line (e.g. 7 tiny circles across // the T-Deck instead of 4). Fix: give the flex row a full width x height TRANSPARENT CELL as the // item, and centre the circle inside it. Now the row packs the intended count per line (4 on the // T-Deck -> two rows of 4) and SPACE_EVENLY distributes those cells across the dropdown's width. const int d = LV_MIN(width, height); lv_obj_t* cell = lv_obj_create(parent); lv_obj_remove_style_all(cell); lv_obj_set_size(cell, width, height); lv_obj_clear_flag(cell, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_obj_t* b = lv_btn_create(cell); lv_obj_set_size(b, d, d); lv_obj_center(b); styleButton(b); lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); // Kill the default theme's press "grow" (+3px each side): the circle sits flush inside its // cell, so growing it on press just overflowed and got clipped — the "cuts off, looks weird" // effect. Press feedback stays via styleButton's brighter pressed fill. lv_obj_set_style_transform_width(b, 0, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_transform_height(b, 0, LV_PART_MAIN | LV_STATE_PRESSED); // Follow the theme accent: solid when on, faint when off (was a fixed green when on). lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, active ? LV_OPA_COVER : LV_OPA_20, LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); if (long_cat >= 0) // long-press jumps to this feature's full settings page lv_obj_add_event_cb(b, ccLongNavCb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)long_cat); lv_obj_t* ic = lv_label_create(b); lv_label_set_text(ic, sym); lv_obj_set_style_text_color(ic, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); if (sub_text && sub_text[0]) { // Glyph + a small caption word stacked inside the circle (the keyboard chip keeps its // off/on/auto mode visible under the keyboard icon). Shrink the glyph a touch to make room. lv_obj_set_style_text_font(ic, &g_font_14, LV_PART_MAIN); lv_obj_align(ic, LV_ALIGN_TOP_MID, 0, d <= 42 ? 2 : 6); lv_obj_t* st = lv_label_create(b); lv_label_set_text(st, sub_text); lv_obj_set_style_text_font(st, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(st, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(st, LV_ALIGN_BOTTOM_MID, 0, d <= 42 ? -1 : -5); } else { // Just the icon, centred. lv_obj_set_style_text_font(ic, &g_font_16, LV_PART_MAIN); lv_obj_center(ic); } } // ---- Display backlight brightness (PWM on PIN_TFT_LEDA_CTL, active-high) ---- // Both touch boards expose PIN_TFT_LEDA_CTL (T-Deck + Heltec V4 TFT), so the // brightness slider is available on both. #if defined(TLORA_PAGER) // The pager ALSO defines PIN_TFT_LEDA_CTL (=42, for naming consistency across // boards — see platformio.ini), but that pin drives the AW9364's pulse-counted // stepped dimmer, not a PWM-capable pin — the #if below would drive nonsense // pulse timing into it via ledcAttachPin/ledcWrite. Must come before that // check. ST7796LCDDisplay already owns the real AW9364 protocol. #define HAS_CC_BRIGHTNESS 1 static uint8_t s_brightness_pct = 100; static void applyBrightness(uint8_t pct) { if (pct < 5) pct = 5; if (pct > 100) pct = 100; s_brightness_pct = pct; display.setBrightness(pct); } #elif defined(PIN_TFT_LEDA_CTL) && (PIN_TFT_LEDA_CTL >= 0) #define HAS_BACKLIGHT_PWM 1 #define HAS_CC_BRIGHTNESS 1 static uint8_t s_brightness_pct = 100; static bool s_bl_pwm_ready = false; constexpr int kBlPwmChannel = 6; static void applyBrightness(uint8_t pct) { if (pct < 5) pct = 5; if (pct > 100) pct = 100; s_brightness_pct = pct; if (!s_bl_pwm_ready) { ledcSetup(kBlPwmChannel, 20000, 8); // 20 kHz, 8-bit ledcAttachPin(PIN_TFT_LEDA_CTL, kBlPwmChannel); // takes the pin over from the display's digitalWrite s_bl_pwm_ready = true; } ledcWrite(kBlPwmChannel, (uint32_t)pct * 255u / 100u); } #elif defined(HAS_TANMATSU) // Tanmatsu: backlights + audio go through the bsp — the CH32 coprocessor (screen + // keyboard backlight) and the ES8156 codec / speaker amp (volume). Three CC sliders. #define HAS_CC_BRIGHTNESS 1 #define HAS_CC_KBD_BACKLIGHT 1 #define HAS_CC_VOLUME 1 static uint8_t s_brightness_pct = 100; static void applyBrightness(uint8_t pct) { // SCREEN backlight (bsp_display; was wrongly the keyboard) if (pct < 5) pct = 5; if (pct > 100) pct = 100; s_brightness_pct = pct; // store the slider value (5..100) // This panel looks ~max by ~25-30% backlight duty and must never go fully black, so map // the slider LINEARLY onto a [FLOOR..CEIL] duty band: every step is visible, the top is // max-useful, and the bottom stays clearly lit. (Tune FLOOR/CEIL to taste.) const int FLOOR = 6, CEIL = 28; // dim-but-lit … perceived-max; the visible range lives down low uint32_t duty = FLOOR + (uint32_t)(pct - 5) * (CEIL - FLOOR) / 95; bsp_display_set_backlight_brightness((uint8_t)duty); } static uint8_t s_kbd_bl_pct = 100; // Stores the Keys-slider brightness only; the hardware is driven per-loop by // tanKbBacklightTick() so the off/on/auto mode (s_kb_bl_mode) is honoured. static void applyKbdBacklight(uint8_t pct) { // keyboard backlight (CH32 coprocessor) if (pct > 100) pct = 100; s_kbd_bl_pct = pct; } // Drive the keyboard backlight from mode + Keys-slider brightness: off → dark, // on → slider brightness, auto → slider brightness while recently active then dark. // (Screen-off forcing to 0 is handled by the caller in the apply loop.) static void tanKbBacklightTick(bool off = false) { uint8_t v = 0; if (!off) { if (s_kb_bl_mode == 1) v = s_kbd_bl_pct; else if (s_kb_bl_mode == 2 && (millis() - s_kb_last_key_ms) < kKbBacklightIdleMs) v = s_kbd_bl_pct; } static uint8_t s_last = 0xFF; // per-frame caller: only hit the CH32 over I2C when it changes if (v != s_last) { s_last = v; bsp_input_set_backlight_brightness(v); } } static uint8_t s_volume_pct = 70; static SemaphoreHandle_t s_tan_audio_mutex = nullptr; static SemaphoreHandle_t tanAudioMutex() { if (!s_tan_audio_mutex) s_tan_audio_mutex = xSemaphoreCreateMutex(); return s_tan_audio_mutex; } // The audio subsystem (ES8156 codec + I2S) is brought up by bsp_device_initialize() // at boot — so here we only set the codec volume and toggle the speaker amplifier. static void applyVolume(uint8_t pct) { if (pct > 100) pct = 100; s_volume_pct = pct; // store the slider value // The bottom ~half of the codec's range sits below the speaker's audible threshold, // so map the slider into the audible band (≈50..100% of the codec) and mute at 0. bsp_audio_set_volume(pct == 0 ? 0.0f : 50.0f + (float)pct * 0.5f); bsp_audio_set_amplifier(pct > 0); } // Short sine "tick" through the I2S codec so adjusting the volume slider is audible. // The codec's I2S DMA REPLAYS its last buffer on TX underrun (no auto-clear), so a bare // tone would loop forever — we follow it with a block of silence LARGER than the DMA ring, // which leaves every descriptor zeroed once played out, and the tone stops cleanly. static void tanBeep() { if (s_volume_pct == 0) return; SemaphoreHandle_t mutex = tanAudioMutex(); if (!mutex || xSemaphoreTake(mutex, 0) != pdTRUE) return; static uint32_t s_last_beep = 0; // throttle: holding the slider ramps fast, but if (millis() - s_last_beep < 200) { xSemaphoreGive(mutex); return; } // don't machine-gun a tone on every repeat step s_last_beep = millis(); i2s_chan_handle_t h = nullptr; if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) { xSemaphoreGive(mutex); return; } const int rate = 44100, freq = 880; const int tone = rate * 30 / 1000; // ~30 ms tone … const int total = tone + rate * 50 / 1000; // … then ~50 ms silence (> the DMA ring) flushes the loop const int fade = rate * 4 / 1000; // 4 ms in/out fade kills the click static int16_t buf[3600 * 2]; // stereo (L,R), ~80 ms — the P4 has RAM to spare const int n = total > 3600 ? 3600 : total; for (int i = 0; i < n; i++) { int16_t s = 0; if (i < tone) { float env = 1.0f; if (i < fade) env = (float)i / fade; else if (i >= tone - fade) env = (float)(tone - i) / fade; s = (int16_t)(9000.0f * env * sinf(2.0f * 3.14159265f * freq * i / rate)); } buf[2 * i] = buf[2 * i + 1] = s; } size_t wr = 0; i2s_channel_write(h, buf, (size_t)n * 2 * sizeof(int16_t), &wr, 200 / portTICK_PERIOD_MS); xSemaphoreGive(mutex); } #elif defined(HAS_TDISPLAY_P4) // T-Display P4: the RM69A10 AMOLED has no backlight — brightness is the panel's own DCS // 0x51 register (RM69A10Display::setBrightness). Volume is loudness only: the notify // chimes scale their amplitude from the pref (the ES8311 DAC volume stays fixed), so // applyVolume just tracks the slider for the CC. #define HAS_CC_BRIGHTNESS 1 #define HAS_CC_VOLUME 1 static uint8_t s_brightness_pct = 100; static void applyBrightness(uint8_t pct) { if (pct < 5) pct = 5; if (pct > 100) pct = 100; s_brightness_pct = pct; // Linear map onto the panel's usable band: DCS 8 is still clearly lit, 255 = max. display.setBrightness((uint8_t)(8 + (uint32_t)(pct - 5) * 247u / 95u)); } static uint8_t s_volume_pct = 70; static void applyVolume(uint8_t pct) { if (pct > 100) pct = 100; s_volume_pct = pct; // play paths read the persisted pref; this keeps the slider live } #elif defined(HAS_THINKNODE_M9) // M9: BL_EN (GPIO17) is a PNP transistor gate. LEDC PWM confirmed working on hardware // (Specter bring-up), but the duty is INVERTED — lower duty on the base = MORE conduction // = brighter. 5kHz confirmed clean; the transistor couldn't keep up at very low frequencies. #define HAS_CC_BRIGHTNESS 1 static uint8_t s_brightness_pct = 100; static bool s_bl_pwm_ready = false; constexpr int kM9BlPwmChannel = 7; // separate channel from the generic LEDA_CTL block above static void applyBrightness(uint8_t pct) { if (pct < 5) pct = 5; if (pct > 100) pct = 100; s_brightness_pct = pct; if (!s_bl_pwm_ready) { ledcSetup(kM9BlPwmChannel, 5000, 8); // 5kHz, 8-bit — confirmed clean on the PNP ledcAttachPin(PIN_TFT_BL_EN, kM9BlPwmChannel); // takes the pin over from board.backlight's digitalWrite s_bl_pwm_ready = true; } ledcWrite(kM9BlPwmChannel, (uint32_t)(100 - pct) * 255u / 100u); // inverted duty } #endif #if defined(HAS_CC_BRIGHTNESS) static void ccBrightnessCb(lv_event_t* e) { applyBrightness((uint8_t)lv_slider_get_value(lv_event_get_target(e))); // live } static void ccBrightnessReleaseCb(lv_event_t* e) { touchPrefsSetBrightness((uint8_t)lv_slider_get_value(lv_event_get_target(e))); // persist } #endif #if defined(HAS_CC_KBD_BACKLIGHT) static void ccKbdBlCb(lv_event_t* e) { applyKbdBacklight((uint8_t)lv_slider_get_value(lv_event_get_target(e))); if (s_kb_bl_mode == 0) s_kb_bl_mode = 1; // adjusting brightness from "off" turns it on so the change is visible tanKbBacklightTick(); // immediate feedback at the new brightness } static void ccKbdBlReleaseCb(lv_event_t* e) { touchPrefsSetKbdBacklight((uint8_t)lv_slider_get_value(lv_event_get_target(e))); touchPrefsSetKbBacklight(s_kb_bl_mode); // persist the (possibly flipped) mode so it sticks } #endif #if defined(HAS_CC_VOLUME) static void ccVolumeCb(lv_event_t* e) { applyVolume((uint8_t)lv_slider_get_value(lv_event_get_target(e))); } // live codec volume static void ccVolumeReleaseCb(lv_event_t* e) { touchPrefsSetSoundVolume((uint8_t)lv_slider_get_value(lv_event_get_target(e))); // persist uiSoundPreview(); // audible feedback at the chosen level } #endif static void openControlCenter() { closeControlCenter(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_cc_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_cc_root); lv_obj_set_size(s_cc_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_cc_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_cc_root, lv_color_black(), LV_PART_MAIN); // Darker scrim behind the glass panel. A true backdrop BLUR isn't feasible live on the // ESP32 (no GPU; a per-frame gaussian would wreck the framerate), so instead heavily mute // the screen behind the translucent card — that keeps the panel's own content crisp/readable // while the card fill stays see-through enough to still read as glass. lv_obj_set_style_bg_opa(s_cc_root, LV_OPA_70, LV_PART_MAIN); lv_obj_clear_flag(s_cc_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_cc_root); lv_obj_add_event_cb(s_cc_root, ccBackdropCb, LV_EVENT_CLICKED, nullptr); // Portrait (V4 TFT, 240 wide) vs landscape (T-Deck, 320 wide): the narrow // screen can't fit the battery/IP beside the clock, so portrait stacks the // header vertically and uses a taller card. const bool portrait = (sw < sh); #if CAP_LARGE_SCREEN const int card_w = (sw - 40 > 560) ? 560 : (sw - 40); // way wider on the 800-px panel #else const int card_w = (sw - 12 > 300) ? 300 : (sw - 12); #endif lv_obj_t* card = lv_obj_create(s_cc_root); lv_obj_remove_style_all(card); #if defined(HAS_TANMATSU) const int card_h = 384; // bigger: header + 3 roomier sliders + toggle grid + sysinfo #elif defined(HAS_THINKNODE_M9) // The panel may report a 240px portrait viewport even though the physical // display is mounted above the keyboard. Eight chips need three rows there; // use the available height instead of clipping the last row in the old // T-Deck-sized 200px card. Landscape still needs only two rows. const int card_h = portrait ? (sh - STATUSBAR_H - 10) : 210; #elif defined(HAS_TDECK_GT911) const int card_h = 200; // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen) #elif defined(TLORA_PAGER) // 222-px-tall screen: the shared V4/T-Deck-landscape 212px card below is // tuned for a 240px-tall screen (212 + the card's own 4px y-offset = 216, // fits under a 218px root there) -- on the pager's shorter 222px screen that // same card overflows the 200px root by 16px, pushing the toggle row/sysinfo // text off the bottom of the physical display. Size from the actual // available height instead of the shared constant, with a small margin. const int card_h = sh - STATUSBAR_H - 4 - 6; #elif defined(HAS_TDISPLAY_P4) // Tall panel (1232 px): the shared V4 236px card is far too short here. The P4's // GPS line sits lower (it clears two 30px sliders, so gps_y ~124 vs the V4's ~70), // and the bottom-anchored 2-row chip grid then climbs up over the GPS text ("the // acquiring text is under the buttons"). Make the dropdown taller so GPS + chips + // sysinfo each get their own band — the chip-row math below derives off card_h. const int card_h = 320; #else // Portrait has headroom on the 320-tall screen; make the card taller so the // brightness slider + toggles + sysinfo all get their own rows. const int card_h = portrait ? 236 : 212; #endif lv_obj_set_size(card, card_w, card_h); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 4); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); // Frosted-glass panel: translucent fill so the (dimmed) screen behind shows through. // Only the panel BACKGROUND is see-through — the clock/sliders/chips drawn on top stay // fully opaque, so nothing becomes hard to read. ~85% keeps it clearly a solid surface. lv_obj_set_style_bg_opa(card, 218, LV_PART_MAIN); lv_obj_set_style_radius(card, 12, LV_PART_MAIN); // Subtle light hairline (instead of the near-black border) reads more like a glass edge. lv_obj_set_style_border_color(card, lv_color_hex(0x3A3D42), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // ---- Clock + date (left) ---- char clock_s[12] = "--:--", date_s[28] = "RTC unset"; #if defined(ESP32) time_t now_t = time(nullptr); if (now_t > 1700000000) { struct tm tm_loc; localtime_r(&now_t, &tm_loc); fmtClockHM(clock_s, sizeof clock_s, &tm_loc); strftime(date_s, sizeof date_s, "%a %d %b %Y", &tm_loc); } #endif lv_obj_t* clk = lv_label_create(card); lv_label_set_text(clk, clock_s); lv_obj_set_style_text_font(clk, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(clk, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(clk, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_t* dt = lv_label_create(card); lv_label_set_text(dt, date_s); lv_obj_set_style_text_font(dt, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(dt, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(dt, LV_ALIGN_TOP_LEFT, 0, 21); // ---- Battery + Wi-Fi info (right) ---- const uint16_t mv = batteryMvSmoothed(); const int pct = batteryPercentFromMv(mv); char batt_s[28]; if (batteryIsCharging(mv)) snprintf(batt_s, sizeof batt_s, LV_SYMBOL_CHARGE " Charging %u.%02uV", (unsigned)(mv / 1000), (unsigned)((mv % 1000) / 10)); else snprintf(batt_s, sizeof batt_s, "%d%% %u.%02uV", pct < 0 ? 0 : pct, (unsigned)(mv / 1000), (unsigned)((mv % 1000) / 10)); char winfo[40] = "Wi-Fi off"; #if defined(ESP32) if (WiFi.status() == WL_CONNECTED) snprintf(winfo, sizeof winfo, "%s", WiFi.localIP().toString().c_str()); #endif // Power icon — just the white glyph in the top-right corner (no button chrome, // iPhone-style). Opens the power off / reboot menu. Battery nudged left (-30) // so it clears the glyph. { lv_obj_t* pb = lv_btn_create(card); lv_obj_remove_style_all(pb); // strip bg + border — icon only lv_obj_set_size(pb, 28, 24); lv_obj_align(pb, LV_ALIGN_TOP_RIGHT, 2, -1); lv_obj_add_flag(pb, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(pb, ccPowerCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* pl = lv_label_create(pb); lv_label_set_text(pl, LV_SYMBOL_POWER); lv_obj_set_style_text_font(pl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(pl); } // Battery: landscape puts it top-right beside the clock; portrait stacks it on // its own line under the date (no room beside the clock at 240 px wide). lv_obj_t* bi = lv_label_create(card); lv_label_set_text(bi, batt_s); lv_obj_set_style_text_font(bi, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(bi, lv_color_hex(COLOR_SUB), LV_PART_MAIN); int row_y = 42; // first content row under the date / battery if (portrait) { lv_obj_align(bi, LV_ALIGN_TOP_LEFT, 0, 40); // battery under the date row_y = 58; } else { lv_obj_align(bi, LV_ALIGN_TOP_RIGHT, -30, 2); } // Brightness slider takes the first row (just under the date / battery) and the // GPS line follows it, so the slider sits ABOVE the GPS line. Boards without a // backlight PWM have no slider, so GPS reclaims that first row. #if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) const int bl_y = row_y; // keyboard-only boards use everything below this slider for controls #elif defined(HAS_TANMATSU) const int bl_y = row_y; // three stacked sliders: Screen / Keys / Sound const int gps_y = row_y + SC(100); // GPS line sits below all three (scaled so it clears taller sliders) #elif defined(HAS_TDISPLAY_P4) const int bl_y = row_y; // two stacked sliders: Screen / Sound const int gps_y = row_y + SC(66); // GPS line clears both rows #elif defined(HAS_CC_BRIGHTNESS) const int bl_y = row_y; const int gps_y = row_y + 12; #else const int gps_y = row_y; #endif #if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // ---- GPS fix status (live; refreshed while the panel is open) ---- s_cc_gps_label = lv_label_create(card); lv_label_set_long_mode(s_cc_gps_label, LV_LABEL_LONG_DOT); lv_obj_set_width(s_cc_gps_label, card_w - 20); lv_label_set_text(s_cc_gps_label, gpsStatusStr(true)); // one line — keeps the dropdown short lv_obj_set_style_text_font(s_cc_gps_label, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_cc_gps_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(s_cc_gps_label, LV_ALIGN_TOP_LEFT, 0, gps_y); #if defined(HAS_EXPANSION_KIT) // Expansion Kit: live local-env one-liner under the GPS status in the CC. const char* env_status = localEnvStatusStr(); if (env_status[0]) { s_cc_env_label = lv_label_create(card); lv_label_set_long_mode(s_cc_env_label, LV_LABEL_LONG_DOT); lv_obj_set_width(s_cc_env_label, card_w - 20); lv_label_set_text(s_cc_env_label, env_status); lv_obj_set_style_text_font(s_cc_env_label, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_cc_env_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(s_cc_env_label, LV_ALIGN_TOP_LEFT, 0, gps_y + 12); } #endif #endif // !HAS_THINKNODE_M9 && !TLORA_PAGER #if defined(HAS_TANMATSU) // ---- Three stacked sliders: Screen brightness, Keyboard backlight, Volume. // A short text label on the left (tofu-proof, no glyph) anchors each thin bar. { struct { const char* name; int val; int lo; lv_event_cb_t live; lv_event_cb_t done; } sl[3] = { { "Screen", s_brightness_pct, 5, ccBrightnessCb, ccBrightnessReleaseCb }, { "Keys", s_kbd_bl_pct, 0, ccKbdBlCb, ccKbdBlReleaseCb }, { "Sound", s_volume_pct, 0, ccVolumeCb, ccVolumeReleaseCb }, }; for (int i = 0; i < 3; i++) { const int y = bl_y + i * SC(30); // roomier vertical spacing (scales with UI size so rows don't stack-overlap) lv_obj_t* lab = lv_label_create(card); lv_label_set_text(lab, sl[i].name); lv_obj_set_style_text_font(lab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lab, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(lab, LV_ALIGN_TOP_LEFT, 0, y - 1); lv_obj_t* s = lv_slider_create(card); lv_obj_set_size(s, card_w - 20 - SC(70), 10); // reserve SC()-scaled room for the label so a bigger // UI-scale label can't overlap the bar lv_obj_align(s, LV_ALIGN_TOP_RIGHT, 0, y); lv_slider_set_range(s, sl[i].lo, 100); lv_slider_set_value(s, sl[i].val, LV_ANIM_OFF); lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); lv_obj_set_style_pad_all(s, 4, LV_PART_KNOB); lv_obj_add_event_cb(s, sl[i].live, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(s, sl[i].done, LV_EVENT_RELEASED, nullptr); } } #elif defined(HAS_TDISPLAY_P4) // ---- Two stacked sliders: Screen brightness (AMOLED DCS 0x51) + Sound volume. // Same anchored-label pattern as the Tanmatsu block, minus its Keys row. { struct { const char* name; int val; int lo; lv_event_cb_t live; lv_event_cb_t done; } sl[2] = { { "Screen", s_brightness_pct, 5, ccBrightnessCb, ccBrightnessReleaseCb }, { "Sound", s_volume_pct, 0, ccVolumeCb, ccVolumeReleaseCb }, }; for (int i = 0; i < 2; i++) { const int y = bl_y + i * SC(30); lv_obj_t* lab = lv_label_create(card); lv_label_set_text(lab, TR(sl[i].name)); lv_obj_set_style_text_font(lab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lab, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(lab, LV_ALIGN_TOP_LEFT, 0, y - 1); lv_obj_t* s = lv_slider_create(card); lv_obj_set_size(s, card_w - 20 - SC(70), 10); lv_obj_align(s, LV_ALIGN_TOP_RIGHT, 0, y); lv_slider_set_range(s, sl[i].lo, 100); lv_slider_set_value(s, sl[i].val, LV_ANIM_OFF); lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); lv_obj_set_style_pad_all(s, 4, LV_PART_KNOB); lv_obj_add_event_cb(s, sl[i].live, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(s, sl[i].done, LV_EVENT_RELEASED, nullptr); } } #elif defined(HAS_CC_BRIGHTNESS) // ---- Brightness slider (thin; a sun/gear glyph anchors it on the left so it // doesn't cost a separate label row). bl_y is computed above so it sits // directly under the date/battery, just above the GPS line. lv_obj_t* bl_ic = lv_label_create(card); lv_label_set_text(bl_ic, LV_SYMBOL_SETTINGS); // small left anchor for the slider lv_obj_set_style_text_font(bl_ic, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(bl_ic, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(bl_ic, LV_ALIGN_TOP_LEFT, 0, bl_y - 2); lv_obj_t* bl = lv_slider_create(card); lv_obj_set_size(bl, card_w - 20 - 22, 6); // thin 6-px bar, room for the glyph on the left lv_obj_align(bl, LV_ALIGN_TOP_RIGHT, 0, bl_y); lv_slider_set_range(bl, 5, 100); lv_slider_set_value(bl, s_brightness_pct, LV_ANIM_OFF); lv_obj_set_style_bg_color(bl, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); // follow the theme accent lv_obj_set_style_bg_color(bl, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); lv_obj_set_style_pad_all(bl, 4, LV_PART_KNOB); // smaller knob to match the thin bar lv_obj_add_event_cb(bl, ccBrightnessCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(bl, ccBrightnessReleaseCb, LV_EVENT_RELEASED, nullptr); #endif #if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // ---- System info line (CPU + RAM% + PSRAM% + IP) — one thin line, pinned to // the very bottom of the card. Memory is shown as % used so it stays // compact and all four facts fit on one row. { char sys_s[72]; ccBuildSysInfo(sys_s, sizeof sys_s); lv_obj_t* sysl = lv_label_create(card); s_cc_sys_label = sysl; // track for the live refresh tick // ONE line only: LONG_DOT still wraps to a 2nd line (dotting only the // overflow) unless the height is clamped — that wrap was bleeding the line // through the toggle chips. Clamp to a single line height. lv_label_set_long_mode(sysl, LV_LABEL_LONG_DOT); lv_obj_set_width(sysl, card_w - 20); lv_obj_set_height(sysl, lv_font_get_line_height(&g_font_12)); lv_label_set_text(sysl, sys_s); lv_obj_set_style_text_font(sysl, &g_font_12, LV_PART_MAIN); // smallest available font lv_obj_set_style_text_color(sysl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(sysl, LV_ALIGN_BOTTOM_MID, 0, 0); } #endif // ---- Quick toggles (bottom row) ---- bool wifi_on = false, ble_on = false; #if defined(ESP32) wifi_on = wifiConfigGetRadioEnabled(); #endif if (g_lv.task) ble_on = bleRequestedOrEnabled(); lv_obj_t* row = lv_obj_create(card); lv_obj_remove_style_all(row); #if defined(HAS_TANMATSU) lv_obj_set_size(row, card_w - 20, 170); // big 2×2 grid of chips lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(row, 10, LV_PART_MAIN); lv_obj_set_style_pad_column(row, 10, LV_PART_MAIN); #elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) // Reclaim everything below brightness: no GPS/status line and no bottom // system-info line. Five compact cells fit across the Pager landscape view; // M9 portrait wraps to three rows. Spread the rows through the reclaimed band. const int controls_y = bl_y + 16; lv_obj_set_size(row, card_w - 20, card_h - controls_y - 20); lv_obj_align(row, LV_ALIGN_TOP_MID, 0, controls_y); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(row, 4, LV_PART_MAIN); #elif defined(HAS_TDECK_GT911) // 2-row grid: 4 chips per row, so chip 5 (Lock) wraps onto a 2nd row. Sits // ABOVE the bottom system-info line (-16 offset leaves room for it). lv_obj_set_size(row, card_w - 20, 80); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(row, 4, LV_PART_MAIN); #else // V4/pager: up to 6 chips (Wi-Fi/BT/GPS/Theme/Keyboard/Sound) in one row, // sized to fit width (chip width is computed from the actual count below, // not hardcoded, since which chips appear varies per board). WRAP as a // safety net so they never overflow the (narrow, in portrait) card. lv_obj_set_size(row, card_w - 20, 54); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_column(row, 5, LV_PART_MAIN); #endif #if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // Bottom-anchored but lifted clear of the 1-line sysinfo (~16 px) plus a gap. lv_obj_align(row, LV_ALIGN_BOTTOM_MID, 0, -20); #endif #if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_SPACE_EVENLY); #else lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); #endif lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); const bool gps_on = g_lv.task && g_lv.task->getGPSState(); // T-Deck: chips in a 2-row grid. V4/pager: chips sized to fit the card width // with even gaps, divisor derived from the actual chip count below. int tw = 66, th = 54; #if defined(HAS_TANMATSU) tw = (card_w - 20 - 20) / 3; // 3 chips per row (Wi-Fi/BT/GPS/Theme/Keys/Sound → 2×3 grid) if (tw > 175) tw = 175; th = 80; #elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) tw = 56; th = 34; #elif defined(HAS_TDECK_GT911) tw = 58; th = 36; #else // Count only the chips this board/session will actually add below (which chips // appear varies per board: V4 has no Keyboard chip; only HAS_UI_SOUND boards get // a Sound chip), so the width divisor matches the real count. int chip_count = 2; // Wi-Fi, Theme always shown if (!g_lv.task || g_lv.task->hasBleCapability()) chip_count++; // BT chip_count++; // GPS (toggle or info-only, always shown) #if CAP_KEYBOARD chip_count++; #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) chip_count++; #endif chip_count++; // Do Not Disturb (all boards) if (portrait && chip_count >= 5) { // Portrait screens (e.g. the V4) have vertical room below the GPS line, so lay 5+ chips // out as TWO rows instead of one cramped row. The row is bottom-anchored and grows // UPWARD, so its height MUST be capped to the gap below the GPS/env text — the first // attempt hard-coded 58px circles, which climbed up and covered the GPS line. const int per_row = (chip_count + 1) / 2; // 6 -> 3 per row, 5 -> 3 then 2 tw = (card_w - 20 - 5 * (per_row - 1)) / per_row; if (tw > 92) tw = 92; int rowh = (card_h - 40) - (gps_y + 38); // card bottom (minus margin) up to just below GPS/env (card_h-40 == the old 196 on the 236px V4 card) if (rowh > 116) rowh = 116; th = (rowh - 8) / 2; // two rows + an 8px inter-row gap if (th > 52) th = 52; if (th < 34) th = 34; lv_obj_set_style_pad_row(row, 8, LV_PART_MAIN); lv_obj_set_height(row, th * 2 + 8); lv_obj_align(row, LV_ALIGN_BOTTOM_MID, 0, -20); // re-anchor after the resize } else { tw = (card_w - 20 - 5 * (chip_count - 1)) / chip_count; // single row, fit all across if (tw > 76) tw = 76; } #endif // Each chip: tap = toggle, long-press = jump to that feature's settings page // (GPS -> Radio & Mesh, where the location-sharing settings live). ccToggle(row, LV_SYMBOL_WIFI, TR("Wi-Fi"), wifi_on, ccWifiCb, tw, th, CAT_WIFI); if (!g_lv.task || g_lv.task->hasBleCapability()) ccToggle(row, LV_SYMBOL_BLUETOOTH, TR("BT"), ble_on, ccBleCb, tw, th, CAT_BLUETOOTH); #if !CAP_GPS ccToggle(row, LV_SYMBOL_GPS, TR("GPS"), false, ccGpsNoneCb, tw, th, -1); // no onboard GPS — info-only, untoggable #else // Long-press opens the GPS settings PAGE. CAT_RADIO here was a leftover from before the // settings reorg gave GPS its own category (the GPS block used to live under radio/device). ccToggle(row, LV_SYMBOL_GPS, TR("GPS"), gps_on, ccGpsCb, tw, th, CAT_GPS); #endif ccToggle(row, LV_SYMBOL_TINT, TR("Theme"), false, ccThemeCb, tw, th, CAT_DISPLAY); #if CAP_KEYBOARD // Keyboard-backlight chip: the keyboard glyph WITH its off/on/auto mode word beneath it, // so the mode stays visible at a glance (sub_text stacks a small caption under the icon). const char* kb_mode = s_kb_bl_mode == 0 ? "off" : (s_kb_bl_mode == 1 ? "on" : "auto"); ccToggle(row, LV_SYMBOL_KEYBOARD, TR("Keyboard"), s_kb_bl_mode != 0, ccKbBacklightCb, tw, th, CAT_KEYBOARD, kb_mode); #endif #if defined(HAS_TDECK_GT911) ccToggle(row, TOUCH_SYM_LOCK, TR("Lock"), false, ccLockCb, tw, th, CAT_LOCK); // real padlock, not an eye #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) // Sound on/off — the notification-tone mute (T-Deck I2S speaker / Heltec V4 piezo / Tanmatsu ES8156). const bool sound_on = g_lv.task && !g_lv.task->isBuzzerQuiet(); ccToggle(row, LV_SYMBOL_AUDIO, TR("Sound"), sound_on, ccSoundCb, tw, th, CAT_SOUND); #endif // Do Not Disturb (the scheduled-silence feature) — dynamic bell: a plain bell when DND is off, // a bell-with-a-slash when it's on. All boards; the time window lives in Settings > Sound. { const bool dnd_on = touchPrefsGetDndEnabled(); ccToggle(row, dnd_on ? TOUCH_SYM_BELL_SLASH : TOUCH_SYM_BELL, TR("DND"), dnd_on, ccDndCb, tw, th, CAT_SOUND); } #if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) ccToggle(row, LV_SYMBOL_REFRESH, "Timer", s_cc_screenshot_delay_s != 0, ccScreenshotDelayCb, tw, th, -1, ccScreenshotDelayText()); ccToggle(row, LV_SYMBOL_IMAGE, "Screenshot", false, ccScreenshotCb, tw, th); #endif // (Power is the round icon in the card's top-right corner, not a grid chip.) } #if CAP_LUA_APPS // ===== Lua app store: installed scan + Store page (LUA_APPS.md Phase 2) ===== static const LuaInstApp* luaStoreFindInstalled(const char* id); // fwd (defined below) static void luaStoreInvalidateInstalled(); static bool s_lua_inst_valid = false; // cache flag: the /apps listing is SD I/O static void luaStoreInvalidateInstalled() { s_lua_inst_valid = false; } #if CAP_BUILTIN_LUA_APPS #include "lua_builtin.h" // generated: the catalog's apps, compiled in // Source for a seeded app, or nullptr when the id is not one of the built-ins. static const char* luaBuiltinSrc(const char* id) { for (int i = 0; i < kLuaBuiltinCount; i++) if (strcmp(kLuaBuiltin[i].id, id) == 0) return kLuaBuiltin[i].src; return nullptr; } #else static const char* luaBuiltinSrc(const char*) { return nullptr; } #endif // The filesystem half of the scan; luaStoreScanInstalled() wraps it and then // seeds the compiled-in apps, so those exist even when storage is absent. static void luaStoreScanInstalledFs(bool force) { if (!luaStoreTablesReady()) return; // default arg on the fwd decl (worker side) if (s_lua_inst_valid && !force) return; // cached — the drawer opens without touching the card s_lua_inst_n = 0; fs::FS* fs = luaHostAppFs(); if (!fs) return; // storage not ready yet — stay invalid so we retry later s_lua_inst_valid = true; char appsdir[48]; luaHostAppPath(appsdir, sizeof appsdir, "/apps"); const int kMax = kLuaInstMax; // Pass 1: manifests (store installs). Pass 2: bare .lua files with no // manifest — a script dropped straight onto the card (any card reader, the // Files app, USB) becomes an app named after its file. Skip macOS "._*" // AppleDouble junk so a Mac-touched card doesn't grow ghost apps. for (int pass = 0; pass < 2 && s_lua_inst_n < kMax; pass++) { File dir = fs->open(appsdir); if (!dir || !dir.isDirectory()) return; File e; while (s_lua_inst_n < kMax && (e = dir.openNextFile())) { const char* nm = e.name(); const char* leaf = strrchr(nm, '/'); leaf = leaf ? leaf + 1 : nm; size_t ln = strlen(leaf); if (leaf[0] == '.') { e.close(); continue; } if (pass == 0 && ln > 5 && strcmp(leaf + ln - 5, ".json") == 0 && e.size() > 0 && e.size() < 900) { char buf[900]; size_t rd = e.read((uint8_t*)buf, sizeof buf - 1); buf[rd] = 0; LuaInstApp& a = s_lua_inst[s_lua_inst_n]; if (luaJsonField(buf, "id", a.id, sizeof a.id) && luaJsonField(buf, "name", a.name, sizeof a.name)) { if (!luaJsonField(buf, "ver", a.ver, sizeof a.ver)) a.ver[0] = 0; if (!luaJsonField(buf, "icon", a.icon, sizeof a.icon)) a.icon[0] = 0; s_lua_inst_n++; } } else if (pass == 1 && ln > 4 && strcmp(leaf + ln - 4, ".lua") == 0) { char id[20]; size_t o = 0; for (size_t k = 0; k + 4 < ln && o + 1 < sizeof id; k++) id[o++] = leaf[k]; id[o] = 0; if (!luaStoreFindInstalled(id)) { // no manifest claimed this id in pass 1 LuaInstApp& a = s_lua_inst[s_lua_inst_n]; snprintf(a.id, sizeof a.id, "%s", id); snprintf(a.name, sizeof a.name, "%s", id); snprintf(a.ver, sizeof a.ver, "dev"); a.icon[0] = 0; s_lua_inst_n++; } } e.close(); } dir.close(); } } static void luaStoreScanInstalled(bool force) { if (s_lua_inst_valid && !force) return; luaStoreScanInstalledFs(force); #if CAP_BUILTIN_LUA_APPS // Seed AFTER the scan so a downloaded copy keeps its own entry and version; // the built-in only fills a gap. Runs even with no filesystem, which is the // whole point on a board that cannot depend on the Store. const int kMaxInst = kLuaInstMax; for (int b = 0; b < kLuaBuiltinCount && s_lua_inst_n < kMaxInst; b++) { if (luaStoreFindInstalled(kLuaBuiltin[b].id)) continue; LuaInstApp& a = s_lua_inst[s_lua_inst_n++]; snprintf(a.id, sizeof a.id, "%s", kLuaBuiltin[b].id); snprintf(a.name, sizeof a.name, "%s", kLuaBuiltin[b].name); snprintf(a.ver, sizeof a.ver, "%s", kLuaBuiltin[b].ver); } #endif } static const LuaInstApp* luaStoreFindInstalled(const char* id) { for (int i = 0; i < s_lua_inst_n; i++) if (strcmp(s_lua_inst[i].id, id) == 0) return &s_lua_inst[i]; return nullptr; } // ---- installed .lang files (Languages tab) ---- struct LangInst { char code[12]; char name[28]; char ver[8]; }; static LangInst* s_langinst = nullptr; // PSRAM (see luaStoreTablesReady) static int s_langinst_n = 0; // Read the "# name:" / "# base:" / "# ver:" header lines of an installed .lang file. static bool luaStoreLangFileMeta(const char* code, char* name, size_t name_cap, char* base, size_t base_cap, char* ver = nullptr, size_t ver_cap = 0) { name[0] = 0; if (base_cap) base[0] = 0; if (ver && ver_cap) ver[0] = 0; fs::FS* fs = luaHostAppFs(); if (!fs) return false; char rel[28], path[64]; snprintf(rel, sizeof rel, "/lang/%s.lang", code); luaHostAppPath(path, sizeof path, rel); File f = fs->open(path, "r"); if (!f) return false; char head[192]; size_t rd = f.read((uint8_t*)head, sizeof head - 1); f.close(); head[rd] = 0; auto grab = [&](const char* tag, char* out, size_t cap) { const char* q = strstr(head, tag); if (!q) return; q += strlen(tag); while (*q == ' ') q++; size_t o = 0; while (q[o] && q[o] != '\n' && q[o] != '\r' && o + 1 < cap) { out[o] = q[o]; o++; } out[o] = 0; }; grab("# name:", name, name_cap); if (base_cap) grab("# base:", base, base_cap); if (ver && ver_cap) { grab("# ver:", ver, ver_cap); if (!ver[0]) snprintf(ver, ver_cap, "1"); // pre-versioning files are v1 } return name[0] != 0; } // The store's tables live in PSRAM: they are ~4.4 KB that only matter while the // store is open, and internal DRAM on the V4 is the scarcest resource there. static bool luaStoreTablesReady() { if (s_lua_cat) return true; auto ps = [](size_t n) { void* p = heap_caps_calloc(1, n, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); return p ? p : calloc(1, n); // no PSRAM (or none free): fall back }; s_lua_cat = (LuaCatApp*) ps(sizeof(LuaCatApp) * kLuaCatMax); s_lua_inst = (LuaInstApp*) ps(sizeof(LuaInstApp) * kLuaInstMax); s_langcat = (LangCatEntry*)ps(sizeof(LangCatEntry) * kLangCatMax); s_langinst = (LangInst*) ps(sizeof(LangInst) * kLangInstMax); return s_lua_cat && s_lua_inst && s_langcat && s_langinst; } static void luaStoreScanLangs() { if (!luaStoreTablesReady()) return; s_langinst_n = 0; fs::FS* fs = luaHostAppFs(); if (!fs) return; char dir[48]; luaHostAppPath(dir, sizeof dir, "/lang"); File d = fs->open(dir); if (!d || !d.isDirectory()) return; File e; while (s_langinst_n < kLangInstMax && (e = d.openNextFile())) { const char* nm = e.name(); const char* leaf = strrchr(nm, '/'); leaf = leaf ? leaf + 1 : nm; size_t ln = strlen(leaf); if (leaf[0] != '.' && ln > 5 && ln - 5 < sizeof(s_langinst[0].code) && strcmp(leaf + ln - 5, ".lang") == 0) { LangInst& L = s_langinst[s_langinst_n]; memcpy(L.code, leaf, ln - 5); L.code[ln - 5] = 0; e.close(); char base[12]; if (!luaStoreLangFileMeta(L.code, L.name, sizeof L.name, base, sizeof base, L.ver, sizeof L.ver)) snprintf(L.name, sizeof L.name, "%s", L.code); if (!L.ver[0]) snprintf(L.ver, sizeof L.ver, "1"); s_langinst_n++; continue; } e.close(); } d.close(); } static void luaStoreParseLangCatalog() { if (!luaStoreTablesReady()) return; s_langcat_n = 0; if (!s_langcat_buf || !s_langcat_buf[0]) { s_langcat_n = -1; return; } const char* p = s_langcat_buf; while (s_langcat_n < kLangCatMax) { const char* ob = strchr(p, '{'); if (!ob) break; const char* cb = strchr(ob, '}'); if (!cb) break; char obj[128]; size_t n = (size_t)(cb - ob + 1); if (n >= sizeof obj) n = sizeof obj - 1; memcpy(obj, ob, n); obj[n] = 0; LangCatEntry& c = s_langcat[s_langcat_n]; if (luaJsonField(obj, "code", c.code, sizeof c.code) && luaJsonField(obj, "name", c.name, sizeof c.name)) { if (!luaJsonField(obj, "ver", c.ver, sizeof c.ver)) snprintf(c.ver, sizeof c.ver, "1"); s_langcat_n++; } p = cb + 1; } if (s_langcat_n == 0) s_langcat_n = -1; } // ---- the Store page ---- static lv_obj_t* s_luastore_root = nullptr; static lv_obj_t* s_luastore_list = nullptr; static lv_timer_t* s_luastore_poll = nullptr; static lv_coord_t s_luastore_w = 240; // list width, set at open — pre-layout get_width() reads 0 static bool s_luastore_busy = false; // an install/remove is in flight static int s_lua_rm_pending = -1; // long-press remove target (drawer tiles) static bool s_tile_lp_fired = false; // swallow the release-click after a long press static int s_luastore_tab = 0; // 0 = Apps, 1 = Built-in, 2 = Languages (kept across opens) static lv_obj_t* s_luastore_tabbtn[3] = { nullptr, nullptr, nullptr }; static void luaStoreRebuildList(); // fwd static void langRebootWithNotice(const char* msg); // fwd (Languages tab actions) void settingsApplyHiddenCats(); // fwd (defined with makeSettings) static void closeLuaStorePage() { if (s_luastore_poll) { lv_timer_del(s_luastore_poll); s_luastore_poll = nullptr; } s_luastore_list = nullptr; s_luastore_tabbtn[0] = s_luastore_tabbtn[1] = s_luastore_tabbtn[2] = nullptr; appPageEnd(&closeLuaStorePage); popupClose(&s_luastore_root); // The drawer underneath was built BEFORE any install/remove done in here — // rebuild it so new tiles appear (and removed ones vanish) immediately. if (s_appdrawer_root) { closeAppDrawer(); openAppDrawer(); } } static void luaStoreParseCatalog() { if (!luaStoreTablesReady()) return; s_lua_cat_n = 0; if (!s_luacat_buf || !s_luacat_buf[0]) { s_lua_cat_n = -1; return; } const char* p = s_luacat_buf; while (s_lua_cat_n < kLuaCatMax) { const char* ob = strchr(p, '{'); if (!ob) break; const char* cb = strchr(ob, '}'); if (!cb) break; char obj[512]; size_t n = (size_t)(cb - ob + 1); if (n >= sizeof obj) n = sizeof obj - 1; memcpy(obj, ob, n); obj[n] = 0; LuaCatApp& c = s_lua_cat[s_lua_cat_n]; if (luaJsonField(obj, "id", c.id, sizeof c.id) && luaJsonField(obj, "name", c.name, sizeof c.name) && luaJsonField(obj, "ver", c.ver, sizeof c.ver)) { if (!luaJsonField(obj, "desc", c.desc, sizeof c.desc)) c.desc[0] = 0; s_lua_cat_n++; } p = cb + 1; } if (s_lua_cat_n == 0) s_lua_cat_n = -1; } // Turn an action button into a "working on it" state. A download over a weak link // takes tens of seconds and the only feedback used to be a 1.2 s toast, so the tap // read as if nothing had happened and people pressed again. // // The caption is HIDDEN rather than deleted: this runs inside the button's own // click event, and deleting objects mid-dispatch is exactly how the popup // use-after-frees happened. Adding the spinner as a new child is safe. static void luaStoreMarkBtnBusy(lv_obj_t* b) { if (!b) return; const uint32_t n = lv_obj_get_child_cnt(b); for (uint32_t k = 0; k < n; k++) lv_obj_add_flag(lv_obj_get_child(b, k), LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(b, lv_color_hex(0x39404C), LV_PART_MAIN); #if LV_USE_SPINNER lv_obj_t* sp = lv_spinner_create(b, 800, 60); const lv_coord_t d = lv_font_get_line_height(&g_font_12) + 2; lv_obj_set_size(sp, d, d); lv_obj_center(sp); lv_obj_clear_flag(sp, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_width(sp, 2, LV_PART_MAIN); lv_obj_set_style_arc_width(sp, 2, LV_PART_INDICATOR); lv_obj_set_style_arc_color(sp, lv_color_hex(0x555E6B), LV_PART_MAIN); lv_obj_set_style_arc_color(sp, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); #else lv_obj_t* dots = lv_label_create(b); // no spinner widget: a static tell still beats nothing lv_label_set_text(dots, "\xE2\x80\xA6"); lv_obj_set_style_text_font(dots, &g_font_12, LV_PART_MAIN); lv_obj_center(dots); #endif } static void luaStoreInstallBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_luastore_busy) return; int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= s_lua_cat_n) return; snprintf(s_luainst_id, sizeof s_luainst_id, "%s", s_lua_cat[idx].id); snprintf(s_luainst_ver, sizeof s_luainst_ver, "%s", s_lua_cat[idx].ver); s_luastore_busy = true; s_luainst_done = false; s_luainst_request = true; ensureTileFetchTaskRunning(); luaStoreMarkBtnBusy(lv_event_get_target(e)); if (g_lv.task) g_lv.task->showAlert(TR("Installing\xE2\x80\xA6"), 1200); } // Delete an installed app's files (.sav kept: reinstalls keep scores) + rescan. static void luaStoreRemoveApp(int idx) { if (idx < 0 || idx >= s_lua_inst_n) return; fs::FS* fs = luaHostAppFs(); if (!fs) return; static const char* const kExt[2] = { "lua", "json" }; for (int x = 0; x < 2; x++) { char rel[40], path[64]; snprintf(rel, sizeof rel, "/apps/%s.%s", s_lua_inst[idx].id, kExt[x]); luaHostAppPath(path, sizeof path, rel); fs->remove(path); } luaStoreScanInstalled(true); // files just deleted if (g_lv.task) g_lv.task->showAlert(TR("Removed"), 1200); } static void luaStoreRemoveBtnCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_luastore_busy) return; luaStoreRemoveApp((int)(intptr_t)lv_event_get_user_data(e)); luaStoreRebuildList(); } static void luaStorePollCb(lv_timer_t* t) { (void)t; if (s_luacat_done) { s_luacat_done = false; luaStoreParseCatalog(); luaStoreRebuildList(); } if (s_luascan_done) { s_luascan_done = false; luaStoreRebuildList(); // worker finished the card re-listing } if (s_langcat_done) { s_langcat_done = false; luaStoreParseLangCatalog(); luaStoreRebuildList(); } if (s_langdl_done) { s_langdl_done = false; s_luastore_busy = false; // "Download failed" alone sent a reporter to GitHub with nothing to go on, because // every failure path in the worker is silent (#274). Name the cause when we know it. auto langDlFailText = []() -> const char* { static char msg[72]; const char* why = (const char*)s_langdl_err; if (!why) return TR("Download failed"); snprintf(msg, sizeof msg, "%s\n%s", TR("Download failed"), TR(why)); return msg; }; if (s_langdl_ok && s_langdl_reboot) { // updated the active language s_langdl_reboot = false; langRebootWithNotice(TR("Language updated - restarting to apply it\xE2\x80\xA6")); return; } s_langdl_reboot = false; if (s_langdl_ok) luaStoreScanLangs(); luaStoreRebuildList(); if (g_lv.task && !s_langdl_silent) g_lv.task->showAlert( s_langdl_ok ? TR("Downloaded - tap Use to switch") : langDlFailText(), s_langdl_ok ? 1800 : 3200); s_langdl_silent = false; } if (s_luainst_done) { s_luainst_done = false; s_luastore_busy = false; // Reflect the install from what we just downloaded rather than trusting a // re-listing of the directory we only now wrote (FAT dir entries can lag a // write on some cards, which left the button reading "Get" until reopen). if (s_luainst_ok && !luaStoreFindInstalled(s_luainst_id) && s_lua_inst_n < kLuaInstMax) { LuaInstApp& a = s_lua_inst[s_lua_inst_n++]; snprintf(a.id, sizeof a.id, "%s", s_luainst_id); snprintf(a.ver, sizeof a.ver, "%s", s_luainst_ver); snprintf(a.name, sizeof a.name, "%s", s_luainst_id); for (int c = 0; c < s_lua_cat_n; c++) // prefer the catalog's display name if (strcmp(s_lua_cat[c].id, s_luainst_id) == 0) { snprintf(a.name, sizeof a.name, "%s", s_lua_cat[c].name); break; } } else if (s_luainst_ok) { // already listed (an update): refresh its version in place for (int i = 0; i < s_lua_inst_n; i++) if (strcmp(s_lua_inst[i].id, s_luainst_id) == 0) snprintf(s_lua_inst[i].ver, sizeof s_lua_inst[i].ver, "%s", s_luainst_ver); } luaStoreRebuildList(); if (g_lv.task) g_lv.task->showAlert(s_luainst_ok ? TR("Installed") : TR("Install failed (network?)"), s_luainst_ok ? 1400 : 2200); } } static void luaStoreHideSwitchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; uint32_t bit = (uint32_t)(uintptr_t)lv_event_get_user_data(e); uint32_t mask = touchPrefsGetAppHide(); // switch ON = app visible -> bit cleared if (lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)) mask &= ~bit; else mask |= bit; touchPrefsSetAppHide(mask); settingsApplyHiddenCats(); // MQTT lives in Settings, not the drawer — apply now } // ---- Languages tab actions ---- // Every language change needs a restart to re-render the whole UI. Show WHY // first: showAlert + an immediate rebootDevice() never painted (the reboot ran // before LVGL's next frame), so the device just went dark under the user's // finger. Schedule the reboot instead, so the notice is on screen for it. static void langRebootTimerCb(lv_timer_t* t) { (void)t; // repeat_count 1 -> LVGL deletes it if (g_lv.task) g_lv.task->rebootDevice(); } static void langRebootWithNotice(const char* msg) { s_luastore_busy = true; // swallow further taps on the way out if (g_lv.task) g_lv.task->showAlert(msg, 2400); lv_timer_t* t = lv_timer_create(langRebootTimerCb, 1800, nullptr); if (t) lv_timer_set_repeat_count(t, 1); else if (g_lv.task) g_lv.task->rebootDevice(); // no timer slot: don't strand the user } // Switching language reboots (same as the Settings picker) so the whole UI — // fonts, keyboard layout, every built label — re-renders consistently. static void luaStoreLangBuiltinCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; uint8_t l = (uint8_t)(intptr_t)lv_event_get_user_data(e); if (l >= LANG_COUNT) return; touchPrefsSetLangFile(""); touchPrefsSetUiLang(l); i18nSetLang(l); kbApplyUiLangDefault(l); langRebootWithNotice(TR("Restarting to apply the language\xE2\x80\xA6")); } static void luaStoreLangUseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; int i = (int)(intptr_t)lv_event_get_user_data(e); if (i < 0 || i >= s_langinst_n) return; // The file's "# base:" names the built-in column missing keys fall back to // (a customized nl file degrades to built-in Dutch, not to English). uint8_t base_lang = LANG_EN; char name[28], base[12]; if (luaStoreLangFileMeta(s_langinst[i].code, name, sizeof name, base, sizeof base) && base[0]) { for (uint8_t l = 0; l < LANG_COUNT; l++) if (strcmp(base, kUiLangCodes[l]) == 0) { base_lang = l; break; } } touchPrefsSetUiLang(base_lang); touchPrefsSetLangFile(s_langinst[i].code); kbApplyUiLangDefault(base_lang); langRebootWithNotice(TR("Restarting to apply the language\xE2\x80\xA6")); } static void luaStoreLangGetCb(lv_event_t* e) { // Get AND Update (same download) if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_luastore_busy) return; int i = (int)(intptr_t)lv_event_get_user_data(e); if (i < 0 || i >= s_langcat_n) return; snprintf(s_langdl_code, sizeof s_langdl_code, "%s", s_langcat[i].code); snprintf(s_langdl_ver, sizeof s_langdl_ver, "%s", s_langcat[i].ver); s_langdl_silent = false; // Updating the language currently in use: apply it by rebooting on success — // the overlay in PSRAM is the old file, so a reboot is the honest apply. char cur[12]; touchPrefsGetLangFile(cur, sizeof cur); s_langdl_reboot = (cur[0] && strcmp(cur, s_langcat[i].code) == 0); s_luastore_busy = true; s_langdl_done = false; s_langdl_request = true; ensureTileFetchTaskRunning(); luaStoreMarkBtnBusy(lv_event_get_target(e)); if (g_lv.task) g_lv.task->showAlert(TR("Downloading\xE2\x80\xA6"), 1200); } static void luaStoreLangRemoveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_luastore_busy) return; int i = (int)(intptr_t)lv_event_get_user_data(e); if (i < 0 || i >= s_langinst_n) return; fs::FS* fs = luaHostAppFs(); if (!fs) return; char rel[28], path[64]; snprintf(rel, sizeof rel, "/lang/%s.lang", s_langinst[i].code); luaHostAppPath(path, sizeof path, rel); fs->remove(path); char cur[12]; touchPrefsGetLangFile(cur, sizeof cur); if (strcmp(cur, s_langinst[i].code) == 0) touchPrefsSetLangFile(""); // deleted the active one luaStoreScanLangs(); luaStoreRebuildList(); } static void luaStoreStyleTabs() { for (int t = 0; t < 3; t++) { lv_obj_t* b = s_luastore_tabbtn[t]; if (!b || !lv_obj_is_valid(b)) continue; const bool on = (t == s_luastore_tab); lv_obj_set_style_bg_color(b, lv_color_hex(on ? COLOR_ACCENT : COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* l = lv_obj_get_child(b, 0); if (l) lv_obj_set_style_text_color(l, lv_color_hex(on ? 0x0E1216 : COLOR_SUB), LV_PART_MAIN); } } static void luaStoreTabCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int t = (int)(intptr_t)lv_event_get_user_data(e); if (t == s_luastore_tab) return; s_luastore_tab = t; if (t == 2) { luaStoreScanLangs(); // tiny dir; sync is fine here if (s_langcat_n <= 0) { // no lang catalog yet -> fetch s_langcat_n = 0; s_langcat_done = false; s_langcat_request = true; ensureTileFetchTaskRunning(); } } luaStoreRebuildList(); } static void luaStoreRebuildList() { if (!s_luastore_list) return; // Keep the reader where they were. This runs from ASYNC completions -- the // catalog fetch, the card scan, a language catalog -- which land a second or // two after the Store opens, i.e. exactly while someone is scrolling down it. // Rebuilding threw them back to the top every time, which also hid whatever // they had scrolled to (an app near the bottom could look like it had no // button at all). Restored after the rows exist, so the value is clamped // against the NEW content height rather than the old one. const lv_coord_t keep_scroll_y = lv_obj_get_scroll_y(s_luastore_list); lv_obj_clean(s_luastore_list); const uint32_t hide = touchPrefsGetAppHide(); const lv_coord_t W = s_luastore_w; const lv_coord_t lh14 = lv_font_get_line_height(&g_font_14); const lv_coord_t lh12 = lv_font_get_line_height(&g_font_12); // The store was tuned on the 320 px T-Deck; the V4 / M9 panels are 240 px // portrait, where a fixed 100 px button gutter eats half the text column. const bool narrow = (W < 260); const lv_coord_t act_w = narrow ? 62 : 78; // Get / Update / Remove button const lv_coord_t act_gut = act_w + (narrow ? 12 : 22); // text column stops here // Height a wrapped string actually needs in a column `w` wide. The cards used // to hardcode "title + two description lines", which only held for the 320 px // T-Deck the store was designed on: on the P4's 568 px portrait panel, and for // any language whose translation runs longer than the English, the description // wraps to three or four lines and the fixed card height cut it off. Measure // the text and let the card follow it. lv_txt_get_size is a pure calculation, // so this is safe before layout (reading lv_obj_get_height here returns 0). auto wrapH = [](const char* txt, const lv_font_t* f, lv_coord_t w) -> lv_coord_t { if (!txt || !txt[0] || w <= 0) return 0; lv_point_t sz; lv_txt_get_size(&sz, txt, f, 0, 0, w, LV_TEXT_FLAG_NONE); return sz.y; }; auto section = [&](const char* txt) { lv_obj_t* l = lv_label_create(s_luastore_list); char up[40]; snprintf(up, sizeof up, "%s", txt); for (char* q = up; *q; ++q) if (*q >= 'a' && *q <= 'z') *q -= 32; // small-caps header lv_label_set_text(l, up); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_letter_space(l, 1, LV_PART_MAIN); lv_obj_set_style_pad_top(l, 10, LV_PART_MAIN); lv_obj_set_style_pad_left(l, 2, LV_PART_MAIN); }; luaStoreStyleTabs(); if (s_luastore_tab == 1) { // ==== tab: Built-in — show/hide the stock apps ==== section(TR("Show in the app drawer")); struct { const char* name; uint32_t bit; } builtins[] = { { "Spectrum", APPHIDE_SPECTRUM }, { "Discover", APPHIDE_DISCOVER }, #if !defined(HAS_TANMATSU) { "VNC", APPHIDE_VNC }, { "Remote", APPHIDE_REMOTE }, #endif #if defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER { "Web", APPHIDE_READER }, #endif { "Signal", APPHIDE_SIGNAL }, { "Mentions", APPHIDE_MENTIONS }, #if defined(HAS_TOUCH_UI) { "Terminal", APPHIDE_TERMINAL }, #endif #if CAP_FILESYSTEM || defined(TLORA_PAGER) { "Files", APPHIDE_FILES }, #endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { "MQTT bridge", APPHIDE_MQTT }, // a Settings section, not a drawer tile #endif }; for (auto& bi : builtins) { lv_obj_t* row = lv_obj_create(s_luastore_list); lv_obj_remove_style_all(row); lv_obj_set_size(row, W - 8, 34); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, bi.name); lv_obj_set_style_text_font(nm, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(nm, LV_ALIGN_LEFT_MID, 2, 0); lv_obj_t* sw = lv_switch_create(row); lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0); if (!(hide & bi.bit)) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, luaStoreHideSwitchCb, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)bi.bit); } return; } if (s_luastore_tab == 2) { // ==== tab: Languages — built-in picks + downloadable/custom .lang files ==== char curFile[12]; touchPrefsGetLangFile(curFile, sizeof curFile); const uint8_t curLang = i18nGetLang(); auto langRow = [&](const char* name, const char* sub) { lv_obj_t* row = lv_obj_create(s_luastore_list); lv_obj_remove_style_all(row); lv_obj_set_size(row, W - 8, 38); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 10, LV_PART_MAIN); lv_obj_set_style_border_color(row, lv_color_hex(0x232830), LV_PART_MAIN); lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); lv_obj_set_style_pad_hor(row, 8, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, name); lv_obj_set_style_text_font(nm, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_width(nm, W - (narrow ? 108 : 150)); // room for [trash][Use] lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_set_height(nm, lh14); // one line: LONG_DOT only ellipsizes when the // height is bounded, else it silently wraps lv_obj_align(nm, LV_ALIGN_LEFT_MID, 0, sub ? -8 : 0); if (sub) { lv_obj_t* sb = lv_label_create(row); lv_label_set_text(sb, sub); lv_obj_set_style_text_font(sb, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sb, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(sb, LV_ALIGN_LEFT_MID, 0, 8); } return row; }; auto actionBtn = [&](lv_obj_t* row, const char* txt, uint32_t col, lv_event_cb_t cb, intptr_t ud, int xoff, int bw) { lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, bw, 28); lv_obj_align(b, LV_ALIGN_RIGHT_MID, xoff, 0); lv_obj_set_style_radius(b, 8, LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(col), LV_PART_MAIN); lv_obj_t* bl = lv_label_create(b); lv_label_set_text(bl, txt); lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN); lv_obj_center(bl); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)ud); return b; }; auto activeTag = [&](lv_obj_t* row, int xoff) { lv_obj_t* a = lv_label_create(row); lv_label_set_text(a, TR("Active")); lv_obj_set_style_text_font(a, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(a, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(a, LV_ALIGN_RIGHT_MID, xoff, 0); }; lv_obj_t* hint = lv_label_create(s_luastore_list); #if CAP_BUILTIN_LANGS lv_label_set_text(hint, TR("Tap Use to switch - the device reboots to apply.")); #else lv_label_set_text(hint, TR("Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own.")); #endif lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(hint, W - 12); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); #if CAP_BUILTIN_LANGS // All translations ship in the image on this board: a picker, no downloads. for (uint8_t l = 0; l < LANG_COUNT; ++l) { lv_obj_t* row = langRow(kUiLangNames[l], nullptr); if (!curFile[0] && l == curLang) activeTag(row, -6); else actionBtn(row, TR("Use"), 0x2A5A8A, luaStoreLangBuiltinCb, (intptr_t)l, 0, narrow ? 52 : 58); } return; #endif section(TR("Installed")); { // English is always here (it's the source keys). Every other language is // a downloaded .lang file — the built-in table only backs the files up. lv_obj_t* row = langRow("English", nullptr); if (!curFile[0] && curLang == LANG_EN) activeTag(row, -6); else actionBtn(row, TR("Use"), 0x2A5A8A, luaStoreLangBuiltinCb, (intptr_t)LANG_EN, 0, narrow ? 52 : 58); } for (int i = 0; i < s_langinst_n; i++) { // A newer catalog ver for this installed file -> offer Update in place. int cat = -1; for (int c = 0; c < s_langcat_n; c++) if (strcmp(s_langcat[c].code, s_langinst[i].code) == 0) { cat = c; break; } // #247: != treated ANY difference as "update available" — including an // installed file NEWER than the catalog (a translator side-loading their // work-in-progress), and clicking Update then DOWNGRADED it. Offer the // update only when the catalog is numerically newer; non-numeric vers // (atof 0 on both) fall back to the old inequality. bool stale = false; if (cat >= 0) { const double cv = atof(s_langcat[cat].ver), iv = atof(s_langinst[i].ver); stale = (cv > 0.0 || iv > 0.0) ? (cv > iv + 1e-9) : strcmp(s_langcat[cat].ver, s_langinst[i].ver) != 0; } char sub[24]; snprintf(sub, sizeof sub, "%s.lang v%s", s_langinst[i].code, s_langinst[i].ver); lv_obj_t* row = langRow(s_langinst[i].name, sub); const bool act = curFile[0] && strcmp(curFile, s_langinst[i].code) == 0; const lv_coord_t tw = narrow ? 30 : 34; // trash const lv_coord_t uw = narrow ? 52 : 58; // Use const lv_coord_t upw = narrow ? 58 : 66; // Update const bool dl = s_luastore_busy && s_langdl_code[0] && strcmp(s_langdl_code, s_langinst[i].code) == 0; // this one is downloading actionBtn(row, LV_SYMBOL_TRASH, 0x8A4444, luaStoreLangRemoveCb, (intptr_t)i, 0, tw); if (stale) { lv_obj_t* ub = actionBtn(row, TR("Update"), 0x4F9DF7, luaStoreLangGetCb, (intptr_t)cat, -(tw + 4), upw); if (dl) luaStoreMarkBtnBusy(ub); // survive a rebuild landing mid-download } else if (act) activeTag(row, -(tw + 8)); else actionBtn(row, TR("Use"), 0x2A5A8A, luaStoreLangUseCb, (intptr_t)i, -(tw + 4), uw); } section(TR("Available")); for (int i = 0; i < s_langcat_n; i++) { bool have = false; for (int j = 0; j < s_langinst_n; j++) if (strcmp(s_langinst[j].code, s_langcat[i].code) == 0) { have = true; break; } if (have) continue; lv_obj_t* row = langRow(s_langcat[i].name, s_langcat[i].code); lv_obj_t* gb = actionBtn(row, TR("Get"), COLOR_ACCENT, luaStoreLangGetCb, (intptr_t)i, 0, narrow ? 52 : 58); if (s_luastore_busy && s_langdl_code[0] && strcmp(s_langdl_code, s_langcat[i].code) == 0) luaStoreMarkBtnBusy(gb); // survive a rebuild landing mid-download } if (s_langcat_n <= 0) { lv_obj_t* h = lv_label_create(s_luastore_list); lv_label_set_text(h, s_langcat_n < 0 ? TR("Language catalog unavailable - check Wi-Fi, then reopen this tab.") : TR("Loading the language catalog\xE2\x80\xA6")); lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(h, lv_color_hex(s_langcat_n < 0 ? 0xE08080 : COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(h, W - 12); lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP); } return; } // ==== tab: Apps (the catalog) ==== if (s_lua_cat_n <= 0) { lv_obj_t* card = lv_obj_create(s_luastore_list); lv_obj_remove_style_all(card); // Height from the wrapped hint, not a one-line guess — on a 240 px panel // the second/third line used to be cut off by the card's own bottom edge. lv_obj_set_size(card, W - 8, lh14 + lh12 * (narrow ? 3 : 2) + 22); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 10, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 8, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, s_lua_cat_n < 0 ? TR("Catalog unavailable") : TR("Loading catalog\xE2\x80\xA6")); lv_obj_set_style_text_font(t, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(t, lv_color_hex(s_lua_cat_n < 0 ? 0xE08080 : COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(t, 0, 0); lv_obj_t* h = lv_label_create(card); useChainedFont(h); if (s_lua_cat_n < 0 && s_tile_fetch_task == nullptr) { // The network worker could not start: report the numbers that decide it // (it needs one contiguous 8 KB internal block) right on screen, so this // is diagnosable from the device instead of a serial cable. char mem[128]; snprintf(mem, sizeof mem, "%s\nfree %u KB, largest block %u KB (needs 8 KB)", TR("Not enough free memory to fetch it. Reboot and try again."), (unsigned)(ESP.getFreeHeap() / 1024), (unsigned)(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) / 1024)); lv_label_set_text(h, mem); } else { lv_label_set_text(h, s_lua_cat_n < 0 ? TR("Turn Wi-Fi on, then tap refresh. Apps already installed still work.") : TR("Fetching the app list from firmware.wadamesh.com")); } lv_obj_set_style_text_font(h, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(h, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(h, W - 26); lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP); lv_obj_set_pos(h, 0, lh14 + 3); // Size to whatever the (possibly translated, possibly multi-line diagnostic) // body actually needs — read the text back so this can't drift from the // branches above. lv_obj_set_height(card, lh14 + 3 + wrapH(lv_label_get_text(h), &g_font_12, W - 26) + 16); } const int chip_h = lh14 + lh12 - 2; // icon chip (square) const lv_coord_t pad_v = 14; // pad_all 7, top + bottom for (int i = 0; i < s_lua_cat_n; i++) { // Per card, because every description wraps differently. Content is the // title line plus the wrapped description, but never shorter than the icon // chip or the action button or those would poke out of a short card. char title[64]; snprintf(title, sizeof title, "%s %s", s_lua_cat[i].name, s_lua_cat[i].ver); // Measure the TITLE too, don't assume one line. A long name plus its version // wraps in the text column, and the description used to sit at a fixed // lh14 + 2 regardless — so the second title line landed on top of it. const lv_coord_t dsw = W - (chip_h + 8) - act_gut; const lv_coord_t ttlH = wrapH(title, &g_font_14, dsw); const lv_coord_t textH = ttlH + 2 + wrapH(s_lua_cat[i].desc, &g_font_12, dsw); lv_coord_t contentH = textH; if (contentH < chip_h) contentH = chip_h; if (contentH < 32) contentH = 32; // the Get/Update/Remove button lv_obj_t* row = lv_obj_create(s_luastore_list); lv_obj_remove_style_all(row); lv_obj_set_size(row, W - 8, contentH + pad_v); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 10, LV_PART_MAIN); lv_obj_set_style_border_color(row, lv_color_hex(0x232830), LV_PART_MAIN); lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(row, 7, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); const LuaInstApp* inst = luaStoreFindInstalled(s_lua_cat[i].id); // Squircle icon chip with the app's initial, matching the drawer's look. const int chip = chip_h; lv_obj_t* ico = lv_obj_create(row); lv_obj_remove_style_all(ico); lv_obj_clear_flag(ico, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(ico, chip, chip); // Top-aligned, not centred: once a card can be four lines tall a centred chip // drifts away from the title it belongs to. lv_obj_align(ico, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_set_style_bg_color(ico, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(ico, LV_OPA_20, LV_PART_MAIN); lv_obj_set_style_border_color(ico, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_border_width(ico, 1, LV_PART_MAIN); lv_obj_set_style_border_opa(ico, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_radius(ico, chip * 24 / 100, LV_PART_MAIN); lv_obj_t* il = lv_label_create(ico); char initial[2] = { s_lua_cat[i].name[0], 0 }; lv_label_set_text(il, initial); lv_obj_set_style_text_font(il, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(il, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_center(il); const int tx = chip + 8; // text column starts past the chip lv_obj_t* nm = lv_label_create(row); lv_label_set_text(nm, title); lv_obj_set_style_text_font(nm, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(inst ? COLOR_ACCENT : COLOR_TEXT), LV_PART_MAIN); lv_obj_set_width(nm, W - tx - act_gut); // clear of the action button lv_label_set_long_mode(nm, LV_LABEL_LONG_WRAP); // WRAP, not DOT: the card grew to fit it lv_obj_set_pos(nm, tx, 0); // Description gets its own full-width line — it used to share the row with // the version badge and was squeezed into an unreadable sliver. lv_obj_t* ds = lv_label_create(row); lv_label_set_text(ds, s_lua_cat[i].desc); lv_obj_set_style_text_font(ds, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(ds, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(ds, W - tx - act_gut); // same column as the title: never under the button lv_label_set_long_mode(ds, LV_LABEL_LONG_WRAP); lv_obj_set_pos(ds, tx, ttlH + 2); // below the title's ACTUAL height, however it wrapped lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, act_w, 32); lv_obj_align(b, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_style_radius(b, 8, LV_PART_MAIN); lv_obj_t* bl = lv_label_create(b); const bool cur = inst && strcmp(inst->ver, s_lua_cat[i].ver) == 0; if (!inst) lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); else if (!cur) lv_obj_set_style_bg_color(b, lv_color_hex(0x4F9DF7), LV_PART_MAIN); lv_label_set_text(bl, !inst ? TR("Get") : cur ? TR("Remove") : TR("Update")); lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN); lv_obj_center(bl); if (inst && cur) { // Remove needs the INSTALLED index int ii = (int)(inst - s_lua_inst); lv_obj_set_style_bg_color(b, lv_color_hex(0x8A4444), LV_PART_MAIN); lv_obj_add_event_cb(b, luaStoreRemoveBtnCb, LV_EVENT_CLICKED, (void*)(intptr_t)ii); } else { lv_obj_add_event_cb(b, luaStoreInstallBtnCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } // A rebuild can land mid-download (a card scan or the catalog fetch finishing) // and would wipe the spinner set on tap, so repaint it from the in-flight id. if (s_luastore_busy && s_luainst_id[0] && strcmp(s_luainst_id, s_lua_cat[i].id) == 0) luaStoreMarkBtnBusy(b); } // ---- installed-but-not-in-catalog (sideloaded from the card) ---- { bool any_side = false; for (int i = 0; i < s_lua_inst_n; i++) { bool in_cat = false; for (int c = 0; c < s_lua_cat_n; c++) if (strcmp(s_lua_cat[c].id, s_lua_inst[i].id) == 0) { in_cat = true; break; } if (!in_cat) { any_side = true; break; } } if (any_side) section(TR("Your own apps")); } for (int i = 0; i < s_lua_inst_n; i++) { bool in_cat = false; for (int c = 0; c < s_lua_cat_n; c++) if (strcmp(s_lua_cat[c].id, s_lua_inst[i].id) == 0) { in_cat = true; break; } if (in_cat) continue; lv_obj_t* row = lv_obj_create(s_luastore_list); lv_obj_remove_style_all(row); lv_obj_set_size(row, W - 8, 40); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 10, LV_PART_MAIN); lv_obj_set_style_border_color(row, lv_color_hex(0x232830), LV_PART_MAIN); lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(row, 7, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* nm = lv_label_create(row); char head[64]; snprintf(head, sizeof head, "%s %s", s_lua_inst[i].name, s_lua_inst[i].ver); lv_label_set_text(nm, head); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_width(nm, W - act_gut); // clear of the Remove button lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_set_height(nm, lh12); // one line (see the Languages rows above) lv_obj_align(nm, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_t* b = lv_btn_create(row); lv_obj_set_size(b, act_w, 28); lv_obj_align(b, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_style_bg_color(b, lv_color_hex(0x8A4444), LV_PART_MAIN); lv_obj_t* bl = lv_label_create(b); lv_label_set_text(bl, TR("Remove")); lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN); lv_obj_center(bl); lv_obj_add_event_cb(b, luaStoreRemoveBtnCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } if (keep_scroll_y > 0) { lv_obj_update_layout(s_luastore_list); // content height must be known first lv_obj_scroll_to_y(s_luastore_list, keep_scroll_y, LV_ANIM_OFF); } } static void openLuaStorePage() { luaStoreTablesReady(); closeLuaStorePage(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr), sh = lv_disp_get_ver_res(nullptr); // lv_layer_top, below the status bar, exactly like the Monitor/Discover pages: // created AFTER the app drawer (also on layer_top), so it stacks above it and // closing reveals the drawer again. s_luastore_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_luastore_root); lv_obj_set_size(s_luastore_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_luastore_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_luastore_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_luastore_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_luastore_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_luastore_root); // Segmented tab bar (fixed above the scrolling list): Apps | Built-in | Languages // Follows the font instead of being a hardcoded 30 px: on the P4's high-DPI panel // (and at the larger UI scales) a 12 px line box plus padding is taller than 30, // so the tab label was clipped inside its own button. Floored at 30 so the S3 // boards keep exactly the bar they had. lv_coord_t bar_h = lv_font_get_line_height(&g_font_12) + 14; if (bar_h < 30) bar_h = 30; { lv_obj_t* bar = lv_obj_create(s_luastore_root); lv_obj_remove_style_all(bar); lv_obj_set_size(bar, sw - 12, bar_h); lv_obj_set_pos(bar, 6, 4); lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); static const char* const kStoreTabs[3] = { "Apps", "Built-in", "Languages" }; #if CAP_BUILTIN_LUA_APPS // This board ships its apps and languages inside the image, so there is // nothing to browse or install — the page is a picker, not a shop. const int t_first = 1, t_count = 2; #else const int t_first = 0, t_count = 3; #endif // Cells sized to their text, NOT even thirds. The P4 is 284 px logical and its // fonts are scaled up, so an even third is ~90 px — "Languages" cannot fit that // at the board's smallest font (uiFitLabelWidth bottoms out at g_font_12, which // is montserrat_16/18/20 there), while "Apps" leaves half its cell empty. Hand // out the bar in proportion to what each label actually measures and the long // one gets the room the short one was wasting. Self-balancing for translations. const lv_coord_t total = sw - 12; lv_coord_t need[3] = { 0, 0, 0 }; int32_t need_sum = 0; for (int t = t_first; t < 3; t++) { lv_point_t sz; lv_txt_get_size(&sz, TR(kStoreTabs[t]), &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); need[t] = sz.x + 16; // text plus breathing room need_sum += need[t]; } if (need_sum <= 0) need_sum = t_count; // degenerate: fall back to even lv_coord_t x = 0; for (int t = t_first; t < 3; t++) { // Last cell takes the remainder so rounding can never leave a gap or overrun. const lv_coord_t cw = (t == 2) ? (total - x) : (lv_coord_t)(((int32_t)total * need[t]) / need_sum); lv_obj_t* b = lv_btn_create(bar); s_luastore_tabbtn[t] = b; lv_obj_remove_style_all(b); lv_obj_set_size(b, cw - 4, bar_h - 4); lv_obj_set_pos(b, x + 2, 0); lv_obj_set_style_radius(b, 8, LV_PART_MAIN); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(kStoreTabs[t])); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); // Shrink the font rather than setting a width: a width plus LONG_DOT does NOT // ellipsize here (DOT only kicks in when the HEIGHT is bounded), it silently // wraps the label onto a second line inside a one-line button. uiFitLabelWidth(l, cw - 8); lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(l); lv_obj_add_event_cb(b, luaStoreTabCb, LV_EVENT_CLICKED, (void*)(intptr_t)t); x += cw; } } s_luastore_list = lv_obj_create(s_luastore_root); lv_obj_remove_style_all(s_luastore_list); s_luastore_w = sw - 12; lv_obj_set_size(s_luastore_list, s_luastore_w, sh - STATUSBAR_H - bar_h - 14); lv_obj_set_pos(s_luastore_list, 6, 4 + bar_h + 4); lv_obj_set_scroll_dir(s_luastore_list, LV_DIR_VER); lv_obj_set_flex_flow(s_luastore_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(s_luastore_list, 6, LV_PART_MAIN); #if CAP_BUILTIN_LUA_APPS if (s_luastore_tab == 0) s_luastore_tab = 2; // no Apps tab on this board #endif appPageBeginSlim(TR("Store"), &closeLuaStorePage); // one-line bar; the tab bar is the page header // Open instantly from the cached install list; the card re-listing (sideload // pickup) runs on the worker and the poll timer repaints when it lands. s_luascan_request = true; if (s_lua_cat_n <= 0) { // no catalog yet this session -> fetch s_lua_cat_n = 0; s_luacat_done = false; s_luacat_request = true; } const bool worker_up = ensureTileFetchTaskRunning(); // The net worker needs an 8 KB contiguous internal block. When memory is too // tight to start it, nothing will ever service the request — so fail visibly // instead of spinning on "Loading catalog..." forever. if (!worker_up) { s_lua_cat_n = -1; s_luacat_request = false; s_langcat_n = -1; s_langcat_request = false; } if (s_luastore_tab == 2) { // reopened straight onto Languages luaStoreScanLangs(); if (s_langcat_n <= 0) { s_langcat_n = 0; s_langcat_done = false; s_langcat_request = true; } } luaStoreRebuildList(); s_luastore_poll = lv_timer_create(luaStorePollCb, 250, nullptr); } // Settings -> Language lands here: the Store, opened straight onto Languages. static void luaStoreOpenLanguages() { s_luastore_tab = 2; openLuaStorePage(); } #endif // CAP_LUA_APPS (store page) // ===== App drawer (full-screen grid launcher) ============================= // Toggle-in alternative to the command-centre home: a grid of every tool. The // home's "Apps" button opens it; its own "Home" button (and the back key) close // it, revealing the command centre. Built as a self-contained lv_layer_top // overlay (NOT the T-Deck-only openFullscreenView) so it compiles on both boards. enum AppDrawerAction { APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS, APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL, APPACT_TERMINAL, APPACT_FILES, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, APPACT_DISCOVER, APPACT_STORE, APPACT_LUA_BASE = 100, // APPACT_LUA_BASE + i = installed Lua app s_lua_inst[i] }; static void closeAppDrawer() { popupClose(&s_appdrawer_root); // del_async + wait_release: the drawer grid scrolls, so guard the throw UAF } // Synchronous close — removes the overlay THIS instant, not deferred. Only safe // where we're NOT inside the drawer's own event chain (e.g. tabChangedCb on a // bottom-bar tap). Leaving Home this way means the map's slow first tile render // can't paint under a still-present, del_async'd drawer. static void closeAppDrawerSync() { if (s_appdrawer_root) { popupClose(&s_appdrawer_root); } } // Set the Home tab's view (false = command centre, true = app drawer) and make the // overlay match. Idempotent. The mode persists across tab switches (tabChangedCb // shows/hides the overlay to match when re-entering / leaving Home). static void setHomeDrawer(bool show) { s_home_drawer_mode = show; s_tab_changed = false; // explicit action — don't let a pending switch flag suppress the next Home toggle if (show) { if (!s_appdrawer_root) openAppDrawer(); } else closeAppDrawer(); } #if defined(HAS_TANMATSU) // Green ○ (F4) tap — mirror the bottom-bar Home button: when already on Home, toggle the app // drawer; otherwise jump to Home. Long-press F4 still opens the control center (see navPump). static void homeKeyActivate() { if (getActiveTab() == HOME_TAB_INDEX) setHomeDrawer(!s_home_drawer_mode); else navGoToMainTab(HOME_TAB_INDEX); } #endif // Bottom-bar Home button = command-centre / app-drawer toggle, but ONLY when you // were already on Home (a same-tap, no switch). tabChangedCb sets s_tab_changed on // a real switch and owns the restore-on-return; this only flips the view when Home // is re-tapped in place, so the two never fight (the old bug was them fighting). static void homeTabClickedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const bool was_switch = s_tab_changed; s_tab_changed = false; if (!was_switch && getActiveTab() == HOME_TAB_INDEX) setHomeDrawer(!s_home_drawer_mode); } // Swipe UP starting on the bottom menu bar -> open the app drawer, from any tab. // The gesture fires on whatever object the press began on, so a list-scroll swipe // (which starts on the list, not the bar) never triggers this. static void tabBarGestureCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_GESTURE) return; if (anyPopupOpen()) return; // don't open the drawer over an open popup (or when already open) lv_indev_t* indev = lv_indev_get_act(); if (indev && lv_indev_get_gesture_dir(indev) == LV_DIR_TOP) { goToTab(HOME_TAB_INDEX); setHomeDrawer(true); } } // ===== @-mentions screen =================================================== // Lists every channel message that @mentions you (textMentionsMe), newest first; // tapping a row jumps to that channel. Opened from the drawer's @ tile, floating // OVER the drawer (back returns to it). The bottom tab bar stays visible. static void closeMentionsScreen() { if (s_mentions_root) { popupClose(&s_mentions_root); } } // Channel thread index for a name, via the public getThreadInfo (findThreadByName // is private). -1 if no matching channel thread. static int findChannelThreadByName(const char* name) { if (!g_lv.task || !name || !name[0]) return -1; for (int i = 0; i < UITask::MAX_UI_THREADS; i++) { bool ch; uint16_t unread; uint32_t ts; char tn[UITask::MAX_THREAD_NAME + 1]; if (!g_lv.task->getThreadInfo(i, ch, unread, ts, tn, sizeof tn)) continue; if (ch && strncmp(tn, name, UITask::MAX_THREAD_NAME) == 0) return i; } return -1; } // Open a thread's chat detail by index — the same sequence the inbox tap uses. static void openThreadDetailByIdx(int idx, bool channel) { if (!g_lv.task || idx < 0) return; LvChatPanel& p = channel ? g_lv.ch : g_lv.dm; g_lv.task->enterThread(channel, idx); bool ch; uint16_t unread; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1]; if (g_lv.task->getThreadInfo(idx, ch, unread, ts, name, sizeof(name))) { char san[UITask::MAX_THREAD_NAME + 8]; copyUtf8ReplacingMissingGlyphs(&g_font_12, san, sizeof(san), name); setChatStatusTitle(san); } p.detail_open = true; hideKb(); if (p.overlay) { lv_obj_clear_flag(p.overlay, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(p.overlay); } refreshChatDetailAsync(p); // AFTER un-hiding so bubbles measure correctly and the open-scroll reaches the newest message #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) showKb(&p); // physical keyboard: auto-focus the composer so typing goes straight in #elif defined(HAS_TANMATSU) navMarkDirty(); // keypad nav: rebuild the focus group onto the chat overlay + focus the composer #endif } static void mentionRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int mi = (int)(intptr_t)lv_event_get_user_data(e); // message ring-slot index closeMentionsScreen(); setHomeDrawer(false); // leave the drawer behind, back to the command centre on return if (mi < 0 || !g_lv.task) return; UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(mi, m)) return; const int t = findChannelThreadByName(m.thread); if (t < 0) return; s_chat_jump_msg_idx = mi; // refreshChatDetail scrolls to this exact message on open goToTab(CHAT_INBOX_TAB_INDEX); openThreadDetailByIdx(t, true); } static void mentionsBackCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) closeMentionsScreen(); } static void openMentionsScreen() { closeMentionsScreen(); if (!g_lv.task) return; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_mentions_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_mentions_root); lv_obj_set_size(s_mentions_root, sw, sh - STATUSBAR_H - TABBAR_H); // tab bar stays visible lv_obj_set_pos(s_mentions_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_mentions_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_mentions_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_mentions_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(s_mentions_root); // Header: "@ Mentions" + back-to-drawer button. lv_obj_t* title = lv_label_create(s_mentions_root); lv_label_set_text(title, TR("@ Mentions")); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_align(title, LV_ALIGN_TOP_LEFT, 12, 9); lv_obj_t* back = lv_btn_create(s_mentions_root); lv_obj_set_size(back, 40, 28); lv_obj_align(back, LV_ALIGN_TOP_RIGHT, -8, 5); styleButton(back); lv_obj_add_event_cb(back, mentionsBackCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bks = lv_label_create(back); lv_label_set_text(bks, LV_SYMBOL_LEFT); lv_obj_set_style_text_font(bks, uiChromeFont(), LV_PART_MAIN); lv_obj_center(bks); // The title follows the selected semantic font role. At the Pager's larger // presets its live fallback line box is taller than the historical 38-px // header, so measure it instead of letting the title overlap the list. const int header_h = LV_MAX(38, 14 + lv_font_get_line_height(&g_font_16)); lv_obj_t* list = lv_obj_create(s_mentions_root); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw, (sh - STATUSBAR_H - TABBAR_H) - header_h); lv_obj_set_pos(list, 0, header_h); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(list, 8, LV_PART_MAIN); lv_obj_set_style_pad_row(list, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(list, LV_DIR_VER); // Scan all channel messages that @mention me, newest first. struct M { uint32_t ts; int idx; }; static M* found = nullptr; // mentions scratch — lazy PSRAM (internal fallback): ~4 KB off internal DRAM if (!found) { found = (M*)heap_caps_malloc(sizeof(M) * (size_t)g_lv.task->msgCap(), MALLOC_CAP_SPIRAM); if (!found) found = (M*)heap_caps_malloc(sizeof(M) * (size_t)g_lv.task->msgCap(), MALLOC_CAP_8BIT); } if (!found) return; int nf = 0; for (int i = 0; i < g_lv.task->msgCap(); i++) { UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(i, m)) continue; if (!m.thread[0] || !m.channel || m.outgoing) continue; if (!textMentionsMe(m.text)) continue; found[nf].ts = m.ts; found[nf].idx = i; nf++; } for (int a = 1; a < nf; a++) { // insertion sort by ts, newest first M key = found[a]; int b = a - 1; while (b >= 0 && found[b].ts < key.ts) { found[b + 1] = found[b]; b--; } found[b + 1] = key; } if (nf == 0) { lv_obj_t* empty = lv_label_create(list); lv_label_set_text(empty, TR("No @mentions yet")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_14, LV_PART_MAIN); return; } const int row_w = sw - 16; // list has 8 px padding each side const int line_h = lv_font_get_line_height(&g_font_12); const int row_top = 7; const int body_y = row_top + line_h + 2; const int row_h = body_y + 2 * line_h + 7; for (int k = 0; k < nf; k++) { UITask::UIMessage m; if (!g_lv.task->getMessageByIndex(found[k].idx, m)) continue; lv_obj_t* row = lv_btn_create(list); lv_obj_remove_style_all(row); lv_obj_set_size(row, row_w, row_h); // channel line + two-line preview + padding lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(row, LV_OPA_40, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(row, 8, LV_PART_MAIN); lv_obj_add_event_cb(row, mentionRowCb, LV_EVENT_CLICKED, (void*)(intptr_t)found[k].idx); lv_obj_t* chl = lv_label_create(row); // channel name (accent) lv_label_set_long_mode(chl, LV_LABEL_LONG_DOT); lv_obj_set_size(chl, row_w - 72, line_h); lv_label_set_text(chl, m.thread); lv_obj_set_style_text_color(chl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(chl, &g_font_12, LV_PART_MAIN); lv_obj_align(chl, LV_ALIGN_TOP_LEFT, 8, row_top); char when[16] = {0}; if (m.ts > 1700000000UL) { time_t tt = (time_t)m.ts; struct tm tmv; localtime_r(&tt, &tmv); fmtClockHM(when, sizeof when, &tmv); } if (when[0]) { lv_obj_t* tl = lv_label_create(row); lv_label_set_text(tl, when); lv_obj_set_style_text_color(tl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(tl, &g_font_12, LV_PART_MAIN); lv_obj_align(tl, LV_ALIGN_TOP_RIGHT, -8, row_top); } char bodytxt[UITask::MAX_SENDER_NAME + UITask::MAX_MSG_TEXT + 4]; snprintf(bodytxt, sizeof bodytxt, "%s: %s", m.sender[0] ? m.sender : "?", m.text); lv_obj_t* bd = lv_label_create(row); // "sender: text", capped at 2 lines lv_label_set_long_mode(bd, LV_LABEL_LONG_DOT); lv_obj_set_size(bd, row_w - 16, 2 * line_h); lv_label_set_text(bd, bodytxt); lv_obj_set_style_text_color(bd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(bd, &g_font_12, LV_PART_MAIN); lv_obj_align(bd, LV_ALIGN_TOP_LEFT, 8, body_y); } } static void appTileCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int act = (int)(intptr_t)lv_event_get_user_data(e); // Tools open OVER the drawer (do NOT close it), so dismissing them returns to // the drawer where they were launched — not the command centre. #if CAP_LUA_APPS if (s_tile_lp_fired) { s_tile_lp_fired = false; return; } // release-click after a long press if (act >= APPACT_LUA_BASE && act < APPACT_LUA_BASE + s_lua_inst_n) { const LuaInstApp& a = s_lua_inst[act - APPACT_LUA_BASE]; const char* emb = luaBuiltinSrc(a.id); // file-first; this is the fallback luaAppLaunchFile(a.id, a.name, emb, emb ? strlen(emb) : 0); return; } #endif switch (act) { case APPACT_MENTIONS: openMentionsScreen(); return; case APPACT_CMDCENTER: setHomeDrawer(false); return; // explicit "back to command centre" case APPACT_SIGNAL: openSignalInfoPopup(); return; // signal/traffic + auto-discover settings case APPACT_SPECTRUM: openSpectrumPage(); return; // swept RF spectrum analyzer (borrows the radio) case APPACT_DISCOVER: openDiscoverPage(); return; // active node-discovery sweep + nearby list #if !defined(HAS_TANMATSU) case APPACT_VNC: openVncPage(); return; // screen mirror + remote control from a browser case APPACT_REMOTE: openRemotePage(); return; // reboot into the web-resolution headless UI #endif #if defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER case APPACT_READER: openReaderPage(); return; // on-device text browser (no proxy) #endif case APPACT_ADVERT: openAdvertModalCb(e); return; case APPACT_POWER: openPowerMenu(); return; #if CAP_LUA_APPS case APPACT_STORE: openLuaStorePage(); return; // Snake is store-only on Lua boards: install it from the catalog and it // appears as a regular installed tile (the native module goes unreferenced // here, so the linker drops it). #else case APPACT_SNAKE: SnakeGame::launch(); return; #endif #if defined(HAS_TOUCH_UI) case APPACT_TERMINAL: homeTerminalCb(e); return; #endif #if CAP_FILESYSTEM || defined(TLORA_PAGER) case APPACT_FILES: homeFilesCb(e); return; #endif default: break; } // Navigation tiles: leave the drawer for the chosen tab. closeAppDrawer(); switch (act) { case APPACT_CHATS: goToTab(CHAT_INBOX_TAB_INDEX); break; case APPACT_CONTACTS: goToTab(CONTACTS_TAB_INDEX); break; case APPACT_MAP: goToTab(MAP_TAB_INDEX); break; case APPACT_SETTINGS: goToTab(SETTINGS_TAB_INDEX); break; default: break; } } // One launcher tile: a rounded panel with an accent icon over a label. The icon // font is g_font_16 — it carries the LV_SYMBOL set AND the spliced person glyph, // so every tile (including Contacts) renders without tofu. #if CAP_LUA_APPS // Long-press on an installed Lua app tile -> confirm -> remove (files deleted, // drawer rebuilt). A long press must swallow the release CLICK so the app // doesn't also launch under the confirm sheet. static uint32_t s_lua_hide_pending = 0; static void luaTileHideConfirmed() { touchPrefsSetAppHide(touchPrefsGetAppHide() | s_lua_hide_pending); s_lua_hide_pending = 0; settingsApplyHiddenCats(); if (s_appdrawer_root) { closeAppDrawer(); openAppDrawer(); } if (g_lv.task) g_lv.task->showAlert(TR("Hidden - turn it back on in the Lua Store"), 2000); } static void luaTileRemoveConfirmed() { luaStoreRemoveApp(s_lua_rm_pending); s_lua_rm_pending = -1; if (s_appdrawer_root) { closeAppDrawer(); openAppDrawer(); } // rebuild tiles } // Which built-ins a long press can hide (the same set the Lua Store lists). static uint32_t appHideBitFor(int act) { switch (act) { case APPACT_SPECTRUM: return APPHIDE_SPECTRUM; case APPACT_DISCOVER: return APPHIDE_DISCOVER; case APPACT_VNC: return APPHIDE_VNC; case APPACT_REMOTE: return APPHIDE_REMOTE; case APPACT_READER: return APPHIDE_READER; case APPACT_TERMINAL: return APPHIDE_TERMINAL; case APPACT_FILES: return APPHIDE_FILES; case APPACT_SIGNAL: return APPHIDE_SIGNAL; case APPACT_MENTIONS: return APPHIDE_MENTIONS; default: return 0; // core tiles (Chats/Map/Settings/...) stay put } } static void appTileLongPressCb(lv_event_t* e) { int act = (int)(intptr_t)lv_event_get_user_data(e); int idx = act - APPACT_LUA_BASE; if (idx >= 0 && idx < s_lua_inst_n) { // installed Lua app -> remove if (luaBuiltinSrc(s_lua_inst[idx].id)) return; // seeded in the image: nothing to uninstall s_tile_lp_fired = true; s_lua_rm_pending = idx; char msg[64]; snprintf(msg, sizeof msg, TR("Remove %s?"), s_lua_inst[idx].name); showConfirm(msg, TR("Remove"), luaTileRemoveConfirmed); return; } const uint32_t bit = appHideBitFor(act); // hideable built-in -> hide if (!bit) return; s_tile_lp_fired = true; s_lua_hide_pending = bit; showConfirm(TR("Hide this app from the drawer?"), TR("Hide"), luaTileHideConfirmed); } #endif static void addAppTile(lv_obj_t* parent, int x, int y, int w, int h, const char* icon, const char* label, int act, int badge, uint32_t icon_col, bool big = false) { // App-style tile: a rounded-square icon chip with the label UNDERNEATH it, // instead of a filled box with the text inside. Same grid footprint (w×h); // the cell itself is transparent and only shows a faint highlight on press. lv_obj_t* t = lv_btn_create(parent); lv_obj_remove_style_all(t); lv_obj_set_size(t, w, h); lv_obj_set_pos(t, x, y); lv_obj_set_style_bg_opa(t, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_radius(t, 12, LV_PART_MAIN); lv_obj_set_style_bg_color(t, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(t, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_flag(t, NAV_ACCENTFOCUS_FLAG); // keyboard/encoder focus = same accent tint as a touch press lv_obj_add_event_cb(t, appTileCb, LV_EVENT_CLICKED, (void*)(intptr_t)act); #if CAP_LUA_APPS if (act >= APPACT_LUA_BASE || appHideBitFor(act)) lv_obj_add_event_cb(t, appTileLongPressCb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)act); #endif // Rounded-square chip (iOS-style squircle), tinted with the app's accent // colour, centred up top. Bigger + a small proportional corner radius so it // reads as a SQUARE app icon, not a circle. #if CAP_UI_SIZE // Reserve the ACTUAL label line height (it grows with the UI-scale font) so the // name never overlaps the icon chip on the big panel. int chip = h - (lv_font_get_line_height(big ? &g_font_14 : &g_font_12) + 10); #else int chip = h - (big ? 26 : 22); // leave one label line beneath (taller in large mode) #endif if (chip > w - 6) chip = w - 6; if (chip > (big ? 80 : 58)) chip = (big ? 80 : 58); // higher cap so large-mode chips actually grow if (chip < 30) chip = 30; lv_obj_t* chip_o = lv_obj_create(t); lv_obj_remove_style_all(chip_o); lv_obj_clear_flag(chip_o, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(chip_o, chip, chip); lv_obj_align(chip_o, LV_ALIGN_TOP_MID, 0, 5); lv_obj_set_style_bg_color(chip_o, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_bg_opa(chip_o, LV_OPA_20, LV_PART_MAIN); lv_obj_set_style_radius(chip_o, chip * 22 / 100, LV_PART_MAIN); // ~22% squircle, not a circle lv_obj_set_style_border_color(chip_o, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_border_width(chip_o, 1, LV_PART_MAIN); lv_obj_set_style_border_opa(chip_o, LV_OPA_50, LV_PART_MAIN); if (act == APPACT_SNAKE) { // The 🐍 emoji (U+1F40D) isn't in the baked colour-emoji set, so a plain // label renders it as a tofu box. Draw the game's blocky snake from a few // rounded cells instead — self-contained, on-theme, and scales with the chip. lv_obj_t* box = lv_obj_create(chip_o); lv_obj_remove_style_all(box); lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(box, 18, 12); lv_obj_center(box); // Tail bottom-left -> right -> up -> head top-right (cells share edges, so the // body reads as one continuous snake). static const struct { int8_t x, y; } seg[4] = { {0,6}, {6,6}, {6,0}, {12,0} }; for (int s = 0; s < 4; s++) { lv_obj_t* cell = lv_obj_create(box); lv_obj_remove_style_all(cell); lv_obj_clear_flag(cell, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(cell, 6, 6); lv_obj_set_pos(cell, seg[s].x, seg[s].y); lv_obj_set_style_bg_color(cell, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(cell, 2, LV_PART_MAIN); } // Dark pupil on the head cell (the last segment, facing right). lv_obj_t* eye = lv_obj_create(box); lv_obj_remove_style_all(eye); lv_obj_clear_flag(eye, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(eye, 2, 2); lv_obj_set_pos(eye, 14, 1); lv_obj_set_style_bg_color(eye, lv_color_hex(0x0E1216), LV_PART_MAIN); lv_obj_set_style_bg_opa(eye, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(eye, 1, LV_PART_MAIN); } else if (act == APPACT_READER) { // 🌐 globe: the ONLY colour-emoji tile, so it drops to a tofu box on the 2 MB V4 // (the imgfont fallback can't decode it under PSRAM pressure). Draw it from a // circle + meridian/parallel lines instead — renders identically on every board, // same approach as the Snake tile above. lv_obj_t* box = lv_obj_create(chip_o); lv_obj_remove_style_all(box); lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(box, 22, 22); lv_obj_center(box); lv_obj_t* circ = lv_obj_create(box); // outline sphere lv_obj_remove_style_all(circ); lv_obj_clear_flag(circ, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(circ, 22, 22); lv_obj_set_pos(circ, 0, 0); lv_obj_set_style_radius(circ, 11, LV_PART_MAIN); lv_obj_set_style_bg_opa(circ, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_color(circ, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_border_width(circ, 2, LV_PART_MAIN); // meridians (longitude): straight centre + two curved sides; parallels (latitude): // equator + one above + one below. Points are box-relative (0..22). static const lv_point_t g_mc[] = {{11,0},{11,22}}; static const lv_point_t g_ml[] = {{11,1},{5,4},{2,11},{5,18},{11,21}}; static const lv_point_t g_mr[] = {{11,1},{17,4},{20,11},{17,18},{11,21}}; static const lv_point_t g_eq[] = {{0,11},{22,11}}; static const lv_point_t g_pt[] = {{4,6},{18,6}}; static const lv_point_t g_pb[] = {{4,16},{18,16}}; const struct { const lv_point_t* p; uint16_t n; } gl[] = { {g_mc,2},{g_ml,5},{g_mr,5},{g_eq,2},{g_pt,2},{g_pb,2} }; for (auto& e : gl) { lv_obj_t* l = lv_line_create(box); lv_line_set_points(l, e.p, e.n); lv_obj_set_pos(l, 0, 0); lv_obj_set_style_line_color(l, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_line_width(l, 1, LV_PART_MAIN); lv_obj_set_style_line_rounded(l, true, LV_PART_MAIN); } } else if (icon) { lv_obj_t* ic = lv_label_create(chip_o); lv_label_set_text(ic, icon); // Large mode renders the glyph at 28 px. The two custom FontAwesome glyphs // (person/antenna) only live in g_font_16's fallback chain, so keep those at 16 // to avoid a tofu box; every other tile is a standard LVGL/ASCII symbol. const bool custom_glyph = (strcmp(icon, TOUCH_SYM_PERSON) == 0 || strcmp(icon, TOUCH_SYM_ANTENNA) == 0); lv_obj_set_style_text_font(ic, (big && !custom_glyph) ? &lv_font_montserrat_28 : &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(ic, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_center(ic); } else { // No glyph for "signal" — draw 4 ascending accent bars, centred in the chip. lv_obj_t* box = lv_obj_create(chip_o); lv_obj_remove_style_all(box); lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(box, 22, 16); lv_obj_center(box); for (int b = 0; b < 4; b++) { lv_obj_t* bar = lv_obj_create(box); lv_obj_remove_style_all(bar); lv_obj_clear_flag(bar, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); // decorative — keep keyboard-nav off the individual bars (focus the app, not each bar) const int bh = 5 + b * 3; // 5, 8, 11, 14 px lv_obj_set_size(bar, 4, bh); lv_obj_set_pos(bar, b * 6, 16 - bh); // bottom-aligned lv_obj_set_style_bg_color(bar, lv_color_hex(icon_col), LV_PART_MAIN); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(bar, 1, LV_PART_MAIN); } } // Label UNDER the chip. lv_obj_t* lb = lv_label_create(t); lv_label_set_text(lb, TR(label)); lv_obj_set_style_text_align(lb, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_font(lb, big ? &g_font_14 : &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lb, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // A long app title (often longer once translated) shrinks to fit ONE line rather // than wrapping to two: fit the font to the tile width, then pin the label to a // single line height so LONG_DOT can only ellipsize on that one line, never wrap. uiFitLabelWidth(lb, w - 2); lv_obj_set_width(lb, w); lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); lv_obj_set_height(lb, lv_font_get_line_height(lv_obj_get_style_text_font(lb, LV_PART_MAIN))); lv_obj_align(lb, LV_ALIGN_BOTTOM_MID, 0, -2); // Notification badge: a small red count pill in the top-right corner. if (badge > 0) { lv_obj_t* bdg = lv_obj_create(t); lv_obj_remove_style_all(bdg); lv_obj_clear_flag(bdg, LV_OBJ_FLAG_CLICKABLE); // taps pass through to the tile lv_obj_set_style_bg_color(bdg, lv_color_hex(0xE0533D), LV_PART_MAIN); lv_obj_set_style_bg_opa(bdg, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(bdg, 9, LV_PART_MAIN); lv_obj_set_style_pad_hor(bdg, 4, LV_PART_MAIN); lv_obj_set_size(bdg, LV_SIZE_CONTENT, 18); lv_obj_align(bdg, LV_ALIGN_TOP_MID, chip / 2 - 6, 2); // overhang the chip's top-right corner lv_obj_t* bt = lv_label_create(bdg); char bn[8]; snprintf(bn, sizeof bn, "%d", badge > 99 ? 99 : badge); lv_label_set_text(bt, bn); lv_obj_set_style_text_color(bt, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_text_font(bt, &g_font_12, LV_PART_MAIN); lv_obj_center(bt); } } // ---- App-drawer settings (the cog in the drawer's top-right) ---------------- static lv_obj_t* s_appgrid_sheet = nullptr; static void closeAppGridSheet() { if (s_appgrid_sheet) { popupClose(&s_appgrid_sheet); } } static void appGridBackdropCb(lv_event_t* e) { // Close only on a backdrop tap, not when a child button bubbles its click up. if (lv_event_get_code(e) == LV_EVENT_CLICKED && lv_event_get_target(e) == s_appgrid_sheet) closeAppGridSheet(); } static void appGridChooseCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const bool large = (bool)(intptr_t)lv_event_get_user_data(e); touchPrefsSetAppGridLarge(large); closeAppGridSheet(); // Re-render the drawer in place with the new grid. if (s_appdrawer_root) { popupClose(&s_appdrawer_root); } openAppDrawer(); } static void appHomeIsDrawerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); #if defined(ESP32) touchPrefsSetHomeIsDrawer(on); #endif s_home_is_drawer = on; if (on) s_home_drawer_mode = true; // make the drawer the live Home view too (persists on return + reboot) } static void openAppGridSheet() { closeAppGridSheet(); lv_coord_t sw = lv_disp_get_hor_res(nullptr), sh = lv_disp_get_ver_res(nullptr); s_appgrid_sheet = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_appgrid_sheet); lv_obj_set_size(s_appgrid_sheet, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_appgrid_sheet, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_appgrid_sheet, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_appgrid_sheet, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_appgrid_sheet, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_appgrid_sheet, appGridBackdropCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN const int card_w = 380, btn_h = 62, pad = 18, hdr = 38, gap = 12; // bigger on the 800×480 panel const int home_row = 46; #else const int card_w = 210, btn_h = 46, pad = 12, hdr = 28, gap = 8; const int home_row = 36; // the "app drawer as home" toggle row below the size buttons #endif lv_obj_t* card = lv_obj_create(s_appgrid_sheet); lv_obj_remove_style_all(card); lv_obj_set_size(card, card_w, hdr + 2 * btn_h + gap + home_row + gap + 2 * pad); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* ttl = lv_label_create(card); lv_label_set_text(ttl, TR("App icon size")); lv_obj_set_style_text_font(ttl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(ttl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(ttl, 0, 0); const bool cur = touchPrefsGetAppGridLarge(); struct { const char* t; bool large; } opt[2] = { { "Compact", false }, { "Large (bigger icons)", true } }; for (int i = 0; i < 2; i++) { lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); lv_obj_set_pos(b, 0, hdr + i * (btn_h + gap)); styleButton(b); const bool is_cur = (opt[i].large == cur); lv_obj_add_event_cb(b, appGridChooseCb, LV_EVENT_CLICKED, (void*)(intptr_t)opt[i].large); lv_obj_t* l = lv_label_create(b); // Mark the current choice with a checkmark + accent-coloured text on the normal // (dark) button. Do NOT fill the button with the accent — that left the near-black // text unreadable on a dark accent (the reported bug). char lbl[48]; snprintf(lbl, sizeof lbl, "%s%s", is_cur ? LV_SYMBOL_OK " " : "", TR(opt[i].t)); lv_label_set_text(l, lbl); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(is_cur ? COLOR_ACCENT : COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); } // "App drawer as home" — make the launcher the persistent Home view (no Commander). { const int ty = hdr + 2 * (btn_h + gap); lv_obj_t* hl = lv_label_create(card); lv_label_set_text(hl, TR("App drawer as home")); lv_obj_set_style_text_font(hl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(hl, LV_ALIGN_TOP_LEFT, 0, ty + 8); lv_obj_t* sw = lv_switch_create(card); lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, ty); #if defined(ESP32) if (touchPrefsGetHomeIsDrawer()) lv_obj_add_state(sw, LV_STATE_CHECKED); #endif lv_obj_add_event_cb(sw, appHomeIsDrawerCb, LV_EVENT_VALUE_CHANGED, nullptr); } } static void appDrawerSettingsCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openAppGridSheet(); } // Manifest icon name -> glyph for a Lua app's drawer tile. Names only, and only // glyphs the UI fonts already carry: a submission cannot ship its own artwork, // so an unknown or absent name falls back to the generic app symbol rather than // drawing a missing-glyph box. LUA_APPS.md promised manifests an "icon" field // from the start; this is it. static const char* luaAppIconGlyph(const char* name) { if (!name || !name[0]) return LV_SYMBOL_PLAY; static const struct { const char* name; const char* glyph; } kMap[] = { { "gps", LV_SYMBOL_GPS }, // location pin — compass / navigation apps { "compass", LV_SYMBOL_GPS }, { "map", LV_SYMBOL_GPS }, { "radio", TOUCH_SYM_ANTENNA }, { "signal", LV_SYMBOL_WIFI }, { "chart", LV_SYMBOL_IMAGE }, { "list", LV_SYMBOL_LIST }, { "message", LV_SYMBOL_ENVELOPE }, { "person", TOUCH_SYM_PERSON }, { "group", TOUCH_SYM_GROUP }, { "bell", TOUCH_SYM_BELL }, { "star", TOUCH_SYM_STAR }, { "search", TOUCH_SYM_ZOOM }, { "settings", LV_SYMBOL_SETTINGS }, { "battery", LV_SYMBOL_CHARGE }, { "game", LV_SYMBOL_PLAY }, }; for (const auto& e : kMap) if (strcmp(e.name, name) == 0) return e.glyph; return LV_SYMBOL_PLAY; } static void openAppDrawer() { closeAppDrawer(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_appdrawer_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_appdrawer_root); // Cover only the tab CONTENT area — between the status bar and the bottom nav // bar — so the drawer "replaces" the command centre while the tab bar stays // visible + tappable (switching tabs there closes the drawer via tabChangedCb). lv_obj_set_size(s_appdrawer_root, sw, sh - STATUSBAR_H - TABBAR_H); lv_obj_set_pos(s_appdrawer_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_appdrawer_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_appdrawer_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_scroll_dir(s_appdrawer_root, LV_DIR_VER); // grid scrolls top->bottom when it overflows lv_obj_set_style_pad_bottom(s_appdrawer_root, 10, LV_PART_MAIN); // room past the last row when scrolled // A clearly visible scrollbar so users discover the lower rows. The mode is // set after the grid is measured (ON when it overflows, OFF when it all fits). lv_obj_set_style_bg_color(s_appdrawer_root, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(s_appdrawer_root, LV_OPA_70, LV_PART_SCROLLBAR); lv_obj_set_style_width(s_appdrawer_root, 5, LV_PART_SCROLLBAR); lv_obj_set_style_radius(s_appdrawer_root, 3, LV_PART_SCROLLBAR); lv_obj_set_style_pad_right(s_appdrawer_root, 2, LV_PART_SCROLLBAR); lv_obj_move_foreground(s_appdrawer_root); // First tile is "Command" (back to the command centre); the rest are the apps. const int unread = g_lv.task ? g_lv.task->getUnreadTotal() : 0; const int mentions = g_lv.task ? g_lv.task->getUnreadMentionCount() : 0; // Per-tile icon colour. Command (house) + Signal follow the theme accent; the // rest get a dedicated, meaningful hue so the grid isn't a wall of one colour. struct { const char* icon; const char* label; int act; int badge; uint32_t color; } tiles[] = { { LV_SYMBOL_HOME, "Cmdr", APPACT_CMDCENTER, 0, COLOR_ACCENT }, // theme { LV_SYMBOL_ENVELOPE, "Chats", APPACT_CHATS, unread, 0x4F9DF7 }, // messaging blue { TOUCH_SYM_PERSON, "Contacts", APPACT_CONTACTS, 0, 0xA784E0 }, // people violet { LV_SYMBOL_GPS, "Map", APPACT_MAP, 0, 0x53C06B }, // location green { LV_SYMBOL_REFRESH, "Discover", APPACT_DISCOVER, 0, 0x9B59FF }, // active node-discovery sweep (purple) { LV_SYMBOL_UPLOAD, "Advertise", APPACT_ADVERT, 0, 0xE072B0 }, // broadcast magenta #if !defined(HAS_TANMATSU) { LV_SYMBOL_IMAGE, "VNC", APPACT_VNC, 0, 0x6C7CF0 }, // browser screen-mirror indigo { LV_SYMBOL_WIFI, "Remote", APPACT_REMOTE, 0, 0x15B6A6 }, // headless web-resolution UI (brand teal) #endif #if defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER { "\xF0\x9F\x8C\x90", "Web", APPACT_READER, 0, 0x6FB7FF }, // on-device text browser (globe) #endif { nullptr, "Signal", APPACT_SIGNAL, 0, COLOR_ACCENT }, // theme (drawn signal bars) { TOUCH_SYM_ANTENNA, "Spectrum", APPACT_SPECTRUM, 0, 0xE8A33D }, // RF spectrum analyzer amber { "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber { LV_SYMBOL_SETTINGS, "Settings", APPACT_SETTINGS, 0, 0x9AA3AD }, // neutral gear grey #if defined(HAS_TOUCH_UI) { ">_", "Terminal", APPACT_TERMINAL, 0, 0x3DD27A }, // console green #endif #if CAP_FILESYSTEM || defined(TLORA_PAGER) { LV_SYMBOL_DIRECTORY, "Files", APPACT_FILES, 0, 0xE6BE4A }, // folder gold #endif #if !CAP_LUA_APPS { nullptr, "Snake", APPACT_SNAKE, 0, 0x53C06B }, // native snake (Lua boards install it from the Store) #else { LV_SYMBOL_DOWNLOAD, "Store", APPACT_STORE, 0, 0x15B6A6 }, // Lua app store (brand teal); short name = fits every panel #endif { LV_SYMBOL_POWER, "Power", APPACT_POWER, 0, 0xE05544 }, // power red }; const int n_static = (int)(sizeof(tiles) / sizeof(tiles[0])); #if CAP_LUA_APPS // Hide filter (Store page toggles) + installed Lua apps as dynamic tiles. const uint32_t hide_mask = touchPrefsGetAppHide(); auto tileHidden = [&](int act) -> bool { switch (act) { case APPACT_SNAKE: return hide_mask & APPHIDE_SNAKE; case APPACT_SPECTRUM: return hide_mask & APPHIDE_SPECTRUM; case APPACT_DISCOVER: return hide_mask & APPHIDE_DISCOVER; case APPACT_VNC: return hide_mask & APPHIDE_VNC; case APPACT_REMOTE: return hide_mask & APPHIDE_REMOTE; case APPACT_READER: return hide_mask & APPHIDE_READER; case APPACT_TERMINAL: return hide_mask & APPHIDE_TERMINAL; case APPACT_FILES: return hide_mask & APPHIDE_FILES; case APPACT_SIGNAL: return hide_mask & APPHIDE_SIGNAL; case APPACT_MENTIONS: return hide_mask & APPHIDE_MENTIONS; default: return false; } }; static int s_draw_order[40]; int n = 0; for (int i = 0; i < n_static; i++) if (!tileHidden(tiles[i].act)) s_draw_order[n++] = i; luaStoreScanInstalled(); int dyn0 = n; for (int i = 0; i < s_lua_inst_n && n < 40; i++) s_draw_order[n++] = -(i + 1); // negative = dynamic index i (void)dyn0; #else const int n = n_static; #endif // Column count: 4 on the T-Deck, 3 on the V4 (a cleaner grid for the smaller // tile set). Tile height is sized to fit the rows on screen but CLAMPED to a // comfortable [60, 84] px band — so a full app list overflows into the vertical // scroll region rather than shrinking the tiles to tiny squares. // Grid size: compact (board default) or LARGE (one fewer column → bigger tiles, // icons + labels, for low vision). Toggled from the app-drawer cog (top-right). const bool big_grid = touchPrefsGetAppGridLarge(); #if defined(HAS_TDECK_GT911) const int cols = big_grid ? 3 : 4; #else const int cols = big_grid ? 2 : 3; #endif const int pad = 10, gap = 8, top = 10; const int grid_w = sw - 2 * pad; const int tile_w = (grid_w - (cols - 1) * gap) / cols; const int rows = (n + cols - 1) / cols; const int avail = (sh - STATUSBAR_H - TABBAR_H) - top - 8; // content area minus top inset + bottom margin int tile_h = (avail - (rows - 1) * gap) / rows; const int hi = big_grid ? 116 : 84; // ceiling const int lo = big_grid ? 78 : 54; // floor: overflow scrolls instead of shrinking to tiny tiles if (tile_h > hi) tile_h = hi; if (tile_h < lo) tile_h = lo; for (int i = 0; i < n; i++) { const int col = i % cols, row = i / cols; const int x = pad + col * (tile_w + gap), y = top + row * (tile_h + gap); #if CAP_LUA_APPS const int oi = s_draw_order[i]; if (oi < 0) { // installed Lua app (dynamic) const LuaInstApp& a = s_lua_inst[-oi - 1]; addAppTile(s_appdrawer_root, x, y, tile_w, tile_h, luaAppIconGlyph(a.icon), a.name, APPACT_LUA_BASE + (-oi - 1), 0, 0x15B6A6, big_grid); continue; } addAppTile(s_appdrawer_root, x, y, tile_w, tile_h, tiles[oi].icon, tiles[oi].label, tiles[oi].act, tiles[oi].badge, tiles[oi].color, big_grid); #else addAppTile(s_appdrawer_root, x, y, tile_w, tile_h, tiles[i].icon, tiles[i].label, tiles[i].act, tiles[i].badge, tiles[i].color, big_grid); #endif } // Keep the scrollbar permanently visible only when the grid actually overflows // the drawer area (so it's discoverable); when it all fits, hide it so there's // no misleading full-height thumb. const int grid_h = top + rows * tile_h + (rows - 1) * gap + 10 /*pad_bottom*/; const int visible = sh - STATUSBAR_H - TABBAR_H; lv_obj_set_scrollbar_mode(s_appdrawer_root, grid_h > visible ? LV_SCROLLBAR_MODE_ON : LV_SCROLLBAR_MODE_OFF); // App-drawer settings cog — top-right, floats above the scrolling grid (does not // move with it). Opens the icon-size chooser (Compact / Large). lv_obj_t* cog = lv_btn_create(s_appdrawer_root); lv_obj_remove_style_all(cog); lv_obj_set_size(cog, 30, 30); lv_obj_add_flag(cog, LV_OBJ_FLAG_FLOATING); lv_obj_align(cog, LV_ALIGN_TOP_RIGHT, -4, 2); lv_obj_set_style_bg_opa(cog, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_bg_color(cog, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(cog, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(cog, 15, LV_PART_MAIN); lv_obj_add_event_cb(cog, appDrawerSettingsCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cogl = lv_label_create(cog); lv_label_set_text(cogl, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_font(cogl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(cogl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_center(cogl); lv_obj_move_foreground(cog); } // Status-bar long-hold (3 s) -> full-screen screenshot to /screenshots on SD. static uint32_t s_sb_press_ms = 0; static bool s_sb_shot_done = false; // millis() of a press the bar's PRESSED handler already used to leave an app page, so // the CLICKED from that same tap gets swallowed instead of toggling the control center. // TIMESTAMPED rather than a plain flag on purpose: the whole reason that handler closes // on touch-DOWN is that the matching CLICKED sometimes never arrives (the cap-touch swipe // detector aborts it), and a sticky bool would then eat the NEXT genuine bar tap. A stale // one simply expires. static uint32_t s_sb_back_ms = 0; static void statusBarHoldCb(lv_event_t* e) { switch (lv_event_get_code(e)) { case LV_EVENT_PRESSED: s_sb_press_ms = millis(); s_sb_shot_done = false; break; case LV_EVENT_PRESSING: if (!s_sb_shot_done && s_sb_press_ms && (uint32_t)(millis() - s_sb_press_ms) >= 3000) { s_sb_shot_done = true; // suppress the release CLICK (no control center) takeScreenshotToSd(); } break; case LV_EVENT_RELEASED: case LV_EVENT_PRESS_LOST: s_sb_press_ms = 0; break; default: break; } } static void statusBarTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; // Snake and Airtime used to need their own cases here because they hand-rolled their // chrome; they now install s_apppage_close via appPageBegin like every other app page, // so the generic hook below covers them — including the PRESSED fallback in // statusBarReaderBackCb, which they never got and which is the only reliable exit on a // cap-touch board that drops the lone CLICKED. if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold // …and this one already closed an app page on touch-DOWN (statusBarReaderBackCb). // Without this the tap goes back AND falls through to the control-center toggle at // the bottom of this function: "back also opens the status bar" (pisti87, beta_64). if (s_sb_back_ms && (uint32_t)(millis() - s_sb_back_ms) < 1500) { s_sb_back_ms = 0; return; } s_sb_back_ms = 0; // A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back // chevron + title, so a tap goes back (closes the page) just like settings. if (s_apppage_close) { s_apppage_close(); return; } // While a settings detail sheet is open the bar shows its Back chevron + title, // so a tap means "go back" rather than opening the control center. if (s_settings_sheet) { const bool reopen_cc = s_settings_from_cc; // did we get here from the dropdown? closeSettingsCategory(); // (clears s_settings_from_cc) if (reopen_cc) openControlCenter(); // Back returns to the dropdown, not Home return; } // In a chat the bar shows its Back chevron (+ thread name on the lower row). A tap // first closes the channel-settings sheet if it's up (so its "back" is the same bar // chevron), otherwise it closes the conversation. (The cog intercepts its own tap.) if (blockedModalIsOpen()) { blockedModalClose(); return; } if (chanScopeIsOpen()) { chanScopeClose(); return; } if (s_chat_title[0]) { if (g_lv.dm.detail_open) closeChatPanel(&g_lv.dm); else if (g_lv.ch.detail_open) closeChatPanel(&g_lv.ch); return; } if (s_cc_root) closeControlCenter(); else openControlCenter(); } // Reader/Web page back: fire on PRESSED (touch-DOWN), not CLICKED. The V4 cap-touch // swipe detector calls lv_indev_wait_release mid-tap and drops the bar's CLICKED (the // same reason the chat back uses PRESSED) — that's why the reader's "‹" tap "did // nothing". This closes the reader on touch-down whenever it's the open page. static void statusBarReaderBackCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_PRESSED) return; // Every full-screen tool page (Reader, Monitor, Spectrum, Discover, VNC, Remote, Advert, …) is // exited only by the bar's CLICKED -> s_apppage_close. On cap-touch the swipe detector's // click-abort (lvglTouchRead) can drop that lone CLICKED, trapping the user on a touch-only board // (the P4). Close on PRESSED too — the proven Reader fallback, now generalised to every app page. // // The CLICKED that follows this same tap must be SWALLOWED. Clearing s_apppage_close does not // make it harmless (the original claim here): statusBarTapCb only skips its app-page branch and // then falls through every other branch to the control-center toggle at its end — so exiting any // app page by the "‹" also popped the dropdown open. Harmless while this was Reader-only, wrong // for every app page since beta_49. if (s_apppage_close) { s_apppage_close(); s_sb_back_ms = millis() | 1u; } // |1: 0 means "none" } // Capture the composited screen to a 16-bit BMP on the SD card. BMP (no // compression) is the quickest viewable format to emit — rows are a direct copy // of the RGB565 framebuffer (LV_COLOR_16_SWAP is 0). Saved to /screenshots. static void takeScreenshotToSd() { #if CAP_SD || defined(TLORA_PAGER) auto toast = [&](const char* m){ if (g_lv.task) g_lv.task->showAlert(m, 1800); }; if (SD.cardType() == CARD_NONE) { toast(TR("Screenshot: no SD card")); return; } const int W = lv_disp_get_hor_res(nullptr); const int H = lv_disp_get_ver_res(nullptr); lv_color_t* buf = (lv_color_t*)heap_caps_malloc((size_t)W * H * sizeof(lv_color_t), MALLOC_CAP_SPIRAM); if (!buf) buf = (lv_color_t*)malloc((size_t)W * H * sizeof(lv_color_t)); if (!buf) { toast(TR("Screenshot: low memory")); return; } // Mirror a forced full-screen redraw (all layers) into buf via the flush hook. g_shot_w = W; g_shot_h = H; g_shot_buf = buf; lv_obj_invalidate(lv_scr_act()); lv_obj_invalidate(lv_layer_top()); lv_obj_invalidate(lv_layer_sys()); lv_refr_now(NULL); g_shot_buf = nullptr; markSdIo(); SD.mkdir("/screenshots"); char path[48]; const time_t now = time(nullptr); if (now > 1700000000) { struct tm tmv; localtime_r(&now, &tmv); strftime(path, sizeof path, "/screenshots/%Y%m%d_%H%M%S.bmp", &tmv); } else { snprintf(path, sizeof path, "/screenshots/up_%lu.bmp", (unsigned long)millis()); } File f = SD.open(path, FILE_WRITE); if (!f) { free(buf); toast(TR("Screenshot: write failed")); return; } const uint32_t row_bytes = (uint32_t)(((W * 16 + 31) / 32) * 4); // 4-byte aligned const uint32_t img_bytes = row_bytes * (uint32_t)H; const uint32_t data_off = 14u + 40u + 12u; // headers + RGB565 bit masks uint8_t hdr[66]; memset(hdr, 0, sizeof hdr); auto put16 = [&](int o, uint16_t v){ hdr[o] = v & 0xFF; hdr[o+1] = v >> 8; }; auto put32 = [&](int o, uint32_t v){ hdr[o]=v&0xFF; hdr[o+1]=(v>>8)&0xFF; hdr[o+2]=(v>>16)&0xFF; hdr[o+3]=(v>>24)&0xFF; }; hdr[0] = 'B'; hdr[1] = 'M'; put32(2, data_off + img_bytes); // file size put32(10, data_off); // pixel data offset put32(14, 40); // DIB header size put32(18, (uint32_t)W); put32(22, (uint32_t)H); // positive = bottom-up put16(26, 1); // planes put16(28, 16); // bpp put32(30, 3); // BI_BITFIELDS put32(34, img_bytes); put32(38, 2835); put32(42, 2835); // ~72 DPI put32(54, 0x0000F800); // red mask put32(58, 0x000007E0); // green mask put32(62, 0x0000001F); // blue mask f.write(hdr, sizeof hdr); static const uint8_t pad[4] = {0,0,0,0}; const uint32_t pad_n = row_bytes - (uint32_t)W * 2u; for (int y = H - 1; y >= 0; --y) { // BMP rows are bottom-up f.write((uint8_t*)(buf + (size_t)y * W), (size_t)W * 2u); if (pad_n) f.write(pad, pad_n); } f.close(); free(buf); toast(path); #else if (g_lv.task) g_lv.task->showAlert(TR("Screenshot needs an SD card"), 1800); #endif } #if defined(DOC_CAPTURE) // ---- Documentation capture (one-off build: -DDOC_CAPTURE) --------------------------------------- // Streams the framebuffer over USB as a 16-bit BMP, then walks the screens automatically so the // host script (scripts/doc/capture.py) can save a PNG per screen. The tour blocks the main loop // while it runs, so the serial carries ONLY these frames (no companion push frames interleave). #include "esp_task_wdt.h" static void captureScreenToSerial(const char* name) { const int W = lv_disp_get_hor_res(nullptr); const int H = lv_disp_get_ver_res(nullptr); lv_color_t* buf = (lv_color_t*)heap_caps_malloc((size_t)W * H * sizeof(lv_color_t), MALLOC_CAP_SPIRAM); if (!buf) buf = (lv_color_t*)malloc((size_t)W * H * sizeof(lv_color_t)); if (!buf) return; g_shot_w = W; g_shot_h = H; g_shot_buf = buf; lv_obj_invalidate(lv_scr_act()); lv_obj_invalidate(lv_layer_top()); lv_obj_invalidate(lv_layer_sys()); lv_refr_now(NULL); g_shot_buf = nullptr; const uint32_t row_bytes = (uint32_t)(((W * 16 + 31) / 32) * 4); // 4-byte aligned rows const uint32_t img_bytes = row_bytes * (uint32_t)H; const uint32_t total = 66u + img_bytes; // 14 + 40 + 12 headers + pixels uint8_t hdr[66]; memset(hdr, 0, sizeof hdr); auto p16 = [&](int o, uint16_t v){ hdr[o]=v&0xFF; hdr[o+1]=v>>8; }; auto p32 = [&](int o, uint32_t v){ hdr[o]=v&0xFF; hdr[o+1]=(v>>8)&0xFF; hdr[o+2]=(v>>16)&0xFF; hdr[o+3]=(v>>24)&0xFF; }; hdr[0]='B'; hdr[1]='M'; p32(2,total); p32(10,66); p32(14,40); p32(18,(uint32_t)W); p32(22,(uint32_t)H); p16(26,1); p16(28,16); p32(30,3); p32(34,img_bytes); p32(38,2835); p32(42,2835); p32(54,0x0000F800); p32(58,0x000007E0); p32(62,0x0000001F); // RGB565 bit masks Serial.printf("\n<<<WMSHOT name=%s w=%d h=%d bytes=%u>>>\n", name, W, H, (unsigned)total); Serial.write(hdr, sizeof hdr); static const uint8_t pad[4] = {0,0,0,0}; const uint32_t pad_n = row_bytes - (uint32_t)W * 2u; for (int y = H - 1; y >= 0; --y) { // BMP rows are bottom-up Serial.write((const uint8_t*)(buf + (size_t)y * W), (size_t)W * 2u); if (pad_n) Serial.write(pad, pad_n); } Serial.print("\n<<<WMEND>>>\n"); Serial.flush(); free(buf); } static void docSettle(int frames) { for (int i = 0; i < frames; ++i) { lv_timer_handler(); delay(22); } esp_task_wdt_reset(); } static void docCaptureTour() { delay(400); esp_task_wdt_reset(); Serial.print("\n<<<WMTOUR START>>>\n"); Serial.flush(); const char* tabs[] = {"chat","contacts","home","map","settings"}; const int tabi[] = {CHAT_INBOX_TAB_INDEX, CONTACTS_TAB_INDEX, HOME_TAB_INDEX, MAP_TAB_INDEX, SETTINGS_TAB_INDEX}; for (int t = 0; t < 5; ++t) { navGoToMainTab(tabi[t]); docSettle(12); captureScreenToSerial(tabs[t]); } for (int c = 0; c < CAT_COUNT; ++c) { navGoToMainTab(SETTINGS_TAB_INDEX); docSettle(3); openSettingsCategory(c); docSettle(12); char nm[48]; int o = snprintf(nm, sizeof nm, "set_"); for (const char* L = kSettingsCats[c].label; *L && o < (int)sizeof(nm) - 1; ++L) { char ch = *L; nm[o++] = (ch >= 'A' && ch <= 'Z') ? (char)(ch + 32) : ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) ? ch : '_'; } nm[o] = 0; captureScreenToSerial(nm); closeSettingsCategory(); docSettle(6); } navGoToMainTab(HOME_TAB_INDEX); docSettle(3); openSpectrumPage(); docSettle(14); captureScreenToSerial("app_spectrum"); closeSpectrumPage(); docSettle(6); openRemotePage(); docSettle(14); captureScreenToSerial("app_remote"); closeRemotePage(); docSettle(6); #if defined(MULTI_TRANSPORT_COMPANION) && CAP_WEB_BROWSER // On-device web browser (8 MB boards only) + the chat-link menu + the QR popup. // The reader renders from the UI-update poll (which doesn't run while this tour // blocks the loop), so drive the fetch-wait + render inline here. openReaderPage("wadamesh.com"); { int _w = 0; while (s_reader_busy && _w < 700) { lv_timer_handler(); delay(22); ++_w; } esp_task_wdt_reset(); } if (s_reader_ok) { readerRenderBody(); readerSetAddrExpanded(false); } else { readerShowMessage(s_reader_msg[0] ? s_reader_msg : "Couldn't load page", 0xE0A0A0); readerSetAddrExpanded(true); } docSettle(8); captureScreenToSerial("app_web"); closeReaderPage(); docSettle(6); openUrlMenu("https://wadamesh.com"); docSettle(10); captureScreenToSerial("app_web_links"); closeUrlMenu(); docSettle(6); openUrlQrPopup("https://wadamesh.com"); docSettle(10); captureScreenToSerial("app_web_qr"); closeUrlQr(); docSettle(6); #endif Serial.print("\n<<<WMTOUR END>>>\n"); Serial.flush(); } #endif // ---- Idle power-save hooks (see TouchSleep.h) ---- // Called by touchSleep::gatePasses(); each hook probes the relevant subsystem. static bool tsScreenOff() { return g_lv.task && g_lv.task->isScreenOff(); } static bool tsNoClient() { return the_mesh.getProtoNumClients() == 0; } // public accessor (proto_num_clients is private) // tsWifiOff: true only when the WiFi radio is actually powered off — the pref // wifiConfigGetRadioEnabled() is the source of truth (wifiConfigApply() drives // WiFi.mode(WIFI_OFF) when it is false). WiFi.status() != WL_CONNECTED was // the previous check but returns true even while the modem is powered and scanning. static bool tsWifiOff() { return !wifiConfigGetRadioEnabled() && WiFi.getMode() == WIFI_OFF; } // tsBleOff: true when no BLE capability is enabled (mirrors the ble_up flag in // updateGlobalStatusBar — hasBleCapability() && isBleEnabled()). static bool tsBleOff() { return !(g_lv.task && g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled()); } // tsOnBattery: true when NOT charging (mirrors the charging bool in updateGlobalStatusBar // — batteryIsCharging(batteryMvSmoothed())). static bool tsOnBattery() { return !batteryIsCharging(batteryMvSmoothed()); } // tsMeshIdle: true when the radio is NOT mid-receive (preamble→RxDone race guarded), // AND no outbound packet / retry / contact write is due now. Future retry and // write deadlines are allowed to use the timed idle-power-saving path. // isRadioReceiving() delegates to Dispatcher::_radio->isReceiving() (RadioLibWrapper override). static bool tsMeshIdle() { return !the_mesh.hasPendingWork() && !the_mesh.isRadioReceiving(); } // Bound the timed idle park by the earliest UI probe or companion retry. static uint32_t tsNextWakeForcingDueMs(uint32_t now_ms) { uint32_t next_delay = UINT32_MAX; if (s_sig_probe_at != 0) { next_delay = (uint32_t)(s_sig_probe_at > now_ms ? (s_sig_probe_at - now_ms) : 0); } uint32_t retry_delay = 0; if (the_mesh.getNextCompanionRetryWakeDelay(retry_delay) && retry_delay < next_delay) { next_delay = retry_delay; } return next_delay; } static uint32_t tsEpochNow() { return (uint32_t)time(nullptr); } // tsBlockReason: returns nullptr when the gate would pass once the screen dims // (i.e. the device is ready to idle-sleep); else returns the highest-priority // blocking condition as a human-readable string for the status-bar toast. // "feature disabled" is NOT a blocking reason — the caller hides the icon instead. static const char* tsBlockReason() { if (!touchSleep::enabled()) return nullptr; // hidden, not blocked if (!tsOnBattery()) return TR("USB powered"); if (!tsWifiOff()) return TR("Wi-Fi on"); if (!tsBleOff()) return TR("BLE on"); if (!tsNoClient()) return TR("client connected"); if (!tsMeshIdle()) return TR("mesh busy"); return nullptr; // ready — only screen-off remains for the gate to pass } // tsWakeReasonStr: human-readable label for each WakeReason enum value, shown in // the idle-sleep diag panel so the user can see *why* the device last woke. // "-" is the sentinel for WakeReason::None (no wake yet since boot). static const char* tsWakeReasonStr(touchSleep::WakeReason r) { using WR = touchSleep::WakeReason; switch (r) { case WR::Timer: return "timer"; case WR::Packet: return "packet"; case WR::Touch: return "touch"; case WR::Button: return "button"; case WR::Other: return "other"; default: return "-"; } } static void uiInstallTouchSleepHooks() { touchSleep::Hooks h{}; h.screenOff = tsScreenOff; h.noClient = tsNoClient; h.wifiOff = tsWifiOff; h.bleOff = tsBleOff; h.onBattery = tsOnBattery; h.meshIdle = tsMeshIdle; h.nextWakeForcingDueMs = tsNextWakeForcingDueMs; h.epochNow = tsEpochNow; touchSleep::begin(h); } #if defined(HAS_TDECK_GT911) // Tap on the sleep readiness icon: toast the current blocking reason so the // user knows WHY the device won't idle-sleep yet (mirrors batteryTapCb shape). static void sleepIconTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!g_lv.task) return; const char* reason = tsBlockReason(); g_lv.task->showAlert(reason ? reason : TR("Sleep ready"), 1600); } #endif // Build the always-on status bar. Called once from UITask::begin after lv_init. // Lives on lv_layer_top (above all tab/chat content on lv_scr_act). It is the // boot-time bottom child of that layer, so modal popups created later (emoji, // add-channel, QR, pickers, …) stack ABOVE it — exactly what we want. The two // exceptions that must sit above the bar (the control-center dropdown that the // bar-tap toggles, and the Snake overlay whose taps the bar swallows) are handled // by edge-triggered move_foreground in updateGlobalStatusBar. (Toast + trackball // cursor stay on lv_layer_sys, which is always above this.) static void buildGlobalStatusBar() { g_statusbar.root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(g_statusbar.root); // Full screen width (responsive to rotation — 240 portrait / 320 landscape). lv_obj_set_size(g_statusbar.root, lv_disp_get_hor_res(nullptr), STATUSBAR_H); lv_obj_set_pos(g_statusbar.root, 0, 0); lv_obj_set_style_bg_color(g_statusbar.root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(g_statusbar.root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(g_statusbar.root, LV_OBJ_FLAG_SCROLLABLE); // Tap the bar to open the control center (quick Wi-Fi/Bluetooth toggles + // clock/battery). Child labels are non-clickable, so taps reach the root. lv_obj_add_flag(g_statusbar.root, LV_OBJ_FLAG_CLICKABLE); // Keypad/focus nav (Tanmatsu, T-Deck trackball d-pad) must NEVER land on the // status bar. It lives permanently on lv_layer_top, so without NAV_SKIP_FLAG // navTopHasVisibleChild() always sees a visible top-layer child and re-roots the // focus group onto the bar — and because the bar is CLICKABLE it becomes the only // focus stop, so the arrows get stuck on the topbar. NAV_SKIP keeps both // navTopHasVisibleChild() and navCollect() off it. (Touch users still tap it; // keypad users open the control center via the dedicated key.) lv_obj_add_flag(g_statusbar.root, NAV_SKIP_FLAG); lv_obj_add_event_cb(g_statusbar.root, statusBarTapCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(g_statusbar.root, statusBarReaderBackCb, LV_EVENT_PRESSED, nullptr); // reader "‹": PRESSED so cap-touch can't drop it // Hold the bar for 3 s to drop a screenshot to the SD card. lv_obj_add_event_cb(g_statusbar.root, statusBarHoldCb, LV_EVENT_PRESSED, nullptr); lv_obj_add_event_cb(g_statusbar.root, statusBarHoldCb, LV_EVENT_PRESSING, nullptr); lv_obj_add_event_cb(g_statusbar.root, statusBarHoldCb, LV_EVENT_RELEASED, nullptr); lv_obj_add_event_cb(g_statusbar.root, statusBarHoldCb, LV_EVENT_PRESS_LOST, nullptr); // No bottom border — the bar blends into the page below it (no hard separation // line under the regular OR double-height bar). lv_obj_set_style_border_width(g_statusbar.root, 0, LV_PART_MAIN); // ---- Glass backdrop for the lower row of the double-height bar ---- // LVGL 8.4 bg gradients can't vary opacity, so fake a vertical solid->translucent // fade with a stack of stepped-opacity strips. The TOP row stays fully solid; the // LOWER row fades to glass so an open chat / the inbox list shows through it and // blends up into the solid top. Built once (hidden + at the BACK, so the bar's // labels/icons draw over it); toggled by updateGlobalStatusBar. While active the // root's own fill is switched transparent so only this backdrop paints the bar. { const lv_coord_t hor = lv_disp_get_hor_res(nullptr); const lv_coord_t rowH = STATUSBAR_H; g_statusbar.fade = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(g_statusbar.fade); lv_obj_set_size(g_statusbar.fade, hor, rowH * 2); lv_obj_set_pos(g_statusbar.fade, 0, 0); lv_obj_clear_flag(g_statusbar.fade, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(g_statusbar.fade, LV_OBJ_FLAG_HIDDEN); // Solid top row (matches the regular bar fill). lv_obj_t* ftop = lv_obj_create(g_statusbar.fade); lv_obj_remove_style_all(ftop); lv_obj_set_size(ftop, hor, rowH); lv_obj_set_pos(ftop, 0, 0); lv_obj_set_style_bg_color(ftop, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(ftop, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(ftop, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); // Lower row: N tiled strips from FULLY OPAQUE at the top edge (a hard blend into the // solid top row) to FULLY TRANSPARENT at the bottom. Quadratic ease-out (1 - t²) so // the TOP stays near-solid and the fade concentrates in the lower part of the row. // More strips = smoother fade; cheap (one lv_obj each). const int N = 12; for (int i = 0; i < N; ++i) { const lv_coord_t y0 = rowH + (lv_coord_t)((long)i * rowH / N); const lv_coord_t y1 = rowH + (lv_coord_t)((long)(i + 1) * rowH / N); lv_obj_t* strip = lv_obj_create(g_statusbar.fade); lv_obj_remove_style_all(strip); lv_obj_set_size(strip, hor, y1 - y0); lv_obj_set_pos(strip, 0, y0); lv_obj_set_style_bg_color(strip, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(strip, (lv_opa_t)(255 - 255 * i * i / ((N - 1) * (N - 1))), LV_PART_MAIN); lv_obj_clear_flag(strip, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); } } // Chat/channel-OVERVIEW actions: [+ add | ✓ mark-read | QR share]. Hidden by // default and shown only on the overview (updateGlobalStatusBar). Most square // panels centre them across the double-height bar; Pager keeps them aligned // with the first-row status cluster so the compact header reads as one row. { // LEFT-aligned group. ~70% of the double-bar height so there's breathing room // above/below; soft rounded corners + a dimmer edge so they don't pop. #if CAP_ROUND_CORNERS // Round panel: the bar is a fixed two rows (no doubling), so size these to sit on // ROW 2's left, inside the corner inset — the status cluster occupies row-2 right. const lv_coord_t BH = 18, BW = 30, GAP = 6, BX0 = SB_INSET_X; const lv_coord_t BY = SB_TOP_PAD + SB_ROW + (SB_ROW - BH) / 2; #elif defined(TLORA_PAGER) // Match the compact status cluster instead of spanning most of the two-row // header. Keep all three actions in the first status-bar row, sharing its // vertical centreline with the clock and connection/battery indicators. const lv_coord_t BH = 20, BW = 26, GAP = 3, BX0 = 6; const lv_coord_t BY = (STATUSBAR_H - BH) / 2; #else const bool portrait = lv_disp_get_hor_res(nullptr) < lv_disp_get_ver_res(nullptr); const lv_coord_t BH = portrait ? 20 : (lv_coord_t)((STATUSBAR_H * 2 - 4) * 7 / 10); const lv_coord_t BW = portrait ? 30 : 34; const lv_coord_t GAP = 4, BX0 = 6; // Portrait's clock occupies row 1. Keep the Mail actions wholly in row 2; // centering them across both rows put their upper half through the time. const lv_coord_t BY = portrait ? STATUSBAR_H + (STATUSBAR_H - BH) / 2 : (lv_coord_t)(STATUSBAR_H * 2 - BH) / 2; #endif auto mk = [&](int slot_from_left) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(g_statusbar.root); lv_obj_set_size(b, BW, BH); lv_obj_set_pos(b, BX0 + slot_from_left * (BW + GAP), BY); styleButton(b); lv_obj_set_style_radius(b, 9, LV_PART_MAIN); // softer corners lv_obj_set_style_border_opa(b, LV_OPA_20, LV_PART_MAIN); // dim the edge so it blends lv_obj_add_flag(b, LV_OBJ_FLAG_HIDDEN); return b; }; g_statusbar.inbox_add = mk(0); // leftmost — green primary lv_obj_set_style_bg_color(g_statusbar.inbox_add, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(g_statusbar.inbox_add, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(g_statusbar.inbox_add, chatsAddBtnCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(g_statusbar.inbox_add); lv_label_set_text(l, LV_SYMBOL_PLUS); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(l, uiChromeFont(), LV_PART_MAIN); lv_obj_center(l); } g_statusbar.inbox_mark = mk(1); // middle — mark all read lv_obj_add_event_cb(g_statusbar.inbox_mark, chatsMarkAllReadBtnCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* ml = lv_label_create(g_statusbar.inbox_mark); lv_label_set_text(ml, LV_SYMBOL_OK); lv_obj_set_style_text_color(ml, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(ml, uiChromeFont(), LV_PART_MAIN); lv_obj_center(ml); } g_statusbar.inbox_qr = mk(2); // rightmost — share QR lv_obj_add_event_cb(g_statusbar.inbox_qr, shareMyContactBtnCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* qimg = lv_img_create(g_statusbar.inbox_qr); lv_img_set_src(qimg, &qr_icon_dsc); #if defined(TLORA_PAGER) lv_img_set_zoom(qimg, 228); // baked 18 px glyph -> 16 px, matching uiChromeFont() #endif lv_obj_set_style_img_recolor(qimg, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_img_recolor_opa(qimg, LV_OPA_COVER, LV_PART_MAIN); lv_obj_center(qimg); } } // Left zone — dynamic per-tab. Default to "MESHCOMOD"; updateGlobal- // StatusBar() swaps to the envelope+count on non-home tabs. g_statusbar.left_label = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.left_label, TR("WADAMESH")); lv_obj_set_style_text_color(g_statusbar.left_label, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.left_label, &g_font_14, LV_PART_MAIN); lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 6, 0); #if defined(TLORA_PAGER) // Keep the large-text title out of the clock/status cluster. Individual home // names may opt into a marquee; other titles truncate inside this window. lv_obj_set_width(g_statusbar.left_label, 200); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); #endif // Tapping the unread badge (✉ N) jumps to the Chats inbox. The CLICKABLE flag is // toggled per-tick in updateGlobalStatusBar so only the badge state intercepts the // tap; every other left-zone state falls through to the bar's control-center tap. lv_obj_set_ext_click_area(g_statusbar.left_label, 8); lv_obj_add_event_cb(g_statusbar.left_label, statusBarUnreadCb, LV_EVENT_CLICKED, nullptr); // Back chevron shown while in a chat (far left). It's a non-clickable affordance — // the bar's own tap handler (statusBarTapCb) closes the chat, like the settings page. g_statusbar.chat_back = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.chat_back, LV_SYMBOL_LEFT); lv_obj_set_style_text_color(g_statusbar.chat_back, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_set_style_text_font(g_statusbar.chat_back, uiChromeFont(), LV_PART_MAIN); lv_obj_align(g_statusbar.chat_back, LV_ALIGN_LEFT_MID, 6, 0); #else lv_obj_set_style_text_font(g_statusbar.chat_back, &g_font_16, LV_PART_MAIN); lv_obj_align(g_statusbar.chat_back, LV_ALIGN_LEFT_MID, 12, 0); // breathing room from the edge #endif lv_obj_add_flag(g_statusbar.chat_back, LV_OBJ_FLAG_HIDDEN); // Channel-settings gear — RIGHT of the back chevron, shown only inside a chat // (updateGlobalStatusBar toggles it). Opens the per-channel region-scope modal. // Clickable child intercepts the tap, so it doesn't trigger the bar's close-tap. g_statusbar.chan_gear = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.chan_gear, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_color(g_statusbar.chan_gear, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); #if defined(TLORA_PAGER) lv_obj_set_style_text_font(g_statusbar.chan_gear, uiChromeFont(), LV_PART_MAIN); lv_obj_align(g_statusbar.chan_gear, LV_ALIGN_LEFT_MID, 30, 0); #else lv_obj_set_style_text_font(g_statusbar.chan_gear, &g_font_16, LV_PART_MAIN); lv_obj_align(g_statusbar.chan_gear, LV_ALIGN_LEFT_MID, 44, 0); // gap after the back chevron #endif lv_obj_add_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_statusbar.chan_gear, 10); lv_obj_add_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_HIDDEN); lv_obj_add_event_cb(g_statusbar.chan_gear, channelGearCb, LV_EVENT_CLICKED, nullptr); #if defined(HAS_TANMATSU) // Ring the channel-settings gear with the green ○ key colour (matches the green F4 hardware key // that opens channel settings in-chat). Keep the gear GLYPH white — only the ring is green. lv_obj_set_style_text_color(g_statusbar.chan_gear, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_pad_all(g_statusbar.chan_gear, 2, LV_PART_MAIN); lv_obj_set_style_border_color(g_statusbar.chan_gear, lv_color_hex(0x2ECC40), LV_PART_MAIN); lv_obj_set_style_border_width(g_statusbar.chan_gear, 2, LV_PART_MAIN); lv_obj_set_style_radius(g_statusbar.chan_gear, LV_RADIUS_CIRCLE, LV_PART_MAIN); #endif // Right zone, anchored to the right edge (from rightmost to leftmost): // battery icon, battery %, signal bars, Wi-Fi, Bluetooth, clock. Compact spacings. g_statusbar.batt_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.batt_icon, LV_SYMBOL_BATTERY_2); lv_obj_set_style_text_color(g_statusbar.batt_icon, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.batt_icon, &g_font_14, LV_PART_MAIN); lv_obj_align(g_statusbar.batt_icon, LV_ALIGN_RIGHT_MID, -2, 0); g_statusbar.batt_pct = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.batt_pct, "?"); lv_obj_set_style_text_color(g_statusbar.batt_pct, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.batt_pct, &g_font_12, LV_PART_MAIN); lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -32, #else -SC(22), #endif 0); // Tapping the battery (icon or %) opens the 24h battery-history chart (logged to // SD on T-Deck, else internal SPIFFS — works on both builds). Generous ext-click // area so the small glyphs are easy to hit; the rest of the bar still routes to // statusBarTapCb (control center). lv_obj_add_flag(g_statusbar.batt_icon, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_statusbar.batt_icon, 10); lv_obj_add_event_cb(g_statusbar.batt_icon, batteryTapCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_flag(g_statusbar.batt_pct, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_statusbar.batt_pct, 10); lv_obj_add_event_cb(g_statusbar.batt_pct, batteryTapCb, LV_EVENT_CLICKED, nullptr); // Passive status indicators: tappable for info (the battery chart) but NOT keypad/trackball // nav targets. Focus nav should only land on the bar's ACTION buttons (chats +, channel // gear, back, unread) — never the battery / signal / sleep glyphs. (navCollect recurses past // the bar root's NAV_SKIP into its children, so each indicator opts out individually.) lv_obj_add_flag(g_statusbar.batt_icon, NAV_SKIP_FLAG); lv_obj_add_flag(g_statusbar.batt_pct, NAV_SKIP_FLAG); g_statusbar.clock = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.clock, "--:--"); lv_obj_set_style_text_color(g_statusbar.clock, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.clock, &g_font_12, LV_PART_MAIN); // Unified across all boards: extra slot reserved for the DND/sleep moon glyph // (T-Deck's sleep_icon and the all-board dnd_icon below both live at -SC(105)). lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -210, #else -SC(160), #endif 0); // Wi-Fi glyph (right of the Bluetooth glyph, left of the signal bars). Painted in // the accent, matching the lit signal bars beside it — the glyph is only ever shown // while the STA is connected, so accent always reads as "this radio is up". g_statusbar.conn_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.conn_icon, ""); lv_obj_set_style_text_color(g_statusbar.conn_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.conn_icon, &g_font_12, LV_PART_MAIN); lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -104, #else -SC(73), #endif 0); // Bluetooth glyph (left of the SD LED). Unified offset across all boards (see clock above). g_statusbar.ble_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.ble_icon, ""); lv_obj_set_style_text_color(g_statusbar.ble_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.ble_icon, &g_font_12, LV_PART_MAIN); // Narrow bars (V4 portrait) have no DND slot beside BLE and their clock sits at -126, so BLE // stays at -111 there; wide bars keep -SC(127) (tight to the DND moon at -144). lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -142, #else (lv_disp_get_hor_res(nullptr) < 300) ? -111 : -SC(127), #endif 0); #if defined(HAS_TDECK_GT911) // Idle power-save readiness indicator — moon glyph, left of the SD LED. T-Deck only, // visible only when the feature is enabled; accent colour = ready, grey = blocked. g_statusbar.sleep_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.sleep_icon, TOUCH_SYM_MOON); lv_obj_set_style_text_color(g_statusbar.sleep_icon, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.sleep_icon, &g_font_12, LV_PART_MAIN); lv_obj_align(g_statusbar.sleep_icon, LV_ALIGN_RIGHT_MID, -SC(144), 0); lv_obj_add_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_HIDDEN); // hidden until feature is on lv_obj_add_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_statusbar.sleep_icon, 8); lv_obj_add_event_cb(g_statusbar.sleep_icon, sleepIconTapCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_flag(g_statusbar.sleep_icon, NAV_SKIP_FLAG); // status glyph, not a focus-nav target #endif // Do Not Disturb glyph — same moon, same slot as the T-Deck sleep_icon above // (sleep_icon is permanently force-hidden every tick, see updateGlobalStatusBar, // since the idle-sleep indicator moved to tinting the battery glyph amber — so // there's no visual collision there; on every other board this is simply a // fresh, all-board indicator in a previously T-Deck-only slot). g_statusbar.dnd_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.dnd_icon, TOUCH_SYM_MOON); lv_obj_set_style_text_color(g_statusbar.dnd_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.dnd_icon, &g_font_12, LV_PART_MAIN); lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -164, #else -SC(144), #endif 0); lv_obj_add_flag(g_statusbar.dnd_icon, LV_OBJ_FLAG_HIDDEN); // shown only while DND is active lv_obj_add_flag(g_statusbar.dnd_icon, NAV_SKIP_FLAG); // passive status glyph, not a focus-nav target // microSD read/write activity LED — a small amber dot in the gap just left of // the Wi-Fi glyph. Hidden when idle; UITask::loop lights it for ~180 ms after // any SD access (markSdIo). A bg-coloured dot (not a glyph) keeps it tiny and // unambiguous as an activity light, and costs no font space. g_statusbar.sd_icon = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(g_statusbar.sd_icon); lv_obj_set_size(g_statusbar.sd_icon, 8, 8); lv_obj_set_style_radius(g_statusbar.sd_icon, 4, LV_PART_MAIN); lv_obj_set_style_bg_color(g_statusbar.sd_icon, lv_color_hex(0xF5A623), LV_PART_MAIN); // amber = SD activity lv_obj_set_style_bg_opa(g_statusbar.sd_icon, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_SCROLLABLE); lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -124, #else -SC(91), #endif 0); lv_obj_add_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_HIDDEN); // shown only during SD I/O // Async mesh-request spinner — a refresh glyph centred in the bar, blinking // while a request is in flight (UITask::loop drives it). Centre keeps it clear // of both the left-zone title and the right-side icon cluster. g_statusbar.async_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.async_icon, LV_SYMBOL_REFRESH); lv_obj_set_style_text_font(g_statusbar.async_icon, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(g_statusbar.async_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(g_statusbar.async_icon, LV_ALIGN_CENTER, 0, 0); lv_obj_add_flag(g_statusbar.async_icon, LV_OBJ_FLAG_HIDDEN); // shown only while a request is in flight // ---- Mesh signal-strength bars (just left of the battery) ---- // Four bars of increasing height; the lit count reflects the SNR of the last // packet we heard (updateGlobalStatusBar recolors them). Dim = no recent RX. { lv_obj_t* sb = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(sb); lv_obj_set_size(sb, 15, 12); lv_obj_align(sb, LV_ALIGN_RIGHT_MID, #if defined(TLORA_PAGER) -82, #else -SC(54), #endif 0); lv_obj_clear_flag(sb, LV_OBJ_FLAG_SCROLLABLE); g_statusbar.sig_box = sb; const int bw = 3, gap = 1; const int hh[4] = {4, 6, 9, 12}; for (int i = 0; i < 4; i++) { lv_obj_t* b = lv_obj_create(sb); lv_obj_remove_style_all(b); lv_obj_set_size(b, bw, hh[i]); lv_obj_set_pos(b, i * (bw + gap), 12 - hh[i]); // bottom-aligned lv_obj_set_style_radius(b, 1, LV_PART_MAIN); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x33363B), LV_PART_MAIN); g_statusbar.sig_bars[i] = b; } } // Keyboard layout indicator — sits left of the clock (only shown while typing). g_statusbar.layout_label = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.layout_label, ""); lv_obj_set_style_text_color(g_statusbar.layout_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.layout_label, &g_font_12, LV_PART_MAIN); lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -182, 0); // follows the clock's shift lv_obj_add_flag(g_statusbar.layout_label, LV_OBJ_FLAG_HIDDEN); // Modal scrim for the bar. Modal popups live on lv_layer_top ABOVE the bar but their // own dim backdrop only covers the screen BELOW the bar (they were authored when the // bar was always-on-top on lv_layer_sys). So the bar would stay bright while the rest // dims. This child matches that dim over the bar, and is CLICKABLE so it swallows taps // on the bar behind a modal. Created last = topmost in the bar; toggled by the driver. // Sized for the tall bar; the bar clips it to the current height. g_statusbar.dim = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(g_statusbar.dim); lv_obj_set_size(g_statusbar.dim, lv_disp_get_hor_res(nullptr), STATUSBAR_H * 2); lv_obj_set_pos(g_statusbar.dim, 0, 0); lv_obj_set_style_bg_color(g_statusbar.dim, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(g_statusbar.dim, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(g_statusbar.dim, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_statusbar.dim, LV_OBJ_FLAG_CLICKABLE); // swallow bar taps behind a modal lv_obj_add_flag(g_statusbar.dim, LV_OBJ_FLAG_HIDDEN); #if CAP_ROUND_CORNERS statusBarLayoutTwoRow(0); // place the clock (row 1) + the status cluster (row 2) #endif } #if CAP_ROUND_CORNERS // Two-row status-bar layout for the round-cornered phone panel. Row 1 (top): the clock // at the right inset (+ keyboard-layout tag to its left, + chat back/cog on the left). // Row 2 (bottom): the battery pinned at the right inset, with the signal/wifi/sd/ble // cluster to its left — that cluster slides right by `slide` px when the %-column is // hidden while charging (mirrors the square-panel slide). The app-name / page-title / // chat thread-name (left_label) is placed by updateGlobalStatusBar. Per-item y nudges // centre the different glyph heights within their row. Base X offsets mirror the // single-row builder so the horizontal spacing is unchanged; +SB_INSET_X keeps the end // glyphs clear of the corner arcs. static void statusBarLayoutTwoRow(int slide) { const int ins = SB_INSET_X; // Row 2 — status cluster, right→left, evenly spaced. Battery pinned at the inset; // the sub-battery cluster (signal/sd/wifi/ble) sits to its left and slides right by // `slide` when the %-column hides while charging. y nudges centre each glyph in the // row (montserrat_14 battery sits a touch higher than the 12px glyphs; sig/sd dots // drop a few px). The scrolling profile name shares this row on the left (placed by // updateGlobalStatusBar). if (g_statusbar.batt_icon) lv_obj_align(g_statusbar.batt_icon, LV_ALIGN_TOP_RIGHT, -(2 + ins), SB_ROW2_Y - 2); if (g_statusbar.batt_pct) lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_TOP_RIGHT, -(26 + ins), SB_ROW2_Y); if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_TOP_RIGHT, -(64 + ins - slide), SB_ROW2_Y + 2); if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_TOP_RIGHT, -(86 + ins - slide), SB_ROW2_Y + 4); if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_TOP_RIGHT, -(102 + ins - slide), SB_ROW2_Y); if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_TOP_RIGHT, -(126 + ins - slide), SB_ROW2_Y); // Row 1 — clock at the right inset (+ keyboard-layout tag to its left when typing). if (g_statusbar.clock) lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_RIGHT, -ins, SB_ROW1_Y); if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_TOP_RIGHT, -(48 + ins), SB_ROW1_Y); // Row 1 — chat back chevron + channel-settings cog on the left (shown only in a chat). if (g_statusbar.chat_back) lv_obj_align(g_statusbar.chat_back, LV_ALIGN_TOP_LEFT, ins, SB_ROW1_Y); if (g_statusbar.chan_gear) lv_obj_align(g_statusbar.chan_gear, LV_ALIGN_TOP_LEFT, ins + 24, SB_ROW1_Y); } #endif static void updateGlobalStatusBar() { if (!g_statusbar.root || !g_lv.task) return; // ---- Layer order (edge-triggered) ---- // The bar lives on lv_layer_top and normally sits at the BACK of it, so overlays created // later — modal popups (emoji, add-channel, QR, pickers) AND the control-center dropdown — // stack ABOVE the bar instead of being clipped under it. (The CC drops down from the bar; // it must render on top, and it has its own backdrop-tap close, with the bar's top row // left uncovered for the bar-tap close.) The ONE thing that needs the bar raised above it // is the Snake overlay — the bar swallows taps over the game. { static bool s_bar_fg = false; // Raise the bar to the FRONT of lv_layer_top for any app page (Spectrum, RF Monitor, // Snake, Airtime, ...): those roots are also on lv_layer_top and moved foreground, so // without this the TALL bar's lower half (back chevron + title) is covered by the page. // Settings sheets sit on lv_scr_act so they never need this. One condition covers every // page now that Snake and Airtime set s_apppage_title too. const bool want_fg = (s_apppage_title != nullptr); if (want_fg) { // Keep the bar the TOPMOST lv_layer_top child so its back tap is always hittable — // re-assert every tick (a page opened over a chat can otherwise let the chat's // widgets sit above the bar's lower row). Only move when it isn't already on top, // so this doesn't invalidate/redraw the bar each frame. lv_obj_t* lt = lv_layer_top(); uint32_t n = lv_obj_get_child_cnt(lt); if (n && lv_obj_get_child(lt, n - 1) != g_statusbar.root) lv_obj_move_foreground(g_statusbar.root); } else if (s_bar_fg) { lv_obj_move_background(g_statusbar.root); } s_bar_fg = want_fg; } // ---- Modal scrim over the bar (edge-triggered) ---- // A modal popup's own dim backdrop only covers the screen BELOW the bar, so without this // the bar stayed bright over a dimmed screen ("weird"). Mirror that dim over the bar. // Exclude overlays that USE the bar / shouldn't dim it: the control-center dropdown, the // settings detail sheet (its glass title), the app drawer, and the channel/blocked sheets // (closed via the bar's back chevron). if (g_statusbar.dim) { static bool s_bar_dim = false; const bool want_dim = anyPopupOpen() && !s_cc_root && !s_settings_sheet && !s_appdrawer_root && !chanScopeIsOpen() && !blockedModalIsOpen() && #if CAP_LUA_APPS !s_luastore_root && // same: the Store page's ‹ back lives in the bar #endif !s_reader_page_open; // the reader uses the bar for its ‹ back — don't dim/block it if (want_dim != s_bar_dim) { s_bar_dim = want_dim; if (want_dim) lv_obj_clear_flag(g_statusbar.dim, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.dim, LV_OBJ_FLAG_HIDDEN); } } // ---- Double-height bar driver (edge-triggered) ---- // The bar goes 2× tall for (a) settings detail pages, (b) the chat/channel OVERVIEW // (lower row = [+ ✓ QR] actions), and (c) an OPEN chat (lower row = thread name, cog // centred across both rows). Pager's compact overview and open-chat controls share // the regular top row, so both states stay single-height. // An open app/tool page (the reader, RF Monitor, …) takes over the bar even when a // chat is still open underneath it, so suppress chat-mode chrome (the cog + back // chevron + centred title) then — otherwise they sit over the page's "‹ title" and // swallow its back tap. // ...and a settings detail page too. It sets s_settings_open_cat rather than // s_apppage_title, so the guard above missed it: opening Settings from inside // a chat left the chat's back chevron AND its cog drawn over the settings // page's own "‹ Title". Two back chevrons on the left of the tall bar, and the // cog is clickable, so a tap near the wrong one went to channel settings or // nowhere instead of going back (#308). const bool chat_open = (s_chat_title[0] != '\0') && !s_apppage_title && (s_settings_open_cat < 0); const bool inbox_overview = (getActiveTab() == CHAT_INBOX_TAB_INDEX) && !chat_open && (s_settings_open_cat < 0) && !s_apppage_title; { #if defined(TLORA_PAGER) const bool want_tall = (s_settings_open_cat >= 0) || (s_apppage_title && !s_apppage_slim); #else const bool want_tall = (s_settings_open_cat >= 0) || (s_apppage_title && !s_apppage_slim) || inbox_overview || chat_open; #endif if (want_tall != s_statusbar_tall) statusBarSetTall(want_tall); // Glass lower row on EVERY double-height bar (settings detail, inbox/chat overview, // open chat) so the tall bar looks consistent everywhere it appears. Switch the // root's own fill transparent so only the fade backdrop paints the bar, then show // it; otherwise the root paints solid and the backdrop hides. Edge-triggered (the // built state — opaque root + hidden fade — already matches the inactive case). // EXCEPT while the channel-settings / blocked-users sheets are open: those are a new // page over the chat, so the bar goes SOLID (no glass revealing the chat behind it). // The round panel keeps a SOLID two-row bar (no glass fade — there's no doubled lower // row to reveal content through), so fade never activates there. const bool fade_active = want_tall && !chanScopeIsOpen() && !blockedModalIsOpen() && !s_reader_page_open && !CAP_ROUND_CORNERS; static bool s_fade_active = false; if (fade_active != s_fade_active) { s_fade_active = fade_active; lv_obj_set_style_bg_opa(g_statusbar.root, fade_active ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_MAIN); if (g_statusbar.fade) { if (fade_active) lv_obj_clear_flag(g_statusbar.fade, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.fade, LV_OBJ_FLAG_HIDDEN); } } lv_obj_t* const inbox_btns[3] = { g_statusbar.inbox_add, g_statusbar.inbox_mark, g_statusbar.inbox_qr }; if (g_statusbar.inbox_add) { for (lv_obj_t* b : inbox_btns) { if (!b) continue; if (inbox_overview) lv_obj_clear_flag(b, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(b, LV_OBJ_FLAG_HIDDEN); } } // Per-element vertical placement in the tall bar. Default = TOP row (system content, // same place as the regular bar). Exceptions: the settings back+title stays CENTRED; // an open chat's thread name drops to the LOWER row and its cog centres across both. // On the round panel the two rows come from each child's BASE align (set by // statusBarLayoutTwoRow), not from a per-tick shift — so never translate there. const lv_coord_t up = (s_statusbar_tall && !CAP_ROUND_CORNERS) ? -(lv_coord_t)(STATUSBAR_H / 2) : 0; const uint32_t nch = lv_obj_get_child_cnt(g_statusbar.root); for (uint32_t i = 0; i < nch; ++i) { lv_obj_t* c = lv_obj_get_child(g_statusbar.root, i); if (c == inbox_btns[0] || c == inbox_btns[1] || c == inbox_btns[2]) continue; if (c == g_statusbar.fade || c == g_statusbar.dim) continue; // full-bar backdrops — never shift lv_coord_t t = up; // top row by default if (c == g_statusbar.left_label) { if (s_settings_open_cat >= 0 || s_apppage_title) t = 0; // settings/tool back+title: centred else if (chat_open) t = -up; // chat thread name: lower row } else if (chat_open && (c == g_statusbar.chan_gear || c == g_statusbar.chat_back)) { t = 0; // chat back + cog: centred across both rows } else if (c == g_statusbar.layout_label) { // The layout indicator is align_to'd against the CLOCK's final (already // translated) coords every tick — shifting it here too double-applied the // top-row offset and clipped its top half out of the bar in a chat. t = 0; } lv_obj_set_style_translate_y(c, t, LV_PART_MAIN); } } // Settings gear sits just left of the thread name in ANY open chat (channel → // scope + blocked users; DM → blocked users). const bool in_chan_chat = chat_open; // already excludes an open app/tool page (see above) // Back chevron + settings cog on the far left while in a chat; both centred in the // tall bar (see the shift loop above). The cog is clickable (channel settings); the // back chevron is just an affordance — the bar's tap closes the chat. if (g_statusbar.chat_back) { // Re-apply the accent each tick so the chevron tracks the live theme colour (it was // set once at boot to the default accent and never updated on an accent change). lv_obj_set_style_text_color(g_statusbar.chat_back, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); if (in_chan_chat) lv_obj_clear_flag(g_statusbar.chat_back, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.chat_back, LV_OBJ_FLAG_HIDDEN); } if (g_statusbar.chan_gear) { if (in_chan_chat) lv_obj_clear_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.chan_gear, LV_OBJ_FLAG_HIDDEN); } // In a chat the thread name (+ unread prefix) is CENTRED on the lower row (the back + // cog sit at the far left); otherwise it's left-aligned per the usual layout. #if CAP_ROUND_CORNERS // Round panel placement of the left zone: // - open chat: thread name centred on ROW 1 (back/cog on row-1 left, clock row-1 right). // - settings/tool page: "‹ Title" on ROW 1 left. // - home / other tabs: the scrolling profile name (or ✉ unread badge) sits on ROW 2, // the SAME line as the battery/status cluster (per request), left-aligned. if (in_chan_chat) { lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_MID, 0, SB_ROW1_Y); } else if (s_settings_open_cat >= 0 || s_apppage_title) { lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_LEFT, SB_INSET_X, SB_ROW1_Y); } else { lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_LEFT, SB_INSET_X, SB_ROW2_Y); } #else if (in_chan_chat) { #if defined(TLORA_PAGER) // Back + gear occupy the first 48 px; cap the title before the clock so // the entire open-chat header shares the overview/status baseline. lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 54, 0); lv_obj_set_width(g_statusbar.left_label, 140); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); #else lv_obj_align(g_statusbar.left_label, LV_ALIGN_CENTER, 0, 0); #endif } else { lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 6, 0); #if defined(TLORA_PAGER) lv_obj_set_width(g_statusbar.left_label, 200); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); #endif } #endif // The home tab shows the user's profile name in a fixed ~11-char window that // marquee-scrolls when it's longer. These track that config so we (a) reset it // when the left zone is reused by another tab, and (b) only re-apply it on an // actual name change — re-setting LONG_SCROLL each tick would restart the // marquee and it'd never advance. static bool s_left_home_cfg = false; static char s_left_home_name[24] = {0}; { int htab = (g_lv.tabview) ? (int)lv_tabview_get_tab_act(g_lv.tabview) : -1; bool home_zone = (s_settings_open_cat < 0) && !s_apppage_title && (s_chat_title[0] == '\0') && (htab == HOME_TAB_INDEX); #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) if (s_fullscreen_view && s_fullscreen_title[0]) home_zone = false; #endif if (!home_zone && s_left_home_cfg) { #if defined(TLORA_PAGER) lv_obj_set_width(g_statusbar.left_label, in_chan_chat ? 140 : 200); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); #else lv_obj_set_width(g_statusbar.left_label, LV_SIZE_CONTENT); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_WRAP); #endif s_left_home_cfg = false; s_left_home_name[0] = '\0'; // force re-config on the next home entry } } // ---- Left zone ---- bool unread_badge = false; // set true only by the branches that render the ✉ count // Recolor markup is used ONLY by the settings-back branch below (to tint the chevron // with the theme accent). Default it OFF so any other state — e.g. a chat title that // contains '#' — is never mis-parsed as a colour code. if (g_statusbar.left_label) lv_label_set_recolor(g_statusbar.left_label, false); // A full-screen tool page (RF Monitor / Spectrum / Send advert) wins the title + Back chevron, // even when opened from inside a settings detail — matches statusBarTapCb routing (apppage first). const char* app_page_title = s_apppage_title ? s_apppage_title : (s_settings_open_cat >= 0) ? TR(kSettingsCats[s_settings_open_cat].label) : nullptr; if (app_page_title && s_reader_bar_url) { // Web app, reading: show the loaded URL small so a long address fits, on a solid // bar. No recolor markup (a '#' fragment in the URL would corrupt the parser) and // strip the scheme for width — "‹ example.com/page". const char* disp = app_page_title; if (!strncmp(disp, "https://", 8)) disp += 8; else if (!strncmp(disp, "http://", 7)) disp += 7; lv_obj_set_style_text_font(g_statusbar.left_label, &g_font_12, LV_PART_MAIN); lv_label_set_recolor(g_statusbar.left_label, false); char sbuf[96]; snprintf(sbuf, sizeof sbuf, "%s %s", LV_SYMBOL_LEFT, disp); // ‹ + address; tap the bar = back lv_label_set_text(g_statusbar.left_label, sbuf); } else if (app_page_title) { // A settings detail sheet OR a tool page is open: the bar carries its Back chevron + // page title, CENTRED in the tall bar and a size up (tapping the bar goes Back). The // chevron is tinted with the theme accent (the title text stays default). lv_obj_set_style_text_font(g_statusbar.left_label, s_statusbar_tall ? &g_font_16 : &g_font_14, LV_PART_MAIN); lv_label_set_recolor(g_statusbar.left_label, true); char sbuf[56]; snprintf(sbuf, sizeof sbuf, "#%06X %s# %s", (unsigned)(COLOR_ACCENT & 0xFFFFFF), LV_SYMBOL_LEFT, app_page_title); lv_label_set_text(g_statusbar.left_label, sbuf); } else if (inbox_overview) { // Chat/channel overview: the action buttons own the left side, and per-thread // unread state is already shown in the list — so keep the left zone blank (no // global ✉ badge competing with the buttons). lv_label_set_text(g_statusbar.left_label, ""); } else if (s_chat_title[0]) { // An open conversation surfaces its thread name here (the in-chat header bar // was removed to reclaim height). Keep the global unread badge as a prefix so // you still see mail waiting in OTHER threads while reading this one. Smaller // font so the name + badge fit. lv_obj_set_style_text_font(g_statusbar.left_label, &g_font_12, LV_PART_MAIN); int total_unread = g_lv.task->getUnreadTotal(); if (total_unread > 0) { char buf[64], ub[8]; if (total_unread > 99) snprintf(ub, sizeof ub, "99+"); else snprintf(ub, sizeof ub, "%d", total_unread); snprintf(buf, sizeof(buf), LV_SYMBOL_ENVELOPE " %s %s", ub, s_chat_title); lv_label_set_text(g_statusbar.left_label, buf); unread_badge = true; } else { lv_label_set_text(g_statusbar.left_label, s_chat_title); } } else #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) if (s_fullscreen_view && s_fullscreen_title[0]) { // A fullscreen tool view (Terminal / Files) borrows the left zone for its // title, so it can drop its own header row and use the full height. lv_label_set_text(g_statusbar.left_label, s_fullscreen_title); } else #endif { int tab = -1; if (g_lv.tabview) tab = (int)lv_tabview_get_tab_act(g_lv.tabview); // The OSM credit is longer than the usual left-zone text, so render it in the // smaller font — otherwise on the narrow V4 portrait bar its end ("…Map") // runs into the Wi-Fi icon. Restore the normal font on other tabs. lv_obj_set_style_text_font(g_statusbar.left_label, #if defined(TLORA_PAGER) tab == MAP_TAB_INDEX ? &lv_font_montserrat_14 : &g_font_14, #else tab == MAP_TAB_INDEX ? &g_font_12 : &g_font_14, #endif LV_PART_MAIN); if (tab == MAP_TAB_INDEX) { // On the immersive map the left zone carries the required OSM attribution. lv_label_set_text(g_statusbar.left_label, s_map_style == 1 ? TR("\xC2\xA9 OpenTopoMap") // © OpenTopoMap (CC-BY-SA) — full text in Options -> Info : TR("\xC2\xA9 OpenStreetMap")); } else if (tab == HOME_TAB_INDEX && touchPrefsGetHideNodeName()) { // Display setting: hide the device name. Clear the left zone — the clock is // parked here instead (see the clock-placement block below). lv_label_set_text(g_statusbar.left_label, ""); s_left_home_name[0] = '\0'; // force marquee re-config if the name returns s_left_home_cfg = false; } else if (tab == HOME_TAB_INDEX) { // Home: the user's profile name, capped to a ~11-char-wide window. Longer // names marquee-scroll so they never run into the status icons. Reconfigure // only when the name actually changes (else the scroll restarts each tick). const char* nm = g_lv.task->getNodeNameCstr(); if (!nm || !nm[0]) nm = "WADAMESH"; if (strncmp(s_left_home_name, nm, sizeof(s_left_home_name)) != 0) { strncpy(s_left_home_name, nm, sizeof(s_left_home_name) - 1); s_left_home_name[sizeof(s_left_home_name) - 1] = '\0'; // FIXED window so the name can never run into the clock/status icons — // ~100 px on the wide T-Deck, narrower on the V4 (portrait, clock sits // further left). A char-count cap can't catch wide glyphs (W/M), so cap // the WIDTH. LONG_SCROLL_CIRCULAR only animates when the name overflows // the window — short names render static — so no threshold is needed. // Big screen (Tanmatsu): give the name almost the whole bar — up to the clock/icon cluster // on the right — so the full profile name shows statically (the marquee only animates when // the text overflows the window). Narrow fixed windows stay on the small T-Deck / V4 bars. const lv_coord_t sb_w = lv_disp_get_hor_res(nullptr); // Reserve = clock anchor SC(126) + its text width + margin, so the name window // ends just before the (now SC-scaled) right-side icon cluster at any UI scale. #if CAP_ROUND_CORNERS // Round panel: the name sits on ROW 2 next to the status cluster, so the window // runs from the left inset up to just before the leftmost icon (Bluetooth, whose // right anchor is 126+inset from the right edge — reserve its glyph + a margin). lv_obj_set_width(g_statusbar.left_label, sb_w - (SB_INSET_X + 126 + 44)); #elif defined(TLORA_PAGER) lv_obj_set_width(g_statusbar.left_label, 200); #else lv_obj_set_width(g_statusbar.left_label, (sb_w >= 600) ? (sb_w - SC(190)) : (sb_w >= 300) ? 100 : 66); // #47a: narrower window on the 240px V4 bar so a long name can't run into the clock #endif lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_set_style_anim_speed(g_statusbar.left_label, 14, LV_PART_MAIN); // slow, readable marquee lv_label_set_text(g_statusbar.left_label, nm); } s_left_home_cfg = true; } else { int total_unread = g_lv.task->getUnreadTotal(); if (total_unread > 0) { char buf[24]; if (total_unread > 99) snprintf(buf, sizeof(buf), LV_SYMBOL_ENVELOPE " 99+"); else snprintf(buf, sizeof(buf), LV_SYMBOL_ENVELOPE " %d", total_unread); lv_label_set_text(g_statusbar.left_label, buf); unread_badge = true; } else { // No unread → blank the left zone entirely. Operator complaint was // the envelope was always lit even with an empty inbox, which read // as "you have mail" 24/7. lv_label_set_text(g_statusbar.left_label, ""); } } } // Activate the unread→Chats shortcut only while the ✉ count is shown; other // left-zone states (Back chevron, profile name, OSM credit, blank) keep the // bar's normal control-center / Back tap. if (g_statusbar.left_label) { if (unread_badge) lv_obj_add_flag(g_statusbar.left_label, LV_OBJ_FLAG_CLICKABLE); else lv_obj_clear_flag(g_statusbar.left_label, LV_OBJ_FLAG_CLICKABLE); } // ---- Connection icon ---- #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool wifi_up = (WiFi.status() == WL_CONNECTED); const bool ble_up = g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled(); // Wi-Fi and Bluetooth are independent glyphs now (Wi-Fi right of Bluetooth), // each shown only when that radio is up. if (wifi_up) { lv_label_set_text(g_statusbar.conn_icon, LV_SYMBOL_WIFI); lv_obj_clear_flag(g_statusbar.conn_icon, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_statusbar.conn_icon, LV_OBJ_FLAG_HIDDEN); } if (g_statusbar.ble_icon) { if (ble_up) { lv_label_set_text(g_statusbar.ble_icon, LV_SYMBOL_BLUETOOTH); lv_obj_clear_flag(g_statusbar.ble_icon, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_statusbar.ble_icon, LV_OBJ_FLAG_HIDDEN); } } #endif #if defined(HAS_TDECK_GT911) // ---- Idle power-save icon ---- (T-Deck only; predicates live in the same // HAS_TDECK_GT911 build environment as the actual light-sleep execution) // Idle power-save state is now shown by tinting the status-bar battery glyph amber // (iPhone-style, see applyBattColor), so the separate moon icon stays hidden. Kept in // the tree (hidden) to preserve the bar layout and its tap handler. if (g_statusbar.sleep_icon) lv_obj_add_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_HIDDEN); #endif // ---- Do Not Disturb icon ---- (shown only while the configured silence // window is active — see dndActive()). On cramped bars (T-Display P4's // round-corner two-row layout, or Heltec V4 rotated to portrait, <300px wide) // there's no room for a dedicated slot, so DND borrows the signal-bars slot // instead while active; signal strength reclaims it the rest of the time. { const bool cramped = CAP_ROUND_CORNERS || (lv_disp_get_hor_res(nullptr) < 300); const bool active = dndActive(); const bool swap_signal = cramped && active; static int s_dnd_state = -1; // (swap_signal<<1 | active) — only touch flags/realign on change const int state = (swap_signal ? 2 : 0) | (active ? 1 : 0); if (state != s_dnd_state) { s_dnd_state = state; if (g_statusbar.sig_box) { if (swap_signal) lv_obj_add_flag(g_statusbar.sig_box, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(g_statusbar.sig_box, LV_OBJ_FLAG_HIDDEN); } if (g_statusbar.dnd_icon) { if (!swap_signal) { // Dedicated slot — re-assert on entry/return so a CAP_ROTATABLE board // (V4) rotating out of portrait mid-DND self-heals from the borrowed // signal-bars position instead of staying stranded there. const int d = batteryIsCharging(batteryMvSmoothed()) ? #if defined(TLORA_PAGER) 45 #else 32 #endif : 0; #if defined(TLORA_PAGER) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); #elif CAP_LARGE_SCREEN lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144) + SC(d), 0); #else lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -144 + d, 0); #endif } if (active) lv_obj_clear_flag(g_statusbar.dnd_icon, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.dnd_icon, LV_OBJ_FLAG_HIDDEN); } } // Track sig_box's live position every tick WHILE swapped (cheap; avoids // staleness if a coincident charging-realign or P4 row-slide moves sig_box // the same tick this block's own edge-trigger doesn't fire). if (swap_signal && g_statusbar.sig_box && g_statusbar.dnd_icon) lv_obj_set_pos(g_statusbar.dnd_icon, lv_obj_get_x(g_statusbar.sig_box), lv_obj_get_y(g_statusbar.sig_box)); } // ---- Mesh signal strength ---- (SNR of the last packet we heard; dims when // nothing's been heard for a while. The periodic zero-hop "discover" advert in // UITask::loop keeps it fresh by prompting nearby nodes/repeaters.) { const uint32_t sig_ms = the_mesh.uiSignalMs(); const bool stale = (sig_ms == 0) || ((millis() - sig_ms) > 300000UL); // 5 min int level = 0; if (!stale) { const int snr = the_mesh.uiSignalSnrQ4() / 4; // dB if (snr >= 5) level = 4; else if (snr >= 0) level = 3; else if (snr >= -7) level = 2; else level = 1; // heard something, just weak -> 1 bar } static int s_sig_level = -1; if (level != s_sig_level) { s_sig_level = level; for (int i = 0; i < 4; i++) if (g_statusbar.sig_bars[i]) lv_obj_set_style_bg_color(g_statusbar.sig_bars[i], lv_color_hex(i < level ? COLOR_ACCENT : 0x33363B), LV_PART_MAIN); } } // ---- Battery ---- const uint16_t mv = batteryMvSmoothed(); const bool charging = batteryIsCharging(mv); const int pct = batteryPercentFromMv(mv); static int s_last_pct = -9999; static bool s_last_charging = false; if (pct != s_last_pct || charging != s_last_charging) { char buf[8]; if (charging) buf[0] = '\0'; // charging -> batteryGlyphForMv shows the bolt; no text else if (pct < 0) snprintf(buf, sizeof(buf), "?"); else snprintf(buf, sizeof(buf), "%d%%", pct); lv_label_set_text(g_statusbar.batt_pct, buf); if (charging != s_last_charging) { // The % column disappears while charging (bolt only), so slide everything // left of the battery rightward to keep it snug against the bolt — else a // %-wide gap opens between the signal bars and the lightning glyph. #if CAP_ROUND_CORNERS // Round panel: re-apply the two-row layout, sliding the row-2 sub-battery cluster // right by the hidden %-column width so it stays snug against the bolt. statusBarLayoutTwoRow(charging ? 32 : 0); #elif defined(TLORA_PAGER) const int d = charging ? 45 : 0; if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_RIGHT_MID, -82 + d, 0); if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -104 + d, 0); if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -124 + d, 0); if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, -142 + d, 0); if (g_statusbar.dnd_icon) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -232 + d, 0); #elif CAP_LARGE_SCREEN // Tanmatsu: the cluster is built with SC() UI-scaling, so this slide MUST scale too — the raw // offsets below marched the scaled glyphs (incl. the BLE icon) into each other at Large/Huge the // instant charging toggled. Bases match the SC() builder (now unified with the non-large-screen // branch below); the clock is re-placed (SC-scaled) by the clock-placement block just below, and // sleep_icon is T-Deck-only so it's omitted here — dnd_icon is NOT T-Deck-only, so it IS included. const int d = charging ? SC(32) : 0; if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_RIGHT_MID, -SC(54) + d, 0); if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -SC(73) + d, 0); if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -SC(91) + d, 0); if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, -SC(127) + d, 0); if (g_statusbar.dnd_icon) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144) + d, 0); if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -SC(182) + d, 0); #else const int d = charging ? 32 : 0; // Base offsets MUST match the builder (which shifted for the SD LED): the // SD dot is at -91, ble -127, clock -142, layout -166. The dot slides with // the cluster too so it stays between Wi-Fi and Bluetooth while charging. if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_RIGHT_MID, -54 + d, 0); if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -73 + d, 0); if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -91 + d, 0); // Narrow bar (V4 portrait) has no DND slot next to BLE (DND borrows the signal slot), // and its clock sits at -126 — so keep BLE at its pre-DND -111 there; -127 lands on the clock. if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, (lv_disp_get_hor_res(nullptr) < 300 ? -111 : -127) + d, 0); if (g_statusbar.clock) lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, -160 + d, 0); if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -182 + d, 0); if (g_statusbar.sleep_icon) lv_obj_align(g_statusbar.sleep_icon, LV_ALIGN_RIGHT_MID, -144 + d, 0); if (g_statusbar.dnd_icon) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -144 + d, 0); #endif } s_last_pct = pct; s_last_charging = charging; } static const char* s_last_glyph = nullptr; const char* g = batteryGlyphForMv(mv); if (g != s_last_glyph) { lv_label_set_text(g_statusbar.batt_icon, g); s_last_glyph = g; } #if defined(HAS_TDECK_GT911) // Re-tint the battery amber/normal when idle power-save toggles (the toggle cb routes through // here via updateGlobalStatusBar). Only on change, so no per-tick style churn. { static int s_last_pwr = -1; const int pwr = touchSleep::enabled() ? 1 : 0; if (pwr != s_last_pwr) { s_last_pwr = pwr; applyBattColor(); } } #endif // ---- Clock ---- #if defined(ESP32) static char s_last_clock[12] = {0}; time_t now_t = time(nullptr); if (now_t > 1700000000) { struct tm tm_loc; localtime_r(&now_t, &tm_loc); char buf[12]; fmtClockHM(buf, sizeof(buf), &tm_loc); // honor the 12/24-hour pref; dedup on the string so a toggle re-renders if (strcmp(buf, s_last_clock) != 0) { lv_label_set_text(g_statusbar.clock, buf); strncpy(s_last_clock, buf, sizeof(s_last_clock) - 1); } } #endif // ---- Reader/Web page: hide the clock so the URL fits in the title zone ---- if (g_statusbar.clock) { static bool s_clk_hidden = false; if (s_reader_bar_url != s_clk_hidden) { s_clk_hidden = s_reader_bar_url; if (s_reader_bar_url) lv_obj_add_flag(g_statusbar.clock, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(g_statusbar.clock, LV_OBJ_FLAG_HIDDEN); } } // ---- Clock placement ---- // With "hide device name" on, the clock sits CENTRED on every screen — clear of // both the left-zone title (chat / Files / map credit) and the right-side icon // cluster, so it never collides with e.g. the "Files" header. Otherwise it's // top-right; charging slides it +32 to hug the bolt once the % column hides. // Re-aligned only on a state change so it isn't laid out every tick. #if CAP_ROUND_CORNERS // Round P4 two-row bar: centre the clock on ROW 1 for the home / normal screens — row-1 // centre is free there (the node name lives on row 2). In a chat the row-1 centre is the // thread title, so the clock moves to row-1 right instead. Re-placed only when the chat // state flips, so it isn't laid out every tick. { static int8_t s_clk_chat = -1; if ((int8_t)chat_open != s_clk_chat) { s_clk_chat = (int8_t)chat_open; if (chat_open) lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_RIGHT, -SB_INSET_X, SB_ROW1_Y); else lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_MID, 0, SB_ROW1_Y); if (g_statusbar.async_icon) lv_obj_align_to(g_statusbar.async_icon, g_statusbar.clock, LV_ALIGN_OUT_LEFT_MID, -4, 0); } } #else { static int8_t s_clk_center = -1; // -1 = unset -> forces the first align static bool s_clk_chg = false; const int8_t want = touchPrefsGetHideNodeName() ? 1 : 0; if (want != s_clk_center || (!want && charging != s_clk_chg)) { s_clk_center = want; s_clk_chg = charging; if (want) lv_obj_align(g_statusbar.clock, LV_ALIGN_CENTER, 0, 0); else { // The narrow V4 portrait bar (<300 px) has no sleep-moon slot, so the clock must sit at // its intended -126 build position; the wide-bar -142 ran it into the width-capped // node-name window on the left (the long-standing "clock overlaps the name" bug). Charging // slides it +32 as the % column hides. // // Wide bar (T-Deck / Tanmatsu): the right-side icon cluster is laid out with SC() scaling // (ble at -SC(111/127)), but the old wide clk_x was a RAW -142 — so at Large/Huge UI scale // the icons marched left PAST the un-scaled clock and the wide "12:05 PM" clock overran the // battery / Wi-Fi / BLE icons. Scale the wide clock with SC() too so it tracks the cluster at // every UI scale (identical to the old -142/-110 at 100%, no node-name regression). Narrow // bar keeps its raw values (it was tuned for the V4 portrait layout at 100% only). const bool narrow_bar = lv_disp_get_hor_res(nullptr) < 300; // Wide bar shifted 18px further left of its old -142/-110 to open a clean // ~16-17px gap for the DND moon icon (now at -SC(144)) on Bluetooth's left // side, instead of the old cramped 15px gap that used to sit here. const int clk_x = #if defined(TLORA_PAGER) charging ? -165 : -210; #else narrow_bar ? (charging ? -94 : -126) : (charging ? -SC(128) : -SC(160)); #endif lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, clk_x, 0); } // Park the async-request spinner just LEFT of the clock wherever it lands, // so it never paints over the clock (incl. the centred hide-name mode). if (g_statusbar.async_icon) lv_obj_align_to(g_statusbar.async_icon, g_statusbar.clock, LV_ALIGN_OUT_LEFT_MID, -4, 0); } } #endif // ---- Layout indicator ---- // Only while a chat/channel conversation is open (s_chat_title set): that is // where typing happens and the active layout matters. Everywhere else it was // permanent bar clutter (and sat next to the clock 24/7 once a secondary // language was enabled). if (g_statusbar.layout_label) { if (keyboardLayoutsAnySecondary() && s_chat_title[0]) { const char* name = keyboardLayoutName(keyboardLayoutsGetCurrent()); lv_label_set_text(g_statusbar.layout_label, name); // Park it just left of the clock on EVERY board, so it tracks wherever the // clock lands (12/24-hour width, the charging slide, centred hide-name // mode, SC() scaling). The old fixed -182 build offset overlapped the wide // 12-hour clock on the T-Deck bar ("7:45 PM" reaches past -182). lv_obj_align_to(g_statusbar.layout_label, g_statusbar.clock, LV_ALIGN_OUT_LEFT_MID, -SC(8), 0); lv_obj_clear_flag(g_statusbar.layout_label, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_statusbar.layout_label, LV_OBJ_FLAG_HIDDEN); } } } // Set a label's text only when it actually changed. lv_label_set_text always // invalidates + re-lays-out the label (it does NOT compare), so calling it every // 250 ms refresh tick with byte-identical text needlessly redraws ~8 labels 4x/s. // Comparing against the label's CURRENT text also makes this safe across home/tab // rebuilds (no module-static "last value" that can outlive the widget). static inline void setLabelIfChanged(lv_obj_t* lbl, const char* txt) { if (lbl && strcmp(lv_label_get_text(lbl), txt) != 0) lv_label_set_text(lbl, txt); } #if defined(HAS_EXPANSION_KIT) static void localEnvHistoryShiftPush(uint16_t batt_mv, int16_t temp_t10, int16_t hum_pct, int16_t press_hpa10, int16_t alt_m) { memmove(&s_home_env_hist_batt_mv[0], &s_home_env_hist_batt_mv[1], sizeof(s_home_env_hist_batt_mv[0]) * (kHomeEnvHistoryPoints - 1)); memmove(&s_home_env_hist_temp_t10[0], &s_home_env_hist_temp_t10[1], sizeof(s_home_env_hist_temp_t10[0]) * (kHomeEnvHistoryPoints - 1)); memmove(&s_home_env_hist_hum[0], &s_home_env_hist_hum[1], sizeof(s_home_env_hist_hum[0]) * (kHomeEnvHistoryPoints - 1)); memmove(&s_home_env_hist_press_hpa10[0], &s_home_env_hist_press_hpa10[1], sizeof(s_home_env_hist_press_hpa10[0]) * (kHomeEnvHistoryPoints - 1)); memmove(&s_home_env_hist_alt_m[0], &s_home_env_hist_alt_m[1], sizeof(s_home_env_hist_alt_m[0]) * (kHomeEnvHistoryPoints - 1)); s_home_env_hist_batt_mv[kHomeEnvHistoryPoints - 1] = batt_mv; s_home_env_hist_temp_t10[kHomeEnvHistoryPoints - 1] = temp_t10; s_home_env_hist_hum[kHomeEnvHistoryPoints - 1] = hum_pct; s_home_env_hist_press_hpa10[kHomeEnvHistoryPoints - 1] = press_hpa10; s_home_env_hist_alt_m[kHomeEnvHistoryPoints - 1] = alt_m; } static void localEnvHistoryMaybeSample(unsigned long now_ms) { if (!g_lv.task) return; if (s_home_env_last_sample_ms != 0 && (int32_t)(now_ms - s_home_env_last_sample_ms) < (int32_t)kHomeEnvSampleMs) return; UITask::LocalEnvSnapshot snap; g_lv.task->getLocalEnvSnapshot(snap); const uint16_t batt_mv = snap.have_batt ? (uint16_t)lroundf(snap.batt_v * 1000.0f) : 0; const int16_t temp_t10 = snap.have_bme_temp ? (int16_t)lroundf(snap.bme_temp_c * 10.0f) : (snap.have_gxhtv3_temp ? (int16_t)lroundf(snap.gxhtv3_temp_c * 10.0f) : INT16_MIN); const int16_t hum_pct = snap.have_bme_hum ? (int16_t)lroundf(snap.bme_hum_pct) : (snap.have_gxhtv3_hum ? (int16_t)lroundf(snap.gxhtv3_hum_pct) : -1); const int16_t press_hpa10 = snap.have_bme_pressure ? (int16_t)lroundf(snap.bme_pressure_hpa * 10.0f) : INT16_MIN; const int16_t alt_m = snap.have_bme_alt ? snap.bme_alt_m : INT16_MIN; if (!s_home_env_hist_seeded) { for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { s_home_env_hist_batt_mv[i] = batt_mv; s_home_env_hist_temp_t10[i] = temp_t10; s_home_env_hist_hum[i] = hum_pct; s_home_env_hist_press_hpa10[i] = press_hpa10; s_home_env_hist_alt_m[i] = alt_m; } s_home_env_hist_seeded = true; } else { localEnvHistoryShiftPush(batt_mv, temp_t10, hum_pct, press_hpa10, alt_m); } s_home_env_last_sample_ms = now_ms; // NOTE: PR #29's pressure-drop "weather alarm" is intentionally not ported, // so the localPressureAlarmMaybeCheck() call is omitted here. if (s_home_env_chart && s_home_env_batt && s_home_env_temp && s_home_env_hum) { lv_chart_set_next_value(s_home_env_chart, s_home_env_batt, batt_mv > 0 ? (lv_coord_t)batt_mv : LV_CHART_POINT_NONE); lv_chart_set_next_value(s_home_env_chart, s_home_env_temp, temp_t10 != INT16_MIN ? (lv_coord_t)(temp_t10 / 10) : LV_CHART_POINT_NONE); lv_chart_set_next_value(s_home_env_chart, s_home_env_hum, hum_pct >= 0 ? (lv_coord_t)hum_pct : LV_CHART_POINT_NONE); } } static void relayoutHomeCharts() { if (!g_lv.home_env || !s_home_env_chart || !s_home_chart_legend) return; const bool home_land = lv_disp_get_hor_res(nullptr) > lv_disp_get_ver_res(nullptr); // makeHome() gives the tab 10 px of padding on each side. Stay inside that // content box; using the full screen width made the chart's top/right frame // look like a stray L drawn across the portrait screen. const int cw = tabContentW() - 20; const int BTNW = SC(100); const int RSTRIP = BTNW + 10; const int chart_w = home_land ? (cw - RSTRIP) : cw; // The env summary line is hard-placed at SC(58) in the tree, which collides with // the (up to two-line, WRAP) stats line on the narrow V4 portrait — the stats line // can reach ~SC(72). Flow the env line directly below the MEASURED stats bottom so // they never overlap; everything downstream (env chart, TX/RX chart, advert button) // already reflows off env_bottom + shrinks the chart to fit the tab. if (g_lv.home_stats) { lv_obj_update_layout(g_lv.home_stats); const lv_coord_t stats_bottom = lv_obj_get_y(g_lv.home_stats) + lv_obj_get_height(g_lv.home_stats); lv_obj_align(g_lv.home_env, LV_ALIGN_TOP_LEFT, 0, stats_bottom + SC(4)); } lv_obj_update_layout(g_lv.home_env); const lv_coord_t env_bottom = lv_obj_get_y(g_lv.home_env) + lv_obj_get_height(g_lv.home_env); const int env_chart_y = LV_MAX(SC(92), (int)env_bottom + SC(6)); lv_obj_set_size(s_home_env_chart, chart_w, SC(34)); lv_obj_set_pos(s_home_env_chart, 0, env_chart_y); const int legend_y = env_chart_y + SC(34) + SC(12); lv_obj_set_pos(s_home_chart_legend, 0, legend_y); const int home_avail = tabContentH() - 20; int chart_h = home_avail - (legend_y + 16) - 4 - (home_land ? 0 : (8 + 36)); if (chart_h > 96) chart_h = 96; if (chart_h < 28) chart_h = 28; #if CAP_LARGE_SCREEN if (s_ui_fscale > 100) chart_h = 0; else chart_h = 96; #endif if (s_home_chart) { lv_obj_set_size(s_home_chart, chart_w, chart_h); lv_obj_set_pos(s_home_chart, 0, legend_y + 16); } if (s_home_adv_btn && !home_land) { const int button_y = legend_y + 16 + chart_h + 8; const int button_gap = 8; const int button_w = (cw - button_gap) / 2; lv_obj_set_pos(s_home_adv_btn, 0, button_y); lv_obj_set_size(s_home_adv_btn, button_w, 36); if (g_lv.home_apps && lv_obj_is_valid(g_lv.home_apps)) { lv_obj_set_pos(g_lv.home_apps, button_w + button_gap, button_y); lv_obj_set_size(g_lv.home_apps, button_w, 36); } } } #endif // HAS_EXPANSION_KIT static void refreshStatusLabels() { if (!g_lv.task) return; #if defined(HAS_EXPANSION_KIT) localEnvHistoryMaybeSample(millis()); #endif // Global status bar updates every refresh — visible on every tab, so // it's not gated on home_active like the home tab's body widgets. updateGlobalStatusBar(); // Live-refresh the control-center drop-down while it's open. It's a top-layer // overlay (not a tab), so this periodic tick is what keeps its GPS line and the // CPU/RAM/PSRAM/IP line current — e.g. the IP appears/clears as Wi-Fi // connects/drops, without having to close and reopen the panel. if (s_cc_root) { if (s_cc_gps_label) setLabelIfChanged(s_cc_gps_label, gpsStatusStr(true)); #if defined(HAS_EXPANSION_KIT) if (s_cc_env_label) setLabelIfChanged(s_cc_env_label, localEnvStatusStr()); #endif if (s_cc_sys_label) { char cc_sys[72]; ccBuildSysInfo(cc_sys, sizeof cc_sys); setLabelIfChanged(s_cc_sys_label, cc_sys); } } uint16_t active_tab = 0xFFFF; if (g_lv.tabview) active_tab = lv_tabview_get_tab_act(g_lv.tabview); const bool home_active = (active_tab == HOME_TAB_INDEX); if (home_active) refreshHomeBattery(); // Push a TX/RX sample onto the home chart: delta packets since last tick. if (home_active && s_home_chart && s_home_chart_tx && s_home_chart_rx) { static uint32_t last_tx = 0; static uint32_t last_rx = 0; uint32_t cur_tx = the_mesh.getNumSentFlood() + the_mesh.getNumSentDirect(); uint32_t cur_rx = the_mesh.getNumRecvFlood() + the_mesh.getNumRecvDirect(); uint32_t dtx = cur_tx >= last_tx ? cur_tx - last_tx : 0; uint32_t drx = cur_rx >= last_rx ? cur_rx - last_rx : 0; last_tx = cur_tx; last_rx = cur_rx; lv_chart_set_next_value(s_home_chart, s_home_chart_tx, (lv_coord_t)dtx); lv_chart_set_next_value(s_home_chart, s_home_chart_rx, (lv_coord_t)drx); if (s_siginfo_chart && s_siginfo_chart_tx && s_siginfo_chart_rx) { lv_chart_set_next_value(s_siginfo_chart, s_siginfo_chart_tx, (lv_coord_t)dtx); lv_chart_set_next_value(s_siginfo_chart, s_siginfo_chart_rx, (lv_coord_t)drx); } // Auto-grow Y range (only grows — keeps the chart visually stable). int peak = (int)((dtx > drx) ? dtx : drx); if (peak > s_home_chart_max_y) { s_home_chart_max_y = peak + 4; lv_chart_set_range(s_home_chart, LV_CHART_AXIS_PRIMARY_Y, 0, s_home_chart_max_y); if (s_siginfo_chart) lv_chart_set_range(s_siginfo_chart, LV_CHART_AXIS_PRIMARY_Y, 0, s_home_chart_max_y); } // Legend: total counts since boot, plus the current y-range peak. { // In-box signal chip + the legend's traffic counts. const uint32_t sms = the_mesh.uiSignalMs(); char sigtxt[16]; if (sms == 0 || (millis() - sms) > 300000UL) snprintf(sigtxt, sizeof sigtxt, "Sig --"); else snprintf(sigtxt, sizeof sigtxt, "Sig %d dB", the_mesh.uiSignalSnrQ4() / 4); if (s_home_chart_sig) setLabelIfChanged(s_home_chart_sig, sigtxt); if (s_home_chart_legend) { char leg[28]; snprintf(leg, sizeof leg, "TX %u / RX %u", (unsigned)cur_tx, (unsigned)cur_rx); setLabelIfChanged(s_home_chart_legend, leg); } } } #if defined(HAS_EXPANSION_KIT) if (active_tab == SENSORS_TAB_INDEX) refreshSensorsTab(); #endif const bool settings_active = (active_tab == SETTINGS_TAB_INDEX); // Map tab: refresh the bottom info strip (self GPS + on-map count) only // when the tab is actually visible. Cheap, but no point doing it on every // tick if the user is somewhere else. if (active_tab == MAP_TAB_INDEX) { mapAutoFollowTick(); refreshMapInfoLabel(); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Reader (text browser): the fetch worker publishes text/status via s_reader_dirty. // The page is a full-screen overlay (any tab), so gate on the page being open. if (s_reader_dirty && s_reader_root) { s_reader_dirty = false; if (!s_reader_busy && s_reader_pending_url[0]) { char pending_url[sizeof s_reader_pending_url]; snprintf(pending_url, sizeof pending_url, "%s", s_reader_pending_url); s_reader_pending_url[0] = 0; readerStart(pending_url); } else { if (s_reader_status) lv_label_set_text(s_reader_status, s_reader_msg); if (!s_reader_busy) { // the fetch finished if (s_reader_ok) { // …with a page readerRenderBody(); readerSetAddrExpanded(false); // collapse the address bar -> fullscreen reading } else if (s_reader_home_missing) { // no optional SD home page: retain the normal URL-entry UX s_reader_home_missing = false; s_reader_hist_n = 0; s_reader_hist_pos = -1; s_reader_url[0] = 0; if (s_reader_text) s_reader_text[0] = 0; s_reader_nlinks = 0; if (s_reader_url_ta) lv_textarea_set_text(s_reader_url_ta, kReaderDefaultUrl); s_reader_pristine = true; readerRenderBody(); readerSetAddrExpanded(true); } else { // …with an error — SHOW it (don't sit on "Loading…") readerShowMessage(s_reader_msg[0] ? s_reader_msg : "Couldn't load page", 0xE0A0A0); readerSetAddrExpanded(true); // expand so the address bar + retry are reachable } } } } // Wi-Fi fetcher arrived a new tile — re-render the map so the freshly // downloaded tile appears. Only re-render if we're actually on the // map tab; otherwise let the next tab activation pick it up. if (s_tile_fetch_dirty && active_tab == MAP_TAB_INDEX) { // Coalesce: a Wi-Fi download burst sets this flag once per delivered tile. // Re-rendering on every flag would run back-to-back full-grid tile read+decode // passes on the UI thread. Cap to ~2.5x/s; leave the flag set so the next // eligible tick still picks up the latest tiles (≤400 ms late, fine mid-download). static uint32_t s_tile_render_ms = 0; const uint32_t nowm = millis(); if ((uint32_t)(nowm - s_tile_render_ms) >= 400) { s_tile_render_ms = nowm; s_tile_fetch_dirty = false; renderMapTiles(); renderMapMarkers(); } } // Line-of-sight worker result handoff (non-blocking). losPoll(); #endif if (g_lv.home_state && home_active) { /* Show which transport radios are up. Wi-Fi + BLE coexist now, so the BLE * glyph is appended whenever BLE is on (alongside the Wi-Fi IP / progress * hint). "Offline" is shown when neither radio is up. */ #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool ble_on = g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled(); const char* ble_suffix = ble_on ? " " LV_SYMBOL_BLUETOOTH : ""; char st[48]; if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(st, sizeof st, LV_SYMBOL_WIFI " %d.%d.%d.%d%s", ip[0], ip[1], ip[2], ip[3], ble_suffix); // Gate on what the USER asked for, not on the driver mode. Reading only // WiFi.getMode() meant that anything leaving the radio in STA made the home // screen announce "Starting…" forever with Wi-Fi switched off, which is // what #305 reported. The mode is still checked, so a genuinely idle STA is // not described as connecting either. } else if (wifiConfigGetRadioEnabled() && WiFi.getMode() == WIFI_STA) { const char* hint; switch (WiFi.status()) { case WL_IDLE_STATUS: hint = TR("Starting…"); break; case WL_NO_SSID_AVAIL: hint = TR("SSID not found"); break; case WL_CONNECT_FAILED: hint = TR("Auth failed"); break; case WL_CONNECTION_LOST: hint = TR("Link lost"); break; case WL_DISCONNECTED: hint = TR("Connecting…"); break; case WL_NO_SHIELD: hint = TR("Init…"); break; default: hint = TR("Connecting…"); break; } snprintf(st, sizeof st, LV_SYMBOL_WIFI " %s%s", hint, ble_suffix); } else if (ble_on) { snprintf(st, sizeof st, "%s", LV_SYMBOL_BLUETOOTH); } else { snprintf(st, sizeof st, "%s", TR("Offline")); } setLabelIfChanged(g_lv.home_state, st); #else setLabelIfChanged(g_lv.home_state, g_lv.task->hasConnection() ? "Connected" : "Disconnected"); #endif } if (g_lv.home_stats && home_active) { #if defined(ESP32) // Second line mirrors the control-center sysinfo: internal RAM + PSRAM, both // shown as % used. (Replaces the old duty-cycle meter line.) const size_t dram_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t dram_tot = heap_caps_get_total_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const size_t ps_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); const size_t ps_tot = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); const unsigned dram_pct = dram_tot ? (unsigned)(100 - (dram_free * 100 / dram_tot)) : 0; const unsigned ps_pct = ps_tot ? (unsigned)(100 - (ps_free * 100 / ps_tot)) : 0; char mem[40]; snprintf(mem, sizeof mem, TR("RAM %u%% \xC2\xB7 PSRAM %u%%"), dram_pct, ps_pct); setLabelIfChanged(g_lv.home_stats, mem); #else setLabelIfChanged(g_lv.home_stats, ""); #endif } #if defined(HAS_EXPANSION_KIT) if (g_lv.home_env && home_active) { char env[192]; buildHomeEnvSummary(env, sizeof env); if (env[0]) setLabelIfChanged(g_lv.home_env, env); else setLabelIfChanged(g_lv.home_env, ""); relayoutHomeCharts(); } #endif // Unread line (its own tappable row): mail icon + translated count. if (g_lv.home_unread && home_active) { char ubuf[24], uline[40]; snprintf(ubuf, sizeof ubuf, TR("Unread %d"), g_lv.task->getUnreadTotal()); snprintf(uline, sizeof uline, LV_SYMBOL_ENVELOPE " %s", ubuf); setLabelIfChanged(g_lv.home_unread, uline); } #if CAP_LARGE_SCREEN // Commander info panel (big screen): live values column. Keys are static (see makeHome); // these 8 lines line up 1:1 with Node / Region / Radio / Signal / Contacts / Channels / Battery / Uptime. if (s_home_info && home_active) { auto* pr = the_mesh.getNodePrefs(); const char* nm = g_lv.task->getNodeNameCstr(); if (!nm || !nm[0]) nm = "node"; const int snr = the_mesh.uiSignalSnrQ4() / 4; const uint16_t mv = g_lv.task->getBattMilliVolts(); const uint32_t up = millis() / 1000; char val[224]; snprintf(val, sizeof val, "%s\n%.3f MHz\nSF%u \xC2\xB7 BW%.0f \xC2\xB7 %ddBm\n%d dB\n%d\n%d\n%.2f V\n%uh %02um", nm, pr ? (double)pr->freq : 0.0, pr ? (unsigned)pr->sf : 0u, pr ? (double)pr->bw : 0.0, pr ? (int)pr->tx_power_dbm : 0, snr, the_mesh.getNumContacts(), the_mesh.getNumChannels(), mv / 1000.0, (unsigned)(up / 3600), (unsigned)((up % 3600) / 60)); setLabelIfChanged(s_home_info, val); } #endif if (g_lv.settings_status && settings_active) { char ss[80]; snprintf(ss, sizeof ss, TR("TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s"), onOff(g_lv.task->isTcpEnabled()), g_lv.task->hasBleCapability() ? onOff(g_lv.task->isBleEnabled()) : "n/a", g_lv.task->getWsConnectedCount(), onOff(g_lv.task->getGPSState()), g_lv.task->isBuzzerQuiet() ? "quiet" : "on"); setLabelIfChanged(g_lv.settings_status, ss); } if (settings_active || g_set_modal.root) refreshSettingsSectionSubtitles(); } // ============================================================ // Boot splash // Brief animated "MESHCOMOD" overlay shown on top of the home screen after // LVGL is up. Replaces the static "Loading..." moment so the device feels // like it's introducing itself. Auto-removes after ~1.7s; tap to dismiss. // ============================================================ static lv_obj_t* s_splash_root = nullptr; // WADAMESH mesh-mark artwork (native 140x84): two zig-zag strokes + 7 dots — the // brand logo. Static so the lv_line point-array pointers stay valid for the life // of the splash (lv_line keeps the pointer; it does not copy the points). static const lv_point_t s_wmark_top[5] = {{0,42},{35,0},{70,42},{105,0},{140,42}}; static const lv_point_t s_wmark_bot[5] = {{0,42},{35,84},{70,42},{105,84},{140,42}}; static const lv_point_t s_wmark_dots[7] = {{0,42},{70,42},{140,42},{35,0},{105,0},{35,84},{105,84}}; static void splashSetOpa(void* var, int32_t v) { lv_obj_set_style_opa(static_cast<lv_obj_t*>(var), static_cast<lv_opa_t>(v), LV_PART_MAIN); } static void splashRemove() { if (!s_splash_root) return; lv_obj_del(s_splash_root); s_splash_root = nullptr; } static void splashFadeOutReady(lv_anim_t* a) { (void)a; splashRemove(); } static void splashHoldThenFadeOut(lv_timer_t* t) { lv_timer_del(t); if (!s_splash_root) return; // Fade the whole splash out together; ready_cb on the last anim removes the overlay. lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, s_splash_root); lv_anim_set_time(&a, 350); lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_TRANSP); lv_anim_set_exec_cb(&a, splashSetOpa); lv_anim_set_path_cb(&a, lv_anim_path_ease_in); lv_anim_set_ready_cb(&a, splashFadeOutReady); lv_anim_start(&a); } static void splashTapDismissCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; splashRemove(); } static void buildBootSplash() { // Full-screen opaque card on lv_layer_sys (above lv_layer_top, where the status // bar lives) so it covers EVERYTHING at boot — including the app drawer, which // auto-opens on top of lv_layer_top when Home defaults to drawer mode and would // otherwise render in front of a lv_layer_top splash. Same dark background as the // rest of the UI so we don't flash a different palette to the user. s_splash_root = lv_obj_create(lv_layer_sys()); lv_obj_remove_style_all(s_splash_root); lv_obj_set_size(s_splash_root, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr)); lv_obj_set_pos(s_splash_root, 0, 0); lv_obj_set_style_bg_color(s_splash_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_splash_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(s_splash_root, 0, LV_PART_MAIN); lv_obj_clear_flag(s_splash_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_splash_root, LV_OBJ_FLAG_FLOATING); lv_obj_move_foreground(s_splash_root); lv_obj_add_event_cb(s_splash_root, splashTapDismissCb, LV_EVENT_CLICKED, nullptr); // ---- WADAMESH logo: the mesh mark (teal dots) + the wordmark ---- // The pre-LVGL boot window paints the plain "WADAMESH" wordmark; this beat // shows the brand logo in full colour — the mesh strokes with the teal dots — // then names the build channel beneath. Fades up as the next boot beat. // Mesh mark: two zig-zag strokes (off-white) + 7 brand-teal dots. Native // artwork is 140x84; it sits in a 160x100 box (a ~10px margin) so the dots // that overhang the stroke vertices aren't clipped at the container edge. lv_obj_t* mark = lv_obj_create(s_splash_root); lv_obj_remove_style_all(mark); lv_obj_set_size(mark, 160, 100); lv_obj_align(mark, LV_ALIGN_CENTER, 0, 0); // dead-centre; matches the pre-LVGL mark lv_obj_clear_flag(mark, LV_OBJ_FLAG_SCROLLABLE); // Full opacity from the first painted frame (NOT faded in): the pre-LVGL boot // screen already shows this exact mark in white at the same position, so the // splash's first frame must land the colour mark in-place — only the dots flip // white->teal. Fading the mark up from black is what flashed between the two. lv_obj_set_style_opa(mark, LV_OPA_COVER, LV_PART_MAIN); for (int li = 0; li < 2; ++li) { lv_obj_t* ln = lv_line_create(mark); lv_line_set_points(ln, li ? s_wmark_bot : s_wmark_top, 5); lv_obj_set_pos(ln, 10, 8); lv_obj_set_style_pad_all(ln, 0, LV_PART_MAIN); lv_obj_set_style_line_width(ln, 5, LV_PART_MAIN); lv_obj_set_style_line_color(ln, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_line_rounded(ln, true, LV_PART_MAIN); } for (int di = 0; di < 7; ++di) { lv_obj_t* dot = lv_obj_create(mark); lv_obj_remove_style_all(dot); lv_obj_set_size(dot, 13, 13); lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(dot, lv_color_hex(0x15B6A6), LV_PART_MAIN); // brand teal lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_pos(dot, s_wmark_dots[di].x + 4, s_wmark_dots[di].y + 2); // centre the 13px dot on the vertex } // Wordmark: "WADA" off-white, "MESH" in brand teal (label recolor). UNSCII_16 // keeps the pixel/mono feel as a continuation of the early boot screen. lv_obj_t* wm = lv_label_create(s_splash_root); lv_label_set_recolor(wm, true); lv_label_set_text(wm, TR("WADA#15B6A6 MESH#")); lv_obj_set_style_text_font(wm, &lv_font_unscii_16, LV_PART_MAIN); lv_obj_set_style_text_color(wm, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_text_letter_space(wm, 3, LV_PART_MAIN); lv_obj_align(wm, LV_ALIGN_CENTER, 0, 60); // below the now-centred mark lv_obj_set_style_opa(wm, LV_OPA_TRANSP, LV_PART_MAIN); // ---- Product line: MESHCOMOD (small, same size as the channel line, between // the WADAMESH wordmark and TOUCH BETA) ---- lv_obj_t* mc = lv_label_create(s_splash_root); lv_label_set_text(mc, "WADAMESH"); // #165: the unlock overlay still said MESHCOMOD after the rebrand lv_obj_set_style_text_font(mc, &lv_font_unscii_8, LV_PART_MAIN); lv_obj_set_style_text_color(mc, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_letter_space(mc, 4, LV_PART_MAIN); lv_obj_align(mc, LV_ALIGN_CENTER, 0, 78); lv_obj_set_style_opa(mc, LV_OPA_TRANSP, LV_PART_MAIN); // ---- Subtitle: build channel ---- lv_obj_t* sub = lv_label_create(s_splash_root); lv_label_set_text(sub, TR("TOUCH BETA")); lv_obj_set_style_text_font(sub, &lv_font_unscii_8, LV_PART_MAIN); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_letter_space(sub, 4, LV_PART_MAIN); lv_obj_align(sub, LV_ALIGN_CENTER, 0, 92); lv_obj_set_style_opa(sub, LV_OPA_TRANSP, LV_PART_MAIN); // (No mark fade — it's shown at full opacity above, replacing the identical // white pre-LVGL mark in-place so there's no flash at the hand-off.) // Phase 2: the wordmark fades in, slightly delayed. lv_anim_t aw; lv_anim_init(&aw); lv_anim_set_var(&aw, wm); lv_anim_set_time(&aw, 450); lv_anim_set_delay(&aw, 300); lv_anim_set_values(&aw, LV_OPA_TRANSP, LV_OPA_COVER); lv_anim_set_exec_cb(&aw, splashSetOpa); lv_anim_set_path_cb(&aw, lv_anim_path_ease_out); lv_anim_start(&aw); // Phase 2.5: the MESHCOMOD product line fades in between wordmark + subtitle. lv_anim_t am; lv_anim_init(&am); lv_anim_set_var(&am, mc); lv_anim_set_time(&am, 450); lv_anim_set_delay(&am, 450); lv_anim_set_values(&am, LV_OPA_TRANSP, LV_OPA_COVER); lv_anim_set_exec_cb(&am, splashSetOpa); lv_anim_set_path_cb(&am, lv_anim_path_ease_out); lv_anim_start(&am); // Phase 3: subtitle fades in last. lv_anim_t as; lv_anim_init(&as); lv_anim_set_var(&as, sub); lv_anim_set_time(&as, 450); lv_anim_set_delay(&as, 600); lv_anim_set_values(&as, LV_OPA_TRANSP, LV_OPA_70); lv_anim_set_exec_cb(&as, splashSetOpa); lv_anim_set_path_cb(&as, lv_anim_path_ease_out); lv_anim_start(&as); // Hold after the last fade-in completes (subtitle ends at ~t=1050 ms, // fade-out starts at t=2400), then trigger fade-out. A shorter timer made // the splash feel rushed — barely time to register the logo before dismiss. lv_timer_t* t = lv_timer_create(splashHoldThenFadeOut, 2400, nullptr); lv_timer_set_repeat_count(t, 1); } // ============================================================ // First-boot setup wizard // ============================================================ // A one-time, full-screen flow shown on a fresh flash (gated by // touchPrefsGetSetupDone): Welcome -> Name -> Region -> Wi-Fi. It reuses the // existing setters (setNodeName, setRadioParams, wifiConfig*) so it's a thin UI // over machinery that already works. Name + region are committed as the user // advances; Wi-Fi (optional) is committed on Finish, which then reboots once so // the radio re-inits with the chosen region and Wi-Fi associates. static const lv_coord_t kSetupBtnH = 40; static void setupShowStep(int step); // fwd: nav callbacks below call back into it static void setupFillRegionList(); // fwd: built by the region step + recoloured on tap static lv_obj_t* setupBtn(const char* txt, lv_event_cb_t cb, bool primary, lv_coord_t x, lv_coord_t y, lv_coord_t w) { lv_obj_t* b = lv_btn_create(s_setup_root); lv_obj_set_size(b, w, kSetupBtnH); lv_obj_set_pos(b, x, y); styleButton(b); if (primary) { lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(0x3B7039), LV_PART_MAIN | LV_STATE_PRESSED); } lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, TR(txt)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); return b; } // Title + optional "Step N of 3" tag + optional wrapped blurb. Returns the y // just below the header so the step body can stack under it. static int setupHeader(const char* title, const char* blurb, const char* step_tag) { const lv_coord_t sw = lv_disp_get_hor_res(nullptr); lv_obj_t* t = lv_label_create(s_setup_root); lv_label_set_text(t, TR(title)); lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); lv_obj_set_width(t, sw - 84); lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(t, 12, 12); int y = 12 + 24; if (step_tag && step_tag[0]) { lv_obj_t* s = lv_label_create(s_setup_root); lv_label_set_text(s, step_tag); lv_obj_set_style_text_font(s, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(s, LV_ALIGN_TOP_RIGHT, -12, 16); } if (blurb && blurb[0]) { lv_obj_t* b = lv_label_create(s_setup_root); lv_label_set_text(b, TR(blurb)); lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP); lv_obj_set_width(b, sw - 24); lv_obj_set_style_text_font(b, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(b, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(b, 12, y + 2); lv_obj_update_layout(b); // resolve wrap height (1 or 2 lines) y += 2 + lv_obj_get_height(b) + 4; } return y; } // Mark setup complete and tear the overlay down, revealing the live OS beneath. // No reboot — used for Skip and (implicitly) anything that doesn't change the // radio/Wi-Fi. The status bar (hidden while the wizard owns the screen) returns. static void setupWizardClose() { g_set_modal.wifi_ssid_ta = nullptr; // we borrowed these for the scan popup g_set_modal.wifi_pwd_ta = nullptr; hideKb(); if (s_setup_root) { popupClose(&s_setup_root); } s_setup_name_ta = nullptr; s_setup_region_list = nullptr; s_setup_region_next_btn = nullptr; s_setup_ssid_ta = nullptr; s_setup_pwd_ta = nullptr; if (g_statusbar.root) lv_obj_clear_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); } static void setupSkipCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; touchPrefsSetSetupDone(true); // don't show it again on next boot setupWizardClose(); if (g_lv.task) g_lv.task->showAlert(TR("You can run setup later in Settings \xE2\x86\x92 Device"), 2200); } static void setupGetStartedCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) setupShowStep(1); } static void setupBackCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_setup_step > 0) setupShowStep(s_setup_step - 1); } static void setupNameNextCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); // flush the on-screen-keyboard mirror into the field if (s_setup_name_ta) { const char* name = lv_textarea_get_text(s_setup_name_ta); if (!name || !name[0]) { if (g_lv.task) g_lv.task->showAlert(TR("Enter a name"), 1200); return; } if (g_lv.task) g_lv.task->setNodeName(name); } setupShowStep(2); } static void setupRegionNextCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_setup_region_sel >= 0 && s_setup_region_sel < (int)k_mesh_radio_preset_count && g_lv.task) { const MeshRadioPreset& p = k_mesh_radio_presets[s_setup_region_sel]; g_lv.task->setRadioParams(p.freq_mhz, p.bw_khz, p.sf, p.cr, p.tx_dbm, static_cast<float>(p.airtime_limit_pct) / 100.0f); } setupShowStep(3); } static void setupFinishCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; kbMirrorSyncToReal(); touchPrefsSetSetupDone(true); // mark done so the wizard won't reappear on next boot #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) if (s_setup_ssid_ta) { char ssid[WIFI_CONFIG_SSID_MAX]; char pwd[WIFI_CONFIG_PWD_MAX]; strncpy(ssid, lv_textarea_get_text(s_setup_ssid_ta), sizeof(ssid) - 1); ssid[sizeof(ssid) - 1] = '\0'; strncpy(pwd, s_setup_pwd_ta ? lv_textarea_get_text(s_setup_pwd_ta) : "", sizeof(pwd) - 1); pwd[sizeof(pwd) - 1] = '\0'; // Trim stray spaces (a leading/trailing space in the SSID = "AP not found" // and the device sits forever in "connecting…"). Mirrors saveWifiCb. trimWifiField(ssid); trimWifiField(pwd); if (ssid[0]) { // empty SSID = user skipped Wi-Fi wifiConfigSetSsid(ssid); wifiConfigSetPwd(pwd); wifiConfigSetRadioEnabled(true); wifiConfigRequestApply(); } } #endif // Region was applied live when the user advanced past the region step // (setRadioParams -> applyRadioFromPrefs). Close the wizard; the Wi-Fi apply // request reconnects live; Pager temporarily releases BLE to preserve order. setupWizardClose(); if (g_lv.task) g_lv.task->showAlert(TR("Setup complete"), 1400); } // Tap a region row: select it + recolour in place (keeps the scroll position). static void setupRegionRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); #if CAP_KEYPAD_NAV const bool was_already_sel = (idx == s_setup_region_sel); #endif s_setup_region_sel = idx; if (!s_setup_region_list) return; const uint32_t n = lv_obj_get_child_cnt(s_setup_region_list); for (uint32_t i = 0; i < n; ++i) { lv_obj_t* c = lv_obj_get_child(s_setup_region_list, i); if (c) lv_obj_set_style_bg_color( c, lv_color_hex((int)i == idx ? COLOR_STATUS_OK : 0x1A1B1C), LV_PART_MAIN); } #if CAP_KEYPAD_NAV // With 20 region presets in this list, walking NEXT one detent at a time past // every remaining row just to reach Next/Back is impractical on a keypad/rotary // -only board (reported: picking a region left no perceived way to advance). // But auto-jumping to Next on the FIRST click (the original fix) moved focus // off the list before the colour change was even visible to confirm which // row got picked (reported: "I couldn't be certain I selected the correct // one"). Two-step now: the first click on a row just selects/recolours it // and focus stays put; only a SECOND click on the row that's ALREADY // selected — an explicit confirm — jumps focus to Next. if (was_already_sel && s_nav_group && s_setup_region_next_btn) { lv_group_focus_obj(s_setup_region_next_btn); s_nav_show = true; } #endif } static void setupFillRegionList() { if (!s_setup_region_list) return; lv_obj_clean(s_setup_region_list); const lv_coord_t rw = lv_disp_get_hor_res(nullptr) - 24 - 12; // list width minus pad/scrollbar for (int i = 0; i < (int)k_mesh_radio_preset_count; ++i) { lv_obj_t* r = lv_btn_create(s_setup_region_list); lv_obj_set_size(r, rw, 34); styleButton(r); lv_obj_set_style_bg_color(r, lv_color_hex(i == s_setup_region_sel ? COLOR_STATUS_OK : 0x1A1B1C), LV_PART_MAIN); lv_obj_add_event_cb(r, setupRegionRowCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* l = lv_label_create(r); lv_label_set_text(l, k_mesh_radio_presets[i].label); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); lv_obj_set_width(l, rw - 16); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_align(l, LV_ALIGN_LEFT_MID, 8, 0); } } static void setupShowStep(int step) { if (!s_setup_root) return; // Unconditional, like every other navMarkDirty() call site in this file — it's a real // rebuild-flag on any CAP_KEYPAD_NAV board (Tanmatsu, M9) and a no-op stub otherwise // (see the two definitions near navMaybeRebuild). Previously gated to HAS_TANMATSU only, // which meant M9 skipped it: lv_obj_clean() below frees the step's buttons/textarea, and // LVGL's allocator can hand the freshly-created replacements the SAME addresses — so // navMaybeRebuild()'s address-based tree signature can come out unchanged and skip the // rebuild, leaving s_nav_group holding stale objects. That's the "Back/Next does nothing, // then navigation is dead" symptom. navMarkDirty(); // wizard step content swaps in place — force the keypad focus group to rebuild hideKb(); // The scan popup writes the chosen SSID through these borrowed pointers; drop // them before the Wi-Fi fields are freed by the clean below. g_set_modal.wifi_ssid_ta = nullptr; g_set_modal.wifi_pwd_ta = nullptr; lv_obj_clean(s_setup_root); s_setup_name_ta = nullptr; s_setup_region_list = nullptr; s_setup_region_next_btn = nullptr; s_setup_ssid_ta = nullptr; s_setup_pwd_ta = nullptr; s_setup_step = step; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const lv_coord_t btn_y = sh - kSetupBtnH - 10; if (step == 0) { setupHeader(TR("Welcome to WADAMESH"), nullptr, nullptr); lv_obj_t* m = lv_label_create(s_setup_root); lv_label_set_text(m, TR("Let's set up your device.\n\n" "You'll pick a name and choose your LoRa region. Takes about a minute.")); lv_label_set_long_mode(m, LV_LABEL_LONG_WRAP); lv_obj_set_width(m, sw - 24); lv_obj_set_style_text_font(m, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(m, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(m, 12, 46); setupBtn(TR("Skip"), setupSkipCb, false, 12, btn_y, 96); setupBtn(TR("Get Started"), setupGetStartedCb, true, 12 + 96 + 8, btn_y, sw - 24 - 96 - 8); } else if (step == 1) { int y = setupHeader(TR("Choose your name"), TR("How you'll appear to other nodes. You can change this later in Settings."), TR("Step 1 of 3")); s_setup_name_ta = lv_textarea_create(s_setup_root); lv_obj_set_size(s_setup_name_ta, sw - 24, 36); lv_obj_set_pos(s_setup_name_ta, 12, y + 6); lv_textarea_set_one_line(s_setup_name_ta, true); taSetPlaceholder(s_setup_name_ta, TR("Your name")); lv_textarea_set_max_length(s_setup_name_ta, 30); if (g_lv.task) { const char* cur = g_lv.task->getNodeNameCstr(); if (cur && cur[0]) lv_textarea_set_text(s_setup_name_ta, cur); } attachSettingsTaEvents(s_setup_name_ta); setupBtn(TR("Back"), setupBackCb, false, 12, btn_y, 72); setupBtn(TR("Next"), setupNameNextCb, true, sw - 12 - 120, btn_y, 120); } else if (step == 2) { int y = setupHeader(TR("Choose your region"), TR("Every node you talk to must match. You can change this later in Settings."), TR("Step 2 of 3")); s_setup_region_list = lv_obj_create(s_setup_root); lv_obj_remove_style_all(s_setup_region_list); lv_obj_set_size(s_setup_region_list, sw - 24, btn_y - (y + 6) - 8); lv_obj_set_pos(s_setup_region_list, 12, y + 6); lv_obj_set_flex_flow(s_setup_region_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(s_setup_region_list, 4, LV_PART_MAIN); lv_obj_set_scroll_dir(s_setup_region_list, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_setup_region_list, LV_SCROLLBAR_MODE_AUTO); if (s_setup_region_sel < 0) s_setup_region_sel = findMatchingMeshRadioPreset(the_mesh.getNodePrefs()); setupFillRegionList(); setupBtn(TR("Back"), setupBackCb, false, 12, btn_y, 72); s_setup_region_next_btn = setupBtn(TR("Next"), setupRegionNextCb, true, sw - 12 - 120, btn_y, 120); } else { int y = setupHeader(TR("Wi-Fi & Bluetooth"), nullptr, TR("Step 3 of 3")); lv_obj_t* m = lv_label_create(s_setup_root); lv_label_set_text(m, TR("This device can run Wi-Fi and Bluetooth at once, as a standalone radio and a " "phone companion (MeshCore app) together.\n\n" "Running both at the same time uses more RAM, so turn on only what you need.\n\n" "Set them up anytime in Settings.")); lv_label_set_long_mode(m, LV_LABEL_LONG_WRAP); lv_obj_set_width(m, sw - 24); lv_obj_set_style_text_font(m, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(m, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(m, 12, y + 6); setupBtn(TR("Back"), setupBackCb, false, 12, btn_y, 72); setupBtn(TR("Finish"), setupFinishCb, true, sw - 12 - 120, btn_y, 120); } } static void setupWizardOpen() { if (s_setup_root) return; if (g_statusbar.root) lv_obj_add_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); // own the whole screen const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_setup_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_setup_root); lv_obj_set_size(s_setup_root, sw, sh); lv_obj_set_pos(s_setup_root, 0, 0); styleSurface(s_setup_root, COLOR_BG, 0); lv_obj_set_style_pad_all(s_setup_root, 0, LV_PART_MAIN); lv_obj_clear_flag(s_setup_root, LV_OBJ_FLAG_SCROLLABLE); s_setup_region_sel = -1; setupShowStep(0); lv_obj_move_foreground(s_setup_root); } // Called once at the end of UITask::begin: show the wizard on a fresh flash. static void setupWizardMaybeOpen() { if (touchPrefsGetSetupDone()) return; // First-boot setup is Wi-Fi-only: force Bluetooth OFF and persist the choice so // it stays off after setup too — the user opts BLE in later via the control // center. BLE co-inits at boot from the default-on pref; tear it down here, // before the UI is interactive, so the user only ever sees Wi-Fi during setup. // (Already-set-up devices return above, so their BLE state is left untouched.) #if defined(ESP32) wifiConfigSetBleEnabled(false); if (g_lv.task && g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled()) g_lv.task->disableBle(); #endif setupWizardOpen(); } // "Run setup again" button in Device settings: re-open the flow over the live // UI. Finishing reboots (to apply region/Wi-Fi); Skip just closes again. static void setupRerunCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; setupWizardOpen(); } // ============================================================ // Theme accent colour: live apply + (wheel + hex) picker // ============================================================ static void applyAccent(uint32_t rgb) { COLOR_ACCENT = accentClampReadable(rgb); COLOR_ACCENT_PRESS = accentDarken(COLOR_ACCENT, 65); // Applied UI-wide on the next build: the Theme picker saves then restarts, so // every widget adopts the colour at once. (A live re-style only ever caught // the always-on tab bar, which is what looked half-applied before.) } // Theme apply callback: runs for every object as it's created (chained after the // default theme via lv_theme_set_parent). The stock theme paints the switch // "on" indicator with its blue primary; recolour it to the user's accent so all // toggles follow the Theme colour instead of being hard-coded blue. Reads the // live COLOR_ACCENT (set by applyAccent at boot before any widget is built), and // a local style override beats the theme's shared style. Knob stays white. static void touchThemeApplyCb(lv_theme_t* /*th*/, lv_obj_t* obj) { if (lv_obj_check_type(obj, &lv_switch_class)) { lv_obj_set_style_bg_color(obj, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR | LV_STATE_CHECKED); } } static lv_theme_t s_touch_theme; // our wrapper theme (parent = stock default) // Curated accent palette for the swatch grid: WADAMESH brand teal (default, // the logo dots) first, then stock grey + a colour spread. static const uint32_t kThemeColors[] = { 0x15B6A6, 0x57585A, 0x3B82F6, 0x2DA8A0, 0x3FA34D, 0x8B5CF6, 0xD2569E, 0xD0524A, 0xE0823C, 0xC8A030, 0x5B6BD0, 0xA85AB0, }; static lv_obj_t* s_accent_picker = nullptr; static lv_obj_t* s_accent_hex_ta = nullptr; static lv_obj_t* s_accent_preview = nullptr; static uint32_t s_accent_sel = 0x15B6A6u; // currently-selected colour (default: brand teal) static bool s_accent_syncing = false; static void accentPreviewShow(uint32_t rgb) { if (s_accent_preview) lv_obj_set_style_bg_color(s_accent_preview, lv_color_hex(accentClampReadable(rgb)), LV_PART_MAIN); } // "RRGGBB" -> rgb, or 0xFFFFFFFF if not exactly 6 hex digits. static uint32_t accentParseHex(const char* t) { uint32_t rgb = 0; int n = 0; for (const char* p = t; *p; ++p) { int v; char ch = *p; if (ch>='0'&&ch<='9') v = ch-'0'; else if (ch>='a'&&ch<='f') v = ch-'a'+10; else if (ch>='A'&&ch<='F') v = ch-'A'+10; else return 0xFFFFFFFFu; rgb = (rgb<<4)|v; n++; } return (n == 6) ? (rgb & 0xFFFFFFu) : 0xFFFFFFFFu; } static void accentSetSelection(uint32_t rgb, bool update_hex) { s_accent_sel = rgb & 0xFFFFFFu; accentPreviewShow(s_accent_sel); if (update_hex && s_accent_hex_ta) { s_accent_syncing = true; char hx[8]; snprintf(hx, sizeof hx, "%06X", (unsigned)s_accent_sel); lv_textarea_set_text(s_accent_hex_ta, hx); s_accent_syncing = false; } } static void accentSwatchCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) accentSetSelection((uint32_t)(uintptr_t)lv_event_get_user_data(e), true); } static void accentHexCb(lv_event_t* e) { if (s_accent_syncing || !s_accent_hex_ta) return; uint32_t rgb = accentParseHex(lv_textarea_get_text(s_accent_hex_ta)); if (rgb != 0xFFFFFFFFu) accentSetSelection(rgb, false); // don't fight the typist } static void accentPickerClose() { if (s_accent_picker) { popupClose(&s_accent_picker); } s_accent_hex_ta = s_accent_preview = nullptr; } static void accentPickerCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) accentPickerClose(); } static void accentSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; applyAccent(s_accent_sel); #if defined(ESP32) touchPrefsSetAccentColor(s_accent_sel); #endif accentPickerClose(); // The accent is read by every widget as it's built, so restart to recolour the // whole UI in one go (the button says "Save & restart"). if (g_lv.task) g_lv.task->rebootDevice(); // saves chat history first } static void accentResetCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) accentSetSelection(0x15B6A6u, true); } static void openAccentPicker() { accentPickerClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_accent_picker = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_accent_picker); lv_obj_set_size(s_accent_picker, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_accent_picker, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_accent_picker, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_accent_picker, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_flex_flow(s_accent_picker, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(s_accent_picker, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(s_accent_picker, 4, LV_PART_MAIN); lv_obj_set_style_pad_top(s_accent_picker, 3, LV_PART_MAIN); lv_obj_set_scroll_dir(s_accent_picker, LV_DIR_VER); lv_obj_t* title = lv_label_create(s_accent_picker); lv_label_set_text(title, TR("Theme colour")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_t* close = lv_btn_create(s_accent_picker); lv_obj_add_flag(close, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_set_size(close, 30, 26); lv_obj_align(close, LV_ALIGN_TOP_RIGHT, -6, 2); styleButton(close); lv_obj_add_event_cb(close, accentPickerCloseCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); // Swatch grid: tap a colour (far friendlier on a touchscreen than a wheel). lv_obj_t* grid = lv_obj_create(s_accent_picker); lv_obj_remove_style_all(grid); lv_obj_set_size(grid, sw - 24, LV_SIZE_CONTENT); lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(grid, 8, LV_PART_MAIN); lv_obj_set_style_pad_column(grid, 8, LV_PART_MAIN); lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE); for (int i = 0; i < (int)(sizeof(kThemeColors)/sizeof(kThemeColors[0])); ++i) { lv_obj_t* sb = lv_btn_create(grid); lv_obj_set_size(sb, 28, 28); lv_obj_set_style_radius(sb, 7, LV_PART_MAIN); lv_obj_set_style_bg_color(sb, lv_color_hex(kThemeColors[i]), LV_PART_MAIN); lv_obj_set_style_bg_opa(sb, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(sb, 1, LV_PART_MAIN); lv_obj_set_style_border_color(sb, lv_color_hex(0x202224), LV_PART_MAIN); lv_obj_add_event_cb(sb, accentSwatchCb, LV_EVENT_CLICKED, (void*)(uintptr_t)kThemeColors[i]); } lv_obj_t* hexrow = lv_obj_create(s_accent_picker); lv_obj_remove_style_all(hexrow); lv_obj_set_size(hexrow, 150, 30); lv_obj_t* hash = lv_label_create(hexrow); useChainedFont(hash); lv_label_set_text(hash, "#"); lv_obj_set_style_text_color(hash, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(hash, LV_ALIGN_LEFT_MID, 2, 0); s_accent_hex_ta = lv_textarea_create(hexrow); lv_textarea_set_one_line(s_accent_hex_ta, true); lv_textarea_set_max_length(s_accent_hex_ta, 6); lv_textarea_set_accepted_chars(s_accent_hex_ta, "0123456789abcdefABCDEF"); lv_obj_set_size(s_accent_hex_ta, 120, 28); lv_obj_align(s_accent_hex_ta, LV_ALIGN_LEFT_MID, 16, 0); lv_obj_add_event_cb(s_accent_hex_ta, accentHexCb, LV_EVENT_VALUE_CHANGED, nullptr); attachSettingsTaEvents(s_accent_hex_ta); s_accent_preview = lv_obj_create(s_accent_picker); lv_obj_remove_style_all(s_accent_preview); lv_obj_set_size(s_accent_preview, 150, 28); lv_obj_set_style_radius(s_accent_preview, 6, LV_PART_MAIN); lv_obj_set_style_bg_opa(s_accent_preview, LV_OPA_COVER, LV_PART_MAIN); lv_obj_t* pv = lv_label_create(s_accent_preview); useChainedFont(pv); lv_label_set_text(pv, TR("Sample text")); lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(pv); // Buttons size to their label instead of to 124/84 px. Those numbers were // measured against "Save & restart" / "Reset"; Hungarian's // "Mentés és újraindítás" is half again as long and ran off both edges of its // button (#307). A wrapping row means an over-long pair drops onto two lines // rather than clipping, and the picker is a scrollable flex column so the // extra height costs nothing. max_width keeps a single very long label inside // the card instead of pushing the row wider than the screen. lv_obj_t* btnrow = lv_obj_create(s_accent_picker); lv_obj_remove_style_all(btnrow); lv_obj_set_width(btnrow, sw - 24); lv_obj_set_height(btnrow, LV_SIZE_CONTENT); lv_obj_set_flex_flow(btnrow, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_flex_align(btnrow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(btnrow, 8, LV_PART_MAIN); lv_obj_set_style_pad_row(btnrow, 6, LV_PART_MAIN); lv_obj_clear_flag(btnrow, LV_OBJ_FLAG_SCROLLABLE); auto sized_btn = [&](lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(btnrow); lv_obj_set_size(b, LV_SIZE_CONTENT, 30); lv_obj_set_style_max_width(b, LV_PCT(100), LV_PART_MAIN); lv_obj_set_style_pad_hor(b, 12, LV_PART_MAIN); styleButton(b); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); return b; }; lv_obj_t* save = sized_btn(accentSaveCb); lv_obj_t* sl = lv_label_create(save); lv_label_set_text(sl, TR("Save & restart")); lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_center(sl); useChainedFont(sl); lv_obj_t* rst = sized_btn(accentResetCb); lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, TR("Reset")); lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_center(rl); useChainedFont(rl); accentSetSelection(touchPrefsGetAccentColor(), true); } static void openAccentPickerCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openAccentPicker(); } // ============================================================ // Per-channel region scope (status-bar gear -> this modal) // ============================================================ static lv_obj_t* s_chanscope_modal = nullptr; static lv_obj_t* s_chanscope_ta = nullptr; static int s_chanscope_slot = -1; static char s_chanscope_name[40] = {0}; // channel name (key for the per-channel mute pref) static void chanScopeClose() { if (s_chanscope_modal) { popupClose(&s_chanscope_modal); } s_chanscope_ta = nullptr; } static bool chanScopeIsOpen() { return s_chanscope_modal != nullptr; } static void chanScopeCloseCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) chanScopeClose(); } // "Mute notification sound" toggle in the Channel settings sheet — flips the per-channel // message-mute bit (name-keyed; the RX-sound path at the bottom of loop() already honours // it). Lets you silence e.g. a busy test channel while the public one still chimes. static void chanScopeMuteCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; if (!s_chanscope_name[0]) return; const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); uint8_t f = touchPrefsGetChannelMute(s_chanscope_name); f = on ? (uint8_t)(f | TOUCH_CHMUTE_MSG) : (uint8_t)(f & ~TOUCH_CHMUTE_MSG); touchPrefsSetChannelMute(s_chanscope_name, f); } static void chanScopeSaveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_chanscope_slot >= 0 && s_chanscope_ta) { const char* t = lv_textarea_get_text(s_chanscope_ta); #if defined(ESP32) touchPrefsSetChannelScope(s_chanscope_slot, t ? t : ""); // #271: register it now rather than at next boot, so messages arriving in // this region are named from the next packet on. Idempotent; the old region // keeps its slot, since a slot outlives the config that introduced it (old // messages must keep resolving to the region they actually arrived under). if (t && t[0]) the_mesh.regionRegistry().ensureRegion(t); #endif } chanScopeClose(); if (g_lv.task) g_lv.task->showAlert(TR("Channel scope saved"), 1200); } // ---- Blocked-users (ignore-list) manager ---------------------------------- static lv_obj_t* s_blocked_modal = nullptr; static uint8_t s_blocked_snap[TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES]; static char s_blocked_names_snap[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]; static void blockedModalClose() { if (s_blocked_modal) { popupClose(&s_blocked_modal); } if (s_apppage_close == blockedModalClose) { // release the tall title bar back to normal s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static bool blockedModalIsOpen() { return s_blocked_modal != nullptr; } // Unblock is PAIRED with the block: blocking a channel sender who is also a saved // contact writes BOTH a name entry (what the channel RX path matches) and a pubkey // entry (what the DM path matches), so clearing only one would leave the sender // still silenced on the other path — an "I unblocked them and they are still gone" // bug. Each callback therefore clears its own entry and its twin. static void blockedUnblockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_MAX) return; uint8_t pub6[TOUCH_IGNORE_KEY_BYTES]; memcpy(pub6, &s_blocked_snap[idx * TOUCH_IGNORE_KEY_BYTES], sizeof pub6); touchPrefsSetIgnored(pub6, false); // Twin: this pubkey's contact name may also hold a name entry (same prefix match // the modal uses to label the row). ContactInfo c; const int nc = the_mesh.getNumContacts(); for (int j = 0; j < nc; ++j) { if (the_mesh.getContactByIdx((uint32_t)j, c) && memcmp(c.id.pub_key, pub6, TOUCH_IGNORE_KEY_BYTES) == 0) { if (c.name[0]) touchPrefsSetNameIgnored(c.name, false); break; } } openBlockedUsersModal(); // rebuild the list } static void blockedUnblockNameCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_NAMES_MAX) return; char nm[TOUCH_IGNORED_NAME_LEN]; snprintf(nm, sizeof nm, "%s", &s_blocked_names_snap[idx * TOUCH_IGNORED_NAME_LEN]); touchPrefsSetNameIgnored(nm, false); // Twin: a saved contact with this name may also hold a pubkey entry. ContactInfo c; const int nc = the_mesh.getNumContacts(); for (int j = 0; j < nc; ++j) { if (the_mesh.getContactByIdx((uint32_t)j, c) && strcmp(c.name, nm) == 0) { touchPrefsSetIgnored(c.id.pub_key, false); break; } } openBlockedUsersModal(); // rebuild the list } // ---- Known regions manager (#271) ------------------------------------------- // The list that makes scope naming possible. A region's key is derived from its // NAME (SHA256("#tag")), so holding the name is enough to recompute an incoming // packet's transport code and recognise it. That is why adding a region here // names its traffic even though we do not participate in it. // // Adding is the whole point of the feature; removing is deliberately "retire": // the slot and name are kept so messages already received under that region keep // resolving, it just stops being matched from now on. See RegionRegistry. static lv_obj_t* s_regions_modal = nullptr; static lv_obj_t* s_regions_ta = nullptr; static void openRegionsModal(); // fwd: the add/remove callbacks rebuild the page static void regionsModalClose() { if (s_regions_modal) { hideKb(); popupClose(&s_regions_modal); } s_regions_ta = nullptr; if (s_apppage_close == regionsModalClose) { s_apppage_title = nullptr; s_apppage_close = nullptr; statusBarSetTall(false); updateGlobalStatusBar(); } } static void regionsAddCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (!s_regions_ta) return; const char* t = lv_textarea_get_text(s_regions_ta); if (!t || !t[0]) { if (g_lv.task) g_lv.task->showAlert(TR("Enter a region name"), 1400); return; } RegionRegistry& reg = the_mesh.regionRegistry(); if (reg.ensureRegion(t) == REGION_SLOT_NONE) { // Two different failures, and the user can only act on one of them: the list // being full, or the name canonicalising away to nothing (blank, or a bare // "#"). Say which rather than silently not adding a row. if (g_lv.task) g_lv.task->showAlert(reg.isFull() ? TR("Region list is full") : TR("Enter a region name"), 2000); return; } openRegionsModal(); // rebuild (closes first) } static void regionsRemoveCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const uint8_t slot = (uint8_t)(intptr_t)lv_event_get_user_data(e); the_mesh.regionRegistry().retire(slot); openRegionsModal(); // rebuild } static void openRegionsModal() { regionsModalClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // Go tall BEFORE measuring, and measure with statusBarCurH() rather than // chatBarH(). They disagree on the Pager: chatBarH() is one row there (that // board keeps back/cog/title on a single line), but statusBarSetTall() still // doubles the bar to STATUSBAR_H * 2. Laying out against chatBarH() therefore // put the page origin a whole row UNDER the bar and the top control was clipped // by it. statusBarCurH() is the height the bar actually has once tall, so the // content clears it on every board and this stays identical elsewhere. s_apppage_title = "Known regions"; s_apppage_close = regionsModalClose; statusBarSetTall(true); const lv_coord_t top = statusBarCurH(); s_regions_modal = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_regions_modal); lv_obj_set_size(s_regions_modal, sw, sh - top); lv_obj_set_pos(s_regions_modal, 0, top); lv_obj_set_style_bg_color(s_regions_modal, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_regions_modal, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_regions_modal, LV_OBJ_FLAG_SCROLLABLE); updateGlobalStatusBar(); lv_obj_move_foreground(s_regions_modal); if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root); const lv_coord_t pad = SC(6); lv_coord_t y = SC(6); // Add row: field + button on one line, so the common action is reachable // without scrolling on the short panels too. const lv_coord_t btn_w = SC(64); s_regions_ta = lv_textarea_create(s_regions_modal); lv_textarea_set_one_line(s_regions_ta, true); lv_textarea_set_max_length(s_regions_ta, TOUCH_REGION_SCOPE_MAXLEN - 1); lv_obj_set_size(s_regions_ta, sw - pad * 3 - btn_w, SC(32)); lv_obj_set_pos(s_regions_ta, pad, y); taSetPlaceholder(s_regions_ta, TR("#region")); attachSettingsTaEvents(s_regions_ta); lv_obj_t* add = lv_btn_create(s_regions_modal); lv_obj_set_size(add, btn_w, SC(32)); lv_obj_set_pos(add, sw - pad - btn_w, y); styleButton(add); lv_obj_add_event_cb(add, regionsAddCb, LV_EVENT_CLICKED, nullptr); { lv_obj_t* l = lv_label_create(add); lv_label_set_text(l, TR("Add")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } y += SC(38); RegionRegistry& reg = the_mesh.regionRegistry(); if (reg.count() <= 0) { lv_obj_t* empty = lv_label_create(s_regions_modal); lv_label_set_text(empty, TR("No regions yet.\n\nAdd a #region to have messages\nscoped to it named in message details.")); lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP); lv_obj_set_width(empty, sw - pad * 2); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(empty, pad, y); return; } lv_obj_t* list = lv_obj_create(s_regions_modal); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw - pad * 2, sh - top - y - SC(6)); lv_obj_set_pos(list, pad, y); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(list, SC(5), LV_PART_MAIN); lv_obj_set_scroll_dir(list, LV_DIR_VER); for (uint8_t slot = 1; slot <= REGION_SLOT_MAX; ++slot) { if (!reg.isActive(slot)) continue; const char* nm = reg.nameForSlot(slot); if (!nm || !nm[0]) continue; lv_obj_t* row = lv_obj_create(list); lv_obj_remove_style_all(row); lv_obj_set_size(row, sw - pad * 2 - SC(4), SC(36)); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 6, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* lbl = lv_label_create(row); lv_label_set_text(lbl, nm); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, sw - pad * 2 - SC(4) - SC(84)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, SC(6), 0); lv_obj_t* rm = lv_btn_create(row); lv_obj_set_size(rm, SC(72), SC(28)); lv_obj_align(rm, LV_ALIGN_RIGHT_MID, -SC(5), 0); styleButton(rm); lv_obj_add_event_cb(rm, regionsRemoveCb, LV_EVENT_CLICKED, (void*)(intptr_t)slot); { lv_obj_t* l = lv_label_create(rm); lv_label_set_text(l, TR("Remove")); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); } } } static void openBlockedUsersModal() { blockedModalClose(); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); // Full-screen page under the GLOBAL tall status bar ("‹ Blocked users", tap = Back) — // same chrome as RF Monitor / advert, so it works opened from anywhere. The bug from // the Contacts overflow was that it never activated the tall bar AND the page (created // on lv_layer_top after the bar) covered it; both are handled below. s_blocked_modal = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_blocked_modal); lv_obj_set_size(s_blocked_modal, sw, sh - chatBarH()); lv_obj_set_pos(s_blocked_modal, 0, chatBarH()); lv_obj_set_style_bg_color(s_blocked_modal, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_blocked_modal, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_blocked_modal, LV_OBJ_FLAG_SCROLLABLE); s_apppage_title = "Blocked users"; s_apppage_close = blockedModalClose; statusBarSetTall(true); updateGlobalStatusBar(); lv_obj_move_foreground(s_blocked_modal); if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root); // keep the tall bar above the page const int n = touchPrefsCopyIgnored(s_blocked_snap); const int nn = touchPrefsCopyIgnoredNames(s_blocked_names_snap); if (n <= 0 && nn <= 0) { lv_obj_t* empty = lv_label_create(s_blocked_modal); lv_label_set_text(empty, TR("No blocked users.\n\nLong-press a message and tap\nBlock to add one.")); lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(empty, 8, 8); return; } lv_obj_t* list = lv_obj_create(s_blocked_modal); lv_obj_remove_style_all(list); lv_obj_set_size(list, sw - 12, sh - chatBarH() - 16); lv_obj_set_pos(list, 6, 8); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(list, 6, LV_PART_MAIN); lv_obj_set_scroll_dir(list, LV_DIR_VER); for (int i = 0; i < n; ++i) { const uint8_t* pub6 = &s_blocked_snap[i * TOUCH_IGNORE_KEY_BYTES]; char nm[40]; bool named = false; ContactInfo c; const int nc = the_mesh.getNumContacts(); for (int j = 0; j < nc; ++j) { if (the_mesh.getContactByIdx((uint32_t)j, c) && memcmp(c.id.pub_key, pub6, TOUCH_IGNORE_KEY_BYTES) == 0) { snprintf(nm, sizeof nm, "%.24s", c.name); named = true; break; } } if (!named) snprintf(nm, sizeof nm, "%02X%02X%02X%02X%02X%02X", pub6[0], pub6[1], pub6[2], pub6[3], pub6[4], pub6[5]); lv_obj_t* row = lv_obj_create(list); lv_obj_remove_style_all(row); lv_obj_set_size(row, sw - 16, 40); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 6, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* lbl = lv_label_create(row); lv_label_set_text(lbl, nm); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, sw - 16 - 96); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* unb = lv_btn_create(row); lv_obj_set_size(unb, 80, 30); lv_obj_align(unb, LV_ALIGN_RIGHT_MID, -6, 0); styleButton(unb); lv_obj_add_event_cb(unb, blockedUnblockCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* ul = lv_label_create(unb); lv_label_set_text(ul, TR("Unblock")); lv_obj_set_style_text_font(ul, &g_font_12, LV_PART_MAIN); lv_obj_center(ul); } // Name-blocked senders (room/channel bots with no pubkey to target). Each // stored slot is a NUL-terminated display name. for (int i = 0; i < nn; ++i) { const char* nm = &s_blocked_names_snap[i * TOUCH_IGNORED_NAME_LEN]; if (!nm[0]) continue; lv_obj_t* row = lv_obj_create(list); lv_obj_remove_style_all(row); lv_obj_set_size(row, sw - 16, 40); lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(row, 6, LV_PART_MAIN); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* lbl = lv_label_create(row); char shown[40]; snprintf(shown, sizeof shown, "%.24s", nm); // name-only entry lv_label_set_text(lbl, shown); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, sw - 16 - 96); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); lv_obj_t* unb = lv_btn_create(row); lv_obj_set_size(unb, 80, 30); lv_obj_align(unb, LV_ALIGN_RIGHT_MID, -6, 0); styleButton(unb); lv_obj_add_event_cb(unb, blockedUnblockNameCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* ul = lv_label_create(unb); lv_label_set_text(ul, TR("Unblock")); lv_obj_set_style_text_font(ul, &g_font_12, LV_PART_MAIN); lv_obj_center(ul); } } static void chanScopeBlockedCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; chanScopeClose(); openBlockedUsersModal(); } static void openChannelScopeModal(int slot, const char* name) { chanScopeClose(); s_chanscope_slot = slot; snprintf(s_chanscope_name, sizeof s_chanscope_name, "%s", (name && name[0]) ? name : ""); const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_chanscope_modal = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_chanscope_modal); // Sit below the DOUBLE-height status bar (this modal only opens from a chat) so no // content hides under the bar. lv_obj_set_size(s_chanscope_modal, sw, sh - chatBarH()); lv_obj_set_pos(s_chanscope_modal, 0, chatBarH()); lv_obj_set_style_bg_color(s_chanscope_modal, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_chanscope_modal, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_chanscope_modal, LV_OBJ_FLAG_SCROLLABLE); // No close/back button in the sheet — the global status bar's "‹" back chevron // (already shown while in a chat) closes this on a bar tap (see statusBarTapCb). lv_obj_t* title = lv_label_create(s_chanscope_modal); lv_label_set_text(title, TR("Channel settings")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, SC(8), SC(8)); lv_obj_t* nm = lv_label_create(s_chanscope_modal); lv_label_set_text(nm, (name && name[0]) ? name : "Channel"); lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_set_width(nm, sw - 16); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(nm, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(nm, SC(8), SC(36)); lv_obj_t* hint = lv_label_create(s_chanscope_modal); lv_label_set_text(hint, TR("Region scope (#tag), blank = default.")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hint, SC(8), SC(54)); s_chanscope_ta = lv_textarea_create(s_chanscope_modal); lv_textarea_set_one_line(s_chanscope_ta, true); lv_textarea_set_max_length(s_chanscope_ta, TOUCH_REGION_SCOPE_MAXLEN - 1); lv_obj_set_size(s_chanscope_ta, sw - 16, SC(34)); lv_obj_set_pos(s_chanscope_ta, SC(8), SC(72)); #if defined(ESP32) { char cur[TOUCH_REGION_SCOPE_MAXLEN] = {0}; char def[TOUCH_REGION_SCOPE_MAXLEN] = {0}; touchPrefsGetChannelScope(slot, cur, sizeof cur); if (cur[0]) lv_textarea_set_text(s_chanscope_ta, cur); touchPrefsGetRegionScope(def, sizeof def); taSetPlaceholder(s_chanscope_ta, def[0] ? def : TR("(no default)")); } #else taSetPlaceholder(s_chanscope_ta, TR("(no default)")); #endif attachSettingsTaEvents(s_chanscope_ta); // ---- Mute notification sound for THIS channel (per-channel; public stays audible) ---- lv_obj_t* mute_lbl = lv_label_create(s_chanscope_modal); lv_label_set_text(mute_lbl, TR("Mute notification sound")); lv_obj_set_style_text_color(mute_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(mute_lbl, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(mute_lbl, SC(8), SC(120)); lv_obj_t* mute_sw = lv_switch_create(s_chanscope_modal); lv_obj_set_size(mute_sw, SC(48), SC(26)); lv_obj_set_pos(mute_sw, sw - SC(48) - SC(10), SC(115)); if (touchPrefsGetChannelMute(s_chanscope_name) & TOUCH_CHMUTE_MSG) lv_obj_add_state(mute_sw, LV_STATE_CHECKED); lv_obj_add_event_cb(mute_sw, chanScopeMuteCb, LV_EVENT_VALUE_CHANGED, nullptr); // Save + Blocked users side by side (one row). const lv_coord_t half_w = (lv_coord_t)((sw - 24) / 2); lv_obj_t* save = lv_btn_create(s_chanscope_modal); lv_obj_set_size(save, half_w, SC(34)); lv_obj_set_pos(save, 8, SC(152)); styleButton(save); lv_obj_add_event_cb(save, chanScopeSaveCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(save); lv_label_set_text(sl, TR("Save")); lv_obj_center(sl); useChainedFont(sl); lv_obj_t* blk = lv_btn_create(s_chanscope_modal); lv_obj_set_size(blk, half_w, SC(34)); lv_obj_set_pos(blk, 8 + half_w + 8, SC(152)); styleButton(blk); lv_obj_add_event_cb(blk, chanScopeBlockedCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bl = lv_label_create(blk); lv_label_set_text(bl, TR("Blocked users")); lv_obj_center(bl); useChainedFont(bl); } // Open settings for the currently-active chat: per-channel region scope for a channel (or the // blocked-users manager if its slot is unknown / it's a DM). Shared by the status-bar gear, the // composer's green ○ chip, and the green F4 hardware key on the Tanmatsu. static void openActiveChatSettings() { if (!g_lv.task) return; // Open the SAME unified thread-settings sheet as a long-press / the per-row gear, for the active // thread — so the in-chat cog, the inbox gear, and a long-press all land on one screen. const int idx = g_lv.task->activeThreadIdx(); if (idx < 0) return; bool ch; uint16_t un; uint32_t ts; char name[UITask::MAX_THREAD_NAME + 1] = ""; if (!g_lv.task->getThreadInfo(idx, ch, un, ts, name, sizeof(name))) return; s_chat_del_thread_idx = idx; s_chat_del_is_channel = ch; openThreadActionSheet(idx, name, ch); } static void channelGearCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) openActiveChatSettings(); } // Full-screen pickers sit on lv_layer_top, but the swipe/gesture handler is global // and would otherwise switch tabs in the menu beneath them. Block that. The settings // detail sheet now lives on the BASE layer (so the glass bar reveals it), so it must // block tab swipes too — otherwise a horizontal drag on it switches the tab underneath. static bool overlayBlocksTabSwipe() { return popupRegistryBlocksSwipe(); } #if defined(HAS_EXPANSION_KIT) // ============================================================ // Sensors tab (Heltec V4 Expansion Kit) // ============================================================ static lv_obj_t* s_sens_atmo_lbl = nullptr; // Temp / Hum / Press / Alt block static lv_obj_t* s_sens_gps_lbl = nullptr; // GPS block static lv_obj_t* s_sens_batt_lbl = nullptr; // Battery block static lv_obj_t* s_sens_time_lbl = nullptr; // "Updated HH:MM:SS" footer static lv_obj_t* s_sens_env_chart = nullptr; static lv_chart_series_t* s_sens_env_temp = nullptr; static lv_chart_series_t* s_sens_env_hum = nullptr; static lv_obj_t* s_sens_env_legend = nullptr; static lv_obj_t* s_sens_press_chart = nullptr; static lv_chart_series_t* s_sens_press_ser = nullptr; static lv_chart_series_t* s_sens_alt_ser = nullptr; static lv_obj_t* s_sens_press_legend = nullptr; static lv_obj_t* s_sens_batt_chart = nullptr; static lv_chart_series_t* s_sens_batt_ser = nullptr; static lv_obj_t* s_sens_batt_legend = nullptr; static lv_obj_t* s_sens_alerts_lbl = nullptr; static void refreshSensorsHistoryCharts() { bool have_temp = false, have_hum = false, have_press = false, have_alt = false, have_batt = false; int press_min = INT_MAX, press_max = INT_MIN; int alt_min = INT_MAX, alt_max = INT_MIN; for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { if (s_home_env_hist_temp_t10[i] != INT16_MIN) have_temp = true; if (s_home_env_hist_hum[i] >= 0) have_hum = true; if (s_home_env_hist_press_hpa10[i] != INT16_MIN) { have_press = true; const int v = s_home_env_hist_press_hpa10[i] / 10; if (v < press_min) press_min = v; if (v > press_max) press_max = v; } if (s_home_env_hist_alt_m[i] != INT16_MIN) { have_alt = true; const int v = s_home_env_hist_alt_m[i]; if (v < alt_min) alt_min = v; if (v > alt_max) alt_max = v; } if (s_home_env_hist_batt_mv[i] > 0) have_batt = true; } if (s_sens_env_chart && s_sens_env_temp && s_sens_env_hum) { const bool show = have_temp || have_hum; if (show) { lv_obj_clear_flag(s_sens_env_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_env_legend) lv_obj_clear_flag(s_sens_env_legend, LV_OBJ_FLAG_HIDDEN); lv_chart_set_all_value(s_sens_env_chart, s_sens_env_temp, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_sens_env_chart, s_sens_env_hum, LV_CHART_POINT_NONE); for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { lv_chart_set_next_value(s_sens_env_chart, s_sens_env_temp, s_home_env_hist_temp_t10[i] != INT16_MIN ? (lv_coord_t)(s_home_env_hist_temp_t10[i] / 10) : LV_CHART_POINT_NONE); lv_chart_set_next_value(s_sens_env_chart, s_sens_env_hum, s_home_env_hist_hum[i] >= 0 ? (lv_coord_t)s_home_env_hist_hum[i] : LV_CHART_POINT_NONE); } lv_chart_refresh(s_sens_env_chart); } else { lv_obj_add_flag(s_sens_env_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_env_legend) lv_obj_add_flag(s_sens_env_legend, LV_OBJ_FLAG_HIDDEN); } } if (s_sens_press_chart && s_sens_press_ser && s_sens_alt_ser) { const bool show = have_press || have_alt; if (show) { lv_obj_clear_flag(s_sens_press_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_press_legend) lv_obj_clear_flag(s_sens_press_legend, LV_OBJ_FLAG_HIDDEN); if (have_press) { int lo = press_min - 3; int hi = press_max + 3; if (hi - lo < 12) { const int mid = (press_min + press_max) / 2; lo = mid - 6; hi = mid + 6; } if (lo < 300) lo = 300; if (hi > 1200) hi = 1200; lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_PRIMARY_Y, lo, hi); } else { lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_PRIMARY_Y, 700, 1100); } if (have_alt) { int lo = alt_min - 20; int hi = alt_max + 20; if (hi - lo < 80) { const int mid = (alt_min + alt_max) / 2; lo = mid - 40; hi = mid + 40; } if (lo < -500) lo = -500; if (hi > 9000) hi = 9000; lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_SECONDARY_Y, lo, hi); } else { lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 4000); } lv_chart_set_all_value(s_sens_press_chart, s_sens_press_ser, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_sens_press_chart, s_sens_alt_ser, LV_CHART_POINT_NONE); for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { lv_chart_set_next_value(s_sens_press_chart, s_sens_press_ser, s_home_env_hist_press_hpa10[i] != INT16_MIN ? (lv_coord_t)(s_home_env_hist_press_hpa10[i] / 10) : LV_CHART_POINT_NONE); lv_chart_set_next_value(s_sens_press_chart, s_sens_alt_ser, s_home_env_hist_alt_m[i] != INT16_MIN ? (lv_coord_t)s_home_env_hist_alt_m[i] : LV_CHART_POINT_NONE); } lv_chart_refresh(s_sens_press_chart); } else { lv_obj_add_flag(s_sens_press_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_press_legend) lv_obj_add_flag(s_sens_press_legend, LV_OBJ_FLAG_HIDDEN); } } if (s_sens_batt_chart && s_sens_batt_ser) { if (have_batt) { lv_obj_clear_flag(s_sens_batt_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_batt_legend) lv_obj_clear_flag(s_sens_batt_legend, LV_OBJ_FLAG_HIDDEN); lv_chart_set_all_value(s_sens_batt_chart, s_sens_batt_ser, LV_CHART_POINT_NONE); for (int i = 0; i < kHomeEnvHistoryPoints; ++i) lv_chart_set_next_value(s_sens_batt_chart, s_sens_batt_ser, s_home_env_hist_batt_mv[i] > 0 ? (lv_coord_t)s_home_env_hist_batt_mv[i] : LV_CHART_POINT_NONE); lv_chart_refresh(s_sens_batt_chart); } else { lv_obj_add_flag(s_sens_batt_chart, LV_OBJ_FLAG_HIDDEN); if (s_sens_batt_legend) lv_obj_add_flag(s_sens_batt_legend, LV_OBJ_FLAG_HIDDEN); } } } static void refreshSensorsTab() { if (!g_lv.task) return; UITask::LocalEnvSnapshot snap; const bool ok = g_lv.task->getLocalEnvSnapshot(snap); // Atmosphere block if (s_sens_atmo_lbl) { char atmo[160]; size_t p = 0; if (!ok || (!snap.have_bme_temp && !snap.have_bme_hum && !snap.have_bme_pressure && !snap.have_bme_alt && !snap.have_gxhtv3_temp && !snap.have_gxhtv3_hum)) { snprintf(atmo, sizeof atmo, "No sensor detected"); } else { if (snap.have_bme_temp) p += snprintf(atmo + p, sizeof atmo - p, "Temp: %.1f\xC2\xB0""C\n", (double)snap.bme_temp_c); else if (snap.have_gxhtv3_temp) p += snprintf(atmo + p, sizeof atmo - p, "Temp: %.1f\xC2\xB0""C\n", (double)snap.gxhtv3_temp_c); if (snap.have_bme_hum) p += snprintf(atmo + p, sizeof atmo - p, "Humidity: %.0f%%RH\n", (double)snap.bme_hum_pct); else if (snap.have_gxhtv3_hum) p += snprintf(atmo + p, sizeof atmo - p, "Humidity: %.0f%%RH\n", (double)snap.gxhtv3_hum_pct); if (snap.have_bme_pressure) p += snprintf(atmo + p, sizeof atmo - p, "Pressure: %.1f hPa\n", (double)snap.bme_pressure_hpa); if (snap.have_bme_alt) p += snprintf(atmo + p, sizeof atmo - p, "Altitude: %d m", (int)snap.bme_alt_m); // Strip trailing newline while (p > 0 && atmo[p-1] == '\n') atmo[--p] = '\0'; } setLabelIfChanged(s_sens_atmo_lbl, atmo); } // GPS block if (s_sens_gps_lbl) { char gps[256]; if (!snap.gps_present) { snprintf(gps, sizeof gps, "No GPS"); } else { size_t p = 0; p += snprintf(gps + p, sizeof gps - p, "State: %s", snap.gps_enabled ? "enabled" : "disabled"); p += snprintf(gps + p, sizeof gps - p, "\nFix: %s", snap.gps_fix ? "yes" : "no"); if (snap.gps_sats >= 0) p += snprintf(gps + p, sizeof gps - p, " Sats: %d", snap.gps_sats); if (g_lv.task) p += snprintf(gps + p, sizeof gps - p, "\nSeen this boot: %s", g_lv.task->getGpsHadFix() ? "yes" : "no"); if (s_gps_speed_kmh > 0.1f) p += snprintf(gps + p, sizeof gps - p, "\nSpeed: %.1f km/h", (double)s_gps_speed_kmh); if (s_gps_course_deg >= 0.0f) p += snprintf(gps + p, sizeof gps - p, " Course: %.0f\xC2\xB0 %s", (double)s_gps_course_deg, bearingCardinal(s_gps_course_deg)); p += snprintf(gps + p, sizeof gps - p, "\nHDOP: n/a"); if (s_gps_last_fix_ms != 0) p += snprintf(gps + p, sizeof gps - p, "\nFix age: %lus", (unsigned long)((millis() - s_gps_last_fix_ms) / 1000UL)); if (!snap.gps_fix && s_gps_last_loss_ms != 0) p += snprintf(gps + p, sizeof gps - p, " Lost: %lus", (unsigned long)((millis() - s_gps_last_loss_ms) / 1000UL)); if (snap.gps_fix) { const double lat = g_lv.task->getNodeLat(); const double lon = g_lv.task->getNodeLon(); p += snprintf(gps + p, sizeof gps - p, "\nLive: %.5f %.5f", lat, lon); } else if (g_lv.task && (g_lv.task->getNodeLat() != 0.0 || g_lv.task->getNodeLon() != 0.0)) { p += snprintf(gps + p, sizeof gps - p, "\nSaved: %.5f %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); } } setLabelIfChanged(s_sens_gps_lbl, gps); } // Battery block if (s_sens_batt_lbl) { char batt[32]; if (snap.have_batt) snprintf(batt, sizeof batt, "%.2f V", (double)snap.batt_v); else snprintf(batt, sizeof batt, "N/A"); setLabelIfChanged(s_sens_batt_lbl, batt); } // Terrain alerts block. PR #29's weather pressure-alarm + track-recorder are // intentionally not ported here, so this shows the altitude history window and // a compact GPS line only (no armed/sound state, no REC stats). if (s_sens_alerts_lbl) { int hist_alt_min = INT_MAX, hist_alt_max = INT_MIN; int16_t first_alt = INT16_MIN, last_alt = INT16_MIN; for (int i = 0; i < kHomeEnvHistoryPoints; ++i) { if (s_home_env_hist_alt_m[i] == INT16_MIN) continue; if (s_home_env_hist_alt_m[i] < hist_alt_min) hist_alt_min = s_home_env_hist_alt_m[i]; if (s_home_env_hist_alt_m[i] > hist_alt_max) hist_alt_max = s_home_env_hist_alt_m[i]; if (first_alt == INT16_MIN) first_alt = s_home_env_hist_alt_m[i]; last_alt = s_home_env_hist_alt_m[i]; } char alerts[320]; size_t p = 0; if (hist_alt_min != INT_MAX && hist_alt_max != INT_MIN) { p += snprintf(alerts + p, sizeof alerts - p, "Altitude window: %d .. %d m", hist_alt_min, hist_alt_max); if (first_alt != INT16_MIN && last_alt != INT16_MIN) { p += snprintf(alerts + p, sizeof alerts - p, " trend %+d m", (int)(last_alt - first_alt)); } } else if (snap.have_bme_alt) { p += snprintf(alerts + p, sizeof alerts - p, "Altitude now: %d m", (int)snap.bme_alt_m); } else { p += snprintf(alerts + p, sizeof alerts - p, "Altitude: n/a"); } if (snap.gps_present) { p += snprintf(alerts + p, sizeof alerts - p, "\nGPS: %s", snap.gps_fix ? "fix" : "no fix"); if (s_gps_last_fix_ms != 0) { p += snprintf(alerts + p, sizeof alerts - p, " age %lus", (unsigned long)((millis() - s_gps_last_fix_ms) / 1000UL)); } if (!snap.gps_fix && s_gps_last_loss_ms != 0) { p += snprintf(alerts + p, sizeof alerts - p, " lost %lus", (unsigned long)((millis() - s_gps_last_loss_ms) / 1000UL)); } } setLabelIfChanged(s_sens_alerts_lbl, alerts); } // Footer timestamp if (s_sens_time_lbl) { char ts[24]; mesh::RTCClock* rtc = the_mesh.getRTCClock(); time_t t = (time_t)(rtc ? rtc->getCurrentTime() : 0); struct tm tmv; if (t > 0 && localtime_r(&t, &tmv)) strftime(ts, sizeof ts, "Updated %H:%M:%S", &tmv); else snprintf(ts, sizeof ts, "Updated --:--:--"); setLabelIfChanged(s_sens_time_lbl, ts); } refreshSensorsHistoryCharts(); } static lv_obj_t* sensorsAddSection(lv_obj_t* page, int& y, const char* title) { lv_obj_t* hdr = lv_label_create(page); lv_label_set_text(hdr, title); lv_obj_set_style_text_font(hdr, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hdr, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_pos(hdr, 8, y); y += SC(18); lv_obj_t* val = lv_label_create(page); lv_label_set_long_mode(val, LV_LABEL_LONG_WRAP); lv_obj_set_width(val, lv_pct(100) - SC(16)); lv_obj_set_style_text_font(val, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(val, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(val, 8, y); return val; } static void makeSensorsTab(lv_obj_t* tab) { styleSurface(tab, COLOR_BG, 0); lv_obj_set_scroll_dir(tab, LV_DIR_VER); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_event_cb(tab, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); int y = SC(6); s_sens_atmo_lbl = sensorsAddSection(tab, y, LV_SYMBOL_CHARGE " Atmosphere"); lv_label_set_text(s_sens_atmo_lbl, "..."); s_sens_env_chart = lv_chart_create(tab); lv_obj_set_size(s_sens_env_chart, lv_pct(100) - SC(16), SC(74)); lv_obj_set_pos(s_sens_env_chart, 8, y + SC(56)); lv_obj_clear_flag(s_sens_env_chart, LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(s_sens_env_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_sens_env_chart, kHomeEnvHistoryPoints); lv_chart_set_range(s_sens_env_chart, LV_CHART_AXIS_PRIMARY_Y, -10, 60); lv_chart_set_range(s_sens_env_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 100); lv_chart_set_div_line_count(s_sens_env_chart, 3, 4); lv_obj_set_style_bg_color(s_sens_env_chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_sens_env_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_sens_env_chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_sens_env_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_sens_env_chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(s_sens_env_chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); lv_obj_set_style_text_font(s_sens_env_chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(s_sens_env_chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(s_sens_env_chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_pad_left(s_sens_env_chart, 4, LV_PART_TICKS); lv_obj_set_style_pad_top(s_sens_env_chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_sens_env_chart, 6, LV_PART_MAIN); lv_obj_set_style_size(s_sens_env_chart, 0, LV_PART_INDICATOR); lv_obj_set_style_line_width(s_sens_env_chart, 2, LV_PART_ITEMS); lv_chart_set_axis_tick(s_sens_env_chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 3, 1, true, 34); lv_chart_set_axis_tick(s_sens_env_chart, LV_CHART_AXIS_SECONDARY_Y, 4, 0, 3, 1, true, 30); s_sens_env_temp = lv_chart_add_series(s_sens_env_chart, lv_color_hex(0xF5A623), LV_CHART_AXIS_PRIMARY_Y); s_sens_env_hum = lv_chart_add_series(s_sens_env_chart, lv_color_hex(0x35C9C9), LV_CHART_AXIS_SECONDARY_Y); lv_chart_set_all_value(s_sens_env_chart, s_sens_env_temp, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_sens_env_chart, s_sens_env_hum, LV_CHART_POINT_NONE); s_sens_env_legend = lv_label_create(tab); lv_label_set_text(s_sens_env_legend, TR("Orange temp Cyan humidity")); lv_obj_set_style_text_font(s_sens_env_legend, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_sens_env_legend, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_sens_env_legend, 8, y + SC(56 + 74 + 4)); y += SC(152); s_sens_press_chart = lv_chart_create(tab); lv_obj_set_size(s_sens_press_chart, lv_pct(100) - SC(16), SC(74)); lv_obj_set_pos(s_sens_press_chart, 8, y); lv_obj_clear_flag(s_sens_press_chart, LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(s_sens_press_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_sens_press_chart, kHomeEnvHistoryPoints); lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_PRIMARY_Y, 700, 1100); lv_chart_set_range(s_sens_press_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 4000); lv_chart_set_div_line_count(s_sens_press_chart, 3, 4); lv_obj_set_style_bg_color(s_sens_press_chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_sens_press_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_sens_press_chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_sens_press_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_sens_press_chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(s_sens_press_chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); lv_obj_set_style_text_font(s_sens_press_chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(s_sens_press_chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(s_sens_press_chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_pad_left(s_sens_press_chart, 4, LV_PART_TICKS); lv_obj_set_style_pad_top(s_sens_press_chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_sens_press_chart, 6, LV_PART_MAIN); lv_obj_set_style_size(s_sens_press_chart, 0, LV_PART_INDICATOR); lv_obj_set_style_line_width(s_sens_press_chart, 2, LV_PART_ITEMS); lv_chart_set_axis_tick(s_sens_press_chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 3, 1, true, 34); lv_chart_set_axis_tick(s_sens_press_chart, LV_CHART_AXIS_SECONDARY_Y, 4, 0, 3, 1, true, 34); s_sens_press_ser = lv_chart_add_series(s_sens_press_chart, lv_color_hex(0x79D36B), LV_CHART_AXIS_PRIMARY_Y); s_sens_alt_ser = lv_chart_add_series(s_sens_press_chart, lv_color_hex(0xA7B2BF), LV_CHART_AXIS_SECONDARY_Y); lv_chart_set_all_value(s_sens_press_chart, s_sens_press_ser, LV_CHART_POINT_NONE); lv_chart_set_all_value(s_sens_press_chart, s_sens_alt_ser, LV_CHART_POINT_NONE); s_sens_press_legend = lv_label_create(tab); lv_label_set_text(s_sens_press_legend, TR("Green pressure Gray altitude")); lv_obj_set_style_text_font(s_sens_press_legend, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_sens_press_legend, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_sens_press_legend, 8, y + SC(74 + 4)); y += SC(110); // Divider { lv_obj_t* div = lv_obj_create(tab); lv_obj_remove_style_all(div); lv_obj_set_size(div, lv_pct(100) - SC(16), 1); lv_obj_set_pos(div, 8, y); lv_obj_set_style_bg_color(div, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(div, LV_OPA_20, LV_PART_MAIN); y += SC(10); } s_sens_gps_lbl = sensorsAddSection(tab, y, LV_SYMBOL_GPS " GPS"); lv_label_set_text(s_sens_gps_lbl, "..."); y += SC(82); // Divider { lv_obj_t* div = lv_obj_create(tab); lv_obj_remove_style_all(div); lv_obj_set_size(div, lv_pct(100) - SC(16), 1); lv_obj_set_pos(div, 8, y); lv_obj_set_style_bg_color(div, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(div, LV_OPA_20, LV_PART_MAIN); y += SC(10); } s_sens_batt_lbl = sensorsAddSection(tab, y, LV_SYMBOL_BATTERY_FULL " Battery"); lv_label_set_text(s_sens_batt_lbl, "..."); s_sens_batt_chart = lv_chart_create(tab); lv_obj_set_size(s_sens_batt_chart, lv_pct(100) - SC(16), SC(70)); lv_obj_set_pos(s_sens_batt_chart, 8, y + SC(30)); lv_obj_clear_flag(s_sens_batt_chart, LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(s_sens_batt_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(s_sens_batt_chart, kHomeEnvHistoryPoints); lv_chart_set_range(s_sens_batt_chart, LV_CHART_AXIS_PRIMARY_Y, 3400, 4600); lv_chart_set_div_line_count(s_sens_batt_chart, 3, 4); lv_obj_set_style_bg_color(s_sens_batt_chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_sens_batt_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_sens_batt_chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(s_sens_batt_chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(s_sens_batt_chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(s_sens_batt_chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); lv_obj_set_style_text_font(s_sens_batt_chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(s_sens_batt_chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(s_sens_batt_chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_pad_left(s_sens_batt_chart, 4, LV_PART_TICKS); lv_obj_set_style_pad_top(s_sens_batt_chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(s_sens_batt_chart, 6, LV_PART_MAIN); lv_obj_set_style_size(s_sens_batt_chart, 0, LV_PART_INDICATOR); lv_obj_set_style_line_width(s_sens_batt_chart, 2, LV_PART_ITEMS); lv_chart_set_axis_tick(s_sens_batt_chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 3, 1, true, 36); lv_obj_add_event_cb(s_sens_batt_chart, battChartTickCb, LV_EVENT_DRAW_PART_BEGIN, nullptr); s_sens_batt_ser = lv_chart_add_series(s_sens_batt_chart, lv_color_hex(0x4F9DF7), LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_all_value(s_sens_batt_chart, s_sens_batt_ser, LV_CHART_POINT_NONE); s_sens_batt_legend = lv_label_create(tab); lv_label_set_text(s_sens_batt_legend, TR("Blue battery Last 6 min")); lv_obj_set_style_text_font(s_sens_batt_legend, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_sens_batt_legend, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_sens_batt_legend, 8, y + SC(30 + 70 + 4)); y += SC(120); { lv_obj_t* div = lv_obj_create(tab); lv_obj_remove_style_all(div); lv_obj_set_size(div, lv_pct(100) - SC(16), 1); lv_obj_set_pos(div, 8, y); lv_obj_set_style_bg_color(div, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(div, LV_OPA_20, LV_PART_MAIN); y += SC(10); } s_sens_alerts_lbl = sensorsAddSection(tab, y, LV_SYMBOL_WARNING " Terrain alerts"); lv_label_set_text(s_sens_alerts_lbl, "..."); y += SC(176); // Footer: last updated s_sens_time_lbl = lv_label_create(tab); lv_label_set_text(s_sens_time_lbl, ""); lv_obj_set_style_text_font(s_sens_time_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_sens_time_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(s_sens_time_lbl, 8, y); refreshSensorsTab(); } #endif // HAS_EXPANSION_KIT // ============================================================ // Build full UI tree // ============================================================ static void buildUiTree() { // Always boot to the Home tab (the last-used tab is no longer restored). uint8_t saved_tab = HOME_TAB_INDEX; #if defined(HAS_EXPANSION_KIT) // Probe the local env sensors ONCE here and cache the result. sensorsUiWanted() // (this AND the user pref) then decides whether the Sensors tab + Home env // widget are built. Re-probing per frame would hammer the I2C bus. s_env_sensors_present = false; if (g_lv.task) { UITask::LocalEnvSnapshot snap; g_lv.task->getLocalEnvSnapshot(snap); s_env_sensors_present = localEnvHasEnvSensors(snap); } #endif // Load the saved theme accent before any widget is built so the whole tree // adopts it. g_lv.tabview/keyboard are still null here, so applyAccent only // sets the colour globals (no live re-style needed at boot). applyAccent(touchPrefsGetAccentColor()); lv_obj_t* root = lv_scr_act(); styleSurface(root, COLOR_BG, 0); lv_obj_set_style_pad_all(root, 0, LV_PART_MAIN); // zero default theme padding so overlays sit at (0,0) lv_obj_set_style_text_color(root, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); // ---- Tabview ---- g_lv.tabview = lv_tabview_create(root, LV_DIR_BOTTOM, TABBAR_H); // Tabview leaves the top STATUSBAR_H pixels free so the global status // bar (on lv_layer_sys) doesn't paint over tab content. Sized from the // live display resolution so it fills the screen in either orientation // (240x320 portrait / 320x240 landscape). lv_obj_set_size(g_lv.tabview, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); lv_obj_align(g_lv.tabview, LV_ALIGN_TOP_LEFT, 0, STATUSBAR_H); styleSurface(g_lv.tabview, COLOR_BG, 0); lv_obj_set_style_anim_time(g_lv.tabview, 150, LV_PART_MAIN); lv_obj_add_event_cb(g_lv.tabview, tabChangedCb, LV_EVENT_VALUE_CHANGED, nullptr); // Disable LVGL's built-in horizontal-drag-to-switch on the tabview content. // Without this, a swipe across the screen advances the tab twice — once via // LVGL's internal drag handler, then again via our applySwipeGesture() on // finger release. { lv_obj_t* tv_content = lv_tabview_get_content(g_lv.tabview); if (tv_content) { lv_obj_clear_flag(tv_content, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scroll_dir(tv_content, LV_DIR_NONE); // Explicit black bg + square corners: applyMapChrome toggles this // container's bg_opa for the immersive map, and the LVGL theme default is // a light, ROUNDED panel — which peeked through as white rounded corners // behind the home/other tabs after returning from the map. Pin it black // and square so the opaque state is pure COLOR_BG. lv_obj_set_style_bg_color(tv_content, lv_color_hex(COLOR_BG), LV_PART_MAIN); lv_obj_set_style_radius(tv_content, 0, LV_PART_MAIN); } } lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview); #if defined(HAS_THINKNODE_M9) // No tab bar on this board (TABBAR_H == 0, see its definition): hide the zero-height // btnmatrix outright so it can never paint, hit-test, or take focus — and build NONE of // the bar chrome below (surface styling, Home re-tap / swipe-up gestures, key hints, the // tab font, the accent "glow" indicator). The M9 switches screens with its dedicated // HOME/MESSAGE/MAP keys and the app drawer; a bottom highlight over content was the only // visible leftover of the bar once the icons went. lv_obj_add_flag(tab_btns, LV_OBJ_FLAG_HIDDEN); #else // Tab bar bar itself sits on pure BG, not the panel — keeps the bottom // strip indistinguishable from the rest of the screen except for the // active-tab highlight. styleSurface(tab_btns, COLOR_BG, 0); // Per-tab item: no background fill in either state — the bottom strip stays // flat against the screen. The active tab is marked only by tinting its icon // with the accent colour (inactive icons are dim grey), instead of the old // low-opacity accent square behind the icon. lv_obj_set_style_bg_opa(tab_btns, LV_OPA_TRANSP, LV_PART_ITEMS); // Active tab: no theme fill / indicator line — the icon is tinted with the // accent colour and a separate thin rounded accent bar (s_tab_indicator) glows // at the bottom edge. Keep the checked cell transparent + borderless so the // default theme's blue checked fill/indicator never shows. lv_obj_set_style_border_width(tab_btns, 0, LV_PART_ITEMS); lv_obj_set_style_border_width(tab_btns, 0, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_bg_opa(tab_btns, LV_OPA_TRANSP, LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_text_color(tab_btns, lv_color_hex(COLOR_SUB), LV_PART_ITEMS); lv_obj_set_style_text_color(tab_btns, lv_color_hex(COLOR_ACCENT), LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_text_font(tab_btns, &g_font_14, LV_PART_MAIN); #if CAP_ROUND_CORNERS // Round panel: inset the bottom tab row from the two bottom corner arcs (left/right) // and lift the icons off the very bottom edge, so no tab glyph sits under a corner. lv_obj_set_style_pad_left(tab_btns, SB_INSET_X, LV_PART_MAIN); lv_obj_set_style_pad_right(tab_btns, SB_INSET_X, LV_PART_MAIN); lv_obj_set_style_pad_bottom(tab_btns, SB_TOP_PAD, LV_PART_MAIN); #endif lv_obj_add_event_cb(tab_btns, homeTabClickedCb, LV_EVENT_CLICKED, nullptr); // Home re-tap toggles the drawer lv_obj_add_event_cb(tab_btns, tabBarGestureCb, LV_EVENT_GESTURE, nullptr); // swipe up from the bar opens the drawer #if CAP_TRACKBALL lv_obj_add_event_cb(tab_btns, navMenubarSizeCb, LV_EVENT_SIZE_CHANGED, nullptr); // keep the keyboard-nav key hints positioned #endif #endif // !HAS_THINKNODE_M9 — tab-bar chrome // Tab labels: icons-only on touch targets; the 480px-wide Pager prefixes each // icon with its physical-keyboard mnemonic so the shortcuts are discoverable. // Add order == index order: Chats(0), Contacts(1), Home(2, middle), Map(3), Settings(4). #if defined(TLORA_PAGER) lv_obj_t* tab_chats = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_ENVELOPE " M"); lv_obj_t* tab_contacts = lv_tabview_add_tab(g_lv.tabview, TOUCH_SYM_PERSON " C"); lv_obj_t* tab_home = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_HOME " H"); lv_obj_t* tab_map = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_GPS " A"); #else lv_obj_t* tab_chats = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_ENVELOPE); lv_obj_t* tab_contacts = lv_tabview_add_tab(g_lv.tabview, TOUCH_SYM_PERSON); // person icon (FA user) lv_obj_t* tab_home = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_HOME); lv_obj_t* tab_map = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_GPS); #endif #if defined(HAS_EXPANSION_KIT) // Sensors tab is conditional: only add it (and shift Settings to index 5) when // an env sensor is present and the user pref is on. Otherwise Settings lands // at index 4 — exactly the non-Expansion layout. The runtime tab-index globals // are set here so the rest of the UI agrees on the indices. lv_obj_t* tab_sensors = nullptr; if (sensorsUiWanted()) { tab_sensors = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_CHARGE); // environment / sensors (Expansion Kit) SENSORS_TAB_INDEX = 4; SETTINGS_TAB_INDEX = 5; TAB_LAST = 5; } else { SENSORS_TAB_INDEX = -1; SETTINGS_TAB_INDEX = 4; TAB_LAST = 4; } #endif #if defined(TLORA_PAGER) lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS " S"); #else lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS); #endif // Slightly larger font for icons so they're easy to tap. #if defined(HAS_THINKNODE_M9) // no tab bar on the M9 (see above) — nothing to size #elif CAP_UI_SIZE lv_obj_set_style_text_font(tab_btns, &g_font_tab, LV_PART_MAIN); #else lv_obj_set_style_text_font(tab_btns, &g_font_16, LV_PART_MAIN); #endif // Home and Set tabs scroll vertically at the tab page level for (lv_obj_t* t : {tab_home, tab_settings}) { styleSurface(t, COLOR_BG, 0); lv_obj_set_scroll_dir(t, LV_DIR_VER); lv_obj_set_scrollbar_mode(t, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_event_cb(t, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); } // ---- Build tab contents ---- makeHome(tab_home); g_lv.dm.channel_mode = false; g_lv.ch.channel_mode = true; makeChatList(tab_chats, g_lv.dm, false, true); makeContactsTab(tab_contacts); makeMapTab(tab_map); #if defined(HAS_EXPANSION_KIT) if (tab_sensors) makeSensorsTab(tab_sensors); #endif // Red "!" update badge over the Settings gear (rightmost bottom tab). A child // of the screen (so it has no layout overriding its alignment) created BEFORE // the chat-detail overlays + above the tabview — it floats over the gear on // the main tabs but is covered by full-screen overlays (chat detail, modals, // lock screen) that sit above it in the screen z-order. s_update_badge = lv_label_create(lv_scr_act()); lv_label_set_text(s_update_badge, "!"); lv_obj_set_size(s_update_badge, 16, 16); lv_obj_set_style_radius(s_update_badge, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(s_update_badge, lv_color_hex(0xE2403A), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_update_badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_text_color(s_update_badge, lv_color_white(), LV_PART_MAIN); // Bottom-bar badges are fixed chrome too: a Large-preset 18-px line cannot // be drawn inside the old 15-px circle without clipping. lv_obj_set_style_text_font(s_update_badge, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_text_align(s_update_badge, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_pad_all(s_update_badge, 0, LV_PART_MAIN); #if defined(HAS_THINKNODE_M9) lv_obj_align(s_update_badge, LV_ALIGN_BOTTOM_RIGHT, -8, -4); // no tab bar on this board #else lv_obj_align(s_update_badge, LV_ALIGN_BOTTOM_RIGHT, -8, -(TABBAR_H - 16)); #endif lv_obj_add_flag(s_update_badge, LV_OBJ_FLAG_HIDDEN); // Unread-count badge over the Chats (envelope) tab — leftmost of 5. Same // floating child-of-screen pattern as s_update_badge; the loop's refresh tick // sets the count + show/hide. Content-width so it grows for 2-3 digits. s_chat_unread_badge = lv_label_create(lv_scr_act()); lv_label_set_text(s_chat_unread_badge, ""); lv_obj_set_size(s_chat_unread_badge, LV_SIZE_CONTENT, 16); lv_obj_set_style_min_width(s_chat_unread_badge, 16, LV_PART_MAIN); lv_obj_set_style_radius(s_chat_unread_badge, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(s_chat_unread_badge, lv_color_hex(0xE0533D), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_chat_unread_badge, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_text_color(s_chat_unread_badge, lv_color_white(), LV_PART_MAIN); lv_obj_set_style_text_font(s_chat_unread_badge, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_text_align(s_chat_unread_badge, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_pad_hor(s_chat_unread_badge, 3, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_chat_unread_badge, 0, LV_PART_MAIN); lv_obj_align(s_chat_unread_badge, LV_ALIGN_BOTTOM_LEFT, #if defined(HAS_THINKNODE_M9) 8, -4); // no tab bar on this board — bottom-left corner #elif defined(HAS_EXPANSION_KIT) lv_disp_get_hor_res(nullptr) / 12 + 7, -(TABBAR_H - 16)); // 6 tabs: half-cell over Chats #else lv_disp_get_hor_res(nullptr) / 10 + 7, -(TABBAR_H - 16)); // 5 tabs: half-cell over Chats #endif lv_obj_add_flag(s_chat_unread_badge, LV_OBJ_FLAG_HIDDEN); #if !defined(HAS_THINKNODE_M9) // Thin rounded accent "glow" bar that marks the active tab. A child of the // screen (like s_update_badge) created BEFORE the chat overlays so those cover // it, and above the tabview so it shows over the bottom bar. A soft accent // shadow gives it the glow; updateTabIndicator() slides it under the active // tab and hides it on the map. NOT built on the M9 (no tab bar): it sat on the // screen, not in the bar, so hiding the bar left it glowing over the content's // bottom edge — the "lingering tab highlight". updateTabIndicator() null-guards. s_tab_indicator = lv_obj_create(lv_scr_act()); lv_obj_remove_style_all(s_tab_indicator); lv_obj_clear_flag(s_tab_indicator, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(s_tab_indicator, 26, 4); lv_obj_set_style_bg_color(s_tab_indicator, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_tab_indicator, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_radius(s_tab_indicator, 2, LV_PART_MAIN); lv_obj_set_style_shadow_color(s_tab_indicator, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_shadow_width(s_tab_indicator, 8, LV_PART_MAIN); lv_obj_set_style_shadow_opa(s_tab_indicator, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_shadow_spread(s_tab_indicator, 0, LV_PART_MAIN); updateTabIndicator(); // place it under the initial active tab #endif // !HAS_THINKNODE_M9 // Create full-screen detail overlays (hidden until a thread is tapped) makeChatDetail(g_lv.dm); makeChatDetail(g_lv.ch); makeSettings(tab_settings); #if defined(HAS_TANMATSU) navBuildTabKeyHints(); // coloured △□○♣◇ shapes on the tab bar = the physical F-keys #endif // ---- Shared on-screen keyboard (child of root, hidden initially) ---- /* On `lv_layer_top()` so the keyboard appears above fullscreen settings/chat overlays. */ g_lv.keyboard = lv_keyboard_create(lv_layer_top()); lv_obj_set_size(g_lv.keyboard, lv_disp_get_hor_res(nullptr), CHAT_KB_H); lv_obj_align(g_lv.keyboard, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_add_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN); // Insert our backspace-deletes-selection interceptor AHEAD of the keyboard's // own default handler: remove the auto-registered default, add the interceptor // first, then re-add the default after it. The interceptor runs first and can // stop_processing to replace the default single-char delete when text is selected. lv_obj_remove_event_cb(g_lv.keyboard, lv_keyboard_def_event_cb); lv_obj_add_event_cb(g_lv.keyboard, kbBackspaceSelCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_lv.keyboard, lv_keyboard_def_event_cb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(g_lv.keyboard, keyboardCb, LV_EVENT_READY, nullptr); lv_obj_add_event_cb(g_lv.keyboard, keyboardCb, LV_EVENT_CANCEL, nullptr); lv_obj_add_event_cb(g_lv.keyboard, keyboardCb, LV_EVENT_VALUE_CHANGED, nullptr); // Hold a letter -> accent picker (issue #22). LONG_PRESSED fires on the held // key; the keys are NO_REPEAT so the hold doesn't auto-type. lv_obj_add_event_cb(g_lv.keyboard, accentLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); // Dark-theme keyboard styling lv_obj_set_style_bg_color(g_lv.keyboard, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_color(g_lv.keyboard, lv_color_hex(0x1B2B3A), LV_PART_ITEMS); lv_obj_set_style_border_color(g_lv.keyboard, lv_color_hex(0x18191A), LV_PART_ITEMS); lv_obj_set_style_text_color(g_lv.keyboard, lv_color_hex(COLOR_TEXT), LV_PART_ITEMS); // Key labels use the extended font (montserrat + Cyrillic/Greek/Arabic fallback) // so the secondary keyboard layouts render real glyphs instead of tofu boxes. // Plain LV_FONT_DEFAULT (montserrat_14, no fallback) showed non-Latin keys as ▯. // g_font_14 is the same size, so key sizing/spacing is unchanged. lv_obj_set_style_text_font(g_lv.keyboard, &g_font_14, LV_PART_ITEMS); lv_obj_set_style_bg_color(g_lv.keyboard, lv_color_hex(COLOR_ACCENT), LV_PART_ITEMS | LV_STATE_PRESSED); // Pre-warm the keyboard: force LVGL to compute its full button-matrix // layout NOW so the first tap on a textarea doesn't pay the ~30+ key // first-render cost. Without this the first keyboard show in the // session lagged ~300-500 ms — most noticeable on the map → chat // marker-open path because freeMapTiles + tab anim were already // competing for the same refresh tick. lv_obj_update_layout(g_lv.keyboard); kbMirrorEnsureCreated(); // ---- Rotation arrows (shown only while the keyboard is up) ---- // Two small chevron buttons that toggle landscape typing in either // direction. Tap a side once to rotate, tap the same side again to return // to portrait. Choice persists via NVS so the user's preferred landscape // direction comes back the next time the keyboard opens. auto makeRotBtn = [](const char* sym, lv_event_cb_t cb) { lv_obj_t* b = lv_btn_create(lv_layer_top()); lv_obj_set_size(b, 32, 26); lv_obj_set_style_bg_color(b, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_border_color(b, lv_color_hex(0x2A3D52), LV_PART_MAIN); lv_obj_set_style_border_width(b, 1, LV_PART_MAIN); lv_obj_set_style_radius(b, 6, LV_PART_MAIN); lv_obj_set_style_pad_all(b, 0, LV_PART_MAIN); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); useChainedFont(l); lv_label_set_text(l, sym); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_center(l); lv_obj_add_flag(b, LV_OBJ_FLAG_HIDDEN); return b; }; // Both buttons use the rotation/refresh glyph; left vs. right position // tells the user which way the display will turn. s_kb_rot_left_btn = makeRotBtn(LV_SYMBOL_REFRESH, kbRotLeftCb); s_kb_rot_right_btn = makeRotBtn(LV_SYMBOL_REFRESH, kbRotRightCb); // On-screen language-cycle key (boards without a physical keyboard). Its label // is the active layout's 2-letter code; kbShowRotateArrows reveals it only when // a secondary layout is enabled. s_kb_lang_btn = makeRotBtn(kbLayoutCode(keyboardLayoutsGetCurrent()), kbLangCycleCb); s_kb_lang_lbl = lv_obj_get_child(s_kb_lang_btn, 0); // (The on-screen "á" accent key was removed — the tap-to-pick accent box that // pops up automatically when you type an accentable letter replaces it. The // s_kb_alt_btn pointer stays null so every guarded reference below no-ops.) // Restore saved keyboard rotation preference (portrait by default). #if defined(ESP32) { SdNvsPrefs pr; if (pr.begin("meshTouch", true)) { uint8_t saved = pr.getUChar("kbrot", 0); pr.end(); if (saved == LV_DISP_ROT_90 || saved == LV_DISP_ROT_270) s_kb_rotation = saved; else s_kb_rotation = LV_DISP_ROT_NONE; } } #endif // Apply saved keyboard preferences at boot. #if defined(ESP32) { uint16_t en_mask = touchPrefsGetEnabledLayouts(); keyboardLayoutsSetEnabledMask(en_mask); uint8_t saved_layout = touchPrefsGetKeyboardLayout(); // If the saved active layout isn't one of the enabled set any more, fall // back to English. if (saved_layout != static_cast<uint8_t>(KeyboardLayoutId::EN) && !(en_mask & (1u << saved_layout))) { saved_layout = static_cast<uint8_t>(KeyboardLayoutId::EN); } keyboardLayoutsApply(g_lv.keyboard, static_cast<KeyboardLayoutId>(saved_layout)); } #endif // Temporary live diagnostics overlay: loop and touch-read counters. s_live_diag_label = lv_label_create(lv_layer_top()); lv_obj_set_width(s_live_diag_label, 236); // Sit just BELOW the global status bar — that bar lives on lv_layer_sys // (above lv_layer_top), so a top-aligned overlay at y=2 would be hidden // underneath it. lv_obj_align(s_live_diag_label, LV_ALIGN_TOP_LEFT, 2, STATUSBAR_H + 2); lv_obj_set_style_text_font(s_live_diag_label, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_live_diag_label, lv_color_hex(0xC7D2DE), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_live_diag_label, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(s_live_diag_label, lv_color_hex(0x0A0B0C), LV_PART_MAIN); lv_obj_set_style_pad_hor(s_live_diag_label, 4, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_live_diag_label, 2, LV_PART_MAIN); lv_obj_set_style_radius(s_live_diag_label, 4, LV_PART_MAIN); lv_label_set_text(s_live_diag_label, TR("diag boot...")); if (!k_show_live_diag_overlay) lv_obj_add_flag(s_live_diag_label, LV_OBJ_FLAG_HIDDEN); #if CAP_TRACKBALL // Trackball cursor: a small ring on the system layer (above the status bar and // modals) so it's always visible. Non-interactive — the click is delivered as // a touch via lvglTouchRead. Hidden until the trackball is moved/clicked. s_tb_cursor = lv_obj_create(lv_layer_sys()); lv_obj_remove_style_all(s_tb_cursor); lv_obj_set_size(s_tb_cursor, kTbCursorDiameter, kTbCursorDiameter); lv_obj_set_style_radius(s_tb_cursor, kTbCursorDiameter / 2, LV_PART_MAIN); lv_obj_set_style_bg_color(s_tb_cursor, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_tb_cursor, LV_OPA_50, LV_PART_MAIN); lv_obj_set_style_border_width(s_tb_cursor, 2, LV_PART_MAIN); lv_obj_set_style_border_color(s_tb_cursor, lv_color_white(), LV_PART_MAIN); lv_obj_set_style_border_opa(s_tb_cursor, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_tb_cursor, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(s_tb_cursor, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_tb_cursor, LV_OBJ_FLAG_HIDDEN); s_tb_cursor_x = lv_disp_get_hor_res(nullptr) / 2; s_tb_cursor_y = lv_disp_get_ver_res(nullptr) / 2; s_tb_target_x = s_tb_render_x = (float)s_tb_cursor_x; s_tb_target_y = s_tb_render_y = (float)s_tb_cursor_y; s_tb_reverse = touchPrefsGetScrollReverse(); s_tb_edge_scroll = touchPrefsGetEdgeScroll(); #endif // ---- Initial data population ---- refreshThreadLists(); // Pre-build the Contacts list at boot so the first tab switch into Contacts // doesn't pay the rebuild cost on the foreground; the cache inside // refreshContactsList will keep subsequent switches free. refreshContactsList(); refreshStatusLabels(); if (g_lv.tabview) lv_tabview_set_act(g_lv.tabview, saved_tab, LV_ANIM_OFF); s_lv_tab_prev = static_cast<int>(saved_tab); if (g_lv.task) g_lv.task->onLvTabChanged(static_cast<int>(saved_tab)); buildBootSplash(); pushDiagLine("UI ready"); } #endif // HAS_TOUCH_UI // ============================================================ // UITask method stubs (non-LVGL screen management shims) // ============================================================ char UITask::checkDisplayOn(char c) { return c; } char UITask::handleLongPress(char c) { return c; } char UITask::handleDoubleClick(char c) { return c; } char UITask::handleTripleClick(char c) { return c; } void UITask::setCurrScreen(UIScreen* c) { curr = c; } void UITask::stepComposerChar(int delta) { _composer_char_idx += delta; } void UITask::stepComposerAction(int delta) { _composer_action_idx += delta; } void UITask::userLedHandler() {} void UITask::discoveredContact(const ContactInfo& contact, bool is_new, uint8_t path_len) { #if !defined(HAS_TANMATSU) s_web_rx_nudge = true; #endif /* `is_new=false` → contact is already in contacts[]; nothing to do (the * Discovered modal only shows pending nodes that haven't been added yet). * On `is_new=true` we either update the existing slot for this pubkey or * claim a free / least-recently-used slot. */ if (!is_new) { // Refresh in_contacts flag if we happen to have a stale entry for it. for (int i = 0; i < DISCOVERED_MAX; ++i) { if (s_discovered[i].used && memcmp(s_discovered[i].ci.id.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) { s_discovered[i].in_contacts = true; break; } } return; } #if defined(ESP32) // Hop filter: auto-delete nodes heard via more hops than the configured limit // (0 = off). Drop the advert and remove any existing entry for this node. { const uint8_t maxhops = touchPrefsGetDiscoveredMaxHops(); if (maxhops > 0 && path_len > maxhops) { for (int i = 0; i < DISCOVERED_MAX; ++i) if (s_discovered[i].used && memcmp(s_discovered[i].ci.id.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) s_discovered[i].used = false; markDiscoveredDirty(); return; } } #endif int slot = -1; // Match by pubkey first so repeated adverts update the same entry. for (int i = 0; i < DISCOVERED_MAX; ++i) { if (s_discovered[i].used && memcmp(s_discovered[i].ci.id.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) { slot = i; break; } } if (slot < 0) { // Free slot wins, otherwise overwrite the oldest entry. uint32_t oldest_ms = UINT32_MAX; int oldest_idx = 0; for (int i = 0; i < DISCOVERED_MAX; ++i) { if (!s_discovered[i].used) { slot = i; break; } if (s_discovered[i].recv_ms < oldest_ms) { oldest_ms = s_discovered[i].recv_ms; oldest_idx = i; } } if (slot < 0) { // Ring full. Evict the oldest only if "auto-delete oldest" is on (default), // so the list stays fresh; otherwise keep the existing list and drop this // new node (the badge shows "N!" to flag that it's full). #if defined(ESP32) if (!touchPrefsGetDiscoveredAutoEvict()) return; #endif slot = oldest_idx; } } s_discovered[slot].used = true; s_discovered[slot].in_contacts = false; s_discovered[slot].path_len = path_len; s_discovered[slot].last_advert_ts = contact.last_advert_timestamp; s_discovered[slot].recv_ms = (uint32_t)millis(); s_discovered[slot].ci = contact; ++s_discovered_seq; markDiscoveredDirty(); // persist across reboots (rate-capped write) } void UITask::onThreadsChanged() { /* Force the periodic mesh-thread refresh to run on the next loop iter so * channels / contacts added via the companion-serial protocol show up * immediately instead of after the 4 s backstop. */ _next_mesh_thread_refresh = 0; #if !defined(HAS_TANMATSU) /* Nudge any Contacts/Chats web client to refetch (new/changed message). This runs * in the mesh callback context; flag it and let the UI loop do the pushTermData so * the data ring stays single-producer (the UI loop is the only other producer). */ s_web_rx_nudge = true; #endif } void UITask::onPingReply(const ContactInfo& contact, const uint8_t* data, size_t len) { s_ui_ping_deadline_ms = 0; // reply arrived, cancel timeout unsigned batt_mv = 0; unsigned uptime_s = 0; unsigned queue_len = 0; int16_t last_rssi = 0; bool parsed = false; /* Repeaters reply with a packed binary RepeaterStats struct: * u16 batt_mv, u16 queue_len, i16 noise_floor, i16 last_rssi, * u32 n_recv, u32 n_sent, u32 air_secs, u32 uptime_secs, ... * Room servers and sensors reply with a JSON blob via StatsFormatHelper. */ if (len >= 20) { batt_mv = (unsigned)(data[0] | (data[1] << 8)); queue_len = (unsigned)(data[2] | (data[3] << 8)); last_rssi = (int16_t)(data[6] | (data[7] << 8)); // uptime_secs is at offset 16 (after 2+2+2+2 + 4+4 = 16) uptime_s = (unsigned)(data[16] | (data[17] << 8) | (data[18] << 16) | (data[19] << 24)); if (batt_mv >= 2000 && batt_mv <= 5500) parsed = true; } if (!parsed) { // JSON fallback for non-repeater nodes char body[160]; size_t copy_len = len < sizeof(body) - 1 ? len : sizeof(body) - 1; memcpy(body, data, copy_len); body[copy_len] = '\0'; const char* p; if ((p = strstr(body, "\"battery_mv\":")) != nullptr) { sscanf(p + 13, "%u", &batt_mv); } if ((p = strstr(body, "\"uptime_secs\":")) != nullptr) { sscanf(p + 14, "%u", &uptime_s); } if ((p = strstr(body, "\"queue_len\":")) != nullptr) { sscanf(p + 12, "%u", &queue_len); } if (batt_mv > 0 || uptime_s > 0) parsed = true; } char nm[24]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), contact.name[0] ? contact.name : "node"); char msg[120]; if (parsed) { if (uptime_s >= 3600) { snprintf(msg, sizeof(msg), "%s: %u.%02uV up %uh%02um q=%u rssi=%d", nm, batt_mv / 1000, (batt_mv % 1000) / 10, uptime_s / 3600, (uptime_s % 3600) / 60, queue_len, (int)last_rssi); } else { snprintf(msg, sizeof(msg), "%s: %u.%02uV up %um q=%u rssi=%d", nm, batt_mv / 1000, (batt_mv % 1000) / 10, uptime_s / 60, queue_len, (int)last_rssi); } } else { snprintf(msg, sizeof(msg), "%s: reply (%u bytes)", nm, (unsigned)len); } showAlert(msg, 5000); } // Telemetry response window — a dismissible card (same style as the map-options // popup) instead of a single-slot toast, so the full multi-line reading stays on // screen until tapped away and can scroll if it's long. Tap the dimmed backdrop // to close. #if CAP_SD || defined(TLORA_PAGER) // telemetry window + per-node log/poll use the mounted SD store static lv_obj_t* s_telemetry_root = nullptr; static lv_obj_t* s_telem_config_root = nullptr; // settings panel spawned on top static lv_obj_t* s_telem_poll_ta = nullptr; // auto-poll interval input (config panel) static bool s_telem_show_batt = true; // chart series visibility (session-scoped) static bool s_telem_show_temp = true; static bool s_telem_show_hum = true; static void openTelemetryConfigWindow(); // fwd static void telemetryConfigClose() { if (s_telem_config_root) { popupClose(&s_telem_config_root); s_telem_poll_ta = nullptr; } } static void telemetryClose() { telemetryConfigClose(); if (s_telemetry_root) { popupClose(&s_telemetry_root); } } static void telemetryWindowDismissCb(lv_event_t* e) { if (lv_event_get_target(e) != s_telemetry_root) return; // backdrop only telemetryClose(); } static void telemetryCloseCb(lv_event_t* e) { (void)e; telemetryClose(); } // the X badge // ---------- Telemetry auto-poll (per node; persisted /meshcomod/telemetry/poll.cfg) ---------- struct TelemPoll { uint8_t key[6]; uint16_t interval_min; uint32_t next_ms; bool used; }; static const int k_telem_poll_max = 8; static TelemPoll s_telem_poll[k_telem_poll_max]; static bool s_telem_poll_loaded = false; static bool telemetryFindContact(const uint8_t* key6, ContactInfo* out) { const uint32_t n = the_mesh.getNumContacts(); for (uint32_t i = 0; i < n; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (memcmp(c.id.pub_key, key6, 6) == 0) { *out = c; return true; } } return false; } static void telemetryPollLoad() { if (s_telem_poll_loaded) return; s_telem_poll_loaded = true; for (auto& e : s_telem_poll) e.used = false; if (SD.cardType() == CARD_NONE) return; File f = SD.open("/meshcomod/telemetry/poll.cfg", FILE_READ); if (!f) return; int idx = 0; while (f.available() && idx < k_telem_poll_max) { String ln = f.readStringUntil('\n'); ln.trim(); if (ln.length() < 14) continue; // 12 hex + space + >=1 digit TelemPoll& e = s_telem_poll[idx]; for (int b = 0; b < 6; ++b) e.key[b] = (uint8_t)strtoul(ln.substring(b * 2, b * 2 + 2).c_str(), nullptr, 16); const int mn = ln.substring(13).toInt(); if (mn <= 0) continue; e.interval_min = (uint16_t)mn; e.next_ms = millis() + 5000; // first poll shortly after load e.used = true; ++idx; } f.close(); } static void telemetryPollSave() { if (SD.cardType() == CARD_NONE) return; markSdIo(); SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/telemetry"); File f = SD.open("/meshcomod/telemetry/poll.cfg", FILE_WRITE); if (!f) return; for (auto& e : s_telem_poll) if (e.used) f.printf("%02X%02X%02X%02X%02X%02X %u\n", e.key[0], e.key[1], e.key[2], e.key[3], e.key[4], e.key[5], (unsigned)e.interval_min); f.close(); } static int telemetryPollGet(const uint8_t* key6) { telemetryPollLoad(); for (auto& e : s_telem_poll) if (e.used && memcmp(e.key, key6, 6) == 0) return e.interval_min; return 0; } static void telemetryPollSet(const uint8_t* key6, int interval_min) { telemetryPollLoad(); for (auto& e : s_telem_poll) if (e.used && memcmp(e.key, key6, 6) == 0) e.used = false; // drop old if (interval_min > 0) for (auto& e : s_telem_poll) if (!e.used) { memcpy(e.key, key6, 6); e.interval_min = (uint16_t)interval_min; e.next_ms = millis() + 3000; e.used = true; break; } telemetryPollSave(); } static void telemetryPollTick(uint32_t now_ms) { if (SD.cardType() == CARD_NONE) return; // Hold auto-poll while a manual request is awaiting its reply — an auto-poll // REQ would overwrite the manual request's pending tag and orphan its reply. // It'll poll on the next interval (the manual pending window is short). if (s_telem_manual_pending) return; telemetryPollLoad(); for (auto& e : s_telem_poll) { if (!e.used || (int32_t)(now_ms - e.next_ms) < 0) continue; e.next_ms = now_ms + (uint32_t)e.interval_min * 60000u; ContactInfo c; if (telemetryFindContact(e.key, &c)) { const int r = the_mesh.sendTelemetryRequestWithGuestLoginForUI(c); // logged on reply; no window if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) markMeshRequest(); } } } // ---------- Telemetry window ---------- static lv_obj_t* s_telem_now_ta = nullptr; static lv_obj_t* s_telem_past_ta = nullptr; static void telemetryRebuild() { openTelemetryWindow(s_telem_node, s_telem_name, s_telem_state); if (s_telem_config_root) lv_obj_move_foreground(s_telem_config_root); // keep the panel on top } static void telemWinApplyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; if (s_telem_now_ta) s_telem_win_now_h = atoi(lv_textarea_get_text(s_telem_now_ta)); if (s_telem_past_ta) s_telem_win_past_h = atoi(lv_textarea_get_text(s_telem_past_ta)); if (s_telem_win_now_h < 0) s_telem_win_now_h = 0; if (s_telem_win_past_h <= s_telem_win_now_h) s_telem_win_past_h = s_telem_win_now_h + 1; telemetryRebuild(); } // Send a telemetry request for the window's node now (the manual Request button — // the window no longer auto-requests on open). Mirrors actionSheetTelemetryCb's // send, but drives the telemetry window's own pending/deadline state. static void telemetryRequestNow() { if (!g_lv.task) return; // Single-flight: a manual request for this node is already awaiting its reply. // Re-firing would send a fresh LOGIN+REQ and overwrite the in-flight pending // tag, orphaning the first reply — just keep waiting on the one in progress. if (s_telem_manual_pending) return; ContactInfo c; if (!telemetryFindContact(s_telem_node, &c)) { openTelemetryWindow(s_telem_node, s_telem_name, TELEM_FAILED); return; } // Send the guest LOGIN now and defer the telemetry REQ until the repeater // acknowledges it (the REQ fires from onContactResponse on the LOGIN-OK, by // which point we're ACL'd so it lands first try — no more "tap twice"). const int r = the_mesh.uiSendRequestAfterGuestLogin(c, MyMesh::UiReqKind::Telemetry); s_telem_reading[0] = '\0'; if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { s_telem_manual_pending = true; s_telem_deadline_ms = millis() + UI_PING_TIMEOUT_MS; markMeshRequest(); openTelemetryWindow(s_telem_node, s_telem_name, TELEM_IN_PROGRESS); } else { s_telem_manual_pending = false; openTelemetryWindow(s_telem_node, s_telem_name, TELEM_FAILED); } } static void telemReqCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; telemetryRequestNow(); } // Config panel: gear opens it, a checkbox toggles a chart series, the textfield // sets the auto-poll interval. static void telemGearCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openTelemetryConfigWindow(); } static void telemCfgDismissCb(lv_event_t* e) { if (lv_event_get_target(e) != s_telem_config_root) return; // backdrop only telemetryConfigClose(); telemetryRebuild(); } static void telemCfgCloseCb(lv_event_t* e) { (void)e; telemetryConfigClose(); telemetryRebuild(); } static void telemShowToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; bool* flag = (bool*)lv_event_get_user_data(e); if (flag) *flag = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); telemetryRebuild(); // redraw the chart immediately; the panel stays on top } static void telemPollApplyCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; int mins = s_telem_poll_ta ? atoi(lv_textarea_get_text(s_telem_poll_ta)) : 0; if (mins < 0) mins = 0; if (mins > 1440) mins = 1440; telemetryPollSet(s_telem_node, mins); // 0 disables auto-poll for this node } static void telemClearConfirmed() { char path[40]; telemetryNodePath(s_telem_node, path, sizeof path); if (SD.cardType() != CARD_NONE) { markSdIo(); SD.remove(path); } telemetryRebuild(); } static void telemClearCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; showConfirm(TR("Clear telemetry history for this node?"), TR("Clear"), telemClearConfirmed); } static void openTelemetryWindow(const uint8_t* key6, const char* name, int state) { if (s_telemetry_root) { popupClose(&s_telemetry_root); } s_telem_now_ta = s_telem_past_ta = nullptr; if (key6 && key6 != s_telem_node) memcpy(s_telem_node, key6, 6); if (name && name != s_telem_name) { strncpy(s_telem_name, name, sizeof s_telem_name - 1); s_telem_name[sizeof s_telem_name - 1] = '\0'; } s_telem_state = state; const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_telemetry_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_telemetry_root); lv_obj_set_size(s_telemetry_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_telemetry_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_telemetry_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_telemetry_root, LV_OPA_50, LV_PART_MAIN); lv_obj_clear_flag(s_telemetry_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_telemetry_root, telemetryWindowDismissCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t cardw = sw - 16; lv_obj_t* card = lv_obj_create(s_telemetry_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, cardw, sh - STATUSBAR_H - 12); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 6); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 10, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); addCloseXBadge(card, telemetryCloseCb); // Gear (left of the X) opens the settings panel: show/hide data, auto-poll, clear. { lv_obj_t* gear = lv_obj_create(card); lv_obj_remove_style_all(gear); lv_obj_set_size(gear, 32, 32); lv_obj_align(gear, LV_ALIGN_TOP_RIGHT, -34, 0); lv_obj_set_style_bg_opa(gear, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_bg_color(gear, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(gear, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(gear, 16, LV_PART_MAIN); lv_obj_add_flag(gear, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(gear, LV_OBJ_FLAG_FLOATING); lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(gear, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(gear); lv_obj_add_event_cb(gear, telemGearCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* gl = lv_label_create(gear); lv_label_set_text(gl, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_font(gl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(gl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_align(gl, LV_ALIGN_TOP_RIGHT, -8, 4); } // Request (left of the gear) — same async-request glyph; sends a telemetry poll. { lv_obj_t* req = lv_obj_create(card); lv_obj_remove_style_all(req); lv_obj_set_size(req, 32, 32); lv_obj_align(req, LV_ALIGN_TOP_RIGHT, -68, 0); lv_obj_set_style_bg_opa(req, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_bg_color(req, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(req, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_radius(req, 16, LV_PART_MAIN); lv_obj_add_flag(req, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(req, LV_OBJ_FLAG_FLOATING); lv_obj_add_flag(req, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(req, LV_OBJ_FLAG_SCROLLABLE); lv_obj_move_foreground(req); lv_obj_add_event_cb(req, telemReqCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* rl = lv_label_create(req); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_set_style_text_font(rl, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_align(rl, LV_ALIGN_TOP_RIGHT, -8, 4); } // Header: name + state. lv_obj_t* title = lv_label_create(card); const char* stxt = (state == TELEM_IN_PROGRESS) ? " \xe2\x80\x94 requesting\xe2\x80\xa6" : (state == TELEM_FAILED) ? " \xe2\x80\x94 last request failed" : ""; lv_label_set_text_fmt(title, "%s%s", name && name[0] ? name : "node", stxt); lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(state == TELEM_FAILED ? 0xE08080 : COLOR_TEXT), LV_PART_MAIN); lv_obj_set_width(title, cardw - 20 - 98); // clear the request, gear and X badges lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); lv_obj_set_pos(title, 0, 0); int y = 22; if (state == TELEM_RECEIVED && s_telem_reading[0]) { lv_obj_t* rl = lv_label_create(card); lv_label_set_long_mode(rl, LV_LABEL_LONG_WRAP); lv_obj_set_width(rl, cardw - 20); lv_label_set_text(rl, s_telem_reading); lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(rl, 0, y); lv_obj_update_layout(rl); // measure the wrapped height so the chart clears it y += lv_obj_get_height(rl) + 6; } // ---- Read the per-node log into the chosen display window ---- static const int k_max = 300; static uint16_t mvs[k_max]; static int16_t t10s[k_max]; static int16_t hums[k_max]; int n = 0; const uint32_t now = (uint32_t)time(nullptr); const uint32_t age_lo = (uint32_t)s_telem_win_now_h * 3600u; const uint32_t age_hi = (uint32_t)s_telem_win_past_h * 3600u; if (SD.cardType() != CARD_NONE) { markSdIo(); char path[40]; telemetryNodePath(s_telem_node, path, sizeof path); File rf = SD.open(path, FILE_READ); if (rf) { while (rf.available() && n < k_max) { String ln = rf.readStringUntil('\n'); if (ln.length() == 0) continue; const uint32_t ep = (uint32_t)strtoul(ln.c_str(), nullptr, 10); if (now > 1700000000 && ep != 0) { const uint32_t age = (now > ep) ? (now - ep) : 0; if (age < age_lo || age > age_hi) continue; // outside the display window } const int t1 = ln.indexOf('\t'); const int t2 = (t1 >= 0) ? ln.indexOf('\t', t1 + 1) : -1; const int t3 = (t2 >= 0) ? ln.indexOf('\t', t2 + 1) : -1; const int t4 = (t3 >= 0) ? ln.indexOf('\t', t3 + 1) : -1; if (t2 < 0 || t3 < 0 || t4 < 0) continue; mvs[n] = (uint16_t)ln.substring(t2 + 1, t3).toInt(); t10s[n] = (int16_t)ln.substring(t3 + 1, t4).toInt(); hums[n] = (int16_t)ln.substring(t4 + 1).toInt(); ++n; } rf.close(); } } // ---- Chart (battery V left; temperature + humidity right) ---- const int chart_x = 30, chart_rpad = 30, chart_h = 108; lv_obj_t* chart = nullptr; if (n > 0) { chart = lv_chart_create(card); lv_obj_set_size(chart, cardw - 20 - chart_x - chart_rpad, chart_h); lv_obj_set_pos(chart, chart_x, y + 4); lv_obj_clear_flag(chart, LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, n); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 3400, 4700); // battery mV (blue) lv_chart_set_range(chart, LV_CHART_AXIS_SECONDARY_Y, -20, 100); // temp degC + humidity % lv_chart_set_div_line_count(chart, 4, 6); lv_obj_set_style_bg_color(chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(chart, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(chart, 1, LV_PART_MAIN); lv_obj_set_style_radius(chart, 6, LV_PART_MAIN); lv_obj_set_style_line_color(chart, lv_color_hex(0x1A1D1F), LV_PART_MAIN); lv_obj_set_style_size(chart, 3, LV_PART_INDICATOR); lv_obj_set_style_pad_top(chart, 6, LV_PART_MAIN); lv_obj_set_style_pad_bottom(chart, 6, LV_PART_MAIN); lv_obj_set_style_text_font(chart, &g_font_12, LV_PART_TICKS); lv_obj_set_style_text_color(chart, lv_color_hex(COLOR_SUB), LV_PART_TICKS); lv_obj_set_style_line_color(chart, lv_color_hex(0x2A2E30), LV_PART_TICKS); lv_obj_set_style_pad_left(chart, 4, LV_PART_TICKS); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_Y, 4, 0, 4, 1, true, 40); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_SECONDARY_Y, 4, 0, 4, 1, true, 36); lv_obj_add_event_cb(chart, battChartTickCb, LV_EVENT_DRAW_PART_BEGIN, nullptr); // primary -> volts // Only the series the user opted to show (toggled in the settings panel). lv_chart_series_t* bs = s_telem_show_batt ? lv_chart_add_series(chart, lv_color_hex(0x4F9DF7), LV_CHART_AXIS_PRIMARY_Y) : nullptr; // battery blue lv_chart_series_t* ts = s_telem_show_temp ? lv_chart_add_series(chart, lv_color_hex(0xF5A623), LV_CHART_AXIS_SECONDARY_Y) : nullptr; // temp orange lv_chart_series_t* hs = s_telem_show_hum ? lv_chart_add_series(chart, lv_color_hex(0x35C9C9), LV_CHART_AXIS_SECONDARY_Y) : nullptr; // humidity cyan for (int i = 0; i < n; ++i) { if (bs) { lv_coord_t v = (lv_coord_t)mvs[i]; if (v > 4700) v = 4700; lv_chart_set_next_value(chart, bs, mvs[i] > 0 ? v : LV_CHART_POINT_NONE); } if (ts) lv_chart_set_next_value(chart, ts, t10s[i] > -3000 ? (lv_coord_t)(t10s[i] / 10) : LV_CHART_POINT_NONE); if (hs) lv_chart_set_next_value(chart, hs, hums[i] >= 0 ? (lv_coord_t)hums[i] : LV_CHART_POINT_NONE); } // legend (only the visible series) char leg[112]; leg[0] = '\0'; if (s_telem_show_batt) strcat(leg, "#4F9DF7 \xe2\x97\x8f# V "); if (s_telem_show_temp) strcat(leg, "#F5A623 \xe2\x97\x8f# \xc2\xb0""C "); if (s_telem_show_hum) strcat(leg, "#35C9C9 \xe2\x97\x8f# %RH"); if (!leg[0]) strcpy(leg, "#808080 (nothing shown \xe2\x80\x94 enable a series in settings)#"); lv_obj_t* lg = lv_label_create(card); lv_label_set_recolor(lg, true); lv_label_set_text(lg, leg); lv_obj_set_style_text_font(lg, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lg, lv_color_hex(COLOR_SUB), LV_PART_MAIN); // match the axis notes (not dark) lv_obj_align_to(lg, chart, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 2); y += chart_h + 28; // clear the chart + its legend line } else { lv_obj_t* none = lv_label_create(card); lv_label_set_text(none, TR("No telemetry in this window.\nRequest one, or widen the range below.")); lv_obj_set_style_text_color(none, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(none, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(none, 0, y + 6); y += 44; } // ---- Display-window fields: description on its own line, inputs on the next ---- // "from" = older bound (larger hours-ago), "to" = newer bound (smaller). Auto-poll, // visibility toggles and clear-history all live in the settings panel (gear). { lv_obj_t* lab = lv_label_create(card); lv_label_set_text(lab, TR("Time range (hours ago)")); lv_obj_set_style_text_font(lab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lab, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(lab, 0, y); y += 18; lv_obj_t* lf = lv_label_create(card); lv_label_set_text(lf, TR("from")); lv_obj_set_style_text_font(lf, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lf, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(lf, 0, y + 8); s_telem_past_ta = lv_textarea_create(card); lv_textarea_set_one_line(s_telem_past_ta, true); lv_textarea_set_accepted_chars(s_telem_past_ta, "0123456789"); lv_textarea_set_max_length(s_telem_past_ta, 4); lv_obj_set_width(s_telem_past_ta, 44); lv_obj_set_pos(s_telem_past_ta, 38, y); attachSettingsTaEvents(s_telem_past_ta); char b1[8]; snprintf(b1, sizeof b1, "%d", s_telem_win_past_h); lv_textarea_set_text(s_telem_past_ta, b1); lv_obj_t* lt = lv_label_create(card); lv_label_set_text(lt, TR("h to")); lv_obj_set_style_text_font(lt, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lt, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(lt, 88, y + 8); s_telem_now_ta = lv_textarea_create(card); lv_textarea_set_one_line(s_telem_now_ta, true); lv_textarea_set_accepted_chars(s_telem_now_ta, "0123456789"); lv_textarea_set_max_length(s_telem_now_ta, 4); lv_obj_set_width(s_telem_now_ta, 44); lv_obj_set_pos(s_telem_now_ta, 132, y); attachSettingsTaEvents(s_telem_now_ta); char b0[8]; snprintf(b0, sizeof b0, "%d", s_telem_win_now_h); lv_textarea_set_text(s_telem_now_ta, b0); lv_obj_t* lh = lv_label_create(card); lv_label_set_text(lh, "h"); lv_obj_set_style_text_font(lh, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lh, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(lh, 182, y + 8); lv_obj_t* showb = lv_btn_create(card); lv_obj_set_size(showb, 64, 32); lv_obj_set_pos(showb, 200, y); styleButton(showb); lv_obj_add_event_cb(showb, telemWinApplyCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* sl = lv_label_create(showb); lv_label_set_text(sl, TR("Show")); lv_obj_center(sl); useChainedFont(sl); y += 42; } } // Settings panel for the telemetry window: which series to chart, the auto-poll // interval (manual minutes; 0 = off), and a clear-history button. Spawned on top // of the telemetry window; toggling a checkbox redraws the chart underneath live. static void openTelemetryConfigWindow() { if (s_telem_config_root) { popupClose(&s_telem_config_root); s_telem_poll_ta = nullptr; } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); s_telem_config_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_telem_config_root); lv_obj_set_size(s_telem_config_root, sw, sh - STATUSBAR_H); lv_obj_set_pos(s_telem_config_root, 0, STATUSBAR_H); lv_obj_set_style_bg_color(s_telem_config_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_telem_config_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_telem_config_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_telem_config_root, telemCfgDismissCb, LV_EVENT_CLICKED, nullptr); const lv_coord_t cardw = sw - 48; lv_obj_t* card = lv_obj_create(s_telem_config_root); lv_obj_remove_style_all(card); lv_obj_set_size(card, cardw, LV_SIZE_CONTENT); lv_obj_set_style_max_height(card, sh - STATUSBAR_H - 24, LV_PART_MAIN); lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 16); styleSurface(card, COLOR_PANEL, 8); lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_set_scroll_dir(card, LV_DIR_VER); addCloseXBadge(card, telemCfgCloseCb); lv_obj_t* title = lv_label_create(card); lv_label_set_text(title, TR("Telemetry settings")); lv_obj_set_style_text_font(title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); int y = 30; // --- Show on chart (checkboxes) --- lv_obj_t* sh1 = lv_label_create(card); lv_label_set_text(sh1, TR("Show on chart")); lv_obj_set_style_text_font(sh1, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(sh1, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(sh1, 0, y); y += 18; auto add_check = [&](const char* txt, bool* flag) { lv_obj_t* cb = lv_checkbox_create(card); lv_checkbox_set_text(cb, txt); lv_obj_set_style_text_color(cb, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(cb, &g_font_14, LV_PART_MAIN); lv_obj_set_style_radius(cb, 3, LV_PART_INDICATOR); lv_obj_set_style_border_color(cb, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); lv_obj_set_style_border_width(cb, 1, LV_PART_INDICATOR); lv_obj_set_style_bg_color(cb, lv_color_hex(0x4F9DF7), LV_PART_INDICATOR | LV_STATE_CHECKED); lv_obj_set_style_bg_opa(cb, LV_OPA_COVER, LV_PART_INDICATOR | LV_STATE_CHECKED); lv_obj_set_style_text_color(cb, lv_color_white(), LV_PART_INDICATOR | LV_STATE_CHECKED); if (*flag) lv_obj_add_state(cb, LV_STATE_CHECKED); lv_obj_add_event_cb(cb, telemShowToggleCb, LV_EVENT_VALUE_CHANGED, flag); lv_obj_set_pos(cb, 4, y); y += 30; }; add_check(TR("Battery (V)"), &s_telem_show_batt); add_check(TR("Temperature"), &s_telem_show_temp); add_check(TR("Humidity"), &s_telem_show_hum); y += 6; // --- Auto-poll (manual minutes; 0 = off) --- lv_obj_t* sh2 = lv_label_create(card); lv_label_set_text(sh2, TR("Auto-poll every")); lv_obj_set_style_text_font(sh2, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(sh2, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(sh2, 0, y + 8); s_telem_poll_ta = lv_textarea_create(card); lv_textarea_set_one_line(s_telem_poll_ta, true); lv_textarea_set_accepted_chars(s_telem_poll_ta, "0123456789"); lv_textarea_set_max_length(s_telem_poll_ta, 4); lv_obj_set_width(s_telem_poll_ta, 56); lv_obj_set_pos(s_telem_poll_ta, 120, y); attachSettingsTaEvents(s_telem_poll_ta); { char pb[8]; snprintf(pb, sizeof pb, "%d", telemetryPollGet(s_telem_node)); lv_textarea_set_text(s_telem_poll_ta, pb); } lv_obj_t* mlab = lv_label_create(card); lv_label_set_text(mlab, TR("min (0=off)")); lv_obj_set_style_text_font(mlab, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(mlab, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(mlab, 182, y + 8); lv_obj_t* ap = lv_btn_create(card); lv_obj_set_size(ap, cardw - 24, 32); lv_obj_set_pos(ap, 0, y + 42); styleButton(ap); lv_obj_set_style_bg_color(ap, lv_color_hex(COLOR_STATUS_OK), LV_PART_MAIN); lv_obj_set_style_bg_opa(ap, LV_OPA_40, LV_PART_MAIN); lv_obj_add_event_cb(ap, telemPollApplyCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* apl = lv_label_create(ap); lv_label_set_text(apl, TR("Apply auto-poll")); lv_obj_center(apl); useChainedFont(apl); y += 86; // --- Clear history --- lv_obj_t* clr = lv_btn_create(card); lv_obj_set_size(clr, cardw - 24, 32); lv_obj_set_pos(clr, 0, y); styleButton(clr); lv_obj_set_style_bg_color(clr, lv_color_hex(0xB23A48), LV_PART_MAIN); lv_obj_add_event_cb(clr, telemClearCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* cl = lv_label_create(clr); lv_label_set_text(cl, TR(LV_SYMBOL_TRASH " Clear history")); lv_obj_center(cl); useChainedFont(cl); } #endif // HAS_TDECK_GT911 (telemetry window) // Decode a CayenneLPP-formatted telemetry response into a human-readable // window. Walks the channels with LPPReader and concatenates whatever common // fields the payload contains (voltage / temperature / humidity / pressure / // GPS / etc). Unknown channels are skipped via LPPReader::skipData. void UITask::onTelemetryReply(const ContactInfo& contact, const uint8_t* data, size_t len) { s_ui_ping_deadline_ms = 0; // reply arrived, cancel timeout char nm[24]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof(nm), contact.name[0] ? contact.name : "node"); if (!data || len == 0) { char msg[64]; snprintf(msg, sizeof(msg), "%s: empty telemetry", nm); showAlert(msg, 4000); return; } // Build a single short summary string. snprintf with running offset keeps // it bounded; once the buffer is full subsequent writes are silently // truncated. char body[160]; int p = 0; const size_t body_cap = sizeof(body); auto bodyRoom = [&]() -> size_t { return (p >= 0 && (size_t)p < body_cap) ? (body_cap - (size_t)p) : 0; }; bool any = false; // Captured for the per-node telemetry log (sentinels = sensor absent). int tl_mv = 0, tl_t10 = -30000, tl_h = -1; // Cap len to 0xFF so LPPReader's uint8_t length doesn't truncate weirdly. uint8_t rd_len = (len > 250) ? 250 : (uint8_t)len; LPPReader rd(data, rd_len); uint8_t channel, type; while (rd.readHeader(channel, type)) { const char* sep = any ? " " : ""; switch (type) { case LPP_VOLTAGE: { float v = 0; if (!rd.readVoltage(v)) goto done; tl_mv = (int)(v * 1000.0f + 0.5f); if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%s%.2fV", sep, (double)v); any = true; break; } case LPP_TEMPERATURE: { float t = 0; if (!rd.readTemperature(t)) goto done; tl_t10 = (int)lroundf(t * 10.0f); if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%s%.1f\xc2\xb0\x43", sep, (double)t); any = true; break; } case LPP_RELATIVE_HUMIDITY: { float h = 0; if (!rd.readRelativeHumidity(h)) goto done; tl_h = (int)lroundf(h); if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%s%.0f%%RH", sep, (double)h); any = true; break; } case LPP_BAROMETRIC_PRESSURE: { float bp = 0; if (!rd.readPressure(bp)) goto done; if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%s%.0fhPa", sep, (double)bp); any = true; break; } case LPP_ALTITUDE: { float a = 0; if (!rd.readAltitude(a)) goto done; if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%salt%.0fm", sep, (double)a); any = true; break; } case LPP_GPS: { float lat = 0, lon = 0, alt = 0; if (!rd.readGPS(lat, lon, alt)) goto done; // Persist the position to the contact so they show on the map — telemetry // is the only location some nodes share (they don't flood position // adverts). Issue #27. Guard against a no-fix 0,0 and out-of-range junk. bool saved = false; if ((lat != 0.0f || lon != 0.0f) && lat >= -90.0f && lat <= 90.0f && lon >= -180.0f && lon <= 180.0f) { saved = the_mesh.uiSetContactGps(contact.id.pub_key, (int32_t)lroundf(lat * 1e6f), (int32_t)lroundf(lon * 1e6f)); } if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%s%s%.4f,%.4f", sep, saved ? LV_SYMBOL_GPS " " : "", (double)lat, (double)lon); any = true; break; } case LPP_ANALOG_INPUT: case LPP_ANALOG_OUTPUT: { // 2 bytes signed * 0.01. Render with channel id so the operator can // tell apart multiple analog readings on one node. float a = rd.readVoltage(a) ? a : 0; // same wire shape; reuse for parse // LPPReader doesn't expose an ANALOG reader, so we used readVoltage // (which is also 2 bytes). The math (multiplier=100, unsigned vs // signed) is close enough for a toast; flag the channel so users // know it's an analog reading. if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%sch%u=%.2f", sep, (unsigned)channel, (double)a); any = true; break; } case LPP_DIGITAL_INPUT: case LPP_DIGITAL_OUTPUT: case LPP_PRESENCE: case LPP_SWITCH: { // 1-byte boolean / state. Reads cursor manually because LPPReader // doesn't expose a digital reader. rd.skipData(type); // walks _pos past the value (1 byte for digital) if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%sch%u=bin", sep, (unsigned)channel); any = true; break; } case LPP_PERCENTAGE: { rd.skipData(type); // 1 byte if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%sch%u=pct", sep, (unsigned)channel); any = true; break; } case LPP_LUMINOSITY: case LPP_CONCENTRATION: case LPP_POWER: case LPP_DIRECTION: case LPP_CURRENT: { rd.skipData(type); // 2 bytes if (bodyRoom()) p += snprintf(body + p, bodyRoom(), "%sch%u t=%02X", sep, (unsigned)channel, (unsigned)type); any = true; break; } default: // Unknown / unused channel type — skip its bytes so the next header // parses correctly. If skipData walks past the buffer, the next // readHeader returns false and we exit. rd.skipData(type); break; } if (p > 0 && (size_t)p >= body_cap - 1) break; // body buffer full } done: { #if CAP_SD || defined(TLORA_PAGER) // Log every reply (manual or auto-poll) to the per-node telemetry file. if (any) telemetryLogAppend(contact.id.pub_key, (uint32_t)time(nullptr), tl_mv, tl_t10, tl_h); #endif // Always also include a short hex dump so we can compare what the // decoder produced against the raw wire bytes. Helps spot wrap/offset // errors and unknown LPP types in one shot. char hex[40]; int hp = 0; const int dump = (len < 12) ? (int)len : 12; for (int i = 0; i < dump && hp + 3 < (int)sizeof(hex); ++i) { hp += snprintf(hex + hp, sizeof(hex) - (size_t)hp, "%02X ", data[i]); } if (hp > 0 && hex[hp - 1] == ' ') hex[hp - 1] = '\0'; char msg[240]; if (any) { snprintf(msg, sizeof(msg), "%s: %s\n[%uB] %s", nm, body, (unsigned)len, hex); } else { snprintf(msg, sizeof(msg), "%s: %uB %s", nm, (unsigned)len, hex); } #if CAP_SD || defined(TLORA_PAGER) // Update the telemetry window ONLY for the node it's showing, and only when a // manual request is in flight — auto-poll just logs (above) without a window. if (s_telem_manual_pending && memcmp(s_telem_node, contact.id.pub_key, 6) == 0) { strncpy(s_telem_reading, msg, sizeof s_telem_reading - 1); s_telem_reading[sizeof s_telem_reading - 1] = '\0'; s_telem_manual_pending = false; s_telem_deadline_ms = 0; if (s_telemetry_root) openTelemetryWindow(s_telem_node, s_telem_name, TELEM_RECEIVED); } #else showAlert(msg, 7000); // non-SD build: no per-node window, keep the toast #endif } } void UITask::onServerClock(const ContactInfo& contact, uint32_t server_epoch) { // Server clock vs ours (#89): servers replay-guard on the newest timestamp // they have seen from us, so a device clock BEHIND the server's stored mark // silently kills logins/posts/keep-alives. Every LOGIN_OK carries the server's // clock in its first 4 bytes — compare and surface real skew. 5 min tolerance // covers LoRa RTT + queueing; genuine trouble is minutes to hours. const uint32_t ours = the_mesh.getRTCClock()->getCurrentTime(); const int32_t skew = (int32_t)(server_epoch - ours); const uint32_t mag = (skew < 0) ? (uint32_t)(-skew) : (uint32_t)skew; if (mag < 300) return; static uint32_t s_skew_warn_ms = 0; // once a minute, tops const uint32_t nowms = millis(); if (s_skew_warn_ms && (uint32_t)(nowms - s_skew_warn_ms) < 60000u) return; s_skew_warn_ms = nowms; char nm[28]; copyUtf8ReplacingMissingGlyphs(&g_font_14, nm, sizeof nm, contact.name); char msg[128]; if (mag < 7200) snprintf(msg, sizeof msg, TR("Device clock differs from \"%s\" by %lu min%s"), nm, (unsigned long)((mag + 30) / 60), skew > 0 ? TR(" (device behind)") : ""); else snprintf(msg, sizeof msg, TR("Device clock differs from \"%s\" by %lu h%s"), nm, (unsigned long)((mag + 1800) / 3600), skew > 0 ? TR(" (device behind)") : ""); showAlert(msg, 3200); } void UITask::onAdminLoginResult(const ContactInfo& contact, bool success, uint8_t perms) { // Which pending attempt, if any, is this the reply to? Disarming the watchdog // used to be unconditional, which was harmless while it only drove a toast — // but it means the once-a-minute background room self-heal, logging in to some // OTHER room, can call off the watchdog for the login the operator is actually // standing in front of. Now that the timeout opens a prompt, that would silently // lose the recovery (#267), so match the contact first. const bool for_admin_attempt = (memcmp(contact.id.pub_key, s_admin_pub32, 32) == 0); const bool for_room_relogin = (s_room_relogin_active && memcmp(contact.id.pub_key, s_room_relogin_pub32, 32) == 0); if (for_admin_attempt || for_room_relogin) loginWaitCancel(); // a reply arrived (#89) // Room read-only downgrade warning (issue #89 hardening): LOGIN_OK with the // zero-permission marker (server reply byte 6 == 2) means the room accepted us // as a READ-ONLY guest — our posts will be silently discarded with no ACK, so // sends would just spin forever with no clue. Surface it even for background // re-logins, which skip the prompt guard below. Throttled to once a minute. if (success && perms == 2 && contact.type == ADV_TYPE_ROOM) { static uint32_t s_ro_warn_ms = 0; const uint32_t nowms = millis(); if ((uint32_t)(nowms - s_ro_warn_ms) > 60000u) { s_ro_warn_ms = nowms; showAlert(TR("Room is read-only for you.\nJoin with the room password to post."), 2600); } } // Result of the room thread sheet's "Log in again". That path sends its login // without opening the prompt, so it never sets s_admin_pub32 and both outcomes // used to fall off the guard below — the row looked identical whether it had // recovered the session or done nothing at all (#267). if (for_room_relogin) { roomReloginClear(); if (success) { char msg[80]; snprintf(msg, sizeof msg, TR("Reconnected to %.40s"), contact.name); showAlert(msg, 1400); return; } // An explicit rejection means the blank login was understood and refused, so // waiting out the remaining watchdog would only delay the same conclusion. openRoomJoinPrompt(contact.id.pub_key, /*after_relogin_failed=*/true); return; } // Only react if the user opened a login prompt against THIS contact — // otherwise this could be a stale login from the companion-serial side // (the web app) bleeding into our touch flow. if (!for_admin_attempt) return; #if defined(ESP32) // Save (or clear) the remembered password based on the Remember // checkbox snapshot. We do this on success only — saving a known-bad // password would lock the operator out of the prefill optimization. if (success && s_admin_pw_remember_flag) { touchPrefsSetRepeaterPassword(contact.id.pub_key, s_admin_pw_attempt); } else if (success && !s_admin_pw_remember_flag) { // Box unchecked at submit time: ensure any previously-remembered // password is cleared, so toggling Remember off has the obvious // semantics. touchPrefsSetRepeaterPassword(contact.id.pub_key, ""); } // Don't leave the password in RAM longer than needed. memset(s_admin_pw_attempt, 0, sizeof(s_admin_pw_attempt)); s_admin_pw_remember_flag = false; #endif if (success) { closeAdminPwPrompt(); if (s_room_join_idx >= 0 && contact.type == ADV_TYPE_ROOM) { // Joined a room server: drop the operator straight into the room chat. // The server now syncs history (sync_since) and pushes new room // messages, which land in the normal conversation view. const int idx = s_room_join_idx; s_room_join_idx = -1; char msg[80]; snprintf(msg, sizeof(msg), TR("Joined %.40s"), contact.name); showAlert(msg, 1400); openMeshContactDm((uint32_t)idx); } else { // Repeater admin login → open the CLI console. s_room_join_idx = -1; openAdminConsole(contact); char msg[80]; snprintf(msg, sizeof(msg), TR("Login OK (perms %u)"), (unsigned)perms); showAlert(msg, 1200); } } else { s_room_join_idx = -1; closeAdminPwPrompt(); showAlert(TR("Login failed"), 2000); } } void UITask::onAdminCommandReply(const ContactInfo& contact, const char* text) { // Only append if the reply is from the contact we're currently logged // into. Replies from a different repeater would be confusing — the // companion-serial side handles those separately via queueMessage. if (memcmp(contact.id.pub_key, s_admin_pub32, 32) != 0) return; adminLogAppend("", text ? text : ""); } void UITask::markThreadsDirty(unsigned long delay_ms) { const unsigned long deadline = millis() + delay_ms; if (!_threads_dirty || deadline < _next_threads_flush_ms) _next_threads_flush_ms = deadline; _threads_dirty = true; } void UITask::markMsgsDirty(unsigned long delay_ms) { // Segmented store: a flush is now a small APPEND to the active segment // (~240 B per message, off-thread), so the old 10/30 s whole-ring-rewrite // clamps are gone — the hard-cut loss window shrinks to this coalesce. // SPIFFS keeps a slightly higher floor purely as flash-wear pacing. if (uiDataFsIsSdCard()) { if (delay_ms < 2000) delay_ms = 2000; } else { if (delay_ms < 5000) delay_ms = 5000; } const unsigned long deadline = millis() + delay_ms; if (!_msgs_dirty || deadline < _next_msgs_flush_ms) _next_msgs_flush_ms = deadline; _msgs_dirty = true; markThreadsDirty(1500); // thread state (last_ts, unread) changed too — coalesce a message burst into one write } // Arm an immediate flush of both history files WITHOUT the min-delay clamp and // WITHOUT blocking this task: the actual writes happen on the hist_flush // worker (or the sync fallback inside flushHistoryIfDue). Called only from the // SD remount paths — the card may be a DIFFERENT one now, so the segment // table is rebuilt from the ring (s_seg_resync) and the full history re-lands // one bounded segment at a time. void UITask::flushHistorySoon() { s_seg_resync = true; _msgs_dirty = true; _next_msgs_flush_ms = millis(); _threads_dirty = true; _next_threads_flush_ms = millis(); } void UITask::flushHistoryIfDue(unsigned long now) { // Observe a finished worker job FIRST — and strictly BEFORE the failure // branch below resets s_hist_flush_ok, or a FAILED job would read as ok // here and get committed, advancing the durability watermark over records // that never reached disk (silent permanent loss). if (s_segjob_kind != SEGJOB_NONE && !s_hist_flush_busy && !s_hist_flush_req) { if (s_hist_flush_ok) { SegJob done{}; done.kind = s_segjob_kind; done.first_seq = s_segjob_first_seq; done.last_seq = s_segjob_last_seq; done.create = s_segjob_create; done.n = s_segjob_n; const uint32_t pre_wm = s_seg_flushed_seq; segCommitJob(done); // Deletes that raced the in-flight APPEND: segMarkSeqDirty skipped them // while their seq was above the watermark ("never reaches disk") — but // this job just put them on disk. Give their segments the compaction // mark now. if (s_seg_flushed_seq > pre_wm) { for (int i = 0; i < _ui_msg_count; ++i) { const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; const UIMessage& m = _ui_msgs[slot]; if (!m.thread[0] && m.seq > pre_wm && m.seq <= s_seg_flushed_seq) { segMarkSeqDirty(m.seq); _msgs_dirty = true; // schedule the follow-up compaction } } } } s_segjob_kind = SEGJOB_NONE; // failed job: data stays pending below the watermark s_segjob_redirty = false; } // A worker write failed (storage hiccup): re-arm and try again. Surface // repeated failures — a persistently failing ring write was previously // 100% silent and read as "messages from the last N minutes vanished" on // the next reboot (the small threads file kept writing, so the thread // list still showed fresh last-message times). if (!s_hist_flush_ok) { s_hist_flush_ok = true; // Back off a hopelessly failing store: each retry against a card that // times out on large writes costs seconds of bus stalls (felt as UI // freezes through the FatFs volume lock). After 5 straight failures // retry every 5 min instead of every 30 s — the RAM ring keeps // everything meanwhile, and a successful write resets the counter. markMsgsDirty(s_msgs_write_fails >= 5 ? 300000 : 5000); if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) { char why[64]; chatSaveFailText(why, sizeof why); char msg[128]; snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history is NOT saving"), why); showAlert(msg, 3200); } } // Thread metadata (~4 KB) flushes on a short delay; the message ring // (scales with MAX_UI_MESSAGES) flushes lazily to reduce flash write pressure. if (_threads_dirty && now >= _next_threads_flush_ms) { if (s_hist_flush_busy) { // The worker is mid-write on the same filesystem; SPIFFS serializes // internally, so writing now would block the loop behind its GC. Defer. _next_threads_flush_ms = now + 1000; } else if (saveThreadsToStorage()) _threads_dirty = false; else _next_threads_flush_ms = now + 2000; } if (_msgs_dirty && now >= _next_msgs_flush_ms) { if (s_hist_flush_busy || s_hist_flush_req) { // A request is armed but the flush task may not be up yet — kick it so a // pending flush can't sit armed forever while messages live only in RAM. ensureHistFlushTaskRunning(); _next_msgs_flush_ms = now + 1000; // one job in flight; new messages ride the next one return; } #if defined(ESP32) // Off-thread writes keep failing? Fall back to the LOOP-TASK write — the // empirically reliable path (the reboot-time persist always succeeds): // when this task writes, nothing else interleaves radio/display traffic // on the shared SPI bus between the SD transactions. Costs one UI hitch // per flush while degraded; a single success resets the counter and the // next flush goes back to the async task. The threshold is a setting // (Settings -> General; default 2, 0 = never fall back). const uint8_t sync_after = touchPrefsGetHistSyncAfter(); if (sync_after && s_msgs_write_fails >= sync_after) { if (saveMsgsToStorage()) { _msgs_dirty = false; return; } _next_msgs_flush_ms = now + (s_msgs_write_fails >= 5 ? 300000 : 5000); return; } // A failed boot migration means the old-format file is still the on-disk // truth — segments must not be written until it converts (they would // shadow it in the loader's precedence order). The sync drain retries // the migration. if (!s_seg_store_ready) { if (saveMsgsToStorage()) { _msgs_dirty = false; return; } _next_msgs_flush_ms = now + 60000; // store is sick; don't thrash the bus return; } // Card remounted (possibly a different/blank card): rebuild the segment // table from the ring — stale on-disk segments are removed and every // chunk is marked compact-dirty, so the normal job flow below re-lands // the whole history one bounded segment at a time, off-thread. if (s_seg_resync) { segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, _ui_seq_next ? _ui_seq_next - 1 : 0); s_seg_resync = false; } // Build the next off-thread job: pending-append batch first, else the // oldest compact-dirty segment. The snapshot is at most ONE segment's // records (~80 KB) — not the old 1.3 MB whole-ring copy. SegJob job{}; const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, segEnsureBuf(&s_segjob_buf), &job); if (armed == 0) { // Everything durable. A resync's leftover files can go now that every // table entry has a real, current file on disk. if (s_seg_stale_purge) segPurgeStaleFiles(); _msgs_dirty = false; return; } if (armed < 0 || !ensureHistFlushTaskRunning()) { // No snapshot buffer / no worker — synchronous drain instead of losing // the flush (a rare spawn failure must not drop hours of chat). if (saveMsgsToStorage()) { _msgs_dirty = false; return; } _next_msgs_flush_ms = now + 2000; return; } // Arm the worker: descriptor written strictly before req (the worker // reads it only after seeing req). s_segjob_kind = job.kind; s_segjob_first_seq = job.first_seq; s_segjob_last_seq = job.last_seq; s_segjob_create = job.create; s_segjob_n = job.n; s_segjob_redirty = false; s_hist_flush_req = true; // One job armed for the worker. A multi-segment backlog (burst bigger // than one segment, or several dirty segments) keeps _msgs_dirty set and // reschedules shortly; each cycle moves one segment. if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) { _next_msgs_flush_ms = now + 1500; return; } _msgs_dirty = false; return; #else if (saveMsgsToStorage()) _msgs_dirty = false; else _next_msgs_flush_ms = now + 2000; #endif } } // ---- UI-side data storage (chat history) ------------------------------------- // Resolve ONCE where the touch UI's own files live: internal SPIFFS when present, // else the SD card under /meshcomod (Launcher / SD-storage installs have no SPIFFS // partition). Caching the result is what stops the "SPIFFS partition could not be // found" spam — the history flush runs every ~2 s, and re-calling SPIFFS.begin() // each time both logged the error and, under Launcher, never actually persisted // the chat to the SD card. static fs::FS* s_ui_data_fs = nullptr; static char s_ui_data_root[16] = ""; #if defined(TLORA_PAGER) static bool s_ui_data_boot_finalized = false; #endif // History stays on the filesystem selected during UITask::begin() for the // whole boot. Switching a live ring from SPIFFS to a newly inserted card can // purge history that was never loaded into RAM; switching a 5000-record SD ring // to SPIFFS can fill the small internal partition. static bool uiDataFsReady() { if (s_ui_data_fs != nullptr) return true; // cache SUCCESS only — a failed resolve MUST stay retryable #if defined(TLORA_PAGER) if (s_ui_data_boot_finalized) { // A boot-adopted card owns the active identity/profile. Never fall back to // another profile's internal history if that card becomes unavailable. If // history failed to resolve during begin(), also never attach it later in // this boot: the RAM ring was not loaded from that card, and a subsequent // flush could otherwise replace its existing segments with an empty ring. if (g_full_data_on_sd) { return false; } // Never adopt unseen card history after the boot loader has finished, but // keep retrying the already-loaded internal backend if its first mount was // transiently unavailable. This avoids a RAM-only history session without // risking a switch to a card whose history was never loaded. if (SPIFFS.begin(false)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } return false; } #endif #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // Tanmatsu + T-Display P4: use each board's mounted internal data partition // (Tanmatsu FFat 'locfd', P4 LittleFS 'storage'), with SD_MMC as fallback. // #167: hot UI data (chat history) lives on the INTERNAL LittleFS -- SD write bursts // electrically disturb this board's AMOLED, and the P4's internal FAT layer is broken // (see the storage note in tdisplay_p4/main/main.cpp). SD = degraded fallback only; // tiles keep using the SD via their own selector. extern bool g_fs_ok; if (g_fs_ok) { #if defined(HAS_TANMATSU) s_ui_data_fs = &FFat; #else s_ui_data_fs = &LittleFS; #endif s_ui_data_root[0] = '\0'; return true; } extern bool g_sd_ok; if (g_sd_ok) { SD_MMC.mkdir("/meshcomod"); s_ui_data_fs = &SD_MMC; strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } #elif defined(TLORA_PAGER) // Follow the boot loader's primary-profile decision. An established card may // legitimately predate the migration marker; falling back to SPIFFS history // in that case would expose and update another identity's conversations. if (g_full_data_on_sd) { if (!fmSdTryMount()) return false; SD.mkdir("/meshcomod"); s_ui_data_fs = &SD; strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } // The boot loader kept identity/preferences internal, either by user choice // or because reconciliation failed closed. History must follow that same // profile decision even when an inserted card carries a valid marker and // history from another device. Never use foreign SD history as a fallback. if (SPIFFS.begin(false)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } return false; #elif defined(HAS_TDECK_GT911) // T-Deck: the SD card is the established persistent history store. if (sdAdoptLiveMount() || fmSdTryMount()) { SD.mkdir("/meshcomod"); s_ui_data_fs = &SD; strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } if (SPIFFS.begin(false)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } #elif defined(HAS_THINKNODE_M9) // ThinkNode M9: same Arduino-SD-on-shared-bus shape as the T-Deck (CAP_SD=1; // used to fall into the V4 SPIFFS #else, losing the SD-backed deep message // ring despite a mounted card). if (sdAdoptLiveMount() || fmSdTryMount()) { SD.mkdir("/meshcomod"); s_ui_data_fs = &SD; strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } if (SPIFFS.begin(false)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } #elif defined(HELTEC_LORA_V4_R8) // R8 (Expansion Kit V2, REMOVABLE card): follow the boot adoption decision, // never bare card presence — mirrors the pager's profile-keyed shape. When // the boot store adopted the card (g_full_data_on_sd: identity/prefs/ // contacts live there), chat history + battery log + Lua app files follow; // otherwise everything stays on internal SPIFFS as before. NB an R8 that // adopted a card on an OLDER build carries a day-one /msgs snapshot on the // card — Settings > Storage > "Copy internal data to SD" refreshes it. if (g_full_data_on_sd) { if (!(sdAdoptLiveMount() || fmSdTryMount())) return false; SD.mkdir("/meshcomod"); s_ui_data_fs = &SD; strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } if (SPIFFS.begin(false) || SPIFFS.begin(true)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } #else // V4 (no SD): internal SPIFFS. Format-on-fail so a fresh / never-formatted // partition becomes usable — that's the V4 history-loss fix. Safe: only formats // an unmountable partition (contents were inaccessible anyway), and only when // begin(false) already failed. if (SPIFFS.begin(false) || SPIFFS.begin(true)) { s_ui_data_fs = &SPIFFS; s_ui_data_root[0] = '\0'; return true; } #endif // Not ready yet — do NOT cache the failure, so a later call can still resolve. return false; } #if CAP_LUA_APPS // ---- Lua app host bridges (LuaAppHost.cpp externs) ---- // The host lives in its own TU; these three shims are its only view of UITask. // A Lua app's timer keeps firing while the screen is off — lv_timer_handler runs // unconditionally — so an app polling a sensor at 10 Hz went on doing it into a // dark screen. Nothing it draws can be seen, so the host skips the tick; the app // resumes on wake and sees the gap through sys.millis() like any other pause. static bool s_lua_keep_awake = false; // wada.sys.keep_awake(true): an app that is MEASURING rather than displaying — // a calibration sweep, a timed capture — must keep running and must not have // the screen blank underneath it. Pausing app ticks with the screen (below) // truncated exactly such a sweep: the M9's default screen timeout is 20 s and // GPS Compass calibrates for 20 s, so collection stopped at the same instant // the countdown was supposed to end. This keeps BOTH alive, and is cleared // when the app closes so it cannot leak into the next one. void luaHostKeepAwake(bool on) { s_lua_keep_awake = on; if (on && g_lv.task) g_lv.task->noteUserInput(); // push the idle timer out now } bool luaHostScreenOn() { if (s_lua_keep_awake) { if (g_lv.task) g_lv.task->noteUserInput(); // and keep pushing it, tick by tick return true; } return g_lv.task ? !g_lv.task->isScreenOff() : true; } const lv_font_t* luaHostFontForSize(int size_class) { return size_class <= 12 ? &g_font_12 : size_class <= 14 ? &g_font_14 : &g_font_16; } // Lua wada.sys.beep() (#245). Routes to the SAME per-board chime the UI uses, so // a Lua app cannot be louder or different from the rest of the firmware, and it // honours the user's sound setting rather than overriding it. Returns whether a // sound was actually produced: boards without a sounder, and a muted device, // both report false so an app can show something instead. bool luaHostBeep() { if (!g_lv.task || g_lv.task->isBuzzerQuiet()) return false; uiPlaySlot(TOUCH_SND_MSG); return true; } void luaHostToast(const char* msg, int ms) { if (g_lv.task) g_lv.task->showAlert(msg, ms); } fs::FS* luaHostAppFs() { return uiDataFsReady() ? s_ui_data_fs : nullptr; } void luaHostAppPath(char* out, size_t cap, const char* rel) { snprintf(out, cap, "%s%s", s_ui_data_root, rel); // SD-rooted stores prefix /meshcomod } #if CAP_LUA_AUDIO namespace { enum class LuaAudioCommandKind : uint8_t { Play, Pause, Resume, Stop, Release }; enum class LuaAudioState : uint8_t { Stopped, Playing, Paused, Ended, Error }; enum class LuaAudioRunResult : uint8_t { Ended, Stopped, Replaced, Released, Error }; enum class LuaAudioTaskState : uint8_t { Stopped, Starting, Running, Stopping }; enum class LuaAudioFormat : uint8_t { Wav, Mp3 }; struct LuaAudioCommand { LuaAudioCommandKind kind = LuaAudioCommandKind::Stop; fs::FS* fs = nullptr; uint32_t owner = 0; LuaAudioFormat format = LuaAudioFormat::Wav; char path[224] = ""; char shown[192] = ""; char source[8] = ""; }; struct LuaAudioStatus { uint32_t owner = 0; LuaAudioState state = LuaAudioState::Stopped; char path[192] = ""; char source[8] = ""; char format[8] = ""; char error[40] = ""; }; struct LuaAudioSinkSession { uint32_t output_rate = 0; float gain = 1.0f; bool open = false; #if defined(HAS_TANMATSU) i2s_chan_handle_t handle = nullptr; SemaphoreHandle_t mutex = nullptr; #endif }; class LuaAudioStorageLease { public: LuaAudioStorageLease() { __atomic_store_n(&s_lua_audio_storage_active, true, __ATOMIC_RELEASE); if (__atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0) __atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); } ~LuaAudioStorageLease() { __atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE); } }; static QueueHandle_t s_lua_audio_queue = nullptr; static TaskHandle_t s_lua_audio_task = nullptr; static LuaAudioTaskState s_lua_audio_task_state = LuaAudioTaskState::Stopped; static portMUX_TYPE s_lua_audio_mux = portMUX_INITIALIZER_UNLOCKED; static LuaAudioStatus s_lua_audio_status; static void luaAudioCopy(char* out, size_t cap, const char* value) { if (!cap) return; snprintf(out, cap, "%s", value ? value : ""); } static void luaAudioSetTrackStatus(const LuaAudioCommand& command, LuaAudioState state, const char* error = nullptr) { portENTER_CRITICAL(&s_lua_audio_mux); s_lua_audio_status.owner = command.owner; s_lua_audio_status.state = state; luaAudioCopy(s_lua_audio_status.path, sizeof s_lua_audio_status.path, command.shown); luaAudioCopy(s_lua_audio_status.source, sizeof s_lua_audio_status.source, command.source); luaAudioCopy(s_lua_audio_status.format, sizeof s_lua_audio_status.format, command.format == LuaAudioFormat::Mp3 ? "mp3" : "wav"); luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error); portEXIT_CRITICAL(&s_lua_audio_mux); } static void luaAudioSetState(uint32_t owner, LuaAudioState state, const char* error = nullptr) { portENTER_CRITICAL(&s_lua_audio_mux); if (s_lua_audio_status.owner == owner) { s_lua_audio_status.state = state; luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error); } portEXIT_CRITICAL(&s_lua_audio_mux); } static bool luaAudioSinkBegin(uint32_t source_rate, LuaAudioSinkSession* sink, const char** error) { if (!sink) return false; const int volume = (int)touchPrefsGetSoundVolume(); if (volume <= 0) { if (error) *error = "muted"; return false; } #if defined(HAS_TDECK_GT911) if (s_notify_playing) { if (error) *error = "busy"; return false; } if (!tdeckAudioInstallRate((int)source_rate)) { if (error) *error = "audio unavailable"; return false; } sink->output_rate = source_rate; sink->gain = (float)volume / 100.0f; #elif defined(TLORA_PAGER) if (s_notify_playing) { if (error) *error = "busy"; return false; } board.setAmpEnabled(true); if (!pagerAudioInstallRate((int)source_rate)) { board.setAmpEnabled(false); if (error) *error = "audio unavailable"; return false; } s_pager_codec.setVolumePercent((uint8_t)volume); s_pager_codec.setMute(false); sink->output_rate = source_rate; #elif defined(HAS_TDISPLAY_P4) if (!p4AudioStreamBegin()) { if (error) *error = "audio unavailable"; return false; } sink->output_rate = p4AudioStreamRate(); sink->gain = (float)volume / 100.0f; #elif defined(HAS_TANMATSU) sink->mutex = tanAudioMutex(); if (!sink->mutex || xSemaphoreTake(sink->mutex, pdMS_TO_TICKS(300)) != pdTRUE) { if (error) *error = "busy"; return false; } if (bsp_audio_get_i2s_handle(&sink->handle) != ESP_OK || !sink->handle) { xSemaphoreGive(sink->mutex); sink->mutex = nullptr; if (error) *error = "audio unavailable"; return false; } applyVolume((uint8_t)volume); sink->output_rate = 44100; #endif sink->open = sink->output_rate != 0; return sink->open; } static bool luaAudioSinkWrite(LuaAudioSinkSession* sink, const int16_t* samples, size_t frames) { if (!sink || !sink->open || !samples || !frames) return false; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) size_t written = 0; const size_t bytes = frames * sizeof(int16_t); return i2s_write(kI2sPort, samples, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK && written == bytes; #elif defined(HAS_TDISPLAY_P4) return p4AudioStreamWrite(samples, frames); #elif defined(HAS_TANMATSU) static int16_t stereo[256 * 2]; if (frames > 256) return false; for (size_t i = 0; i < frames; ++i) stereo[2 * i] = stereo[2 * i + 1] = samples[i]; size_t written = 0; const size_t bytes = frames * 2 * sizeof(int16_t); return i2s_channel_write(sink->handle, stereo, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK && written == bytes; #endif } static void luaAudioSinkEnd(LuaAudioSinkSession* sink) { if (!sink || !sink->open) return; #if defined(HAS_TDECK_GT911) i2s_zero_dma_buffer(kI2sPort); i2s_driver_uninstall(kI2sPort); #elif defined(TLORA_PAGER) pagerAudioUninstall(); board.setAmpEnabled(false); #elif defined(HAS_TDISPLAY_P4) p4AudioStreamEnd(); #elif defined(HAS_TANMATSU) static const int16_t silence[256 * 2] = {}; for (int i = 0; i < 16; ++i) { size_t written = 0; i2s_channel_write(sink->handle, silence, sizeof silence, &written, pdMS_TO_TICKS(300)); } if (sink->mutex) xSemaphoreGive(sink->mutex); sink->handle = nullptr; sink->mutex = nullptr; #endif sink->open = false; } static bool luaAudioControlMatches(const LuaAudioCommand& control, uint32_t owner) { return control.kind == LuaAudioCommandKind::Play || control.owner == owner; } static bool luaAudioPollControl(const LuaAudioCommand& command, LuaAudioCommand* replacement, LuaAudioRunResult* result) { LuaAudioCommand control; if (xQueueReceive(s_lua_audio_queue, &control, 0) != pdTRUE || !luaAudioControlMatches(control, command.owner)) return false; if (control.kind == LuaAudioCommandKind::Play) { if (replacement) *replacement = control; if (result) *result = LuaAudioRunResult::Replaced; return true; } if (control.kind == LuaAudioCommandKind::Stop) { if (result) *result = LuaAudioRunResult::Stopped; return true; } if (control.kind == LuaAudioCommandKind::Release) { if (result) *result = LuaAudioRunResult::Released; return true; } if (control.kind != LuaAudioCommandKind::Pause) return false; luaAudioSetState(command.owner, LuaAudioState::Paused); for (;;) { if (xQueueReceive(s_lua_audio_queue, &control, portMAX_DELAY) != pdTRUE) continue; if (!luaAudioControlMatches(control, command.owner)) continue; if (control.kind == LuaAudioCommandKind::Resume) { luaAudioSetState(command.owner, LuaAudioState::Playing); return false; } if (control.kind == LuaAudioCommandKind::Play) { if (replacement) *replacement = control; if (result) *result = LuaAudioRunResult::Replaced; return true; } if (control.kind == LuaAudioCommandKind::Release) { if (result) *result = LuaAudioRunResult::Released; return true; } if (control.kind == LuaAudioCommandKind::Stop) { if (result) *result = LuaAudioRunResult::Stopped; return true; } } } static LuaAudioRunResult luaAudioRunTrack(const LuaAudioCommand& command, LuaAudioCommand* replacement) { LuaAudioStorageLease storage_lease; File file = command.fs ? command.fs->open(command.path, "r") : File(); if (!file || file.isDirectory()) { if (file) file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found"); return LuaAudioRunResult::Error; } uint16_t channels = 0; uint32_t source_rate = 0, data_len = 0; if (!wavParse(file, &channels, &source_rate, &data_len) || data_len < (uint32_t)channels * sizeof(int16_t)) { file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format"); return LuaAudioRunResult::Error; } LuaAudioSinkSession sink; const char* sink_error = nullptr; __atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE); if (!luaAudioSinkBegin(source_rate, &sink, &sink_error)) { __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, sink_error ? sink_error : "audio unavailable"); return LuaAudioRunResult::Error; } luaAudioSetTrackStatus(command, LuaAudioState::Playing); LuaAudioRunResult result = LuaAudioRunResult::Ended; const char* run_error = nullptr; bool interrupted = false; uint32_t remaining = data_len; uint64_t resample_phase = 0; int16_t input[256]; int16_t output[256]; size_t output_count = 0; const uint32_t frame_bytes = (uint32_t)channels * sizeof(int16_t); while (remaining >= frame_bytes && !interrupted) { if (luaAudioPollControl(command, replacement, &result)) break; size_t want = sizeof input; if (want > remaining) want = remaining; want -= want % frame_bytes; const int got = file.read((uint8_t*)input, want); if (got <= 0) { result = LuaAudioRunResult::Error; run_error = "read failed"; break; } const size_t frames = (size_t)got / frame_bytes; for (size_t i = 0; i < frames; ++i) { int32_t sample = channels == 2 ? ((int32_t)input[2 * i] + input[2 * i + 1]) / 2 : input[i]; sample = (int32_t)((float)sample * sink.gain); if (sample > 32767) sample = 32767; else if (sample < -32768) sample = -32768; resample_phase += sink.output_rate; while (resample_phase >= source_rate) { output[output_count++] = (int16_t)sample; resample_phase -= source_rate; if (output_count == sizeof output / sizeof output[0]) { if (!luaAudioSinkWrite(&sink, output, output_count)) { result = LuaAudioRunResult::Error; run_error = "output failed"; interrupted = true; break; } output_count = 0; } } if (interrupted) break; } remaining -= (uint32_t)got; } if (!interrupted && result == LuaAudioRunResult::Ended && output_count && !luaAudioSinkWrite(&sink, output, output_count)) { result = LuaAudioRunResult::Error; run_error = "output failed"; } luaAudioSinkEnd(&sink); file.close(); __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); if (result == LuaAudioRunResult::Ended) luaAudioSetState(command.owner, LuaAudioState::Ended); else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released) luaAudioSetState(command.owner, LuaAudioState::Stopped); else if (result == LuaAudioRunResult::Error) luaAudioSetState(command.owner, LuaAudioState::Error, run_error ? run_error : "playback failed"); return result; } struct LuaMp3Work { mp3dec_t decoder; mp3dec_scratch_t scratch; uint8_t input[16 * 1024]; mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME]; }; static uint32_t luaAudioReadLe32(const uint8_t* value) { return (uint32_t)value[0] | ((uint32_t)value[1] << 8) | ((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24); } static uint32_t luaAudioMp3DataEnd(File& file) { uint32_t end = (uint32_t)file.size(); uint8_t footer[128]; if (end >= 128 && file.seek(end - 128) && file.read(footer, 3) == 3 && !memcmp(footer, "TAG", 3)) { end -= 128; } if (end >= 32 && file.seek(end - 32) && file.read(footer, 32) == 32 && !memcmp(footer, "APETAGEX", 8)) { const uint32_t version = luaAudioReadLe32(footer + 8); const uint32_t tag_size = luaAudioReadLe32(footer + 12); const uint32_t flags = luaAudioReadLe32(footer + 20); const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u); if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= end) end -= (uint32_t)total_size; } file.seek(0); return end; } static LuaAudioRunResult luaAudioRunMp3Track(const LuaAudioCommand& command, LuaAudioCommand* replacement) { LuaAudioStorageLease storage_lease; File file = command.fs ? command.fs->open(command.path, "r") : File(); if (!file || file.isDirectory()) { if (file) file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found"); return LuaAudioRunResult::Error; } LuaMp3Work* work = (LuaMp3Work*)heap_caps_malloc( sizeof(LuaMp3Work), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!work) { file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, "out of memory"); return LuaAudioRunResult::Error; } s_wada_mp3_scratch = &work->scratch; mp3dec_init(&work->decoder); const uint32_t data_end = luaAudioMp3DataEnd(file); if (!data_end) { s_wada_mp3_scratch = nullptr; heap_caps_free(work); file.close(); luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format"); return LuaAudioRunResult::Error; } __atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE); luaAudioSetTrackStatus(command, LuaAudioState::Playing); LuaAudioRunResult result = LuaAudioRunResult::Ended; const char* run_error = nullptr; LuaAudioSinkSession sink; uint32_t source_rate = 0; uint64_t resample_phase = 0; int16_t output[256]; size_t output_count = 0; size_t input_start = 0, input_count = 0; bool eof = false, decoded_any = false, interrupted = false; while (!interrupted) { if (luaAudioPollControl(command, replacement, &result)) break; if (!eof && input_count < 4096) { if (input_start && input_start + input_count + 4096 > sizeof work->input) { memmove(work->input, work->input + input_start, input_count); input_start = 0; } size_t room = sizeof work->input - (input_start + input_count); const uint32_t position = (uint32_t)file.position(); const uint32_t remaining = position < data_end ? data_end - position : 0; if (room > remaining) room = remaining; const int got = room ? file.read(work->input + input_start + input_count, room) : 0; if (got > 0) { input_count += (size_t)got; eof = file.position() >= data_end; } else if (file.position() < data_end) { result = LuaAudioRunResult::Error; run_error = "read failed"; break; } else { eof = true; } } if (!input_count) { if (!decoded_any) { result = LuaAudioRunResult::Error; run_error = "unsupported format"; } break; } mp3dec_frame_info_t info = {}; const int samples = mp3dec_decode_frame(&work->decoder, work->input + input_start, (int)input_count, work->pcm, &info); if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) { result = LuaAudioRunResult::Error; run_error = "decode failed"; break; } if (info.frame_bytes > 0) { input_start += (size_t)info.frame_bytes; input_count -= (size_t)info.frame_bytes; if (!input_count) input_start = 0; } else if (eof) { if (!decoded_any) { result = LuaAudioRunResult::Error; run_error = "unsupported format"; } break; } else if (input_start + input_count == sizeof work->input) { result = LuaAudioRunResult::Error; run_error = "decode failed"; break; } else { continue; } if (samples <= 0) continue; if (info.layer != 3 || (info.channels != 1 && info.channels != 2) || info.hz < 8000 || info.hz > 48000) { result = LuaAudioRunResult::Error; run_error = "unsupported format"; break; } if (!sink.open) { const char* sink_error = nullptr; if (!luaAudioSinkBegin((uint32_t)info.hz, &sink, &sink_error)) { result = LuaAudioRunResult::Error; run_error = sink_error ? sink_error : "audio unavailable"; break; } source_rate = (uint32_t)info.hz; } else if (source_rate != (uint32_t)info.hz) { result = LuaAudioRunResult::Error; run_error = "sample rate changed"; break; } decoded_any = true; for (int i = 0; i < samples; ++i) { int32_t sample = info.channels == 2 ? ((int32_t)work->pcm[2 * i] + work->pcm[2 * i + 1]) / 2 : work->pcm[i]; sample = (int32_t)((float)sample * sink.gain); if (sample > 32767) sample = 32767; else if (sample < -32768) sample = -32768; resample_phase += sink.output_rate; while (resample_phase >= source_rate) { output[output_count++] = (int16_t)sample; resample_phase -= source_rate; if (output_count == sizeof output / sizeof output[0]) { if (!luaAudioSinkWrite(&sink, output, output_count)) { result = LuaAudioRunResult::Error; run_error = "output failed"; interrupted = true; break; } output_count = 0; } } if (interrupted) break; } } if (!interrupted && result == LuaAudioRunResult::Ended && output_count && !luaAudioSinkWrite(&sink, output, output_count)) { result = LuaAudioRunResult::Error; run_error = "output failed"; } luaAudioSinkEnd(&sink); s_wada_mp3_scratch = nullptr; heap_caps_free(work); file.close(); __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); if (result == LuaAudioRunResult::Ended) luaAudioSetState(command.owner, LuaAudioState::Ended); else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released) luaAudioSetState(command.owner, LuaAudioState::Stopped); else if (result == LuaAudioRunResult::Error) luaAudioSetState(command.owner, LuaAudioState::Error, run_error ? run_error : "playback failed"); return result; } static void luaAudioWorker(void*) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); bool release = false; while (!release) { LuaAudioCommand command; if (xQueueReceive(s_lua_audio_queue, &command, portMAX_DELAY) != pdTRUE) continue; if (command.kind == LuaAudioCommandKind::Release) { luaAudioSetState(command.owner, LuaAudioState::Stopped); break; } if (command.kind == LuaAudioCommandKind::Stop) { luaAudioSetState(command.owner, LuaAudioState::Stopped); continue; } if (command.kind != LuaAudioCommandKind::Play) continue; for (;;) { LuaAudioCommand replacement; const LuaAudioRunResult result = command.format == LuaAudioFormat::Mp3 ? luaAudioRunMp3Track(command, &replacement) : luaAudioRunTrack(command, &replacement); if (result == LuaAudioRunResult::Replaced) { command = replacement; continue; } release = result == LuaAudioRunResult::Released; break; } } __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); __atomic_store_n(&s_lua_audio_storage_pending, 0u, __ATOMIC_RELEASE); __atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE); xQueueReset(s_lua_audio_queue); portENTER_CRITICAL(&s_lua_audio_mux); s_lua_audio_task = nullptr; s_lua_audio_task_state = LuaAudioTaskState::Stopped; portEXIT_CRITICAL(&s_lua_audio_mux); vTaskDelete(nullptr); } static bool luaAudioReturnError(char* error, size_t cap, const char* message) { luaAudioCopy(error, cap, message); return false; } static bool luaAudioPathEndsWith(const char* path, const char* suffix) { if (!path || !suffix) return false; const size_t path_len = strlen(path), suffix_len = strlen(suffix); if (path_len < suffix_len) return false; path += path_len - suffix_len; for (size_t i = 0; i < suffix_len; ++i) { char a = path[i], b = suffix[i]; if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); if (a != b) return false; } return true; } } // namespace bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, const char* source, uint32_t owner, char* error, size_t error_cap) { if (!fs) return luaAudioReturnError(error, error_cap, "no storage"); if (!path || !path[0] || strlen(path) >= 224) return luaAudioReturnError(error, error_cap, "bad path"); if (!g_lv.task || g_lv.task->isBuzzerQuiet() || touchPrefsGetSoundVolume() == 0) return luaAudioReturnError(error, error_cap, "muted"); File probe = fs->open(path, "r"); if (!probe || probe.isDirectory()) { if (probe) probe.close(); return luaAudioReturnError(error, error_cap, "not found"); } uint16_t channels = 0; uint32_t rate = 0, data_len = 0; LuaAudioFormat format; bool supported = false; if (luaAudioPathEndsWith(shown, ".wav")) { format = LuaAudioFormat::Wav; supported = wavParse(probe, &channels, &rate, &data_len) && data_len > 0; } else if (luaAudioPathEndsWith(shown, ".mp3")) { format = LuaAudioFormat::Mp3; supported = probe.size() > 0; } probe.close(); if (!supported) return luaAudioReturnError(error, error_cap, "unsupported format"); if (!s_lua_audio_queue) s_lua_audio_queue = xQueueCreate(4, sizeof(LuaAudioCommand)); if (!s_lua_audio_queue) return luaAudioReturnError(error, error_cap, "out of memory"); bool start_task = false; portENTER_CRITICAL(&s_lua_audio_mux); if (s_lua_audio_task_state == LuaAudioTaskState::Stopped) { s_lua_audio_task_state = LuaAudioTaskState::Starting; start_task = true; } else if (s_lua_audio_task_state != LuaAudioTaskState::Running) { portEXIT_CRITICAL(&s_lua_audio_mux); return luaAudioReturnError(error, error_cap, "busy"); } portEXIT_CRITICAL(&s_lua_audio_mux); TaskHandle_t task = nullptr; if (start_task) { if (xTaskCreate(luaAudioWorker, "lua-audio", 8192, nullptr, 3, &task) != pdPASS) { portENTER_CRITICAL(&s_lua_audio_mux); s_lua_audio_task_state = LuaAudioTaskState::Stopped; portEXIT_CRITICAL(&s_lua_audio_mux); return luaAudioReturnError(error, error_cap, "out of memory"); } portENTER_CRITICAL(&s_lua_audio_mux); s_lua_audio_task = task; s_lua_audio_task_state = LuaAudioTaskState::Running; portEXIT_CRITICAL(&s_lua_audio_mux); } LuaAudioCommand command; command.kind = LuaAudioCommandKind::Play; command.fs = fs; command.owner = owner; command.format = format; luaAudioCopy(command.path, sizeof command.path, path); luaAudioCopy(command.shown, sizeof command.shown, shown); luaAudioCopy(command.source, sizeof command.source, source); __atomic_fetch_add(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); if (xQueueSend(s_lua_audio_queue, &command, 0) != pdPASS) { __atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); if (start_task) xTaskNotifyGive(task); return luaAudioReturnError(error, error_cap, "busy"); } luaAudioSetTrackStatus(command, LuaAudioState::Playing); if (start_task) xTaskNotifyGive(task); return true; } bool luaHostAudioPause(uint32_t owner, bool pause) { LuaAudioState state; TaskHandle_t task; LuaAudioTaskState task_state; portENTER_CRITICAL(&s_lua_audio_mux); state = s_lua_audio_status.owner == owner ? s_lua_audio_status.state : LuaAudioState::Stopped; task = s_lua_audio_task; task_state = s_lua_audio_task_state; portEXIT_CRITICAL(&s_lua_audio_mux); if (!task || task_state != LuaAudioTaskState::Running || (pause ? state != LuaAudioState::Playing : state != LuaAudioState::Paused)) return false; LuaAudioCommand command; command.kind = pause ? LuaAudioCommandKind::Pause : LuaAudioCommandKind::Resume; command.owner = owner; return xQueueSend(s_lua_audio_queue, &command, 0) == pdPASS; } bool luaHostAudioStop(uint32_t owner, bool release) { TaskHandle_t task; LuaAudioTaskState task_state; portENTER_CRITICAL(&s_lua_audio_mux); task = s_lua_audio_task; task_state = s_lua_audio_task_state; if (release && task && task_state == LuaAudioTaskState::Running) s_lua_audio_task_state = LuaAudioTaskState::Stopping; portEXIT_CRITICAL(&s_lua_audio_mux); if (!task || task_state == LuaAudioTaskState::Stopped) { luaAudioSetState(owner, LuaAudioState::Stopped); return true; } if (task_state != LuaAudioTaskState::Running) return false; LuaAudioCommand command; command.kind = release ? LuaAudioCommandKind::Release : LuaAudioCommandKind::Stop; command.owner = owner; const BaseType_t queued = release ? xQueueSendToFront(s_lua_audio_queue, &command, pdMS_TO_TICKS(100)) : xQueueSend(s_lua_audio_queue, &command, 0); if (queued != pdPASS) { if (release) { portENTER_CRITICAL(&s_lua_audio_mux); if (s_lua_audio_task_state == LuaAudioTaskState::Stopping) s_lua_audio_task_state = LuaAudioTaskState::Running; portEXIT_CRITICAL(&s_lua_audio_mux); } return false; } if (!release) return true; for (int i = 0; i < 160; ++i) { vTaskDelay(pdMS_TO_TICKS(5)); portENTER_CRITICAL(&s_lua_audio_mux); task = s_lua_audio_task; portEXIT_CRITICAL(&s_lua_audio_mux); if (!task) return true; } return false; } void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap, char* path, size_t path_cap, char* source, size_t source_cap, char* format, size_t format_cap, char* error, size_t error_cap) { LuaAudioStatus status; portENTER_CRITICAL(&s_lua_audio_mux); status = s_lua_audio_status; portEXIT_CRITICAL(&s_lua_audio_mux); if (status.owner != owner) status = LuaAudioStatus(); const char* state_name = "stopped"; if (status.state == LuaAudioState::Playing) state_name = "playing"; else if (status.state == LuaAudioState::Paused) state_name = "paused"; else if (status.state == LuaAudioState::Ended) state_name = "ended"; else if (status.state == LuaAudioState::Error) state_name = "error"; luaAudioCopy(state, state_cap, state_name); luaAudioCopy(path, path_cap, status.path); luaAudioCopy(source, source_cap, status.source); luaAudioCopy(format, format_cap, status.format); luaAudioCopy(error, error_cap, status.error); } #endif #if CAP_LUA_SD_LIST // Return the physical removable card, never the app-data filesystem. Mounting // stays in UITask so Lua cannot bypass shared-bus coordination or create a // second SD lifecycle. The existing backoff keeps repeated no-card calls cheap. fs::FS* luaHostSdFs(bool* busy) { if (busy) *busy = false; if (s_sd_fail_note_ms) return nullptr; if (!s_sd_mounted && !sdAdoptLiveMount()) { if (sdRuntimeLifecycleBusy()) { if (busy) *busy = true; return nullptr; } if (!fmSdTryMount()) return nullptr; } if (!s_sd_mounted) return nullptr; markSdIo(); return &SD; } bool luaHostSdReadFailed() { return sdReadFailedCardDead(); } #endif // ---- wada.mesh read-only bridges (no mesh types cross into the host TU) ---- // Hex-encode the first n bytes of a public key. 4 bytes (8 hex chars) is what the // rest of the UI uses to name a node, and it is what an app needs to line up a // contact with a discovery hit or an overheard advert. static void luaPubkeyHex(const uint8_t* pk, int n, char* out, size_t cap) { static const char* H = "0123456789abcdef"; size_t o = 0; for (int i = 0; i < n && o + 2 < cap; i++) { out[o++] = H[(pk[i] >> 4) & 0x0F]; out[o++] = H[pk[i] & 0x0F]; } if (cap) out[o < cap ? o : cap - 1] = '\0'; } int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago, double* lat, double* lon, char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6) { ContactInfo ci; if (idx < 0 || !the_mesh.getContactByIdx((uint32_t)idx, ci)) return 0; snprintf(name, name_cap, "%s", ci.name); *type = ci.type; if (pk_hex && pk_cap) luaPubkeyHex(ci.id.pub_key, 4, pk_hex, pk_cap); *lat = ci.gps_lat / 1.0e6; *lon = ci.gps_lon / 1.0e6; if (lat_e6) *lat_e6 = ci.gps_lat; // stored as micro-degrees already if (lon_e6) *lon_e6 = ci.gps_lon; // One clock read per contacts() walk, not one per contact: on boards whose // clock is an I2C RTC (the M9's PCF8563 on Wire) getCurrentTime() is a bus // transaction, and an app listing 100 contacts would otherwise stall the UI // loop for ~100 ms each call. meshContacts() always walks from idx 0. static uint32_t s_walk_now = 0; if (idx == 0) s_walk_now = the_mesh.getRTCClock() ? the_mesh.getRTCClock()->getCurrentTime() : 0; const uint32_t now = s_walk_now; *secs_ago = (ci.last_advert_timestamp && now > ci.last_advert_timestamp) ? now - ci.last_advert_timestamp : 0; return 1; } int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops, int* route, int* len, int* org_kind, char* org_hex, size_t org_cap, uint32_t* at_ms) { MyMesh::UiRxRec r; if (idx < 0 || !the_mesh.uiRxLogGet((uint8_t)idx, r)) return 0; if (at_ms) *at_ms = r.ms; // the record's own timestamp: a stable delivery key *ms_ago = millis() - r.ms; *ptype = r.ptype; *rssi = r.rssi; *snr = (float)r.snr_q4 / 4.0f; *hops = r.hops; *route = r.route; *len = r.len; *org_kind = r.org_kind; // kind 1 = a real public-key prefix (advert); kind 2 = the one-byte // destination and source hashes an addressed frame carries. Encoded to the // width the frame actually justifies, so 8 hex chars always means a key. if (org_hex && org_cap) luaPubkeyHex(r.org, r.org_kind == 1 ? 4 : 2, org_hex, org_cap); return 1; } void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s, uint32_t* rx_pkts, uint32_t* rx_err, int* budget_ms) { *rssi = radio_driver.getCurrentRSSI(); *noise = radio_driver.getNoiseFloor(); *rx_air_s = (uint32_t)(the_mesh.getReceiveAirTime() / 1000UL); *tx_air_s = (uint32_t)(the_mesh.getTotalAirTime() / 1000UL); *rx_pkts = radio_driver.getPacketsRecv(); *rx_err = radio_driver.getPacketsRecvErrors(); *budget_ms = (int)the_mesh.getRemainingTxBudget(); } void luaHostRadioStats2(uint32_t* rx_evt, uint32_t* rx_drop, uint32_t* tx_pkts, float* freq, float* bw, int* sf, int* duty_pct) { *rx_evt = radio_driver.getRxEvents(); *rx_drop = radio_driver.getRxQueueDrops(); *tx_pkts = radio_driver.getPacketsSent(); NodePrefs* p = the_mesh.getNodePrefs(); *freq = p->freq; *bw = p->bw; *sf = p->sf; // airtime_factor -> the duty ceiling the dispatcher enforces (see #161) *duty_pct = p->airtime_factor > 0 ? (int)(100.0f / (1.0f + p->airtime_factor) + 0.5f) : 100; } // Widened for console mode: CAP_LUA_SDK_EXT is OFF on the V4, which is exactly // the board console mode exists for, and the console needs the same send path. // Reusing it rather than duplicating matters most for luaHostMeshSendChannel: // it matches the channel BY NAME at transmit time, and a cached slot index is // how messages went out encrypted to the wrong channel before. #if CAP_LUA_SDK_EXT || CAP_CONSOLE // ---- Lua app permissions: mesh send ---------------------------------------- // wada.mesh.send() is the first WRITE path a store app has into the mesh, and // anything it sends goes out under the user's own node name -- to readers it is // indistinguishable from the user typing it. That is a different category from // an app writing a file, so it needs explicit, informed, per-app consent. // // The grant is bound to ONE app id. Approving a beacon app grants nothing to any // other app, and re-installing under a different id asks again. Stored as a tiny // text file next to the app data so it survives reboots and can be inspected. // // The app cannot bypass this: it never sees a prompt API. wada.mesh.send simply // returns false until a grant exists, and the FIRST refusal raises the prompt. // Read by the confirm callback. Only one app runs at a time (apps are // full-screen) and only one dialog can be open, so a single slot is enough. static char s_lua_perm_target[24] = {0}; static void luaPermPath(char* out, size_t cap) { luaHostAppPath(out, cap, "/apps/perms.kv"); } // Permission bits. The on-disk value is this mask as a decimal, which keeps every // file written before read permission existed valid: "=1" already meant send. // 0 = asked, nothing allowed 1 = send 2 = read 3 = both // Entry PRESENCE is what records "we asked" -- absent is not the same as refused, // and the settings page shows that difference. int luaHostAppPerms(const char* app_id, bool* asked) { if (asked) *asked = false; if (!app_id || !*app_id || !s_ui_data_fs) return 0; char path[80]; luaPermPath(path, sizeof path); File f = s_ui_data_fs->open(path, "r"); if (!f) return 0; int mask = 0; char line[64]; while (f.available()) { const size_t n = f.readBytesUntil('\n', line, sizeof(line) - 1); line[n] = '\0'; char* eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; if (strcmp(line, app_id) == 0) { if (asked) *asked = true; mask = atoi(eq + 1); break; } } f.close(); return mask; } // 1 = granted, -1 = refused, 0 = never asked. int luaHostSendPerm(const char* app_id) { bool asked = false; const int mask = luaHostAppPerms(app_id, &asked); if (!asked) return 0; return (mask & LUA_PERM_SEND) ? 1 : -1; } // True when the app may be shown incoming messages. No prompt is raised from // here: unlike send, this fires on someone ELSE's traffic arriving, so a dialog // would appear unbidden. An app asks for it in the settings page instead. bool luaHostReadPerm(const char* app_id) { bool asked = false; return (luaHostAppPerms(app_id, &asked) & LUA_PERM_READ) != 0; } // Private conversations are their own grants. Posting to a channel the user is // already in is a different act from writing to one person as them, and reading // channel traffic is different from reading their DMs — so an app that needs one // does not silently acquire the other. int luaHostDmSendPerm(const char* app_id) { bool asked = false; const int mask = luaHostAppPerms(app_id, &asked); if (!asked) return 0; return (mask & LUA_PERM_DM_SEND) ? 1 : -1; } bool luaHostDmReadPerm(const char* app_id) { bool asked = false; return (luaHostAppPerms(app_id, &asked) & LUA_PERM_DM_READ) != 0; } // Set or clear ONE permission bit, leaving the app's other grants alone. The // prompt path used to write a whole mask, so asking for send silently revoked a // read grant the user had given in Settings — harmless with one bit, wrong the // moment there is more than one. static void luaPermWrite(const char* app_id, int mask); static void luaPermSetBit(const char* app_id, int bit, bool on) { bool asked = false; int mask = luaHostAppPerms(app_id, &asked); if (on) mask |= bit; else mask &= ~bit; luaPermWrite(app_id, mask); } static void luaPermWrite(const char* app_id, int mask) { if (!app_id || !*app_id || !s_ui_data_fs) return; // Rewrite whole-file: the table is a handful of short lines, and an in-place // edit would have to deal with a shrinking record. char path[80]; luaPermPath(path, sizeof path); String keep; File in = s_ui_data_fs->open(path, "r"); if (in) { char line[64]; while (in.available()) { const size_t n = in.readBytesUntil('\n', line, sizeof(line) - 1); line[n] = '\0'; if (!line[0]) continue; char* eq = strchr(line, '='); if (eq) { *eq = '\0'; const bool same = (strcmp(line, app_id) == 0); *eq = '='; if (same) continue; } keep += line; keep += '\n'; } in.close(); } char rec[40]; snprintf(rec, sizeof rec, "%s=%d\n", app_id, mask); keep += rec; WdtHeavyGuard guard; // small, but it is still a flash write char appsdir[80]; luaHostAppPath(appsdir, sizeof appsdir, "/apps"); s_ui_data_fs->mkdir(appsdir); File out = s_ui_data_fs->open(path, "w"); if (!out) return; out.write((const uint8_t*)keep.c_str(), keep.length()); out.close(); } // Raised from wada.mesh.send's first refusal. Deliberately blunt about what is // being granted -- "send messages" understates it; the point the user needs is // that the messages carry THEIR name. void luaHostRequestSendPerm(const char* app_id, const char* app_name) { if (!app_id || !*app_id) return; snprintf(s_lua_perm_target, sizeof s_lua_perm_target, "%s", app_id); // Record the refusal FIRST, so the answer is "no" unless the user says // otherwise -- a power cut mid-dialog must not leave a grant. It also stops // this app re-prompting: its next send sees -1 and fails without a dialog. luaPermSetBit(app_id, LUA_PERM_SEND, false); // refused unless the user says otherwise const char* label = (app_name && *app_name) ? app_name : app_id; char msg[220]; snprintf(msg, sizeof msg, TR("\"%s\" wants to send messages on the mesh.\n\n" "They will be sent as %s and cannot be told apart from messages you typed.\n\n" "Only allow this for an app you trust."), label, the_mesh.getNodePrefs()->node_name); // Cancel needs no handler: the refusal is already on disk. The user can revisit // a decision by deleting /apps/perms.kv. showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_SEND, true); }); } // Send on a channel matched BY NAME. Matching by name rather than a cached slot // index is deliberate: a stale slot transmits on the WRONG key, which is exactly // the bug behind the channel-send fix in the touch composer. // Send a direct message to a contact BY NAME, or post to a room server (a room is // just a contact of type ADV_TYPE_ROOM, and the send is identical — which is why one // call covers both). Mirrors into the on-device chat thread and registers the expected // ACK exactly as a composer send does, so an app-sent message is a first-class message // rather than something that vanishes off the radio with no local trace. bool luaHostMeshSendDM(const char* to_name, const char* text, bool* was_room) { if (was_room) *was_room = false; if (!to_name || !*to_name || !text || !*text) return false; ContactInfo* by = nullptr; const uint32_t n = the_mesh.getNumContacts(); for (uint32_t i = 0; i < n; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if (strcmp(c.name, to_name) != 0) continue; by = the_mesh.lookupContactByPubKey(c.id.pub_key, PUB_KEY_SIZE); break; } if (!by) return false; // no contact with that name if (was_room) *was_room = (by->type == ADV_TYPE_ROOM); ContactInfo rcpt = *by; rcpt.out_path_len = OUT_PATH_UNKNOWN; // flood, same as the Chats composer does const uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique(); uint32_t expected_ack = 0, est_timeout = 0; const int rr = the_mesh.sendMessage(rcpt, ts, 0, (char*)text, expected_ack, est_timeout); if (rr == MSG_SEND_FAILED) return false; the_mesh.uiRegisterExpectedAck(expected_ack, by->id.pub_key); if (g_lv.task) g_lv.task->appSentMsgToContact(by->id.pub_key, by->name, text, expected_ack, the_mesh.uiLastSentFp()); return true; } // Names of the channels configured on this device, so an app can discover the // private ones rather than having to be told their names. Names only: the channel // SECRET is never exposed to Lua, so an app can post to a channel the user already // has but can never derive one or hand it to anybody. int luaHostMeshChannelNames(char out[][32], int max_n) { int n = 0; for (int i = 0; i < MAX_GROUP_CHANNELS && n < max_n; i++) { ChannelDetails cd; if (!the_mesh.getChannel(i, cd) || !cd.name[0]) continue; snprintf(out[n], 32, "%s", cd.name); n++; } return n; } // Raised from wada.mesh.send_dm's first refusal. Worded harder than the channel // prompt on purpose: a channel post is visible to everyone on that channel and is // obviously "from" the node, while a direct message arrives in one person's private // thread looking exactly like something the user typed to them. void luaHostRequestDmSendPerm(const char* app_id, const char* app_name) { if (!app_id || !*app_id) return; snprintf(s_lua_perm_target, sizeof s_lua_perm_target, "%s", app_id); luaPermSetBit(app_id, LUA_PERM_DM_SEND, false); // refused unless the user says otherwise const char* label = (app_name && *app_name) ? app_name : app_id; char msg[240]; snprintf(msg, sizeof msg, TR("\"%s\" wants to send PRIVATE messages as you.\n\n" "It could write to any of your contacts, or post to a room, and the " "message will look exactly like one you typed.\n\n" "Only allow this for an app you trust."), label); showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_DM_SEND, true); }); } bool luaHostMeshSendChannel(const char* chan_name, const char* text) { if (!chan_name || !*chan_name || !text || !*text) return false; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails cd; if (!the_mesh.getChannel(i, cd) || !cd.name[0]) continue; if (strcmp(cd.name, chan_name) != 0) continue; uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique(); const char* sender = the_mesh.getNodePrefs()->node_name; if (!the_mesh.sendGroupMessage(ts, cd.channel, sender, (char*)text, (int)strlen(text))) return false; if (g_lv.task) g_lv.task->appSentMsgToChannel(cd.name, text, the_mesh.uiLastSentFp()); return true; } return false; // no such channel on this device } #endif // CAP_LUA_SDK_EXT || CAP_CONSOLE #if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too // wada.sys.battery(). Reads the SMOOTHED millivolts the status bar uses, not a // raw ADC sample, so a Lua chart cannot show noise the rest of the UI hides. void luaHostBattery(uint16_t* mv, int* pct, bool* charging) { const uint16_t v = batteryMvSmoothed(); if (mv) *mv = v; if (pct) *pct = batteryPercentFromMv(v); if (charging) *charging = batteryIsCharging(v); } // wada.sys.gps(). False when there is no fix -- the caller then gets nil rather // than the last known position, which wada.mesh.self() already provides. An app // plotting a track needs to tell a live fix from a stale one. Also false while // the user has GPS switched off (same gate updateGpsLocation() applies), so a // last sentence parsed before the toggle cannot masquerade as a live fix. // Altitude (getGpsAltitude(), metres, the accessor the Device page uses) is // included because height dominates LoRa range: two samples a metre apart on // the map can be a hilltop and a hollow, and a coverage survey that records // only lat/lon cannot tell them apart afterwards. `time` is satellite time // (0 until the receiver has decoded the date), which is the only clock a // logger can trust before the device has been near a network. // Speed/course exist only where the board's target.cpp provides wadaGpsMotion() // on top of WadaNmeaLocationProvider (HAS_GPS_MOTION); the core provider keeps // its RMC fields private, so other boards report NAN and the binding omits the // fields. #if defined(HAS_GPS_MOTION) extern bool wadaGpsMotion(float* speed_kmh, float* course_deg); #endif bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, uint32_t* fix_time, int32_t* lat_e6, int32_t* lon_e6, float* speed_kmh, float* course_deg) { // Pre-set the motion fields BEFORE the fix gate: a caller that bails on a // false return still reads defined values, and boards without HAS_GPS_MOTION // report NAN so the binding can omit the fields rather than publish a 0. if (speed_kmh) *speed_kmh = NAN; if (course_deg) *course_deg = NAN; if (!g_lv.task || !g_lv.task->getGPSState() || !g_lv.task->getGpsFix()) return false; const double la = g_lv.task->getNodeLat(), lo = g_lv.task->getNodeLon(); if (lat) *lat = la; if (lon) *lon = lo; if (sats) *sats = g_lv.task->getGpsSats(); if (alt_m) *alt_m = g_lv.task->getGpsAltitude(); if (fix_time) *fix_time = g_lv.task->getGpsTime(); // Lua is built LUA_32BITS, so its floats are SINGLE precision: a latitude // keeps roughly a metre. Fine to display, not fine to log -- so the exact // micro-degrees go across as integers too, which int32 holds without loss // (180e6 is well inside its range). A track logger should write these. if (lat_e6) *lat_e6 = (int32_t)llround(la * 1.0e6); if (lon_e6) *lon_e6 = (int32_t)llround(lo * 1.0e6); #if defined(HAS_GPS_MOTION) { float spd = NAN, crs = NAN; if (wadaGpsMotion(&spd, &crs)) { if (speed_kmh) *speed_kmh = spd; if (course_deg) *course_deg = crs; } } #endif return true; } #endif // CAP_LUA_SDK_EXT || CAP_CONSOLE #if CAP_COMPASS // wada.sys.compass(). Raw field vector from the board's magnetometer; the only // one wired so far is the M9's QMC6309 (variants/thinknode_m9/M9Compass.*). // Runs on the UI thread like every other luaHost* bridge; the driver's I2C // reads are short and the bus (Wire) has no other task on it. #if CAP_IMU // wada.sys.accel(). Acceleration in g, sensor frame, as the chip reports it — // the mapping to the device's own axes is the consumer's, for the same reason // as the magnetometer: it is not documented for this board and had to be // measured. bool luaHostAccel(float* x, float* y, float* z) { #if defined(HAS_M9_IMU) return m9ImuRead(x, y, z); #else (void)x; (void)y; (void)z; return false; #endif } #endif // CAP_IMU bool luaHostCompass(float* x, float* y, float* z, bool* overflow) { #if defined(HAS_M9_COMPASS) return m9CompassRead(x, y, z, overflow); #else (void)x; (void)y; (void)z; (void)overflow; return false; #endif } #endif // CAP_COMPASS #if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too // ---- Settings -> App permissions ------------------------------------------- // You could GRANT a permission but not review or take one back, which is only // acceptable on a bench. "Which apps can transmit as me?" has to be answerable, // and answerable without knowing a file path. // // Lists every app that has been ASKED (an entry in perms.kv) plus every INSTALLED // app, so an app that has not asked yet is visible with its answer pre-set rather // than appearing out of nowhere the first time it wants the radio. // user_data packs (app index << 4) | permission bit -- ids[] outlives the sheet. static char s_appperm_ids[12][24]; // user_data packs (app index, permission bit) into one pointer. The shift MUST // stay wider than the widest permission bit: it was 4, and LUA_PERM_PROBE is 16, // so the probe switch overflowed straight into the index field. Toggling it // addressed the NEXT app with bit 0 -- changing no permission, but still writing // that app's record, which marks it "asked". An app that has been asked and not // granted is refused outright instead of prompting, so an unrelated app silently // lost the ability to send with no dialog ever shown (reported on Discord). #define APPPERM_SHIFT 8 static_assert(LUA_PERM_PROBE < (1 << APPPERM_SHIFT), "APPPERM_SHIFT must exceed the widest LUA_PERM_* bit"); static void appPermsToggleCb(lv_event_t* e) { const intptr_t packed = (intptr_t)lv_event_get_user_data(e); const int idx = (int)(packed >> APPPERM_SHIFT); const int bit = (int)(packed & ((1 << APPPERM_SHIFT) - 1)); if (idx < 0 || idx >= 12 || !s_appperm_ids[idx][0]) return; const char* id = s_appperm_ids[idx]; bool asked = false; int mask = luaHostAppPerms(id, &asked); if (lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED)) mask |= bit; else mask &= ~bit; luaPermWrite(id, mask); // writing also RECORDS the decision (entry presence) } static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw) { int y = 6; lv_obj_t* hint = lv_label_create(page); lv_label_set_text(hint, TR("What each installed app is allowed to do. Anything an app sends goes out " "under your node name and cannot be told apart from a message you " "typed. Private covers direct messages and room posts, and is kept " "separate from channels on purpose. Turn one off to take it back.")); lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); lv_obj_set_width(hint, lblw); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(hint, 6, y); lv_obj_update_layout(hint); y += lv_obj_get_height(hint) + 10; // Collect ids: everything installed, plus anything already in perms.kv (an app // can be removed while its decision lives on -- that decision must stay visible // and revocable, or a reinstall silently inherits an old yes). auto& ids = s_appperm_ids; int n = 0; for (int i = 0; i < s_lua_inst_n && n < 12; ++i) { snprintf(ids[n], sizeof ids[n], "%s", s_lua_inst[i].id); n++; } if (s_ui_data_fs) { char path[80]; luaPermPath(path, sizeof path); File f = s_ui_data_fs->open(path, "r"); if (f) { char line[64]; while (f.available() && n < 12) { const size_t got = f.readBytesUntil('\n', line, sizeof(line) - 1); line[got] = '\0'; char* eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; bool dup = false; for (int k = 0; k < n; ++k) if (strcmp(ids[k], line) == 0) { dup = true; break; } if (!dup) { snprintf(ids[n], sizeof ids[n], "%s", line); n++; } } f.close(); } } if (n == 0) { lv_obj_t* none = lv_label_create(page); lv_label_set_text(none, TR("No apps installed.")); lv_obj_set_style_text_font(none, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(none, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(none, 6, y); return; } for (int i = 0; i < n; ++i) { const int perm = luaHostSendPerm(ids[i]); char row[40]; snprintf(row, sizeof row, "%s", ids[i]); const int h = settingsRowLabel(page, y, 6, row, COLOR_TEXT, nullptr, 56); bool asked = false; const int mask = luaHostAppPerms(ids[i], &asked); const int rowh = (h > 0 ? h : 18); // Four switches: speaking in your name vs reading what arrives, each split // again between channels and private conversations. They are different risks -- // posting to a channel you are already in is not the same act as writing to one // person as you, and channel traffic is not your DMs -- so an app that wants // more than one has to be given each. struct { const char* label; int bit; } perms[] = { { TR("Post to channels as me"), LUA_PERM_SEND }, { TR("Read channel messages"), LUA_PERM_READ }, { TR("Send private messages as me"), LUA_PERM_DM_SEND }, { TR("Read private messages"), LUA_PERM_DM_READ }, { TR("Send discovery probes"), LUA_PERM_PROBE }, }; int sy = y + rowh + 2; for (int k = 0; k < (int)(sizeof perms / sizeof perms[0]); ++k) { lv_obj_t* pl = lv_label_create(page); lv_label_set_text(pl, perms[k].label); lv_obj_set_style_text_font(pl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(pl, 16, sy + 4); lv_obj_t* sw = lv_switch_create(page); lv_obj_set_size(sw, 44, 24); lv_obj_set_pos(sw, lblw - 44 + 6, sy); if (mask & perms[k].bit) lv_obj_add_state(sw, LV_STATE_CHECKED); // user_data packs the app index and the bit, so one callback serves both. lv_obj_add_event_cb(sw, appPermsToggleCb, LV_EVENT_VALUE_CHANGED, (void*)(intptr_t)((i << APPPERM_SHIFT) | perms[k].bit)); sy += 30; } if (!asked) { lv_obj_t* st = lv_label_create(page); lv_label_set_text(st, TR("not asked yet")); lv_obj_set_style_text_font(st, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(st, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_pos(st, 16, sy); sy += 18; } y = sy + 8; (void)perm; } } // ---- wada.mesh.discover: the active half of discovery ----------------------- // A probe is a zero-hop broadcast that asks every node in earshot to answer, so // one call costs airtime across the whole neighbourhood, not just ours. That is // also exactly what makes it worth having: a reply PROVES the link works from // where you are standing, which no amount of listening can establish. // // Its own permission rather than reusing "send": posting a message and making // every neighbour transmit are different impositions on other people's radios, // and a survey app has no business acquiring the ability to speak as the user. int luaHostProbePerm(const char* app_id) { bool asked = false; const int mask = luaHostAppPerms(app_id, &asked); if (!asked) return 0; return (mask & LUA_PERM_PROBE) ? 1 : -1; } void luaHostRequestProbePerm(const char* app_id, const char* app_name) { if (!app_id || !*app_id) return; snprintf(s_lua_perm_target, sizeof s_lua_perm_target, "%s", app_id); luaPermSetBit(app_id, LUA_PERM_PROBE, false); // refused unless the user says otherwise const char* label = (app_name && *app_name) ? app_name : app_id; char msg[240]; snprintf(msg, sizeof msg, TR("\"%s\" wants to send discovery probes.\n\n" "Each probe asks every node in range to reply, so it uses airtime " "on the whole local mesh, not just yours.\n\n" "Nothing is sent under your name and no message is transmitted."), label); showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_PROBE, true); }); } // type_filter is a bitmask over ADV_TYPE_*; 0 means every node type. // Returns the scan tag (non-zero) if the probe went out. uint32_t luaHostMeshDiscover(int type_filter) { return the_mesh.uiStartDiscoverScan((uint8_t)(type_filter & 0xFF)); } #endif // CAP_LUA_SDK_EXT || CAP_CONSOLE void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon, char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6) { snprintf(name, name_cap, "%s", the_mesh.getNodePrefs()->node_name); *lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0; *lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0; if (lat_e6) *lat_e6 = (int32_t)llround(*lat * 1.0e6); if (lon_e6) *lon_e6 = (int32_t)llround(*lon * 1.0e6); if (pk_hex && pk_cap) luaPubkeyHex(the_mesh.self_id.pub_key, 4, pk_hex, pk_cap); } // ---- Discovery results (read side) ----------------------------------------- // The table the Discover app fills. Reading it is passive -- these are replies // the radio already received -- so it is ungated, like contacts() and rx_log(). // FIRING a probe is the gated half; see luaHostMeshDiscover below. int luaHostDiscoverCount() { return the_mesh.discoverCount(); } void luaHostDiscoverClear() { the_mesh.discoverClear(); } int luaHostDiscoverAt(int idx, char* pk_hex, size_t pk_cap, char* name, size_t name_cap, int* type, int* rssi, float* snr, float* their_snr, int* hops, uint32_t* first_ms_ago, uint32_t* last_ms_ago, int* heard) { MyMesh::DiscoverHit h; if (idx < 0 || !the_mesh.discoverGet((uint8_t)idx, h)) return 0; luaPubkeyHex(h.pubkey, 4, pk_hex, pk_cap); // A responder is only named if the user already has it as a contact. A // discovery reply carries no name of its own, and inventing one would let an // app present an unknown node as a known one. ContactInfo* c = the_mesh.lookupContactByPubKey(h.pubkey, PUB_KEY_SIZE); snprintf(name, name_cap, "%s", c ? c->name : ""); *type = h.node_type; *rssi = h.our_rssi; *snr = (float)h.our_snr_q4 / 4.0f; // The reverse link: how well THEY heard US. This is the half a wardriving // survey cannot get any other way, and it is why a probe beats listening. *their_snr = (float)h.their_snr_q4 / 4.0f; *hops = h.path_len; const uint32_t now = millis(); *first_ms_ago = now - h.first_ms; *last_ms_ago = now - h.last_ms; *heard = h.heard; return 1; } // ---- wada.ui.input: one modal text field, reusing the file-manager prompt ---- // Exactly one callback per call: the text on OK, nullptr on any other close, so // an app can always re-enable itself. static void (*s_lua_prompt_cb)(const char*) = nullptr; static void luaPromptDeliver(const char* text) { void (*cb)(const char*) = s_lua_prompt_cb; s_lua_prompt_cb = nullptr; if (cb) cb(text); } void luaHostTextPromptCancel() { // closed some other way: report a cancel void (*cb)(const char*) = s_lua_prompt_cb; s_lua_prompt_cb = nullptr; if (cb) cb(nullptr); } #if CAP_SENSORS // wada.sys.env(). Reports per-field presence rather than a value with a magic // "missing" number, so an app can distinguish an absent sensor from a real // reading of zero. The snapshot is the same one the Sensors tab renders. void luaHostEnv(bool* ok, bool* have_t, float* temp_c, bool* have_h, float* hum_pct, bool* have_p, float* press_hpa, bool* have_alt, int* alt_m) { *ok = false; if (!g_lv.task) return; UITask::LocalEnvSnapshot snap; if (!g_lv.task->getLocalEnvSnapshot(snap) || !snap.query_ok) return; // Two possible sources for temperature and humidity: the BME280 on the // Expansion Kit and the GXHTC3 on the board. Prefer the BME, fall back. *have_t = snap.have_bme_temp || snap.have_gxhtv3_temp; *temp_c = snap.have_bme_temp ? snap.bme_temp_c : snap.gxhtv3_temp_c; *have_h = snap.have_bme_hum || snap.have_gxhtv3_hum; *hum_pct = snap.have_bme_hum ? snap.bme_hum_pct : snap.gxhtv3_hum_pct; *have_p = snap.have_bme_pressure; *press_hpa = snap.bme_pressure_hpa; *have_alt = snap.have_bme_alt; *alt_m = snap.bme_alt_m; *ok = (*have_t || *have_h || *have_p); } #endif // App teardown. Drops the callback FIRST so the close below cannot re-enter a // Lua state that is being torn down, then closes the dialog -- it lives on // lv_layer_top and would otherwise outlive the app that opened it. void luaHostTextPromptDismiss() { s_lua_prompt_cb = nullptr; fmPromptClose(); } void luaHostTextPrompt(const char* title, const char* initial, void (*cb)(const char*)) { luaHostTextPromptCancel(); // a second prompt cancels the first // Register the callback AFTER the dialog exists. fmTextPrompt opens with its // own fmPromptClose(), which reports a cancel to whatever is pending -- so // setting cb first made every prompt fire cb(nil) before the user saw it. fmTextPrompt(title && *title ? title : TR("Enter text"), initial, luaPromptDeliver); s_lua_prompt_cb = cb; } #endif // True when the chat history lives on a removable SD card (T-Deck SPI SD or the // Tanmatsu's SD_MMC) rather than internal flash — those devices get the deep // message ring (MAX_UI_MESSAGES_SD) since the card has room for the bigger file. static bool uiDataFsIsSdCard() { if (!uiDataFsReady()) return false; #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) return s_ui_data_fs == &SD_MMC; #elif defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) return s_ui_data_fs == &SD; #else return false; #endif } static File uiDataOpen(const char* name, const char* mode) { #if defined(TDP4_POKE_TRACE) // #167 hunt: every ui-data open with its issuing core. Writes ([SDW]) were all correctly on // core 0 during a flashing manual flow -- so now READS are traced too ([SDR]): a read is the // same FATFS/SDMMC critical-section machinery, and UI flows read constantly from core 1. if (mode && mode[0] != 'r') printf("[SDW] %lu core%d uiData %s %s\n", (unsigned long)millis(), xPortGetCoreID(), mode, name); else printf("[SDR] %lu core%d uiData r %s\n", (unsigned long)millis(), xPortGetCoreID(), name); #endif if (!uiDataFsReady()) return File(); char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); File f = s_ui_data_fs->open(p, mode); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // A failed WRITE open on the SD-backed history store is the wedge tell (reads // fail legitimately on first boot). Called from the loop task AND the core-0 // history worker — sdNoteIoFailure is a volatile stamp, safe from both. if (!f && mode && mode[0] == 'w' && s_ui_data_fs == &SD) sdNoteIoFailure(); #endif return f; } // Delete a ui-data file — used to quarantine a blob that fails to load so a // corrupt/truncated file (interrupted SD/FAT write, brownout) can't wedge the // reader on every boot. The next save rewrites it clean. static void uiDataRemove(const char* name) { if (!uiDataFsReady()) return; char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); s_ui_data_fs->remove(p); } // Atomically replace a ui-data file (write tmp, then rename) so a panic mid-write // cannot leave a truncated header that quarantines the whole chat on next boot. static bool uiDataReplaceFile(const char* final_name, const char* tmp_name) { if (!uiDataFsReady()) return false; char fin[80], tmp[80]; snprintf(fin, sizeof fin, "%s%s", s_ui_data_root, final_name); snprintf(tmp, sizeof tmp, "%s%s", s_ui_data_root, tmp_name); s_ui_data_fs->remove(fin); return s_ui_data_fs->rename(tmp, fin); } // Shared helper: read one on-disk record into a zero-initialized current-layout // struct, skipping any surplus tail bytes from a newer (longer) record. static bool readHistoryRec(File& f, void* dst, size_t cur_sz, size_t disk_sz) { memset(dst, 0, cur_sz); const size_t take = disk_sz < cur_sz ? disk_sz : cur_sz; if (f.readBytes(reinterpret_cast<char*>(dst), take) != static_cast<int>(take)) return false; if (disk_sz > cur_sz && !f.seek(f.position() + (disk_sz - cur_sz))) return false; return true; } bool UITask::loadThreadsFromStorage() { #if defined(ESP32) uiDataRemove(k_ui_threads_tmp_path); // sweep an orphaned tmp from an interrupted save (mirrors the msgs-tmp sweep) File f = uiDataOpen(k_ui_threads_path, "r"); if (!f) return false; UiHistoryHeader hdr{}; if (f.readBytes(reinterpret_cast<char*>(&hdr), sizeof(hdr)) != static_cast<int>(sizeof(hdr)) || hdr.magic != k_ui_threads_magic || hdr.version < k_ui_history_min_version || hdr.version > k_ui_history_version) { f.close(); uiDataRemove(k_ui_threads_path); return false; // corrupt header — quarantine so it can't wedge every boot } const size_t disk_sz = (hdr.version >= 5 && hdr.thread_rec_size) ? hdr.thread_rec_size : sizeof(UiHistoryThread); if (disk_sz == 0 || disk_sz > 4096) { f.close(); uiDataRemove(k_ui_threads_path); return false; } // Restore the companion message counter carried here for the segmented msgs // store (older writers left it 0; the msgs-file loaders overwrite it anyway // when they run, and the mesh re-asserts it with every message). if (hdr.msgcount) _msgcount = static_cast<int>(hdr.msgcount); UiHistoryThread t{}; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!readHistoryRec(f, &t, sizeof(t), disk_sz)) { f.close(); uiDataRemove(k_ui_threads_path); return false; } // truncated/corrupt record _ui_threads[i].used = t.used != 0; _ui_threads[i].channel = t.channel != 0; _ui_threads[i].unread = t.unread; _ui_threads[i].last_ts = t.last_ts; _ui_threads[i].mesh_contact_idx = t.mesh_contact_idx; memcpy(_ui_threads[i].mesh_contact_pub, t.mesh_contact_pub, sizeof(t.mesh_contact_pub)); memcpy(_ui_threads[i].mesh_contact_key6, t.mesh_contact_key6, sizeof(t.mesh_contact_key6)); _ui_threads[i].mesh_channel_slot = t.mesh_channel_slot; strncpy(_ui_threads[i].name, t.name, MAX_THREAD_NAME); _ui_threads[i].name[MAX_THREAD_NAME] = '\0'; } f.close(); _active_thread_idx = hdr.active_thread_idx; _active_thread_is_channel = hdr.active_thread_is_channel != 0; if (_active_thread_idx < 0 || _active_thread_idx >= MAX_UI_THREADS || !_ui_threads[_active_thread_idx].used) { _active_thread_idx = -1; _active_thread_is_channel = false; } return true; #else return false; #endif } bool UITask::loadMsgsFromStorage() { #if defined(ESP32) File f = uiDataOpen(k_ui_msgs_path, "r"); if (!f) return false; UiMsgFileHeader hdr{}; if (f.readBytes(reinterpret_cast<char*>(&hdr), sizeof(hdr)) != static_cast<int>(sizeof(hdr)) || hdr.magic != k_ui_msgs_magic || hdr.version < k_ui_history_min_version || hdr.version > k_ui_history_version) { f.close(); uiDataRemove(k_ui_msgs_path); return false; // corrupt header — quarantine so it can't crash/wedge every boot } const size_t disk_sz = (hdr.version >= 5 && hdr.msg_rec_size) ? hdr.msg_rec_size : sizeof(UiHistoryMsg); if (disk_sz == 0 || disk_sz > 4096) { f.close(); uiDataRemove(k_ui_msgs_path); return false; } // The file carries the WRITER's full ring (one record per slot), so its slot // count is derived from the file size — NOT from this build's capacity. That // lets a 500-slot file load into a 5000 ring (SD upgrade) and a 5000-slot file // shrink into a 500 ring (card pulled), instead of quarantining real history. const int file_slots = (int)(((size_t)f.size() - sizeof(hdr)) / disk_sz); if (file_slots == 0 && hdr.ui_msg_count == 0) { // Legitimately empty (every message was deleted, so the compacting writer // emitted a header-only file) — NOT corruption. Load the empty ring; the // old quarantine here deleted the file and made the boot fall through to // stale fallbacks. f.close(); _ui_msg_count = 0; _ui_msg_head = 0; _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast<int>(hdr.msgcount); return true; } if (file_slots <= 0 || hdr.ui_msg_count > file_slots || hdr.ui_msg_head >= file_slots) { f.close(); uiDataRemove(k_ui_msgs_path); return false; // header disagrees with the file body } // Linearize while reading: chronological rank c of file slot i is derived from // the writer's ring geometry (oldest = head - count), and message c lands in // OUR slot c (minus `drop` when shrinking — keep only the newest cap records). const int n_file = hdr.ui_msg_count; const int n_keep = n_file > _ui_msg_cap ? _ui_msg_cap : n_file; const int drop = n_file - n_keep; const int oldest = (hdr.ui_msg_head - n_file + 2 * file_slots) % file_slots; UiHistoryMsg m{}; for (int i = 0; i < file_slots; ++i) { if (!readHistoryRec(f, &m, sizeof(m), disk_sz)) { f.close(); uiDataRemove(k_ui_msgs_path); return false; } // truncated/corrupt record const int c = (i - oldest + file_slots) % file_slots; // chronological rank (0 = oldest) if (c < drop || c >= n_file) continue; // empty slot / outside the kept window UIMessage& d = _ui_msgs[c - drop]; d.ts = m.ts; d.channel = m.channel != 0; d.outgoing = m.outgoing != 0; d.meta_flags = m.meta_flags; d.path_len = m.path_len; d.snr_q4 = m.snr_q4; d.rssi = m.rssi; strncpy(d.thread, m.thread, MAX_THREAD_NAME); d.thread[MAX_THREAD_NAME] = '\0'; strncpy(d.sender, m.sender, MAX_SENDER_NAME); d.sender[MAX_SENDER_NAME] = '\0'; strncpy(d.text, m.text, MAX_MSG_TEXT); d.text[MAX_MSG_TEXT] = '\0'; } f.close(); _ui_msg_count = n_keep; // ring is now linear: slots 0..n_keep-1, oldest first _ui_msg_head = n_keep % _ui_msg_cap; _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast<int>(hdr.msgcount); return true; #else return false; #endif } // Segmented-store loader (generation 3). Reads every discovered segment // oldest-first into the ring, quarantining corrupt segments INDIVIDUALLY (the // single-file store threw the entire history away on one bad record), keeping // the newest <= cap records when the on-disk set is larger than this boot's // ring (card written under the deep cap, loaded under the small one), and // leaving the ring LINEAR (slot 0 = oldest, head = n) — the same post-load // invariant every ring consumer relies on. Returns false when no segment // files exist at all, so the caller can fall back to the older formats. bool UITask::loadMsgsFromSegments() { #if defined(ESP32) uint32_t seqs[k_ui_seg_max]; const int nseg = uiSegScan(seqs, k_ui_seg_max, true /*sweep orphaned tmps*/); s_seg_count = 0; s_seg_total_bytes = 0; if (nseg <= 0) return false; // Commit marker missing => this segment set is an unfinished migration (a // power cut between chunk writes). Wipe it and let the caller fall back to // the old-format file, which migrateRingToSegments only deletes after full // verification. { File okf = uiDataOpen(k_ui_seg_ok, FILE_READ); const bool have_marker = (bool)okf; if (okf) okf.close(); if (!have_marker) { for (int i = 0; i < nseg; ++i) uiSegRemoveFile(seqs[i]); return false; } } WdtHeavyGuard _wg; // ~20 segment reads on a cold card can take a while int w = 0; // running chronological index; ring slot = w % cap uint32_t max_seq = 0; for (int si = 0; si < nseg; ++si) { File f; uint16_t rec_sz = 0; if (!uiSegOpenValidated(seqs[si], f, &rec_sz)) { uiSegRemoveFile(seqs[si]); // quarantine THIS segment only continue; } UiSegInfo info{}; info.first_seq = seqs[si]; UIMessage rec; uint32_t seq = 0; uint16_t nrec = 0; bool skipped = false; // duplicates dropped -> file != table -> rewrite it while (uiSegReadRec(f, rec_sz, &rec, &seq)) { // stops at EOF or a crash-ragged tail // Strictly monotonic seq filter. Segments are read in ascending // first_seq order, so a record that does not advance the sequence is a // duplicate or an out-of-order leftover (an interrupted repair, a stale // file a crash left behind, a re-appended batch) — dropping it keeps the // ring chronological and idempotent no matter what the disk holds. if (rec.seq != 0 && rec.seq <= max_seq) { skipped = true; continue; } if (rec.seq == 0) rec.seq = max_seq + 1; // defensive backfill (pre-seq record) max_seq = rec.seq; info.last_seq = rec.seq; _ui_msgs[w % _ui_msg_cap] = rec; ++w; ++nrec; } f.close(); info.disk_recs = nrec; info.live_recs = nrec; info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz; // The file holds records the table doesn't account for — rewrite it so disk // and table agree again (also drops the dead weight for good). if (skipped) info.rewrite_open = 1; if (nrec == 0) { uiSegRemoveFile(seqs[si]); continue; } // header-only husk — reclaim if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = info; } const int total = w; const int n_keep = total > _ui_msg_cap ? _ui_msg_cap : total; if (total > _ui_msg_cap) { // The ring wrapped while loading: the oldest KEPT record sits at slot // total % cap. Rotate left by that amount so the ring is linear again. // Three-reversal rotation: O(cap) record swaps, no large temp buffer. const int cap = _ui_msg_cap; const int k = total % cap; if (k > 0) { auto rev = [this](int a, int b) { while (a < b) { UIMessage t = _ui_msgs[a]; _ui_msgs[a] = _ui_msgs[b]; _ui_msgs[b] = t; ++a; --b; } }; rev(0, k - 1); rev(k, cap - 1); rev(0, cap - 1); } } // Reconcile the table with any dropped-oldest records: fully-dropped // segments violate the disk == ring invariant and are deleted outright; the // boundary segment keeps its file but is marked compact-dirty so the next // compaction (rewritten from the RAM ring) trims it to the kept records. const uint32_t min_kept_seq = n_keep > 0 ? _ui_msgs[0].seq : 0; int out = 0; uint32_t total_bytes = 0; for (int i = 0; i < s_seg_count; ++i) { if (n_keep == 0 || s_seg[i].last_seq < min_kept_seq) { uiSegRemoveFile(s_seg[i].first_seq); continue; } if (s_seg[i].first_seq < min_kept_seq) s_seg[i].compact_dirty = 1; total_bytes += s_seg[i].bytes; s_seg[out++] = s_seg[i]; } s_seg_count = out; s_seg_total_bytes = total_bytes; _ui_msg_count = n_keep; _ui_msg_head = n_keep % _ui_msg_cap; _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _ui_seq_next = max_seq + 1; s_seg_flushed_seq = max_seq; // everything loaded IS on disk by definition // _msgcount isn't stored in segments; the threads-file header carries it and // the mesh layer re-asserts it with every message. Keep it monotonic vs seq. if ((int)max_seq > _msgcount) _msgcount = (int)max_seq; s_seg_store_ready = true; return true; #else return false; #endif } // One-time migration: the ring was just loaded from an OLD format (split v6 // single file or the legacy combined file) — write it out as segments, read // every segment back and verify counts + seq bounds, and only then delete the // old msgs file. Any failure rolls the segment set back completely so the // intact old file stays authoritative on the next boot (a partial segment set // would otherwise shadow it in the loader's precedence order). bool UITask::migrateRingToSegments() { #if defined(ESP32) uiDataEnsureDirs(); // The marker goes away FIRST: from here until the verified end of this // migration the on-disk segment set is provisional, and a boot that finds it // without the marker wipes it and re-reads the (still intact) old file. uiDataRemove(k_ui_seg_ok); { // clear any earlier/partial set so leftovers can't survive as extra segments uint32_t old_seqs[k_ui_seg_max]; const int old_n = uiSegScan(old_seqs, k_ui_seg_max, true); for (int i = 0; i < old_n; ++i) uiSegRemoveFile(old_seqs[i]); } s_seg_count = 0; s_seg_total_bytes = 0; UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); if (!buf) return false; // Walk the ring CHRONOLOGICALLY and skip tombstones. Both matter: this runs // not only at boot (ring linear) but also as a retry after a failed boot // migration, by which time appendMessage may have WRAPPED the ring and the // user may have deleted messages. Slicing raw slots there produced segments // in slot order (first_seq > last_seq, scrambled history that read-back // verification cannot detect) and persisted tombstones as ghost records. bool ok = true; int in_chunk = 0; for (int i = 0; i <= _ui_msg_count && ok; ++i) { const bool flush_chunk = (i == _ui_msg_count) || (in_chunk == k_ui_seg_records); if (flush_chunk && in_chunk > 0) { const uint32_t fseq = buf[0].seq; ok = uiSegCompactWrite(fseq, buf, in_chunk, true /*sync-writer tmp*/); if (ok && s_seg_count < k_ui_seg_max) { UiSegInfo info{}; info.first_seq = fseq; info.last_seq = buf[in_chunk - 1].seq; info.disk_recs = info.live_recs = (uint16_t)in_chunk; info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)in_chunk * (uint32_t)sizeof(UiSegMsg); s_seg[s_seg_count++] = info; } in_chunk = 0; } if (i == _ui_msg_count) break; const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; if (!_ui_msgs[slot].thread[0]) continue; // tombstone — never persisted buf[in_chunk++] = _ui_msgs[slot]; } if (ok) { // Read-back verification: every segment must reproduce its exact record // count and seq bounds before the old file may be deleted. for (int i = 0; ok && i < s_seg_count; ++i) { File f; uint16_t rec_sz = 0; if (!uiSegOpenValidated(s_seg[i].first_seq, f, &rec_sz)) { ok = false; break; } UIMessage r; uint32_t seq = 0, first = 0, last = 0; uint16_t cnt = 0; bool ordered = true; while (uiSegReadRec(f, rec_sz, &r, &seq)) { if (cnt == 0) first = seq; else if (seq <= last) ordered = false; // seqs must strictly increase within a segment last = seq; ++cnt; } f.close(); ok = (ordered && cnt == s_seg[i].disk_recs && first == s_seg[i].first_seq && last == s_seg[i].last_seq); } // Segments must also be ordered relative to each other. for (int i = 1; ok && i < s_seg_count; ++i) ok = (s_seg[i].first_seq > s_seg[i - 1].last_seq); } if (!ok) { for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); s_seg_count = 0; s_seg_total_bytes = 0; return false; // old file untouched; the scheduler retries the migration later } // Verified. Commit: marker, then drop the old-format files. { File okf = uiDataOpen(k_ui_seg_ok, "w"); if (!okf) { // can't commit -> leave the old file authoritative and retry later for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); s_seg_count = 0; s_seg_total_bytes = 0; return uiMsgsWriteFail('m'); } okf.print("segstore v1\n"); okf.close(); } uint32_t total_bytes = 0; for (int i = 0; i < s_seg_count; ++i) total_bytes += s_seg[i].bytes; s_seg_total_bytes = total_bytes; uiDataRemove(k_ui_msgs_path); uiDataRemove(k_ui_msgs_tmp_path); uiDataRemove(k_ui_msgs_tmp2_path); s_seg_flushed_seq = s_seg_count ? s_seg[s_seg_count - 1].last_seq : 0; s_seg_resync = false; // a full write IS the resync s_seg_stale_purge = false; s_seg_store_ready = true; return true; #else return false; #endif } bool UITask::loadLegacyHistoryFromStorage() { #if defined(ESP32) File f = uiDataOpen(k_ui_history_path, "r"); if (!f) return false; UiHistoryHeader hdr{}; if (f.readBytes(reinterpret_cast<char*>(&hdr), sizeof(hdr)) != static_cast<int>(sizeof(hdr))) { f.close(); return false; } if (hdr.magic != k_ui_history_magic || hdr.version < k_ui_history_min_version || hdr.version > k_ui_history_version || hdr.ui_msg_count > _ui_msg_cap || hdr.ui_msg_head >= _ui_msg_cap) { f.close(); return false; } // On-disk record sizes. v5+ stores them so a blob written by an older build // (shorter records — fields appended since) still loads: we read the stored // size and leave the appended tail zeroed. A v4 blob predates these fields // but shares the current record layout, so fall back to sizeof(). Reject // absurd sizes from a corrupt blob. const size_t disk_thread_sz = (hdr.version >= 5 && hdr.thread_rec_size) ? hdr.thread_rec_size : sizeof(UiHistoryThread); const size_t disk_msg_sz = (hdr.version >= 5 && hdr.msg_rec_size) ? hdr.msg_rec_size : sizeof(UiHistoryMsg); if (disk_thread_sz == 0 || disk_thread_sz > 4096 || disk_msg_sz == 0 || disk_msg_sz > 4096) { f.close(); return false; } UiHistoryThread t{}; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!readHistoryRec(f, &t, sizeof(t), disk_thread_sz)) { f.close(); return false; } _ui_threads[i].used = t.used != 0; _ui_threads[i].channel = t.channel != 0; _ui_threads[i].unread = t.unread; _ui_threads[i].last_ts = t.last_ts; _ui_threads[i].mesh_contact_idx = t.mesh_contact_idx; memcpy(_ui_threads[i].mesh_contact_pub, t.mesh_contact_pub, sizeof(t.mesh_contact_pub)); memcpy(_ui_threads[i].mesh_contact_key6, t.mesh_contact_key6, sizeof(t.mesh_contact_key6)); _ui_threads[i].mesh_channel_slot = t.mesh_channel_slot; strncpy(_ui_threads[i].name, t.name, MAX_THREAD_NAME); _ui_threads[i].name[MAX_THREAD_NAME] = '\0'; } // The legacy file was written when MAX_UI_MESSAGES was smaller (96). Derive // how many message slots are actually present from the remaining file bytes so // we don't try to read past EOF when the ring has grown. Slots beyond the // legacy count are left zero — the reconciliation pass below will clear any // unread counts whose messages were already evicted. const size_t bytes_after_threads = f.size() > f.position() ? f.size() - f.position() : 0; const int legacy_slots = (int)(bytes_after_threads / disk_msg_sz); UiHistoryMsg m{}; for (int i = 0; i < _ui_msg_cap; ++i) { if (i < legacy_slots) { if (!readHistoryRec(f, &m, sizeof(m), disk_msg_sz)) { f.close(); return false; } _ui_msgs[i].ts = m.ts; _ui_msgs[i].channel = m.channel != 0; _ui_msgs[i].outgoing = m.outgoing != 0; _ui_msgs[i].meta_flags = m.meta_flags; _ui_msgs[i].path_len = m.path_len; _ui_msgs[i].snr_q4 = m.snr_q4; _ui_msgs[i].rssi = m.rssi; strncpy(_ui_msgs[i].thread, m.thread, MAX_THREAD_NAME); _ui_msgs[i].thread[MAX_THREAD_NAME] = '\0'; strncpy(_ui_msgs[i].sender, m.sender, MAX_SENDER_NAME); _ui_msgs[i].sender[MAX_SENDER_NAME] = '\0'; strncpy(_ui_msgs[i].text, m.text, MAX_MSG_TEXT); _ui_msgs[i].text[MAX_MSG_TEXT] = '\0'; } else { _ui_msgs[i] = UIMessage{}; } } f.close(); _ui_msg_count = hdr.ui_msg_count; _ui_msg_head = hdr.ui_msg_head; _msgcount = static_cast<int>(hdr.msgcount); _active_thread_idx = hdr.active_thread_idx; _active_thread_is_channel = hdr.active_thread_is_channel != 0; if (_active_thread_idx < 0 || _active_thread_idx >= MAX_UI_THREADS || !_ui_threads[_active_thread_idx].used) { _active_thread_idx = -1; _active_thread_is_channel = false; } return true; #else return false; #endif } bool UITask::loadHistoryFromStorage() { #if defined(ESP32) const bool have_threads = loadThreadsFromStorage(); // Format precedence: segments (generation 3) > split v6 single file > // legacy combined file. The older loaders are retained as migration // readers; whenever one of them supplies the ring, migrateRingToSegments() // below converts it (verify-then-delete, rollback on any failure). const bool from_segments = loadMsgsFromSegments(); bool have_msgs = from_segments; if (!from_segments) { // Single-file-era hygiene: a crash mid-flush can orphan a temp file; // they're never read, just reclaim the space. (Segment tmps are swept by // the scan inside loadMsgsFromSegments.) uiDataRemove(k_ui_msgs_tmp_path); uiDataRemove(k_ui_msgs_tmp2_path); have_msgs = loadMsgsFromStorage(); if (!have_threads && !have_msgs) { // Neither split file found — try the legacy combined format. if (!loadLegacyHistoryFromStorage()) return false; // Schedule the split threads-file write; messages land in segments via // the migration below (the legacy combined file is left in place, as // the split migration always did). markThreadsDirty(500); } } // Clear persisted unread counts for threads whose messages are no longer in // the ring. The display ring is a fixed-size cache independent of the unread // counter; if the device rebooted after ring overflow the counter can be // non-zero while the ring holds nothing for that thread, producing a phantom // badge that opens to an empty chat. Resetting here is safe: the user never // saw those messages anyway (ring had already evicted them before the save). for (int i = 0; i < MAX_UI_THREADS; ++i) { if (_ui_threads[i].used && _ui_threads[i].unread > 0 && !threadHasMessageHistory(i)) { _ui_threads[i].unread = 0; _ui_threads[i].has_mention = false; } } if (!from_segments) { // Backfill per-record sequence numbers for records loaded from the // pre-segment formats (their records carry no seq on disk): stamp them in // chronological order, seed the generator past them, then convert the // ring to segments. A failed migration leaves the old file authoritative // and s_seg_store_ready false — the flush scheduler retries it before it // will write any segment. for (int i = 0; i < _ui_msg_count; ++i) { const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; _ui_msgs[slot].seq = (uint32_t)(i + 1); } _ui_seq_next = (uint32_t)_ui_msg_count + 1; migrateRingToSegments(); } return true; #else return false; #endif } bool UITask::saveThreadsToStorage() { #if defined(ESP32) #if defined(HAS_TDISPLAY_P4) // #167: the threads file is rewritten on every contact add/delete and chat-state change -- // hop the write to the core-0 storage task like every other hot writer (see p4StorageCall). if (!p4OnStorageTask()) { struct A { UITask* t; bool ok; } a{ this, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = a->t->saveThreadsToStorage(); }, &a); return a.ok; } #endif WdtHeavyGuard _wg; // Write to the tmp, commit with rename: a power cut mid-write leaves the old // index intact instead of a short file the next boot would quarantine. File f = uiDataOpen(k_ui_threads_tmp_path, "w"); if (!f) return false; UiHistoryHeader hdr{}; hdr.magic = k_ui_threads_magic; hdr.version = k_ui_history_version; hdr.thread_rec_size = static_cast<uint16_t>(sizeof(UiHistoryThread)); hdr.active_thread_idx = static_cast<int16_t>(_active_thread_idx); hdr.active_thread_is_channel = _active_thread_is_channel ? 1u : 0u; // The segmented msgs store doesn't carry the companion message counter — // this (frequently-rewritten, small) file does instead. hdr.msgcount = static_cast<uint32_t>(_msgcount); if (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) != sizeof(hdr)) { f.close(); uiDataRemove(k_ui_threads_tmp_path); return false; } UiHistoryThread t{}; for (int i = 0; i < MAX_UI_THREADS; ++i) { memset(&t, 0, sizeof(t)); t.used = _ui_threads[i].used ? 1u : 0u; t.channel = _ui_threads[i].channel ? 1u : 0u; t.unread = _ui_threads[i].unread; t.last_ts = _ui_threads[i].last_ts; t.mesh_contact_idx = _ui_threads[i].mesh_contact_idx; memcpy(t.mesh_contact_pub, _ui_threads[i].mesh_contact_pub, sizeof(t.mesh_contact_pub)); memcpy(t.mesh_contact_key6, _ui_threads[i].mesh_contact_key6, sizeof(t.mesh_contact_key6)); t.mesh_channel_slot = _ui_threads[i].mesh_channel_slot; strncpy(t.name, _ui_threads[i].name, MAX_THREAD_NAME); t.name[MAX_THREAD_NAME] = '\0'; if (f.write(reinterpret_cast<const uint8_t*>(&t), sizeof(t)) != sizeof(t)) { f.close(); uiDataRemove(k_ui_threads_tmp_path); return false; } } f.close(); return uiDataReplaceFile(k_ui_threads_path, k_ui_threads_tmp_path); #else return false; #endif } // Write a message ring (the live one at shutdown, the worker's snapshot for the // periodic flush) to the history file. Chunked writer (same pattern that fixed // the contacts stall, #82): records are packed into an INTERNAL-RAM chunk and // flushed in ~6 KB writes, so the ring costs ~20 FS calls (500 slots) / ~210 // (5000 slots) instead of one small write per record. This is NOT the "one big // write from PSRAM" that regressed before: the chunk lives in internal RAM (the // flash driver never bounces a PSRAM source). Alloc failure falls back to the // original per-record writes. // Record a ring-write outcome (runs on the loop task OR the core-0 worker — // volatile stores only, LVGL-free) and stamp the SD health probe on failure so // a wedged card gets arbitrated instead of the write failing silently forever. static bool uiMsgsWriteResult(bool ok) { const uint32_t m = (uint32_t)millis(); const time_t now_t = time(nullptr); const uint32_t ep = (now_t > 1700000000) ? (uint32_t)now_t : 0; // 0 when the clock is unset if (ok) { s_msgs_write_ok_ms = m ? m : 1; s_msgs_write_ok_epoch = ep; s_msgs_write_fails = 0; } else { s_msgs_write_fail_ms = m ? m : 1; s_msgs_write_fail_epoch = ep; if (s_msgs_write_fails < 0xFFFFu) s_msgs_write_fails = s_msgs_write_fails + 1; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (s_ui_data_fs == &SD) sdNoteIoFailure(); #endif } return ok; } // Failure wrapper: record WHERE the write died and the errno — 'open failed // with ENFILE' (VFS file-handle table full) needs a completely different fix // than 'body write failed with EIO' (card), and without this they were // indistinguishable on a device with no readable serial. static bool uiMsgsWriteFail(char stage) { s_msgs_write_stage = (uint8_t)stage; s_msgs_write_errno = errno; return uiMsgsWriteResult(false); } // ---- Segmented store: low-level file ops -------------------------------- // Pure file-layer primitives (no scheduling, no table state — that lives with // the flush scheduler). All take explicit record arrays (snapshots or the // ring under the loop task's ownership) and report through the same // uiMsgsWriteResult/uiMsgsWriteFail health machinery as the old writer, so // the About diagnostics, failure toasts, sync-fallback and SD-wedge // arbitration keep working unchanged. Stage codes: 'a' append, 'c' compact, // 'd' data-dir create, 's' segment scan (plus errno). // Root-relative segment file name: "/msgs/seg_<first_seq>.bin<suffix>". // Worst case "/msgs/seg_4294967295.bin.tm2" = 28 chars — fits uiDataOpen's // 80-byte path buffer and SPIFFS' 31-char flat-name limit. static void uiSegName(uint32_t first_seq, const char* suffix, char* out, size_t cap) { snprintf(out, cap, "%s/seg_%lu.bin%s", k_ui_seg_dir, (unsigned long)first_seq, suffix ? suffix : ""); } // Ensure the data root + segment dir exist. FAT backends (SD /meshcomod, // SD_MMC /meshcomod, FFat) need real directories; SPIFFS has a flat namespace // where mkdir fails harmlessly and slash-in-name files just work — so this is // best-effort by design. Called at backend resolve and from every remount // path (a fresh replacement card has neither directory). static void uiDataEnsureDirs() { if (!uiDataFsReady()) return; char p[80]; if (s_ui_data_root[0]) s_ui_data_fs->mkdir(s_ui_data_root); snprintf(p, sizeof p, "%s%s", s_ui_data_root, k_ui_seg_dir); s_ui_data_fs->mkdir(p); // no-op/failure on SPIFFS is fine (flat names) } // Marshal one RAM record into the on-disk segment record (drops the RAM-only // fields, carries seq). Mirrors the old writer's field list exactly. static void uiSegMarshal(const UITask::UIMessage& s, UiSegMsg* d) { memset(d, 0, sizeof(*d)); d->m.ts = s.ts; d->m.channel = s.channel ? 1u : 0u; d->m.outgoing = s.outgoing ? 1u : 0u; d->m.meta_flags = s.meta_flags; d->m.path_len = s.path_len; d->m.snr_q4 = s.snr_q4; d->m.rssi = s.rssi; strncpy(d->m.thread, s.thread, sizeof(d->m.thread) - 1); d->m.thread[sizeof(d->m.thread) - 1] = '\0'; strncpy(d->m.sender, s.sender, sizeof(d->m.sender) - 1); d->m.sender[sizeof(d->m.sender) - 1] = '\0'; strncpy(d->m.text, s.text, sizeof(d->m.text) - 1); d->m.text[sizeof(d->m.text) - 1] = '\0'; d->seq = s.seq; } // Chunked record writer shared by append + compact: marshals through a small // INTERNAL-RAM buffer (the flash/SD drivers must never see a PSRAM source // pointer), per-record fallback when the heap is tight. Returns false on any // short write (errno left for the caller's stage report). static bool uiSegWriteRecords(File& f, const UITask::UIMessage* recs, int n) { const size_t REC = sizeof(UiSegMsg); size_t chunk_recs = 6144 / REC; if (chunk_recs < 1) chunk_recs = 1; uint8_t* buf = (uint8_t*)malloc(REC * chunk_recs); // internal RAM by default bool ok = true; if (buf) { size_t fill = 0; for (int k = 0; ok && k < n; ++k) { uiSegMarshal(recs[k], reinterpret_cast<UiSegMsg*>(buf + fill)); fill += REC; if (fill == REC * chunk_recs) { ok = (f.write(buf, fill) == fill); fill = 0; } } if (ok && fill > 0) ok = (f.write(buf, fill) == fill); free(buf); } else { UiSegMsg rec; for (int k = 0; ok && k < n; ++k) { uiSegMarshal(recs[k], &rec); ok = (f.write(reinterpret_cast<const uint8_t*>(&rec), sizeof(rec)) == sizeof(rec)); } } return ok; } // Append records to a segment file. `create` = the file does not exist yet // (write the header first). Driven by table state, NOT by File::size() — the // Tanmatsu FFat metadata layer lies about sizes. A crash mid-append leaves a // ragged tail the loader truncates to the record boundary; nothing else is at // risk (the previous records and every other segment are untouched — this is // the whole point of the segmented layout). static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n) { #if defined(HAS_TDISPLAY_P4) // #167: history writes hop to the core-0 storage task (see p4StorageCall in DataStore.cpp) -- // a core-1 SD write can mask the DSI frame-restart ISR long enough to drop a display frame. if (!p4OnStorageTask()) { struct A { uint32_t fs; bool c; const UITask::UIMessage* r; int n; bool ok; } a{ first_seq, create, recs, n, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegAppendRecords(a->fs, a->c, a->r, a->n); }, &a); return a.ok; } #endif WdtHeavyGuard _wg; errno = 0; char name[48]; uiSegName(first_seq, "", name, sizeof name); // create: open truncating — a stale file under the same key (crash residue) // must not end up with a second header appended mid-file. const char* mode = create ? "w" : FILE_APPEND; File f = uiDataOpen(name, mode); if (!f) { // First failure on a fresh card/dir is usually a missing parent dir — // create it and retry once before reporting. uiDataEnsureDirs(); errno = 0; f = uiDataOpen(name, mode); if (!f) return uiMsgsWriteFail('a'); } bool ok = true; if (create) { UiSegHeader hdr{}; hdr.magic = k_ui_seg_magic; hdr.version = k_ui_seg_version; hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); hdr.first_seq = first_seq; ok = (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) == sizeof(hdr)); } if (ok) ok = uiSegWriteRecords(f, recs, n); f.close(); if (!ok) { // A short write can have landed whole records already. The file content is // now untrusted: appending the retry after that tail would duplicate (or, // if the failure split a record, MISALIGN) records the loader accepts as // history. Flag the segment so the scheduler repairs it with a full // rewrite before any further append. Volatile-only work — safe from the // worker task; the entry lookup runs on the loop task's table but only // writes a byte the loop task re-reads. for (int i = 0; i < s_seg_count; ++i) if (s_seg[i].first_seq == first_seq) { s_seg[i].rewrite_open = 1; break; } if (create) uiSegRemoveFile(first_seq); // nothing valid in it yet return uiMsgsWriteFail('a'); } return uiMsgsWriteResult(true); } // Rewrite one segment from the given (live, tombstone-free) records — tmp + // rename, same crash discipline as the old writer but with a one-segment // blast radius. n == 0 removes the segment file outright (everything in it // was deleted). `sync_writer` picks the tmp namespace: the loop-task sync // writer must never share a tmp with a possibly-stalled worker write (two // truncating opens of one path interleave into garbage — the .tmp/.tm2 // lesson from the single-file store). static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer) { #if defined(HAS_TDISPLAY_P4) if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) struct A { uint32_t fs; const UITask::UIMessage* r; int n; bool sw; bool ok; } a{ first_seq, recs, n, sync_writer, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegCompactWrite(a->fs, a->r, a->n, a->sw); }, &a); return a.ok; } #endif WdtHeavyGuard _wg; errno = 0; char fin[48], tmp[48]; uiSegName(first_seq, "", fin, sizeof fin); if (n <= 0) { uiDataRemove(fin); return uiMsgsWriteResult(true); } uiSegName(first_seq, sync_writer ? ".tm2" : ".tmp", tmp, sizeof tmp); File f = uiDataOpen(tmp, "w"); if (!f) { uiDataEnsureDirs(); errno = 0; f = uiDataOpen(tmp, "w"); if (!f) return uiMsgsWriteFail('c'); } UiSegHeader hdr{}; hdr.magic = k_ui_seg_magic; hdr.version = k_ui_seg_version; hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); hdr.first_seq = first_seq; bool ok = (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) == sizeof(hdr)); if (ok) ok = uiSegWriteRecords(f, recs, n); f.close(); if (!ok) { uiDataRemove(tmp); return uiMsgsWriteFail('c'); } errno = 0; if (!uiDataReplaceFile(fin, tmp)) return uiMsgsWriteFail('c'); return uiMsgsWriteResult(true); } // Remove a retired segment (ring grew past retention; its records are gone // from RAM too). static void uiSegRemoveFile(uint32_t first_seq) { #if defined(HAS_TDISPLAY_P4) if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) p4StorageCall([](void* p){ uiSegRemoveFile((uint32_t)(uintptr_t)p); }, (void*)(uintptr_t)first_seq); return; } #endif char name[48]; uiSegName(first_seq, "", name, sizeof name); uiDataRemove(name); } // Open + validate a segment for reading. Returns the on-disk record size via // *rec_size_out (self-describing header) and leaves the File positioned at // the first record. Returns false on a corrupt header — the caller // quarantines THAT segment only. static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out) { char name[48]; uiSegName(first_seq, "", name, sizeof name); f = uiDataOpen(name, FILE_READ); if (!f) return false; UiSegHeader hdr{}; if (f.readBytes(reinterpret_cast<char*>(&hdr), sizeof(hdr)) != (int)sizeof(hdr) || hdr.magic != k_ui_seg_magic || hdr.version == 0 || hdr.version > k_ui_seg_version || hdr.msg_rec_size == 0 || hdr.msg_rec_size > 4096 || hdr.first_seq != first_seq) { f.close(); return false; } *rec_size_out = hdr.msg_rec_size; return true; } // Read ONE record from an open segment into a RAM record. Returns false at a // clean EOF or a ragged (crash-truncated) tail — the caller just stops there; // records already read are valid. Size-agnostic via readHistoryRec (older // shorter records zero-fill their tail, so a missing seq field reads 0 and // the loader backfills it). static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out) { UiSegMsg rec{}; if (!readHistoryRec(f, &rec, sizeof(rec), disk_sz)) return false; memset(out, 0, sizeof(*out)); out->ts = rec.m.ts; out->channel = rec.m.channel != 0; out->outgoing = rec.m.outgoing != 0; out->meta_flags = rec.m.meta_flags; out->path_len = rec.m.path_len; out->snr_q4 = rec.m.snr_q4; out->rssi = rec.m.rssi; strncpy(out->thread, rec.m.thread, UITask::MAX_THREAD_NAME); out->thread[UITask::MAX_THREAD_NAME] = '\0'; strncpy(out->sender, rec.m.sender, UITask::MAX_SENDER_NAME); out->sender[UITask::MAX_SENDER_NAME] = '\0'; strncpy(out->text, rec.m.text, UITask::MAX_MSG_TEXT); out->text[UITask::MAX_MSG_TEXT] = '\0'; out->seq = rec.seq; *seq_out = rec.seq; return true; } // Discover segment files: fills out_first_seqs sorted ascending, returns the // count (or -1 when even the scan location can't be opened — distinct from // "no segments yet"). Handles both directory layouts: a real <root>/msgs dir // (FAT backends) and SPIFFS' flat namespace (scan "/" and prefix-match the // name). When sweep_tmps is set, orphaned .tmp/.tm2 leftovers from a crash // mid-compact are deleted along the way (boot hygiene). static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps) { if (!uiDataFsReady()) return -1; char dirpath[80]; snprintf(dirpath, sizeof dirpath, "%s%s", s_ui_data_root, k_ui_seg_dir); File dir = s_ui_data_fs->open(dirpath); bool flat = false; if (!dir || !dir.isDirectory()) { // SPIFFS (flat namespace): enumerate the root and match the name prefix. if (dir) dir.close(); flat = true; dir = s_ui_data_fs->open(s_ui_data_root[0] ? s_ui_data_root : "/"); if (!dir || !dir.isDirectory()) { if (dir) dir.close(); return -1; } } const size_t root_len = strlen(s_ui_data_root); int n = 0; for (;;) { String path = dir.getNextFileName(); if (path.length() == 0) break; // Normalize to a root-relative name so uiDataRemove/uiDataOpen accept it. const char* rel = path.c_str(); if (root_len && strncmp(rel, s_ui_data_root, root_len) == 0) rel += root_len; const char* base = strrchr(rel, '/'); base = base ? base + 1 : rel; if (flat) { // Flat scan sees every file — keep only "/msgs/seg_*" names. if (strncmp(rel, k_ui_seg_dir, strlen(k_ui_seg_dir)) != 0) continue; } if (strncmp(base, "seg_", 4) != 0) continue; char* end = nullptr; const unsigned long fs_val = strtoul(base + 4, &end, 10); if (!end || end == base + 4) continue; if (strcmp(end, ".bin") == 0) { if (n < max_out) out_first_seqs[n++] = (uint32_t)fs_val; } else if (sweep_tmps && (strcmp(end, ".bin.tmp") == 0 || strcmp(end, ".bin.tm2") == 0)) { uiDataRemove(rel); } } dir.close(); // Insertion sort ascending — n is tiny (<= k_ui_seg_max). for (int i = 1; i < n; ++i) { const uint32_t v = out_first_seqs[i]; int j = i - 1; while (j >= 0 && out_first_seqs[j] > v) { out_first_seqs[j + 1] = out_first_seqs[j]; --j; } out_first_seqs[j + 1] = v; } return n; } // ---- Segmented store: flush scheduling ------------------------------------ // All of this state is LOOP-TASK-owned; the hist_flush worker only runs // uiSegRunArmedJob() and reports through s_hist_flush_ok. // Snapshot buffer for one job (<= one segment of records). Lazy, kept for the // session — two exist (worker vs sync drain) so the writers can never share. static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot) { if (!*slot) { const size_t sz = sizeof(UITask::UIMessage) * (size_t)k_ui_seg_records; *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!*slot) *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_8BIT); } return *slot; } // Live records with seq in [lo, hi], chronological, from the ring. static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out) { int n = 0; for (int i = 0; i < count && n < max_out; ++i) { const int slot = (head - count + i + cap) % cap; const UITask::UIMessage& m = ring[slot]; if (!m.thread[0]) continue; // tombstone if (m.seq < lo || m.seq > hi) continue; out[n++] = m; } return n; } // Build the next job into `buf` + `out`. PURE with respect to the worker // descriptor — arming (copying into the statics + raising req) is the async // caller's step, so the sync drain can use this with its own buffer and a // local job without the worker ever seeing it. Returns 1 = job built, // 0 = nothing pending (all durable), -1 = no buffer. static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, UITask::UIMessage* buf, SegJob* out) { if (!buf) return -1; // 0) REPAIR FIRST. A segment whose file content is untrusted (failed append // left a partial tail, or a resync left it stale/absent) must be fully // rewritten before anything appends to it — otherwise a retry appends the // same batch after the partial tail (duplicate or misaligned records the // loader would ingest as history) or creates a HEADERLESS file. The // rewrite absorbs the unflushed tail too, bounded by the segment size; // whatever doesn't fit stays above the watermark and lands as a normal // append into the NEXT segment afterwards. for (int i = 0; i < s_seg_count; ++i) { if (!s_seg[i].rewrite_open) continue; const uint32_t hi = (i + 1 < s_seg_count) ? (s_seg[i + 1].first_seq - 1) : 0xFFFFFFFFu; const int rn = segGatherRange(ring, cap, count, head, s_seg[i].first_seq, hi, buf, k_ui_seg_records); out->kind = SEGJOB_COMPACT; // a compact IS the repair (tmp + rename, header written) out->first_seq = s_seg[i].first_seq; out->last_seq = rn > 0 ? buf[rn - 1].seq : 0; out->create = false; out->n = rn; // 0 -> unlink (every record in the range is gone) out->repair = true; // commit also clears rewrite_open + advances the watermark return 1; } // 1) Pending appends: live records newer than the durability watermark, // chronological, capped to the room left in the active segment. bool create = true; uint32_t target = 0; int room = k_ui_seg_records; if (s_seg_count > 0 && s_seg[s_seg_count - 1].disk_recs < k_ui_seg_records) { create = false; target = s_seg[s_seg_count - 1].first_seq; room = k_ui_seg_records - (int)s_seg[s_seg_count - 1].disk_recs; } // At the table cap (s_seg_count == k_ui_seg_max) a CREATE-append cannot be // recorded: segCommitJob only tables a create while s_seg_count < the cap, so // the file it writes becomes an untabled orphan AND the durability watermark // never advances -> the same append re-arms every cycle (permanent write-spin), // orphaned seg_*.bin pile up as the ring shifts the target first_seq, and the // card eventually fills ("SD I/O fails after a few hours"). DEFER the append: // skip step 1 entirely (crucially, do NOT run the gather/watermark advance // below on un-written records) and fall through to step 2, which retires the // oldest segment first. At the cap the oldest 256 records have aged out of the // 5000-record ring (24*256 disk slots > ring), so that segment is fully // evicted -> compact_dirty (segNoteEvicted) -> step 2 unlinks it and frees a // slot with NO live-record loss. The deferred records are the NEWEST (above // the watermark, so the ring evicts them last); they wait safely in RAM and // land next cycle once a slot is free. Below the cap this is a no-op. if (!(create && s_seg_count >= k_ui_seg_max)) { int n = 0; uint32_t last = s_seg_flushed_seq; for (int i = 0; i < count; ++i) { const int slot = (head - count + i + cap) % cap; const UITask::UIMessage& m = ring[slot]; if (m.seq <= s_seg_flushed_seq) continue; if (m.thread[0]) { if (n >= room) break; // segment full — the rest rides the next cycle buf[n++] = m; } if (m.seq > last) last = m.seq; // deleted-before-flush records advance the // watermark with no write (never hit disk) } if (n > 0) { out->kind = SEGJOB_APPEND; out->first_seq = create ? buf[0].seq : target; out->last_seq = last; out->create = create; out->n = n; return 1; } if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail } // 2) Oldest compact-dirty segment: rewrite it from the ring's live records. for (int i = 0; i < s_seg_count; ++i) { if (!s_seg[i].compact_dirty) continue; const int cn = segGatherRange(ring, cap, count, head, s_seg[i].first_seq, s_seg[i].last_seq, buf, k_ui_seg_records); out->kind = SEGJOB_COMPACT; out->first_seq = s_seg[i].first_seq; out->last_seq = cn > 0 ? buf[cn - 1].seq : 0; out->create = false; out->n = cn; // 0 = every record gone -> unlink the file return 1; } return 0; } // Apply a successfully-executed job to the table (loop task only). static void segCommitJob(const SegJob& job) { if (job.kind == SEGJOB_APPEND) { if (job.create && s_seg_count < k_ui_seg_max) { UiSegInfo info{}; info.first_seq = job.first_seq; info.bytes = (uint32_t)sizeof(UiSegHeader); s_seg[s_seg_count++] = info; s_seg_total_bytes = s_seg_total_bytes + (uint32_t)sizeof(UiSegHeader); } // Credit the job to ITS OWN segment (looked up by key), never blindly to // the last table entry — the table can have changed between arm and commit. for (int i = 0; i < s_seg_count; ++i) { if (s_seg[i].first_seq != job.first_seq) continue; UiSegInfo& a = s_seg[i]; a.disk_recs = (uint16_t)(a.disk_recs + job.n); a.live_recs = (uint16_t)(a.live_recs + job.n); if (job.last_seq > a.last_seq) a.last_seq = job.last_seq; const uint32_t add = (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); a.bytes += add; s_seg_total_bytes = s_seg_total_bytes + add; if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; break; } } else if (job.kind == SEGJOB_COMPACT) { for (int i = 0; i < s_seg_count; ++i) { if (s_seg[i].first_seq != job.first_seq) continue; s_seg_total_bytes = (s_seg_total_bytes >= s_seg[i].bytes) ? s_seg_total_bytes - s_seg[i].bytes : 0; if (job.n == 0) { for (int k = i; k + 1 < s_seg_count; ++k) s_seg[k] = s_seg[k + 1]; --s_seg_count; } else { s_seg[i].disk_recs = s_seg[i].live_recs = (uint16_t)job.n; s_seg[i].last_seq = job.last_seq; s_seg[i].bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); // A delete that landed while this compact was writing predates // nothing: the snapshot was taken BEFORE it, so the record is still // in the file — keep the segment dirty for a follow-up pass. s_seg[i].compact_dirty = s_segjob_redirty ? 1 : 0; s_seg_total_bytes = s_seg_total_bytes + s_seg[i].bytes; if (job.repair) { // The file now matches the table exactly, so it is trustworthy again // and the records it absorbed from the unflushed tail are durable. s_seg[i].rewrite_open = 0; if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; } } break; } } } static bool segMoreWorkPending(uint32_t newest_seq) { if (newest_seq > s_seg_flushed_seq) return true; for (int i = 0; i < s_seg_count; ++i) if (s_seg[i].compact_dirty || s_seg[i].rewrite_open) return true; return false; } // Executed by the hist_flush worker (or inline by the sync drain's caller). static bool uiSegRunArmedJob() { switch (s_segjob_kind) { case SEGJOB_APPEND: return uiSegAppendRecords(s_segjob_first_seq, s_segjob_create, s_segjob_buf, s_segjob_n); case SEGJOB_COMPACT: return uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, s_segjob_n, false); default: return true; } } // A record with this seq was tombstoned (deleted) — mark its segment for // compaction. Unflushed records (seq above the watermark) need nothing: the // append builder skips tombstones, so they simply never reach disk. static void segMarkSeqDirty(uint32_t seq) { if (seq == 0 || seq > s_seg_flushed_seq) return; for (int i = 0; i < s_seg_count; ++i) { if (seq >= s_seg[i].first_seq && seq <= s_seg[i].last_seq) { s_seg[i].compact_dirty = 1; // Racing an in-flight compact of this very segment: its snapshot was // taken before this delete, so the commit must not clear the flag. if (s_segjob_kind == SEGJOB_COMPACT && s_segjob_first_seq == s_seg[i].first_seq && (s_hist_flush_busy || s_hist_flush_req)) s_segjob_redirty = true; return; } } } // The ring overwrote its oldest record (capacity eviction). Deliberately does // NOT trigger a rewrite per eviction (that would be write-amplification per // message all over again): the segment file only gets unlinked once ALL its // records have aged out; a partially-evicted boundary segment keeps its stale // tail on disk until the next boot's loader trims it (bounded: one segment). static void segNoteEvicted(uint32_t seq) { if (seq == 0) return; for (int i = 0; i < s_seg_count; ++i) { if (seq < s_seg[i].first_seq || seq > s_seg[i].last_seq) continue; if (s_seg[i].live_recs > 0) --s_seg[i].live_recs; if (s_seg[i].live_recs == 0) s_seg[i].compact_dirty = 1; // job -> unlink (n gathers to 0) return; } } // Card remounted (wedge recovery / reinsert / FM insert / post-format): the // on-disk segment set may be stale, foreign, or gone. Rebuild the table from // the ring's live records with every chunk marked UNTRUSTED, so the normal job // flow rewrites each one (header + records, tmp + rename) one bounded segment // at a time. // // Deliberately does NOT delete anything up front: on a same-card recovery the // existing files still hold the very history we are re-landing, and unlinking // them first turned recovery into a multi-minute window where a power cut lost // data that had been perfectly durable. Same-key files are simply replaced by // each repair's rename; anything left over (different chunk boundaries, or a // genuinely foreign card) is swept once the re-land is complete — and the // loader's seq-monotonic filter makes a leftover harmless even if we crash // before that sweep. static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq) { s_seg_count = 0; s_seg_total_bytes = 0; UiSegInfo cur{}; int in_chunk = 0; for (int i = 0; i < count; ++i) { const int slot = (head - count + i + cap) % cap; const UITask::UIMessage& m = ring[slot]; if (!m.thread[0]) continue; if (in_chunk == 0) { cur = UiSegInfo{}; cur.first_seq = m.seq; cur.rewrite_open = 1; // file content untrusted -> repair rewrites it } cur.last_seq = m.seq; ++in_chunk; if (in_chunk == k_ui_seg_records) { cur.live_recs = (uint16_t)in_chunk; if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = cur; in_chunk = 0; } } if (in_chunk > 0 && s_seg_count < k_ui_seg_max) { cur.live_recs = (uint16_t)in_chunk; s_seg[s_seg_count++] = cur; } s_seg_flushed_seq = newest_seq; s_seg_stale_purge = true; // sweep leftovers once every chunk has landed } // Unlink segment files that are not part of the table. Runs only when no work // is pending (so every table entry has a real, current file) — see the purge // rationale on segRetableFromRing. static void segPurgeStaleFiles() { uint32_t seqs[k_ui_seg_max]; const int nseg = uiSegScan(seqs, k_ui_seg_max, true); if (nseg < 0) return; // scan location unreadable — try again later for (int i = 0; i < nseg; ++i) { bool keep = false; for (int k = 0; k < s_seg_count; ++k) if (s_seg[k].first_seq == seqs[i]) { keep = true; break; } if (!keep) uiSegRemoveFile(seqs[i]); } s_seg_stale_purge = false; } bool UITask::saveMsgsToStorage() { #if defined(ESP32) // Synchronous drain of ALL outstanding message-store work on the LOOP task — // shutdown/reboot, the delete flows (persistHistoryNow), the sync-fallback // and the no-worker fallback all land here. Loop-task writes are the // empirically reliable path on the shared SPI bus. Returns true only when // everything is durable. if (!uiDataFsReady()) return false; // A worker that outlived uiHistWaitWorkerIdle's 9 s cap still holds a segment // file OPEN. Writing here would remove/rename a path under that open handle: // with FF_FS_LOCK=0 the stalled handle keeps writing into clusters the FS has // freed and may re-allocate, cross-linking chains and corrupting the volume. // Report failure instead (the caller surfaces "Chat history save FAILED"); // the data stays in the RAM ring and the segment is already flagged for a // repair rewrite by uiHistWaitWorkerIdle. if (s_hist_flush_busy) return false; if (!s_seg_store_ready) { // A failed boot migration left the old-format file authoritative — // converting it IS the flush (verify-then-delete inside). return migrateRingToSegments(); } if (s_seg_resync) { segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, _ui_seq_next ? _ui_seq_next - 1 : 0); s_seg_resync = false; } // Drain job by job, executing each here. Appends are NEVER FILE_APPEND on // this path: a worker stalled past uiHistWaitWorkerIdle's 9 s cap could // still hold an append handle on the same file, and two appenders // interleave records — instead the whole (bounded, <= ~60 KB) active // segment is rewritten from the ring via the sync tmp namespace, where // rename-over is last-writer-wins with each candidate internally // consistent. UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); // NEVER the worker's buffer: // a worker stalled past the 9 s idle-wait cap may still be reading its own. for (int guard = 0; guard < k_ui_seg_max + 4; ++guard) { SegJob job{}; const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, buf, &job); if (armed == 0) { // segBuildJob returns 0 for "all durable" AND for a create-append it had to // DEFER at the segment cap with no slot freeable this pass (rare // fragmentation edge — the common at-cap path retires the oldest evicted // segment inside this loop and resolves). Only the former is saved; report // the latter as a failure so the caller surfaces it and retries, instead of // dropping the newest records on a false success. Normal case: // segMoreWorkPending is false here, so this is a no-op. if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) return false; if (s_seg_stale_purge) segPurgeStaleFiles(); // everything landed — drop leftovers return true; } if (armed < 0) return false; // no snapshot buffer bool ok; if (job.kind == SEGJOB_APPEND) { if (job.create && s_seg_count < k_ui_seg_max) { UiSegInfo info{}; info.first_seq = job.first_seq; s_seg[s_seg_count++] = info; } const int n2 = segGatherRange(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, job.first_seq, job.last_seq, buf, k_ui_seg_records); ok = uiSegCompactWrite(job.first_seq, buf, n2, true); if (ok) { const uint32_t newest = job.last_seq; job.kind = SEGJOB_COMPACT; // commit with absolute-value semantics job.n = n2; segCommitJob(job); if (newest > s_seg_flushed_seq) s_seg_flushed_seq = newest; } } else { ok = uiSegCompactWrite(job.first_seq, buf, job.n, true); if (ok) segCommitJob(job); } if (!ok) return false; } return !segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0); #else return false; #endif } // ============================================================ // Thread management // ============================================================ #if CAP_CONSOLE // Walk the thread table for the console. Read-only: seeing a thread listed with // its unread count must not change that count. int UITask::consoleThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel) { if (!_ui_threads || idx < 0) return 0; int seen = 0; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!_ui_threads[i].used) continue; if (seen++ != idx) continue; snprintf(name, cap, "%s", _ui_threads[i].name); if (unread) *unread = (int)_ui_threads[i].unread; if (is_channel) *is_channel = _ui_threads[i].channel; return 1; } return 0; } // Read back a thread's stored messages, NEWEST first. Read-only, like // consoleThreadAt: showing history is not the same act as clearing unread, so // the caller decides whether to also mark it read. int UITask::consoleHistoryAt(const char* thread, int back, char* sender, size_t sc, char* text, size_t tc, uint32_t* ts, bool* outgoing) { if (!_ui_msgs || !thread || !*thread || back < 0) return 0; int seen = 0; for (int n = 0; n < _ui_msg_count; ++n) { // walk backwards from the newest record in the ring int i = (_ui_msg_head - 1 - n + _ui_msg_cap * 2) % _ui_msg_cap; const UIMessage& m = _ui_msgs[i]; if (strncmp(m.thread, thread, MAX_THREAD_NAME) != 0) continue; if (seen++ != back) continue; snprintf(sender, sc, "%s", m.sender); snprintf(text, tc, "%s", m.text); if (ts) *ts = m.ts; if (outgoing) *outgoing = m.outgoing; return 1; } return 0; } // Clearing unread is an explicit act, which is the whole point: the monitor // showing a message must not count as having read it. bool UITask::consoleMarkThreadRead(const char* name) { if (!_ui_threads || !name || !*name) return false; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!_ui_threads[i].used) continue; if (strncmp(_ui_threads[i].name, name, MAX_THREAD_NAME) != 0) continue; markThreadRead(i); return true; } return false; } #endif int UITask::findOrCreateThread(const char* name, bool channel) { if (!name || !name[0]) return -1; // _ui_threads is allocated further down begin(), which console mode returns // before, so it is null there. An incoming message reached here and // dereferenced it: the device panicked, and since it rebooted straight back // into console mode it did so every time a message arrived. Guard rather than // allocate: in console mode nothing reads the table. if (!_ui_threads) return -1; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (_ui_threads[i].used && _ui_threads[i].channel == channel && strncmp(_ui_threads[i].name, name, MAX_THREAD_NAME) == 0) return i; } for (int i = 0; i < MAX_UI_THREADS; ++i) { if (_ui_threads[i].used) continue; _ui_threads[i].used = true; _ui_threads[i].channel = channel; _ui_threads[i].mesh_contact_idx = -1; memset(_ui_threads[i].mesh_contact_pub, 0, sizeof(_ui_threads[i].mesh_contact_pub)); memset(_ui_threads[i].mesh_contact_key6, 0, sizeof(_ui_threads[i].mesh_contact_key6)); _ui_threads[i].mesh_channel_slot = -1; _ui_threads[i].unread = 0; _ui_threads[i].has_mention = false; _ui_threads[i].last_ts = millis(); strncpy(_ui_threads[i].name, name, MAX_THREAD_NAME); _ui_threads[i].name[MAX_THREAD_NAME] = '\0'; return i; } return -1; } bool UITask::removeThread(int idx) { if (idx < 0 || idx >= MAX_UI_THREADS) return false; if (!_ui_threads[idx].used) return false; _thread_hist_dirty = true; // slot is being freed + its ring records dropped // Match the thread by stored name (channel/DM) before wiping the slot so // we can drop any ring messages that belonged to it. Without this the // entries would still occupy the bounded _ui_msgs ring and confuse a // future thread of the same name. const bool was_channel = _ui_threads[idx].channel; char name_copy[MAX_THREAD_NAME + 1]; strncpy(name_copy, _ui_threads[idx].name, MAX_THREAD_NAME); name_copy[MAX_THREAD_NAME] = '\0'; // Walk the ring and clear messages keyed to this thread name + kind. bool purged_any = false; for (int i = 0; i < _ui_msg_cap; ++i) { if (_ui_msgs[i].channel != (was_channel ? 1u : 0u)) continue; if (strncmp(_ui_msgs[i].thread, name_copy, MAX_THREAD_NAME) != 0) continue; _ui_msgs[i].thread[0] = '\0'; _ui_msgs[i].text[0] = '\0'; _ui_msgs[i].sender[0] = '\0'; segMarkSeqDirty(_ui_msgs[i].seq); purged_any = true; } // Persist the purge — without this the deleted thread's messages survive in // the messages FILE until some unrelated send re-saves it, and a same-name // thread created later (or a reboot in that window) resurrects the old chat. if (purged_any) markMsgsDirty(); _ui_threads[idx] = UIThread{}; _ui_threads[idx].used = false; _ui_threads[idx].mesh_contact_idx = -1; _ui_threads[idx].mesh_channel_slot = -1; if (_active_thread_idx == idx) _active_thread_idx = -1; // Flush thread metadata immediately — markThreadsDirty(200) would be lazy, // and flushHistoryIfDue further defers writes while the user is on the inbox, // so a reboot in that window would resurrect the deleted thread. if (!saveThreadsToStorage()) { _threads_dirty = true; _next_threads_flush_ms = millis() + 200; } else { _threads_dirty = false; } return true; } bool UITask::looksLikeKnownChannel(const char* name) const { if (!name || !name[0]) return false; return name[0] == '#' || strncmp(name, "ch:", 3) == 0 || strncmp(name, "chan:", 5) == 0; } void UITask::refreshThreadsFromMesh() { // Reclaim DM thread slots that have no message history. These were created // by an earlier auto-link pass for every known contact, but they bloat the // 48-slot thread table and starve newly-added channels (which are far // fewer). DMs with actual history stay; new DM threads are created on // demand when openMeshContactDm() runs or a message arrives. // // EXCEPTION: never wipe the currently-active thread. The user might have // it open and be typing into it — wiping mid-compose means the next Send // tap sees `_ui_threads[_active_thread_idx].name` empty, appendMessage's // findOrCreateThread("") returns -1, and both the bubble and the chat // list silently drop the message. This was the "send-from-map-marker // doesn't track messages" bug. for (int t = 0; t < MAX_UI_THREADS; ++t) { if (t == _active_thread_idx) continue; if (_ui_threads[t].used && !_ui_threads[t].channel && !threadHasMessageHistory(t)) { _ui_threads[t].used = false; _ui_threads[t].name[0] = '\0'; _ui_threads[t].mesh_contact_idx = -1; memset(_ui_threads[t].mesh_contact_pub, 0, sizeof(_ui_threads[t].mesh_contact_pub)); memset(_ui_threads[t].mesh_contact_key6, 0, sizeof(_ui_threads[t].mesh_contact_key6)); } } for (int t = 0; t < MAX_UI_THREADS; ++t) { if (_ui_threads[t].used && !_ui_threads[t].channel && (hasContactPub(_ui_threads[t].mesh_contact_pub) || hasContactKey6(_ui_threads[t].mesh_contact_key6))) { _ui_threads[t].mesh_contact_idx = -1; } } ContactInfo c; for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (!the_mesh.getContactByIdx(static_cast<uint32_t>(i), c) || !c.name[0]) continue; int t = -1; for (int k = 0; k < MAX_UI_THREADS; ++k) { if (!_ui_threads[k].used || _ui_threads[k].channel) continue; if (hasContactPub(_ui_threads[k].mesh_contact_pub) && memcmp(_ui_threads[k].mesh_contact_pub, c.id.pub_key, PUB_KEY_SIZE) == 0) { t = k; break; } if (!hasContactPub(_ui_threads[k].mesh_contact_pub) && hasContactKey6(_ui_threads[k].mesh_contact_key6) && memcmp(_ui_threads[k].mesh_contact_key6, c.id.pub_key, 6) == 0) { t = k; break; } } if (t < 0) { const int by_name = findThreadByName(c.name, false); if (by_name >= 0) { // If a same-name thread is already pinned to a different key, do not // overwrite it here. This avoids DM thread/key drift on duplicate names. if (hasContactKey6(_ui_threads[by_name].mesh_contact_key6) && memcmp(_ui_threads[by_name].mesh_contact_key6, c.id.pub_key, 6) != 0) { continue; } t = by_name; } // No matching thread for this contact — don't pre-allocate one. A // thread gets created lazily by openMeshContactDm() / appendMessage() // when the user actually starts a conversation. Pre-allocating burns // slots that channels can no longer claim. } if (t >= 0) { _ui_threads[t].mesh_contact_idx = static_cast<int16_t>(i); memcpy(_ui_threads[t].mesh_contact_pub, c.id.pub_key, sizeof(_ui_threads[t].mesh_contact_pub)); memcpy(_ui_threads[t].mesh_contact_key6, c.id.pub_key, sizeof(_ui_threads[t].mesh_contact_key6)); // Follow a peer's rename. The thread was matched by KEY above, but inbound // messages are routed by findThreadByName(sender) — so leaving the old name // here spawned a SECOND thread for the same peer the next time they wrote. if (c.name[0] && strncmp(_ui_threads[t].name, c.name, MAX_THREAD_NAME) != 0) { strlcpy(_ui_threads[t].name, c.name, sizeof(_ui_threads[t].name)); _threads_dirty = true; } } } #ifdef MAX_GROUP_CHANNELS ChannelDetails cd; for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { if (!the_mesh.getChannel(i, cd)) continue; if (!cd.name[0]) continue; int t = findOrCreateThread(cd.name, true); if (t >= 0) { _ui_threads[t].mesh_channel_slot = static_cast<int16_t>(i); } } #endif } int UITask::findThreadByName(const char* name, bool channel) const { if (!name || !name[0]) return -1; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (_ui_threads[i].used && _ui_threads[i].channel == channel && strncmp(_ui_threads[i].name, name, MAX_THREAD_NAME) == 0) return i; } return -1; } void UITask::syncThreadMeshSlots(const char* thread_name, bool channel) { int t = findThreadByName(thread_name, channel); if (t < 0) return; if (channel) { _ui_threads[t].mesh_channel_slot = -1; #ifdef MAX_GROUP_CHANNELS ChannelDetails cd; for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { if (the_mesh.getChannel(i, cd) && cd.name[0] && strncmp(cd.name, thread_name, MAX_THREAD_NAME) == 0) { _ui_threads[t].mesh_channel_slot = static_cast<int16_t>(i); break; } } #endif } else { _ui_threads[t].mesh_contact_idx = -1; const bool thread_has_pub = hasContactPub(_ui_threads[t].mesh_contact_pub); const bool thread_has_key = hasContactKey6(_ui_threads[t].mesh_contact_key6); ContactInfo c2; if (thread_has_pub) { for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (!the_mesh.getContactByIdx(static_cast<uint32_t>(i), c2)) continue; if (memcmp(c2.id.pub_key, _ui_threads[t].mesh_contact_pub, PUB_KEY_SIZE) == 0) { _ui_threads[t].mesh_contact_idx = static_cast<int16_t>(i); memcpy(_ui_threads[t].mesh_contact_key6, c2.id.pub_key, sizeof(_ui_threads[t].mesh_contact_key6)); return; } } return; } if (thread_has_key) { for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (!the_mesh.getContactByIdx(static_cast<uint32_t>(i), c2)) continue; if (memcmp(c2.id.pub_key, _ui_threads[t].mesh_contact_key6, 6) == 0) { _ui_threads[t].mesh_contact_idx = static_cast<int16_t>(i); memcpy(_ui_threads[t].mesh_contact_pub, c2.id.pub_key, sizeof(_ui_threads[t].mesh_contact_pub)); return; } } // Keep keyed binding pinned even if currently unresolved in contact list. return; } memset(_ui_threads[t].mesh_contact_pub, 0, sizeof(_ui_threads[t].mesh_contact_pub)); for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (the_mesh.getContactByIdx(static_cast<uint32_t>(i), c2) && strncmp(c2.name, thread_name, MAX_THREAD_NAME) == 0) { _ui_threads[t].mesh_contact_idx = static_cast<int16_t>(i); memcpy(_ui_threads[t].mesh_contact_pub, c2.id.pub_key, sizeof(_ui_threads[t].mesh_contact_pub)); memcpy(_ui_threads[t].mesh_contact_key6, c2.id.pub_key, sizeof(_ui_threads[t].mesh_contact_key6)); break; } } } } bool UITask::lookupActiveContact(ContactInfo& out) const { if (_active_thread_idx < 0 || _active_thread_idx >= MAX_UI_THREADS) return false; if (_active_dm_contact_set) { for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (the_mesh.getContactByIdx(static_cast<uint32_t>(i), out) && memcmp(out.id.pub_key, _active_dm_contact_pub, PUB_KEY_SIZE) == 0) return true; } } const UIThread& th = _ui_threads[_active_thread_idx]; const bool has_pub = hasContactPub(th.mesh_contact_pub); const bool has_key6 = hasContactKey6(th.mesh_contact_key6); if (th.mesh_contact_idx >= 0 && the_mesh.getContactByIdx(static_cast<uint32_t>(th.mesh_contact_idx), out)) { if ((has_pub && memcmp(out.id.pub_key, th.mesh_contact_pub, PUB_KEY_SIZE) == 0) || (!has_pub && (!has_key6 || memcmp(out.id.pub_key, th.mesh_contact_key6, 6) == 0))) return true; } if (has_pub) { for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (the_mesh.getContactByIdx(static_cast<uint32_t>(i), out) && memcmp(out.id.pub_key, th.mesh_contact_pub, PUB_KEY_SIZE) == 0) return true; } } if (has_key6) { for (int i = 0; i < the_mesh.getNumContacts(); ++i) { if (the_mesh.getContactByIdx(static_cast<uint32_t>(i), out) && memcmp(out.id.pub_key, th.mesh_contact_key6, 6) == 0) return true; } // Fallback for stale keyed threads after contact list churn. // Keep this enabled so messaging doesn't hard-fail while diagnostics are active. } // Do not fall back to name-only matching for DM sends. // Name collisions (or stale history-only threads) can encrypt to the wrong peer key, // which appears as undecodable gibberish on the receiver. return false; } int UITask::appendMessage(const char* thread, const char* sender, const char* text, bool channel, bool outgoing, bool mark_unread, uint32_t ack_hash, uint8_t deliv_state, uint8_t meta_flags, uint8_t path_len, int8_t snr_q4, int8_t rssi, const uint8_t* in_path, uint8_t in_path_n, uint32_t sent_fp, uint16_t in_scope) { int t_idx = findOrCreateThread(thread, channel); if (t_idx < 0) return -1; if (!_ui_msgs) return -1; // console mode: the ring is never allocated UIMessage& m = _ui_msgs[_ui_msg_head]; // Full ring: this append overwrites the oldest record — tell the segment // table so a fully-aged-out segment file gets unlinked. if (_ui_msg_count == _ui_msg_cap) segNoteEvicted(m.seq); // Bubble timestamps must match the status-bar clock, which reads the NTP-synced ESP32 // system clock (time(nullptr)) — NOT the mesh RTC (getRTCClock()). On boards whose // hardware RTC chip reads BCD/century garbage, getCurrentTime() returned wrong epochs // that rendered as 1910-09-03 / 1994 (GH #100/#152). Use the system clock; the ~1.7e9 // epoch still outranks any uptime-seconds value for last_ts/sort, and we fall back to // uptime when the clock is unset (the Info popup shows "—" for non-epoch values). { time_t sys = time(nullptr); m.ts = (sys > 1700000000) ? (uint32_t)sys : (uint32_t)(millis() / 1000); } m.seq = _ui_seq_next++; // monotonic record id — the segment store keys on it m.channel = channel; m.outgoing = outgoing; m.ack_hash = ack_hash; m.deliv_state = deliv_state; m.meta_flags = meta_flags; m.path_len = path_len; m.snr_q4 = snr_q4; m.rssi = rssi; m.sent_fp = sent_fp; m.in_scope = in_scope; m.in_path_n = (in_path && in_path_n) ? (in_path_n > MAX_UI_PATH ? (uint8_t)MAX_UI_PATH : in_path_n) : 0; if (m.in_path_n) memcpy(m.in_path, in_path, m.in_path_n); strncpy(m.thread, thread ? thread : (channel ? "#general" : "Unknown"), MAX_THREAD_NAME); m.thread[MAX_THREAD_NAME] = '\0'; strncpy(m.sender, sender ? sender : (_node_prefs ? _node_prefs->node_name : "me"), MAX_SENDER_NAME); m.sender[MAX_SENDER_NAME] = '\0'; strncpy(m.text, text ? text : "", MAX_MSG_TEXT); m.text[MAX_MSG_TEXT] = '\0'; // An append is the ONLY mutation that needs no scan: this thread now demonstrably has // history. A full ring also EVICTED the oldest record above, which may have been some other // thread's last message, so the cached flags have to be rebuilt before they are trusted again. if (_ui_msg_count == _ui_msg_cap) _thread_hist_dirty = true; if (!_thread_hist_dirty && _thread_msgs[t_idx] < 0xFFFF) ++_thread_msgs[t_idx]; if (_ui_msg_count < _ui_msg_cap) ++_ui_msg_count; _ui_msg_head = (_ui_msg_head + 1) % _ui_msg_cap; _ui_threads[t_idx].last_ts = m.ts; enforceHistoryCap(t_idx); // keep this chat within the per-chat cap (Settings -> Chats) if (mark_unread) _ui_threads[t_idx].unread++; if (mark_unread && channel && !outgoing && textMentionsMe(text)) { _ui_threads[t_idx].has_mention = true; // blue @ on the inbox row } ++_msgcount; markMsgsDirty(); #if !defined(HAS_TANMATSU) s_web_rx_nudge = true; // any new/changed message -> nudge the Contacts/Chats web client to refetch #endif return t_idx; } void UITask::onMessageAcked(uint32_t ack_hash) { if (ack_hash == 0) return; bool any = false; for (int i = 0; i < _ui_msg_cap; ++i) { UIMessage& m = _ui_msgs[i]; if (!m.outgoing || m.channel) continue; if (m.ack_hash == 0) continue; if (m.ack_hash == ack_hash && m.deliv_state != DELIV_DELIVERED) { m.deliv_state = DELIV_DELIVERED; any = true; } } #if defined(HAS_TOUCH_UI) if (any) g_lv.dirty_timeline = true; #else (void)any; #endif #if !defined(HAS_TANMATSU) if (any) s_web_rx_nudge = true; // delivery flipped to ✓✓ -> refresh the web chat #endif } void UITask::onTracePingResult(uint32_t tag, int8_t their_snr, int8_t our_snr, uint8_t extra_hops, const int8_t* extra_snrs) { (void)tag; // SNR values come from the wire as int8_t * 4. Convert to floating-point // dB just for display so a user reading "-3.25 dB" understands the scale // (negative SNR is below the noise floor — typical for chirp-spread LoRa). const float their_dB = ((float)their_snr) / 4.0f; const float our_dB = ((float)our_snr) / 4.0f; // Route trace (from the message Info popup): render the FULL hop list in a // dismissable popup, one line per hop's RX SNR, instead of the compact toast. if (s_trace_route_pending) { s_trace_route_pending = false; char body[256]; int p = 0; p += snprintf(body + p, sizeof(body) - p, "Per-hop RX SNR\n"); p += snprintf(body + p, sizeof(body) - p, "Hop 1 %+.1f dB", (double)their_dB); for (int i = 0; i < extra_hops && extra_snrs && p < (int)sizeof(body); ++i) { const float dB = ((float)extra_snrs[i]) / 4.0f; p += snprintf(body + p, sizeof(body) - p, "\nHop %d %+.1f dB", i + 2, (double)dB); } snprintf(body + p, sizeof(body) - p, "\nReturn %+.1f dB (us)", (double)our_dB); openTraceResultPopup("Trace route", body); return; } char msg[96]; if (extra_hops == 0 || !extra_snrs) { snprintf(msg, sizeof(msg), "Trace: them %.1fdB \xe2\x86\x90 us %.1fdB", (double)their_dB, (double)our_dB); } else { // Show extra hops compactly. Cap to 2 to fit a single toast line. char hops[32] = {0}; int p = 0; int n = (extra_hops < 2) ? extra_hops : 2; for (int i = 0; i < n; ++i) { const float dB = ((float)extra_snrs[i]) / 4.0f; p += snprintf(hops + p, sizeof(hops) - (size_t)p, " %+.1f", (double)dB); } snprintf(msg, sizeof(msg), "Trace: them %.1f us %.1f hops%s", (double)their_dB, (double)our_dB, hops); } showAlert(msg, 5000); } void UITask::sortThreadsByRecent(bool channel_mode, int out_indexes[], int& out_count) const { out_count = 0; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (_ui_threads[i].used && _ui_threads[i].channel == channel_mode) out_indexes[out_count++] = i; } for (int i = 0; i < out_count; ++i) { for (int j = i + 1; j < out_count; ++j) { if (_ui_threads[out_indexes[j]].last_ts > _ui_threads[out_indexes[i]].last_ts) { int tmp = out_indexes[i]; out_indexes[i] = out_indexes[j]; out_indexes[j] = tmp; } } } } int UITask::getThreadMessageIndexes(int thread_idx, int out_indexes[], int max_out, bool newest_first) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return 0; int n = 0; for (int i = 0; i < _ui_msg_count && n < max_out; ++i) { int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; const UIMessage& m = _ui_msgs[idx]; if (strncmp(m.thread, _ui_threads[thread_idx].name, MAX_THREAD_NAME) == 0 && m.channel == _ui_threads[thread_idx].channel) out_indexes[n++] = idx; } if (!newest_first) { for (int i = 0; i < n / 2; ++i) { int tmp = out_indexes[i]; out_indexes[i] = out_indexes[n - 1 - i]; out_indexes[n - 1 - i] = tmp; } } return n; } void UITask::setActiveThread(int idx, bool channel_mode) { if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used) { _active_thread_idx = -1; _active_dm_contact_set = false; memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); return; } _active_thread_idx = idx; _active_thread_is_channel = channel_mode; const bool was_unread = (_ui_threads[idx].unread != 0) || _ui_threads[idx].has_mention; // Capture the unread count for the Discord-style "new messages" divider + // scroll-to-divider in refreshChatDetail, before we clear it here. Both chat // open paths (thread list + DM-from-contacts) funnel through here. s_unread_at_open = _ui_threads[idx].unread; s_chat_just_opened = true; _ui_threads[idx].unread = 0; _ui_threads[idx].has_mention = false; if (was_unread) markThreadsDirty(200); // persist the read state (survives a manual reboot) if (channel_mode) { _active_dm_contact_set = false; memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); } else { const UIThread& th = _ui_threads[idx]; if (hasContactPub(th.mesh_contact_pub)) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, th.mesh_contact_pub, sizeof(_active_dm_contact_pub)); } else if (th.mesh_contact_idx >= 0) { ContactInfo c{}; if (the_mesh.getContactByIdx(static_cast<uint32_t>(th.mesh_contact_idx), c)) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, c.id.pub_key, sizeof(_active_dm_contact_pub)); } else { _active_dm_contact_set = false; memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); } } else { _active_dm_contact_set = false; memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); } } // Do NOT force a history save here. Opening a chat only clears the unread // count + sets the active thread — it changes no message content, so a // full SPIFFS write on every chat-open is pure overhead and was the // "freeze when opening a chat" hitch. The unread-clear persists on the // next message save or on reboot; message content already persists on // send/receive (appendMessage -> markMsgsDirty). } void UITask::resetComposer() { _compose_buf[0] = '\0'; _composer_char_idx = 0; _composer_action_idx = 0; } void UITask::appendComposerChar(char c) { size_t n = strlen(_compose_buf); if (n + 1 >= sizeof(_compose_buf)) return; _compose_buf[n] = c; _compose_buf[n + 1] = '\0'; } void UITask::appendComposerText(const char* text) { if (!text) return; size_t n = strlen(_compose_buf); size_t tlen = strlen(text); if (n + tlen >= sizeof(_compose_buf)) return; memcpy(_compose_buf + n, text, tlen + 1); } void UITask::backspaceComposerChar() { size_t n = strlen(_compose_buf); if (n == 0) return; size_t i = n; while (i > 0 && ((unsigned char)_compose_buf[i - 1] & 0xC0) == 0x80) --i; if (i > 0) --i; _compose_buf[i] = '\0'; } bool UITask::sendComposerToActiveThread(const char* override_text) { static uint32_t s_last_ui_tx_ts = 0; static uint8_t s_ui_tx_attempt = 4; if (_active_thread_idx < 0) return false; // Resend (message action menu) passes the original message text; a normal send uses // the composer draft. On a resend we must NOT clear the composer (a half-typed draft). const char* text = (override_text && override_text[0]) ? override_text : _compose_buf; if (!text[0]) return false; const char* sender = (_node_prefs && _node_prefs->node_name[0]) ? _node_prefs->node_name : "me"; size_t tlen = strlen(text); if (tlen > MAX_TEXT_LEN) tlen = MAX_TEXT_LEN; char truncated[MAX_TEXT_LEN + 1]; memcpy(truncated, text, tlen); truncated[tlen] = '\0'; if (_active_thread_is_channel) { const char* thread_name = _ui_threads[_active_thread_idx].name; int16_t slot = _ui_threads[_active_thread_idx].mesh_channel_slot; ChannelDetails cd; // CRITICAL: the cached slot must still point at THIS channel — verify by NAME, not just that the // slot is valid. After a channel add/remove the indices shift, so a stale mesh_channel_slot can // resolve to a DIFFERENT but valid channel; the message would then appear here under the right // thread yet transmit on the WRONG channel's key. Re-bind by name if it doesn't match. // (User report: a message typed in #wadamesh went out on #test.) bool ok = (slot >= 0 && the_mesh.getChannel(slot, cd) && cd.name[0] && strncmp(cd.name, thread_name, MAX_THREAD_NAME) == 0); if (!ok) { syncThreadMeshSlots(thread_name, true); // re-resolve the slot from the channel name slot = _ui_threads[_active_thread_idx].mesh_channel_slot; ok = (slot >= 0 && the_mesh.getChannel(slot, cd) && cd.name[0] && strncmp(cd.name, thread_name, MAX_THREAD_NAME) == 0); } if (!ok) { showAlert(TR("Channel not found"), 1500); return false; } uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique(); if (ts <= s_last_ui_tx_ts) ts = s_last_ui_tx_ts + 1; s_last_ui_tx_ts = ts; { char tx_line[96]; snprintf(tx_line, sizeof(tx_line), "TX CH ts=%lu len=%u", static_cast<unsigned long>(ts), static_cast<unsigned>(strlen(truncated))); pushDiagLine(tx_line); } // Per-channel region-scope override: if this channel has one set, tag the // flood with it instead of the default scope (popped right after the send). char chan_rgn[TOUCH_REGION_SCOPE_MAXLEN] = {0}; touchPrefsGetChannelScope(slot, chan_rgn, sizeof(chan_rgn)); const bool scope_pushed = the_mesh.pushChannelScope(chan_rgn); const bool grp_sent = the_mesh.sendGroupMessage(ts, cd.channel, sender, truncated, static_cast<int>(strlen(truncated))); if (scope_pushed) the_mesh.popChannelScope(); if (!grp_sent) { showAlert(TR("Send failed"), 1200); return false; } appendMessage(_ui_threads[_active_thread_idx].name, sender, truncated, true, true, false, 0, DELIV_NONE, 0, 0, 0, 0, nullptr, 0, the_mesh.uiLastSentFp()); if (!override_text) resetComposer(); // a resend must not wipe a half-typed draft showAlert(TR("Sent"), 900); return true; } UIThread& th = _ui_threads[_active_thread_idx]; syncThreadMeshSlots(th.name, false); ContactInfo recipient; uint8_t target_pub[PUB_KEY_SIZE]; bool have_target = false; if (_active_dm_contact_set) { memcpy(target_pub, _active_dm_contact_pub, sizeof(target_pub)); have_target = true; } else if (hasContactPub(th.mesh_contact_pub)) { memcpy(target_pub, th.mesh_contact_pub, sizeof(target_pub)); have_target = true; } else { // Fallback: if the thread has no pub key (e.g., created from an incoming message // before the contact was indexed, or wiped during a save/load cycle), find a contact // by matching thread name. This avoids the "No DM key" dead-end after a reboot. for (int i = 0; i < the_mesh.getNumContacts(); ++i) { ContactInfo c{}; if (the_mesh.getContactByIdx(static_cast<uint32_t>(i), c) && strncmp(c.name, th.name, MAX_THREAD_NAME) == 0) { memcpy(target_pub, c.id.pub_key, sizeof(target_pub)); // Latch this discovery onto the thread so subsequent sends don't re-scan. memcpy(_ui_threads[_active_thread_idx].mesh_contact_pub, c.id.pub_key, sizeof(_ui_threads[_active_thread_idx].mesh_contact_pub)); _ui_threads[_active_thread_idx].mesh_contact_idx = static_cast<int16_t>(i); _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, c.id.pub_key, sizeof(_active_dm_contact_pub)); have_target = true; break; } } } if (!have_target) { pushDiagLine("TX DM blocked: no locked pubkey"); showAlert(TR("No DM key"), 1500); return false; } ContactInfo* by_pub = the_mesh.lookupContactByPubKey(target_pub, PUB_KEY_SIZE); if (!by_pub) { pushDiagLine("TX DM blocked: locked key missing"); showAlert(TR("Contact missing"), 1500); return false; } recipient = *by_pub; ContactInfo tx_recipient = recipient; // Deterministic touch send behavior: avoid stale direct-path state by forcing // per-message flood routing for DM sends. tx_recipient.out_path_len = OUT_PATH_UNKNOWN; { char tx_line[96]; snprintf(tx_line, sizeof(tx_line), "TX DM to %02X%02X%02X%02X%02X%02X p=%d->flood len=%u", tx_recipient.id.pub_key[0], tx_recipient.id.pub_key[1], tx_recipient.id.pub_key[2], tx_recipient.id.pub_key[3], tx_recipient.id.pub_key[4], tx_recipient.id.pub_key[5], static_cast<int>(recipient.out_path_len), static_cast<unsigned>(strlen(truncated))); pushDiagLine(tx_line); } uint32_t expected_ack = 0, est_timeout = 0; uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique(); if (ts <= s_last_ui_tx_ts) ts = s_last_ui_tx_ts + 1; s_last_ui_tx_ts = ts; uint8_t attempt = s_ui_tx_attempt++; if (attempt < 4) { attempt = 4; s_ui_tx_attempt = 5; } int r = the_mesh.sendMessage(tx_recipient, ts, attempt, truncated, expected_ack, est_timeout); { uint32_t hash4 = 0; bool has_hash = the_mesh.getLastTxtTxHash4(hash4); char tx_line[132]; snprintf(tx_line, sizeof(tx_line), "TX DM ts=%lu att=%u r=%d ack=%lu h=%08lX", static_cast<unsigned long>(ts), static_cast<unsigned>(attempt), r, static_cast<unsigned long>(expected_ack), static_cast<unsigned long>(has_hash ? hash4 : 0UL)); pushDiagLine(tx_line); } if (r == MSG_SEND_FAILED) { showAlert(TR("Send failed"), 1200); appendMessage(_ui_threads[_active_thread_idx].name, sender, truncated, false, true, false, expected_ack, DELIV_FAILED); return false; } // Register the ACK hash so when the recipient acks the DM, processAck // matches it and fires UITask::onMessageAcked → bubble flips to ✓✓. the_mesh.uiRegisterExpectedAck(expected_ack, recipient.id.pub_key); appendMessage(_ui_threads[_active_thread_idx].name, sender, truncated, false, true, false, expected_ack, DELIV_SENT, 0, 0, 0, 0, nullptr, 0, the_mesh.uiLastSentFp()); if (!override_text) resetComposer(); // a resend must not wipe a half-typed draft showAlert(TR("Sent"), 900); return true; } // ============================================================ // UITask::begin // ============================================================ #if CAP_CONSOLE #include "ConsoleUI.h" // Console mode (CONSOLE_MODE.md). Decided once at begin() from a boot pref and // never re-read, so nothing can flip the front end mid-session. When set, LVGL // is never initialised and no draw buffer is allocated: that skip IS the saving, // so it has to be a genuine one and not a hidden LVGL instance. static bool s_console_mode = false; bool uiConsoleModeActive() { return s_console_mode; } // Route a UI alert to the console. Callers in main.cpp keep calling showAlert() // and do not need to know which front end is running. static void consoleAlert(const char* msg) { if (s_console_mode && msg) consoleWriteLine(msg); } #else bool uiConsoleModeActive() { return false; } #endif // The chat store: message ring + thread table, both PSRAM. Extracted from // begin() because CONSOLE MODE needs it too. Console mode returns from begin() // long before the LVGL setup, and skipping this left _ui_threads null, which an // incoming message then dereferenced. It is also what makes unread counts work // there: the console shows a message as it arrives, but the message is still // appended and still marked unread, so opening the thread later is what clears // it, not having seen it scroll past. void UITask::allocMessageStore() { #if defined(ESP32) _ui_msg_cap = uiDataFsIsSdCard() ? MAX_UI_MESSAGES_SD : MAX_UI_MESSAGES; uiDataEnsureDirs(); // segment dir exists before the loader scans / the first append #endif size_t msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap; const size_t threads_bytes = sizeof(UIThread) * MAX_UI_THREADS; if (!_ui_msgs) { _ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM); if (!_ui_msgs && _ui_msg_cap > MAX_UI_MESSAGES) { // PSRAM can't fit the deep ring (unexpected) — drop to the base size rather // than strand 1.3 MB in internal RAM via the fallback below. _ui_msg_cap = MAX_UI_MESSAGES; msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap; _ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM); } if (!_ui_msgs) _ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_8BIT); } if (!_ui_threads) { _ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_SPIRAM); if (!_ui_threads) _ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_8BIT); } if (_ui_msgs) memset(_ui_msgs, 0, msgs_bytes); if (_ui_threads) memset(_ui_threads, 0, threads_bytes); for (int i = 0; i < MAX_UI_THREADS; ++i) { _ui_threads[i].mesh_contact_idx = -1; memset(_ui_threads[i].mesh_contact_pub, 0, sizeof(_ui_threads[i].mesh_contact_pub)); memset(_ui_threads[i].mesh_contact_key6, 0, sizeof(_ui_threads[i].mesh_contact_key6)); _ui_threads[i].mesh_channel_slot = -1; } } void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; _node_prefs = node_prefs; #if CAP_CONSOLE && defined(ESP32) s_console_mode = touchPrefsGetConsoleMode(); // SELF-HEAL. If the previous boot panicked while console mode was on, come up // in the graphical UI and turn console mode off. Without this a crash on the // console path is unrecoverable from the device: it panics, reboots into the // console, panics again. The plan called for three ways out and this is the // one that does not need the user to know anything. if (s_console_mode && esp_reset_reason() == ESP_RST_PANIC) { s_console_mode = false; touchPrefsSetConsoleMode(false); Serial.println("[BOOT] console mode disabled after a panic — starting the UI"); } if (s_console_mode) { // Nothing below this point runs: no lv_init(), no draw buffer, no widget // tree. The console owns the panel from here. // The chat store, exactly as the graphical path builds it. Console mode // needs it for two reasons: an incoming message dereferences the thread // table, and the monitor below relies on the NORMAL append path so a // message stays unread until its thread is actually opened. allocMessageStore(); Serial.println("[BOOT] console: begin"); Serial.flush(); consoleBegin(_display); Serial.println("[BOOT] console: panel up"); Serial.flush(); consoleBanner(the_mesh.getNodePrefs() ? the_mesh.getNodePrefs()->node_name : nullptr, FIRMWARE_VERSION); consoleWriteLineC(CC_DIM, "'help' for everything, 'ui' for the graphical interface"); consoleWriteLine(""); Serial.println("[BOOT] console: text ok"); Serial.flush(); the_mesh.setTerminalSink(&consoleWriteLine); // Screen-timeout state. Set further down begin() for the graphical path, // which console mode returns before, so without this _screen_timeout_ms // stayed at its constructor value and the panel never slept: the console // stayed lit indefinitely. Same pref the UI reads. touchPrefsBegin(); _screen_timeout_ms = (uint32_t)touchPrefsGetScreenTimeoutSecs() * 1000u; _last_input_ms = millis(); _screen_off = false; Serial.printf("[BOOT] console: screen timeout %lus\n", (unsigned long)(_screen_timeout_ms / 1000u)); Serial.println("[BOOT] console: sink set"); Serial.flush(); return; } #endif #if defined(WADA_LUA_SPIKE) luaSpikeRun(); // Phase 0 measurement pass (LUA_APPS.md) — spike builds only #endif // #161 one-time heal: presets used to write airtime_factor = pct/100 (a 10% preset // configured ~91% duty). Exact-preset matches with the old value get the correct // factor rewritten + persisted; see healPresetAirtimeFactor for the guard. healPresetAirtimeFactor(); // #271: seed the region registry with the default and per-channel scopes held // in touch prefs. MyMesh::begin() seeds the companion-configured default from // NodePrefs; the touch UI stores its display name separately, so it must be // included here as well. ensureRegion() canonicalises and de-duplicates, so // channels sharing a region collapse to one slot and re-running each boot is a // no-op. Registering a region is what lets an incoming message be NAMED as // being from it. A channel scoped to "#denmark" means we hold that key, so // every "#denmark" message becomes identifiable, not just this channel's. { char rgn[TOUCH_REGION_SCOPE_MAXLEN] = {0}; if (touchPrefsGetRegionScope(rgn, sizeof rgn) > 0 && rgn[0]) the_mesh.regionRegistry().ensureRegion(rgn); } #ifdef MAX_GROUP_CHANNELS for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { char rgn[TOUCH_REGION_SCOPE_MAXLEN] = {0}; if (touchPrefsGetChannelScope(i, rgn, sizeof rgn) > 0 && rgn[0]) the_mesh.regionRegistry().ensureRegion(rgn); } #endif // #207 auto-retry: apply the persisted Radio & Mesh toggle (default ON). the_mesh.setCompanionRetryEnabled(touchPrefsGetRetryEcho()); #if defined(ESP32) // Clock floor (#89): restore the highest epoch this device ever handed out so // a power cycle without a time source cannot step protocol timestamps // backwards (server replay guards silently drop those). Seed before the UI // generates traffic; written back rate-capped in loop() + on shutdown(). rtc_clock.seedFloor(touchPrefsGetClockFloor()); // ...then push the resulting time into the ESP32 system clock, which is what every // displayed timestamp reads. On a board with a battery-backed RTC chip (P4) the chip // knows the time but the system clock is still on its power-on seed, so the UI would // otherwise show 15 May 2024 until a network/GPS sync landed. Order matters: after // seedFloor(), so a dead chip still yields the persisted floor instead of the seed. rtc_clock.seedSystemClock(); #endif // GPS resume: initBasicGPS() always leaves the module stopped at boot, so a // saved "GPS on" pref never actually starts the hardware until the user // toggles it — and since the pref already reads "on", the first tap turns it // OFF, so it takes an off/on dance to get a fix. Re-apply the saved pref here // (runs after sensors.begin()) so GPS resumes automatically after a reboot. // setSettingValue("gps") is a no-op on boards without GPS; on the T-Deck the // flaky boot-time gps_detected race is disabled (ENV_SKIP_GPS_DETECT), so this // reliably starts the NMEA reader. if (_sensors && _node_prefs && _node_prefs->gps_enabled) { _sensors->setSettingValue("gps", "1"); } // Map options exposes the same source toggle on T-Deck and T-Pager. Restore // that choice on both boards; forcing it off on Pager made a selected SD pack // silently revert to the internal/server cache after every reboot. s_tiles_from_sd = touchPrefsGetTilesFromSd(); // map tile source: server (default) vs microSD s_map_night = touchPrefsGetMapNight(); // map night mode (render-time tile invert) s_map_show_coords = touchPrefsGetMapShowCoords(); // per-element map text/marker visibility s_map_show_tilexyz = touchPrefsGetMapShowTileXYZ(); s_map_tile_debug = touchPrefsGetMapTileDebug(); // developer: tile-pipeline diagnostic overlay s_map_show_contacts = touchPrefsGetMapShowContacts(); s_map_show_links = touchPrefsGetMapShowLinks(); // dotted self->contact link lines (PR #61) s_map_style = touchPrefsGetMapStyle(); // 0=OpenStreetMap (default), 1=OpenTopoMap { const uint8_t pz = touchPrefsGetMapZoom(); // restore the last user-set map zoom if (pz >= k_map_zoom_min && pz <= k_map_zoom_max) s_map_zoom = pz; } s_map_zoom_buttons = touchPrefsGetMapZoomButtons(); // map zoom control: slider (default) vs +/- buttons s_home_is_drawer = touchPrefsGetHomeIsDrawer(); // Home tab default: Commander (default) vs app drawer // Monitor RX/RAW log rings live in PSRAM (2×28×80 = 4.4 KB) so they don't sit // in scarce internal DRAM that Wi-Fi DMA needs. Zero-init; the push/collect // helpers null-guard, so a (vanishingly unlikely) OOM just drops Monitor logs. if (!s_rxlog_ring) s_rxlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_SPIRAM); if (!s_rxlog_ring) s_rxlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_8BIT); if (!s_rawlog_ring) s_rawlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_SPIRAM); if (!s_rawlog_ring) s_rawlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_8BIT); #if defined(ESP32) crashDumpCheck(); // detect a pending panic coredump (Settings → About shows an Export button) // Bump the CPU above the 80 MHz base-config default. The base was // chosen for power on a headless companion build; on a touch device // the user is actively interacting and 80 MHz is visibly sluggish on // popups + tab switches. // IMPORTANT: the ESP32-S3 only clocks at 80 / 160 / 240 MHz off the PLL. // The previous setCpuFrequencyMhz(180) was an INVALID frequency, so the call // failed silently (rtc_clk_cpu_freq_mhz_to_config() returns false) and left // the V4 pinned at its 80 MHz base (ESP32_CPU_FREQ=80) — that was the // sluggishness. 160 MHz is the known-good 2x bump; 240 MHz showed RGB565 // noise on the map's SJPG decode, so 160 is the ceiling. The T-Deck sets no // ESP32_CPU_FREQ so it already boots at the 240 MHz default — bumping only // the V4 here avoids dragging the T-Deck *down* to 160. The V4-R8 env now // sets ESP32_CPU_FREQ=240 itself (whole of setup() at full clock), so this // is a no-op there. #if !defined(HAS_TDECK_GT911) setCpuFrequencyMhz(240); // S3 max; watch the map tiles for SJPG decode noise (drop to 160 if it shows) #endif // Local-time zone = CET/CEST base + the user's manual hour offset (Settings -> // Device -> Time offset), so every clock reads correctly even offline. The // Wi-Fi NTP path applies the same string. The RTC stays UTC — this only // shifts localtime() display. { char tz[48]; touchPrefsBuildLocalTz(tz, sizeof tz); setenv("TZ", tz, 1); tzset(); } #if defined(TLORA_PAGER) || defined(HELTEC_LORA_V4_R8) // Boot storage setup may have mounted SD before UITask starts. Synchronize // the UI's lifecycle state before tile-backend selection and before the // first loop tick can run health/remount logic. (R8: without this the // reinsert watch SD.end()'d the LIVE DataStore volume ~30 s after boot — // spurious "SD card remounted" toast, or a slow card stranded unmounted.) sdAdoptLiveMount(); #endif // Mount the dedicated tiles LittleFS partition. formatOnFail=true so a // pristine partition (all-0xFF after the partition-table upgrade) gets // initialised to an empty FS instead of failing to mount. partition // label "tiles" matches the CSV; base path "/tiles_lfs" is for VFS // access (we use the FS object directly, so it's mostly a label). s_tiles_fs_ready = s_tiles_fs.begin(true /*formatOnFail*/, "/tiles_lfs", 10 /*maxOpenFiles*/, "tiles"); #if defined(ESP32) // Mount failed outright: the Arduino LittleFS wrapper only auto-formats when // esp_vfs_littlefs_register returns exactly ESP_FAIL — but grow_on_mount=true // means a grow mismatch (or other error) returns a DIFFERENT code, so the // partition is left unmounted and the map showed "Reflash the tiles partition" // forever even though the partition is present. format() targets the stored // "tiles" label, so force a wipe + remount. Tiles are a re-downloadable cache. if (!s_tiles_fs_ready) { WIRE_DBG("[TILE] tiles begin() failed — forcing format() + remount"); s_tiles_fs.format(); s_tiles_fs_ready = s_tiles_fs.begin(true, "/tiles_lfs", 10, "tiles"); WIRE_DBG("[TILE] forced reformat -> ready=%d totalBytes=%u\n", (int)s_tiles_fs_ready, (unsigned)s_tiles_fs.totalBytes()); } // Corruption guard: the partition-table change (pre-alpha_12 grew the OTA // slots) left some devices with a tiles FS that MOUNTS but has a broken // superblock — block_count reads as 0, so the first mkdir on a fresh zoom dir // hit a divide-by-zero deep in lfs_alloc and rebooted the tile worker (caught // via coredump: exccause 6 in lfs_alloc ← ensureTilesDirPath). formatOnFail // doesn't catch this (mount "succeeded"). Detect the bad state via a zero/absurd // totalBytes and force a clean reformat. Tiles are a re-downloadable cache, so // wiping them is harmless. if (s_tiles_fs_ready) { const size_t tb = s_tiles_fs.totalBytes(); if (tb == 0 || tb > 8u * 1024u * 1024u) { // tiles partition is ~4.75 MB WIRE_DBG("[TILE] tiles FS bad (totalBytes=%u) — reformatting\n", (unsigned)tb); s_tiles_fs.end(); s_tiles_fs.format(); s_tiles_fs_ready = s_tiles_fs.begin(true, "/tiles_lfs", 10, "tiles"); WIRE_DBG("[TILE] reformat -> ready=%d totalBytes=%u\n", (int)s_tiles_fs_ready, (unsigned)s_tiles_fs.totalBytes()); } } // Pick the active tile-cache backend now the partition mount has been tried. // Prefer the dedicated "tiles" partition; if it's absent — e.g. running under // Launcher, whose partition table has no "tiles" partition — fall back to the // SD card ROOT /tiles so Wi-Fi tiles still cache + display instead of failing // with "Map storage error". s_tiles_fs_ready then means "a tile cache // (partition OR SD) is available". if (s_tiles_fs_ready) { s_tile_fs = &s_tiles_fs; s_tile_root[0] = '\0'; } #if defined(HAS_TDECK_GT911) // Under Launcher there's no "tiles" partition; cache to the SD card instead. // main.cpp already mounted the card for SD data storage (boot runs well before // ui_task.begin()), so PREFER that live mount via SD.cardType(). Calling // fmSdTryMount() unconditionally would SD.end()+remount the card mid-boot and // can fail while DataStore holds the volume — which left the map stuck on the // storage error. Only walk the mount ladder if the card isn't already up. else if (sdAdoptLiveMount() || fmSdTryMount()) { s_tile_fs = &SD; s_tile_root[0] = '\0'; // cache to the SD ROOT /tiles/<z>/<x>/<y>.jpg — the same place // the offline library + microSD-tile mode use, so the Wi-Fi cache // MERGES with it. Retires the old separate /meshcomod/tiles spot; // the dir path is created on demand by the fetcher (tileCacheMkdir). s_tiles_fs_ready = true; WIRE_DBG("[TILE] no tiles partition -> caching Wi-Fi tiles on SD /tiles (cardType=%d)\n", (int)SD.cardType()); } #endif #if defined(TLORA_PAGER) // Under the LauncherHub the pager runs in the LAUNCHER's partition table, which has NO // dedicated "tiles" partition — so the LittleFS mount above fails and the map showed // "Reflash the tiles partition". Fall back to the microSD card like the T-Deck: cache // Wi-Fi tiles to the SD ROOT /tiles/<z>/<x>/<y>.jpg (merges with any offline /maps library). else if (fmSdTryMount()) { s_tile_fs = &SD; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; WIRE_DBG("[TILE] pager: no tiles partition -> caching Wi-Fi tiles on SD /tiles"); } #endif #if defined(HAS_TANMATSU) // Tanmatsu has no dedicated "tiles" partition. PREFER the microSD card for the tile cache: the // internal FFat 'locfd' partition has a broken FAT metadata layer on this P4 — f_getfree reports // 0 free and f_stat (exists()/size()) misreports present files as missing — so cached tiles were // unreadable and the map re-downloaded forever, rendering nothing (#tiles). SD is reliable FAT + // gigabytes; tiles cache to /tiles/<z>/<x>/<y>.jpg there and render via the fs-agnostic tileCache* // helpers (same shape as the T-Deck SD path). Fall back to the (quirky) FFat only when no card. else if (tanSdTryMount()) { s_tile_fs = &SD_MMC; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; WIRE_DBG("[TILE] Tanmatsu: caching Wi-Fi tiles on microSD /tiles (SD_MMC)"); } else if (g_fs_ok) { s_tile_fs = &FFat; s_tile_root[0] = '\0'; // no card -> internal FFat (best-effort; f_stat quirks may hamper reads) s_tiles_fs_ready = true; WIRE_DBG("[TILE] Tanmatsu: no SD card -> caching Wi-Fi tiles on FFat /tiles (best-effort)"); } #endif #if defined(HAS_TDISPLAY_P4) // T-Display P4: no dedicated "tiles" partition in its table. Prefer the microSD (SD_MMC slot 0, // mounted by main.cpp at boot — XL9535 SD_EN must be LOW, it gates the slot's power); else the // internal 8 MB FFat 'storage' partition — a standard FFat mount of OUR OWN partition, so none of // the Tanmatsu locfd metadata quirks apply. Either way the map gets a working Wi-Fi tile cache. else if (SD_MMC.cardType() != CARD_NONE) { s_tile_fs = &SD_MMC; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; printf("[TILE] T-Display P4: caching Wi-Fi tiles on microSD /tiles (SD_MMC)\n"); } else { if (g_fs_ok) { s_tile_fs = &FFat; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; printf("[TILE] T-Display P4: no SD card -> caching Wi-Fi tiles on FFat /tiles\n"); } else { s_tile_fs = nullptr; printf("[TILE] T-Display P4: NO tile backend (no SD, FFat down)\n"); } } #endif #if !defined(HAS_TDISPLAY_P4) else { s_tile_fs = nullptr; // no cache backend at all -> map shows the storage notice } #endif // Remember the non-microSD backend, then (microSD-tile mode) re-point the cache at // the SD card ROOT so Wi-Fi-fetched tiles land in /tiles/<z>/<x>/<y>.jpg right next // to the user's SD library and persist across mode switches (#20). mapOptTilesSdCb // mirrors this when the toggle flips at runtime. s_tile_fs_default = s_tile_fs; strncpy(s_tile_root_default, s_tile_root, sizeof s_tile_root_default - 1); #if CAP_SD || defined(TLORA_PAGER) if (s_tiles_from_sd && (sdAdoptLiveMount() || fmSdTryMount())) { s_tile_fs = &SD; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; if (!s_tile_fs_default) { s_tile_fs_default = &SD; s_tile_root_default[0] = '\0'; } WIRE_DBG("[TILE] microSD-tile mode -> caching Wi-Fi tiles on SD /tiles (merges with library)"); } #endif #if defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Prefer the SD card for the Wi-Fi tile cache even when "Tiles from SD card" // is off. The 4.75 MB tiles partition fills after a few panned screens of // downloads and has NO eviction, after which every further tile fetch fails // forever ("panning doesn't reload tiles"). Cache to the card root /tiles // (same layout the Launcher T-Deck uses; merges with any dropped-in pack). // The partition stays registered as s_tile_fs_default, so // mapNoteStorageChanged falls back to it if the card ever wedges or (R8) is // pulled — maps stay online either way. #if defined(HAS_THINKNODE_M9) // M9: the card is BUILT IN — always worth walking the full mount ladder. if (s_tile_fs != &SD && (sdAdoptLiveMount() || fmSdTryMount())) { #else // R8: the Expansion-Kit card is REMOVABLE — follow the boot mount only // (adopting is a cached cardType() check; a card-less boot pays nothing and // a late-inserted card is adopted by sdHealthTick + mapNoteStorageChanged). if (s_tile_fs != &SD && sdAdoptLiveMount()) { #endif s_tile_fs = &SD; s_tile_root[0] = '\0'; s_tiles_fs_ready = true; printf("[TILE] caching Wi-Fi tiles on SD /tiles\n"); } #endif #endif #if defined(ESP32) // One-time SPIFFS cleanup + audit. Earlier dev builds wrote map tiles to // SPIFFS (/tiles/<z>/<x>/<y>.jpg) before the dedicated "tiles" LittleFS // partition existed; the spiffs partition keeps its old offset across the // partition-table upgrade, so that junk survives. A near-full SPIFFS turns // every chat-history write into a multi-second GC storm — the "open a DM / // receive a message and everything freezes" symptom (the history blob is // only ~21 KB, so a slow write means the partition is thrashing, not big). // // arduino-esp32 core 3.x: File::name() is the BASENAME; full path is // path(), and SPIFFS.remove() needs the full path. The old purge matched // name() against "/tiles" and so freed nothing. Walk the tree via a dir // queue (descends into a synthesized "tiles/" dir), list every file so we // can see ground truth in the Settings diag, and delete any image file // (*.jpg / *.png) — no legitimate SPIFFS file is an image, so identity / // contacts3 / channels2 / new_prefs / chat history are never touched. { // Big work buffers from INTERNAL heap (NOT PSRAM: SPIFFS.remove() disables // the flash cache, making PSRAM unreadable mid-op). Freed right after. const int PATHLEN = 72; const int QCAP = 256; // dir paths to visit (dirs < files, so ample) const int VCAP = 64; // image victims collected per dir char (*dirq)[PATHLEN] = (char (*)[PATHLEN])malloc((size_t)QCAP * PATHLEN); char (*victims)[PATHLEN] = (char (*)[PATHLEN])malloc((size_t)VCAP * PATHLEN); if (dirq && victims) { int total_purged = 0, listed = 0; uint32_t total_kb = 0; // Multi-pass: a single pass with QCAP=256 cleans the whole /tiles tree, // but loop a few times as insurance (rmdir empties shrink the tree, and // a dir with >VCAP images is finished on a later pass). Stop when a pass // deletes nothing. for (int pass = 0; pass < 6; ++pass) { int qh = 0, qt = 0; strncpy(dirq[qt], "/", PATHLEN - 1); dirq[qt][PATHLEN - 1] = '\0'; ++qt; int pass_purged = 0; while (qh < qt) { char dpath[PATHLEN]; strncpy(dpath, dirq[qh++], PATHLEN - 1); dpath[PATHLEN - 1] = '\0'; File dir = SPIFFS.open(dpath); if (!dir) continue; if (!dir.isDirectory()) { dir.close(); continue; } int nv = 0; File e = dir.openNextFile(); while (e) { const char* full = e.path(); // full path incl. leading '/' char fp[PATHLEN]; strncpy(fp, full ? full : "", PATHLEN - 1); fp[PATHLEN - 1] = '\0'; if (e.isDirectory()) { if (qt < QCAP) { strncpy(dirq[qt], fp, PATHLEN - 1); dirq[qt][PATHLEN - 1] = '\0'; ++qt; } } else { uint32_t sz = (uint32_t)e.size(); if (pass == 0 && listed < 22) { char ln[80]; snprintf(ln, sizeof(ln), "%s %uKB", fp, (unsigned)(sz / 1024)); pushDiagLine(ln); ++listed; } size_t L = strlen(fp); bool is_img = (L > 4) && (strcasecmp(fp + L - 4, ".jpg") == 0 || strcasecmp(fp + L - 4, ".png") == 0); if (is_img && nv < VCAP) { strncpy(victims[nv], fp, PATHLEN - 1); victims[nv][PATHLEN - 1] = '\0'; ++nv; total_kb += sz / 1024; } } e.close(); e = dir.openNextFile(); } dir.close(); for (int i = 0; i < nv; ++i) { if (SPIFFS.remove(victims[i])) { ++pass_purged; ++total_purged; } } // Drop now-empty tile dirs so later passes reach overflow subtrees. if (strncmp(dpath, "/tiles", 6) == 0) SPIFFS.rmdir(dpath); } if (pass_purged == 0) break; } if (total_purged > 0) { char m[56]; snprintf(m, sizeof(m), "Purged %d SPIFFS images (%uKB)", total_purged, (unsigned)total_kb); pushDiagLine(m); } } if (dirq) free(dirq); if (victims) free(victims); char su[56]; snprintf(su, sizeof(su), TR("SPIFFS %u/%u KB used"), (unsigned)(SPIFFS.usedBytes() / 1024), (unsigned)(SPIFFS.totalBytes() / 1024)); pushDiagLine(su); } #endif touchPrefsBegin(); uint16_t to_s = touchPrefsGetScreenTimeoutSecs(); _screen_timeout_ms = static_cast<uint32_t>(to_s) * 1000u; s_lock_on_screen_off = touchPrefsGetLockOnScreenOff(); #endif _last_input_ms = millis(); _screen_off = false; #ifdef PIN_USER_BTN pinMode(PIN_USER_BTN, INPUT_PULLUP); #endif #ifdef PIN_AUX_BTN pinMode(PIN_AUX_BTN, INPUT_PULLUP); #endif _next_refresh = millis() + UI_REFRESH_MS; _alert[0] = '\0'; _alert_expiry = 0; _msgcount = 0; _ui_msg_count = 0; _ui_msg_head = 0; _next_thread_seed = 0; _active_thread_idx = -1; _active_thread_is_channel = false; _thread_scroll = 0; _composer_mode = false; _composer_char_idx = 0; _composer_action_idx = 0; _compose_buf[0] = '\0'; _active_dm_contact_set = false; memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); // Message ring + thread table live in PSRAM, not internal DRAM — internal RAM // is tight and the WiFi stack aborts if it can't allocate its connection timers // there. Falls back to internal only if PSRAM is somehow full. // Ring depth: devices whose history lives on the SD card get the DEEP ring // (5000 msgs ≈ 1.3 MB PSRAM — the card comfortably holds the bigger file); // internal-flash devices keep 500. Decided once, before the alloc; the loader // linearizes a history file written under either capacity. allocMessageStore(); // NOTE: a dev-era "interop safety" block used to live here that, on EVERY // boot, force-reset manual_add_contacts=0 and OR'd AUTO_ADD_CHAT|REPEATER into // autoadd_config, then savePrefs()'d. That silently clobbered the user's saved // Auto-add settings on every reboot (and didn't actually affect advert/message // decoding — auto-add is purely contact-list behaviour). Removed: the user's // saved Auto-add configuration is now respected as-is across reboots. Fresh // installs still get sensible defaults from the firmware's pref initialisation. _threads_dirty = false; _next_threads_flush_ms = 0; _msgs_dirty = false; _next_msgs_flush_ms = 0; loadHistoryFromStorage(); #if defined(TLORA_PAGER) // From here on, the loaded ring and its filesystem are one atomic history // generation. If no backing store was available, wait for a reboot rather // than adopting an inserted card whose on-disk history was never loaded. s_ui_data_boot_finalized = true; #endif _next_mesh_thread_refresh = millis() + 2000; refreshThreadsFromMesh(); _touch_screen = TouchUiScreen::Home; #if defined(HAS_TOUCH_UI) g_lv.task = this; if (!g_lv.ready) { // Claim the net worker's 8 KB internal stack NOW, while the heap is still // whole. Its own comment always said "allocated at UITask::begin() time", // but the only caller was the fallback inside ensureTileFetchTaskRunning() // — which runs when the user first opens the map/store, by which point the // Wi-Fi driver has taken the internal heap. On the V4 that left a 4-byte // largest block, so the worker could never start and tiles, the update // check and the app catalog were all silently dead. reserveTileFetchStack(); lv_init(); initTouchFontFallbacks(); // NB: re-pointing the LVGL theme's font here does NOT work (tried, verified // on device: no effect) and neither does a text_font style on the screen / // top / sys layers — the theme applies a font per widget, which beats both. // Non-ASCII coverage is handled at the label instead: useChainedFont() swaps // a resolved raw Montserrat for its chained twin, and every label that sets // no font of its own calls it. #if CAP_ROUND_CORNERS STATUSBAR_H = SB_TOP_PAD + SB_ROW * 2; // top safe-area + two rows (round phone panel) #else STATUSBAR_H = SC(22); // grow the status bar to fit bigger text at Large/Huge (no-op at 100%) #endif // Allocate the draw buffer in PSRAM so the ~12 KB it costs comes out of // the 8 MB external RAM instead of the 320 KB internal DRAM that WiFi // DMA buffers also need. Falls back to DRAM if PSRAM allocation fails. if (!g_draw_buffer) { #if defined(HAS_TANMATSU) // P4 + 800x480 logical (via LVGL sw-rotate, ROT_270). A full-LOGICAL-WIDTH band (800px) // so software rotation always has room for a full status-bar/list row. Lives in the // abundant 32MB PSRAM — internal DRAM is precious and the MIPI-DSI flush reads PSRAM fine. g_draw_buf_px = 800 * LV_DRAW_BUF_LINES; const size_t buf_bytes = sizeof(lv_color_t) * g_draw_buf_px; g_draw_buffer = (lv_color_t*)heap_caps_malloc(buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); #elif defined(HAS_TDISPLAY_P4) // P4: LVGL renders at half res (upscaled 2x on flush) — full-width band in the abundant 32MB PSRAM. // AMOLED (RM69A10) = 284-wide; TFT-LCD (HI8561) = 270-wide. #if defined(HAS_TDP4_LCD) g_draw_buf_px = 270 * LV_DRAW_BUF_LINES; #else g_draw_buf_px = 284 * LV_DRAW_BUF_LINES; #endif const size_t buf_bytes = sizeof(lv_color_t) * g_draw_buf_px; g_draw_buffer = (lv_color_t*)heap_caps_malloc(buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); #else #if defined(HAS_RAK_TAP_V2) const int draw_band_w = 320; #else const int draw_band_w = 240; #endif const size_t buf_bytes = sizeof(lv_color_t) * draw_band_w * LV_DRAW_BUF_LINES; // Internal DMA-capable DRAM — this is the hot loop's read source during // SPI flush. The T-Pager is deliberately the exception: ST7796LCDDisplay // uses synchronous pushColors (not DMA), while BLE needs this contiguous // internal block later when a client connects and negotiates security. #if defined(TLORA_PAGER) g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!g_draw_buffer) { g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); } #else g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!g_draw_buffer) { g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } #endif if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); // Last-ditch under severe DRAM pressure — e.g. a unit whose PSRAM didn't // init (some T-Deck clones are QSPI, not the expected OPI), so even SPIRAM // requests strain the internal heap. A tiny buffer still renders instead of // leaving g_draw_buffer NULL -> NULL-deref in lvglFlush -> boot panic loop. if (!g_draw_buffer) { g_draw_buf_px = draw_band_w * 8; g_draw_buffer = (lv_color_t*)heap_caps_malloc(sizeof(lv_color_t) * g_draw_buf_px, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(sizeof(lv_color_t) * g_draw_buf_px); } #endif // HAS_TANMATSU } lv_disp_draw_buf_init(&g_lv.draw_buf, g_draw_buffer, nullptr, g_draw_buf_px); g_cap_touch_hw_started = false; g_lv.touch_inited = false; // Load the persistent global UI orientation NOW — before the display // resolution and screens are set up — so every layout query // (lv_disp_get_hor/ver_res) returns the rotated size and the whole UI is // built for the chosen orientation. Changing the setting reboots, so this // is the single point where orientation is established for the session. #if defined(ESP32) s_ui_rotation = touchPrefsGetUiRotation(); #if defined(HELTEC_LORA_V4_R8) // V4-R8: force PORTRAIT at every boot. A tester who switched to landscape got stuck: the // shared cap-touch landscape transform was tuned on the V4's ST7789 driver, whose portrait // baseline is panel rotation 2 (+180) -- the R8's LovyanGFX panel runs baseline 0, so the // same map landed every touch point-mirrored and the setting could not be reached to undo // it. The R8-specific map in HeltecV4CapTouch.cpp is the candidate fix (tester-verify); // until a tester confirms it, booting always returns to the known-good portrait, so the // worst case of trying landscape is a reboot. if (s_ui_rotation != LV_DISP_ROT_NONE) { s_ui_rotation = LV_DISP_ROT_NONE; touchPrefsSetUiRotation(LV_DISP_ROT_NONE); // Heal the PANEL too: main.cpp's boot wordmark already applied the saved // landscape rotation before this guard ran, so reverting only the pref // left LVGL rendering portrait frames into a landscape-rotated panel — // one fully garbled session per landscape attempt. (::display — the // begin() parameter shadows the global, and the DisplayDriver base has // no setDisplayRotation.) ::display.setDisplayRotation(0); } #endif #if defined(HAS_TDECK_GT911) // The T-Deck panel is landscape-native (320x240) — the early boot wordmark // already renders upright at panel rotation 3. Always run the UI in // landscape so it fills the screen (the portrait default left it narrow and // clipped). ROT_270 maps to panel rotation 3, matching the boot wordmark. s_ui_rotation = LV_DISP_ROT_270; #endif #if defined(HAS_TANMATSU) // MIPI-DSI panel is portrait-native (480x800); the device is used in landscape. badge-bsp's // default rotation is 270 -> we run the UI landscape (logical 800x480) via LVGL sw-rotate. s_ui_rotation = LV_DISP_ROT_270; #endif #if defined(TLORA_PAGER) // Panel is native 222x480 portrait; the pager has no touch to reorient with, // so always run landscape via hardware MADCTL rotation (ROT_270 -> panel // rotation 3, same mapping the boot wordmark already uses). s_ui_rotation = LV_DISP_ROT_270; #endif #if defined(HAS_THINKNODE_M9) // M9 is landscape in hardware: boot DISPLAY_ROTATION=1 verified upright on // the tester's unit (bring-up #6). Without this override the portrait // default rendered LVGL at 240x320 into the 320x240 panel window — content // sat left with the bottom wrapped ("a bit left and down", bring-up #7). // ROT_90 maps to panel rotation 1 in applyHardwarePanelRotation, so the // UI-init re-apply matches the boot splash orientation. s_ui_rotation = LV_DISP_ROT_90; #endif #if defined(HAS_RAK_TAP_V2) // RAK Tap V2 panel is rotated 270° in hardware (DISPLAY_ROTATION=3); the UI // must match so LVGL renders the full 320x240 landscape surface. s_ui_rotation = LV_DISP_ROT_270; #endif #if defined(ATTAKY_MESH_SERIES) // Display and touch share this landscape transform. s_ui_rotation = LV_DISP_ROT_90; #endif #if !defined(HAS_TANMATSU) // REMOTE mode: render the UI to a virtual 480x800 PORTRAIT display for the web // (headless/browser use). No physical-panel rotation — the panel is a placeholder. s_remote_mode = touchPrefsGetRemoteMode(); s_remote_landscape = touchPrefsGetRemoteLandscape(); // Bootloop guard: arm an RTC flag before entering remote mode; it's cleared a few // seconds into a successful run (see UITask::loop). If a remote boot ever fails to // complete, the next boot sees the flag still set and auto-disables remote — so the // device can never get stuck. (RTC memory survives a soft reset; a cold power-on // resets it, so a fresh boot always honours the pref.) if (s_remote_mode) { esp_reset_reason_t rr = esp_reset_reason(); if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) s_rmt_boot_guard = 0; if (s_rmt_boot_guard == RMT_GUARD_MAGIC) { // previous remote boot didn't finish touchPrefsSetRemoteMode(false); s_remote_mode = false; s_rmt_boot_guard = 0; } else { s_rmt_boot_guard = RMT_GUARD_MAGIC; // arm; cleared after a good run } } #if defined(ATTAKY_MESH_SERIES) // Remote landscape must follow the board's own rotation: this panel is ROT_90- // native, so a hardcoded 270 would leave remote mode upside down. if (s_remote_mode) s_ui_rotation = s_remote_landscape ? LV_DISP_ROT_90 : LV_DISP_ROT_NONE; #else if (s_remote_mode) s_ui_rotation = s_remote_landscape ? LV_DISP_ROT_270 : LV_DISP_ROT_NONE; #endif #endif // Apply the saved backlight brightness (takes the LEDA pin over from the // display's digitalWrite via LEDC PWM). Both touch boards have the LEDA pin. #if defined(HAS_CC_BRIGHTNESS) applyBrightness(touchPrefsGetBrightness()); #endif #if defined(HAS_CC_KBD_BACKLIGHT) applyKbdBacklight(touchPrefsGetKbdBacklight()); #endif #if defined(HAS_CC_VOLUME) applyVolume(touchPrefsGetSoundVolume()); // codec already up via bsp_device_initialize #endif #if CAP_KEYBOARD s_kb_bl_mode = touchPrefsGetKbBacklight(); #endif #if defined(HAS_TDECK_KEYBOARD) { uint8_t p = touchPrefsGetKbdBacklight(); s_tdeck_kb_bl_pct = (p < 1) ? 1 : (p > 100 ? 100 : p); } // shared kbd_bl pref (dark = mode off, not pct 0) #endif // Accent-popup picker (both boards: on-screen + physical keyboard). Default on. s_accent_popups = touchPrefsGetAccentPopups(); { multi_heap_info_t hi{}; heap_caps_get_info(&hi, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); Serial.printf("[MEM] boot int: total=%u free=%u largest=%u minfree=%u | psram free=%u\n", (unsigned)(hi.total_free_bytes + hi.total_allocated_bytes), (unsigned)hi.total_free_bytes, (unsigned)hi.largest_free_block, (unsigned)hi.minimum_free_bytes, (unsigned)ESP.getFreePsram()); } i18nSetLang(touchPrefsGetUiLang()); // active UI translation language (before the UI builds) #if CAP_LUA_APPS uiLangFileBootLoad(); // overlay the active .lang file (mounts data storage a bit earlier) s_luascan_request = true; // pre-warm the installed-apps cache off the UI thread #endif #endif const bool ui_landscape = (s_ui_rotation == LV_DISP_ROT_90 || s_ui_rotation == LV_DISP_ROT_270); lv_disp_drv_init(&g_lv.disp_drv); #if defined(HAS_TANMATSU) // MIPI-DSI panel can't MADCTL-rotate, so LVGL rotates in SOFTWARE. Give LVGL the PHYSICAL // portrait resolution; lv_disp_set_rotation(ROT_270) below makes the logical surface landscape // and rotates each flushed area into panel coords. UI scale renders at a SMALLER physical res // (lvglFlush upscales to the 480x800 panel) so the whole UI lays out bigger — see s_lv_pw. // UI scaling is FONT-based at NATIVE resolution now (crisp) — see s_ui_fscale / // initTouchFontFallbacks. Resolution stays native; the flush upscaler is left dead (s_lv_pw // stays == the panel width, so it's never entered). g_lv.disp_drv.hor_res = TAN_PANEL_PW; // 480 g_lv.disp_drv.ver_res = TAN_PANEL_PH; // 800 #elif defined(HAS_TDISPLAY_P4) // Render at HALF the native panel; the P4 DisplayDriver upscales 2x on flush, so the whole UI is // uniformly 2x bigger on the high-DPI panel (simpler than per-element scaling). The touch driver // reports in this same logical space. Two SKUs (build-time): AMOLED 568x1232 -> 284x616 (GT9895 // touch); TFT-LCD 540x1168 -> 270x584 (HI8561 integrated touch). #if defined(HAS_TDP4_LCD) g_lv.disp_drv.hor_res = 270; g_lv.disp_drv.ver_res = 584; #else g_lv.disp_drv.hor_res = 284; g_lv.disp_drv.ver_res = 616; #endif #elif defined(TLORA_PAGER) // The normal Pager UI is fixed landscape. Remote mode may rotate its virtual // display for the browser without changing the physical placeholder panel. g_lv.disp_drv.hor_res = (s_remote_mode && !s_remote_landscape) ? 222 : 480; g_lv.disp_drv.ver_res = (s_remote_mode && !s_remote_landscape) ? 480 : 222; #else // Landscape rotates the panel in HARDWARE (smooth — no per-pixel software // rotation each flush), so tell LVGL the already-rotated resolution and let // it render/flush natively in 320x240. // Remote render = 480x288 (5:3): rendered SMALL on-device, upscaled to fill the browser // window. Every streaming stage (LVGL rasterise, shadow memcpy, RLE encode, socket bytes) // scales with pixel count, so fewer pixels = proportionally faster — ~44% less than // 640x384. The browser bilinear-upscales the canvas (near-free on the GPU) at the cost of // some softness. Orientation toggles live via the Rotate button ([0x04] + s_remote_landscape). // 400x240 (was 480x288): keeps the whole mirror footprint (~514 KB) BELOW the VNC // footprint that already allocates on the 2 MB-PSRAM Heltec V4 (~616 KB), so remote is // guaranteed to fit anywhere VNC does instead of black-screening on a failed alloc. Also // faster (fewer pixels). Upscaled in the browser. g_lv.disp_drv.hor_res = s_remote_mode ? (s_remote_landscape ? 400 : 240) : (ui_landscape ? 320 : 240); g_lv.disp_drv.ver_res = s_remote_mode ? (s_remote_landscape ? 240 : 400) : (ui_landscape ? 240 : 320); #endif g_lv.disp_drv.flush_cb = lvglFlush; g_lv.disp_drv.draw_buf = &g_lv.draw_buf; // sw_rotate stays enabled ONLY for the transient keyboard-landscape trick // when the base orientation is portrait. In hardware landscape the LVGL // rotation is left at NONE so it never software-rotates on top of the panel. g_lv.disp_drv.sw_rotate = 1; lv_disp_drv_register(&g_lv.disp_drv); #if defined(HAS_TANMATSU) // Apply the 270° software rotation now that the driver is registered: logical surface // becomes 800x480 and lv_disp_get_hor/ver_res report landscape for every layout query. lv_disp_set_rotation(lv_disp_get_default(), LV_DISP_ROT_270); #endif // Make the full glyph set the INHERITED default font. The default theme sets // no general text_font (only a checkbox marker), so any label without its own // font falls through to LV_FONT_DEFAULT (montserrat, no fallback) and renders // non-Latin text as tofu boxes. text_font is an inheritable style, so setting // it on the active screen + the top/sys layers cascades to every child (modals // live on lv_layer_top, the status bar on lv_layer_sys). g_font_14 = montserrat // + the extras_* Cyrillic/Greek/Arabic fallback; labels with an explicit font // still override this. (g_font_* are set up in initTouchFontFallbacks above.) lv_obj_set_style_text_font(lv_scr_act(), &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_font(lv_layer_top(), &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_font(lv_layer_sys(), &g_font_14, LV_PART_MAIN); // Chain a tiny wrapper theme onto the active default theme so every switch's // "on" colour follows the accent (touchThemeApplyCb), instead of the stock // blue. Parent = the current theme, so all default styling still applies; our // apply_cb only recolours switches. Must run before buildUiTree so it catches // every widget built below. if (lv_disp_t* _thd = lv_disp_get_default()) { if (lv_theme_t* _base = lv_disp_get_theme(_thd)) { s_touch_theme = *_base; // inherit fonts/colours/flags lv_theme_set_parent(&s_touch_theme, _base); // apply base first, then ours lv_theme_set_apply_cb(&s_touch_theme, touchThemeApplyCb); lv_disp_set_theme(_thd, &s_touch_theme); } } if (ui_landscape) { applyHardwarePanelRotation(s_ui_rotation); // rotate the ST7789 heltecV4CapTouchSetPointRotation(s_ui_rotation); // LVGL won't, so driver does } // Swipe-axis transform always matches the visible orientation. heltecV4CapTouchSetRotation(s_ui_rotation); #if CAP_TRACKBALL // Trackball: set up the direction GPIOs/ISRs and orient motion to the UI. tdeckTrackballBegin(); tdeckTrackballSetRotation(s_ui_rotation); #endif #if defined(HAS_PAGER_KEYBOARD) pagerKeyboardBegin(); #endif #if defined(HAS_PAGER_ENCODER) pagerEncoderBegin(); #endif // (Audio: the I2S speaker amp is installed on demand per tone — see // tdeckPlayNotify — so nothing to set up at boot.) // Load the saved theme accent BEFORE the bar is built: the status bar is the one // widget tree created here in begin() rather than in buildUiTree (which loads the // accent itself), so without this its accent-tinted glyphs — the Wi-Fi/Bluetooth // icons — would freeze the compile-time default instead of the user's colour. // Idempotent: buildUiTree's own call just re-sets the same globals. applyAccent(touchPrefsGetAccentColor()); // Build the always-on top status bar AFTER the display driver is // registered — lv_layer_sys() needs an active disp or it returns // nullptr, and creating a child of nullptr was the boot-loop cause. buildGlobalStatusBar(); #if defined(HAS_TANMATSU) || defined(TLORA_PAGER) // No touchscreen: drive the UI with a KEYPAD indev, with a focus group // navMaybeRebuild() repopulates per screen. Tanmatsu feeds it from badge-bsp // keys (navPump, below); the pager feeds it from its own keyboard/encoder // drain in the main loop (mirrors the T-Deck's handleHwKey() path for // typing, plus a small updatePagerEncoder() for focus movement — see loop()). s_nav_group = lv_group_create(); // populated per-screen by navMaybeRebuild()/navCollect() lv_group_set_focus_cb(s_nav_group, navFocusCb); // amber focus ring + scroll-into-view lv_indev_drv_init(&g_lv.indev_drv); g_lv.indev_drv.type = LV_INDEV_TYPE_KEYPAD; g_lv.indev_drv.read_cb = tanmatsuKeypadRead; // device-neutral: just drains navFifo g_lv.indev_drv.disp = lv_disp_get_default(); if (lv_indev_t* kp = lv_indev_drv_register(&g_lv.indev_drv)) lv_indev_set_group(kp, s_nav_group); else pushDiagLine("LVGL keypad indev failed"); #if defined(HAS_TANMATSU) bsp_input_get_queue(&s_nav_queue); #endif #else // Physical touchscreen indev — skipped in remote mode (the panel is a placeholder; // its 320x240 coords would map wrong onto the 480x800 web UI). The web pointer drives it. if (!s_remote_mode) { lv_indev_drv_init(&g_lv.indev_drv); g_lv.indev_drv.type = LV_INDEV_TYPE_POINTER; g_lv.indev_drv.read_cb = lvglTouchRead; g_lv.indev_drv.disp = lv_disp_get_default(); if (!lv_indev_drv_register(&g_lv.indev_drv)) pushDiagLine("LVGL indev failed"); } #endif #if !defined(HAS_TANMATSU) // Web UI mirror: stream this display + accept a phone browser's taps as a second // pointer indev (opt-in via the VNC/REMOTE apps; see WebMirror / the WS server). // Size the remote ring to just above ONE frame (w*h*2 + 48 KB) instead of a fixed // 1 MB: the old 1 MB ring failed to allocate on the 2 MB-PSRAM Heltec V4 (VNC's // smaller default ring fit), leaving active()==false and a black browser. The ring // only ever holds one dirty region at a time, so frame-sized is also functionally // enough. begin() shrink-retries if even this is tight, so remote streams as long // as one frame fits in PSRAM. s_web_fb_w = lv_disp_get_hor_res(NULL); // shadow-buffer dimensions for the mirror s_web_fb_h = lv_disp_get_ver_res(NULL); const size_t rmt_frame = (size_t)s_web_fb_w * s_web_fb_h * 2; g_web_mirror.begin(s_remote_mode ? (rmt_frame + 48u * 1024u) : (size_t)0); g_web_mirror.setScreenSize(s_web_fb_w, s_web_fb_h); g_web_mirror.setEnabled(s_remote_mode ? true : touchPrefsGetWebMirror()); g_web_mirror.setRemote(s_remote_mode); // browser shows the Rotate button only in remote mode g_web_mirror.setTerminalOn(!s_remote_mode && touchPrefsGetWebTerminal()); // web mesh terminal (runtime; not in the off-screen boot mode) if (s_remote_mode) { const bool mirror_ready = g_web_mirror.ringBytes() > 0 && webMirrorEnsureBufs(); Serial.printf("[REMOTE] mirror %dx%d ring=%u buffers=%s\n", s_web_fb_w, s_web_fb_h, (unsigned)g_web_mirror.ringBytes(), mirror_ready ? "ready" : "FAILED"); } lv_indev_drv_init(&s_web_indev_drv); s_web_indev_drv.type = LV_INDEV_TYPE_POINTER; s_web_indev_drv.read_cb = webPointerRead; s_web_indev_drv.disp = lv_disp_get_default(); lv_indev_drv_register(&s_web_indev_drv); #endif #if CAP_KEYPAD_NAV && !defined(HAS_TANMATSU) && !defined(TLORA_PAGER) // Second indev for the keyboard/d-pad nav: a KEYPAD indev + focus group. // On the T-Deck it's optional (touchPrefsGetKbdNav()) since touch is the // primary input; on the M9 (no touch at all) it's always driven — see // s_kbd_nav below. (handleHwKey feeds navFifo; navMaybeRebuild runs in the // loop.) With it off the group stays empty, so it shows no focus ring and // does nothing. s_nav_group = lv_group_create(); lv_group_set_focus_cb(s_nav_group, navFocusCb); // amber focus ring + scroll-into-view lv_indev_drv_init(&s_nav_keypad_drv); s_nav_keypad_drv.type = LV_INDEV_TYPE_KEYPAD; s_nav_keypad_drv.read_cb = tanmatsuKeypadRead; s_nav_keypad_drv.disp = lv_disp_get_default(); if (lv_indev_t* kp = lv_indev_drv_register(&s_nav_keypad_drv)) lv_indev_set_group(kp, s_nav_group); else pushDiagLine("LVGL nav keypad indev failed"); #if defined(ESP32) #if defined(HAS_TANMATSU) || defined(HAS_THINKNODE_M9) s_kbd_nav = true; // keyboard-only device: nav is always on (no touch to fall back to) #else s_kbd_nav = touchPrefsGetKbdNav(); #endif #if CAP_TRACKBALL s_tb_nav = touchPrefsGetTbNav(); // trackball: D-pad UI nav (default) vs soft cursor #endif s_nav_mbar_keys = touchPrefsGetNavMenubarKeys(); // menubar letter hints: off by default for (int i = 0; i < 5; i++) { uint8_t k = touchPrefsGetNavKey(i); if (k) s_nav_keys[i] = k; } // load programmable tab hotkeys for (int i = 0; i < 8; i++) { uint8_t k = touchPrefsGetNavDirKey(i); if (k) s_dir_keys[i] = k; } // load programmable control + scroll keys #endif #endif // Opt-in (#64): push the persisted "scope direct floods to my region" flag into the // mesh so it survives reboot. OFF by default — no effect unless the user enabled it. the_mesh.setScopeDirectFloods(touchPrefsGetScopeDirect()); #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 high-gain FEM LNA: apply the saved state at boot (default OFF / bypassed on // the plain V4; ON on the V4-R8 since prefs v49). No-op on a V4.2 board (femLnaControllable() == false). if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna()); #endif #if defined(HAS_TDISPLAY_P4) // T-Display P4 antenna select: force the on-board antenna (RF1 / IO1 HIGH) on EVERY boot, // deliberately ignoring whatever was chosen last session. The external socket may have // nothing screwed onto it, and transmitting into an open connector is what damages the PA — // so the state you get for free after any power cycle, crash or OTA has to be the safe one. // LilyGo's own driver preloads the same level for the same reason. Choosing external is a // per-session confirmed action; this re-asserts powerOnSequence()'s park in case anything // touched IO1 in between. xl9535.setAntennaMode(Xl9535::ANT_INTERNAL); #endif // Buffered LoRa receive (experimental, default OFF): apply the saved opt-in. if (touchPrefsGetRxQueue()) radio_driver.rxQueueEnable(true); buildUiTree(); #if !defined(HAS_TANMATSU) // Remote boot completed (display + full UI built without crashing) -> disarm the // bootloop guard now, not 8s into the loop. A rotate/orientation reboot re-enters // remote and completes begin() in a few seconds, so clearing here means back-to-back // reboots never look like an "incomplete boot" and wrongly auto-disable remote mode. if (s_remote_mode) s_rmt_boot_guard = 0; #endif // Force immediate full repaint to replace the TFT "Loading..." banner. lv_obj_invalidate(lv_scr_act()); lv_disp_t* lv_disp = lv_disp_get_default(); if (lv_disp) lv_refr_now(lv_disp); g_lv.lvgl_tick_prev_us = micros(); g_lv.ready = true; // Wire BaseChatMesh / Dispatcher trace lines (mesh_touch_tx_tracef → touchDiagTraceLine) // into the on-device Diag panel. Without this, every TX_COMPOSE / CMP / TX_AIR line // emitted from BaseChatMesh.cpp goes to /dev/null because s_touch_diag_cb was never set. touchDiagTraceRegister(&pushDiagLine); pushDiagLine("diag trace bound"); // First-boot setup wizard (welcome -> name -> region -> Wi-Fi). Overlays the // freshly-built UI; gated by touchPrefsGetSetupDone() so it's shown once. setupWizardMaybeOpen(); // Drawer-as-home pref: open the launcher as the initial Home view (but not over // the first-boot wizard, and only when Home is the active tab). if (s_home_is_drawer && !s_setup_root && getActiveTab() == HOME_TAB_INDEX) setHomeDrawer(true); #if defined(ESP32) if (!s_setup_root) crashReportMaybePrompt(); // a panic coredump is waiting -> proactively offer to send it #endif } g_lv.dirty_threads = true; g_lv.dirty_timeline = true; g_lv.defer_heavy_refresh = false; g_lv.heavy_refresh_at_ms = 0; // Wire the idle light-sleep predicate hooks once the UI is fully initialised. // All hooks are installed here so main.cpp can call touchSleep::loopEnd() on // every loop tick without a separate begin() site in main.cpp. uiInstallTouchSleepHooks(); // Restore the persisted on/off state (default OFF until the user enables it). touchSleep::setEnabled(touchPrefsGetSleepIdle()); #endif } // ============================================================ // UITask methods // ============================================================ void UITask::showAlert(const char* text, int duration_millis) { if (!text) return; #if CAP_CONSOLE // main.cpp reports Wi-Fi and Bluetooth outcomes through here in ~20 places. // They keep calling showAlert and do not need to know which front end is up. if (s_console_mode) { consoleAlert(text); return; } #endif strncpy(_alert, text, sizeof(_alert) - 1); _alert[sizeof(_alert) - 1] = '\0'; _alert_expiry = millis() + static_cast<unsigned long>(duration_millis); #if defined(HAS_TOUCH_UI) pushDiagLine(text); showAlertToastLvgl(text, static_cast<uint32_t>(duration_millis > 0 ? duration_millis : 900)); #endif } int UITask::getUnreadTotal() const { // Throttle the (slightly non-trivial) recompute to ~1 Hz and cache the // result. The status bar polls this every 250 ms on every non-Home tab; // recomputing the filtered sum each time was needless per-tick work. // 1 s staleness on an unread badge is imperceptible. static uint32_t s_last_ms = 0; static int s_cached = 0; static bool s_have_cached = false; const uint32_t now = millis(); if (s_have_cached && (uint32_t)(now - s_last_ms) < 1000u) return s_cached; int total = 0; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!_ui_threads[i].used) continue; // Count only what the Chats inbox actually shows (see // getCombinedInboxCount): channels always, DM threads only when they // have displayable message history. A DM that bumped its unread counter // but stored no readable message — e.g. from a peer whose advert we // haven't received yet, so the body can't be decrypted — would // otherwise show a phantom unread badge with nothing to open. if (!_ui_threads[i].channel && !threadHasMessageHistory(i)) continue; total += _ui_threads[i].unread; } s_last_ms = now; s_cached = total; s_have_cached = true; return total; } void UITask::markThreadRead(int idx) { if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used) return; if (_ui_threads[idx].unread == 0 && !_ui_threads[idx].has_mention) return; _ui_threads[idx].unread = 0; _ui_threads[idx].has_mention = false; markThreadsDirty(200); // persist soon (survives a manual reboot) } // Clear unread for the thread whose detail is currently open. refreshChatDetail // calls this while a chat/channel is on screen, so messages that arrive *while // you're reading* don't keep inflating that thread's badge (the "unread count // too high" symptom on a busy channel you're watching). Opening already clears // once in setActiveThread; this keeps it at zero for the duration you're in it. void UITask::markActiveThreadRead() { if (_active_thread_idx < 0) return; if (_ui_threads[_active_thread_idx].unread == 0 && !_ui_threads[_active_thread_idx].has_mention) return; markThreadRead(_active_thread_idx); g_lv.dirty_threads = true; // refresh the inbox row badge immediately } void UITask::markAllThreadsRead() { bool any = false; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!_ui_threads[i].used) continue; if (_ui_threads[i].unread || _ui_threads[i].has_mention) { _ui_threads[i].unread = 0; _ui_threads[i].has_mention = false; any = true; } } if (any) markThreadsDirty(200); // persist soon (survives a manual reboot) } bool UITask::threadHasMention(int idx) const { return idx >= 0 && idx < MAX_UI_THREADS && _ui_threads[idx].used && _ui_threads[idx].has_mention; } int UITask::getThreadCount(bool channel_mode, int out_indexes[], int max_out) const { int scratch[UI_SORT_SCRATCH]; int n = 0; sortThreadsByRecent(channel_mode, scratch, n); int copy_n = (n < max_out) ? n : max_out; for (int i = 0; i < copy_n; ++i) out_indexes[i] = scratch[i]; return copy_n; } // One ring pass that answers "has any message?" for EVERY thread at once (see the note on // _thread_hist). Stops as soon as every live thread is accounted for, so a device whose // threads all have recent traffic barely reads the ring at all. void UITask::rebuildThreadHistoryFlags() const { for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_msgs[i] = 0; for (int i = 0; i < _ui_msg_count; ++i) { const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; const UIMessage& m = _ui_msgs[idx]; if (!m.thread[0]) continue; // blanked by a clear / cap trim for (int t = 0; t < MAX_UI_THREADS; ++t) { if (!_ui_threads[t].used) continue; if (m.channel == _ui_threads[t].channel && strncmp(m.thread, _ui_threads[t].name, MAX_THREAD_NAME) == 0) { if (_thread_msgs[t] < 0xFFFF) ++_thread_msgs[t]; break; } } } _thread_hist_dirty = false; } // Trim this thread back to the per-chat cap by blanking its OLDEST records. Walking from the // oldest end finds them immediately for the chat that is actually over the cap (it is the one // filling the ring), so the common case is a couple of steps, not a full scan. void UITask::enforceHistoryCap(int thread_idx) { const uint16_t cap = touchPrefsGetHistPerChat(); if (!cap || thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return; if (_thread_hist_dirty) rebuildThreadHistoryFlags(); if (_thread_msgs[thread_idx] <= cap) return; const bool ch = _ui_threads[thread_idx].channel; const char* nm = _ui_threads[thread_idx].name; int trimmed = 0; for (int i = _ui_msg_count - 1; i >= 0 && _thread_msgs[thread_idx] > cap; --i) { const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; // i = oldest .. 0 = newest UIMessage& m = _ui_msgs[idx]; if (!m.thread[0] || m.channel != ch) continue; if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue; m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; segMarkSeqDirty(m.seq); --_thread_msgs[thread_idx]; ++trimmed; } if (trimmed) _msgs_dirty = true; } bool UITask::threadHasMessageHistory(int thread_idx) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return false; if (_thread_hist_dirty) rebuildThreadHistoryFlags(); return _thread_msgs[thread_idx] > 0; } int UITask::getCombinedInboxCount(int out_indexes[], int max_out) const { int scratch[UI_SORT_SCRATCH]; int n = 0; for (int i = 0; i < MAX_UI_THREADS; ++i) { if (!_ui_threads[i].used) continue; if (!_ui_threads[i].channel && !threadHasMessageHistory(i)) continue; scratch[n++] = i; } for (int a = 0; a < n; ++a) { for (int b = a + 1; b < n; ++b) { if (_ui_threads[scratch[b]].last_ts > _ui_threads[scratch[a]].last_ts) { int t = scratch[a]; scratch[a] = scratch[b]; scratch[b] = t; } } } const int copy_n = (n < max_out) ? n : max_out; for (int i = 0; i < copy_n; ++i) out_indexes[i] = scratch[i]; return copy_n; } bool UITask::getThreadInfo(int idx, bool& channel, uint16_t& unread, uint32_t& ts, char* name, size_t name_len) const { if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used) return false; channel = _ui_threads[idx].channel; unread = _ui_threads[idx].unread; ts = _ui_threads[idx].last_ts; if (name && name_len > 0) { strncpy(name, _ui_threads[idx].name, name_len - 1); name[name_len - 1] = '\0'; } return true; } int UITask::getActiveThreadMessageCount(int out_indexes[], int max_out, bool newest_first) const { return getThreadMessageIndexes(_active_thread_idx, out_indexes, max_out, newest_first); } bool UITask::getMessageByIndex(int msg_idx, UIMessage& out) const { if (msg_idx < 0 || msg_idx >= _ui_msg_cap) return false; out = _ui_msgs[msg_idx]; return true; } // Delete = TOMBSTONE, not compaction: blanking the thread tag makes every walker // (getThreadMessageIndexes, previews, the virt layout) skip the slot, while the // physical ring indexes that live UI widgets and s_chat_msg_idx hold stay valid. // The slot ages out of the ring naturally. bool UITask::deleteMessageBySlot(int msg_idx) { if (msg_idx < 0 || msg_idx >= _ui_msg_cap) return false; UIMessage& m = _ui_msgs[msg_idx]; if (!m.thread[0]) return false; // free or already tombstoned m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; segMarkSeqDirty(m.seq); // the owning segment needs a compaction pass _msgs_dirty = true; return true; } int UITask::clearThreadHistory(int thread_idx) { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return 0; int cleared = 0; for (int i = 0; i < _ui_msg_count; ++i) { const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; UIMessage& m = _ui_msgs[idx]; if (m.thread[0] && strncmp(m.thread, _ui_threads[thread_idx].name, MAX_THREAD_NAME) == 0 && m.channel == _ui_threads[thread_idx].channel) { m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; segMarkSeqDirty(m.seq); ++cleared; } } _ui_threads[thread_idx].unread = 0; _ui_threads[thread_idx].has_mention = false; _thread_msgs[thread_idx] = 0; // emptied, and no other thread was touched _threads_dirty = true; if (cleared) _msgs_dirty = true; return cleared; } int UITask::getUnreadMentionCount() const { int c = 0; for (int i = 0; i < MAX_UI_THREADS; i++) if (_ui_threads[i].used && _ui_threads[i].has_mention) c++; return c; } void UITask::enterThread(bool channel_mode, int idx) { setActiveThread(idx, channel_mode); } bool UITask::ignoreSenderInActiveThread(const char* sender_name) { if (_active_thread_idx < 0 || _active_thread_idx >= MAX_UI_THREADS) return false; uint8_t pub[32]; bool have = false; if (!_active_thread_is_channel) { // DM *or* ROOM — both are contact threads. For a ROOM the thread's contact is the // SERVER, not the person who posted, so blocking its pubkey silenced the ENTIRE room // (newRoomMsgFromPubWithMeta drops on the room pubkey). Blocking one loudmouth should // never cost you the whole room. Room posts carry only an author display name (passed // as the sender override), so block the AUTHOR by name and leave the room intact. If // the tapped sender IS the room's own name, fall through to the old pubkey block so // muting a whole room from inside it still works. const uint8_t* tpub = _ui_threads[_active_thread_idx].mesh_contact_pub; if (sender_name && sender_name[0] && hasContactPub(tpub)) { ContactInfo* rc = the_mesh.lookupContactByPubKey(tpub, PUB_KEY_SIZE); if (rc && rc->type == ADV_TYPE_ROOM && strcmp(rc->name, sender_name) != 0) return touchPrefsSetNameIgnored(sender_name, true); } if (hasContactPub(tpub)) { memcpy(pub, tpub, sizeof(pub)); have = true; } } else if (sender_name && sender_name[0]) { // Channel: block by NAME, ALWAYS — this is the only thing the channel RX path // can actually match. A group packet carries no per-sender pubkey (MyMesh's // channel branch calls newMsgFromPubWithMeta with from_pub = nullptr), so the // prefix ignore-list is never consulted for channel traffic and a pubkey-only // block is a SILENT NO-OP. That was issue #177: the sender showed up in the // blocked list, yet their posts kept arriving, because a sender who happened to // be a saved contact took the pubkey path below and never got a name entry. const bool name_ok = touchPrefsSetNameIgnored(sender_name, true); ContactInfo c; const int n = the_mesh.getNumContacts(); for (int i = 0; i < n; ++i) { if (the_mesh.getContactByIdx((uint32_t)i, c) && strcmp(c.name, sender_name) == 0) { memcpy(pub, c.id.pub_key, sizeof(pub)); have = true; break; } } // Not a saved contact (e.g. a bot posting in a joined room): the name entry is // the whole block. A saved contact ALSO gets the pubkey entry below, so their // DMs stay blocked exactly as before — the name entry is purely additive and is // what silences their channel posts. if (!have) return name_ok; } if (!have) return false; touchPrefsSetIgnored(pub, true); return true; } void UITask::openMeshContactDm(uint32_t mesh_contact_index) { ContactInfo c; if (!the_mesh.getContactByIdx(mesh_contact_index, c) || !c.name[0]) return; int t = findOrCreateThread(c.name, false); if (t < 0) return; _ui_threads[t].mesh_contact_idx = static_cast<int16_t>(mesh_contact_index); memcpy(_ui_threads[t].mesh_contact_pub, c.id.pub_key, sizeof(_ui_threads[t].mesh_contact_pub)); memcpy(_ui_threads[t].mesh_contact_key6, c.id.pub_key, sizeof(_ui_threads[t].mesh_contact_key6)); _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, c.id.pub_key, sizeof(_active_dm_contact_pub)); markThreadsDirty(200); setActiveThread(t, false); #if defined(HAS_TOUCH_UI) if (!g_lv.ready) { g_lv.dirty_threads = true; return; } hideKb(); closeSettingsModal(); if (g_lv.ch.detail_open && g_lv.ch.overlay) { lv_obj_add_flag(g_lv.ch.overlay, LV_OBJ_FLAG_HIDDEN); g_lv.ch.detail_open = false; } { char san[UITask::MAX_THREAD_NAME + 8]; copyUtf8ReplacingMissingGlyphs(&g_font_12, san, sizeof(san), c.name); setChatStatusTitle(san); // contact name → status bar (no in-chat header bar) } g_lv.dm.detail_open = true; if (g_lv.dm.overlay) { lv_obj_clear_flag(g_lv.dm.overlay, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_lv.dm.overlay); } refreshChatDetailAsync(g_lv.dm); // AFTER un-hiding so bubbles measure correctly and the open-scroll reaches the newest message #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Physical keyboard: focus the composer on open so typing goes straight in. showKb(&g_lv.dm); #endif // Skip the LV_ANIM_ON tab transition (150 ms slide) — the chat detail // overlay covers the tabview so the slide is invisible anyway, and the // animation was stealing refresh ticks from the first composer tap + // keyboard show, causing a ~300-500 ms perceived lag on the map → // marker → Send msg path. if (g_lv.tabview) { lv_tabview_set_act(g_lv.tabview, CHAT_INBOX_TAB_INDEX, LV_ANIM_OFF); } g_lv.dirty_threads = true; #endif } void UITask::resetActiveDmPath() { ContactInfo c; if (!hasActiveThread() || activeThreadIsChannel()) { showAlert(TR("Select a DM thread"), 1200); return; } if (!lookupActiveContact(c)) { showAlert(TR("No contact for thread"), 1200); return; } the_mesh.resetPathTo(c); showAlert(TR("Path reset"), 900); } void UITask::onLvTabChanged(int tab_index) { if (tab_index < 0) tab_index = 0; #if defined(HAS_TANMATSU) navUpdateTabHilite(tab_index); // hook (active-cell pill is auto-applied by the btnmatrix CHECKED style) #endif { const int k_last = static_cast<int>(TouchUiScreen::Settings); if (tab_index > k_last) tab_index = k_last; } _touch_screen = static_cast<TouchUiScreen>(static_cast<uint8_t>(tab_index)); } void UITask::appendDiag(const char* message) { #if defined(HAS_TOUCH_UI) pushDiagLine(message); #else (void)message; #endif } void UITask::logRxFrame(float snr, float rssi, const uint8_t* raw, int len) { #if defined(HAS_TOUCH_UI) char rx_line[RXLOG_COLS]; snprintf(rx_line, sizeof(rx_line), "RX %dB len=%d snr=%.2f rssi=%.0f", len, len, snr, rssi); pushLogLine(s_rxlog_ring, s_rxlog_line, rx_line); char raw_line[RXLOG_COLS]; int p = snprintf(raw_line, sizeof(raw_line), "%dB ", len); if (p < 0) p = 0; static const char k_hex[] = "0123456789ABCDEF"; const int cap = static_cast<int>(sizeof(raw_line)); const int dump_len = (len > 18) ? 18 : len; for (int i = 0; raw && i < dump_len && p + 3 < cap; ++i) { const uint8_t b = raw[i]; raw_line[p++] = k_hex[(b >> 4) & 0x0F]; raw_line[p++] = k_hex[b & 0x0F]; raw_line[p++] = (i + 1 < dump_len) ? ' ' : '\0'; } if (dump_len == 0) { raw_line[p++] = '-'; raw_line[p] = '\0'; } else if (len > dump_len && p + 4 < cap) { raw_line[p++] = ' '; raw_line[p++] = '.'; raw_line[p++] = '.'; raw_line[p++] = '.'; raw_line[p] = '\0'; } else { raw_line[cap - 1] = '\0'; } pushLogLine(s_rawlog_ring, s_rawlog_line, raw_line); if (g_set_modal.kind == SettingsModalKind::Log && g_set_modal.root) { refreshLogModalView(); } #else (void)snr; (void)rssi; (void)raw; (void)len; #endif } bool UITask::isButtonPressed() const { return false; } void UITask::toggleBuzzer() { if (!_node_prefs) return; _node_prefs->buzzer_quiet = _node_prefs->buzzer_quiet ? 0 : 1; the_mesh.savePrefs(); } bool UITask::getGPSState() { if (!_node_prefs) return false; return _node_prefs->gps_enabled != 0; } #if defined(HAS_EXPANSION_KIT) bool UITask::getLocalEnvSnapshot(LocalEnvSnapshot& out) const { // The sensor half of this is a LIVE I2C conversation with the BME280, and this // function is called from refreshStatusLabels — the ~1 Hz home-screen refresh. So a // V4-R8 with the Expansion Kit was running a repeated-start I2C transaction every // second, forever, on the UI thread. // // That crashed a device in the field (coredump, beta_65 V4-R8): Adafruit's driver // reads the BME280 with write_then_read, i.e. a repeated start, which puts Arduino's // TwoWire into nonStop mode holding its lock across two calls. If that sequence does // not complete — a slow or unhappy sensor is enough — nonStop stays set, and the NEXT // beginTransmission takes the "release the lock" branch for a lock this task no longer // holds. FreeRTOS asserts inside xQueueGenericSend and the device panics: // assert failed: xQueueGenericSend queue.c:832 (... xMutexHolder == xTaskGetCurrentTaskHandle()) // The bug is in TwoWire's lock accounting, which is not ours to fix, but polling it at // 1 Hz is what turned a rare bus hiccup into a reboot. // // So cache the queried half and refresh it on a timer. The readings are room // temperature and pressure; nobody needs them re-measured every frame. This also takes // a blocking I2C round trip off the UI thread's per-refresh path. static uint32_t s_env_cache_ms = 0; // millis() of the last real query (0 = never) static LocalEnvSnapshot s_env_cache; static bool s_env_cache_valid = false; const uint32_t env_now = millis(); const bool env_fresh = s_env_cache_valid && s_env_cache_ms != 0 && (uint32_t)(env_now - s_env_cache_ms) < 3000; out = LocalEnvSnapshot(); out.buzzer_available = true; out.buzzer_quiet = _node_prefs ? (_node_prefs->buzzer_quiet != 0) : true; out.gps_present = _sensors && _sensors->getLocationProvider(); out.gps_enabled = _node_prefs && _node_prefs->gps_enabled; out.gps_fix = _sensors && _sensors->getLocationProvider() && _sensors->getLocationProvider()->isValid(); out.gps_sats = (_sensors && _sensors->getLocationProvider()) ? (int)_sensors->getLocationProvider()->satellitesCount() : -1; if (_board) { const uint16_t batt_mv = _board->getBattMilliVolts(); out.have_batt = batt_mv > 0; out.batt_v = batt_mv / 1000.0f; } if (!_sensors) return localEnvHasAnySensors(out); if (env_fresh) { // Copy only the sensor-derived fields; GPS and battery above are cheap and stay live. const LocalEnvSnapshot& c = s_env_cache; out.query_ok = c.query_ok; out.have_bme_temp = c.have_bme_temp; out.bme_temp_c = c.bme_temp_c; out.have_bme_hum = c.have_bme_hum; out.bme_hum_pct = c.bme_hum_pct; out.have_bme_pressure = c.have_bme_pressure; out.bme_pressure_hpa= c.bme_pressure_hpa; out.have_bme_alt = c.have_bme_alt; out.bme_alt_m = c.bme_alt_m; out.have_gxhtv3_temp = c.have_gxhtv3_temp; out.gxhtv3_temp_c = c.gxhtv3_temp_c; out.have_gxhtv3_hum = c.have_gxhtv3_hum; out.gxhtv3_hum_pct = c.gxhtv3_hum_pct; return localEnvHasAnySensors(out); } CayenneLPP telemetry(96); out.query_ok = _sensors->querySensors(TELEM_PERM_ENVIRONMENT, telemetry); s_env_cache_ms = env_now ? env_now : 1; // stamp even on failure: do not retry-storm a sick bus if (!out.query_ok) { s_env_cache = out; s_env_cache_valid = true; return localEnvHasAnySensors(out); } LPPReader rd(telemetry.getBuffer(), telemetry.getSize()); uint8_t channel = 0, type = 0; while (rd.readHeader(channel, type)) { switch (type) { case LPP_TEMPERATURE: if (channel == 2 && !out.have_bme_temp) out.have_bme_temp = rd.readTemperature(out.bme_temp_c); else if (channel == 3 && !out.have_gxhtv3_temp) out.have_gxhtv3_temp = rd.readTemperature(out.gxhtv3_temp_c); else rd.skipData(type); break; case LPP_RELATIVE_HUMIDITY: if (channel == 2 && !out.have_bme_hum) out.have_bme_hum = rd.readRelativeHumidity(out.bme_hum_pct); else if (channel == 3 && !out.have_gxhtv3_hum) out.have_gxhtv3_hum = rd.readRelativeHumidity(out.gxhtv3_hum_pct); else rd.skipData(type); break; case LPP_BAROMETRIC_PRESSURE: if (channel == 2 && !out.have_bme_pressure) out.have_bme_pressure = rd.readPressure(out.bme_pressure_hpa); else rd.skipData(type); break; case LPP_ALTITUDE: { float alt_m = 0.0f; if (channel == 2 && !out.have_bme_alt && rd.readAltitude(alt_m)) { out.bme_alt_m = (int16_t)lroundf(alt_m); out.have_bme_alt = true; } else rd.skipData(type); break; } default: rd.skipData(type); break; } } s_env_cache = out; // seed the cache with what we just measured s_env_cache_valid = true; return localEnvHasAnySensors(out); } bool UITask::getLocalEnvSummary(char* buf, size_t cap) const { if (!buf || cap == 0) return false; buf[0] = '\0'; LocalEnvSnapshot snap; if (!getLocalEnvSnapshot(snap)) return false; int p = 0; if (snap.have_batt && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, LV_SYMBOL_BATTERY_FULL " Battery %.2fV", (double)snap.batt_v); } if ((snap.have_bme_temp || snap.have_bme_hum || snap.have_bme_pressure || snap.have_bme_alt) && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%sBME280 ", p > 0 ? "\n" : ""); bool first = true; if (snap.have_bme_temp && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%.1f\xc2\xb0\x43", (double)snap.bme_temp_c); first = false; } if (snap.have_bme_hum && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%s%.0f%%RH", first ? "" : " ", (double)snap.bme_hum_pct); first = false; } if (snap.have_bme_pressure && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%s%.0fhPa", first ? "" : " ", (double)snap.bme_pressure_hpa); first = false; } if (snap.have_bme_alt && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%s%dm", first ? "" : " ", (int)snap.bme_alt_m); } } if ((snap.have_gxhtv3_temp || snap.have_gxhtv3_hum) && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%sGXHTV3 ", p > 0 ? "\n" : ""); bool first = true; if (snap.have_gxhtv3_temp && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%.1f\xc2\xb0\x43", (double)snap.gxhtv3_temp_c); first = false; } if (snap.have_gxhtv3_hum && p < (int)cap) { p += snprintf(buf + p, cap - (size_t)p, "%s%.0f%%RH", first ? "" : " ", (double)snap.gxhtv3_hum_pct); } } if (cap > 0) buf[cap - 1] = '\0'; return buf[0] != '\0'; } #endif // HAS_EXPANSION_KIT void UITask::toggleGPS() { if (!_node_prefs) return; // Critical: just flipping the pref bit does NOT power the GPS hardware. // The companion-serial CMD_SET_CUSTOM_VAR("gps") path delegates to // SensorManager::setSettingValue, which is what calls start_gps() / // stop_gps() to enable the L76K (or similar) module and the NMEA reader. // The touch toggle was skipping that step — pref was updated but the GPS // never actually started, so the user saw "GPS on" but no fix arrived. // Mirror the CMD_SET_CUSTOM_VAR sequence: hardware first, pref + save // after, so a partial failure (e.g. no GPS detected) doesn't leave the // pref claiming the GPS is enabled. const bool target_on = !(_node_prefs->gps_enabled); const char* value = target_on ? "1" : "0"; bool hw_ok = false; if (_sensors) hw_ok = _sensors->setSettingValue("gps", value); // If the sensor manager doesn't recognise the setting (built without // ENV_INCLUDE_GPS), fall back to just flipping the pref so the UI is // still consistent — same behaviour as before. (void)hw_ok; _node_prefs->gps_enabled = target_on ? 1 : 0; the_mesh.savePrefs(); } bool UITask::setNodeName(const char* s) { if (!_node_prefs) return false; if (!s) s = ""; strncpy(_node_prefs->node_name, s, sizeof(_node_prefs->node_name) - 1); _node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0'; // Report the real write result — the "Name saved" toast used to show even // when the storage write silently failed. return the_mesh.savePrefs(); } bool UITask::setPosition(double lat, double lon) { if (!_sensors) return false; _sensors->node_lat = lat; _sensors->node_lon = lon; // A manual edit is authoritative: keep the GPS auto-persist baseline in sync so // it doesn't immediately overwrite what the user just typed. _gps_saved_lat = lat; _gps_saved_lon = lon; the_mesh.savePrefs(); return true; } bool UITask::getGpsFix() { if (!_sensors) return false; LocationProvider* lp = _sensors->getLocationProvider(); return lp && lp->isValid(); } int UITask::getGpsSats() { if (!_sensors) return -1; LocationProvider* lp = _sensors->getLocationProvider(); return lp ? (int)lp->satellitesCount() : -1; } uint32_t UITask::getGpsTime() { if (!_sensors) return 0; LocationProvider* lp = _sensors->getLocationProvider(); if (!lp) return 0; const long t = lp->getTimestamp(); // The provider builds this from the NMEA date/time fields, which read as year 0 (epoch far in // the past) until the receiver has actually decoded them off the air. Anything plausibly recent // means real satellite time. return (t > 1700000000L) ? (uint32_t)t : 0; } int UITask::getGpsAltitude() { if (!_sensors) return 0; LocationProvider* lp = _sensors->getLocationProvider(); if (!lp || !lp->isValid()) return 0; return (int)(lp->getAltitude() / 1000); // provider reports millimetres } // Keep the node's advertised location synced to GPS once we have a fix, and // persist it occasionally so a fix survives reboot ("had a fix at least once"). // Called every UITask::loop(). No-op on boards without GPS (provider == NULL). void UITask::updateGpsLocation(unsigned long now) { if (!_sensors || !getGPSState()) return; LocationProvider* lp = _sensors->getLocationProvider(); if (!lp || !lp->isValid()) return; const double la = (double)lp->getLatitude() / 1000000.0; const double lo = (double)lp->getLongitude() / 1000000.0; if (la == 0.0 && lo == 0.0) return; // ignore a not-yet-populated fix _gps_had_fix = true; // Live update — the profile modal and the next advert both read node_lat/lon. _sensors->node_lat = la; _sensors->node_lon = lo; // Persist only when moved meaningfully (~11 m) and at most once / 2 min, so we // don't churn SPIFFS on the shared worker (flash writes stall both cores). double dlat = la - _gps_saved_lat; if (dlat < 0) dlat = -dlat; double dlon = lo - _gps_saved_lon; if (dlon < 0) dlon = -dlon; if (now >= _gps_next_persist_ms && (dlat > 1e-4 || dlon > 1e-4)) { _gps_saved_lat = la; _gps_saved_lon = lo; _gps_next_persist_ms = now + 120000; the_mesh.savePrefs(); } } bool UITask::setRadioParams(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr, int8_t tx_dbm, float airtime_factor) { if (!_node_prefs) return false; // Clamp TX to the radio's range here — the same bound the boot-time load applies — so a too-high // value is corrected the moment it's set + saved, not silently dropped on the next reboot. tx_dbm = constrain(tx_dbm, (int8_t)-9, (int8_t)MAX_LORA_TX_POWER); _node_prefs->freq = freq_mhz; _node_prefs->bw = bw_khz; _node_prefs->sf = sf; _node_prefs->cr = cr; _node_prefs->tx_power_dbm = tx_dbm; _node_prefs->airtime_factor = airtime_factor; the_mesh.savePrefs(); the_mesh.applyRadioFromPrefs(); // take effect immediately — no reboot needed return true; } void UITask::setAutoAddConfig(uint8_t mask, uint8_t max_hops, uint8_t manual_add) { if (!_node_prefs) return; _node_prefs->autoadd_config = mask; _node_prefs->autoadd_max_hops = max_hops; _node_prefs->manual_add_contacts = manual_add ? 1u : 0u; the_mesh.savePrefs(); } void UITask::setAdvertLocationPolicy(uint8_t policy) { if (!_node_prefs) return; _node_prefs->advert_loc_policy = policy; the_mesh.savePrefs(); } void UITask::setPathHashMode(uint8_t mode) { if (!_node_prefs) return; if (mode > 2) mode = 2; _node_prefs->path_hash_mode = mode; the_mesh.savePrefs(); } void UITask::setTelemetryAllow(bool on) { if (!_node_prefs) return; // Battery + environment telemetry only — location telemetry keeps its own separate // setting so this simple toggle can't expose position. _node_prefs->telemetry_mode_base = on ? TELEM_MODE_ALLOW_ALL : TELEM_MODE_DENY; _node_prefs->telemetry_mode_env = on ? TELEM_MODE_ALLOW_ALL : TELEM_MODE_DENY; the_mesh.savePrefs(); } // Position sharing, deliberately its own control rather than part of the switch above // (#266). DENY / ALLOW_FLAGS (only contacts you granted it) / ALLOW_ALL. void UITask::setLocationTelemetryMode(uint8_t mode) { if (!_node_prefs) return; if (mode > TELEM_MODE_ALLOW_ALL) mode = TELEM_MODE_DENY; _node_prefs->telemetry_mode_loc = mode; // MeshCore answers a telemetry request at all only when the BASE permission is set, // so location would silently never be sent with the master switch off. Turning // sharing ON implies answering requests; say so via the note under the dropdown // rather than leaving the user with a setting that does nothing. if (mode != TELEM_MODE_DENY && _node_prefs->telemetry_mode_base == TELEM_MODE_DENY) { _node_prefs->telemetry_mode_base = TELEM_MODE_ALLOW_ALL; } the_mesh.savePrefs(); } void UITask::setExperimentalFlags(uint8_t multi_acks, uint8_t client_repeat, uint8_t rx_boosted) { if (!_node_prefs) return; _node_prefs->multi_acks = multi_acks ? 1u : 0u; _node_prefs->client_repeat = client_repeat ? 1u : 0u; _node_prefs->rx_boosted_gain = rx_boosted ? 1u : 0u; the_mesh.savePrefs(); // Base meshcomod branch does not expose applyRadioFromPrefs(); keep persisted only. } bool UITask::setWifiRadio(bool on) { (void)on; return false; } bool UITask::setDeviceTimeFromSystemClock() { #if defined(ESP32) const uint32_t t = (uint32_t)time(nullptr); // ESP32RTCClock::begin() seeds the system clock to exactly MIN_VALID_EPOCH on power-on, // so "still at the seed" means never synced. Pushing that into the mesh clock is a // two-year step backwards that setCurrentTime()'s own MIN check cannot catch, because // the value IS the constant it compares against — that is how a P4 (whose RTC chip left // the system clock on the seed) could sync 2024 over a perfectly good clock. The old // `t < 100000` guard only caught a clock counting up from zero, which never happens here. if (t <= ClockFloorRTC::MIN_VALID_EPOCH) return false; the_mesh.getRTCClock()->setCurrentTime(t); return true; #else the_mesh.getRTCClock()->setCurrentTime((uint32_t)(millis() / 1000)); return true; #endif } #if defined(HELTEC_LORA_V4_R8) // ---- V4-R8 panel sleep (anti burn-in) -------------------------------------- // The R8 (which defines HELTEC_LORA_V4_TFT too) drives its ST7789 with LovyanGFX // on the shared FSPI/SPI2 bus, NOT the Adafruit driver on HSPI. So it must NOT use // the HSPI s_cmd_spi shim below: that shim's SPIClass(HSPI)->begin(16,-1,15,-1) // re-routes GPIO16/15 (the FSPI SCLK/MOSI LovyanGFX owns) to HSPI, stealing the // display bus. After a sleep/wake the panel keeps its last frame (GRAM retained) // but LGFX can no longer drive it and the locked flush hangs the loop — the // tester's "wake shows the last screen, frozen, needs reset". Route SLPIN/SLPOUT // through LGFX's own SPI2 bus instead (display is the LGFXDisplay instance). static void touchPanelSleep(bool slp) { static bool s_asleep = false; if (slp == s_asleep) return; s_asleep = slp; display.panelSleep(slp); } #elif defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_TFT) // ---- ST7789 panel sleep (anti burn-in) ------------------------------------- // Backlight-off alone is NOT screen-off: the ST7789 keeps refreshing the same // static image behind the dark backlight, continuously biasing the liquid // crystals — which is what actually sticks an image into the LCD (Kaj + // wyvern.red burn-in reports). The core driver's Adafruit object is private and // wraps no sleep API (and the T-Deck has no TFT reset line, PIN_TFT_RST=-1), so // send the one-byte ST7789 commands ourselves on the same HSPI bus + CS/DC pins // the driver uses. SLPIN stops the oscillator, booster and LC drive; panel RAM // is RETAINED, so wake = SLPOUT + DISPON and the old image is back with no // redraw. Safe alongside LVGL: flushes run on this same task (no concurrent // bus use), and the datasheet allows memory writes while asleep. static void touchPanelCmd(uint8_t cmd) { static SPIClass* s_cmd_spi = nullptr; if (!s_cmd_spi) { s_cmd_spi = new SPIClass(HSPI); // same port as the driver's displaySPI s_cmd_spi->begin(PIN_TFT_SCL, -1, PIN_TFT_SDA, -1); // same pins; re-init is idempotent } s_cmd_spi->beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0)); digitalWrite(PIN_TFT_DC, LOW); // command digitalWrite(PIN_TFT_CS, LOW); s_cmd_spi->transfer(cmd); digitalWrite(PIN_TFT_CS, HIGH); digitalWrite(PIN_TFT_DC, HIGH); // back to data (the driver's idle default) s_cmd_spi->endTransaction(); } static void touchPanelSleep(bool slp) { static bool s_asleep = false; if (slp == s_asleep) return; s_asleep = slp; if (slp) { touchPanelCmd(0x28); // DISPOFF — blank the output touchPanelCmd(0x10); // SLPIN — stop oscillator/booster/LC drive (RAM retained) delay(5); // t_SLPIN: bus must stay quiet 5 ms } else { touchPanelCmd(0x11); // SLPOUT — RAM was retained through sleep delay(6); // datasheet: >=5 ms before the next command touchPanelCmd(0x29); // DISPON — the pre-sleep image reappears } } #else static inline void touchPanelSleep(bool) {} #endif /* Screen sleep = backlight off + ST7789 sleep-in (not a full panel reset). * Panel RAM survives SLPIN, so wake is near-instant and the previous image is * still on the glass when the LED lights back up — no partial re-render. */ static inline void touchScreenBacklight(bool on) { #if defined(TLORA_PAGER) // Also has TFT_BL defined (=-1, disabling TFT_eSPI's own backlight pin // support), which would otherwise fall into the #elif defined(TFT_BL) branch // below and digitalWrite a "-1" pin -- must come first. touchPanelSleep() // has no ST7796-specific implementation (falls to the no-op default below, // same as Tanmatsu), so screen-sleep here is backlight-off only, not a real // panel sleep command -- a known, low-severity gap, not a regression (this // board never had panel-sleep to begin with). if (on) { touchPanelSleep(false); display.setBrightness(s_brightness_pct); } else { display.setBrightness(0); touchPanelSleep(true); } #elif defined(HAS_BACKLIGHT_PWM) // Both touch boards drive the backlight via LEDC PWM on PIN_TFT_LEDA_CTL once // applyBrightness() has claimed the pin at boot. A plain digitalWrite would // then be a no-op (on the V4, TFT_BL == PIN_TFT_LEDA_CTL == GPIO21), so drive // the PWM duty directly: 0 = off, saved brightness = on. if (!s_bl_pwm_ready) applyBrightness(s_brightness_pct); // ensure LEDC is attached if (on) touchPanelSleep(false); // wake the panel BEFORE lighting it (old frame intact) ledcWrite(kBlPwmChannel, on ? ((uint32_t)s_brightness_pct * 255u / 100u) : 0u); if (!on) touchPanelSleep(true); // then stop the panel driving the crystals (anti burn-in) #if defined(HELTEC_LORA_V4_R8) // Dark screen: throttle the CHSC6x poll task (8 ms -> 50 ms) — its blocking // I2C read rides the SHARED sensor/RTC bus on this board, and full-rate // polling with the screen off was pure idle drain. Wake-on-touch keeps // working, at most ~42 ms later. (Local extern: the include path resolves // the VENDORED MeshCore copy of HeltecV4CapTouch.h, which predates this // function — the compiled driver is the repo copy in src/helpers/input.) extern void heltecV4CapTouchSetSlowPoll(bool slow); heltecV4CapTouchSetSlowPoll(!on); #endif #elif defined(TFT_BL) pinMode(TFT_BL, OUTPUT); #ifdef TFT_BACKLIGHT_ON digitalWrite(TFT_BL, on ? TFT_BACKLIGHT_ON : !TFT_BACKLIGHT_ON); #else digitalWrite(TFT_BL, on ? HIGH : LOW); #endif #elif defined(HAS_TANMATSU) // Tanmatsu: the screen backlight is the CH32-driven display backlight (not LEDC / TFT_BL). Off = a // true 0 duty; on = restore the saved brightness. Without this, idle-off + sleep set _screen_off // but the panel never actually went dark (this branch used to be the (void)on no-op). if (on) applyBrightness(s_brightness_pct); else bsp_display_set_backlight_brightness(0); #elif defined(HAS_THINKNODE_M9) // M9: BL_EN (GPIO17) drives a PNP transistor gate — LEDC PWM works (confirmed on hardware, // Specter bring-up), but duty is INVERTED vs. a normal N-channel/NPN setup: lower duty on // the base = MORE conduction = brighter. applyBrightness() below already accounts for this. if (on) applyBrightness(s_brightness_pct); else ledcWrite(kM9BlPwmChannel, 255); // inverted: 255 = 0% conduction = off #elif defined(HAS_TDISPLAY_P4) // T-Display P4: the RM69A10 AMOLED has no backlight pin — "brightness" is the panel's own DCS // 0x51 register. Off = 0 (blanks the AMOLED), on = restore the saved brightness. Without this // branch touchScreenBacklight was the (void)on no-op, so screen-timeout, wake, lock, sleep and // the burn-in guard all silently did nothing on the P4 (reported: "screen timeout not working"). if (on) applyBrightness(s_brightness_pct); else display.setBrightness(0); #else (void)on; #endif } #if defined(ESP32) // Drop the CPU clock while the screen is off to save power. The LoRa radio RX, // Wi-Fi and BLE all keep working at 80 MHz (the Wi-Fi floor), so the node never // stops listening to the mesh — we just stop burning 240 MHz to render nothing. // Restored to 240 MHz on wake so tile decode / rendering stays fast. Only // switches when the target changes (the call is a hard DFS switch). static void setCpuForScreen(bool screen_on) { #if defined(HAS_TDISPLAY_P4) // The P4's Arduino HAL only accepts 360 MHz (240/80 log a "could not be set" error every screen // toggle) — no DFS downclock available through this API on that chip, so skip entirely. (void)screen_on; #else static int s_cur_mhz = 240; const int want = screen_on ? 240 : 80; if (want == s_cur_mhz) return; setCpuFrequencyMhz(want); s_cur_mhz = want; #endif } #else static inline void setCpuForScreen(bool) {} #endif void UITask::noteUserInput() { /* Called from touch input. If the screen was explicitly locked via the * BOOT button, ignore touches entirely — only a BOOT button press can * release that. Idle-timeout locks still unlock on touch. */ if (_screen_off && _manual_lock) return; _last_input_ms = millis(); #if defined(ATTAKY_MESH_SERIES) // This board wakes on POWER_BTN only (polled in the UI loop). A touch on a dark // panel is already absorbed by the indev read, and must not light it either. if (_screen_off) return; #endif if (_screen_off) wakeScreen(); } void UITask::wakeScreen() { if (!_screen_off) return; setCpuForScreen(true); // back to 240 MHz before we render touchScreenBacklight(true); _screen_off = false; _manual_lock = false; _last_input_ms = millis(); } void UITask::lockScreen() { // Backlight off + manual lock so touch is ignored (noteUserInput() // early-returns) until a deliberate unlock. #if defined(HAS_TANMATSU) // Tanmatsu: a Vol- LONG-press locks. Light the screen + build/show the lock-screen overlay (so the // wallpaper + clock are visible the moment you lock) and block app input (_manual_lock). Another // long Vol- press unlocks; the idle timeout still dims the backlight while locked. _manual_lock = true; _screen_off = false; setCpuForScreen(true); touchScreenBacklight(true); lockscreenShow(); _lock_lit_ms = millis(); // arm the burn-in guard — a lit lock screen always dims, even at timeout "never" _last_input_ms = millis(); return; #endif touchScreenBacklight(false); setCpuForScreen(false); // screen dark -> drop to 80 MHz _screen_off = true; _manual_lock = true; #if defined(HAS_TDECK_GT911) // T-Deck: build the lock screen overlay now (dark). A key/trackball press // reveals it; holding the trackball unlocks. (V4 keeps the plain off+wake.) lockscreenShow(); _lock_lit_ms = millis(); // arm the burn-in guard so a lit lock screen always dims (#55) #endif } void UITask::lockscreenReveal() { #if CAP_LOCK_SCREEN if (!_manual_lock) return; if (_screen_off) { lockscreenShow(); // build + foreground FIRST lv_refr_now(nullptr); // force it to actually paint before the backlight comes on setCpuForScreen(true); touchScreenBacklight(true); _screen_off = false; _lock_lit_ms = millis(); } else { lockscreenShow(); // already lit: just re-foreground (idempotent, cheap) } _last_input_ms = millis(); #endif } void UITask::unlockScreen() { _manual_lock = false; _screen_off = false; setCpuForScreen(true); touchScreenBacklight(true); #if CAP_LOCK_SCREEN lockscreenHide(); #endif _last_input_ms = millis(); } // Tanmatsu Vol- LONG-press handler: toggle the hard (overlay) screen lock. void UITask::toggleScreenLock() { if (_manual_lock) unlockScreen(); else lockScreen(); } // Tanmatsu Vol- SHORT-click / idle-dim: soft screen sleep — backlight off, NOT hard-locked, so any // key (or another Vol- click) wakes it. Like the V4 user button, minus the touch-block (no touch here). void UITask::sleepScreen() { if (_screen_off) return; touchScreenBacklight(false); setCpuForScreen(false); // screen dark -> drop to 80 MHz _screen_off = true; } uint16_t UITask::getScreenTimeoutSecs() const { #if defined(ESP32) return touchPrefsGetScreenTimeoutSecs(); #else return _screen_timeout_ms / 1000; #endif } bool UITask::setScreenTimeoutSecs(uint16_t seconds) { #if defined(ESP32) if (!touchPrefsSetScreenTimeoutSecs(seconds)) return false; #endif _screen_timeout_ms = static_cast<uint32_t>(seconds) * 1000u; _last_input_ms = millis(); return true; } bool UITask::sendAdvertNow() { return the_mesh.advert(); } bool UITask::sendAdvertFlood() { return the_mesh.sendAdvert(true); } bool UITask::sendAdvertZeroHop() { return the_mesh.sendAdvert(false); } // Signal probe: ask the nearest repeater to reply (a directed trace, never a flood) // so the live signal reading updates from a real link measurement. If no repeater // with a known path is available, fall back to a zero-hop advert (announce ourselves; // passive RX still updates the signal from any directly-heard neighbour). bool UITask::sendSignalProbe() { if (the_mesh.uiSendSignalProbe() != 0) return true; return the_mesh.sendAdvert(false); } // Two writers interleaving on the history file would corrupt it: wait out an // in-flight worker flush (bounded; worst observed SPIFFS GC ~6-8 s) and cancel // a pending one. Returns true if a pending flush was cancelled — its snapshot // was never written, so the caller must treat the ring as dirty and write it. static bool uiHistWaitWorkerIdle() { const bool cancelled = s_hist_flush_req; const uint8_t had_kind = s_segjob_kind; const uint32_t had_seq = s_segjob_first_seq; s_hist_flush_req = false; const uint32_t t0 = millis(); while (s_hist_flush_busy && (uint32_t)(millis() - t0) < 9000) delay(10); // Drop the worker descriptor UNCONDITIONALLY: the caller is about to write // synchronously from the ring, and leaving it armed let the next // flushHistoryIfDue observe a stale job and commit it a SECOND time // (double-counted record/byte totals, or a duplicate table entry). s_segjob_kind = SEGJOB_NONE; s_segjob_redirty = false; // If the job was already in the worker's hands its fate is now unknown — it // may have written some or all of its records without being credited. Flag // the target segment untrusted so the synchronous drain REWRITES it from the // ring instead of appending on top (which would duplicate whatever landed). if (had_kind != SEGJOB_NONE && !cancelled) { for (int i = 0; i < s_seg_count; ++i) if (s_seg[i].first_seq == had_seq) { s_seg[i].rewrite_open = 1; break; } } return cancelled; } bool UITask::enableBle() { if (!_serial) return false; #if defined(TLORA_PAGER) if (bleEnableWaitingForWifi()) { // Remember the request; main.cpp enables BLE when this bounded association // either succeeds or falls back. Wi-Fi intent without an active attempt // never enters this branch. wifiConfigSetBleEnabled(true); return false; } #endif _serial->enableBle(); // The concrete transport applies the heap guard only when it must cold-start // NimBLE. A resident disabled stack can always be re-enabled allocation-free. if (_serial->isBleEnabled()) return true; #if defined(TLORA_PAGER) // Same cold-start contract as Wi-Fi above. This is not an OOM failure the // user can repair by toggling the other radio: remember the requested state // and allocate NimBLE during the next ordered boot, before LVGL fragments // internal DRAM. rebootDevice() flushes pending history before ESP.restart(). wifiConfigSetBleEnabled(true); showAlert(TR("Restarting to enable Bluetooth"), 800); lv_refr_now(NULL); rebootDevice(); #endif return false; } void UITask::persistHistoryNow() { uiHistWaitWorkerIdle(); // writes below cover strictly newer data saveThreadsToStorage(); saveMsgsToStorage(); } void UITask::rebootDevice() { WdtHeavyGuard idle_wdt_guard; LoopWdtGuard loop_wdt_guard; // Persist chat history synchronously before we reboot — the periodic // flush is rate-capped, so without this a reboot could drop the most // recent chat history. // A worker still busy after the 9 s wait cap counts as unwritten data too: // its write may never land (stalled on a dying card), and _msgs_dirty was // already cleared when its snapshot was armed — skipping the sync save here // silently dropped everything since the last successful flush. if (uiHistWaitWorkerIdle() || s_hist_flush_busy) _msgs_dirty = true; if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty && !saveMsgsToStorage()) { // Last-chance save failed — say so (with the diagnosis) instead of // rebooting into silent loss. char why[64]; chatSaveFailText(why, sizeof why); char msg[128]; snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history save FAILED"), why); showAlert(msg, 1800); // console-aware: prints instead when there is no LVGL #if CAP_CONSOLE if (!s_console_mode) lv_refr_now(NULL); #else lv_refr_now(NULL); #endif delay(1500); // let the warning actually paint before the reset } discoveredFlushNow(); // persist the Discovered ring before we go down the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices) the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring touchPrefsFlush(); // finish queued A/B snapshots before reset if (_board) _board->reboot(); } #if CAP_CONSOLE && defined(ESP32) // The console's way back to the graphical UI (the `ui` / `exit` command). // // It must go through rebootDevice(), not ESP.restart(), and the reason is not // obvious: touchPrefsSetConsoleMode() only QUEUES an A/B snapshot. SdNvsPrefs // deliberately keeps filesystem I/O off the calling thread, and // touchPrefsFlush() -- which rebootDevice() calls last -- is what forces the // queued write out. Restarting directly discarded it, so the pref still said // "console" on the next boot and `ui` looked like it did nothing. // // Going through rebootDevice() also means the console exits on the same terms // as everything else: chat history, the Discovered ring and the sync replay // ring are all persisted first. void consoleHostRebootToUi() { touchPrefsSetConsoleMode(false); if (g_lv.task) { g_lv.task->rebootDevice(); return; } touchPrefsFlush(); // no UITask (should not happen): at least do not lose the pref ESP.restart(); } #endif void UITask::msgRead(int msgcount) { _msgcount = msgcount; } // ---- "At a glance" notification -------------------------------------------- // New, board-agnostic feature (distinct from the T-Deck's existing opt-in // "msgFlash", which fully wakes to the live app + pulses the keyboard // backlight): while the device is UNLOCKED but its screen has idle-dimmed to // off, an incoming message (DND permitting) briefly lights a plain black // overlay showing just the channel/sender name and the message text, then // dims back out on its own after a fixed window -- a glance, not a wake. // Never opens the chat, so the message stays unread, and whatever tab/chat // was open underneath is left exactly as it was. atGlanceShow() below only // builds/updates the overlay; UITask::newMsgImpl() decides whether to fire it // (and wakes the screen + arms the auto-hide window); UITask::loop() clears it. static lv_obj_t* s_glance_root = nullptr; static lv_obj_t* s_glance_title = nullptr; static lv_obj_t* s_glance_body = nullptr; static uint32_t s_glance_lit_ms = 0; // millis() when (re)shown, 0 = not showing -- loop() re-dims 5s later static bool s_glance_fading_out = false; // one-shot guard: the 200ms fade-out anim has already been started static void atGlanceOpaCb(void* var, int32_t v) { lv_obj_set_style_opa(static_cast<lv_obj_t*>(var), static_cast<lv_opa_t>(v), LV_PART_MAIN); } // Message-body font, built once: stock Montserrat 28 (ASCII) -> extras_lat_28 // (accented Latin, em-dash, ellipsis -- now compiled on every board, not just the // Tanmatsu; see extras_lat_28.c) -> extras_16 tail (Cyrillic/Greek/Arabic, baked at // 16 px so those scripts render a bit small relative to Latin text, but never a // missing-glyph box). 28, not 24: only 12/14/16/28 are actually enabled in this // project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself // is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER // widgets use, not something to repurpose for one feature. // // T-Deck experiment: 20px instead of 28px (LV_FONT_MONTSERRAT_20 + extras_lat_20 // widened for this board specifically, see lv_conf.h/extras_lat_20.c) -- trying // a smaller glance body on this panel. Revert by dropping this branch + those // two gate widenings. static lv_font_t s_glance_body_font; static bool s_glance_font_ready = false; static void atGlanceEnsureFont() { if (s_glance_font_ready) return; s_glance_font_ready = true; #if defined(HAS_TDECK_GT911) static lv_font_t s_lat20; s_lat20 = extras_lat_20; s_lat20.fallback = &extras_16; s_glance_body_font = lv_font_montserrat_20; s_glance_body_font.fallback = &s_lat20; #else static lv_font_t s_lat28; s_lat28 = extras_lat_28; s_lat28.fallback = &extras_16; s_glance_body_font = lv_font_montserrat_28; s_glance_body_font.fallback = &s_lat28; #endif } static void atGlanceHide() { if (s_glance_root) { // Only the TEXT fades (see atGlanceShow()) -- the root's own opa is never // touched, so nothing to reset on it. Just cancel any in-flight label fades. if (s_glance_title) { lv_anim_del(s_glance_title, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); } if (s_glance_body) { lv_anim_del(s_glance_body, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); } lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); } s_glance_lit_ms = 0; s_glance_fading_out = false; } // `fade_in`: true on the first reveal of a burst (screen was off), false when // a later message in the same burst just updates the text on an already-lit // overlay -- no need to re-fade something already fully visible. static void atGlanceShow(const char* title, const char* body, bool fade_in) { if (!g_lv.ready) return; if (!s_glance_root) { s_glance_root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(s_glance_root); lv_obj_set_style_bg_color(s_glance_root, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_glance_root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps -- no UI leak underneath // Never a keyboard/encoder nav target on any board: same reasoning as the // lock screen's NAV_SKIP_FLAG (see lockscreenShow()) -- a CLICKABLE // top-layer overlay with nothing to navigate to would otherwise be // collected as the sole focus target, and navFocusCb's focus-highlight // would invert its text to dark the instant the nav group (re)builds. lv_obj_add_flag(s_glance_root, NAV_SKIP_FLAG); // Small "eyebrow" label (channel/sender) above the message, in the app's // accent colour -- the message itself is the thing that needs to be // readable from a few feet away (desk-mounted device), so it gets the // dominant size/position below and this stays a secondary context cue. s_glance_title = lv_label_create(s_glance_root); lv_obj_set_style_text_font(s_glance_title, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(s_glance_title, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_width(s_glance_title, lv_pct(85)); lv_obj_set_style_text_align(s_glance_title, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); lv_label_set_long_mode(s_glance_title, LV_LABEL_LONG_DOT); // single line, ellipsize -- guaranteed to fit every board's width lv_obj_align(s_glance_title, LV_ALIGN_TOP_LEFT, 14, 30); // below the ~22 px status bar band (the real status bar renders above this overlay) // Message body: 28 px (bumped up from 16 -- 16 wasn't legible from a few // feet away on a desk-mounted device) via atGlanceEnsureFont()'s own // fallback chain, so arbitrary chat text (accents, em-dash, ellipsis) // still never shows a missing-glyph box at this size. atGlanceEnsureFont(); s_glance_body = lv_label_create(s_glance_root); lv_obj_set_style_text_font(s_glance_body, &s_glance_body_font, LV_PART_MAIN); lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN); lv_obj_set_width(s_glance_body, lv_pct(85)); lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); // LV_LABEL_LONG_DOT only wraps across multiple lines (dot-ellipsizing the LAST // one on overflow) when the label has a fixed HEIGHT, not just a fixed width -- // left at auto/content height (as this was), it sizes to exactly one line, so // every board was effectively single-line regardless of panel size (reported: // text cropped at the end of the first line instead of continuing on a second). // Give it the actual remaining space below this label's own y-offset down to a // small bottom margin, computed from this board's resolution + active body font, // so it uses exactly what's available -- more lines on tall panels, fewer on the // 222 px-tall Pager -- instead of a guessed fixed line count. { const lv_coord_t avail_h = lv_disp_get_ver_res(nullptr) - 60 - 10; const lv_coord_t line_h = lv_font_get_line_height(&s_glance_body_font); lv_obj_set_height(s_glance_body, avail_h > line_h ? avail_h : line_h); } lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12 } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); lv_obj_set_size(s_glance_root, sw, sh); lv_obj_set_pos(s_glance_root, 0, 0); lv_label_set_text(s_glance_title, title ? title : ""); lv_label_set_text(s_glance_body, body ? body : ""); // Cancel any fade-out already begun (by an earlier message in this burst) on // either label. lv_anim_del(s_glance_title, atGlanceOpaCb); lv_anim_del(s_glance_body, atGlanceOpaCb); lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_glance_root); if (fade_in) { // Only the TEXT fades in, never the root: the root's bg_opa is COVER (solid // black) from the moment it's created and stays that way, so the very first // frame -- forced-painted BEFORE the backlight comes on (see newMsgImpl()) -- // is already a clean black screen, not a transparent one letting the live // app screen show through underneath for a frame (reported: "I see the app // screen then I see the at-glance"). Fading the whole root's opa used to fade // the background along with the text, which is what caused that. lv_obj_set_style_opa(s_glance_title, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_opa(s_glance_body, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; for (lv_obj_t* t : targets) { lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, t); lv_anim_set_time(&a, 180); lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); lv_anim_set_exec_cb(&a, atGlanceOpaCb); lv_anim_start(&a); } } else { // Already visible -- just swap the text, no re-fade. lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); } } // Shared core for newMsg / newMsgFromPubWithMeta. Bundles the channel-vs-DM // sender parsing + thread routing in one place so the meta-aware path doesn't // drift from the plain path. `meta_flags` is 0 (no RX metadata) when called // from base newMsg; the touch UI ignores zero-meta entries in the Info popup. void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t meta_flags, int8_t snr_q4, int8_t rssi, const char* sender_override) { (void)path_len; _msgcount = msgcount; const bool have_sender_override = (sender_override && sender_override[0]); bool channel = (g_last_event == UIEventType::channelMessage); const char* thread = channel ? (from_name && from_name[0] ? from_name : "#unknown") : (from_name && from_name[0] ? from_name : "Unknown"); // For channel/group messages the on-wire `text` is "SenderName: body" — // split that so the bubble can show the sender as a small label and the // body as the message. DMs already arrive with the sender as from_name. // Parsed up here (before the chime) so a blocked sender can be dropped first. char parsed_sender[MAX_SENDER_NAME + 1]; parsed_sender[0] = '\0'; const char* body = text ? text : ""; if (channel && text && !have_sender_override) { const char* colon = strstr(text, ": "); if (colon) { int slen = static_cast<int>(colon - text); if (slen > 0 && slen <= MAX_SENDER_NAME) { strncpy(parsed_sender, text, slen); parsed_sender[slen] = '\0'; body = colon + 2; } } } const char* sender = have_sender_override ? sender_override : (channel ? (parsed_sender[0] ? parsed_sender : (from_name && from_name[0] ? from_name : "node")) : (from_name && from_name[0] ? from_name : "node")); #if defined(ESP32) // Blocked-by-name sender: a channel/room bot we have no pubkey to target via // the prefix ignore-list (room posts carry only a display name). Drop the post // entirely — no bubble, no notification, no chime. Set from the message // long-press "Block" when the sender isn't a saved contact (gubbinsgalore's // self-advertising room bot). // Also applies to ROOM posts: those arrive with channel == false but WITH a sender // override (the resolved author), and a room author can only ever be blocked by name // since the post carries no per-author pubkey. Without have_sender_override here, a // room name-block was stored but never enforced. A plain DM has neither flag set, so it // keeps using the pubkey filter and is unaffected — important, because a DM contact // sharing a display name with a blocked room bot must not vanish. if ((channel || have_sender_override) && touchPrefsIsNameIgnored(sender)) return; // Single-character spam filter (Settings > Radio & Mesh). A lot of the junk on the // mesh is a 1-byte payload, because that is the cheapest possible message to send // and it still costs every receiver a notification. Nobody sends one character on // purpose, so dropping them is safe in a way that a length threshold would not be. // // Tested on `body`, NOT on `text`: a channel post arrives on the wire as // "SenderName: body", so the raw string is never short even when the message is. // `body` is the parsed message for channels and the whole text for a DM. // // Placed with the block filters and BEFORE the chime below, so a dropped message // is silent too — a spam filter that still beeps has not filtered anything. if (touchPrefsGetIgnoreTinyMsgs()) { const char* b = body ? body : ""; while (*b == ' ' || *b == '\t' || *b == '\r' || *b == '\n') b++; // whitespace is not content size_t blen = strlen(b); while (blen > 0 && (b[blen-1] == ' ' || b[blen-1] == '\t' || b[blen-1] == '\r' || b[blen-1] == '\n')) blen--; if (blen <= 1) return; // 0 or 1 character of actual content } #endif #if CAP_LUA_SDK_EXT // Hand a running Lua app the incoming message (app.on_message), AFTER the block // and spam filters so an app sees exactly the messages the user does. Permission // is rechecked per message inside deliverMessage, so revoking in Settings stops // delivery at once rather than at the app's next launch. Channel traffic rides // LUA_PERM_READ; direct messages and room posts need the separate, more sensitive // LUA_PERM_DM_READ. "kind" lets an app tell them apart rather than inferring it. if (luaAppIsOpen()) { const char* kind = channel ? "channel" : (have_sender_override ? "room" : "dm"); luaAppMessage(kind, channel ? thread : "", sender, body); } #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) // Notification chime (T-Deck I2S speaker / Heltec V4 piezo / Tanmatsu I2S codec). // @-mentions get a distinct sound + their own enable, so an operator can mute message // sounds but still hear @-mentions. Each respects the per-channel mute flags. { const bool is_mention = channel && text && textMentionsMe(text); const bool is_dm = (g_last_event == UIEventType::contactMessage); // private/direct message const uint8_t cmute = channel ? touchPrefsGetChannelMute(thread) : 0; if (!isBuzzerQuiet() && !dndActive()) { // master Sound switch / Do Not Disturb: either silences everything if (is_mention && touchPrefsGetSoundMentions() && !(cmute & TOUCH_CHMUTE_MEN)) uiPlaySlot(TOUCH_SND_MEN); else if (is_dm) { if (touchPrefsGetSoundDirect()) uiPlaySlot(TOUCH_SND_DM); } else if (touchPrefsGetSoundMessages() && !(cmute & TOUCH_CHMUTE_MSG)) uiPlaySlot(TOUCH_SND_MSG); } } #endif #if defined(HAS_TOUCH_UI) // "At a glance": device unlocked but idle-dimmed to off -- light the plain // black glance overlay instead of the full app (see atGlanceShow() above). // Master-disabled via Options > Display > "At a glance" // (touchPrefsGetGlanceEnabled()) skips this entirely. Skipped if DND is on. // Manually locked is ALSO skipped unless the user opted in via Options > // Display > "At a glance while locked" (touchPrefsGetGlanceWhenLocked()) // -- off by default since it's a real trade-off (message text becomes // readable off a locked device). `_screen_off` covers the first message of // a burst (wake + show); `s_glance_lit_ms` (already showing, from an // earlier message in the same window) covers later ones -- update the text // and restart the 5 s window instead of dropping them, since our own wake // already cleared _screen_off by then. #if defined(ESP32) const bool glance_enabled_ok = touchPrefsGetGlanceEnabled(); const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked(); #else const bool glance_enabled_ok = true; const bool glance_locked_ok = !_manual_lock; #endif if (glance_enabled_ok && !dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { const bool was_off = _screen_off; atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst if (was_off) { lv_refr_now(nullptr); // paint before the backlight comes on -- no stale-frame flash if (_manual_lock) { // Locked: light the panel WITHOUT clearing the lock -- wakeScreen() // unconditionally clears _manual_lock, which would silently unlock the // device (touch/input live again) just to show a glance. Mirrors // lockscreenReveal()'s peek path instead: same fields, lock untouched. // The auto-hide re-dim below (UITask::loop()) is symmetric -- it never // touches _manual_lock either, so the device is exactly as locked after // the glance as before it. setCpuForScreen(true); touchScreenBacklight(true); _screen_off = false; // MUST bump this even though it's not real input: loop()'s generic idle // screen-timeout check fires on !_screen_off && (now - _last_input_ms) >= // _screen_timeout_ms regardless of _manual_lock -- leaving it stale (from // before the lock) made that check true on the very next loop() tick, // instantly flipping the backlight back off before the glance was ever // seen (reported: sound played but nothing showed). wakeScreen() bumps // this for the same reason; omitting it here was the bug. _last_input_ms = millis(); #if CAP_LOCK_SCREEN _lock_lit_ms = millis(); // keep the lock screen's own burn-in guard armed too #endif } else { wakeScreen(); } } s_glance_lit_ms = millis(); s_glance_fading_out = false; // a new message cancels any fade-out already begun } #endif // Inbound route (repeater hashes) for the Info popup — flood RX only; read // synchronously from MyMesh, which stashed it just before this call. uint8_t in_path[MAX_UI_PATH]; uint8_t in_path_n = 0; uint16_t in_scope = 0; if (meta_flags & MSG_META_HAS_RX) { // Route hashes only exist on floods, but the transport SCOPE also rides scoped DIRECT // messages (the "Scope direct msgs to region" feature) -- reading it only under IS_FLOOD // meant a scoped DM's Info popup showed no scope at all (#157). if (meta_flags & MSG_META_IS_FLOOD) in_path_n = the_mesh.lastRxPath(in_path, sizeof(in_path)); bool has_scope = false; in_scope = the_mesh.lastRxScope(&has_scope); if (has_scope) { meta_flags |= MSG_META_HAS_SCOPE; // Verified at RX (the check needs the packet); the popup only reads the bit. if (the_mesh.lastRxScopeIsHome()) meta_flags |= MSG_META_SCOPE_HOME; // Which registered region it verified against (#271), also decided at RX // for the same reason: the packet is gone by the time the popup opens, so // the answer has to be captured here and persisted with the message. meta_flags = metaWithScopeSlot(meta_flags, the_mesh.lastRxScopeSlot()); } } const int msg_slot = _ui_msg_head; // appendMessage writes _ui_msgs[head], then advances head appendMessage(thread, sender, body, channel, false, true, 0 /*ack_hash*/, DELIV_NONE, meta_flags, path_len, snr_q4, rssi, in_path_n ? in_path : nullptr, in_path_n, 0 /*sent_fp*/, in_scope); // Stamp the bubble with the sender's embedded send-time (stashed by MyMesh:: // queueMessage) instead of "now" — fixes room-server history replay showing the // whole batch at the delivery time. Only override with a real epoch; live DMs // (embedded ts ≈ now) are unaffected. Consume-once so it never leaks to the next. { uint32_t emb_ts = the_mesh.uiConsumeLastSenderTs(); // Sanity WINDOW, not just a lower bound: a peer with a garbage-future clock sends e.g. // ~2.42e9, which as a signed 32-bit time_t wraps to 1910-09-03 06:42:07 (GH #100/#152). // Same window as MyMesh.cpp's advert-time guard (>1.7e9 && <2.0e9). if (emb_ts > 1700000000 && emb_ts < 2000000000) _ui_msgs[msg_slot].ts = emb_ts; } syncThreadMeshSlots(thread, channel); #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) // Mirror incoming traffic into the terminal live feed (only while it's open). // Runs on the mesh thread (core 1, same as the UI loop) so the append is safe. // M9 included: its terminal (and the chat mode's "to <name>"/"send") is fully // wired, so without the mirror a console conversation showed only the TX side. if (s_term_log_box) { char line[200]; const bool has_snr = (meta_flags & MSG_META_HAS_RX); const float snr = snr_q4 / 4.0f; if (channel) { if (has_snr) snprintf(line, sizeof line, "RX [%s] %s: %s (snr %.1f)", thread, sender, body, snr); else snprintf(line, sizeof line, "RX [%s] %s: %s", thread, sender, body); termLogAppendC(TERM_C_RX_CH, nullptr, line); } else { if (has_snr) snprintf(line, sizeof line, "RX %s: %s (snr %.1f)", sender, body, snr); else snprintf(line, sizeof line, "RX %s: %s", sender, body); termLogAppendC(TERM_C_RX_DM, nullptr, line); } } #endif #if CAP_CONSOLE // The monitor: show it as it arrives, without opening anything. Deliberately // placed AFTER appendMessage, which was called with mark_unread = true, so the // message is stored and still counts as unread. Watching it scroll past is not // reading it; opening the thread is. if (s_console_mode && touchPrefsGetConsoleMonitor()) { char line[220]; // Three colours, because the three parts answer different questions: // WHERE it arrived (channel, cyan), WHO said it (sender, yellow) and WHAT // they said (grey). A DM has no channel, so its sender takes the magenta // slot instead and stands out in a busy feed. int n1 = 0, n2 = 0; if (channel) { n1 = snprintf(line, sizeof line, "#%s ", thread); if (n1 < 0) n1 = 0; n2 = snprintf(line + n1, sizeof line - n1, "%s: ", sender); if (n2 < 0) n2 = 0; } else { n2 = snprintf(line, sizeof line, "%s: ", sender); if (n2 < 0) n2 = 0; } snprintf(line + n1 + n2, sizeof line - n1 - n2, "%s", body ? body : ""); consoleWriteLineSeg(CC_CHAN, n1, channel ? CC_SENDER : CC_DM, n2, line); } #endif #if defined(HAS_TOUCH_UI) g_lv.dirty_threads = true; g_lv.dirty_timeline = true; #endif } void UITask::newMsgFromPub(uint8_t path_len, const uint8_t* from_pub, const char* from_name, const char* text, int msgcount) { if (from_pub && touchPrefsIsIgnored(from_pub)) return; // blocked sender — drop (no chat entry, no notify) newMsg(path_len, from_name, text, msgcount); if (!from_pub || !from_name || !from_name[0]) return; const int t = findThreadByName(from_name, false); if (t < 0) return; _ui_threads[t].mesh_contact_idx = -1; memcpy(_ui_threads[t].mesh_contact_pub, from_pub, PUB_KEY_SIZE); memcpy(_ui_threads[t].mesh_contact_key6, from_pub, 6); if (!_ui_threads[t].channel && t == _active_thread_idx) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, from_pub, sizeof(_active_dm_contact_pub)); } syncThreadMeshSlots(from_name, false); } void UITask::newMsgFromPubWithMeta(uint8_t path_len, bool is_flood, const uint8_t* from_pub, const char* from_name, const char* text, int msgcount, int8_t snr_q4, int8_t rssi) { if (from_pub && touchPrefsIsIgnored(from_pub)) return; // blocked sender — drop (no chat entry, no notify) const uint8_t meta_flags = MSG_META_HAS_RX | (is_flood ? MSG_META_IS_FLOOD : 0); newMsgImpl(path_len, from_name, text, msgcount, meta_flags, snr_q4, rssi); // Mirror newMsgFromPub's contact-pub plumbing so the newly-arrived DM // resolves back to its contact entry for replies. if (!from_pub || !from_name || !from_name[0]) return; const int t = findThreadByName(from_name, false); if (t < 0) return; _ui_threads[t].mesh_contact_idx = -1; memcpy(_ui_threads[t].mesh_contact_pub, from_pub, PUB_KEY_SIZE); memcpy(_ui_threads[t].mesh_contact_key6, from_pub, 6); if (!_ui_threads[t].channel && t == _active_thread_idx) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, from_pub, sizeof(_active_dm_contact_pub)); } syncThreadMeshSlots(from_name, false); } void UITask::newRoomMsgFromPubWithMeta(uint8_t path_len, bool is_flood, const uint8_t* from_pub, const char* from_name, const char* author_name, const char* text, int msgcount, int8_t snr_q4, int8_t rssi) { if (from_pub && touchPrefsIsIgnored(from_pub)) return; // blocked room — drop const uint8_t meta_flags = MSG_META_HAS_RX | (is_flood ? MSG_META_IS_FLOOD : 0); // The thread is the room (from_name); the per-message sender label is the // resolved author. g_last_event is roomMessage here, so newMsgImpl keeps the // thread DM-style (channel=false) — matching the thread the user opens by // tapping the room contact — while the timeline shows the author label for // room threads (thread_is_room). newMsgImpl(path_len, from_name, text, msgcount, meta_flags, snr_q4, rssi, author_name); // Map the room thread back to the room contact so replies/login resolve. if (!from_pub || !from_name || !from_name[0]) return; const int t = findThreadByName(from_name, false); if (t < 0) return; _ui_threads[t].mesh_contact_idx = -1; memcpy(_ui_threads[t].mesh_contact_pub, from_pub, PUB_KEY_SIZE); memcpy(_ui_threads[t].mesh_contact_key6, from_pub, 6); if (!_ui_threads[t].channel && t == _active_thread_idx) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, from_pub, sizeof(_active_dm_contact_pub)); } syncThreadMeshSlots(from_name, false); } void UITask::appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text, uint32_t ack_hash, uint32_t sent_fp) { if (!to_name || !to_name[0] || !text || !text[0]) return; const char* sender = (_node_prefs && _node_prefs->node_name[0]) ? _node_prefs->node_name : "me"; // Mirror an app-originated DM as a local outgoing bubble in the recipient's thread (#46) — the // on-device UI is otherwise the only message consumer that never sees sends made from the // companion app. appendMessage creates the thread if it doesn't exist and refreshes the view. // sent_fp links a locally-originated send (web/terminal) to the repeats-heard ring (0 = none). appendMessage(to_name, sender, text, false /*channel*/, true /*outgoing*/, false /*mark_unread*/, ack_hash, DELIV_SENT, 0, 0, 0, 0, nullptr, 0, sent_fp); // Lock the thread to the contact's pubkey so a reply typed on-device resolves it (mirrors the // receive path above). const int t = findThreadByName(to_name, false); if (t < 0 || !to_pub) return; _ui_threads[t].mesh_contact_idx = -1; memcpy(_ui_threads[t].mesh_contact_pub, to_pub, PUB_KEY_SIZE); memcpy(_ui_threads[t].mesh_contact_key6, to_pub, 6); if (!_ui_threads[t].channel && t == _active_thread_idx) { _active_dm_contact_set = true; memcpy(_active_dm_contact_pub, to_pub, sizeof(_active_dm_contact_pub)); } syncThreadMeshSlots(to_name, false); } void UITask::appSentMsgToChannel(const char* channel_name, const char* text, uint32_t sent_fp) { if (!channel_name || !channel_name[0] || !text || !text[0]) return; const char* sender = (_node_prefs && _node_prefs->node_name[0]) ? _node_prefs->node_name : "me"; // Mirror an app-originated channel message as a local outgoing bubble in the channel thread — the // channel-send command otherwise never tells the on-device UI about companion sends (the DM path // does, via appSentMsgToContact above). appendMessage creates the thread + refreshes the view. // sent_fp links a locally-originated send (web/terminal) to the repeats-heard ring (0 = none). appendMessage(channel_name, sender, text, true /*channel*/, true /*outgoing*/, false /*mark_unread*/, 0 /*ack_hash*/, DELIV_SENT, 0, 0, 0, 0, nullptr, 0, sent_fp); } void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { newMsgImpl(path_len, from_name, text, msgcount, 0 /*meta_flags*/, 0 /*snr*/, 0 /*rssi*/); } void UITask::notify(UIEventType t) { g_last_event = t; // A discovered/added contact should refresh the Contacts list even mid-frame and // even while a companion app is connected (issue #73). Flag it; UITask::loop does // the rebuild on the LVGL thread (refreshContactsList no-op's when nothing changed). if (t == UIEventType::newContactMessage) s_ct_contacts_dirty = true; const char* msg = nullptr; switch (t) { case UIEventType::contactMessage: msg = TR("New DM"); break; case UIEventType::channelMessage: msg = TR("New channel msg"); break; case UIEventType::roomMessage: msg = TR("New room msg"); break; case UIEventType::newContactMessage: msg = TR("New contact"); break; case UIEventType::ack: showAlert(TR("Delivered"), 900); return; default: return; } #if defined(HAS_TANMATSU) // Pulse the envelope-icon LED for an actual incoming message (contact discovery is too spammy). if (t == UIEventType::contactMessage || t == UIEventType::channelMessage || t == UIEventType::roomMessage) msgLedFlash(); #endif #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Same idea on the T-Deck and M9 (no notification LED): wake the screen + briefly light the keyboard. if (touchPrefsGetMsgFlash() && (t == UIEventType::contactMessage || t == UIEventType::channelMessage || t == UIEventType::roomMessage)) { s_msgflash_until = millis() + 1600; s_msgflash_wake = true; } #endif // Background traffic (also reflected in the tab badges): show a subtle, low-key // chip rather than the prominent centre alert toast, so it's less intrusive. // New-contact discovery can be spammy in a busy mesh, so its chip is opt-out in // Settings -> Auto-add; when suppressed we still log the diag line + tab badge. const bool toast = (t != UIEventType::newContactMessage) || touchPrefsGetNewContactToast(); if (toast) { strncpy(_alert, msg, sizeof(_alert) - 1); _alert[sizeof(_alert) - 1] = '\0'; _alert_expiry = millis() + 1100UL; } #if defined(HAS_TOUCH_UI) pushDiagLine(msg); if (toast) showSubtleNotifyLvgl(msg, 1100); #endif } // ============================================================ // UITask::loop // ============================================================ // ---- beta_31 field-freeze tracer (diagnostics only, no behaviour change) ---- // Any instrumented main-loop section that blocks >200 ms lands in this ring, // printed to Serial as [STALL] and listed at the bottom of Settings -> About so // a field tester can simply photograph the screen. uiCp() checkpoints attribute // a slow UITask::loop pass to the section that actually ate the time. StallRec g_stall_ring[16]; // struct defined above sysInfoText (About shows the ring) uint8_t g_stall_cnt = 0, g_stall_w = 0; void stallLog(const char* tag, uint32_t dur_ms) { if (dur_ms < 200) return; g_stall_ring[g_stall_w] = { (uint32_t)(millis() / 1000u), (uint16_t)(dur_ms > 65535 ? 65535 : dur_ms), tag }; g_stall_w = (uint8_t)((g_stall_w + 1) % 16); if (g_stall_cnt < 16) g_stall_cnt++; Serial.printf("[STALL] %s %lums\n", tag, (unsigned long)dur_ms); } const char* g_ui_stall_tag = ""; // slowest section of the current UITask::loop pass uint16_t g_ui_stall_max = 0; static const char* s_ui_cp_tag = ""; static uint32_t s_ui_cp_t0 = 0; static inline void uiCp(const char* next) { const uint32_t nowms = millis(); const uint32_t dt = nowms - s_ui_cp_t0; if (dt > g_ui_stall_max) { g_ui_stall_max = (uint16_t)(dt > 65535 ? 65535 : dt); g_ui_stall_tag = s_ui_cp_tag; } s_ui_cp_tag = next; s_ui_cp_t0 = nowms; } #if CAP_SD || defined(TLORA_PAGER) // A mount lifecycle change invalidates every open FAT handle, so it must wait // until all UI-side and worker-side SD consumers are quiescent. SPI transaction // locking arbitrates individual transfers; this predicate arbitrates VFS lifetime. static bool sdRuntimeLifecycleBusy() { bool busy = s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request || s_sdinfo_busy || touchPrefsIoBusy(); #if defined(MULTI_TRANSPORT_COMPANION) // The web reader and the Lua audio player are independent SD consumers, so // both gate the mount lifecycle. The reader excludes ITSELF: it calls this // from its own task while holding the card, and would otherwise deadlock. const bool reader_is_caller = s_reader_sd_busy && s_reader_sd_owner == xTaskGetCurrentTaskHandle(); busy = busy || (s_reader_sd_busy && !reader_is_caller); #endif #if CAP_LUA_AUDIO busy = busy || luaAudioStorageBusy(); #endif #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) busy = busy || s_notify_playing; #endif #if CAP_SD || defined(TLORA_PAGER) // Before a lifecycle owner requests a backend pause, keep the conservative // queued+active gate used by format/reset paths. Once the pause is asserted, // no queued request can acquire a new lease, so only the current lease owns // the VFS; the preserved backlog is not a reason to delay SD.end(). if (s_tile_fs == &SD) { busy = busy || (tileBackendSwapRequested() ? tileFetchWorkerActive() : tileFetchPendingLoad() > 0); } #endif return busy; } // Runtime SD wedge detect + recover. Some writer saw an SD write fail while the // card was supposedly mounted (sdNoteIoFailure) — verify with real I/O and, // if the card stopped answering, drop and remount it once all SD consumers are // quiescent. Without this the firmware is blind to a wedged card: // SD.cardType() keeps returning its cached mount-time type, s_sd_mounted stays // true so fmSdTryMount() no-ops, and every SD feature fails silently until the // user power-cycles (the "SD stops working until reboot" report). static void sdHealthTick() { static uint32_t s_next_probe_ms = 0; static uint32_t s_next_bg_probe_ms = 0; #if defined(TLORA_PAGER) static uint32_t s_next_detect_ms = 0; static uint32_t s_absent_first_ms = 0; static uint8_t s_absent_samples = 0; #endif const uint32_t now = (uint32_t)millis(); #if defined(TLORA_PAGER) if (!s_sd_mounted && !sdRuntimeLifecycleBusy() && sdAdoptLiveMount()) { s_sd_data_warn_next_ms = 0; if (!s_ui_data_fs) uiDataFsReady(); if (s_ui_data_fs == &SD) { SD.mkdir("/meshcomod"); uiDataEnsureDirs(); if (g_lv.task) g_lv.task->flushHistorySoon(); } mapNoteStorageChanged(); return; } #endif #if CAP_SD || defined(TLORA_PAGER) // A teardown may have completed while the tile worker still held its final // File. Defer dropping the stale SD backend until that request is finished; // this also closes the failed-format path below. if (!s_sd_mounted && s_tile_fs == &SD && !tileFetchWorkerActive()) { mapNoteStorageChanged(); } #endif if (!s_sd_mounted) { #if defined(TLORA_PAGER) // Another loop-side owner (notably the open File Manager) may have won the // quiescent unmount race after card-detect marked removal pending. s_pager_sd_removal_pending = false; #endif s_sd_fail_note_ms = 0; #if defined(TLORA_PAGER) extern bool g_contacts_on_sd; if (g_contacts_on_sd && !touchSleep::isSleeping() && (!s_sd_data_warn_next_ms || (int32_t)(now - s_sd_data_warn_next_ms) >= 0)) { s_sd_data_warn_next_ms = sdDataWarnDeadline(now); if (g_lv.task) g_lv.task->showAlert(sdRemovedAlertText(), 5000); } #endif // Reinsert watch. After a failed remount ("SD card lost") the SD VFS is // unregistered and NOTHING outside the file manager ever re-runs a mount // — a reinserted card then LOOKED recovered (battery quietly falls back // to SPIFFS) while chat-history writes kept failing silently and new // messages were never persisted. One light single-attempt mount (no // ladder; an empty slot fails in well under a second) every 30 s while // awake — a healthy warm card mounts on the first 4 MHz attempt. The FM // poll / SD-row tap still provide the full ladder for stubborn cards. if (touchSleep::isSleeping()) return; if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; s_next_bg_probe_ms = now + 30000; if (s_fm_list) return; // FM open: its 2 s poll owns remounting if (sdRuntimeLifecycleBusy()) return; #if defined(TLORA_PAGER) if (!board.sdCardPresent()) return; // card-detect says the slot is empty #endif SPIClass* spi = sdSharedSPI(); if (!spi) return; #if defined(TLORA_PAGER) // The Pager shares this SPIClass with display + radio: one 4 MHz attempt, // with every other CS parked HIGH and no SD.end() of a potentially live bus. const bool begin_ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); const bool remounted = begin_ok && SD.cardType() != CARD_NONE; #else SD.end(); bool remounted = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE; if (remounted) { const uint32_t hz = sdTryFastClock(PIN_SD_CS, *spi, 4000000, "SD"); // no-op unless SD_SPI_FAST_HZ remounted = hz != 0; if (remounted) g_sd_operating_hz = hz; } #endif if (remounted) { s_sd_mounted = true; s_sd_size = SD.cardSize(); s_sd_retry_after_ms = 0; #if defined(TLORA_PAGER) s_sd_data_warn_next_ms = 0; #endif markSdIo(); if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Land the RAM ring on the card promptly, not up to 30+ s later: every // message received while the card was out is only in RAM. Armed as an // OFF-THREAD flush — a synchronous write here froze the UI for >30 s on // a card that mounts fine but times out on large writes. mkdir first — // a fresh replacement card has no /meshcomod yet, and without it every // history flush fails silently. if (!s_ui_data_fs) uiDataFsReady(); if (s_ui_data_fs == &SD) { SD.mkdir("/meshcomod"); uiDataEnsureDirs(); // segment dir too — a fresh replacement card has neither if (g_lv.task) g_lv.task->flushHistorySoon(); } #endif mapNoteStorageChanged(); // the map layer has to be told too, or it stays blank } else { #if defined(TLORA_PAGER) if (begin_ok) SD.end(); // release only our failed, quiescent mount sdPagerParkCs(); #else SD.end(); // leave cardType() honestly CARD_NONE #endif } return; } if (s_sd_format_pending) return; // format owns the card right now #if CAP_SD || defined(TLORA_PAGER) // Reinsertion may have completed while an internal-cache fetch was still // active. mapNoteStorageChanged deliberately deferred the backend swap; make // it effective at the first idle tick without invalidating that open File. // Skip it while card-detect has already confirmed removal: s_sd_mounted stays // true until the teardown below wins quiescence, so adopting here would point // the backend at a card that is physically gone and burn two full map // re-renders on the loop task before the same tick undoes it. bool sd_leaving = false; #if defined(TLORA_PAGER) sd_leaving = s_pager_sd_removal_pending; #endif if (!sd_leaving && s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready) && s_tile_fs != &SD && !tileFetchWorkerActive()) { mapNoteStorageChanged(); } #endif #if defined(TLORA_PAGER) // Card detect is pure I2C, so it must keep running even while an SD worker is // busy. Once removal is confirmed, pause the worker at a request boundary; // the normal lifecycle gate then waits only for the genuinely in-flight // operation before SD.end(), preserving the queued backlog. if (!s_pager_sd_removal_pending && (int32_t)(now - s_next_detect_ms) >= 0) { s_next_detect_ms = now + 500; const TLoraPagerBoard::SdCardState state = board.sdCardState(); if (state != TLoraPagerBoard::SdCardState::Absent) { // Present resets the debounce. Unknown also resets it so an I2C fault can // never be accumulated into a destructive VFS teardown. s_absent_samples = 0; s_absent_first_ms = 0; } else if (!s_absent_samples || (uint32_t)(now - s_absent_first_ms) > 1100) { s_absent_samples = 1; s_absent_first_ms = now; } else if (++s_absent_samples >= 2) { s_absent_samples = 0; s_absent_first_ms = 0; s_pager_sd_removal_pending = true; // Stop the worker at the next request boundary. Its current File may // finish, but the queued backlog stays intact while SD.end() and the // fallback selection run below. tileBackendSwapTryBegin(); sdNoteIoFailure(); } } if (s_pager_sd_removal_pending) { if (sdRuntimeLifecycleBusy()) return; fmSdUnmount(); s_pager_sd_removal_pending = false; mapNoteStorageChanged(); s_sd_data_warn_next_ms = sdDataWarnDeadline(now); if (!touchSleep::isSleeping() && g_lv.task) g_lv.task->showAlert(sdRemovedAlertText(), 5000); if (s_fm_list && (s_fm_fs == &SD || !s_fm_fs)) fmShowRoots(); return; } #endif #if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA if (s_sdfw_request) return; // worker streaming a firmware download to /BINS #endif // Probe when some SD user flagged a failure (rate-limited to 1/5 s), and // also on a slow 30 s background cadence — a card yanked while nothing is // writing (idle home screen) would otherwise never be noticed at all, since // detection is failure-driven and reads fall back silently. A healthy-card // probe is one SEND_STATUS transaction (sub-ms), so the idle cost is nil. // The background probe pauses in the sleep regime (screen off, loop // throttled): nobody sees the toast, an SD poke keeps the card out of its // own power-down, and a yank-while-asleep is caught by the first probe // after wake. The FAILURE-driven probe stays active asleep on purpose — // scheduled telemetry keeps logging to SD during sleep, and a wedge there // must still recover. if (s_sd_fail_note_ms) { if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; } else { if (touchSleep::isSleeping()) return; if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; } // This tick is going to probe and may invalidate the VFS. Assert the backend // pause before the lifecycle-busy check, so queued requests cannot repeatedly // win the boundary while recovery waits. Other SD consumers still drain first. tileBackendSwapTryBegin(); if (sdRuntimeLifecycleBusy()) return; s_next_probe_ms = now + 5000; s_next_bg_probe_ms = now + 30000; s_sd_fail_note_ms = 0; if (sdProbeAlive()) { // Transient failure (card full, bad path, ...) — keep the mount and let the // preserved tile queue continue on it. #if defined(MULTI_TRANSPORT_COMPANION) && defined(HAS_THINKNODE_M9) // The note window blanked every SD tile read (loadTileJpeg bails on it) — // arm the rate-capped map repaint so pan gaps heal without a keypress. if (s_map_last_missing > 0) s_tile_fetch_dirty = true; #endif tileBackendSwapFinish(); return; } fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake if (fmSdTryMount()) { #if defined(TLORA_PAGER) s_sd_data_warn_next_ms = 0; #endif if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (!s_ui_data_fs) uiDataFsReady(); if (s_ui_data_fs == &SD) { SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root uiDataEnsureDirs(); // segment dir too if (g_lv.task) g_lv.task->flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) } #endif mapNoteStorageChanged(); } else { // Remount failed — card needs a real power cycle (reinsert). s_sd_mounted is // now false and SD.cardType() reads CARD_NONE, so features degrade honestly // instead of silently failing, and the usual remount paths keep retrying. #if defined(TLORA_PAGER) s_sd_data_warn_next_ms = sdDataWarnDeadline(now); if (g_lv.task) g_lv.task->showAlert(sdRemovedAlertText(), 5000); #else if (g_lv.task) g_lv.task->showAlert(TR("SD card lost - reinsert to recover"), 2600); #endif mapNoteStorageChanged(); // drop tiles read off the card that just went away } } #endif void UITask::loop() { unsigned long now = millis(); #if CAP_CONSOLE if (s_console_mode) { #if CAP_TOUCH // Bring up the input hardware ourselves. The graphical path does this far // below, behind `if (!g_lv.ready) return;` — and g_lv.ready is false here // because LVGL is never initialised, so nothing ever started the poll task // and the keyboard was never even begun. That is why keys did nothing. // // One call covers both: the background poll task owns the shared I2C bus // and scans the touch panel AND (on the T-Deck) the keyboard, which is // exactly why they must not be polled from two places at once. static bool s_con_input_up = false; if (!s_con_input_up) { if (heltecV4CapTouchBegin()) { heltecV4CapTouchStartBackgroundPoll(8); s_con_input_up = true; } } #endif // The physical-keyboard drain lives further down this function, below this // return, so console mode has to do its own. Same ring, filled by the task // started above. bool con_activity = false; #if defined(HAS_TDECK_KEYBOARD) if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers(); else tdeckKeyboardAllowModifiers(); for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; con_activity = true; s_kb_last_key_ms = now; // A key pressed on a dark screen WAKES rather than types, the same as a // touch does in the UI. Otherwise the character you used to see the screen // ends up in the command you are typing. if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; } consoleKey(key); } #endif #if CAP_TOUCH { uint16_t _tx, _ty; if (heltecV4CapTouchGetLive(&_tx, &_ty)) { con_activity = true; } } #endif #if defined(HAS_TDECK_TRACKBALL) // Trackball scrolls the scrollback. Roll up for history, down for live. // Accumulated because the ball reports many small deltas per physical flick; // one line per detent would race past everything. { int tdx = 0, tdy = 0; if (tdeckTrackballReadMotion(&tdx, &tdy) && tdy != 0) { static int tb_accum = 0; tb_accum += tdy; while (tb_accum <= -2) { consoleScroll(+1); tb_accum += 2; } // up = back while (tb_accum >= 2) { consoleScroll(-1); tb_accum -= 2; } // down = live con_activity = true; if (_screen_off) wakeScreen(); } } #endif if (con_activity) _last_input_ms = now; // Screen timeout. The graphical path does this far below, so without it a // console stayed lit forever: battery drain, and burn-in on the panels that // suffer from it. Same pref, same helpers. if (!_screen_off && _screen_timeout_ms > 0 && (uint32_t)(now - _last_input_ms) >= _screen_timeout_ms) { touchScreenBacklight(false); setCpuForScreen(false); // idle -> 80 MHz, as the UI does _screen_off = true; } #if defined(HAS_TDECK_KEYBOARD) // Keyboard backlight: off / on / auto, and dark whenever the screen is. { uint8_t kb_bl = 0; if (s_kb_bl_mode == 1) kb_bl = tdeckKbBlLevel(); else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = tdeckKbBlLevel(); if (_screen_off) kb_bl = 0; tdeckKeyboardSetBacklight(kb_bl); } #endif // Nothing to draw while the panel is dark; skip the render entirely. if (!_screen_off) consoleLoop(); return; // the graphical loop is never entered } #endif #if defined(ESP32) // Snapshot copying is quick; all filesystem I/O runs on the core-0 worker. touchPrefsTick(now); // One-shot (#178): contacts starred on-device before the flags mirror // existed have their favorite only in TouchPrefs — invisible to the core's // overwrite-oldest eviction. Stamp flags bit 0 once per boot, after the // mesh + prefs are both up. Saves only when something actually changed. { static bool s_fav_synced = false; if (!s_fav_synced && now > 9000) { s_fav_synced = true; bool changed = false; const int n = the_mesh.getNumContacts(); for (int i = 0; i < n; ++i) { ContactInfo c; if (!the_mesh.getContactByIdx(i, c)) continue; if ((c.flags & 0x01) == 0 && touchPrefsIsFavorite(c.id.pub_key)) changed |= the_mesh.uiSetContactFavorite(c.id.pub_key, true); } if (changed) Serial.println("[UI] favorite flags synced into contact table (#178)"); } } #endif #if !defined(HAS_TANMATSU) // REMOTE mode: draw/refresh the physical-panel placeholder (first pass via the IP // sentinel, then whenever the IP changes), and clear the bootloop guard once this // boot has run a few seconds (proving it came up cleanly). if (s_remote_mode) { if (s_rmt_boot_guard && now > 8000) s_rmt_boot_guard = 0; // Rotate button on the web page -> flip orientation by rebooting into the other axis. if (uint8_t oreq = g_web_mirror.takeOrient()) { const bool want_land = (oreq == 1); if (want_land != s_remote_landscape) { touchPrefsSetRemoteLandscape(want_land); if (g_lv.task) { g_lv.task->showAlert(TR("Rotating - reconnecting..."), 1600); g_lv.task->rebootDevice(); } } } if (s_remote_exit_armed && (now - s_remote_exit_armed) >= 3000) { s_remote_exit_armed = 0; drawRemotePlaceholder(false); } // Exit button on the web page -> leave remote mode (board-agnostic; the main way out // for keyboard-less boards like the Heltec V4). if (g_web_mirror.takeExit()) exitRemoteModeNow(); #if CAP_TOUCH remoteTouchTick(); // 3 s touch-and-hold on the panel also leaves remote (no SPACE key) #endif uint32_t rip = (WiFi.status() == WL_CONNECTED) ? (uint32_t)WiFi.localIP() : 0; if (rip != s_remote_ph_ip) drawRemotePlaceholder(); } // Web mesh terminal: run any command the browser typed through the exact same dispatch // as the on-device console (chat commands else the config CLI). Output streams back via // the termLogAppendC hook. Own the CLI sink while active + the on-device console isn't open. if (g_web_mirror.terminalOn() && s_term_log_box == nullptr) { MyMesh::setTerminalSink(&terminalSink); // idempotent; routes config-CLI/async replies to the web char tcmd[256]; while (g_web_mirror.popTermCmd(tcmd, sizeof tcmd)) { // '@'-prefixed lines are the Contacts/Chats data API (JSON back over the data channel); // everything else is a terminal command (browser echoes it locally, so don't echo back). if (handleWebDataCmd(tcmd)) continue; if (!terminalRunChatCommand(tcmd)) the_mesh.runLocalCli(tcmd); } // Live receive: a mesh callback flagged new/changed messages -> nudge the web client (single-producer here). if (s_web_rx_nudge) { s_web_rx_nudge = false; g_web_mirror.pushTermData("{\"t\":\"rx\"}"); } // Periodic status bar refresh (battery / wifi / clock / signal / unread) for the web header. static uint32_t s_web_status_next = 0; if ((int32_t)(millis() - s_web_status_next) >= 0) { s_web_status_next = millis() + 3000; webPushStatus(); } } #endif g_ui_stall_max = 0; g_ui_stall_tag = ""; s_ui_cp_tag = "ui:head"; s_ui_cp_t0 = now; #if defined(ESP32) // Persist the clock floor every 15 min (coalesced into the next prefs snapshot). // Power loss costs at most this window of floor progress — a soft reset keeps the // ESP32 RTC domain ticking, so only true power-off needs the persisted copy. { static unsigned long s_floor_due = 0; if (now >= s_floor_due) { s_floor_due = now + 15UL * 60UL * 1000UL; touchPrefsSetClockFloor(rtc_clock.getFloor()); // no-op unless it grew } } #endif #if defined(DOC_CAPTURE) // One-shot: once the boot splash is gone, wait for the host to send 'G' (scripts/doc/capture.py), // then walk every screen and stream each framebuffer over USB. The 'G' handshake avoids a // flash-vs-connect timing race — the host can attach whenever and trigger the tour. { static bool s_doc_done = false; if (!s_doc_done && s_splash_root == nullptr) { while (Serial.available()) { if (Serial.read() == 'G') { s_doc_done = true; docCaptureTour(); break; } } } } #endif // Safety net: the Spectrum app borrows the radio (s_spectrum_active pauses // the_mesh.loop() in main.cpp). Its close paths all restore + clear the flag, // but if the page ever vanished without that, the mesh would stay off the // radio forever — so re-apply the mesh params and release ownership here. if (s_spectrum_active && !s_spec_root) spectrumRestoreRadio(); uiCp("ui:hist"); flushHistoryIfDue(now); uiCp("ui:ctdirty"); // A contact was discovered/updated via an advert — possibly while a companion app was connected // over BLE (issue #73). FORCE the rebuild: an advert can fill in a name, or refresh an existing // (or app-added) contact, WITHOUT changing getNumContacts() — which the count-cached // refreshContactsList() would no-op, leaving the node invisible until a manual re-sort (exactly the // "only shows when I sort by messages received" report). Keep the flag set until we're actually on // the Contacts tab so an advert heard on another tab isn't dropped, and coalesce an advert flood to // at most one rebuild per ~350 ms. if (s_ct_contacts_dirty && getActiveTab() == CONTACTS_TAB_INDEX) { static unsigned long s_ct_dirty_refresh_ms = 0; // 2.5 s coalescing (was 350 ms): a full rebuild costs ~1.3 s at ~570 contacts // (#82), so an advert flood must not be able to queue them back to back. The // flag stays set, so the last advert in a burst still lands within 2.5 s. if ((now - s_ct_dirty_refresh_ms) > 2500) { s_ct_contacts_dirty = false; s_ct_dirty_refresh_ms = now; contactsListForceRefresh(); // bypass the count-cache — a name-fill / re-advert doesn't change the count } } // Safety net (#73): some contact mutations never set the dirty flag above — messaging a not-yet- // added node creates an anon contact (core addContact), the companion app imports one over BLE // (CMD_ADD_UPDATE_CONTACT), a delete removes one. getNumContacts() is O(1), so cheaply catch ANY // count change while the Contacts tab is visible and rebuild, independent of adverts. { static int s_ct_seen_count = -1; if (getActiveTab() == CONTACTS_TAB_INDEX) { const int nct = the_mesh.getNumContacts(); if (nct != s_ct_seen_count) { s_ct_seen_count = nct; refreshContactsList(); } } } #if defined(HAS_TANMATSU) if (s_msgled_flash_until) msgLedRefresh(getUnreadTotal() > 0); // end the one-shot envelope-LED flash on time #endif { static bool s_disc_loaded = false; if (!s_disc_loaded) { s_disc_loaded = true; loadDiscovered(); } } uiCp("ui:disc"); discoveredFlushIfDue(now); // persist the discovered ring (rate-capped) so it survives reboot uiCp("ui:gps"); updateGpsLocation(now); uiCp("ui:timers"); // GPS sync/persist is its own bucket: it can write prefs // sync + persist node location from GPS once fixed ++s_live_diag_loops; if (_alert_expiry != 0 && now >= _alert_expiry) { _alert[0] = '\0'; _alert_expiry = 0; } /* UI ping timeout: if no reply arrived in UI_PING_TIMEOUT_MS, show a * "No reply" toast and clear the pending state in MyMesh so a stale match * doesn't fire later. */ if (s_ui_ping_deadline_ms != 0 && now >= s_ui_ping_deadline_ms) { s_ui_ping_deadline_ms = 0; the_mesh.cancelUIDeferredLogin(); the_mesh.cancelUIPingPending(); char msg[64]; snprintf(msg, sizeof(msg), TR("No reply from %s"), s_ui_ping_target_name); showAlert(msg, 3500); } #if CAP_SD || defined(TLORA_PAGER) // Manual telemetry request timed out — flip the open window to "failed" (it // still shows the history). Auto-poll has no deadline, so it never lands here. if (s_telem_manual_pending && s_telem_deadline_ms != 0 && now >= s_telem_deadline_ms) { s_telem_manual_pending = false; s_telem_deadline_ms = 0; the_mesh.cancelUIDeferredLogin(); // drop a not-yet-acked LOGIN so a late OK can't fire a stale REQ the_mesh.cancelUIPingPending(); // drop the pending reply tag if the REQ was already sent if (s_telemetry_root) openTelemetryWindow(s_telem_node, s_telem_name, TELEM_FAILED); } #endif /* User button (BOOT / PIN_USER_BTN): press toggles the screen. When the * panel is off it wakes + resets the idle timer; when on it locks * immediately. Active-low with internal pullup. * * IMPORTANT: GPIO0 is the BOOT strapping pin — if the user held it during * a manual flash, it's still LOW for ~hundreds of ms after reset, and a * naive edge detect (`prev=HIGH` default) would fire a spurious "press" * on the first loop iter and manual-lock the screen. We sample the real * state once on first entry so a held BOOT doesn't trigger a fake edge. */ #ifdef PIN_USER_BTN { static bool s_user_btn_inited = false; static uint8_t s_user_btn_prev = HIGH; if (!s_user_btn_inited) { s_user_btn_prev = digitalRead(PIN_USER_BTN); s_user_btn_inited = true; } uint8_t v = digitalRead(PIN_USER_BTN); #if CAP_TRACKBALL /* On the T-Deck, PIN_USER_BTN (GPIO0) is the trackball centre click — make * it act as a touch at the cursor: a held click = a held press (so taps, * long-press and drag all work), released = released. While the screen is * off a click only wakes it, and is NOT delivered as a tap. */ const bool tb_pressed = (v == LOW); // Swallow the click that unlocks the screen: without this, the SAME held // click is re-injected as a cursor tap on the next iteration (once the // screen is back on), which clicks the UI underneath — the "flash" on // unlock. Consume it until the button is released. static bool s_tb_wake_consume = false; static unsigned long s_tb_hold_start = 0; // press timestamp for hold-to-unlock if (_manual_lock) { // Hard-locked: a press reveals the lock screen (lights it); HOLDING the // trackball for kLockUnlockHoldMs unlocks. A short tap just lights it. if (tb_pressed && s_user_btn_prev == HIGH) { lockscreenReveal(); // light the lock screen, don't unlock s_tb_hold_start = now; } if (tb_pressed && s_tb_hold_start && !_screen_off) { const unsigned long held = now - s_tb_hold_start; if (held >= kLockUnlockHoldMs) { unlockScreen(); // hides the "Unlocking…" popup too s_tb_hold_start = 0; s_tb_wake_consume = true; // swallow the release so it isn't a UI tap } else if (held >= 200) { // ignore a quick wake-tap; show on a real hold lockscreenUnlockProgress(kLockUnlockHoldMs - held); } } if (!tb_pressed) { s_tb_hold_start = 0; lockscreenUnlockPopupHide(); } // released early -> cancel s_tb_click_press = false; // never deliver clicks as taps while locked } else if (_screen_off) { if (tb_pressed && s_user_btn_prev == HIGH) { wakeScreen(); s_tb_wake_consume = true; } s_tb_click_press = false; } else if (s_mentionnav_active) { // The mention rows are NAV_SKIP, so the trackball button confirms the // private highlight instead of injecting a pointer tap behind the popup. if (tb_pressed && s_user_btn_prev == HIGH) { mentionNavConfirm(); s_tb_last_active_ms = now; noteUserInput(); } s_tb_click_press = false; } else if (s_emoji_sheet) { // Emoji picker open: a fresh press inserts the highlighted glyph via the // selector instead of injecting a cursor tap. Edge-triggered so a held // click doesn't repeat-insert. if (tb_pressed && s_user_btn_prev == HIGH) { emojiSelectorClick(); s_tb_last_active_ms = now; noteUserInput(); } s_tb_click_press = false; } else { if (!tb_pressed) s_tb_wake_consume = false; // released after unlock -> clicks re-armed s_tb_click_press = tb_pressed && !s_tb_wake_consume; if (s_tb_click_press) { s_tb_last_active_ms = now; noteUserInput(); } } #elif defined(HAS_RAK_TAP_V2) // RAK Tap V2: single BOOT button (GPIO0), no trackball, no keyboard. // Short press (<1s): toggle screen on/off (does NOT hard-lock -- touch // can still wake, since there is no second button for unlock). // Long press (>=1s): open power menu (Power off / Reboot / Cancel). static unsigned long s_rak_btn_down_ms = 0; static bool s_rak_long_fired = false; if (v == LOW && s_user_btn_prev == HIGH) { s_rak_btn_down_ms = now; s_rak_long_fired = false; } else if (v == LOW && s_user_btn_prev == LOW) { if (now - s_rak_btn_down_ms >= 1000 && !s_rak_long_fired) { s_rak_long_fired = true; if (_screen_off) wakeScreen(); openPowerMenu(); } } else if (v == HIGH && s_user_btn_prev == LOW) { if (!s_rak_long_fired) { if (_screen_off) { wakeScreen(); } else { touchScreenBacklight(false); setCpuForScreen(false); _screen_off = true; // NO _manual_lock -- touch can still wake the screen. } } } #else // Generic: Heltec V4 -- short press toggles screen + lock if (v == LOW && s_user_btn_prev == HIGH) { if (_screen_off) { #if defined(TLORA_PAGER) /* Pager only: hard-locked means BOOT is a no-op -- holding Backspace * is the deliberate unlock gesture (updatePagerBackspaceUnlockHold). * A plain idle-dimmed (not manually locked) screen still wakes on * BOOT exactly as before. NOT applied to the V4 below (#else of this * #if), which has no keyboard -- BOOT is its only lock/unlock control * and must keep instantly unlocking it. */ if (!_manual_lock) wakeScreen(); #else /* wakeScreen() clears _manual_lock so subsequent touches work. */ wakeScreen(); #endif } else { touchScreenBacklight(false); setCpuForScreen(false); _screen_off = true; _manual_lock = true; // touch cannot unlock until BOOT pressed again } } #endif s_user_btn_prev = v; } #endif #ifdef PIN_AUX_BTN { static bool s_aux_btn_inited = false; static uint8_t s_aux_btn_prev = HIGH; if (!s_aux_btn_inited) { s_aux_btn_prev = digitalRead(PIN_AUX_BTN); s_aux_btn_inited = true; } uint8_t v = digitalRead(PIN_AUX_BTN); if (v == LOW && s_aux_btn_prev == HIGH) { noteUserInput(); /* For now the aux button just wakes the screen — separate action * (e.g. send advert, cycle tab) can be wired here later. */ } s_aux_btn_prev = v; } #endif /* Screen timeout: toggle just the backlight (not the TFT RST/SPI) when idle * for `_screen_timeout_ms`. 0 disables auto-off. */ // Signed compare: input handlers earlier in THIS loop iteration (the trackball // click block, aux button) call noteUserInput() with a fresh millis() that can // be a few ms AFTER `now` (sampled at the top of loop()). An unsigned compare // then made (now - _last_input_ms) underflow to ~4.29e9 and fire the timeout on // every click. Signed keeps a slightly-ahead stamp negative (= "just had input"). if (_screen_timeout_ms > 0 && !_screen_off && (int32_t)(now - _last_input_ms) >= (int32_t)_screen_timeout_ms) { #if !defined(HAS_TDISPLAY_P4) // The P4 has no unlock path (no button/overlay), so it must NEVER hard-lock, even if an old // pref left the flag set — it always takes the plain screen-off branch below, which wakes on // touch. (Guards the trap; the "Lock when screen off" toggle is also hidden on the P4.) if (s_lock_on_screen_off && !_manual_lock) { // "Lock when screen off": idle dim also hard-locks, so the touchscreen is // inert until a deliberate unlock (trackball hold on the T-Deck, BOOT // press on the V4) — Tim's request to stop pocket-taps while dark. The // !_manual_lock guard stops the Tanmatsu's lit lock screen from re-firing // lockScreen() (and re-lighting) on every idle tick. lockScreen(); } else #endif { touchScreenBacklight(false); setCpuForScreen(false); // idle dim (no lock) -> drop to 80 MHz too _screen_off = true; } } // Burn-in guard (#55): a LIT hard-locked panel ALWAYS dims after a bounded window — independent of // the screen-timeout setting (which may be "never") and of held / ghost touches that keep re-revealing // the lock screen. _lock_lit_ms is re-armed only on a real off->lit transition (lock / reveal), so it // can't be pushed out forever; a deliberate reveal re-lights it and it dims again shortly after. // Was T-Deck-only; now every board — the Tanmatsu's lit lock screen otherwise never went dark at // screen-timeout "never" (wyvern.red burn-in report). No-op where _lock_lit_ms is never armed (V4). if (_manual_lock && !_screen_off && _lock_lit_ms) { uint32_t lock_dim = (_screen_timeout_ms > 0 && _screen_timeout_ms < 20000u) ? _screen_timeout_ms : 20000u; if ((int32_t)(now - _lock_lit_ms) >= (int32_t)lock_dim) { touchScreenBacklight(false); setCpuForScreen(false); _screen_off = true; } } #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // notify-flash (and so the notify wake): T-Deck + M9 // Notify-wake re-dim: a screen lit by a MESSAGE (not user input) goes dark again after a // short bounded window. On a busy channel the per-message wakes otherwise keep a static // image lit for the full screen-timeout — or forever at "never" — which retains into the // LCD (wyvern.red burn-in report). Any real input inside the window hands back to the // normal timeout. Set in the msgflash consumer below AFTER wakeScreen()/lockscreenReveal() // stamp _last_input_ms, so "input newer than the stamp" means the user, not the wake. if (s_notify_wake_ms) { if (_screen_off) s_notify_wake_ms = 0; // already dark again else if (_last_input_ms > s_notify_wake_ms) s_notify_wake_ms = 0; // user took over else if ((int32_t)(now - s_notify_wake_ms) >= 10000) { s_notify_wake_ms = 0; touchScreenBacklight(false); setCpuForScreen(false); _screen_off = true; } } #endif // HAS_TDECK_KEYBOARD || HAS_M9_KEYBOARD (notify-wake re-dim) // "At a glance" auto-hide: same re-dim shape as the msgflash window above, // but this feature's own fixed 5 s window (the last 200 ms of it spent // fading out, not a hard cut) and unconditional on every board (see // atGlanceShow()/newMsgImpl() above). Real input during the window hands // off to the user actually looking at the screen now -- don't snatch it // back off from underneath them; just stop tracking it and let the normal // idle timeout (already bumped by that real input) take over. if (s_glance_lit_ms) { if (_screen_off) atGlanceHide(); // already dark again some other way else if (_last_input_ms > s_glance_lit_ms) atGlanceHide(); // user took over else { const int32_t elapsed = (int32_t)(now - s_glance_lit_ms); if (!s_glance_fading_out && elapsed >= 4800) { s_glance_fading_out = true; lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; // text only -- see atGlanceShow() for (lv_obj_t* t : targets) { lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, t); lv_anim_set_time(&a, 200); lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_TRANSP); lv_anim_set_exec_cb(&a, atGlanceOpaCb); lv_anim_start(&a); } } if (elapsed >= 5000) { atGlanceHide(); touchScreenBacklight(false); setCpuForScreen(false); _screen_off = true; } } } #if defined(HAS_TOUCH_UI) if (!g_lv.ready) return; #if CAP_TOUCH // The Tanmatsu has NO CHSC6x cap-touch — input comes from the bsp keypad (LVGL KEYPAD indev). // heltecV4CapTouchBegin() probes CHSC6x I2C addresses on arduino Wire (port 0), which badge-bsp // already owns for its CH32V203 coprocessor; the failed i2c_new_master_bus/release there breaks // the coprocessor bus and kills the keyboard. Since touch never inits (no chip), this retried // every loop and disrupted I2C continuously. Skip all cap-touch hardware on this board. if (!g_lv.touch_inited) { g_lv.touch_inited = heltecV4CapTouchBegin(); // Surface the one-shot I2C bus-scan result to the Set-tab diag ring exactly // once (Begin runs the scan on its first call; the string is stable after). static bool s_touch_scan_logged = false; if (!s_touch_scan_logged) { s_touch_scan_logged = true; pushDiagLine(heltecV4CapTouchDebug()); } if (g_lv.touch_inited) { pushDiagLine("touch init ok"); // Once hardware is up, hand the chsc6x poll off to a pinned task on // core 0 so it runs at a fixed ~125 Hz independent of LVGL render and // the_mesh.loop() — that's the only way touch stays snappy when the // mesh stack bursts. Falls back to inline polling in lvglTouchRead if // task creation fails. if (heltecV4CapTouchStartBackgroundPoll(8)) { pushDiagLine("touch async @ 125 Hz"); } else { pushDiagLine("touch async start failed"); } } else if (!g_cap_touch_hw_started) { g_cap_touch_hw_started = true; pushDiagLine("touch init retrying"); } } #endif // !HAS_TANMATSU (cap-touch hardware init) // Swipe → tab change (blocked while a chat detail overlay is open) int8_t swipe_x = 0, swipe_y = 0; if (heltecV4CapTouchPopSwipe(&swipe_x, &swipe_y)) { applySwipeGesture(swipe_x, swipe_y); } // Advance LVGL tick using micros so timers still run when millis() does not advance. // With LV_TICK_CUSTOM=1 (lv_conf.h) LVGL reads esp_timer_get_time() itself and // lv_tick_inc isn't declared — keep the manual path compiled out so the build // works either way. #if !LV_TICK_CUSTOM { const uint32_t t_us = micros(); if (t_us < g_lv.lvgl_tick_prev_us) { g_lv.lvgl_tick_prev_us = t_us; } else { uint32_t add_ms = (t_us - g_lv.lvgl_tick_prev_us) / 1000U; if (add_ms > 500U) add_ms = 500U; if (add_ms > 0) { lv_tick_inc(add_ms); g_lv.lvgl_tick_prev_us += add_ms * 1000U; } } } #endif // A repeater echo of one of our sent floods was just counted — repaint the // open chat so the sent bubble's repeat tag ticks up live. if (the_mesh.takeEchoDirty()) { g_lv.dirty_timeline = true; #if !defined(HAS_TANMATSU) s_web_rx_nudge = true; // repeat-heard tag ticked up -> refresh the web chat too #endif } bool heavy_ok = !g_lv.defer_heavy_refresh || now >= g_lv.heavy_refresh_at_ms; if (g_lv.defer_heavy_refresh && heavy_ok) g_lv.defer_heavy_refresh = false; if (g_lv.dirty_threads && heavy_ok) { refreshThreadLists(); g_lv.dirty_threads = false; } if (g_lv.dirty_timeline && heavy_ok) { // Only repaint the detail that is currently open. if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); g_lv.dirty_timeline = false; } // Jump arrows dim to 50% one second after the last scroll activity (every // scroll tick re-brightens them via chatUpdateJumpButtons). if (!s_jump_dimmed && s_jump_active_ms && (uint32_t)(millis() - s_jump_active_ms) > 1000) { if (g_lv.dm.detail_open) jumpBtnsSetDim(&g_lv.dm, true); else if (g_lv.ch.detail_open) jumpBtnsSetDim(&g_lv.ch, true); else s_jump_dimmed = true; // nothing open: park the state } #if CAP_SD || defined(TLORA_PAGER) sdHealthTick(); // wedge detect + remount, driven by writer-flagged failures (any task) // microSD insert/remove detection — only while the file manager is open, so // there's no idle SPI traffic. Lifecycle changes run on this loop task; each // device transfer uses the shared SPIClass's transaction arbitration. if (s_fm_list) { static unsigned long s_sd_poll_next = 0; if (now >= s_sd_poll_next) { s_sd_poll_next = now + 2000; if (!s_sd_mounted) { // Only re-probe after the backoff window — repeatedly re-initing an // unmountable card spikes current / churns the bus and can reset the board. if (!sdRuntimeLifecycleBusy() && now >= s_sd_retry_after_ms && fmSdTryMount()) { showAlert(TR("SD card inserted"), 1500); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) if (!s_ui_data_fs) uiDataFsReady(); if (s_ui_data_fs == &SD) { SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root uiDataEnsureDirs(); // segment dir too flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) } #endif mapNoteStorageChanged(); if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } } else if (!sdRuntimeLifecycleBusy() && !sdProbeAlive()) { // Real-I/O probe, NOT `SD.cardType() == CARD_NONE`: cardType() is cached // at mount and only SD.end() ever resets it, so the old check could never // fire — neither a yanked card nor a wedged one was ever detected here. // Never probe/unmount while any SD consumer is active: SD.end() leaves // its open FAT handle stale and recovery would sabotage that operation. fmSdUnmount(); mapNoteStorageChanged(); #if defined(TLORA_PAGER) s_sd_data_warn_next_ms = sdDataWarnDeadline(now); showAlert(sdRemovedAlertText(), 5000); #else showAlert(TR("SD card removed"), 1500); #endif if (s_fm_fs == &SD || !s_fm_fs) fmShowRoots(); } } } #endif uiCp("ui:threads"); if (now >= _next_mesh_thread_refresh) { /* Pick up contact / channel additions that came in from the companion * serial client (CMD_SET_CHANNEL etc.). 4 s is the backstop — the app * also explicitly pings the UI via onThreadsChanged() for instant pickup. */ _next_mesh_thread_refresh = now + 4000; refreshThreadsFromMesh(); g_lv.dirty_threads = true; } if (now >= _next_refresh) { refreshStatusLabels(); // Unread-count badge over the Chats tab icon (bottom bar). if (s_chat_unread_badge) { const int u = getUnreadTotal(); if (u > 0) { char b[8]; if (u > 99) snprintf(b, sizeof b, "99+"); else snprintf(b, sizeof b, "%d", u); lv_label_set_text(s_chat_unread_badge, b); lv_obj_clear_flag(s_chat_unread_badge, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(s_chat_unread_badge, LV_OBJ_FLAG_HIDDEN); } } #if defined(HAS_TANMATSU) msgLedRefresh(getUnreadTotal() > 0); // envelope LED: breathe green when unread, dark when caught up #endif // Discovered-count badge on the Contacts-tab "Discovered" button. if (s_ct_disc_badge) { const int dc = discoveredCount(); if (dc > 0) { char b[8]; if (dc >= DISCOVERED_MAX) snprintf(b, sizeof b, "%d!", dc); // ring full else snprintf(b, sizeof b, "%d", dc > 99 ? 99 : dc); lv_label_set_text(s_ct_disc_badge, b); lv_obj_clear_flag(s_ct_disc_badge, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(s_ct_disc_badge, LV_OBJ_FLAG_HIDDEN); } } _next_refresh = now + UI_REFRESH_MS; } #if CAP_TRACKBALL uiCp("ui:input"); updateTrackball(now); #elif defined(HAS_PAGER_ENCODER) updatePagerEncoder(now); #endif #if defined(HAS_TDECK_KEYBOARD) if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers(); else tdeckKeyboardAllowModifiers(); // Drain physical-keyboard presses buffered by the touch task into the field. for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit if (_screen_off) tdeckKeyboardDiscardModifiers(); handleHwKey(key); } // Focusing a text field (cursor starts blinking — tapped or auto-focused) // counts as activity, exactly like a keypress: it bumps the timer so the auto // backlight lights up and then fades on the same idle window. { static lv_obj_t* s_kb_prev_ta = nullptr; lv_obj_t* cur_ta = g_lv.keyboard ? lv_keyboard_get_textarea(g_lv.keyboard) : nullptr; if (cur_ta && cur_ta != s_kb_prev_ta) s_kb_last_key_ms = now; s_kb_prev_ta = cur_ta; } // Keyboard backlight: off / on / auto (lit after activity, off after idle). uint8_t kb_bl = 0; if (s_kb_bl_mode == 1) kb_bl = tdeckKbBlLevel(); else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = tdeckKbBlLevel(); if (_screen_off || _manual_lock) kb_bl = 0; // dark/locked screen -> keep the keyboard dark too // New-message notify flash: light the screen so the user sees a message arrived. When the // screen is hard-locked, REVEAL the lock screen (lights the wallpaper, keeps the lock) rather // than wakeScreen() — wakeScreen() clears _manual_lock, which would leave the lock overlay up // but the trackball hold-to-unlock inert (the "can't unlock after a message; have to re-lock // with space first" bug). Only an unlocked idle-dim gets the full wake + keyboard pulse. if (s_msgflash_wake) { s_msgflash_wake = false; if (_manual_lock) { lockscreenReveal(); s_notify_wake_ms = millis(); } // locked: light wallpaper, keep the lock else if (_screen_off) { wakeScreen(); s_notify_wake_ms = millis(); } // idle-dimmed: normal wake // Stamped AFTER the wake fns set _last_input_ms, so only LATER real input reads // as "user took over" and cancels the short notify re-dim window (see loop above). } if (!_screen_off && !_manual_lock && s_msgflash_until && (int32_t)(now - s_msgflash_until) < 0) kb_bl = tdeckKbBlLevel(); tdeckKeyboardSetBacklight(kb_bl); serviceLockscreen(); // refresh the lock-screen clock on minute roll-over serviceLockingCountdown(now); // advance / fire the spacebar "Locking…" countdown #elif defined(HAS_PAGER_KEYBOARD) // No separate core-0 touch task to own the I2C bus (no touch at all), so poll // and drain right here, once per tick. Space press-and-hold locks the screen // (updatePagerSpaceHold); Backspace press-and-hold unlocks it again // (updatePagerBackspaceUnlockHold) -- the latter must run unconditionally, // BEFORE the normal-mode isScreenOff() split below, since it has to keep // working while the screen is dark. updatePagerKbBacklight() follows the // same rule. Remote Mode pauses both because it owns the lit placeholder. pagerKeyboardPoll(); if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt(); // Remote Mode keeps the placeholder lit and reserves all keys for its local // escape path, so normal lock/backlight state machines stay paused there. if (!s_remote_mode) { updatePagerBackspaceUnlockHold(now); updatePagerKbBacklight(now); } if (s_remote_mode) { // The physical panel is only a placeholder in Remote Mode. Match the // T-Deck/M9 contract: discard normal input and use two SPACE presses as // the local escape path. for (int kbi = 0; kbi < 12; ++kbi) { int key = pagerKeyboardReadKey(); if (key <= 0) break; remotePhysicalKey(key); } pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); } else if (g_lv.task && g_lv.task->isScreenOff()) { // Same rationale as updatePagerEncoder(): no touch/trackball wake path on // this board, so a keypress while idle-dimmed just wakes the screen // instead of being silently swallowed (which is what handleHwKey()'s own // isScreenOff() guard does on every other board -- fine there since they // always have touch or the trackball to wake with instead). Drain the // whole batch so nothing queued here leaks through as real input on the // very next tick right after waking. bool any = false; bool saw_backspace = false; for (int kbi = 0; kbi < 12; ++kbi) { int k = pagerKeyboardReadKey(); if (k <= 0) break; any = true; if (k == 0x08) saw_backspace = true; } // Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while // idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home) // the instant the screen wakes. pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); // Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding // Backspace does (updatePagerBackspaceUnlockHold, polled unconditionally // above off the raw held-state, so it's unaffected by this drain either // way). A Backspace TAP, though, should PEEK the lock screen -- light the // wallpaper, stay locked -- the keyboard-only equivalent of the T-Deck's // trackball click while locked (lockscreenReveal() is the exact same call // that path, and the new-message notify flash, already use). Without this // the ring byte a Backspace press pushes immediately on press was just // getting silently drained above with everything else, so tapping it // while the screen was actually dark did nothing (reported bug) even // though holding it through to unlockScreen worked fine. if (g_lv.task->isManualLock()) { if (saw_backspace) g_lv.task->lockscreenReveal(); } else if (any) { g_lv.task->wakeScreen(); } } else { for (int kbi = 0; kbi < 12; ++kbi) { int key = pagerKeyboardReadKey(); if (key <= 0) break; handleHwKey(key); } updatePagerAltShiftChord(); updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); updatePagerSpaceHold(now); // Missing on this board until now (T-Deck/M9 both already call it in their // own equivalent branch): without this the lock-screen clock/unread badge // never refreshed again after the first reveal -- correct at the moment // you peek, then frozen there for as long as the screen stays lit. serviceLockscreen(); } #elif defined(HAS_M9_KEYBOARD) // M9's keyboard controller is on its OWN bus (Wire1) — no touch task shares // it, so polling happens right here on the UI thread rather than from a // separate core-0 task (the poll itself rate-limits to ~15 ms inside // m9KeyboardPoll so the free-running loop doesn't hammer the bus). // // Keys used to dispatch against a one-tick-stale nav mirror: Enter's tree // mutations (open/close a modal) only land inside lv_timer_handler at the // END of a tick, so the FIRST arrow of the next tick ran navMoveDir against // s_nav_objs still mirroring the screen behind the modal — the intermittent // "modal navigation breaks out to the screen behind" bug. Sync the group to // the current tree before draining (cheap: sig-compare early-out). if (s_kbd_nav || s_tb_nav) navMaybeRebuild(); m9KeyboardPoll(); #if defined(HAS_M9_COMPASS) m9CompassIdleTick(); // park the magnetometer when no app is reading it #endif #if defined(HAS_M9_IMU) m9ImuIdleTick(); // and the accelerometer #endif for (int kbi = 0; kbi < 12; ++kbi) { int key = m9KeyboardReadKey(); if (key <= 0) break; if (!_screen_off) s_kb_last_key_ms = now; if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit handleHwKey(key); } // New-message notify flash — same contract as the T-Deck branch above: // hard-locked reveals the lock screen (lights the wallpaper, keeps the // lock), an unlocked idle-dim gets the full wake. Especially useful here: // no notification LED and no touch to check the screen with. if (s_msgflash_wake) { s_msgflash_wake = false; if (_manual_lock) { lockscreenReveal(); s_notify_wake_ms = millis(); } else if (_screen_off) { wakeScreen(); s_notify_wake_ms = millis(); } } // Keyboard backlight: the controller DOES expose duty control (reg 0x02; // it also auto-lights on keypress and times out after 10 s on its own — // this sets the level/mode our side wants). off / on / auto follow the same // s_kb_bl_mode the Control Center "Keyboard" chip cycles; write-on-change // only, so the tick costs zero I2C in steady state. { uint8_t kb_bl = 0; if (s_kb_bl_mode == 1) kb_bl = 255; else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = 255; if (_screen_off || _manual_lock) kb_bl = 0; // dark/locked screen -> keyboard dark too else if (s_msgflash_until && (int32_t)(now - s_msgflash_until) < 0) kb_bl = 255; // notify pulse // Cache the last duty the controller ACTUALLY took, not the last one // computed: the write is dropped while the controller (its own MCU on the // switched rail) is still booting, or on a NACK — latching a dropped // write would leave the controller on its power-on default until the // next mode/lock change. And 255 is a normal FIRST value (mode "On" // restored from prefs; auto during the first idle window), so a 0xFF // "never written" sentinel would swallow it — use -1, which no real duty // can equal. A failed write stays pending but retries at a calm 250 ms // cadence, not every free-running loop pass: a found-then-wedged bus // would otherwise pay a real I2C transaction (worst case the full Wire // timeout) per pass. static int s_kb_bl_last = -1; static uint32_t s_kb_bl_retry_ms = 0; if ((int)kb_bl != s_kb_bl_last && (int32_t)(now - s_kb_bl_retry_ms) >= 0) { if (m9KeyboardSetBacklight(kb_bl)) s_kb_bl_last = kb_bl; else s_kb_bl_retry_ms = now + 250; } } serviceLockscreen(); serviceLockingCountdown(now); #endif #if defined(ATTAKY_MESH_SERIES) // POWER_BTN (AW9523 @0x59 P07) toggles the panel. Polled before the screen-off // early-outs so it works with the backlight down; it is this board's only wake. attakyKeysPoll(); if (attakyPowerKeyPressed()) { if (_screen_off) wakeScreen(); else sleepScreen(); } #endif #if defined(HAS_ATTAKY_MESH_KEYBOARD) { lv_obj_t* akb_ta = g_lv.keyboard ? lv_keyboard_get_textarea(g_lv.keyboard) : nullptr; attakyKeyboardPoll(akb_ta != nullptr); for (int kbi = 0; akb_ta && kbi < 16; ++kbi) { int key = attakyKeyboardReadKey(); if (key <= 0) break; if (!_screen_off) noteUserInput(); // Enter in the chat composer sends (upstream does this in handleHwKey(), which // this board does not compile; READY only dismisses the keys). Honour the // enter-sends pref and rebind after, so the module can type the next message. if (key == 0x0D || key == 0x0A) { if (s_kb_panel && touchPrefsGetEnterSends()) { LvChatPanel* const p = s_kb_panel; // the send path clears s_kb_panel composerSendFromPanel(p); if (p->composer_ta && lv_obj_is_valid(p->composer_ta)) showKb(p); break; // keyboard rebound above; akb_ta is stale from here } else if (s_kb_panel) lv_textarea_add_char(akb_ta, '\n'); // enter-sends off: compose multi-line else lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr); // settings field: confirm } else if (key == 0x08 || key == 0x7F) lv_textarea_del_char(akb_ta); // '#' summons the on-screen keys for this editing session (hideKb clears it), // opening straight on the symbol panel: the module already types letters and // digits, so the keys are only for symbols its 5x5 matrix cannot reach. else if (key == '#' && attakyKeyboardPresent() && !s_osk_forced) { s_osk_forced = true; if (s_kb_panel) showKb(s_kb_panel); else kbMirrorBind(akb_ta); // Set the mode after the reveal (it redraws the map). 'abc' still returns to letters. if (g_lv.keyboard) lv_keyboard_set_mode(g_lv.keyboard, LV_KEYBOARD_MODE_SPECIAL); break; // both rebind the keyboard's textarea; akb_ta is stale from here } else if (key >= 0x20) lv_textarea_add_char(akb_ta, (uint32_t)key); } } #endif #if !defined(HAS_TANMATSU) // While a web-mirror browser is connected, count it as activity so the device screen // stays awake (otherwise the idle timer would dim it and swallow remote input). if (g_web_mirror.active()) { static uint32_t s_web_awake_ms = 0; if (now - s_web_awake_ms > 3000) { noteUserInput(); s_web_awake_ms = now; } // Tell the browser to summon/hide the phone soft keyboard when an editable field // gains/loses focus (the browser can't see the pixels to know). lv_obj_t* fta = g_lv.keyboard ? lv_keyboard_get_textarea(g_lv.keyboard) : nullptr; g_web_mirror.setKbFocused(fta != nullptr); #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Physical-keyboard boards hide the on-screen keyboard, so browser keystrokes are // injected via the same path as the hardware keys (printable chars type in; 0x08 = // backspace, 0x0D = enter/send). uint16_t wk; // Drain generously: a paste or a phone autocomplete arrives as one burst, and a low cap // left the rest sitting in the ring until the next iteration (where it could be pushed // out by newer keys and silently lost). Each key here is cheap. for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) handleHwKey((int)wk); #else // On-screen-keyboard boards (V4/RAK): the browser's laptop keyboard should type too, not // only pointer-taps on the on-screen keys. Inject into the focused field — which IS the // keyboard's textarea (the mirror strip on these boards), so it behaves exactly like an // on-screen key tap and syncs to the real field on Enter. Enter fires the keyboard's // ready cb (send/next); backspace deletes; other printables type in. uint16_t wk; // Same generous drain as the physical-keyboard branch above (burst paste / autocomplete). for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) { if (!fta) continue; // no editable field focused -> drain + drop if (wk == 0x0D || wk == 0x0A) lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr); else if (wk == 0x08 || wk == 0x7F) lv_textarea_del_char(fta); else if (wk >= 0x20) lv_textarea_add_char(fta, (uint32_t)wk); } #endif } #endif #if defined(HAS_TANMATSU) // Drive the keyboard backlight from off/on/auto + the Keys-slider brightness; keep it // dark whenever the screen is off/locked. The tick caches, so this per-frame call only // hits the CH32 over I2C when the value actually changes. tanKbBacklightTick(_screen_off || _manual_lock); #endif uiCp("ui:diag"); refreshLiveDiag(now); // Keep the signal fresh with a "discover" probe: send a ZERO-HOP advert whenever // we have no recent DIRECT signal. Zero-hop = neighbours only, never re-broadcast, // so the probe injects NO repeated traffic into the mesh — it just announces us to // whoever is in direct range. The reading itself comes passively from directly-heard // (0-hop) neighbours (logRxRaw gates the signal to hops==0), so in a fully silent // mesh the icon stays dark until a neighbour transmits — the honest answer (no direct // neighbour = no direct signal). Off / poll-interval live in the "Signal & traffic" popup. { // s_sig_probe_at is file-scope (promoted for tsNextWakeForcingDueMs); no re-init here. (void)0; // placeholder — variable declared at file scope above if ((long)(now - s_sig_probe_at) >= 0) { if (touchPrefsGetSigProbeEnabled()) { const uint32_t poll_ms = (uint32_t)touchPrefsGetSigPollMins() * 60000UL; // minutes -> ms s_sig_probe_at = now + poll_ms; const uint32_t sms = the_mesh.uiSignalMs(); // Skip the announce when a direct neighbour was heard within the poll window. if (sms == 0 || (now - sms) >= poll_ms - 5000UL) sendSignalProbe(); } else { s_sig_probe_at = now + 60000; // probe off: just re-check the toggle each minute } } } // Room-session self-heal (issue #89): while a room-server chat is OPEN, make sure // its login session is alive. The keep-alive only arms from a LOGIN_OK, so a // session from before this build — or one the core expired (2.5x the interval // with no ACK), or one the server dropped (reboot, LRU eviction, 3 failed // pushes) — needs a fresh login to resume both directions. A blank login is // enough for a client the room already knows (no password re-entry) and skips // the server's replay guard. Throttled to one attempt a minute, chat open only. { static uint32_t s_room_relogin_ms = 0; if (g_lv.dm.detail_open && g_lv.task && g_lv.task->hasActiveThread() && !g_lv.task->activeThreadIsChannel() && (uint32_t)(now - s_room_relogin_ms) >= 60000UL) { uint8_t rpub[32]; if (g_lv.task->getThreadContactPub(g_lv.task->activeThreadIdx(), rpub) && !the_mesh.uiHasConnectionTo(rpub)) { ContactInfo* rc = the_mesh.lookupContactByPubKey(rpub, PUB_KEY_SIZE); if (rc && rc->type == ADV_TYPE_ROOM) { s_room_relogin_ms = (uint32_t)now; // Raw send on purpose — NOT roomReloginBegin(). This fires unprompted // once a minute while the operator is reading a room; arming the // watchdog here would let it escalate to a password prompt nobody // asked for. Silent best-effort is the whole contract of a self-heal. the_mesh.uiRoomRelogin(rpub); // LOGIN_OK re-arms the keep-alive + resyncs } } } } // Periodic self-advert: the standard MeshCore flood / zero-hop advert on a timer (intervals set // in the Advertise app, stored in TouchPrefsStore). Separate from the signal probe above, which // only floods when the mesh has gone quiet. First fire lands one full interval after enable/boot. { // One-shot flood self-advert shortly after boot (opt-in, #76; off by default). Refreshes our // pubkey for peers with auto-add on — useful after a reflash wiped storage and left them a stale // identity. Fires once per boot; enabling mid-session waits for the next boot (it IS a boot advert). static bool s_boot_adv_done = false; static uint32_t s_boot_adv_at = 0; if (!s_boot_adv_done) { if (!touchPrefsGetBootAdvert()) s_boot_adv_done = true; // disabled — never fire this session else if (!s_boot_adv_at) s_boot_adv_at = (uint32_t)now + 6000u; // arm ~6s after first loop else if ((int32_t)((uint32_t)now - s_boot_adv_at) >= 0) { the_mesh.sendAdvert(true); // flood: reaches peers across repeaters (region-scoped like #68) s_boot_adv_done = true; } } static uint32_t s_next_flood_adv = 0, s_next_local_adv = 0; const uint16_t fh = touchPrefsGetFloodAdvHrs(); if (fh) { if (!s_next_flood_adv) s_next_flood_adv = (uint32_t)now + (uint32_t)fh * 3600000UL; else if ((int32_t)((uint32_t)now - s_next_flood_adv) >= 0) { the_mesh.sendAdvert(true); // flood: relayed across the mesh (region-scoped like #68) s_next_flood_adv = (uint32_t)now + (uint32_t)fh * 3600000UL; } } else s_next_flood_adv = 0; const uint16_t lm = touchPrefsGetLocalAdvMin(); if (lm) { if (!s_next_local_adv) s_next_local_adv = (uint32_t)now + (uint32_t)lm * 60000UL; else if ((int32_t)((uint32_t)now - s_next_local_adv) >= 0) { the_mesh.sendAdvert(false); // zero-hop: only neighbours hear it s_next_local_adv = (uint32_t)now + (uint32_t)lm * 60000UL; } } else s_next_local_adv = 0; } batteryLogTick((uint32_t)now); // 5-min battery sample (SD on T-Deck, else SPIFFS) #if CAP_SD || defined(TLORA_PAGER) telemetryPollTick((uint32_t)now); // auto-poll due nodes -> log (no window) #endif uiCp("ui:verchk"); versionCheckService(now); // firmware update check (gear badge + About line) uiCp("ui:sbar"); refreshSysInfo(now); // live uptime / heap on the About sub-tab #if defined(HAS_TDECK_GT911) refreshSleepDiag(now); // live sleep counters on the Lock settings panel #endif wifiScanService(); // draw Wi-Fi scan results when the worker finishes ctDeleteServiceTick(); // chunked contacts bulk-delete (advances the progress bar) // Accidental tab-switch guard: while content is actively being scrolled (and // for a short grace period after the scroll ends), make the bottom tab bar // ignore clicks so a stray finger landing on it mid-scroll can't change tabs. // Deliberate tab taps when not scrolling are unaffected. { static unsigned long s_tabbar_lock_until = 0; static bool s_tabbar_locked = false; lv_indev_t* indev = lv_indev_get_next(nullptr); if (indev && lv_indev_get_scroll_obj(indev) != nullptr) { s_tabbar_lock_until = now + 350; } // Also hard-lock the tab bar while Snake is open so a swipe/tap near the // bottom can't slip through to the app switcher (e.g. into the map). const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen() || luaAppIsOpen(); if (want_lock != s_tabbar_locked && g_lv.tabview) { lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview); if (tab_btns) { if (want_lock) lv_obj_clear_flag(tab_btns, LV_OBJ_FLAG_CLICKABLE); else lv_obj_add_flag(tab_btns, LV_OBJ_FLAG_CLICKABLE); s_tabbar_locked = want_lock; } } } // microSD activity LED: lit for k_sd_io_led_ms after the last SD access // (markSdIo). Edge-triggered — LVGL is only touched when the lit/dark state // flips, so an idle device costs one comparison per loop and nothing else. if (g_statusbar.sd_icon) { static int8_t s_sd_led = -1; // -1 = unset -> forces the initial sync const bool lit = (g_sd_io_ms != 0) && ((uint32_t)(now - g_sd_io_ms) < k_sd_io_led_ms); if ((int8_t)lit != s_sd_led) { s_sd_led = lit; if (lit) lv_obj_clear_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_HIDDEN); } } // Async mesh-request spinner: blink the centre glyph while a request is sending // or awaiting a reply (recent markMeshRequest, a pending UI reply, or the LOS // worker). Cheap — a blink toggle, no animation. if (g_statusbar.async_icon) { const bool active = (g_mesh_req_ms != 0 && (uint32_t)(now - g_mesh_req_ms) < 1500u) || (s_ui_ping_deadline_ms != 0) || (s_telem_manual_pending && s_telem_deadline_ms != 0) // manual telemetry req stays lit for its whole pending window, like a ping || s_los_busy; static bool s_async_shown = false; static uint32_t s_async_blink = 0; if (active) { if ((uint32_t)(now - s_async_blink) >= 350u) { s_async_blink = (uint32_t)now; s_async_shown = !s_async_shown; if (s_async_shown) lv_obj_clear_flag(g_statusbar.async_icon, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_statusbar.async_icon, LV_OBJ_FLAG_HIDDEN); } } else if (!lv_obj_has_flag(g_statusbar.async_icon, LV_OBJ_FLAG_HIDDEN)) { s_async_shown = false; lv_obj_add_flag(g_statusbar.async_icon, LV_OBJ_FLAG_HIDDEN); } } #if defined(HAS_TANMATSU) navMaybeRebuild(); // keep the keyboard-nav focus group in sync with the visible screen navPump(); // drain bsp keys: queue focus moves, type straight into focused fields #elif defined(TLORA_PAGER) // No touch fallback here either (like Tanmatsu) -- nav is always on, just fed // from the keyboard/encoder drain above instead of a bsp queue. navMaybeRebuild(); #elif CAP_KEYPAD_NAV // Keep the focus group synced whenever EITHER nav mode is active: the keyboard ESDFX nav // (T-Deck) / d-pad nav (M9) fed by handleHwKey, or the trackball D-pad nav (fed by // updateTrackball -> navMoveDir). if (s_kbd_nav || s_tb_nav) navMaybeRebuild(); #endif #if !defined(HAS_TANMATSU) // Web UI mirror: on a new connect / heal, invalidate the whole screen so LVGL // redraws it into the shadow buffer (takeFullRepaint is armed by the WS server). if (g_web_mirror.active() && g_web_mirror.takeFullRepaint()) lv_obj_invalidate(lv_scr_act()); #endif uiCp("ui:lvgl"); lv_timer_handler(); uiCp("ui:tail"); #if (CAP_SD || defined(TLORA_PAGER)) && \ (defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)) sdRestoreRun(); // intentionally outside the LVGL event/render call stack #endif #if !defined(HAS_TANMATSU) webMirrorTick(); // coalesced, rate-capped, backpressure-gated send of the dirty region #endif #if defined(HAS_TANMATSU) if (s_nav_entered_obj) { lv_obj_t* ent = s_nav_entered_obj; s_nav_entered_obj = nullptr; // WIZARD ONLY: after Enter on an option, jump to the primary action so the next Enter confirms. // In the main app this drifted focus to the last collected item (the Send-advert button), so it's // gated to the first-boot wizard (s_setup_root). if (s_setup_root && s_nav_group && lv_obj_is_valid(ent) && lv_group_get_focused(s_nav_group) == ent && !lv_obj_check_type(ent, &lv_switch_class) && !lv_obj_check_type(ent, &lv_checkbox_class) && s_nav_last && s_nav_last != ent && lv_obj_is_valid(s_nav_last)) lv_group_focus_obj(s_nav_last); } #endif #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Deferred microSD FAT32 format (runs a couple ticks after the notice paints). // f_mkfs blocks the loop for tens of seconds on a big card, so drop the loop // watchdog around it (CPU0 idle keeps running, so no reset) to avoid a // mid-format reset that would corrupt the card. // Gate matches fmSdDoFormat (T-Deck + M9 + V4-R8): the Format button existed on // all three, but this worker was T-Deck-only — on the others the formatting // notice stayed up forever with no format running, and the pending latch even // blocked remounting until reboot (#172). // Re-check quiescence at fire time: the two ticks that let the notice paint // are also two ticks in which a consumer can start (a map repaint queues tile // fetches, history flushes, prefs snapshots). Hold the countdown rather than // SD.end() under an open handle, but give up instead of waiting forever — // the longest legitimate holder is a tile download, capped at 12 s. static uint32_t s_sd_format_wait_until = 0; if (s_sd_format_pending == 1 && sdRuntimeLifecycleBusy()) { if (!s_sd_format_wait_until) s_sd_format_wait_until = millis() + 15000; if ((int32_t)(millis() - s_sd_format_wait_until) < 0) { s_sd_format_pending = 2; // re-arm; the notice stays on screen } else { s_sd_format_wait_until = 0; s_sd_format_pending = 0; fmHideFormatOverlay(); showAlert(TR("SD stayed busy - format cancelled, retry"), 3000); } } if (s_sd_format_pending && --s_sd_format_pending == 0) { s_sd_format_wait_until = 0; bool ok = false; { LoopWdtGuard loop_wdt_guard; SPIClass* spi = sdSharedSPI(); // this board's SD bus (T-Deck: LoRa SPI; R8: TFT FSPI; M9: shared SPI) if (spi) { // Explicit reformat (not SD.begin's format_if_empty, which only formats an // already-unreadable card): init the card to register the FatFs diskio at // pdrv (no mount), f_mkfs to FAT32, write the MESHCOMOD label by hand while // unmounted, release, then remount the fresh FS via SD.begin. Works on any // card (FAT32, exFAT, blank) since f_mkfs hardware-inits + overwrites it. SD.end(); // drop any existing mount uint8_t pdrv = sdcard_init(PIN_SD_CS, spi, 4000000); if (pdrv != 0xFF) { char drv[3] = { (char)('0' + pdrv), ':', 0 }; void* work = malloc(4096); // f_mkfs scratch (>= FF_MAX_SS) if (work) { if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { sdWriteFatLabel(pdrv, "MESHCOMOD"); ok = true; } free(work); } sdcard_uninit(pdrv); } if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); } } fmHideFormatOverlay(); if (ok && SD.cardType() != CARD_NONE) { s_sd_mounted = true; s_sd_size = SD.cardSize(); sdEnsureMeshcomodFolders(); // BINS / RECBCK / SETTINGS / MAPS / LOGS / meshcomod(+msgs) // The format just erased every chat segment while the table still // claims they exist — without a resync the durability watermark keeps // pretending the old records are on disk (permanently lost) and the // next append targets a segment file that is gone. Rebuild + re-land. uiDataEnsureDirs(); flushHistorySoon(); mapNoteStorageChanged(); char done[56], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(done, sizeof done, TR("SD formatted - %s (MESHCOMOD)"), cs); showAlert(done, 3500); } else { s_sd_mounted = false; // The backend handshake waits for any current File and preserves queued // requests, then drops the failed SD backend without leaving a pause set. mapNoteStorageChanged(); showAlert(TR("Format failed (no card / SD error)"), 3500); } if (s_fm_list) fmShowRoots(); } #endif // Deferred copy/move (paste). WDT off — a big tree can take a while. Ungated // like fmActPasteCb: every board with the Files app can paste, and with this // worker T-Deck-only the busy overlay stayed up forever on the rest (#172 class). if (s_fm_paste_pending && --s_fm_paste_pending == 0) { bool ok = false; { LoopWdtGuard loop_wdt_guard; ok = fmDoPaste(); } fmHideFormatOverlay(); showAlert(ok ? TR("Pasted") : TR("Paste failed"), ok ? 1500 : 2400); if (s_fm_list) fmRefresh(); } #endif } void UITask::setBootPhase(const char* label) { (void)label; } void UITask::shutdown(bool restart) { (void)restart; // Flush chat history before we go down. if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty) saveMsgsToStorage(); #if defined(ESP32) touchPrefsSetClockFloor(rtc_clock.getFloor()); // queue final clock-floor update (#89) touchPrefsFlush(); // finish queued A/B snapshots #endif if (_display) { _display->startFrame((ColorVal)0x0000); // explicit dark (core palettes vary) _display->setColor((ColorVal)0xFFFF); _display->drawTextCentered(_display->width() / 2, _display->height() / 2, "Shutting down..."); _display->endFrame(); } } // ============================================================================ // Unified popup registry — the ONE table behind anyPopupOpen(), the Esc/red-X // dismiss ladder and the tab-swipe blocker (thin wrappers defined mid-file). // Ordered topmost-first: the dismiss walker closes the FIRST open entry, so a // row's position IS its z/priority. Lives at EOF so every popup's static root // and closer is already declared. Adding a popup = adding ONE row here — it is // then counted, Esc-dismissable and swipe-aware everywhere at once. // is_open : whether the popup is showing // close : dismiss it (all closers route through popupClose()); nullptr = // not user-dismissable (progress overlays stay up until done) // flags : PF_COUNT = counts for anyPopupOpen(); PF_SWIPE = blocks tab swipes // ============================================================================ struct PopupEnt { bool (*is_open)(); void (*close)(); uint8_t flags; }; static constexpr uint8_t PF_COUNT = 1; static constexpr uint8_t PF_SWIPE = 2; #define P_OPEN(root) []{ return (root) != nullptr; } static const PopupEnt k_popup_registry[] = { { P_OPEN(s_urlqr_root), []{ closeUrlQr(); }, PF_COUNT }, // chat URL -> QR { P_OPEN(s_urlmenu_root), []{ closeUrlMenu(); }, PF_COUNT }, // chat URL -> action menu { P_OPEN(s_meminfo_root), []{ closeMemInfo(); }, PF_COUNT }, { P_OPEN(s_discover_root), []{ closeDiscoverPage(); }, PF_COUNT }, { P_OPEN(s_spec_root), []{ closeSpectrumPage(); }, PF_COUNT }, { P_OPEN(s_advert_root), []{ closeAdvertPage(); }, PF_COUNT }, // was dismissable but never counted #if defined(HAS_EXPANSION_KIT) { P_OPEN(s_expansion_root), []{ closeExpansionCard(); }, PF_COUNT }, // was in no registry at all { P_OPEN(s_local_sensors_root), []{ closeLocalSensorsPage(); }, PF_COUNT }, // was in no registry at all #endif { P_OPEN(s_siginfo_root), []{ closeSigInfoPopup(); }, PF_COUNT }, #if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) { P_OPEN(s_fm_img_root), []{ fmImageClose(); }, PF_COUNT }, { P_OPEN(s_editor_root), []{ fmEditorClose(); }, PF_COUNT }, { P_OPEN(s_fm_prompt), []{ fmPromptClose(); }, PF_COUNT }, { P_OPEN(s_fm_actions), []{ fmCloseActions(); }, PF_COUNT }, #if CAP_SOUND_FILES // s_fm_snd_root exists only under this gate (same set as the old CAP_SD||PAGER inside this block, minus the M9) { P_OPEN(s_fm_snd_root), []{ fmSndClose(); }, PF_COUNT }, // was in no registry at all #endif { P_OPEN(s_fm_fmt_overlay), nullptr, PF_COUNT }, // format progress: block keys, not dismissable { P_OPEN(s_term_picker_root), []{ closeTermCmdPicker(); }, PF_COUNT }, { P_OPEN(s_fullscreen_view), []{ closeFullscreenView(); if (g_lv.tabview) lv_tabview_set_act(g_lv.tabview, HOME_TAB_INDEX, LV_ANIM_OFF); }, PF_COUNT }, #endif { P_OPEN(s_confirm_modal), []{ confirmDismiss(); }, PF_COUNT }, #if CAP_SD || defined(TLORA_PAGER) { P_OPEN(s_telem_config_root), []{ telemetryConfigClose(); }, PF_COUNT }, // sits on the telemetry window { P_OPEN(s_telemetry_root), []{ telemetryClose(); }, PF_COUNT }, #endif #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) { P_OPEN(s_lockwall_picker), []{ lockwallPickerClose(); }, PF_COUNT }, #endif { P_OPEN(s_accent_picker), []{ accentPickerClose(); }, PF_COUNT | PF_SWIPE }, { P_OPEN(s_tz_picker), []{ tzPickerClose(); }, PF_COUNT }, { P_OPEN(s_chanscope_modal), []{ chanScopeClose(); }, PF_COUNT | PF_SWIPE }, { P_OPEN(s_blocked_modal), []{ blockedModalClose(); }, PF_COUNT | PF_SWIPE }, { P_OPEN(s_wifi_scan_popup), []{ wifiScanPopupClose(); }, PF_COUNT }, #if !defined(HAS_TANMATSU) { P_OPEN(s_vnc_root), []{ closeVncPage(); }, PF_COUNT }, { P_OPEN(s_remote_root), []{ closeRemotePage(); }, PF_COUNT }, #endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { P_OPEN(s_wifi_sheet), []{ wifiSheetClose(); }, PF_COUNT }, // was in no registry at all { P_OPEN(s_reader_root), []{ closeReaderPage(); }, PF_COUNT }, // on-device text browser #endif #if defined(HAS_TDECK_GT911) { P_OPEN(s_snd_menu), []{ sndMenuClose(); }, PF_COUNT }, // was in no registry at all #endif { P_OPEN(s_backup_picker), []{ backupPickerClose(); }, PF_COUNT }, { P_OPEN(s_batt_chart_root), []{ batteryChartClose(); }, PF_COUNT }, { P_OPEN(s_appgrid_sheet), []{ closeAppGridSheet(); }, PF_COUNT }, { P_OPEN(s_emoji_sheet), []{ closeEmojiSheet(); }, PF_COUNT }, { P_OPEN(s_disc_settings_root), []{ closeDiscoveredSettings(); }, PF_COUNT }, { P_OPEN(s_map_opts_root), []{ closeMapOptions(); }, PF_COUNT }, { P_OPEN(s_map_contacts_root), []{ closeMapContacts(); }, PF_COUNT }, { P_OPEN(s_map_picker_root), []{ closeMapPicker(); }, PF_COUNT }, { P_OPEN(s_trace_result_root), []{ closeTraceResultPopup(); }, PF_COUNT }, { P_OPEN(s_msg_menu_root), []{ closeMsgActionMenu(); }, PF_COUNT }, { P_OPEN(s_msg_info_root), []{ closeMsgInfoPopup(); }, PF_COUNT }, { P_OPEN(s_admin_picker_root), []{ closeAdminCmdPicker(); }, PF_COUNT }, { P_OPEN(s_admin_pw_root), []{ closeAdminPwPrompt(); }, PF_COUNT }, { P_OPEN(s_addch_sheet), []{ closeAddChannelSheet(); }, PF_COUNT }, { P_OPEN(s_qr_sheet), []{ closeQuickReplySheet(); }, PF_COUNT }, { P_OPEN(s_channel_long_sheet), []{ closeChannelLongSheet(); }, PF_COUNT }, { P_OPEN(s_action_sheet_root), []{ closeActionSheet(); }, PF_COUNT }, { P_OPEN(s_contacts_search_sheet), []{ closeContactsSearchSheet(); }, PF_COUNT }, { P_OPEN(s_contacts_overflow_root),[]{ closeContactsOverflowSheet(); }, PF_COUNT }, { P_OPEN(s_share_my_root), []{ closeShareMyContact(); }, PF_COUNT }, #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { P_OPEN(s_los_root), []{ closeLosModal(); }, PF_COUNT }, #endif { P_OPEN(s_admin_root), []{ closeAdminConsole(); }, PF_COUNT }, #if CAP_LUA_APPS // The Store page (also where Settings -> Language lands, see luaStoreOpenLanguages) // was never registered, so backspace/Esc did nothing on it and the ONLY way out was // tapping the status bar's back chevron -- every other settings screen closes with the // key (issue #281). Sits below the modals that open OVER the store (confirm, pickers) // so those still close first. { P_OPEN(s_luastore_root), []{ closeLuaStorePage(); }, PF_COUNT }, #endif { P_OPEN(s_settings_sheet), []{ closeSettingsCategory(); }, PF_COUNT | PF_SWIPE }, { []{ return settingsModalIsOpen(); }, []{ closeSettingsModal(); }, PF_COUNT }, { P_OPEN(s_power_menu), []{ closePowerMenu(); }, PF_COUNT }, { P_OPEN(s_cc_root), []{ closeControlCenter(); }, PF_COUNT }, { P_OPEN(s_ct_sort_sheet), []{ ctSortSheetClose(); }, PF_COUNT }, { P_OPEN(s_ctd_overlay), nullptr, PF_COUNT }, // bulk-delete progress: block keys only { P_OPEN(s_mentions_root), []{ closeMentionsScreen(); }, PF_COUNT }, { []{ return s_ct_select_mode; }, []{ ctSetSelectMode(false); }, 0 }, // a MODE: Esc leaves it, but it is not "a popup is open" { P_OPEN(s_appdrawer_root), []{ setHomeDrawer(false); }, PF_COUNT }, }; #undef P_OPEN static bool popupRegistryAny() { for (const auto& e : k_popup_registry) if ((e.flags & PF_COUNT) && e.is_open()) return true; return false; } static bool popupRegistryDismissTop() { for (const auto& e : k_popup_registry) if (e.is_open()) { // Stop at the first OPEN row either way: a null-close row is a key // BLOCKER (progress overlay) — walking past it would dismiss whatever // sits BENEATH the overlay mid-operation (e.g. the fullscreen Files // view under a running SD format). if (e.close) { e.close(); return true; } return false; } return false; } static bool popupRegistryBlocksSwipe() { for (const auto& e : k_popup_registry) if ((e.flags & PF_SWIPE) && e.is_open()) return true; return false; } // Last Wi-Fi STA disconnect reason, surfaced by wifiStaStatusBrief as // "auth failed (rNN)". Defined HERE rather than in src/main.cpp because every // target compiles this file, while the two ESP32-P4 targets are IDF builds with // their own main.cpp -- the S3-only definition left both of them failing to link. // The S3 Wi-Fi event handler in src/main.cpp writes it through an extern. #if defined(ESP32) volatile uint8_t g_wifi_last_disc_reason = 0; #endif