diff --git a/release-notes/beta_25.txt b/release-notes/beta_25.txt new file mode 100644 index 0000000..efcb50f --- /dev/null +++ b/release-notes/beta_25.txt @@ -0,0 +1,18 @@ +# beta_25 (TEST build — one user-facing note per non-blank, non-# line; # lines are section comments) + +# --- Fixed --- +Contacts you discover now show up in your contact list right away — even if the device was on another screen, or the node was added by the phone app over Bluetooth. No more changing the sort to make them appear. Thanks to 1nerdherder for the report. +Manual telemetry and status requests now work on the first tap instead of needing a second. Thanks to Dan Vybiral (@Vybo) for the fix. +On the emoji picker and quick-chat replies, pressing Esc or the red X now closes just the popup instead of the whole chat screen. +Lock screen: you can now unlock the device after the screen wakes from an incoming message. + +# --- New --- +New "Advertise on boot" option in the Advertise app (off by default): turn it on to send one advert when the device powers on, so nearby nodes refresh your details after a reflash. + +# --- Tanmatsu --- +Tanmatsu: Wi-Fi map tiles now download and display, cached to the microSD card. +Tanmatsu: contacts and message history now survive a reboot (moved to the microSD card, so keep a card inserted). +Tanmatsu: fixed the crash that stopped the app from booting; popups now fit the screen and scroll, and keyboard navigation stays inside an open popup. + +# --- Also --- +This is a test-channel build (opt in with "Get test builds" on the About screen). Everything from the stable beta_21 and the earlier test builds is included. diff --git a/src/DataStore.h b/src/DataStore.h index 7d01153..39d3778 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -45,6 +45,10 @@ public: #endif FILESYSTEM* getPrimaryFS() const { return _fs; } FILESYSTEM* getSecondaryFS() const { return _fsExtra; } + // Route contacts + channels (see _getContactsChannelsFS) to a different FS than identity/prefs. + // Used on the Tanmatsu to keep identity/prefs on the (working) internal FFat while putting the + // frequently-rewritten contacts/channels on the reliable SD card. Call before begin(). + void setSecondaryFS(FILESYSTEM* fs) { _fsExtra = fs; } bool loadMainIdentity(mesh::LocalIdentity &identity); bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); diff --git a/src/helpers/esp32/MqttBridge.cpp b/src/helpers/esp32/MqttBridge.cpp index ada94f5..ea55d0b 100644 --- a/src/helpers/esp32/MqttBridge.cpp +++ b/src/helpers/esp32/MqttBridge.cpp @@ -1,5 +1,11 @@ #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) #include "MqttBridge.h" + +// The single global bridge instance (extern in the header). Defined for every board: the header +// makes MqttBridge a no-op stub on the Tanmatsu, and the real bridge below on all other boards. +MqttBridge mqtt_bridge; + +#if !defined(HAS_TANMATSU) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 ---- #include #include #include @@ -9,8 +15,6 @@ #include "mbedtls/md.h" #include "mbedtls/base64.h" -MqttBridge mqtt_bridge; - // Read every field from the "mqtt" NVS namespace into the members. isKey() guards // avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the // USB-CDC companion stream (see TouchPrefsStore prefsGetStr note). @@ -212,4 +216,5 @@ void MqttBridge::reloadConfig() { _host, _port, (int)_enabled, (int)_pubDm, (int)_pubChannel, (int)_encOn); } +#endif // !HAS_TANMATSU (real implementation) #endif // ESP32 && MULTI_TRANSPORT_COMPANION diff --git a/src/helpers/esp32/MqttBridge.h b/src/helpers/esp32/MqttBridge.h index 3ddab4a..34a448e 100644 --- a/src/helpers/esp32/MqttBridge.h +++ b/src/helpers/esp32/MqttBridge.h @@ -2,6 +2,25 @@ #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) #include + +#if defined(HAS_TANMATSU) +// ---- MQTT DISABLED on the Tanmatsu (ESP32-P4) ---- +// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient +// path this bridge uses is not wired for that and crashed at boot. Provide a no-op bridge with the +// SAME public API so the shared companion + settings code links without pulling PubSubClient into +// the P4 build. Re-enable once esp-hosted TCP is proven on the Tanmatsu. +class MqttBridge { +public: + void begin(const char* /*nodeHex*/) {} + void loop() {} + void publishDM(const char*, const uint8_t*, float, uint8_t, uint32_t, const char*) {} + void publishChannel(int, const char*, float, uint8_t, uint32_t, const char*) {} + bool enabled() const { return false; } + static void saveConfig(const char*, uint16_t, const char*, const char*, + bool, bool, const char*, bool) {} + void reloadConfig() {} +}; +#else #include #include @@ -78,6 +97,7 @@ private: bool sealToB64(const char* plain, char* out, size_t outCap); static void escapeJson(const char* src, char* dst, size_t dstLen); }; +#endif // HAS_TANMATSU (no-op stub) vs real MqttBridge extern MqttBridge mqtt_bridge; diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 77a9d11..a8e72c7 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -35,7 +35,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 28; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install) +static const uint8_t TOUCH_CFG_VER = 30; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -99,6 +99,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t flood_adv_hrs; // periodic flood self-advert interval in hours (0 = off) — v27 (trailing) uint16_t local_adv_min; // periodic zero-hop self-advert interval in minutes (0 = off) — v27 (trailing) uint8_t beta_updates; // opt-in to test/beta firmware on the OTA check + install (bool) — v28 (trailing) + uint8_t boot_advert; // one-shot flood self-advert ~6s after boot (bool, 0=off) — v30 (trailing) — #76 }; static TouchCfg s_cfg; @@ -155,7 +156,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.map_show_tilexyz = 1; c.map_show_contacts = 1; c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols) - c.ui_scale = 0; // default: 100% UI scale (native resolution) + c.ui_scale = 1; // default: 150% "Large" UI scale (Tanmatsu; S3 boards ignore this) #if defined(HAS_TANMATSU) c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on #else @@ -168,6 +169,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.flood_adv_hrs = 0; // OFF: no periodic flood self-advert (advertise manually) c.local_adv_min = 0; // OFF: no periodic zero-hop self-advert c.beta_updates = 0; // OFF: stable update channel (opt-in to beta/test firmware) + c.boot_advert = 0; // OFF: no automatic advert on boot — opt-in (#76) c.sleep_idle = 0; // default: idle light-sleep OFF { const char* d = "ertui"; for (int i = 0; i < 5; i++) c.nav_keys[i] = (uint8_t)d[i]; } // default tab hotkeys E/R/T/U/I c.map_zoom_buttons = 0; // default: map zoom = slider @@ -246,6 +248,8 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 26) s_cfg.msg_flash = 0; if (s_cfg.ver < 27) { s_cfg.flood_adv_hrs = 0; s_cfg.local_adv_min = 0; } if (s_cfg.ver < 28) s_cfg.beta_updates = 0; + if (s_cfg.ver < 29 && s_cfg.ui_scale == 0) s_cfg.ui_scale = 1; // bump old 100% default -> Large (150%) + if (s_cfg.ver < 30) s_cfg.boot_advert = 0; // #76 new trailing field: advert-on-boot off by default s_cfg.ver = TOUCH_CFG_VER; s_cfg.magic = TOUCH_CFG_MAGIC; cfgFlush(); // rewrite with new fields defaulted-in @@ -977,6 +981,16 @@ bool touchPrefsSetMsgFlash(bool on) { return cfgFlush(); } +bool touchPrefsGetBootAdvert() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.boot_advert != 0; +} +bool touchPrefsSetBootAdvert(bool on) { + if (!s_begun) touchPrefsBegin(); + s_cfg.boot_advert = on ? 1 : 0; + return cfgFlush(); +} + // Periodic self-advert intervals (0 = off). Validation mirrors MeshCore: flood in hours (cap 168); // local zero-hop in minutes, 0 or 60-240 (MeshCore's MIN_LOCAL_ADVERT_INTERVAL is 60). uint16_t touchPrefsGetFloodAdvHrs() { diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index b2c2067..6e7086f 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -148,6 +148,11 @@ bool touchPrefsSetFemLna(bool on); bool touchPrefsGetMsgFlash(); bool touchPrefsSetMsgFlash(bool on); +/* Advertise on boot (#76): fire one flood self-advert ~6s after boot so peers with auto-add on + * refresh our pubkey (useful after a reflash wiped storage). Opt-in, default off. All boards. */ +bool touchPrefsGetBootAdvert(); +bool touchPrefsSetBootAdvert(bool on); + /* Periodic self-advert intervals (the standard MeshCore flood/local advert, on a timer). 0 = off. * Flood in hours; local zero-hop in minutes (0 or 60-240). Scheduled in UITask::loop via sendAdvert. */ uint16_t touchPrefsGetFloodAdvHrs(); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 8fee236..f826eaf 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -882,6 +882,13 @@ constexpr int TAB_LAST = 4; // 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 + // ---- 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 @@ -2480,6 +2487,9 @@ static void navMoveDir(int dir) { 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 @@ -2878,15 +2888,15 @@ static void navPump() { { 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) - LvChatPanel* cp = navOpenChatPanel(); if (cp) { closeChatPanel(cp); break; } // tap: close chat… - if (anyPopupOpen()) { hwKeyDismissTopPopup(); break; } // …or the topmost modal… + 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 (down) { LvChatPanel* cp = navOpenChatPanel(); if (cp) { closeChatPanel(cp); break; } } // close an open chat/channel first - if (anyPopupOpen()) { if (down) hwKeyDismissTopPopup(); break; } // else the topmost modal/sheet + 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 @@ -2994,7 +3004,10 @@ static bool navCollect(lv_obj_t* obj) { 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 && navCollect(c)) descHasClickable = true; + // 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) @@ -3129,7 +3142,10 @@ static void navMaybeRebuild() { 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) - navAddStatusBarActions(); // + the visible top-bar action buttons (+ ✓ QR / gear) + // 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 — @@ -3184,6 +3200,20 @@ static void navMaybeRebuild() { 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; @@ -5187,9 +5217,9 @@ static void openThreadActionSheet(int thread_idx, const char* name, bool is_chan // Enlarged on the big Tanmatsu panel so this long-press sheet doesn't look lost // (PSC is a no-op on the smaller boards, so they're unchanged). #if CAP_LARGE_SCREEN - const int card_w = PSC(220); - const int btn_h = PSC(42); - const int pad = PSC(10); + const int card_w = PSC(210); + const int btn_h = SC(40); // SC not PSC: the 1.7x PSC boost made these rows oversized on the Tanmatsu + const int pad = PSC(8); #else const int card_w = 220; const int btn_h = 42; @@ -5752,12 +5782,12 @@ static void openQuickReplyPicker(LvChatPanel* p) { // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards. #if CAP_LARGE_SCREEN - const int card_w = PSC(220); - const int btn_h = PSC(32); - const int pad = PSC(8); - const int title_h = PSC(26); - const int hint_h = PSC(22); - const int row_gap = PSC(4); + const int card_w = PSC(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); #else const int card_w = 220; const int btn_h = 32; // 34→32: 6 macro rows have to fit in the @@ -5766,7 +5796,8 @@ static void openQuickReplyPicker(LvChatPanel* p) { const int hint_h = 22; // card that clipped behind the bar. const int row_gap = 4; #endif - const int card_h = title_h + TOUCH_QUICK_REPLY_COUNT * (btn_h + row_gap) + hint_h + pad; + int card_h = title_h + TOUCH_QUICK_REPLY_COUNT * (btn_h + row_gap) + hint_h + pad; + 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); @@ -6822,6 +6853,10 @@ static void advertAutoLocalCb(lv_event_t* e) { 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) { lv_obj_del_async(s_advert_root); s_advert_root = nullptr; } if (s_apppage_close == closeAdvertPage) { // release the tall title bar back to normal @@ -6924,6 +6959,19 @@ static void openAdvertModalCb(lv_event_t* e) { 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); @@ -13054,9 +13102,9 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // historical integers, untouched (PSC is a no-op there). #if CAP_LARGE_SCREEN const int card_w = PSC(232); - const int btn_h = PSC(30); - const int btn_gap = PSC(6); - const int title_h = PSC(28); + int btn_h = PSC(30); // vertical metrics are mutable: the shrink-to-fit below trims them + int btn_gap = PSC(6); // when the big-screen 1.7x scale would push the sheet off-screen + int title_h = PSC(28); const int padding = PSC(6); #else const int card_w = 232; @@ -13064,9 +13112,9 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // clipping below the status bar. Delete spans the full width as the // bottom danger row. Repeater worst case: 8 grid items -> 4 rows + 1 // delete row = 5 * (30+6) + title 28 + pad 6 = 214 px (fits in 298). - const int btn_h = 30; - const int btn_gap = 6; - const int title_h = 28; + int btn_h = 30; + int btn_gap = 6; + int title_h = 28; const int padding = 6; #endif // msg/ping + telemetry + (trace ping + admin, repeaters only) + range @@ -13100,14 +13148,15 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // 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 card_h = title_h + (grid_rows + 1) * (btn_h + btn_gap) + padding; - // On landscape displays (sh=240) the backdrop is only 218px; cap the card - // to that space and let it scroll vertically when buttons overflow. + int card_h = title_h + (grid_rows + 1) * (btn_h + btn_gap) + padding; + // 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; - const int capped_card_h = LV_MIN(card_h, avail_card_h); + 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, capped_card_h); + 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); @@ -13115,11 +13164,7 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch 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); - if (card_h > capped_card_h) { - lv_obj_set_scroll_dir(card, LV_DIR_VER); - } else { - lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); - } + 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); @@ -13137,13 +13182,25 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch lv_obj_set_width(title, card_w - 2 * padding - 28); 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 ((grid_rows + 1) * (btn_h + btn_gap) > 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 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 = title_h; + int y = 0; int col = 0; // 0 = left column, 1 = right column // On the Tanmatsu the rows are PSC-taller, so step the row label up one font // size to fill them; the smaller boards keep the original g_font_12. @@ -13155,7 +13212,7 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // 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(card); + 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); @@ -13173,7 +13230,7 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // 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(card); + 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); @@ -18507,9 +18564,11 @@ static void openContactsOverflowSheetCb(lv_event_t* e) { // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards (PSC no-op). // 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. - const int card_w = PSC(200), btn_h = PSC(30), btn_gap = PSC(4), title_h = PSC(22), padding = PSC(8); + const int card_w = PSC(200); + 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; - const int card_h = title_h + rows * (btn_h + btn_gap) + padding; + 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); @@ -18521,7 +18580,9 @@ static void openContactsOverflowSheetCb(lv_event_t* e) { 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); + 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")); @@ -19250,6 +19311,9 @@ static inline void tileCacheMkdir(const char* rel) { // 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) @@ -19257,15 +19321,14 @@ static bool tilesFsLowSpace() { // 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) { - 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 = FFat.totalBytes(), use = FFat.usedBytes(); - const size_t freeb = (tot > use) ? (tot - use) : 0; - low = (tot > 0) && (freeb < 1536 * 1024); - } - return low; + // 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, @@ -20013,29 +20076,25 @@ static void tileFetchTaskFn(void* arg) { "%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); - if (tileCacheExists(path_jpg)) { - // Validate it's actually a JPEG. An older build — or a proxy/OSM error - // response cached as a .jpg — leaves a non-image that renders as a BLACK - // quadrant, and because the file "exists" the fetcher used to skip it - // forever (never re-fetched). Read the 2 magic bytes (cheap, on this fetch - // thread) and re-download if they're wrong. - bool good = false; + // 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[2] = {0, 0}; - if (vf.size() >= 2 && vf.read(m, 2) == 2 && m[0] == 0xFF && m[1] == 0xD8) good = true; + 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 (s_tile_fetch_pending > 0) --s_tile_fetch_pending; + continue; + } + tileCacheRemove(path_jpg); // present but unreadable/short/bad -> fall through and re-download } - if (good) { - ++s_tile_fetch_ok; - if (s_tile_fetch_pending > 0) --s_tile_fetch_pending; - continue; - } - tileCacheRemove(path_jpg); // bad cached tile — fall through and re-download a good one - } - if (tileCacheExists(path_png)) { - tileCacheRemove(path_png); // stale noise-tile — reclaim the space } + 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 @@ -20155,6 +20214,7 @@ static void tileFetchTaskFn(void* arg) { 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) { @@ -20441,31 +20501,34 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, } #endif if (!s_tiles_fs_ready) return false; - if (!tileCacheExists(path)) 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.) File f = tileCacheOpen(path, "r"); if (!f) return false; - const size_t sz = f.size(); - // Cap MUST match the fetcher's write cap (100 KB, see tileFetchTaskFn). It was - // 80 KB here while the writer allowed up to 100 KB, so a dense tile saved at - // 80-100 KB was written to cache but then silently REJECTED on read — it never - // rendered, and the map sat on "Downloading…" for that area forever. - if (sz == 0 || sz > 100 * 1024) { f.close(); return false; } // sanity cap (matches writer) - uint8_t* buf = (uint8_t*)lvglPsramAlloc(sz); + const size_t CAP = 100 * 1024; + uint8_t* buf = (uint8_t*)lvglPsramAlloc(CAP); if (!buf) { f.close(); return false; } - const size_t n = f.read(buf, sz); + 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(); - if (n != sz) { lvglPsramFree(buf); return false; } - // Reject a cached tile whose header isn't a JPEG SOI — a garbled/partial download - // that slipped through decodes to a blank/half "twilight zone" tile otherwise. - // Drop it from the writable online cache so the render miss re-queues a fresh - // fetch; never touch read-only SD map packs (can't re-download those). - if (sz < 3 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF) { + // Reject a cached tile whose header isn't a JPEG SOI — a garbled/partial download that slipped + // through decodes to a blank/half "twilight zone" tile otherwise. Drop it from the writable online + // cache so the render miss re-queues a fresh fetch; never touch read-only SD packs (can't re-fetch). + if (n < 3 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF) { lvglPsramFree(buf); if (!s_tiles_from_sd) tileCacheRemove(path); return false; } *out_data = buf; - *out_len = sz; + *out_len = n; return true; #else (void)z; (void)x; (void)y; (void)out_data; (void)out_len; @@ -20510,10 +20573,13 @@ static bool tileExistsAt(uint8_t z, long x, long y) { #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); - if (tileCacheExists(p)) return true; + { 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); - return tileCacheExists(p); + { File f = tileCacheOpen(p, "r"); if (f) { f.close(); return true; } } + return false; } #endif // ESP32 @@ -20779,13 +20845,17 @@ static void renderMapTiles() { // 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[240]; + 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) + (s_tile_fs == &SD_MMC ? "SD cache" : "flash cache"), +#else (s_tile_root[0] ? "SD cache" : "flash cache"), +#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); @@ -24949,6 +25019,7 @@ static void refreshChatList(LvChatPanel& p) { 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]); @@ -32977,8 +33048,17 @@ static bool s_ui_data_resolved = false; static bool uiDataFsReady() { if (s_ui_data_fs != nullptr) return true; // cache SUCCESS only — a failed resolve MUST stay retryable #if defined(HAS_TANMATSU) - // Tanmatsu: no SD global + no SPIFFS partition — chat history lives on the internal - // 'locfd' FAT partition (mounted at boot in main.cpp; same store as DataStore + the FM). + // Tanmatsu: prefer the microSD card. The internal FFat 'locfd' loses frequently-rewritten data on + // this P4 (broken FAT metadata; see the tile-cache notes), so chat history vanished on reboot. SD is + // reliable FAT — mirror the T-Deck's /meshcomod root. Fall back to FFat only when no card is present. + // Both are mounted at boot in main.cpp (g_sd_ok / g_fs_ok). + 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; + } extern bool g_fs_ok; if (g_fs_ok) { s_ui_data_fs = &FFat; s_ui_data_root[0] = '\0'; return true; } #elif defined(HAS_TDECK_GT911) @@ -34099,18 +34179,23 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no } #endif #if defined(HAS_TANMATSU) - // Tanmatsu's 16M.csv has no dedicated "tiles" partition (and no SPIFFS/SD), so the - // LittleFS mount above fails. Rather than surface a "reflash the partition" error, - // fall back to the internal FFat ('locfd') data partition the file browser already - // uses — Wi-Fi-fetched tiles cache to /tiles///.jpg there and render exactly - // like the partition/SD backends (the render + fetch paths are fs-agnostic via the - // tileCache* helpers). Same "this board uses FFat" precedent as the chat-history store - // (s_ui_data_fs = &FFat). Network-only in practice: empty until Wi-Fi downloads tiles. + // 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///.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'; // -> /tiles///.jpg, a self-contained dir on locfd + 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] no tiles partition -> caching Wi-Fi tiles on FFat /tiles (Tanmatsu)"); + WIRE_DBG("[TILE] Tanmatsu: no SD card -> caching Wi-Fi tiles on FFat /tiles (best-effort)"); } #endif else { @@ -35586,12 +35671,31 @@ void UITask::loop() { // radio forever — so re-apply the mesh params and release ownership here. if (s_spectrum_active && !s_spec_root) spectrumRestoreRadio(); flushHistoryIfDue(now); - // A contact was discovered/added (possibly while a companion app was connected — - // the old code left the device screen unaware; issue #73). Rebuild the Contacts - // list if it's the visible tab; refreshContactsList no-op's when nothing changed. - if (s_ct_contacts_dirty) { - s_ct_contacts_dirty = false; - if (getActiveTab() == CONTACTS_TAB_INDEX) refreshContactsList(); + // 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; + if ((now - s_ct_dirty_refresh_ms) > 350) { + 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 @@ -35932,9 +36036,16 @@ void UITask::loop() { if (s_kb_bl_mode == 1) kb_bl = 0xFF; else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = 0xFF; if (_screen_off || _manual_lock) kb_bl = 0; // dark/locked screen -> keep the keyboard dark too - // New-message notify flash: wake the screen so the message is visible, then briefly force the - // keyboard lit for the pulse window (overrides the off/idle backlight mode). - if (s_msgflash_wake) { s_msgflash_wake = false; if (_screen_off) wakeScreen(); } + // 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(); // locked: light wallpaper, keep the lock (unlock still works) + else if (_screen_off) wakeScreen(); // idle-dimmed: normal wake + } if (!_screen_off && !_manual_lock && s_msgflash_until && (int32_t)(now - s_msgflash_until) < 0) kb_bl = 0xFF; tdeckKeyboardSetBacklight(kb_bl); serviceLockscreen(); // refresh the lock-screen clock on minute roll-over @@ -35974,6 +36085,20 @@ void UITask::loop() { // 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) { diff --git a/tanmatsu/fetch-deps.sh b/tanmatsu/fetch-deps.sh index 8fe2fb3..ddb742d 100755 --- a/tanmatsu/fetch-deps.sh +++ b/tanmatsu/fetch-deps.sh @@ -51,6 +51,9 @@ vend "MicroNMEA" # GPS NMEA parsing vend "AsyncTCP" # the core's ESP32Board HTTP-OTA + companion-over-IP transport vend "ESPAsyncWebServer" # WebSocket/HTTP companion server (needs the task WDT on — sdkconfigs/wadamesh) vend "AsyncElegantOTA" # OTA web UI (the core's ESP32Board HTTP-OTA) +# NOTE: PubSubClient is deliberately NOT vended — the MQTT bridge is compiled out on the Tanmatsu +# (HAS_TANMATSU no-op stub in MqttBridge.h); the P4's esp-hosted Wi-Fi isn't wired for it and it +# crashed the app at boot. Re-add this vend if MQTT is ever brought up on the P4. # Bump the component CMakeLists so the next build re-globs (IDF component scripts can't use # file(GLOB CONFIGURE_DEPENDS) — they run in script mode during requirement expansion). touch components/meshcore/CMakeLists.txt components/ardlibs/CMakeLists.txt 2>/dev/null || true diff --git a/tanmatsu/main/CMakeLists.txt b/tanmatsu/main/CMakeLists.txt index 9515083..adeaf9c 100644 --- a/tanmatsu/main/CMakeLists.txt +++ b/tanmatsu/main/CMakeLists.txt @@ -41,7 +41,7 @@ set(WADA_DEFS MULTI_TRANSPORT_COMPANION TCP_PORT=5000 WS_PORT=8765 BLE_PIN_CODE=123456 # enables the app's NimBLE companion (BLE over the C6) LORA_FREQ=869.618 LORA_BW=62.5 LORA_SF=8 - MAX_CONTACTS=500 MAX_GROUP_CHANNELS=40 # 2000 was a ~400KB internal-DRAM strand; BT's SDIO RX buffers (MALLOC_CAP_INTERNAL|DMA) need that headroom + MAX_CONTACTS=2000 MAX_GROUP_CHANNELS=40 # P4 has ample RAM; contacts array is PSRAM-resident (the_mesh placement-new) ENABLE_PRIVATE_KEY_IMPORT=1 ENABLE_PRIVATE_KEY_EXPORT=1 DISPLAY_CLASS=TanmatsuDisplay # microSD lives on the P4's SDMMC *slot 0* (IOMUX pins CLK43/CMD44/D0-3 39-42); slot 1 is the @@ -53,5 +53,12 @@ set(WADA_DEFS foreach(_d ${WADA_DEFS}) idf_build_set_property(COMPILE_OPTIONS "-D${_d}" APPEND) endforeach() + +# Firmware version shown on the About page. The Tanmatsu builds outside the S3 release.sh flow (which +# injects these), so set them here or About shows only a generic "development build" with no version. +# Quotes are escaped so the compiler sees string literals. TODO: have build.sh pass the real tag/date +# per release (e.g. from git describe) instead of hardcoding; bump this each beta for now. +idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"beta_25\"" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"1 Jul 2026\"" APPEND) # (the adcAttachPin compat shim is force-included per-component in meshcore + wadamesh_app — # NOT globally, which would feed the C++ header to FreeRTOS's .S assembly files and break them.) diff --git a/tanmatsu/main/main.cpp b/tanmatsu/main/main.cpp index ef346a0..11092ae 100644 --- a/tanmatsu/main/main.cpp +++ b/tanmatsu/main/main.cpp @@ -14,6 +14,7 @@ #include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init() #include #include // DataStore persists on the internal 'locfd' FAT partition +#include // microSD (slot 0): reliable store for contacts/channels/chat (internal FFat loses them on this P4) #include "esp_partition.h" // enumerate partitions (AppFS apps boot fresh — verify locfd is visible) #include #include @@ -54,6 +55,7 @@ void halt() { while (1) { delay(1000); } } // DataStore takes — FILESYSTEM == fs::FS on ESP32). See the storage block in setup(). DataStore store(FFat, rtc_clock); bool g_fs_ok = false; // true once FFat(locfd) is mounted; UITask's file browser checks this (extern) +bool g_sd_ok = false; // true once the microSD (SD_MMC) is mounted; contacts/channels/chat persist there (extern) MultiTransportCompanionInterface serial_interface; StdRNG fast_rng; SimpleMeshTables tables; @@ -298,9 +300,12 @@ static void hostedConnectC6() { } static void wadameshSetup() { - Serial.begin(115200); + // Do NOT call Serial.begin() on the Tanmatsu (P4). The launcher already owns the USB-Serial-JTAG + // console; re-initialising it here crashed the app during boot (before the splash could even draw + // — the beta_21+ symptom of "blue screen, no boot log"). Serial output is swallowed on this board's + // IDF console anyway, so printf (not Serial) carries the [BOOT] logs. See tanmatsu-serial-begin memory. delay(150); - Serial.println("[BOOT] wadamesh / tanmatsu"); + printf("[BOOT] wadamesh / tanmatsu\n"); // badge-bsp: panel + input + power. The panel must be up before LVGL flushes to it; ask for // RGB565 to match LVGL's 16-bit color depth (lvglFlush -> writePixelsRGB565 -> bsp_display_blit). @@ -369,6 +374,13 @@ static void wadameshSetup() { } else { printf("[storage] persistence disabled this boot (DataStore opens fail gracefully)\n"); } + // The internal FFat 'locfd' on this P4 has a broken FAT metadata layer (see the tile-cache notes): + // it keeps rarely-written data (identity/prefs) but LOSES the frequently-rewritten contacts + chat on + // reboot. So route contacts/channels (and chat history, see uiDataFsReady) to the microSD card, which + // is reliable FAT. Identity/NodePrefs stay on FFat — they persist there and survive a card-less boot. + g_sd_ok = SD_MMC.begin("/sdcard", false /*1-bit*/) && SD_MMC.cardType() != CARD_NONE; + printf("[storage] SD_MMC.begin = %s\n", g_sd_ok ? "OK" : "no card"); + if (g_sd_ok) store.setSecondaryFS(&SD_MMC); // contacts + channels -> SD; identity/prefs stay on FFat store.begin(); bootLog("Mesh stack");