diff --git a/platformio.ini b/platformio.ini index 79e9c91..0ac9a99 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,6 +167,7 @@ build_flags = build_src_filter = +<*.cpp> + +<*.c> + + + @@ -341,6 +342,7 @@ build_flags = build_src_filter = +<*.cpp> + +<*.c> + + + diff --git a/release-notes/beta_35.txt b/release-notes/beta_35.txt new file mode 100644 index 0000000..0e3668b --- /dev/null +++ b/release-notes/beta_35.txt @@ -0,0 +1,11 @@ +# beta_35 (TEST) - V4 breathing room: freezes fixed, 30 KB of internal RAM back, crash-safe settings. On top of beta_34. +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Fixed --- +Heltec V4: multi-second freezes right after receiving messages are gone, including the screen refusing to wake from dark mode. Saving chat history to the internal flash could stall the device for up to 6 seconds (the V4 has no SD card, and internal flash pauses for housekeeping); that save now runs on a background task, so the screen and touch stay responsive no matter what storage is doing. +Turning Bluetooth on while Wi-Fi is running no longer reboots the device when memory is tight: the switch now checks free memory first and tells you "Not enough free memory for Bluetooth. Turn Wi-Fi off first." instead of crashing. +The same protection applies the other way around: turning Wi-Fi on with Bluetooth active used to claim "Wi-Fi on" while the radio silently never started. It now either really starts or tells you why it cannot. +Settings could vanish for a single boot after an unlucky reboot (the device came up with a default name once, then recovered). Settings writes are now crash-safe: a reboot or power cut at any moment leaves at least one intact copy on storage, the loader self-heals from it, and a failed save now says "Couldn't save the name to storage" instead of pretending it worked. + +# --- Improved --- +About 30 KB of the ESP32's scarce internal RAM has been freed by moving buffers to the external PSRAM and dropping unused USB components from the build. This is the headroom that makes Wi-Fi and Bluetooth run together comfortably again, especially on the Heltec V4. diff --git a/src/DataStore.cpp b/src/DataStore.cpp index 18c1f9f..a3ee0b0 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -242,10 +242,19 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) { if (_fs->exists(_rp("/new_prefs"))) { loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename + } else if (_fs->exists(_rp("/new_prefs.tmp"))) { + // Main file gone but a staged copy exists: a reboot landed between the temp + // write and the swap (or the swap was torn). Recover from it — this is the + // "device booted with the default name once" failure mode. + MESH_DEBUG_PRINTLN("DataStore: /new_prefs missing, recovering from .tmp"); + loadPrefsInt("/new_prefs.tmp", prefs, node_lat, node_lon); + savePrefs(prefs, node_lat, node_lon); // re-establish the main file } else if (_fs->exists(_rp("/node_prefs"))) { loadPrefsInt("/node_prefs", prefs, node_lat, node_lon); savePrefs(prefs, node_lat, node_lon); // save to new filename _fs->remove(_rp("/node_prefs")); // remove old + } else { + MESH_DEBUG_PRINTLN("DataStore: no prefs file found — using defaults"); } } @@ -339,8 +348,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no } } -void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { - File file = openWrite(_fs, "/new_prefs"); +bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { + // Write to a temp file first and swap it in afterwards: a reboot / power cut + // mid-write can then never destroy the only copy (the loader recovers from + // whichever file survived). SPIFFS has no atomic rename-over, so the swap is + // remove+rename — the loader handles the tiny between-steps window too. + File file = openWrite(_fs, "/new_prefs.tmp"); if (file) { uint8_t pad[8]; memset(pad, 0, sizeof(pad)); @@ -377,10 +390,17 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 91 file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 92 file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 93 - file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125 + size_t last = file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125 file.close(); + if (last != sizeof(_prefs.default_scope_key)) { + _fs->remove(_rp("/new_prefs.tmp")); // short write (storage full?) — keep the old main file + return false; + } + _fs->remove(_rp("/new_prefs")); + return _fs->rename(_rp("/new_prefs.tmp"), _rp("/new_prefs")); } + return false; } void DataStore::loadContacts(DataStoreHost* host) { diff --git a/src/DataStore.h b/src/DataStore.h index 39d3778..9bc44cf 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -52,7 +52,10 @@ public: bool loadMainIdentity(mesh::LocalIdentity &identity); bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); - void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); + // Crash-safe: writes /new_prefs.tmp, then swaps it in. Returns false when the + // write or the swap failed (storage full / torn) so callers can surface it — + // the save used to fail silently and the UI toasted "saved" regardless. + bool savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); void loadContacts(DataStoreHost* host); void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); void loadChannels(DataStoreHost* host); diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index f93886f..a9e398d 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -265,7 +265,15 @@ struct MeshcomodCmdCacheEntry { uint32_t seen_ms; char text[128]; }; -static MeshcomodCmdCacheEntry s_meshcomod_cmd_cache[MESHCOMOD_CMD_CACHE_SIZE] = {}; +// PSRAM-first (internal fallback), zero-initialized (0.8 KB off internal .bss). +static void* msPsAlloc(size_t n) { + void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM); + if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT); + if (p) memset(p, 0, n); + return p; +} +static MeshcomodCmdCacheEntry* s_meshcomod_cmd_cache = + (MeshcomodCmdCacheEntry*)msPsAlloc(sizeof(MeshcomodCmdCacheEntry) * MESHCOMOD_CMD_CACHE_SIZE); static int s_meshcomod_cmd_cache_next = 0; static uint32_t s_meshcomod_last_reply_ts = 0; static uint32_t s_last_cmd_txt_ts = 0; diff --git a/src/MyMesh.h b/src/MyMesh.h index 59e0fdb..db7ff39 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -731,7 +731,7 @@ public: private: public: - void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + bool savePrefs() { return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } // Set the default flood scope (region) used to tag outgoing flood packets, so // repeaters on region-scoped networks ("flood only for their region") will diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index f7b7fc0..3fa9a12 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1646,15 +1646,23 @@ void touchPrefsSetKbdBacklight(uint8_t pct) { if (pct > 100) pct = 100; if (!s_b // Per-channel mute (name-keyed NVS blob "chm" + tiny RAM cache) -------------- static const char* KEY_CHM = "chm"; static const int CHM_ENTRY = TOUCH_CHMUTE_NAME + 1; // 32-byte name + 1 flag byte -static uint8_t s_chm[TOUCH_CHMUTE_MAX * CHM_ENTRY]; +// PSRAM-first (internal fallback), zero-initialized — keeps these keyed tables +// off the scarce internal SRAM (same pattern as UITask's psAlloc). +static void* tpPsAlloc(size_t n) { + void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM); + if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT); + if (p) memset(p, 0, n); + return p; +} +static uint8_t* s_chm = (uint8_t*)tpPsAlloc(TOUCH_CHMUTE_MAX * CHM_ENTRY); static int s_chm_n = -1; // -1 = not loaded yet static void chmLoad() { if (s_chm_n >= 0) return; s_chm_n = 0; if (!s_begun) touchPrefsBegin(); if (!s_prefs.isKey(KEY_CHM)) return; - size_t n = s_prefs.getBytes(KEY_CHM, s_chm, sizeof(s_chm)); - if (n == 0 || n > sizeof(s_chm)) { s_chm_n = 0; return; } + size_t n = s_prefs.getBytes(KEY_CHM, s_chm, (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY)); + if (n == 0 || n > (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY)) { s_chm_n = 0; return; } s_chm_n = (int)(n / CHM_ENTRY); } static int chmFind(const char* name) { @@ -1703,15 +1711,15 @@ static const int CHE_GLYPH = 16; static const int CHE_ENTRY = CHE_NAME + CHE_GLYPH; static const int CHE_MAX = 24; static const char* KEY_CHE = "chemoji"; -static uint8_t s_che[CHE_MAX * CHE_ENTRY]; +static uint8_t* s_che = (uint8_t*)tpPsAlloc(CHE_MAX * CHE_ENTRY); static int s_che_n = -1; // -1 = not loaded yet static void cheLoad() { if (s_che_n >= 0) return; s_che_n = 0; if (!s_begun) touchPrefsBegin(); if (!s_prefs.isKey(KEY_CHE)) return; - size_t n = s_prefs.getBytes(KEY_CHE, s_che, sizeof(s_che)); - if (n == 0 || n > sizeof(s_che)) { s_che_n = 0; return; } + size_t n = s_prefs.getBytes(KEY_CHE, s_che, (size_t)(CHE_MAX * CHE_ENTRY)); + if (n == 0 || n > (size_t)(CHE_MAX * CHE_ENTRY)) { s_che_n = 0; return; } s_che_n = (int)(n / CHE_ENTRY); } static int cheFind(const char* name) { diff --git a/src/main.cpp b/src/main.cpp index 525129e..9f07e7b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -67,7 +67,21 @@ static uint32_t _atoi(const char* sp) { #ifdef MULTI_TRANSPORT_COMPANION #include #include "helpers/esp32/MqttBridge.h" - MultiTransportCompanionInterface serial_interface; + #include + #include + // ~9.2 KB of TCP/WS/USB framing buffers. Internal DRAM is the scarce pool on + // the touch boards (Wi-Fi + BLE coexistence needs ~50 KB free), and none of + // these buffers are touched from ISR context, so build the whole object in + // PSRAM (heap is up before C++ static init on ESP32; falls back to internal + // RAM if PSRAM is absent). In-TU init order runs this before + // ui_task(&serial_interface) further down. + static void* s_si_mem = [] { + void* p = heap_caps_malloc(sizeof(MultiTransportCompanionInterface), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return p ? p : malloc(sizeof(MultiTransportCompanionInterface)); + }(); + MultiTransportCompanionInterface& serial_interface = + *new (s_si_mem) MultiTransportCompanionInterface(); #ifndef TCP_PORT #define TCP_PORT 5000 #endif diff --git a/src/ui-touch/KeyboardLayouts.cpp b/src/ui-touch/KeyboardLayouts.cpp index a036c8c..d4efc26 100644 --- a/src/ui-touch/KeyboardLayouts.cpp +++ b/src/ui-touch/KeyboardLayouts.cpp @@ -27,7 +27,7 @@ static constexpr lv_btnmatrix_ctrl_t KC(uint8_t width_mul) { /* ---------- Bulgarian on-screen keyboard ---------- */ /* Lower-case Cyrillic, 3 letter rows + 1 control row. */ -static const char* kb_bg_lower[] = { +static const char* const kb_bg_lower[] = { "я","в","е","р","т","ъ","у","и","о","п","\n", "а","с","д","ф","г","х","й","к","л","ь","\n", "з","ц","ч","ж","б","н","м","ш","щ","ю","\n", @@ -42,7 +42,7 @@ static const lv_btnmatrix_ctrl_t kb_bg_lower_ctrl[] = { }; /* Upper-case Cyrillic. */ -static const char* kb_bg_upper[] = { +static const char* const kb_bg_upper[] = { "Я","В","Е","Р","Т","Ъ","У","И","О","П","\n", "А","С","Д","Ф","Г","Х","Й","К","Л","Ь","\n", "З","Ц","Ч","Ж","Б","Н","М","Ш","Щ","Ю","\n", @@ -58,7 +58,7 @@ static const lv_btnmatrix_ctrl_t kb_bg_upper_ctrl[] = { /* ---------- Russian (ЙЦУКЕН) on-screen keyboard ---------- */ /* Standard Russian layout, 3 rows of 11 = all 33 letters. */ -static const char* kb_ru_lower[] = { +static const char* const kb_ru_lower[] = { "й","ц","у","к","е","н","г","ш","щ","з","х","\n", "ф","ы","в","а","п","р","о","л","д","ж","э","\n", "я","ч","с","м","и","т","ь","б","ю","ъ","ё","\n", @@ -72,7 +72,7 @@ static const lv_btnmatrix_ctrl_t kb_ru_lower_ctrl[] = { KC(2), KC(1), KC(4), KC(2), KC(1) }; -static const char* kb_ru_upper[] = { +static const char* const kb_ru_upper[] = { "Й","Ц","У","К","Е","Н","Г","Ш","Щ","З","Х","\n", "Ф","Ы","В","А","П","Р","О","Л","Д","Ж","Э","\n", "Я","Ч","С","М","И","Т","Ь","Б","Ю","Ъ","Ё","\n", @@ -88,7 +88,7 @@ static const lv_btnmatrix_ctrl_t kb_ru_upper_ctrl[] = { /* ---------- Ukrainian on-screen keyboard ---------- */ /* Standard Ukrainian layout, 3 rows of 11 = all 33 letters. */ -static const char* kb_uk_lower[] = { +static const char* const kb_uk_lower[] = { "й","ц","у","к","е","н","г","ш","щ","з","х","\n", "ф","і","в","а","п","р","о","л","д","ж","є","\n", "я","ч","с","м","и","т","ь","б","ю","ї","ґ","\n", @@ -100,7 +100,7 @@ static const lv_btnmatrix_ctrl_t kb_uk_lower_ctrl[] = { 0,0,0,0,0,0,0,0,0,0,0, KC(2), KC(1), KC(4), KC(2), KC(1) }; -static const char* kb_uk_upper[] = { +static const char* const kb_uk_upper[] = { "Й","Ц","У","К","Е","Н","Г","Ш","Щ","З","Х","\n", "Ф","І","В","А","П","Р","О","Л","Д","Ж","Є","\n", "Я","Ч","С","М","И","Т","Ь","Б","Ю","Ї","Ґ","\n", @@ -115,7 +115,7 @@ static const lv_btnmatrix_ctrl_t kb_uk_upper_ctrl[] = { /* ---------- Serbian (Cyrillic) on-screen keyboard ---------- */ /* Serbian azbuka, 3 rows of 10 = all 30 letters. */ -static const char* kb_sr_lower[] = { +static const char* const kb_sr_lower[] = { "љ","њ","е","р","т","з","у","и","о","п","\n", "а","с","д","ф","г","х","ј","к","л","ч","\n", "ж","џ","ц","в","б","н","м","ђ","ћ","ш","\n", @@ -127,7 +127,7 @@ static const lv_btnmatrix_ctrl_t kb_sr_lower_ctrl[] = { 0,0,0,0,0,0,0,0,0,0, KC(2), KC(1), KC(4), KC(2), KC(1) }; -static const char* kb_sr_upper[] = { +static const char* const kb_sr_upper[] = { "Љ","Њ","Е","Р","Т","З","У","И","О","П","\n", "А","С","Д","Ф","Г","Х","Ј","К","Л","Ч","\n", "Ж","Џ","Ц","В","Б","Н","М","Ђ","Ћ","Ш","\n", @@ -142,7 +142,7 @@ static const lv_btnmatrix_ctrl_t kb_sr_upper_ctrl[] = { /* ---------- Greek on-screen keyboard ---------- */ /* ΕΛΟΤ-style positions; 9+9+7 = all 24 letters (+ final sigma ς). */ -static const char* kb_el_lower[] = { +static const char* const kb_el_lower[] = { "ς","ε","ρ","τ","υ","θ","ι","ο","π","\n", "α","σ","δ","φ","γ","η","ξ","κ","λ","\n", "ζ","χ","ψ","ω","β","ν","μ","\n", @@ -154,7 +154,7 @@ static const lv_btnmatrix_ctrl_t kb_el_lower_ctrl[] = { 0,0,0,0,0,0,0, KC(2), KC(1), KC(4), KC(2), KC(1) }; -static const char* kb_el_upper[] = { +static const char* const kb_el_upper[] = { "Σ","Ε","Ρ","Τ","Υ","Θ","Ι","Ο","Π","\n", "Α","Σ","Δ","Φ","Γ","Η","Ξ","Κ","Λ","\n", "Ζ","Χ","Ψ","Ω","Β","Ν","Μ","\n", @@ -171,7 +171,7 @@ static const lv_btnmatrix_ctrl_t kb_el_upper_ctrl[] = { /* Arabic-101 letter positions, 3 rows of 11 = all 28 letters + ة ى ء ئ ؤ و. * Arabic is unicameral, so the same map serves LOWER and UPPER. The text * field renders RTL + shaped via LV_USE_BIDI / LV_USE_ARABIC_PERSIAN_CHARS. */ -static const char* kb_ar_lower[] = { +static const char* const kb_ar_lower[] = { "ض","ص","ث","ق","ف","غ","ع","ه","خ","ح","ج","\n", "ش","س","ي","ب","ل","ا","ت","ن","م","ك","ط","\n", "ئ","ء","ؤ","ر","ى","ة","و","ز","ظ","ذ","د","\n", @@ -193,7 +193,7 @@ static const lv_btnmatrix_ctrl_t kb_ar_lower_ctrl[] = { * copy to restore it when the user cycles back to English. EN_KB_BTN mirrors the * library's private LV_KB_BTN (popover preview + width). */ #define EN_KB_BTN(w) (LV_BTNMATRIX_CTRL_POPOVER | (w)) -static const char* kb_en_lower[] = { +static const char* const kb_en_lower[] = { "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", @@ -207,7 +207,7 @@ static const lv_btnmatrix_ctrl_t kb_en_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_en_upper[] = { +static const char* const kb_en_upper[] = { "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", @@ -224,7 +224,7 @@ static const lv_btnmatrix_ctrl_t kb_en_upper_ctrl[] = { /* ---------- French (AZERTY) on-screen keyboard ---------- */ /* Keeps the proven LVGL control rows/punctuation, but swaps the alpha rows to * a familiar French AZERTY order. Accents still come from the existing popup. */ -static const char* kb_fr_lower[] = { +static const char* const kb_fr_lower[] = { "1#", "a", "z", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "q", "s", "d", "f", "g", "h", "j", "k", "l", "m", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "", "\n", @@ -238,7 +238,7 @@ static const lv_btnmatrix_ctrl_t kb_fr_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_fr_upper[] = { +static const char* const kb_fr_upper[] = { "1#", "A", "Z", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "Q", "S", "D", "F", "G", "H", "J", "K", "L", "M", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "", "\n", @@ -254,7 +254,7 @@ static const lv_btnmatrix_ctrl_t kb_fr_upper_ctrl[] = { /* Dutch largely stays QWERTY, but a dedicated IJ key makes the most common * digraph directly reachable without forcing users through the accent popup. */ -static const char* kb_nl_lower[] = { +static const char* const kb_nl_lower[] = { "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", "ij", "\n", @@ -268,7 +268,7 @@ static const lv_btnmatrix_ctrl_t kb_nl_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_nl_upper[] = { +static const char* const kb_nl_upper[] = { "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", "IJ", "\n", @@ -284,7 +284,7 @@ static const lv_btnmatrix_ctrl_t kb_nl_upper_ctrl[] = { /* German QWERTZ keeps the proven LVGL control rows and swaps the Y/Z alpha * positions. Umlauts / eszett still come from the accent popup. */ -static const char* kb_de_lower[] = { +static const char* const kb_de_lower[] = { "1#", "q", "w", "e", "r", "t", "z", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "y", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", @@ -298,7 +298,7 @@ static const lv_btnmatrix_ctrl_t kb_de_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_de_upper[] = { +static const char* const kb_de_upper[] = { "1#", "Q", "W", "E", "R", "T", "Z", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "Y", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", @@ -313,7 +313,7 @@ static const lv_btnmatrix_ctrl_t kb_de_upper_ctrl[] = { }; /* Spanish keeps QWERTY but promotes n-tilde directly onto the alpha deck. */ -static const char* kb_es_lower[] = { +static const char* const kb_es_lower[] = { "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "ñ", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", @@ -327,7 +327,7 @@ static const lv_btnmatrix_ctrl_t kb_es_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_es_upper[] = { +static const char* const kb_es_upper[] = { "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "Ñ", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", @@ -342,7 +342,7 @@ static const lv_btnmatrix_ctrl_t kb_es_upper_ctrl[] = { }; /* Italian gets the common accented vowels directly on the base layer. */ -static const char* kb_it_lower[] = { +static const char* const kb_it_lower[] = { "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "à", LV_SYMBOL_NEW_LINE, "\n", "è", "ì", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", @@ -356,7 +356,7 @@ static const lv_btnmatrix_ctrl_t kb_it_lower_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; -static const char* kb_it_upper[] = { +static const char* const kb_it_upper[] = { "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "À", LV_SYMBOL_NEW_LINE, "\n", "È", "Ì", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", @@ -373,9 +373,9 @@ static const lv_btnmatrix_ctrl_t kb_it_upper_ctrl[] = { struct OsKeyboardLayout { KeyboardLayoutId id; const char* name; - const char** lower_map; + const char* const* lower_map; const lv_btnmatrix_ctrl_t* lower_ctrl; - const char** upper_map; + const char* const* upper_map; const lv_btnmatrix_ctrl_t* upper_ctrl; }; @@ -691,8 +691,8 @@ void keyboardLayoutsApply(lv_obj_t* keyboard, KeyboardLayoutId id) { s_current_layout = id; const OsKeyboardLayout& lo = k_os_layouts[static_cast(id)]; - lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER, lo.lower_map, lo.lower_ctrl); - lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_UPPER, lo.upper_map, lo.upper_ctrl); + lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER, const_cast(lo.lower_map), lo.lower_ctrl); // maps now in flash; LVGL never writes them + lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_UPPER, const_cast(lo.upper_map), lo.upper_ctrl); } KeyboardLayoutId keyboardLayoutsGetCurrent() { diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2b7e2dd..017118e 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -981,7 +981,10 @@ struct LvContactButtonCtx { lv_obj_t* age_lbl; // the row's Heard label — updated in place on the 60s age tick (#82) }; -static LvContactButtonCtx s_contacts_ctx[128]; +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 { @@ -1067,7 +1070,8 @@ struct LvUiState { // ---- 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_LINES][DIAG_COLS]; +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 @@ -3648,6 +3652,15 @@ static bool s_verchk_recheck = false; // force a fresh check now (e.g // 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 +// 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; // snapshot armed, waiting for the worker +static volatile bool s_hist_flush_busy = false; // worker owns the snapshot + the history file +static volatile bool s_hist_flush_ok = true; // last worker write result (retry on false) +static bool uiHistWorkerFlush(); // defined with the storage code below 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; @@ -6183,7 +6196,13 @@ static void toggleTcpCb(lv_event_t* e) { static void toggleBleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task || !g_lv.task->hasBleCapability()) return; - g_lv.task->isBleEnabled() ? g_lv.task->disableBle() : g_lv.task->enableBle(); + if (g_lv.task->isBleEnabled()) { + g_lv.task->disableBle(); + } else if (!g_lv.task->enableBle()) { + g_lv.task->showAlert(TR("Not enough free memory for Bluetooth. Turn Wi-Fi off first."), 2200); + refreshStatusLabels(); + return; + } g_lv.task->showAlert(g_lv.task->isBleEnabled() ? TR("BLE on") : TR("BLE off"), 900); refreshStatusLabels(); } @@ -6857,6 +6876,8 @@ static void saveProfileNameCb(lv_event_t* e) { 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); } } @@ -10734,7 +10755,15 @@ static void bleEnableSwitchCb(lv_event_t* e) { 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); if (want == g_lv.task->isBleEnabled()) return; - if (want) g_lv.task->enableBle(); else g_lv.task->disableBle(); + if (want) { + if (!g_lv.task->enableBle()) { + lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); // revert the switch + g_lv.task->showAlert(TR("Not enough free memory for Bluetooth. Turn Wi-Fi off first."), 2200); + 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 @@ -11106,6 +11135,19 @@ static void wifiScanOpenAndKick() { s_wifiscan_request = true; } +// 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 — the UI +// toasted "Wi-Fi on" while the radio never came up. Refuse at the toggle instead, +// symmetrically with UITask::enableBle(). Thresholds match the boot co-init guard. +static bool wifiEnableGuardOk() { +#if defined(ESP32) + return ESP.getFreeHeap() >= 50u * 1024u && ESP.getMaxAllocHeap() >= 20u * 1024u; +#else + return true; +#endif +} + // "Scan" button -> open the popup + queue a scan on the core-0 worker. If Wi-Fi // is off (BLE active), bring the radio up LIVE first — it coexists with NimBLE, // no reboot — then scan; the worker brings STA up and lists networks. @@ -11113,6 +11155,10 @@ 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 (!wifiEnableGuardOk()) { + if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); + return; + } wifiConfigSetRadioEnabled(true); // wantsWifi() now true -> the scan worker can bring STA up wifiConfigRequestApply(); // main loop brings esp_wifi up live (no reboot) if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi on, scanning\xE2\x80\xA6"), 1200); @@ -11191,6 +11237,11 @@ 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 && !wifiEnableGuardOk()) { + lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); // revert the switch + if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); + return; + } wifiConfigSetRadioEnabled(on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi on") : TR("Wi-Fi off"), 800); refreshStatusLabels(); @@ -12207,7 +12258,8 @@ 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}; -static char s_admin_log[1024] = {0}; // ring-ish log buffer +#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. @@ -12360,7 +12412,7 @@ static void closeAdminConsole() { static void adminLogAppend(const char* prefix, const char* text) { if (!text) return; - size_t cap = sizeof(s_admin_log); + 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. @@ -19143,7 +19195,7 @@ static void openContactsOverflowSheetCb(lv_event_t* e) { // Contacts — Sort & filter sheet + multi-select delete // ============================================================ static bool s_ct_select_mode = false; -static uint8_t s_ct_sel[128][6]; // pub_key prefix of currently-selected (deletable) contacts +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 @@ -20025,7 +20077,7 @@ struct MapTile { bool in_use; // true = slot's (z,x,y) is valid; false = empty slot }; -static MapTile s_map_tiles[k_map_visible_tiles_max] = {}; +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: @@ -20494,6 +20546,16 @@ static void tileFetchTaskFn(void* arg) { s_los_result_ready = true; continue; } + // Chat-history flush: write the loop thread's snapshot. 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 = uiHistWorkerFlush(); + s_hist_flush_busy = false; + continue; + } // Firmware update check (one-shot, infrequent). Reuses this worker's stack. if (s_verchk_request) { s_verchk_request = false; @@ -21054,7 +21116,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, // Free everything currently in the tile cache. static void freeMapTiles() { - for (auto& t : s_map_tiles) freeMapTileSlot(t); + for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) freeMapTileSlot(s_map_tiles[_ti]); } #if defined(ESP32) @@ -21189,7 +21251,7 @@ static void renderMapTiles() { renderMapMarkers(); // Pass 1 — keep matching slots, free non-matching ones. - for (auto& t : s_map_tiles) { + 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) { freeMapTileSlot(t); continue; } bool kept = false; @@ -21217,7 +21279,7 @@ static void renderMapTiles() { if (wanted[i].placed) { any_loaded = true; continue; } // Find an empty slot. MapTile* dst = nullptr; - for (auto& t : s_map_tiles) { + 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 @@ -21444,7 +21506,7 @@ static void applyMapTextVis() { // 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[k_route_max] = {}; +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 @@ -26340,7 +26402,7 @@ static void refreshContactsList() { 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 >= (int)(sizeof(s_contacts_ctx)/sizeof(s_contacts_ctx[0]))) break; + if (k >= CONTACTS_CTX_MAX) break; const Entry& e = s_entries[k]; const bool is_rep = (e.type == ADV_TYPE_REPEATER); @@ -26459,7 +26521,7 @@ static void refreshContactsList() { // 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 = (int)(sizeof(s_contacts_ctx) / sizeof(s_contacts_ctx[0])); + 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); @@ -28524,6 +28586,11 @@ static void ccWifiCb(lv_event_t* e) { // Live: the main loop brings esp_wifi up (WiFi.mode/begin) or down (WIFI_OFF) // in response to this pref — no reboot. const bool on = wifiConfigGetRadioEnabled(); + if (!on && !wifiEnableGuardOk()) { + if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); + openControlCenter(); + return; + } wifiConfigSetRadioEnabled(!on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi off") : TR("Wi-Fi on"), 800); openControlCenter(); @@ -28536,7 +28603,13 @@ static void ccBleCb(lv_event_t* e) { #if defined(ESP32) // Live: enableBle() lazily brings NimBLE up if it wasn't started at boot. const bool on = g_lv.task->isBleEnabled(); - on ? g_lv.task->disableBle() : g_lv.task->enableBle(); + if (on) { + g_lv.task->disableBle(); + } else if (!g_lv.task->enableBle()) { + g_lv.task->showAlert(TR("Not enough free memory for Bluetooth. Turn Wi-Fi off first."), 2200); + openControlCenter(); + return; + } g_lv.task->showAlert(on ? TR("Bluetooth off") : TR("Bluetooth on"), 800); openControlCenter(); #endif @@ -33948,14 +34021,54 @@ void UITask::markMsgsDirty(unsigned long delay_ms) { markThreadsDirty(1500); // thread state (last_ts, unread) changed too — coalesce a message burst into one write } +// Snapshot of the message ring handed to the core-0 worker for the off-thread +// write. Allocated once in PSRAM (ring-sized: ~115 KB SPIFFS ring / ~1.1 MB SD +// ring); the worker only ever touches the snapshot, never the live ring. +static UITask::UIMessage* s_hist_snap = nullptr; +static int s_hist_snap_cap = 0; +static uint16_t s_hist_snap_count = 0; +static uint16_t s_hist_snap_head = 0; +static uint32_t s_hist_snap_msgcount = 0; + void UITask::flushHistoryIfDue(unsigned long now) { + // A worker write failed (storage hiccup): re-arm and try again. + if (!s_hist_flush_ok) { s_hist_flush_ok = true; markMsgsDirty(5000); } // 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 (saveThreadsToStorage()) _threads_dirty = false; + 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) { + _next_msgs_flush_ms = now + 1000; // one flush in flight; new messages ride the next one + return; + } +#if defined(ESP32) + // Snapshot the ring (a few ms of PSRAM memcpy) and hand the write to the + // core-0 worker. Messages arriving while the worker writes stay in the live + // ring and re-arm the dirty flag, so they land in the next flush. + if (!s_hist_snap || s_hist_snap_cap != _ui_msg_cap) { + if (s_hist_snap) { heap_caps_free(s_hist_snap); s_hist_snap = nullptr; } + s_hist_snap = (UITask::UIMessage*)heap_caps_malloc(sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + s_hist_snap_cap = s_hist_snap ? _ui_msg_cap : 0; + } + if (s_hist_snap) { + memcpy(s_hist_snap, _ui_msgs, sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap); + s_hist_snap_count = (uint16_t)_ui_msg_count; + s_hist_snap_head = (uint16_t)_ui_msg_head; + s_hist_snap_msgcount = (uint32_t)_msgcount; + s_hist_flush_req = true; // worker picks it up + _msgs_dirty = false; + return; + } +#endif + // No PSRAM for a snapshot: fall back to the old synchronous write. if (saveMsgsToStorage()) _msgs_dirty = false; else _next_msgs_flush_ms = now + 2000; } @@ -34319,16 +34432,17 @@ bool UITask::saveThreadsToStorage() { #endif } -bool UITask::saveMsgsToStorage() { +// 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. +static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, + uint16_t count, uint16_t head, uint32_t msgcount) { #if defined(ESP32) - // 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 — a full-ring rewrite froze the UI 1-2 s per incoming message on - // busy meshes. 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), and the per-record field copies are unchanged. Alloc failure falls - // back to the original per-record writes. WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC File f = uiDataOpen(k_ui_msgs_path, "w"); if (!f) return false; @@ -34337,9 +34451,9 @@ bool UITask::saveMsgsToStorage() { hdr.magic = k_ui_msgs_magic; hdr.version = k_ui_history_version; hdr.msg_rec_size = static_cast(sizeof(UiHistoryMsg)); - hdr.ui_msg_count = static_cast(_ui_msg_count); - hdr.ui_msg_head = static_cast(_ui_msg_head); - hdr.msgcount = static_cast(_msgcount); + hdr.ui_msg_count = count; + hdr.ui_msg_head = head; + hdr.msgcount = msgcount; if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; } @@ -34351,22 +34465,22 @@ bool UITask::saveMsgsToStorage() { bool ok = true; if (buf) { size_t fill = 0; - for (int i = 0; ok && i < _ui_msg_cap; ++i) { + for (int i = 0; ok && i < cap; ++i) { UiHistoryMsg* m = reinterpret_cast(buf + fill); memset(m, 0, REC); - m->ts = _ui_msgs[i].ts; - m->channel = _ui_msgs[i].channel ? 1u : 0u; - m->outgoing = _ui_msgs[i].outgoing ? 1u : 0u; - m->meta_flags = _ui_msgs[i].meta_flags; - m->path_len = _ui_msgs[i].path_len; - m->snr_q4 = _ui_msgs[i].snr_q4; - m->rssi = _ui_msgs[i].rssi; - strncpy(m->thread, _ui_msgs[i].thread, MAX_THREAD_NAME); - m->thread[MAX_THREAD_NAME] = '\0'; - strncpy(m->sender, _ui_msgs[i].sender, MAX_SENDER_NAME); - m->sender[MAX_SENDER_NAME] = '\0'; - strncpy(m->text, _ui_msgs[i].text, MAX_MSG_TEXT); - m->text[MAX_MSG_TEXT] = '\0'; + m->ts = msgs[i].ts; + m->channel = msgs[i].channel ? 1u : 0u; + m->outgoing = msgs[i].outgoing ? 1u : 0u; + m->meta_flags = msgs[i].meta_flags; + m->path_len = msgs[i].path_len; + m->snr_q4 = msgs[i].snr_q4; + m->rssi = msgs[i].rssi; + strncpy(m->thread, msgs[i].thread, sizeof(m->thread) - 1); + m->thread[sizeof(m->thread) - 1] = '\0'; + strncpy(m->sender, msgs[i].sender, sizeof(m->sender) - 1); + m->sender[sizeof(m->sender) - 1] = '\0'; + strncpy(m->text, msgs[i].text, sizeof(m->text) - 1); + m->text[sizeof(m->text) - 1] = '\0'; fill += REC; if (fill == REC * chunk_recs) { ok = (f.write(buf, fill) == fill); @@ -34377,26 +34491,48 @@ bool UITask::saveMsgsToStorage() { free(buf); } else { UiHistoryMsg m{}; - for (int i = 0; ok && i < _ui_msg_cap; ++i) { + for (int i = 0; ok && i < cap; ++i) { memset(&m, 0, sizeof(m)); - m.ts = _ui_msgs[i].ts; - m.channel = _ui_msgs[i].channel ? 1u : 0u; - m.outgoing = _ui_msgs[i].outgoing ? 1u : 0u; - m.meta_flags = _ui_msgs[i].meta_flags; - m.path_len = _ui_msgs[i].path_len; - m.snr_q4 = _ui_msgs[i].snr_q4; - m.rssi = _ui_msgs[i].rssi; - strncpy(m.thread, _ui_msgs[i].thread, MAX_THREAD_NAME); - m.thread[MAX_THREAD_NAME] = '\0'; - strncpy(m.sender, _ui_msgs[i].sender, MAX_SENDER_NAME); - m.sender[MAX_SENDER_NAME] = '\0'; - strncpy(m.text, _ui_msgs[i].text, MAX_MSG_TEXT); - m.text[MAX_MSG_TEXT] = '\0'; + m.ts = msgs[i].ts; + m.channel = msgs[i].channel ? 1u : 0u; + m.outgoing = msgs[i].outgoing ? 1u : 0u; + m.meta_flags = msgs[i].meta_flags; + m.path_len = msgs[i].path_len; + m.snr_q4 = msgs[i].snr_q4; + m.rssi = msgs[i].rssi; + strncpy(m.thread, msgs[i].thread, sizeof(m.thread) - 1); + m.thread[sizeof(m.thread) - 1] = '\0'; + strncpy(m.sender, msgs[i].sender, sizeof(m.sender) - 1); + m.sender[sizeof(m.sender) - 1] = '\0'; + strncpy(m.text, msgs[i].text, sizeof(m.text) - 1); + m.text[sizeof(m.text) - 1] = '\0'; ok = (f.write(reinterpret_cast(&m), sizeof(m)) == sizeof(m)); } } f.close(); return ok; +#else + (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; + return false; +#endif +} + +// Worker-side entry: write the snapshot the loop thread armed (core-0 task; the +// loop thread never waits on this, which is the whole point). +static bool uiHistWorkerFlush() { + if (!s_hist_snap || s_hist_snap_cap <= 0) return true; // nothing armed + return uiWriteMsgsFile(s_hist_snap, s_hist_snap_cap, + s_hist_snap_count, s_hist_snap_head, s_hist_snap_msgcount); +} + +bool UITask::saveMsgsToStorage() { +#if defined(ESP32) + // Synchronous write of the LIVE ring — shutdown/reboot and the no-PSRAM + // fallback only; the periodic flush goes through the worker snapshot. + return uiWriteMsgsFile(_ui_msgs, _ui_msg_cap, + static_cast(_ui_msg_count), + static_cast(_ui_msg_head), + static_cast(_msgcount)); #else return false; #endif @@ -36123,8 +36259,9 @@ bool UITask::setNodeName(const char* s) { 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'; - the_mesh.savePrefs(); - return true; + // 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) { @@ -36451,7 +36588,33 @@ bool UITask::sendSignalProbe() { 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; + s_hist_flush_req = false; + const uint32_t t0 = millis(); + while (s_hist_flush_busy && (uint32_t)(millis() - t0) < 9000) delay(10); + return cancelled; +} + +bool UITask::enableBle() { + if (!_serial) return false; +#if defined(ESP32) + // Same thresholds as the boot-time co-init guard (main.cpp) — keep in sync. + const size_t BLE_COEXIST_MIN_FREE = 50u * 1024u; + const size_t BLE_COEXIST_MIN_BLOCK = 20u * 1024u; + if (ESP.getFreeHeap() < BLE_COEXIST_MIN_FREE || ESP.getMaxAllocHeap() < BLE_COEXIST_MIN_BLOCK) + return false; +#endif + _serial->enableBle(); + return true; +} + void UITask::persistHistoryNow() { + uiHistWaitWorkerIdle(); // writes below cover strictly newer data saveThreadsToStorage(); saveMsgsToStorage(); } @@ -36460,6 +36623,7 @@ void UITask::rebootDevice() { // 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. + if (uiHistWaitWorkerIdle()) _msgs_dirty = true; // cancelled snapshot = unwritten data if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty) saveMsgsToStorage(); discoveredFlushNow(); // persist the Discovered ring before we go down diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index e3bcea4..12b46af 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -443,7 +443,12 @@ public: void disableTcp() { if (_serial) _serial->disableTcp(); } bool hasBleCapability() const { return _serial && _serial->hasBleCapability(); } bool isBleEnabled() const { return _serial && _serial->isBleEnabled(); } - void enableBle() { if (_serial) _serial->enableBle(); } + // Live BLE enable, guarded like the boot co-init in main.cpp: NimBLE needs + // ~50 KB free internal heap + a 20 KB contiguous block, and starting it below + // that does not fail cleanly — it panics mid-init (the "reboots when I turn + // BLE on with Wi-Fi running" report). Returns false when refused; the caller + // shows the reason and reverts its switch. Defined in UITask.cpp. + bool enableBle(); void disableBle() { if (_serial) _serial->disableBle(); } int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; } void setDeviceTimeFromSystemClock(); diff --git a/src/usb_unused_class_stubs.c b/src/usb_unused_class_stubs.c new file mode 100644 index 0000000..155eef4 --- /dev/null +++ b/src/usb_unused_class_stubs.c @@ -0,0 +1,60 @@ +/******************************************************************************* + * usb_unused_class_stubs.c — keep TinyUSB's MSC + DFU-mode class drivers OUT of + * the link (~8.2 KB of internal-DRAM .bss: _mscd_buf 4096 + _dfu_ctx 4116). + * + * Why this works: the precompiled libarduino_tinyusb.a was built with every + * device class enabled, so usbd.c's class-driver table references mscd_* and + * dfu_moded_* unconditionally, which drags msc_device.c.obj / dfu_device.c.obj + * (and their big static buffers) into every USB-OTG build. Providing these + * symbols ourselves satisfies usbd's references first, so the archive members + * are never pulled in. + * + * Why it is safe: an interface class only becomes live if something calls + * tinyusb_enable_interface() for it — this firmware (and the Arduino core init + * for a CDC-on-boot build) only ever registers CDC. Verified in the linker map: + * the ONLY referencer of these objects is usbd.c.obj's driver table, and the + * wadamesh tree contains no USBMSC / FirmwareMSC / DFU usage. usbd still calls + * every table entry's init()/reset() on startup and bus reset, which is why + * these are no-ops rather than absent; open() returning 0 means "interface not + * claimed" and is never reached anyway (no such interface descriptor exists). + * + * Deliberately NOT stubbed: cdcd_* (the companion/console link) and dfu_rtd_* + * (runtime-DFU is tiny and part of the reset-to-bootloader plumbing). + * + * V4 only in practice: the T-Deck ships ARDUINO_USB_MODE=1 (HW-CDC, no TinyUSB + * device stack linked), where these definitions are simply dead code. + ******************************************************************************/ +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) + +#include +#include + +/* --- MSC class driver (tinyusb usbd_pvt.h driver-table signatures) --- */ +void mscd_init(void) {} +void mscd_reset(uint8_t rhport) { (void)rhport; } +uint16_t mscd_open(uint8_t rhport, const void* itf_desc, uint16_t max_len) { + (void)rhport; (void)itf_desc; (void)max_len; + return 0; /* interface not claimed (never offered in the descriptor) */ +} +bool mscd_control_xfer_cb(uint8_t rhport, uint8_t stage, const void* request) { + (void)rhport; (void)stage; (void)request; + return false; +} +bool mscd_xfer_cb(uint8_t rhport, uint8_t ep_addr, uint32_t result, uint32_t xferred_bytes) { + (void)rhport; (void)ep_addr; (void)result; (void)xferred_bytes; + return false; +} + +/* --- DFU-mode class driver (dfu_rtd_* runtime-DFU intentionally untouched) --- */ +void dfu_moded_init(void) {} +void dfu_moded_reset(uint8_t rhport) { (void)rhport; } +uint16_t dfu_moded_open(uint8_t rhport, const void* itf_desc, uint16_t max_len) { + (void)rhport; (void)itf_desc; (void)max_len; + return 0; +} +bool dfu_moded_control_xfer_cb(uint8_t rhport, uint8_t stage, const void* request) { + (void)rhport; (void)stage; (void)request; + return false; +} + +#endif /* ESP32 */