diff --git a/CONSOLE_MODE.md b/CONSOLE_MODE.md index 8e5e0f60..931469cd 100644 --- a/CONSOLE_MODE.md +++ b/CONSOLE_MODE.md @@ -366,7 +366,7 @@ which is why the console needed far fewer new commands than the UI has screens. | Quick replies | none | gap | | Backups | none | gap | | Language | none | gap | -| MQTT bridge | none | gap | +| MQTT bridge | `mqtt status` | **done** (diagnostics only) | | App permissions | none | with Phase 4 | | General | `console`/`ui` toggle | partial | diff --git a/THINKNODE_M9_SHORTCUTS.md b/THINKNODE_M9_SHORTCUTS.md index 37ed648c..f9f1c955 100644 --- a/THINKNODE_M9_SHORTCUTS.md +++ b/THINKNODE_M9_SHORTCUTS.md @@ -20,20 +20,33 @@ this page is the five-minute tour that makes the board feel native. the second layer instead. Start typing in a chat and the composer focuses itself; any key wakes the screen from idle. -## The one rule: Back closes what you see +## The one rule: Back closes what you see, then goes back **Back** always closes the *top* layer, one per press: map pan mode → an open dropdown → the power menu → the Control Center → a full-screen app → a dialog -→ any popup → an open chat → back out of the page. Nothing ever closes -invisibly beneath something else. The only exception is deliberate: progress -overlays (SD format, bulk delete) block all keys until the operation finishes. +→ any popup → an open chat. Nothing ever closes invisibly beneath something +else. + +Once there is nothing left covering the screen, **Back returns you to the +previous screen** — the tab you came from, however you got there: a function +key, an app-drawer tile, "Show on map", a contact's Send-msg button. Keep +pressing and you keep walking back through where you have been. When the trail +runs out Back lands on Home, and on Home with nothing open it does nothing, +because that is the root. + +**HOME** is the shortcut past all of that: it goes straight to the root and +clears the trail, so Back from there won't jump you back out again. + +The only exception is deliberate: progress overlays (SD format, bulk delete) +block all keys until the operation finishes — Back will not navigate out from +under a running operation. ## Function keys | Key | Press | Hold | |---|---|---| | **MSG** | Jump to the Chats tab (closes an open app first) | — | -| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home | — | +| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home (and clear the Back trail) | — | | **@ (Mentions)** | Open the Mentions screen | — | | **ADV** | Open the Send Advert page | **Toggle GPS on/off** | | **MAP** | Jump to the Map tab — press again *on* the map to toggle pan mode | — | @@ -77,9 +90,24 @@ app can trap the keys, even while the screen is locked. If you last used the M9 on an earlier beta, the notable navigation changes: +- **Back now actually goes back** — once everything covering the screen was + closed, Back had nothing left to do and the press simply died on any bare + tab. It now walks back through the screens you visited, then Home. Jumps that + used to strand you ("Show on map" from Discover or Contacts, a route replay + from a chat, the Home unread line, the ✉ badge, app-drawer tiles) all return + properly. **Back also works in the first-boot wizard** now, stepping back + through its screens like the on-screen Back button. - **Back restored and made consistent** — it had regressed to doing nothing outside text fields, and could close hidden popups beneath the Control Center. Both fixed; the close-what-you-see rule above now holds everywhere. +- **The map opens faster the first time** — the cold open was doing a + whole-card zoom scan whose result was thrown away, and (with no GPS fix) was + repeating that scan on *every* open. The "Loading map…" hint now appears + immediately instead of after the scan, so the board no longer looks frozen on + the previous screen while the first tiles decode. +- **The d-pad no longer goes dead for a second** when a message arrives, and + the Spectrum page no longer steers an invisible cursor around the app drawer + underneath it. - **Apps can't strand you** — opening Advert/Mentions over a running app used to orphan it with no key path back; app permission dialogs are now answerable with the d-pad. diff --git a/deploy/apps/lang/21/bg.lang b/deploy/apps/lang/21/bg.lang index cca93b65..dc561b25 100644 --- a/deploy/apps/lang/21/bg.lang +++ b/deploy/apps/lang/21/bg.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/de.lang b/deploy/apps/lang/21/de.lang index 58690f9d..8ea2825a 100644 --- a/deploy/apps/lang/21/de.lang +++ b/deploy/apps/lang/21/de.lang @@ -1109,3 +1109,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/el.lang b/deploy/apps/lang/21/el.lang index 44c66c80..3ef7cf24 100644 --- a/deploy/apps/lang/21/el.lang +++ b/deploy/apps/lang/21/el.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/es.lang b/deploy/apps/lang/21/es.lang index c7b8baba..33145f44 100644 --- a/deploy/apps/lang/21/es.lang +++ b/deploy/apps/lang/21/es.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/fr.lang b/deploy/apps/lang/21/fr.lang index cb92be80..3a865a21 100644 --- a/deploy/apps/lang/21/fr.lang +++ b/deploy/apps/lang/21/fr.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/hu.lang b/deploy/apps/lang/21/hu.lang index 5f6dddd0..f17ce4af 100644 --- a/deploy/apps/lang/21/hu.lang +++ b/deploy/apps/lang/21/hu.lang @@ -1130,3 +1130,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/it.lang b/deploy/apps/lang/21/it.lang index f5cc5177..3fe86369 100644 --- a/deploy/apps/lang/21/it.lang +++ b/deploy/apps/lang/21/it.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/nl.lang b/deploy/apps/lang/21/nl.lang index a2f82b60..1d732adc 100644 --- a/deploy/apps/lang/21/nl.lang +++ b/deploy/apps/lang/21/nl.lang @@ -1110,3 +1110,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/pt-br.lang b/deploy/apps/lang/21/pt-br.lang index cb0a884e..ef47fe3c 100644 --- a/deploy/apps/lang/21/pt-br.lang +++ b/deploy/apps/lang/21/pt-br.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/ro.lang b/deploy/apps/lang/21/ro.lang index 6f561190..c25535fc 100644 --- a/deploy/apps/lang/21/ro.lang +++ b/deploy/apps/lang/21/ro.lang @@ -1114,3 +1114,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/ru.lang b/deploy/apps/lang/21/ru.lang index 8c2f2725..747c7d36 100644 --- a/deploy/apps/lang/21/ru.lang +++ b/deploy/apps/lang/21/ru.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/sr.lang b/deploy/apps/lang/21/sr.lang index 8e7267bf..36feaaa0 100644 --- a/deploy/apps/lang/21/sr.lang +++ b/deploy/apps/lang/21/sr.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/21/uk.lang b/deploy/apps/lang/21/uk.lang index 10d5f672..b0088968 100644 --- a/deploy/apps/lang/21/uk.lang +++ b/deploy/apps/lang/21/uk.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/bg.lang b/deploy/apps/lang/bg.lang index cca93b65..dc561b25 100644 --- a/deploy/apps/lang/bg.lang +++ b/deploy/apps/lang/bg.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/de.lang b/deploy/apps/lang/de.lang index 58690f9d..8ea2825a 100644 --- a/deploy/apps/lang/de.lang +++ b/deploy/apps/lang/de.lang @@ -1109,3 +1109,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/el.lang b/deploy/apps/lang/el.lang index 44c66c80..3ef7cf24 100644 --- a/deploy/apps/lang/el.lang +++ b/deploy/apps/lang/el.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/es.lang b/deploy/apps/lang/es.lang index c7b8baba..33145f44 100644 --- a/deploy/apps/lang/es.lang +++ b/deploy/apps/lang/es.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/fr.lang b/deploy/apps/lang/fr.lang index cb92be80..3a865a21 100644 --- a/deploy/apps/lang/fr.lang +++ b/deploy/apps/lang/fr.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/hu.lang b/deploy/apps/lang/hu.lang index 5f6dddd0..f17ce4af 100644 --- a/deploy/apps/lang/hu.lang +++ b/deploy/apps/lang/hu.lang @@ -1130,3 +1130,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/it.lang b/deploy/apps/lang/it.lang index f5cc5177..3fe86369 100644 --- a/deploy/apps/lang/it.lang +++ b/deploy/apps/lang/it.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/nl.lang b/deploy/apps/lang/nl.lang index a2f82b60..1d732adc 100644 --- a/deploy/apps/lang/nl.lang +++ b/deploy/apps/lang/nl.lang @@ -1110,3 +1110,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/pt-br.lang b/deploy/apps/lang/pt-br.lang index cb0a884e..ef47fe3c 100644 --- a/deploy/apps/lang/pt-br.lang +++ b/deploy/apps/lang/pt-br.lang @@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/ro.lang b/deploy/apps/lang/ro.lang index 6f561190..c25535fc 100644 --- a/deploy/apps/lang/ro.lang +++ b/deploy/apps/lang/ro.lang @@ -1114,3 +1114,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/ru.lang b/deploy/apps/lang/ru.lang index 8c2f2725..747c7d36 100644 --- a/deploy/apps/lang/ru.lang +++ b/deploy/apps/lang/ru.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/sr.lang b/deploy/apps/lang/sr.lang index 8e7267bf..36feaaa0 100644 --- a/deploy/apps/lang/sr.lang +++ b/deploy/apps/lang/sr.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/deploy/apps/lang/uk.lang b/deploy/apps/lang/uk.lang index 10d5f672..b0088968 100644 --- a/deploy/apps/lang/uk.lang +++ b/deploy/apps/lang/uk.lang @@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol Older keyboard protocol Older keyboard protocol Using the older keyboard protocol Using the older keyboard protocol turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching +Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it diff --git a/release-notes/beta_75.txt b/release-notes/beta_75.txt new file mode 100644 index 00000000..4678f98c --- /dev/null +++ b/release-notes/beta_75.txt @@ -0,0 +1,26 @@ +# beta_75 - test build. Stable stays beta_65. +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Your offline map pack was being deleted ----------------------------------------------- +If you keep map tiles on an SD card, the firmware was permanently deleting files from your own map pack. Every tile it wanted but could not find as a .jpg erased the .png sitting next to it, and that deletion is not recoverable. +The cleanup responsible was written for the firmware's own small tile cache, but on every board that keeps tiles on the card it was pointed at the card instead. It is now limited to the cache it was meant for. Found and fixed by Christopher Van Hoose, confirmed on a ThinkNode M9. + +# --- Names ----------------------------------------------------------------------------------- +Long sender names are stored in full. Names can be 31 characters and only the first 24 were kept, so anything longer was cut permanently on arrival. Nothing is lost now, and existing chat history is untouched. +A long name no longer pushes the message text out of shape, and a colon inside an ordinary message is no longer mistaken for an author name. +Both by Ruben Laban, a first contribution. + +# --- ThinkNode M9 ---------------------------------------------------------------------------- +The map opens in about 1.5 seconds instead of 2.6. +Back returns to the previous screen rather than jumping out of where you were. +The SD card is remounted properly after formatting it on the device. +Labels that update while you watch no longer overlap the rows beneath them, and controls that are hidden or inactive are skipped when moving with the d-pad instead of trapping the cursor. +GPS acquisition time is measured from when the search started rather than from when you happened to open the page. +All from Christopher Van Hoose. + +# --- Also ------------------------------------------------------------------------------------ +Notification indicators and modal focus behave correctly, and there is a command to check MQTT connection status. Both by Michael A. Cojocari. + +# --- For testers ------------------------------------------------------------------------------- +If you keep an offline map pack on SD, this release stops the damage but cannot undo it. Check whether tiles are missing and re-copy that area if so. +Still open and not fixed here: chat history resetting to an older point on reboot, a Tanmatsu whose chat store will not finish migrating, a map that renders nothing since beta_71, a screen that will not stay dark after a manual lock, and the web flasher hanging at "wrapping up" on the T-Display P4. Several of those are waiting on information from the people who reported them. diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index b769e62c..79172e3b 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -280,12 +280,101 @@ static const char* kMeshcomodHelpMsg = "set tx set TX power\n" "wifi status|on|off|scan\n" "wifi use | set ssid | set pwd | apply | clear\n" + "mqtt status\n" "tcp status|on|off\n" "ble status|on|off\n" "ota status|start|netdiag | ota url \n" "reboot restart the device\n" "bootloader / dfu reboot to download mode"; +#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) +static const char* mqttResultText(int result) { + switch (result) { + case -4: return "connection timeout"; + case -3: return "connection lost"; + case -2: return "TCP connect failed"; + case -1: return "disconnected"; + case 0: return "connected"; + case 1: return "bad protocol"; + case 2: return "rejected client ID"; + case 3: return "broker unavailable"; + case 4: return "bad credentials"; + case 5: return "unauthorized"; + default: return "unknown"; + } +} + +static void mqttFormatAge(char* out, size_t cap, uint32_t timestamp, uint32_t now) { + if (!timestamp) { snprintf(out, cap, "never"); return; } + const uint32_t seconds = (uint32_t)(now - timestamp) / 1000u; + if (seconds < 60u) snprintf(out, cap, "%lus ago", (unsigned long)seconds); + else if (seconds < 3600u) snprintf(out, cap, "%lum %lus ago", + (unsigned long)(seconds / 60u), + (unsigned long)(seconds % 60u)); + else snprintf(out, cap, "%luh %lum ago", (unsigned long)(seconds / 3600u), + (unsigned long)((seconds / 60u) % 60u)); +} + +static void mqttFormatStatus(char* out, size_t cap) { + MqttBridgeStatus status; + mqtt_bridge.getStatus(status); + if (!status.available) { + snprintf(out, cap, "mqtt: unavailable on this build"); + return; + } + if (status.phase == MqttBridgePhase::Disabled) { + snprintf(out, cap, "mqtt: disabled"); + return; + } + if (status.phase == MqttBridgePhase::Misconfigured) { + snprintf(out, cap, "mqtt: misconfigured (broker host is empty)"); + return; + } + + const uint32_t now = millis(); + char state[64]; + char timing[96] = {}; + switch (status.phase) { + case MqttBridgePhase::WaitingForWifi: + snprintf(state, sizeof(state), "mqtt: waiting for Wi-Fi"); + break; + case MqttBridgePhase::Connecting: { + char age[32]; + mqttFormatAge(age, sizeof(age), status.lastAttemptMs, now); + snprintf(state, sizeof(state), "mqtt: connecting"); + snprintf(timing, sizeof(timing), "attempt: %s", age); + break; + } + case MqttBridgePhase::Connected: { + char age[32]; + mqttFormatAge(age, sizeof(age), status.lastConnectedMs, now); + snprintf(state, sizeof(state), "mqtt: connected"); + snprintf(timing, sizeof(timing), "connected: %s", age); + break; + } + case MqttBridgePhase::RetryWait: { + const int32_t remaining = (int32_t)(status.retryAtMs - now); + const uint32_t seconds = remaining > 0 ? ((uint32_t)remaining + 999u) / 1000u : 0u; + snprintf(state, sizeof(state), "mqtt: retrying (%lus)", (unsigned long)seconds); + if (status.lastResultMs) { + char age[32]; + mqttFormatAge(age, sizeof(age), status.lastResultMs, now); + snprintf(timing, sizeof(timing), "last error: %d (%s), %s", + (int)status.lastResult, mqttResultText(status.lastResult), age); + } + break; + } + default: + snprintf(state, sizeof(state), "mqtt: unavailable on this build"); + break; + } + snprintf(out, cap, "%s\nbroker: %s:%u\npublish: channels=%s dm=%s encrypted=%s%s%s", + state, status.host, (unsigned)status.port, + status.publishChannel ? "on" : "off", status.publishDm ? "on" : "off", + status.encrypted ? "yes" : "no", timing[0] ? "\n" : "", timing); +} +#endif + #define MESHCOMOD_CMD_CACHE_SIZE 6 struct MeshcomodCmdCacheEntry { uint32_t ts; @@ -698,6 +787,23 @@ bool MyMesh::handleMeshcomodCommand(const char* text, int text_len) { return true; } + if (isCmd(p, "mqtt")) { + const char* q = p + 4; + while (*q == ' ' || *q == '\t') q++; + if (isCmd(q, "status")) { +#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) + char reply[320]; + mqttFormatStatus(reply, sizeof(reply)); + pushMeshcomodReply(reply); +#else + pushMeshcomodReply("mqtt: unavailable on this build"); +#endif + return true; + } + pushMeshcomodReply("usage:\n- mqtt status"); + return true; + } + if (strncasecmp(p, "tcp", 3) == 0 && (p[3] == '\0' || p[3] == ' ' || p[3] == '\t')) { p += 3; while (*p == ' ' || *p == '\t') p++; @@ -5219,6 +5325,16 @@ void MyMesh::checkCLIRescueCmd() { } else { Serial.printf(" Error: unknown config: %s\n", config); } + } else if (strcmp(cli_command, "mqtt.status") == 0 || + strcmp(cli_command, "get mqtt.status") == 0 || + strcmp(cli_command, "mqtt status") == 0) { +#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) + char reply[320]; + mqttFormatStatus(reply, sizeof(reply)); + Serial.printf(" > %s\n", reply); +#else + Serial.println(" > mqtt: unavailable on this build"); +#endif } else if (strcmp(cli_command, "get wifi.ssid") == 0) { #ifdef ESP32 #if defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION) diff --git a/src/helpers/esp32/MqttBridge.cpp b/src/helpers/esp32/MqttBridge.cpp index 0ed2093b..0422844e 100644 --- a/src/helpers/esp32/MqttBridge.cpp +++ b/src/helpers/esp32/MqttBridge.cpp @@ -36,17 +36,52 @@ size_t MqttNbClient::write(const uint8_t* buf, size_t size) { return WiFiClient::write(buf, size); } +void MqttBridge::getStatus(MqttBridgeStatus& out) const { + portENTER_CRITICAL(&_statusMux); + out = _status; + portEXIT_CRITICAL(&_statusMux); +} + +void MqttBridge::syncStatusConfig(MqttBridgePhase phase) { + MqttBridgeStatus next; + next.phase = phase; + next.available = true; + next.requestedEnabled = _requestedEnabled; + next.publishDm = _pubDm; + next.publishChannel = _pubChannel; + next.encrypted = _encOn; + strncpy(next.host, _host, sizeof(next.host) - 1); + next.port = _port; + portENTER_CRITICAL(&_statusMux); + _status = next; + portEXIT_CRITICAL(&_statusMux); +} + +void MqttBridge::setStatusPhase(MqttBridgePhase phase, int result, uint32_t now, + bool recordResult, uint32_t retryAt) { + portENTER_CRITICAL(&_statusMux); + _status.phase = phase; + _status.retryAtMs = retryAt; + if (phase == MqttBridgePhase::Connecting) _status.lastAttemptMs = now; + if (phase == MqttBridgePhase::Connected) _status.lastConnectedMs = now; + if (recordResult) { + _status.lastResult = (int8_t)result; + _status.lastResultMs = now; + } + portEXIT_CRITICAL(&_statusMux); +} + // 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). void MqttBridge::loadConfig() { - _enabled = false; _pubDm = false; _pubChannel = true; + _requestedEnabled = false; _enabled = false; _pubDm = false; _pubChannel = true; _host[0] = _user[0] = _pwd[0] = _psk[0] = '\0'; _port = 1883; SdNvsPrefs p; if (p.begin("mqtt", true)) { - _enabled = p.getBool("en", false); + _requestedEnabled = p.getBool("en", false); _port = (uint16_t)p.getUInt("port", 1883); _pubDm = p.getBool("dm", false); _pubChannel = p.getBool("ch", true); @@ -57,7 +92,13 @@ void MqttBridge::loadConfig() { p.end(); } deriveKey(); - if (!_enabled || _host[0] == '\0') _enabled = false; + _enabled = _requestedEnabled && _host[0] != '\0'; + MqttBridgePhase phase = MqttBridgePhase::Disabled; + if (_requestedEnabled && _host[0] == '\0') phase = MqttBridgePhase::Misconfigured; + else if (_enabled) phase = WiFi.status() == WL_CONNECTED + ? MqttBridgePhase::RetryWait + : MqttBridgePhase::WaitingForWifi; + syncStatusConfig(phase); } // Derive the AES key from the passphrase. mbedtls_md() (generic digest) is used @@ -88,7 +129,12 @@ void MqttBridge::begin(const char* nodeHex) { } bool MqttBridge::reconnect() { - if (!_enabled || WiFi.status() != WL_CONNECTED) return false; + if (!_enabled) return false; + if (WiFi.status() != WL_CONNECTED) { + setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED, + millis(), false); + return false; + } char clientId[32], lwtTopic[80]; snprintf(clientId, sizeof(clientId), "wadamesh-%s", _nodeHex); @@ -98,11 +144,21 @@ bool MqttBridge::reconnect() { ? _mqtt.connect(clientId, _user, _pwd, lwtTopic, 0, true, "offline") : _mqtt.connect(clientId, nullptr, nullptr, lwtTopic, 0, true, "offline"); + const uint32_t now = millis(); + const int result = _mqtt.state(); if (ok) { + setStatusPhase(MqttBridgePhase::Connected, result, now, true); _mqtt.publish(lwtTopic, "online", true); Serial.printf("[MQTT] connected as %s\n", clientId); } else { - Serial.printf("[MQTT] connect failed, rc=%d\n", _mqtt.state()); + MqttBridgeStatus current; + getStatus(current); + const uint32_t retryAt = current.lastAttemptMs + ? current.lastAttemptMs + RECONNECT_INTERVAL_MS + : now + RECONNECT_INTERVAL_MS; + setStatusPhase(MqttBridgePhase::RetryWait, result, now, true, + retryAt); + Serial.printf("[MQTT] connect failed, rc=%d\n", result); } return ok; } @@ -119,19 +175,41 @@ void MqttBridge::reconnectTask(void* arg) { void MqttBridge::loop() { if (!_enabled || _connecting) return; + uint32_t now = millis(); + if (WiFi.status() != WL_CONNECTED) { + if (_mqtt.connected()) _mqtt.loop(); + setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED, + now, false); + return; + } if (!_mqtt.connected()) { - uint32_t now = millis(); + MqttBridgeStatus current; + getStatus(current); + if (current.phase == MqttBridgePhase::Connected) { + setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true, + now); + } if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) { _lastReconnectMs = now; _connecting = true; + setStatusPhase(MqttBridgePhase::Connecting, MQTT_DISCONNECTED, + now, false); if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this, 1, nullptr, 0) != pdPASS) { _connecting = false; // task OOM — try again next interval + setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED, + now, false, now + RECONNECT_INTERVAL_MS); } + } else { + setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED, + now, false, _lastReconnectMs + RECONNECT_INTERVAL_MS); } return; } - _mqtt.loop(); + if (!_mqtt.loop()) { + setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true, + now); + } } // AES-256-GCM seal: out = base64( nonce[12] || ciphertext || tag[16] ). Single-task diff --git a/src/helpers/esp32/MqttBridge.h b/src/helpers/esp32/MqttBridge.h index 751eed7a..18997acc 100644 --- a/src/helpers/esp32/MqttBridge.h +++ b/src/helpers/esp32/MqttBridge.h @@ -3,6 +3,32 @@ #include +enum class MqttBridgePhase : uint8_t { + Unavailable, + Disabled, + Misconfigured, + WaitingForWifi, + Connecting, + Connected, + RetryWait, +}; + +struct MqttBridgeStatus { + MqttBridgePhase phase = MqttBridgePhase::Unavailable; + bool available = false; + bool requestedEnabled = false; + bool publishDm = false; + bool publishChannel = false; + bool encrypted = false; + char host[64] = {}; + uint16_t port = 1883; + int8_t lastResult = -1; + uint32_t lastAttemptMs = 0; + uint32_t lastConnectedMs = 0; + uint32_t lastResultMs = 0; + uint32_t retryAtMs = 0; +}; + #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ---- // The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient @@ -16,6 +42,7 @@ public: 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; } + void getStatus(MqttBridgeStatus& out) const { out = MqttBridgeStatus{}; } static void saveConfig(const char*, uint16_t, const char*, const char*, bool, bool, const char*, bool) {} void reloadConfig() {} @@ -75,6 +102,7 @@ public: float snr, uint8_t hops, uint32_t ts, const char* text); bool enabled() const { return _enabled; } + void getStatus(MqttBridgeStatus& out) const; // Persist config (called from Settings UI save). static void saveConfig(const char* host, uint16_t port, @@ -87,6 +115,7 @@ private: MqttNbClient _wc; PubSubClient _mqtt{_wc}; char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0' + bool _requestedEnabled = false; bool _enabled = false; bool _pubDm = false; // DMs off by default (private 1:1 traffic) bool _pubChannel = true; // channel messages on by default @@ -101,11 +130,16 @@ private: // True while the one-shot connect task owns _mqtt/_wc. The loop thread must // not touch either until it clears (PubSubClient is not thread-safe). volatile bool _connecting = false; + mutable portMUX_TYPE _statusMux = portMUX_INITIALIZER_UNLOCKED; + MqttBridgeStatus _status; static const uint32_t RECONNECT_INTERVAL_MS = 15000; void loadConfig(); // shared by begin() / reloadConfig() void deriveKey(); // _key = SHA-256(_psk); sets _encOn + void setStatusPhase(MqttBridgePhase phase, int result, uint32_t now, + bool recordResult, uint32_t retryAt = 0); + void syncStatusConfig(MqttBridgePhase phase); bool reconnect(); static void reconnectTask(void* arg); // one-shot task body wrapping reconnect() void pub(const char* subtopic, const char* json); // seals if _encOn diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 10a0026f..bb939cd5 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1728,13 +1728,66 @@ bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored) { } // Ignored / blocked sender NAMES (channel/room senders that aren't contacts) --- -// One NVS blob "ign_nm" of up to TOUCH_IGNORED_NAMES_MAX fixed-width, +// One NVS blob "ign_nm2" of up to TOUCH_IGNORED_NAMES_MAX fixed-width, // NUL-padded TOUCH_IGNORED_NAME_LEN slots. Same read/replace/write scheme as // the 6-byte prefix list above. -static const char* KEY_IGN_NAMES = "ign_nm"; +// The blob carries no header — the entry count is derived by dividing its length by the +// slot width — so widening the slot re-slots every stored entry: slot 1 would start inside +// the old name 0, every row after the first would render as a mangled suffix and would stop +// matching, and the first write back would make that permanent. A new key sidesteps it: the +// old blob is read once with the OLD stride, re-slotted, written here, and removed. A key +// rename is unambiguous by construction, unlike sniffing the blob length (28 and 32 share +// multiples at 224 and 448 bytes, both inside the 16-entry cap). +static const char* KEY_IGN_NAMES = "ign_nm2"; +static const char* KEY_IGN_NAMES_V1 = "ign_nm"; +constexpr int TOUCH_IGN_NAME_LEN_V1 = 28; + +// One-shot: fold a pre-widening "ign_nm" blob into the current key. No-op once migrated +// (and on a fresh device, where neither key exists). Latched for the boot because the +// caller runs on the RX path for EVERY incoming message — two NVS key lookups per message +// is not a price worth paying for a check that can only change once. +static bool s_ign_names_migrated = false; + +static void ignNamesMigrateV1() { + if (s_ign_names_migrated) return; + if (!s_begun) touchPrefsBegin(); + if (s_prefs.isKey(KEY_IGN_NAMES) || !s_prefs.isKey(KEY_IGN_NAMES_V1)) { + s_ign_names_migrated = true; // nothing to fold in, now or on any later call + return; + } + + char old_buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGN_NAME_LEN_V1]; + size_t n = s_prefs.getBytes(KEY_IGN_NAMES_V1, old_buf, sizeof(old_buf)); + const int count = (n == 0 || n > sizeof(old_buf)) ? 0 : (int)(n / TOUCH_IGN_NAME_LEN_V1); + + char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]; + memset(buf, 0, sizeof(buf)); + for (int i = 0; i < count; ++i) { + // Old slots hold at most 27 chars, so they always fit the wider one. + strncpy(&buf[i * TOUCH_IGNORED_NAME_LEN], &old_buf[i * TOUCH_IGN_NAME_LEN_V1], + TOUCH_IGN_NAME_LEN_V1 - 1); + } + + s_prefs.end(); + bool ok = false; + if (s_prefs.begin(TOUCH_NS, false)) { + ok = (count == 0) || + s_prefs.putBytes(KEY_IGN_NAMES, buf, (size_t)(count * TOUCH_IGNORED_NAME_LEN)) > 0; + if (ok) s_prefs.remove(KEY_IGN_NAMES_V1); // only once the new key actually holds the list + s_prefs.end(); + } + s_begun = s_prefs.begin(TOUCH_NS, true); + // Latch on SUCCESS only. Latching a failed fold would leave the list reading empty for + // the rest of the boot — every block silently stops firing — and the next Block tap + // would then write a fresh ign_nm2 that orphans every old entry for good. Retrying + // costs the two key lookups per message the latch exists to avoid, but only while the + // store is already refusing writes. + s_ign_names_migrated = ok; +} static int ignNamesReadAll(char out[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]) { if (!s_begun) touchPrefsBegin(); + ignNamesMigrateV1(); if (!s_prefs.isKey(KEY_IGN_NAMES)) return 0; // absent on a fresh device — skip [E] NOT_FOUND size_t n = s_prefs.getBytes(KEY_IGN_NAMES, out, TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN); if (n == 0 || n > (size_t)(TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN)) return 0; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index e0aed700..0cce0ca0 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -392,7 +392,13 @@ int touchPrefsCopyIgnored(uint8_t* out_buf); * • touchPrefsCopyIgnoredNames: copy every stored name into `out_buf` * (>= TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN bytes); returns count. */ constexpr int TOUCH_IGNORED_NAMES_MAX = 16; -constexpr int TOUCH_IGNORED_NAME_LEN = 28; // fixed NUL-padded slot (>= MAX_SENDER_NAME+1) +// Fixed NUL-padded slot, and it MUST stay >= UITask::MAX_SENDER_NAME + 1 — a slot narrower +// than a sender stores a cut name that the full-width RX check can never match again, so +// the block shows in the list and silently never fires. That invariant was prose only; it +// is now a static_assert in UITask.cpp. Widened 28 -> 32 with MAX_SENDER_NAME 24 -> 31; the +// slot width is baked into the stored blob (the entry count is its length / this), so the +// key was renamed in the same change to migrate rather than mis-slot the old one. +constexpr int TOUCH_IGNORED_NAME_LEN = 32; bool touchPrefsIsNameIgnored(const char* name); bool touchPrefsSetNameIgnored(const char* name, bool ignored); int touchPrefsCopyIgnoredNames(char* out_buf); diff --git a/src/helpers/esp32/WebSocketCompanionServer.cpp b/src/helpers/esp32/WebSocketCompanionServer.cpp index 2692bc59..51603257 100644 --- a/src/helpers/esp32/WebSocketCompanionServer.cpp +++ b/src/helpers/esp32/WebSocketCompanionServer.cpp @@ -326,12 +326,12 @@ static const char WS_HTML_TERMINAL_PAGE[] = "var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to ',c:'to '},{l:'send ',c:'send '},{l:'public ',c:'public '},{l:'exit',c:'exit'},\n" " {h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},\n" " {h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name ',c:'set name '},{l:'set freq ',c:'set freq '},{l:'set bw ',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx ',c:'set tx '},\n" - " {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n" + " {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'mqtt status',c:'mqtt status'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n" " {h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];\n" "function cmdPicker(){var h='

Commands

';QCMDS.forEach(function(x,i){if(x.h)h+='
'+esc(x.h)+'
';else h+=''});\n" " E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});\n" " E('sheet').classList.add('on');E('scrim').classList.add('on')}\n" - "var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n" + "var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','mqtt ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n" "function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}\n" "function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}\n" " tadd('\\n> '+v+'\\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\\n','err')}\n" diff --git a/src/main.cpp b/src/main.cpp index 83a5473c..96b824c3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1238,6 +1238,58 @@ void setup() { } } #endif + // Repair an unusable SPIFFS whether or not the profile lives on SD. + // + // This used to be `if (!sd_storage && !spiffs_ok)`, which is a catch-22: an + // invalid SPIFFS forces want_full_sd above ("Move the WHOLE store to SD when + // the device has no usable SPIFFS"), and being on SD then skipped the repair — + // so the partition stayed unformatted forever. Every boot logged + // `E SPIFFS: mount failed, -10025` and internal storage read as 0 bytes. + // + // The consequence is not cosmetic. The migration path documents that it + // "never deletes its SPIFFS sources, so a device that already adopted the card + // still has a byte-identical copy here" — i.e. the design assumes internal + // flash holds a second copy of the identity. A device that went SD-native + // because SPIFFS was broken never had that copy, so /meshcomod/identity/_main.id + // on the card is the ONLY thing making it its node. On the ThinkNode M9 the + // card is soldered, so it cannot even be moved to another device to recover. + // + // Formatting here is safe for the profile: sd_storage was already decided + // above and is not revisited, so this only makes the internal filesystem + // usable again — it does not move anyone's data. A brand-new device is + // unaffected (a virgin partition is formatted on the same call it always was). + // KNOWN BUG, deliberately NOT fixed here yet — read before touching this. + // + // The condition is a catch-22. An invalid SPIFFS forces want_full_sd above + // ("Move the WHOLE store to SD when the device has no usable SPIFFS"), and + // being on SD then SKIPS this repair — so the partition stays unformatted + // forever. Every boot logs `E SPIFFS: mount failed, -10025` and internal + // storage reads 0 bytes. It is not cosmetic: the migration path documents that + // it "never deletes its SPIFFS sources, so a device that already adopted the + // card still has a byte-identical copy here", i.e. the design assumes internal + // flash holds a SECOND copy of the identity. A device that went SD-native + // *because* SPIFFS was broken never had that copy, so + // /meshcomod/identity/_main.id on the card is the only thing making it its + // node — and on the ThinkNode M9 that card is soldered. + // + // Reproduced on M9 hardware 2026-08-28. Changing this to + // `if (!spiffs_ok) spiffs_ok = SPIFFS.begin(true);` DOES repair the partition + // (the boot then reports the internal fs as ok, and the profile correctly + // stays on SD), but that boot then panicked and looped: + // Guru Meditation Error: Core 1 panic'ed (InstrFetchProhibited) + // Backtrace: WiFiEventCbList::WiFiEventCbList(const&) + // -> WiFiGenericClass::_eventCallback -> _arduino_event_task + // The follow-up matters: once the partition was formatted, the SAME build with + // this line back as-is — SPIFFS now mounting AND the profile on SD, i.e. the + // exact state that crashed — boots cleanly and repeatedly. So the fault is not + // "SPIFFS is mounted"; it is almost certainly the 3.375 MB format ITSELF + // running at this point in setup(), blocking long enough to race the Arduino + // Wi-Fi event task that is starting up alongside it. + // + // So the fix is not this one-liner: it is to repair the partition somewhere + // that can afford a multi-second blocking format (before the Wi-Fi/event tasks + // exist, or deferred to a UI-driven action with a progress notice), and to + // confirm on hardware that a cold first-format boot survives. if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "SPIFFS"); #if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI) diff --git a/src/ui-touch/ChannelSenderSplit.h b/src/ui-touch/ChannelSenderSplit.h new file mode 100644 index 00000000..b1ebe94d --- /dev/null +++ b/src/ui-touch/ChannelSenderSplit.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +// A channel/group post arrives on the wire as "SenderName: body" — the author is +// part of the text, not a separate field (unlike a DM, where the sender rides +// along as from_name). Split it so the UI can label the bubble with the author +// and show only the body underneath. +namespace ChannelSenderSplit { + +// The widest author a channel post can legitimately name. MeshCore keeps every +// name in a char[32] (ContactInfo::name, ChannelDetails::name, +// NodePrefs::node_name), so 31 characters is the whole wire width. +constexpr size_t kMaxWireName = 31; + +// Copies the embedded author into `sender_out` (always NUL-terminated, empty when +// there is nothing to split) and returns the start of the body — `text` itself +// when the string carries no author prefix. +// +// A name longer than the destination is TRUNCATED, not rejected. MeshCore names +// run to 31 chars while UIMessage::sender holds 24, and refusing the split for +// the overflowing ones is what put the channel name in the bubble header and left +// "nick: message" in the body. +// +// A prefix wider than kMaxWireName is a different thing entirely and IS rejected: +// no name can be that long, so the ": " belongs to an unprefixed message ("the +// repeater at Ouderkerk: it works"). Splitting there would invent an author and +// hide the start of the body. That plausibility check is what the old +// `slen <= MAX_SENDER_NAME` test provided; it has to be the WIRE width and not +// the destination width, or a long real name gets rejected all over again. +// +// A truncated name ends in "..." (ASCII, like chatFitLeadingEllipsis — the baked +// fonts carry no U+2026), so a cut name reads as cut rather than as a different +// node with a shorter name. The marker is part of the stored sender, so the +// bubble label, the chat-list preview and block-by-name all agree on one string. +inline const char* split(const char* text, char* sender_out, size_t sender_cap) { + if (sender_out && sender_cap > 0) sender_out[0] = '\0'; + if (!text) return ""; + if (!sender_out || sender_cap < 2) return text; + + const char* colon = strstr(text, ": "); + if (!colon || colon == text) return text; + if (static_cast(colon - text) > kMaxWireName) return text; // not a name — see above + + static const char kEllipsis[] = "..."; + const size_t kEllipsisLen = sizeof(kEllipsis) - 1; + + const size_t cap = sender_cap - 1; // bytes available, NUL excluded + size_t slen = static_cast(colon - text); + const bool truncated = slen > cap; + // Only mark when the name still gets at least as many bytes as the marker — + // on a destination too small for that, "nick" says more than "n...". + const bool mark = truncated && cap >= 2 * kEllipsisLen; + if (truncated) { + slen = mark ? cap - kEllipsisLen : cap; + // Never cut a multi-byte character in half: step back to the start of the + // sequence the cap lands inside, so the label gets valid UTF-8 rather than a + // stray '*' from the missing-glyph sanitiser. + while (slen > 0 && (static_cast(text[slen]) & 0xC0U) == 0x80U) --slen; + if (slen == 0) return text; // one long sequence and nothing fits — leave it whole + } + + memcpy(sender_out, text, slen); + if (mark) { + memcpy(sender_out + slen, kEllipsis, kEllipsisLen); + slen += kEllipsisLen; + } + sender_out[slen] = '\0'; + return colon + 2; +} + +} // namespace ChannelSenderSplit diff --git a/src/ui-touch/ConsoleUI.cpp b/src/ui-touch/ConsoleUI.cpp index e4a078f8..1ced2d35 100644 --- a/src/ui-touch/ConsoleUI.cpp +++ b/src/ui-touch/ConsoleUI.cpp @@ -548,11 +548,12 @@ void submit() { consoleWriteLine("mesh contacts, chans, unread"); consoleWriteLine(" chat show a thread"); consoleWriteLine(" discover / discovered"); - consoleWriteLine("radio stat, batt, wifi"); consoleWriteLine(" read clear that thread's unread"); consoleWriteLine(" monitor on|off show arriving messages"); consoleWriteLine(" to pick a contact or channel"); consoleWriteLine(" msg send to it"); + consoleWriteLine("network mqtt status, tcp status, ble status"); + consoleWriteLine("radio stat, batt, wifi"); consoleWriteLine("node anything the CLI answers:"); consoleWriteLine(" advert, get name, set name , time, ver"); return; diff --git a/src/ui-touch/SenderExtField.h b/src/ui-touch/SenderExtField.h new file mode 100644 index 00000000..d788b28c --- /dev/null +++ b/src/ui-touch/SenderExtField.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +// A stored sender name is split across two on-disk fields: the head lives in the frozen +// UiHistoryMsg::sender, and anything past it in UiSegMsg::sender_ext, which is appended at +// the very end of the segment record. The split exists so the record can hold a full-width +// name without moving any pre-existing field — see the comment on UiSegMsg::sender_ext. +// +// Nothing outside the persistence layer sees the split: UIMessage::sender is one string, +// and these two calls are the only places the halves come apart and go back together. +// +// A multi-byte character may straddle the boundary. That is harmless — the halves are +// always rejoined before the name is measured, drawn or compared — so this deliberately +// does NOT try to split on a character boundary, which would waste bytes of a field sized +// to hold the longest name exactly. +namespace SenderExtField { + +// Writes `sender` into the two destination fields. `base` is always NUL-terminated; `ext` +// carries the overflow and is NOT NUL-terminated when it is full (it is a fixed-width tail, +// and its length is implied by base being full). Both are zero-filled first, so a short +// name leaves a clean record. +inline void store(const char* sender, char* base, size_t base_cap, char* ext, size_t ext_cap) { + if (base && base_cap > 0) memset(base, 0, base_cap); + if (ext && ext_cap > 0) memset(ext, 0, ext_cap); + if (!sender || !base || base_cap == 0) return; + + // Walked with a pointer rather than indexed + memcpy'd: the copy length and the bounds + // check are then the same expression, which keeps -Warray-bounds quiet on inlined + // short literals. These are 31 bytes at most, so byte-at-a-time costs nothing. + const char* p = sender; + size_t head = 0; + while (head + 1 < base_cap && *p) base[head++] = *p++; + base[head] = '\0'; + if (head + 1 < base_cap || !ext || ext_cap == 0) return; // fitted, or nowhere to spill + + size_t tail = 0; + while (tail < ext_cap && *p) ext[tail++] = *p++; +} + +// Rejoins the two fields into `out`. Reads are bounded by the FIELD sizes, not by strlen: +// a corrupt record may leave `base` unterminated, and it is followed on disk by the message +// body, so an unbounded read would splice the text onto the name. +inline void load(const char* base, size_t base_cap, const char* ext, size_t ext_cap, + char* out, size_t out_cap) { + if (!out || out_cap == 0) return; + out[0] = '\0'; + if (!base || base_cap == 0) return; + + const size_t head_cap = base_cap - 1; + const char* p = base; + size_t head = 0; + while (head < head_cap && head + 1 < out_cap && *p) out[head++] = *p++; + out[head] = '\0'; + + // The tail only means anything when the head field is exactly full — that is the only + // way a name could have spilled. Anything else is an old record's zero-fill or garbage + // behind a short name, and must not be appended. + if (head != head_cap || !ext || ext_cap == 0) return; + + const char* q = ext; + size_t n = head; + size_t tail = 0; + while (tail < ext_cap && n + 1 < out_cap && *q) { out[n++] = *q++; ++tail; } + out[n] = '\0'; +} + +} // namespace SenderExtField diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 59ef6ea8..cec31593 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -127,6 +127,13 @@ static void* wadaMp3Scratch() { return s_wada_mp3_scratch; } #include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) #include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution +#include "ChannelSenderSplit.h" // host-tested "SenderName: body" split for channel/room posts +#include "SenderExtField.h" // host-tested split/rejoin of a sender across the two on-disk fields +// The split refuses a "SenderName: " prefix wider than the wire can carry, so that cap +// must never sit BELOW what UIMessage::sender can hold — otherwise a name the field +// stores fine gets rejected as implausible and the author lands back in the body. +static_assert(ChannelSenderSplit::kMaxWireName >= (size_t)UITask::MAX_SENDER_NAME, + "the split would reject a name UIMessage::sender can hold"); #if defined(HAS_TOUCH_UI) #include @@ -190,6 +197,11 @@ static void* wadaMp3Scratch() { return s_wada_mp3_scratch; } // its include path, so an angle include picks that up and misses accessors we // add here (e.g. the signal-probe prefs). Quotes force the local src/ copy. #include "../helpers/esp32/TouchPrefsStore.h" + // A block-list slot narrower than a sender name stores a cut name that the full-width + // RX check can never match again — the block shows in Settings and silently never + // fires. TouchPrefsStore.h stated this invariant in prose; prose is what let it rot. + static_assert(TOUCH_IGNORED_NAME_LEN >= UITask::MAX_SENDER_NAME + 1, + "block-list slot must hold a full sender name, or blocking silently stops working"); #include "../helpers/esp32/WebMirror.h" // web UI mirror (framebuffer + pointer bridge) #include "../helpers/ClockFloorRTC.h" extern ClockFloorRTC rtc_clock; // the board clock (variants/*/target.cpp); its send-timestamp floor is seeded/persisted from here (#89) @@ -313,6 +325,15 @@ struct __attribute__((packed)) UiHistoryThread { char name[UITask::MAX_THREAD_NAME + 1]; }; +// On-disk width of UiHistoryMsg::sender, FROZEN at what v6 blobs were written with. +// Deliberately NOT tied to UITask::MAX_SENDER_NAME: `sender` sits between `thread` and +// `text`, so growing it in place shifts `text` for every already-stored record. Nothing +// detects that — the segment loader validates only magic/version/rec_size, so an old +// record would prefix-read into the new offsets and every message would silently lose +// the first bytes of its body. A sender too long for this field spills into +// UiSegMsg::sender_ext instead (see SenderExtField.h). +constexpr int k_ui_disk_sender_len = 25; // == the historical MAX_SENDER_NAME + 1 + struct __attribute__((packed)) UiHistoryMsg { uint32_t ts; uint8_t channel; @@ -327,7 +348,7 @@ struct __attribute__((packed)) UiHistoryMsg { int8_t snr_q4; int8_t rssi; char thread[UITask::MAX_THREAD_NAME + 1]; - char sender[UITask::MAX_SENDER_NAME + 1]; + char sender[k_ui_disk_sender_len]; char text[UITask::MAX_MSG_TEXT + 1]; // Append any FUTURE fields HERE (after text) and bump k_ui_history_version. // The v5+ loader zero-fills appended fields for older blobs, so appending @@ -391,7 +412,35 @@ struct __attribute__((packed)) UiSegHeader { struct __attribute__((packed)) UiSegMsg { UiHistoryMsg m; uint32_t seq; + // Names run to 31 chars on the wire (MeshCore's ContactInfo::name[32]) but m.sender + // freezes at the 24 the v6 record was written with. The tail lives HERE, appended at + // the very end — after seq, not inside m — so every pre-existing field keeps its + // offset and the min(rec_size) prefix read stays correct in BOTH directions: an old + // 233-byte record zero-fills this and yields its original short name, and an older + // firmware reading this 240-byte record copies the first 233 bytes and ignores the + // tail (the name falls back to 24 chars, nothing else changes). That is why + // k_ui_seg_version STAYS 1 — bumping it would make that older firmware reject the + // file outright (uiSegOpenValidated rejects version > k_ui_seg_version) and quarantine + // a perfectly readable history. Split/rejoin lives in SenderExtField.h. + // + // Rollback caveat, since two widths now exist in the wild: an older firmware READS + // this record correctly, but if it then APPENDS to the same segment it writes 233-byte + // records into a file whose header says 240, and everything after them reads at the + // wrong stride. Nothing on this side can prevent that (the write is the old build's), + // and a version bump would not help either — it would only make the old build reject + // the file instead. Our own direction of the same hazard IS handled: see the width + // check in loadMsgsFromSegments, which refuses to append across widths. + char sender_ext[UITask::MAX_SENDER_NAME + 1 - k_ui_disk_sender_len]; }; + +// Pin what an ALREADY-WRITTEN record's reader depends on. These are offsets, not the total +// size, precisely because appending further fields at the tail stays safe — what must never +// move is anything a v6-era record already holds, since nothing on the read path would +// detect the shift. Growing the record past these offsets is fine; moving them is not. +static_assert(sizeof(UiHistoryMsg) == 229, "v6 record layout changed — old blobs would mis-read"); +static_assert(offsetof(UiSegMsg, seq) == 229, "seq moved — old segments would mis-read"); +static_assert(offsetof(UiSegMsg, sender_ext) == 233, + "the appended tail must start where the old record ended, or old segments mis-read"); } // namespace #endif @@ -3581,12 +3630,55 @@ static void navSwitchTab(int dir) { } // Jump straight to a main tab (the coloured F-keys). Close any open modal/sheet first so the jump -// lands on the bare page. -static void navGoToMainTab(int tab) { - for (int i = 0; i < 8 && anyPopupOpen(); i++) hwKeyDismissTopPopup(); +// lands on the bare page. Returns false when a popup REFUSED to close: the registry's key-blocker +// rows (SD-format progress, bulk-delete progress) have a null closer by design, and the old +// unconditional loop spun eight times closing nothing and then switched tabs out from under the +// running operation anyway. Callers that ignore the result still compile unchanged. +static bool navGoToMainTab(int tab) { + for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } + if (anyPopupOpen()) return false; goToTab(tab); + return true; } +#if defined(HAS_M9_KEYBOARD) +// ---- M9 navigation history --------------------------------------------------- +// The Back ladder (m9HandleNavKey, M9_KEY_HW_BACK) peels modals, app pages, chats +// and modes innermost-first, and it does that part correctly. What it never had is +// "the screen I was on BEFORE this one": every tab jump was one-way, so once the +// layers were peeled Back fell through to navPushTap(LV_KEY_ESC) — and nothing in +// this build consumes LV_KEY_ESC (the sole consumer is lv_dropdown, already peeled +// a rung earlier, and navMaybeRebuild deliberately keeps the tab bar out of the +// focus group). The press was consumed and nothing happened, on every bare tab. +// +// This records ONLY that missing piece: which main tab you came from. It +// deliberately does NOT shadow the layers the ladder already peels — duplicating +// them here would double-close. In particular the app drawer is NOT pushed: it is +// a popup-registry row that rung 6 already closes, and its mode survives a tab +// switch, so returning to Home restores it on its own. +// +// NEVER store an lv_obj_t* here. Settings sheets, app pages and the drawer are +// destroyed (often via del_async), so a stored pointer would dangle. Store the tab +// index only, and treat an entry that no longer applies as a skip rather than +// letting it cost the user a dead press. +static constexpr int kM9NavMax = 12; // 12 * 2 B = 24 B of .bss +static int16_t s_m9_nav[kM9NavMax]; +static int s_m9_nav_n = 0; +static bool s_m9_nav_replaying = false; // a pop's own goToTab must not re-push + +static void m9NavPush(int tab) { + if (s_m9_nav_replaying) return; + if (s_m9_nav_n > 0 && s_m9_nav[s_m9_nav_n - 1] == (int16_t)tab) return; // collapse repeats + if (s_m9_nav_n == kM9NavMax) { // full: drop the OLDEST + memmove(&s_m9_nav[0], &s_m9_nav[1], sizeof(s_m9_nav[0]) * (kM9NavMax - 1)); + s_m9_nav_n--; + } + s_m9_nav[s_m9_nav_n++] = (int16_t)tab; +} +static void m9NavClear() { s_m9_nav_n = 0; } +static bool m9NavPop(); // body needs goToTab + s_m9_map_pan; defined beside them +#endif + // Visible focus ring (the LVGL default theme's focus outline is invisible on this dark UI). Driven // by the group's focus-changed callback. Reverse-video ("negative") highlight: the // focused element is FILLED with the text colour and its text flipped to the @@ -5155,6 +5247,10 @@ static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw); // fw #endif static lv_obj_t* s_update_badge = nullptr; // red "!" over the bottom-bar gear static lv_obj_t* s_chat_unread_badge = nullptr; // red unread-count badge over the bottom-bar Chats icon +#if defined(HAS_THINKNODE_M9) +static lv_obj_t* s_m9_mail_indicator = nullptr; +static lv_obj_t* s_m9_contact_indicator = nullptr; +#endif static lv_obj_t* s_tab_indicator = nullptr; // thin rounded accent glow bar under the active tab static lv_obj_t* s_update_subtab_badge = nullptr;// red dot over the "About" sub-tab button static lv_obj_t* s_update_about_lbl = nullptr; // status line on the About sub-tab @@ -5399,6 +5495,7 @@ static lv_obj_t* s_setup_region_next_btn = nullptr; // so a keypad-nav region p static lv_obj_t* s_setup_ssid_ta = nullptr; // (legacy) Wi-Fi fields — the wizard's Wi-Fi step is now an info screen static lv_obj_t* s_setup_pwd_ta = nullptr; static void setupWizardOpen(); // fwd: re-trigger the flow (Device settings button) +static void setupShowStep(int step); // fwd: the M9 Back key steps the wizard, same as its on-screen Back static void setupRerunCb(lv_event_t* e); // fwd: "Run setup again" button callback // Our release number (the N in "beta_N"); -1 if this isn't a tagged build. @@ -8346,6 +8443,8 @@ static void openQuickReplyPicker(LvChatPanel* p) { static void openAppDrawer(); // fwd — app-drawer overlay (defined below openControlCenter) static void closeAppDrawer(); static void setHomeDrawer(bool show); +static void ctExitSelectMode(); // fwd — leave Contacts multi-select if it is on (defined with the Contacts tab). + // Must sit OUTSIDE any CAP_KEYPAD_NAV guard: tabChangedCb below calls it on every board. static void closeMentionsScreen(); // fwd — @-mentions screen (defined with the drawer) static void openMentionsScreen(); // fwd — same static void closeAppDrawerSync(); // fwd — synchronous drawer close (safe outside the drawer's own event) @@ -8428,6 +8527,7 @@ static void tabChangedCb(lv_event_t* e) { closeAppDrawerSync(); // leaving Home -> hide (mode kept); sync so the map can't paint under it } if (new_t == CONTACTS_TAB_INDEX) refreshContactsList(); + else if (prev_t == CONTACTS_TAB_INDEX) ctExitSelectMode(); // a mode must not outlive the tab it belongs to #if defined(HAS_EXPANSION_KIT) if (new_t == SENSORS_TAB_INDEX) refreshSensorsTab(); #endif @@ -8712,7 +8812,7 @@ static void settingsFieldFocusCb(lv_event_t* e) { #endif } -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) +#if CAP_KEYBOARD && CAP_KEYPAD_NAV // Recursively find the first text field under `obj` (depth-first). static lv_obj_t* findFirstTextarea(lv_obj_t* obj) { if (!obj) return nullptr; @@ -8729,14 +8829,20 @@ static lv_obj_t* findFirstTextarea(lv_obj_t* obj) { // Deferred via lv_async from createSettingsModal so the modal's fields exist by // the time it runs: focus the first text field of a freshly-opened popup modal // so the physical keyboard types straight into it. Mirrors settingsFieldFocusCb. -static void tdeckModalAutoFocusAsync(void* root) { +static void physicalKeyboardModalAutoFocusAsync(void* root) { lv_obj_t* r = static_cast(root); if (!r || !lv_obj_is_valid(r)) return; // modal already closed in the meantime? lv_obj_t* ta = findFirstTextarea(r); if (!ta) return; // no text field -> nothing to focus s_kb_panel = nullptr; // a settings field, not the chat composer kbMirrorBind(ta); // bind the keyboard to it - lv_obj_add_state(ta, LV_STATE_FOCUSED); // show the cursor + s_nav_focus_hint = ta; + navMarkDirty(); + navMaybeRebuild(); + if (s_nav_group && lv_group_get_focused(s_nav_group) == ta) { + s_nav_ta_editing = true; + navSyncCursor(); + } noteKbActivity(); } #endif @@ -9292,11 +9398,11 @@ static lv_obj_t* createSettingsModal(const char* title, SettingsModalKind kind) g_set_modal.root = root; g_set_modal.kind = kind; s_settings_content_w = (sw - 8) - 12; // modal body width minus its 6px padding each side -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) +#if CAP_KEYBOARD && CAP_KEYPAD_NAV // Physical keyboard: once the caller has finished adding this modal's fields // (deferred to the next frame), focus its first text field so the user can // type straight away. Popup modals only — the inline-settings path returns above. - lv_async_call(tdeckModalAutoFocusAsync, root); + lv_async_call(physicalKeyboardModalAutoFocusAsync, root); #endif return content; } @@ -10034,7 +10140,12 @@ static void openDiscoveredModalCb(lv_event_t* e) { lv_obj_set_style_text_color(l, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(l, 2, y); - y += SC(38); + // The text names every enabled type TWICE ("Auto-add on for chats, + // repeaters, rooms, sensors — new chats, repeaters, rooms, sensors land + // in Contacts automatically."), so with several types enabled it wraps + // well past the fixed SC(38) and printed over the first discovered card. + lv_obj_update_layout(l); + y += LV_MAX(SC(38), lv_obj_get_height(l) + SC(6)); } } @@ -10745,7 +10856,11 @@ static void buildRadioSettings() { lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(rl, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_pos(rl, 2, y); - y += SC(22); + // Width is set, so this label WRAPS. A 4-digit time-on-air (any SF11/SF12 + // preset) or a longer translation takes it to two lines, and the fixed + // SC(22) advance ran the second line through the MESH section divider. + lv_obj_update_layout(rl); + y += LV_MAX(SC(22), lv_obj_get_height(rl) + SC(4)); } mk_section("MESH"); @@ -11648,34 +11763,49 @@ static void buildSystemInfoSettings() { lv_obj_t* body = createSettingsModal(TR("System info"), SettingsModalKind::SystemInfo); char buf[704]; - // Live tier (uptime + heap): a small constant-height label refreshed at 1 Hz. + // FLEX COLUMN, deliberately not the manual y cursor the rest of this file uses. + // Both labels below are re-texted AFTER build with text whose LINE COUNT + // genuinely varies: + // • the live tier gains a hard '\n' the moment a chat-store write fails + // (sysInfoTextLive's "last save: FAIL ...\n append failed: ..." branch), + // and it is refreshed at 1 Hz while this page is open; + // • the slow tier grows as the off-thread microSD scan publishes its + // size/free lines, and as the loop-stall ring fills from "none recorded" + // to six "-Ns Nms" rows — on a page that is itself a documented + // stall source. + // The old code pinned each child to the MEASURED height of the one above it, + // so any of that growth printed straight over the next widget: the live tier + // over the "Chip / ESP32-S3" heading, the slow tier over the Memory detail + // button. Reserving a worst case is not viable here — neither the stall ring + // nor the SD strings have a fixed upper bound — so let the container reflow + // instead. Children are created in visual order, which is what flex needs. + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(body, 6, LV_PART_MAIN); + lv_obj_set_style_pad_left(body, 2, LV_PART_MAIN); + + // Live tier (uptime + heap), refreshed at 1 Hz. lv_obj_t* lbl = lv_label_create(body); s_sysinfo_lbl = lbl; lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, lv_pct(100)); - lv_obj_set_pos(lbl, 2, 0); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); sysInfoTextLive(buf, sizeof buf); lv_label_set_text(lbl, buf); // Slow tier below it: chip/flash/SD/NVS/stalls/build — re-set only on change. - lv_obj_update_layout(lbl); lv_obj_t* rest = lv_label_create(body); s_sysinfo_rest_lbl = rest; lv_label_set_long_mode(rest, LV_LABEL_LONG_WRAP); lv_obj_set_width(rest, lv_pct(100)); - lv_obj_set_pos(rest, 2, lv_obj_get_y(lbl) + lv_obj_get_height(lbl) + 2); lv_obj_set_style_text_font(rest, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(rest, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); sysInfoTextRest(buf, sizeof buf); lv_label_set_text(rest, buf); // "Memory detail" button below the info text (per-region heap breakdown). - lv_obj_update_layout(rest); lv_obj_t* membtn = lv_btn_create(body); lv_obj_set_size(membtn, lv_pct(96), SC(34)); - lv_obj_set_pos(membtn, 2, lv_obj_get_y(rest) + lv_obj_get_height(rest) + 8); styleButton(membtn); lv_obj_add_event_cb(membtn, openMemoryDetailCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* mbl = lv_label_create(membtn); @@ -12656,9 +12786,37 @@ static void buildDeviceSettings(int sec) { lv_obj_set_style_text_color(g_set_modal.gps_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_set_modal.gps_status, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(g_set_modal.gps_status, 4, y); - lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); - lv_obj_update_layout(g_set_modal.gps_status); - y += LV_MAX(SC(22), lv_obj_get_height(g_set_modal.gps_status) + SC(6)); // status is 2 lines now + // RESERVE THE WORST-CASE HEIGHT, don't measure the current text. This label is + // re-texted every tick while the page is open (updateSettingsLiveFields), and + // every row below it is positioned ABSOLUTELY from this build-time `y` — so a + // status that grows after the page was built lands on top of them. Concretely: + // open the page with GPS off and the text is a one-line "GPS: off", so `y` + // advanced ~22 px; toggle GPS on — the obvious thing to do on this page — and + // the text becomes "GPS: searching Nm" plus the ~100-character cold-start + // sentence, which wraps to several lines on a 240 px panel and printed over + // the "GPS serial baud" label and its dropdown. + // Measure the tallest string this label can ever hold, with the real font and + // width, and PIN the height to it. A later refresh then cannot reflow anything. + // Both long variants are probed because which one is tallest depends on the + // active translation, not just on English. + { + char probe[200]; + snprintf(probe, sizeof probe, "%s 00m00s\n%s", TR("GPS: searching"), + TR("No position yet. A cold start needs a clear view of the sky and can take several minutes.")); + lv_label_set_text(g_set_modal.gps_status, probe); + lv_obj_update_layout(g_set_modal.gps_status); + lv_coord_t worst_h = lv_obj_get_height(g_set_modal.gps_status); + + snprintf(probe, sizeof probe, "%s \xc2\xb7 00 sats \xc2\xb7 0000 m\n00.00000, 000.00000", TR("GPS: fix")); + lv_label_set_text(g_set_modal.gps_status, probe); + lv_obj_update_layout(g_set_modal.gps_status); + if (lv_obj_get_height(g_set_modal.gps_status) > worst_h) + worst_h = lv_obj_get_height(g_set_modal.gps_status); + + lv_obj_set_height(g_set_modal.gps_status, worst_h); + lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); + y += LV_MAX(SC(22), worst_h + SC(6)); + } // (Expansion Kit moved to the Sensors page — see the DSEC_SENSORS block below.) @@ -14239,11 +14397,17 @@ static void buildBluetoothSettings() { y += SC(38); lv_obj_t* hint = lv_label_create(body); + // Give it a width so it WRAPS: with none, the label sizes to its text and + // simply runs off the right edge. The English string already overflows the + // grouped-settings width here, before any longer translation. + lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); + lv_obj_set_width(hint, lv_pct(100)); lv_label_set_text(hint, TR("Type a new 6-digit code \xC2\xB7 applies after a reboot")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(hint, 2, y); - y += SC(18); + lv_obj_update_layout(hint); + y += LV_MAX(SC(18), lv_obj_get_height(hint) + SC(4)); } #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) @@ -15413,6 +15577,15 @@ static void buildWifiSettings() { lv_obj_set_width(g_set_modal.wifi_sta_status_l, cw - SC(54)); // clear of the switch lv_obj_set_style_text_color(g_set_modal.wifi_sta_status_l, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_set_modal.wifi_sta_status_l, &g_font_12, LV_PART_MAIN); + // Pin it to ONE line, as the header comment above promises. LV_LABEL_LONG_DOT + // only ellipsizes against the object's HEIGHT — with the label's default + // LV_SIZE_CONTENT height there is nothing to ellipsize against, so a long + // status just wrapped and grew instead. That matters here because this label + // is re-texted live (wifiRefreshStatus) with SSIDs and IP addresses, and the + // header only reserves SC(30) below it: at the M9's narrow content width a + // two-line status ran from y+SC(7) straight into s_wifi_list_cont, which is + // pinned at SC(30) and painted after it. A fixed height makes the dots work. + lv_obj_set_height(g_set_modal.wifi_sta_status_l, SC(16)); lv_obj_set_pos(g_set_modal.wifi_sta_status_l, 2, y + SC(7)); lv_label_set_text(g_set_modal.wifi_sta_status_l, TR("Loading...")); y += SC(30); @@ -15503,6 +15676,15 @@ static void openLogModalCb(lv_event_t* e) { // goToTab defined after all callbacks that need it static void goToTab(int idx) { if (!g_lv.tabview) return; +#if defined(HAS_M9_KEYBOARD) + // The single choke point for main-tab navigation, and the only place that + // fires LV_EVENT_VALUE_CHANGED explicitly — so recording the outgoing tab + // here covers every jump that goes through it (function keys, home tiles, + // app-drawer tiles, "Show on map", route replay). The raw + // lv_tabview_set_act() call sites bypass it by construction; the one that + // matters on this board (openMeshContactDm) is routed through the same push. + { const int cur = getActiveTab(); if (cur != idx) m9NavPush(cur); } +#endif // Clear any keyboard-nav focus highlight BEFORE the tab switches. Activating a // clickable element with Enter (e.g. the Home "Unread" line) fires its CLICKED // handler WITHOUT moving group focus, so navFocusCb never runs to un-highlight it @@ -15513,7 +15695,15 @@ static void goToTab(int idx) { #if CAP_KEYPAD_NAV if (s_nav_styled) { navUnstyle(s_nav_styled); s_nav_styled = nullptr; } #endif - lv_tabview_set_act(g_lv.tabview, static_cast(idx), LV_ANIM_ON); + // The map's first paint blocks this thread for ~2.5 s inside the tab-change + // handler. With the 200-400 ms slide still running, the lv_refr_now that + // pushes the "Loading map…" hint can land while the map page is still + // off-screen — the hint never reaches the panel and the board reads as + // frozen on the previous tab. Skip the slide for the map only; every other + // tab keeps it. (Swipe-driven switches call lv_tabview_set_act inside LVGL + // and are unaffected.) + lv_tabview_set_act(g_lv.tabview, static_cast(idx), + idx == MAP_TAB_INDEX ? LV_ANIM_OFF : LV_ANIM_ON); // lv_tabview_set_act() does NOT emit LV_EVENT_VALUE_CHANGED, so the full // tab-change handler (tabChangedCb → onMapTabActivated, status bar, chat // overlay cleanup, onLvTabChanged, heavy refresh) was skipped for swipe-driven @@ -17811,7 +18001,12 @@ static void openAddContactModalCb(lv_event_t* e) { lv_obj_set_style_text_font(s_addct_error_l, &g_font_12, LV_PART_MAIN); lv_label_set_text(s_addct_error_l, ""); lv_obj_set_pos(s_addct_error_l, 2, y); - y += 24; + // This label is EMPTY at build time, so the 24 px below reserved space for + // text that did not exist yet. addContactSubmitCb then fills it with a + // validation message that wraps to two lines and printed over the submit + // button. Pin the height to the reservation so the error wraps INSIDE it. + lv_obj_set_height(s_addct_error_l, SC(30)); + y += SC(34); lv_obj_t* b = lv_btn_create(body); lv_obj_set_size(b, lv_pct(100),36); @@ -19194,7 +19389,7 @@ static const char* k_term_banner = "Config: ver clock status get advert reboot\n" " set \n" "MeshCom: wifi status, tcp status, ble status,\n" - " ota status, tcp on/off, ble on/off.\n"; + " mqtt status, ota status, tcp/ble on/off.\n"; // Quick-pick catalogue (mirrors the repeater admin picker). Commands here are // the ones MyMesh::handleMeshcomodCommand understands natively when run @@ -19232,6 +19427,7 @@ static const AdminCmdEntry k_term_cmds[] = { { "wifi set pwd ", "wifi set pwd " }, { "wifi apply - connect now", "wifi apply" }, { "wifi clear - forget creds", "wifi clear" }, + { "mqtt status", "mqtt status" }, { "tcp status", "tcp status" }, { "tcp on", "tcp on" }, { "tcp off", "tcp off" }, @@ -19555,7 +19751,7 @@ static void termCmdExit() { static bool termIsCliKeyword(const char* word, size_t len) { static const char* kw[] = { "help", "ver", "version", "clock", "time", "status", "get", "set", - "advert", "advert.zerohop", "reboot", "wifi", "tcp", "ble", "ota", + "advert", "advert.zerohop", "reboot", "wifi", "mqtt", "tcp", "ble", "ota", "ok", "cancel", }; for (const char* k : kw) { @@ -22095,6 +22291,14 @@ static void buildFileManager(lv_obj_t* body) { lv_label_set_long_mode(s_fm_path_lbl, LV_LABEL_LONG_DOT); lv_obj_set_pos(s_fm_path_lbl, loc_x, 6); lv_obj_set_width(s_fm_path_lbl, loc_w); + // One line, fixed. LV_LABEL_LONG_DOT ellipsizes against the object's HEIGHT, + // and a label defaults to LV_SIZE_CONTENT — so with no height set, a path too + // long for loc_w wrapped and grew downward instead of getting its dots. This + // text changes every time the user walks into a directory, the field sits at + // y=6 inside a header only HDR_H (34) tall, and s_fm_list is pinned at HDR_H + // and painted after it, so the overflow disappeared under the list. Height = + // one line of g_font_12 plus the 3 px pad_ver either side. + lv_obj_set_height(s_fm_path_lbl, SC(21)); lv_obj_set_style_text_font(s_fm_path_lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_fm_path_lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_bg_color(s_fm_path_lbl, lv_color_hex(0x101418), LV_PART_MAIN); @@ -24258,10 +24462,17 @@ static void openSpectrumPage() { lv_obj_add_event_cb(s_spec_root, spectrumDismissCb, LV_EVENT_CLICKED, nullptr); // Keyboard/d-pad nav (M9, T-Deck, Tanmatsu): nothing on this page is actionable — the // chart and the waterfall's scale box are plain containers, so the nav collector was - // highlighting them for no reason. Skip the whole subtree (same as the lock screen): - // the focus group stays empty and the only way out is Back (hardware key / bar chevron / - // Esc — all routed through the page ladder, none of which needs focus). - lv_obj_add_flag(s_spec_root, NAV_SKIP_FLAG); + // highlighting them for no reason. The focus group should stay empty and the only way + // out be Back (hardware key / bar chevron / Esc — all routed through the page ladder, + // none of which needs focus). + // PASSTHRU, not SKIP: NAV_SKIP_FLAG hides this root from navTopHasVisibleChild and + // navTopFrontmostChild too, so the collector decided the top layer showed nothing and + // re-rooted the group onto the ACTIVE SCREEN — i.e. the app drawer still open beneath + // this full-screen page (tool tiles deliberately leave it up). The d-pad then moved an + // unseen focus ring across hidden drawer tiles and OK launched them. PASSTHRU keeps the + // page as the focus container while excluding the container itself as a target, which + // is what the paragraph above actually describes. + lv_obj_add_flag(s_spec_root, NAV_PASSTHRU_FLAG); // Settings-subpage chrome: the GLOBAL status bar goes tall and shows "‹ Spectrum" // (tap the bar = Back). Content insets below the bar's lower row — no second header. @@ -24299,6 +24510,12 @@ static void openSpectrumPage() { lv_obj_set_size(s_spec_chart, chart_w, chart_h); lv_obj_set_pos(s_spec_chart, chart_x, chart_y); lv_obj_clear_flag(s_spec_chart, LV_OBJ_FLAG_SCROLLABLE); + // The trace is a read-out, never a control. lv_chart does NOT clear + // LV_OBJ_FLAG_CLICKABLE the way lv_label / lv_img do (LVGL 8.4 lv_obj.c sets + // it in the base constructor), so without this the nav collector takes the + // chart as the page's FIRST focus stop and navFocusCb paints it reverse-video + // — a solid fill straight over the live trace. Reported on the M9. + lv_obj_add_flag(s_spec_chart, NAV_SKIP_FLAG); lv_obj_set_style_bg_color(s_spec_chart, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(s_spec_chart, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(s_spec_chart, lv_color_hex(0x18191A), LV_PART_MAIN); @@ -24358,6 +24575,7 @@ static void openSpectrumPage() { lv_obj_set_style_bg_color(sw_box, lv_color_hex(kRampStops[s]), LV_PART_MAIN); lv_obj_set_style_bg_opa(sw_box, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(sw_box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(sw_box, NAV_SKIP_FLAG); // inert colour key — same reason as the chart above } s_spec_scale_hi_lbl = lv_label_create(s_spec_root); // ceiling dBm (top, strong) lv_label_set_text(s_spec_scale_hi_lbl, "--"); @@ -25296,6 +25514,31 @@ static void ctSetSelectMode(bool on){ refreshContactsList(); } +// Select mode is a MODE, not a popup: its registry row carries flags=0, so +// anyPopupOpen() never reports it, and nothing cleared it when the user left the +// Contacts tab. Left set, it made Back close it INVISIBLY from another tab — the +// registry walks in table order and its row sits above the app drawer's — costing +// a press with nothing on screen changing, exactly what the M9's "nothing ever +// closes invisibly beneath something else" rule forbids. The board already applies +// this discipline to map pan mode; this gives select mode the same. +static void ctExitSelectMode() { + if (!s_ct_select_mode) return; + // Deliberately NOT ctSetSelectMode(false). That one ends in + // s_ct_list_force + refreshContactsList(), i.e. a full teardown and rebuild + // of the contacts list — measured at ~1.3 s with a large list (#82). We are + // called from tabChangedCb as the user LEAVES Contacts, inside the tabview's + // VALUE_CHANGED chain, so paying it here would freeze the tab they are + // moving TO. Drop the mode and restore the toolbars now, and just ARM + // s_ct_list_force: tabChangedCb already calls refreshContactsList() on the + // way back into Contacts, so the clean rebuild happens where it is visible. + s_ct_select_mode = false; + s_ct_sel_n = 0; + if (s_ct_normal_bar) lv_obj_clear_flag(s_ct_normal_bar, LV_OBJ_FLAG_HIDDEN); + if (s_ct_select_bar) lv_obj_add_flag(s_ct_select_bar, LV_OBJ_FLAG_HIDDEN); + ctUpdateDelLabel(); + s_ct_list_force = true; +} + // Select EVERY contact passing the current filter + search (favorites skipped). static void ctSelectAllFiltered(){ if(!g_lv.task) return; @@ -26048,6 +26291,7 @@ static volatile int16_t s_tile_fetch_last_code = 0; // 'w' wrote ok 'O' open("w") failed (dir missing / SD write-protect / bus) // 'P' short/failed disk write (card full or SD error mid-write) // 'Z' bad/oversized content-length 'e' HTTP != 200 +// 'J' body was not a JPEG (proxy error page / blank body) // 'S' cache low-space gate 'H' internal-heap gate 'W' Wi-Fi down static volatile char s_tile_fetch_last_wr = '-'; static volatile uint16_t s_tile_fetch_open_fail = 0; // tileCacheOpen("w") returned an invalid File @@ -26121,6 +26365,11 @@ static int k_map_canvas_h = 226; constexpr uint8_t k_map_zoom_min = 3; constexpr uint8_t k_map_zoom_max = 19; constexpr uint8_t k_map_zoom_default = 14; +// Minimum gap between the progressive per-tile repaints in renderMapTiles. Each +// repaint is a full-screen composite plus the two transparent chrome bars (~115 ms +// on the M9), so painting after every tile dominated the cold open. 350 ms still +// gives ~3 visible fill-in steps across a ~1 s read+decode pass. +constexpr uint32_t k_map_progressive_ms = 350; // 3×3 grid covers a 768×768-px slab — ~130 px of pan buffer on each side // of the 240×226 viewport. We tried 5×5 (25 tiles, 3.2 MB PSRAM) for the // bigger buffer but it pushed PSRAM usage to the edge on the S3 boards — @@ -27423,7 +27672,16 @@ static void tileFetchTaskFn(void* arg) { tileCacheRemove(path_jpg); // present but unreadable/short/bad -> fall through and re-download } } - tileCacheRemove(path_png); // drop any stale .png from an older build — remove is a no-op if absent (don't gate on the broken exists()) + // Drop any stale .png from an older build — but ONLY on the firmware's own + // LittleFS cache partition, which is what this cleanup was written for. + // s_tile_fs is re-pointed at the SD ROOT on boards that cache tiles to the + // card (M9 always; see UITask::begin), and s_tile_root is "" there — so + // unguarded this resolved to SD.remove("/tiles///.png"), deleting + // the user's own offline map pack. The skip-check above only ever tests the + // .jpg, so every queued tile whose .jpg was absent erased the co-located + // .png. Same discriminator tileCacheMarkSdIo/tilesFsLowSpace already use. + if (s_tile_fs == &s_tiles_fs) + tileCacheRemove(path_png); // 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 @@ -27508,6 +27766,25 @@ static void tileFetchTaskFn(void* arg) { s_tile_fetch_step = 'r'; int content_len = http.getSize(); // Sanity cap — refuse anything > 100 KB. Tiles are typically 10-30 KB. + // NOTE (2026-08-28): a "stream it anyway when Content-Length is absent" + // variant was tried here for upstream #339 and REVERTED. Two reasons, both + // worth knowing before anyone tries it again: + // 1. It was aimed at the wrong target. The `wrZ` in that report is STALE + // telemetry (the same screenshot shows Wi-Fi off), and the maintainer's + // own curl against the proxy returns a valid Content-Length, so + // getSize() == -1 does not apply. The live symptom there is `d0/9` — + // the SD READ path finding nothing — not this write path. + // 2. It would not have worked. On arduino-esp32 2.0.17 http.getStreamPtr() + // hands back the RAW client; chunk de-framing lives only inside + // writeToStream(). Reading the raw stream on a genuinely chunked reply + // writes the hex chunk headers into the cache file, so the SOI check + // below rejects it anyway — 'Z' simply becomes 'J'. It also spun + // readBytes() for the full 2 s socket timeout at the end of every body + // (Stream::readBytes is an unyielded byte-at-a-time loop), on the + // core-0 task whose IDLE feeds the watchdog. + // If chunked support is ever genuinely needed, use http.writeToStream(&f) + // (fs::File is a Stream) and validate the file afterwards, rather than + // hand-rolling the framing here. if (content_len > 0 && content_len <= 100 * 1024) { File f = tileCacheOpen(path_jpg, "w"); if (f) { @@ -27588,7 +27865,14 @@ static void tileFetchTaskFn(void* arg) { s_tile_fetch_step = 'w'; s_tile_fetch_last_wr = 'w'; ++s_tile_fetch_ok; - s_tile_fetch_dirty = true; // render thread picks this up + // Visible zoom ONLY — same rule as the already-cached path above. A + // settled centre also enqueues a 29-tile background pyramid (z7/z8/z9 + + // two singles), and flagging dirty for those drove a full grid re-render + // AND marker rebuild on the LVGL thread up to 2.5x/s for the whole + // download window, for tiles that are not on screen at any point. Those + // passes also chew the internal DMA heap this very worker gates on, so + // they slowed the downloads they were waiting for. + if (req.z == s_map_zoom) s_tile_fetch_dirty = true; // render thread picks this up } else { WIRE_DBG("[TILE] -> FAILED %s\n", path_jpg); ++s_tile_fetch_failed; @@ -28140,8 +28424,14 @@ static bool tileExistsAt(uint8_t z, long x, long y) { // which would make the zoom guard think whole levels are un-cached. A successful open == it's there. snprintf(p, sizeof p, "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, x, y); { File f = tileCacheOpen(p, "r"); if (f) { f.close(); return true; } } - snprintf(p, sizeof p, "%s/%u/%ld/%ld.png", mapTileRoot(), (unsigned)z, x, y); - { File f = tileCacheOpen(p, "r"); if (f) { f.close(); return true; } } + // .jpg ONLY — deliberately no .png leg here. This branch must mirror the + // generic loader, and that builds exactly one path ("%s/%u/%ld/%ld.jpg", see + // loadTileJpeg) with no .png fallback: the LittleFS cache is JPEG-only by + // design. Probing .png made the zoom guard advertise — and the auto-snap + // select — levels the loader then could not draw, i.e. a silently blank map + // instead of an honest "Max zoom for this pack". Dropping it also halves the + // cost of every probe, which matters because bestAvailableZoom and + // mapZoomStep walk many levels per call. return false; } #endif // ESP32 @@ -28302,6 +28592,9 @@ static void renderMapTiles() { lv_obj_t* parent = s_map_pan_layer ? s_map_pan_layer : s_map_canvas; bool any_loaded = false; int n_missing = 0; // visible tiles we couldn't load from disk + // Progressive-paint throttle — see the lv_refr_now() call inside the loop. + uint32_t prog_last_ms = 0; + int prog_painted = 0; for (int i = 0; i < n_wanted; ++i) { if (wanted[i].placed) { any_loaded = true; continue; } // Pick an empty slot, PREFERRING one that already owns a reusable 128 KB @@ -28414,7 +28707,23 @@ static void renderMapTiles() { // screen, which is heavy on internal DMA RAM — doing that per-tile WHILE the // Wi-Fi worker holds a socket pushed the heap into an OOM reboot. When tiles // are arriving from the network, let LVGL batch one redraw at the end. - if (tileFetchPendingLoad() == 0) lv_refr_now(NULL); + // THROTTLED. Each of these composites the whole 240x320 screen AND alpha- + // blends the transparent status + tab bars over it — measured on the M9 at + // ~115 ms a call: measured 1042 ms of compositing across a 9-tile cold open, against + // read=497ms and decode=539ms. The per-tile paint was therefore costing MORE + // than the SD reads and the JPEG decodes COMBINED, to draw intermediate + // frames that flick past too fast to read individually. Paint the first tile + // straight away (so the map starts filling immediately), then at most every + // k_map_progressive_ms. Still visibly progressive, roughly a third of the + // cost. The final frame is painted by the normal LVGL tick once this returns. + if (tileFetchPendingLoad() == 0) { + const uint32_t now_ms = millis(); + if (prog_painted == 0 || (uint32_t)(now_ms - prog_last_ms) >= k_map_progressive_ms) { + lv_refr_now(NULL); + prog_last_ms = millis(); // AFTER the paint: throttle the gap between paints, not their starts + ++prog_painted; + } + } // Yield to the IDLE task after each tile's read + decode + paint. This loop // runs on loopTask, which is what the task watchdog watches via IDLE1; a full // 9-tile load (or a few slow SD reads) can otherwise pin the core long enough @@ -30770,6 +31079,18 @@ static void onMapTabActivated() { memset(s_tile_fetch_dedup, 0, sizeof(s_tile_fetch_dedup)); s_tile_fetch_dedup_head = 0; #endif + // Nine tile reads + JPEG decodes block this callback for ~2.5 s on the first + // open after boot (measured: [STALL] ui:lvgl 2597ms, M9_PORT.md). Show the + // hint and flush a frame FIRST — ahead of the recenter/zoom-probe block + // below, not just ahead of renderMapTiles. Sitting after that block, the hint + // could not paint until the probe had already finished, so the whole scan + // happened with the OUTGOING tab still frozen on the panel and the device + // looking wedged. Nothing here depends on the centre or zoom. + if (s_map_status_lbl) { + lv_label_set_text(s_map_status_lbl, TR("Loading map\xe2\x80\xa6")); + lv_obj_clear_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); + } + lv_refr_now(NULL); // drain one frame so the label reaches the panel before we block // The entire UI is now clocked at 160 MHz from UITask::begin, so there's // no per-tab boost dance here anymore. (Tried 240 MHz briefly — the // PSRAM bus tightens enough at that clock for the SJPG decoder to @@ -30795,18 +31116,23 @@ static void onMapTabActivated() { #if defined(ESP32) have_saved_zoom = (touchPrefsGetMapZoom() != 0); #endif - const uint8_t best = bestAvailableZoom(s_map_center_lat, s_map_center_lon); + // Only probe when the answer can actually be USED. Two ways it could not: + // • a persisted zoom wins outright, so the result is discarded one line + // down — but the walk was paid anyway, on every boot's first map open; + // • a still-zero centre has no tile to find at ANY level, so the walk ran + // all 17 zoom levels against the card and returned 0. That case is not + // first-open-only: s_map_view_inited below only latches once a centre + // exists, so without a fix (or a Profile location) the guard above never + // closes and this repeated on EVERY map open, forever. + // bestAvailableZoom has no other caller, so this is pure dead-work removal. + const bool centred = !(s_map_center_lat == 0.0 && s_map_center_lon == 0.0); + const uint8_t best = (have_saved_zoom || !centred) + ? 0 : bestAvailableZoom(s_map_center_lat, s_map_center_lon); if (best != 0 && !have_saved_zoom) s_map_zoom = best; - if (!(s_map_center_lat == 0.0 && s_map_center_lon == 0.0)) s_map_view_inited = true; + if (centred) s_map_view_inited = true; } - // 9 SPIFFS reads + JPEG decodes take ~1-3 seconds on first paint. Show a - // visible "loading" hint and force an LVGL render pass BEFORE we block on - // the actual tile reads, so the user doesn't think the device froze. - if (s_map_status_lbl) { - lv_label_set_text(s_map_status_lbl, TR("Loading map\xe2\x80\xa6")); - lv_obj_clear_flag(s_map_status_lbl, LV_OBJ_FLAG_HIDDEN); - } - lv_refr_now(NULL); // drain one frame so the label paints before we block + // (The "Loading map…" hint + frame flush moved to the top of this function — + // it has to precede the zoom probe above, not just the tile reads below.) renderMapTiles(); renderMapMarkers(); refreshMapInfoLabel(); @@ -33630,15 +33956,10 @@ static void chatParseMessageDisplay(const UITask::UIMessage& m, bool channel_mod (channel_mode && (m.sender[0] == '\0' || (m.sender[0] == 'r' && m.sender[1] == 'x' && m.sender[2] == '\0'))))) { - const char* colon = strstr(m.text, ": "); - if (colon) { - const int slen = static_cast(colon - m.text); - if (slen > 0 && slen <= UITask::MAX_SENDER_NAME) { - strncpy(d.retro_sender, m.text, slen); - d.retro_sender[slen] = '\0'; - d.show_sender = d.retro_sender; - d.show_text = colon + 2; - } + const char* body = ChannelSenderSplit::split(m.text, d.retro_sender, sizeof(d.retro_sender)); + if (d.retro_sender[0]) { + d.show_sender = d.retro_sender; + d.show_text = body; } } copyUtf8ReplacingMissingGlyphs(&g_font_12, d.san_sender, sizeof(d.san_sender), d.show_sender); @@ -38355,6 +38676,26 @@ static void serviceLockingCountdown(unsigned long now) { // (s_m9_map_pan — the Map-key pan-mode flag — is declared with the map state, // next to s_map_follow: mapAutoFollowTick pauses while it's set.) +// Return to the previously recorded main tab (see the M9 nav history block up by +// navGoToMainTab). Defined here rather than with the push because it needs +// s_m9_map_pan, which lives with the map state further down. Entries that no +// longer apply — the tab was removed, or we are already on it — are SKIPPED, not +// reported as a successful back: a stale entry must never cost the user a press. +// Returns false when the history is exhausted, which is the caller's cue to fall +// through to the next rung. +static bool m9NavPop() { + while (s_m9_nav_n > 0) { + const int tab = (int)s_m9_nav[--s_m9_nav_n]; + if (!g_lv.tabview || tab < 0 || tab > TAB_LAST || tab == getActiveTab()) continue; + s_m9_nav_replaying = true; // suppress the re-push from our own goToTab + s_m9_map_pan = false; // a pan flag must never outlive its tab (mirrors M9_KEY_HOME) + goToTab(tab); + s_m9_nav_replaying = false; + return true; + } + return false; +} + static bool m9HandleNavKey(int key) { if (!s_kbd_nav) return false; switch (key) { @@ -38378,7 +38719,18 @@ static bool m9HandleNavKey(int key) { // registry, or Back would close the enclosing modal instead of the list. // s_ct_select_mode is a flags=0 registry row (a MODE, not "a popup is // open"), so anyPopupOpen() alone would never let Back leave it. - if (s_setup_root) return true; // wizard: nothing to back out of + // Wizard: step BACK through it, exactly what its own on-screen Back + // button does (setupBackCb). The old "nothing to back out of" comment was + // wrong — the wizard is a four-step stack (welcome/name/region/Wi-Fi) and + // steps 1-3 each draw a working Back. This function exists precisely + // because the d-pad is the M9's only input INCLUDING first-boot setup, so + // it was the one place that premise had been abandoned. Returning false + // would not help either: the T-Deck swallow just below eats the key next. + if (s_setup_root) { + if (s_setup_step > 0) setupShowStep(s_setup_step - 1); + s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); + return true; + } // Pan is only the innermost "thing open" while the bare map is frontmost. // A popup stacked over it (CTRL) or a tab jump that missed a clear makes // the flag stale — drop it silently so THIS press acts on what the user @@ -38409,6 +38761,29 @@ static bool m9HandleNavKey(int key) { else if (s_apppage_close && !s_confirm_modal) s_apppage_close(); else if (anyPopupOpen() || s_ct_select_mode) hwKeyDismissTopPopup(); else if (LvChatPanel* cp = navOpenChatPanel()) closeChatPanel(cp); + // Every layer that was covering the screen is peeled — NOW go back a + // screen. This rung is what the ladder was missing: without it Back fell + // straight through to LV_KEY_ESC, which nothing in this build consumes + // (the sole consumer, lv_dropdown, is peeled several rungs above, and the + // tab bar is deliberately not a focus stop), so the press was swallowed + // and NOTHING happened on any bare tab. Sitting below the peel rungs is + // what keeps modals closing innermost-first — and a blocker popup still + // stops Back at the registry rung above, so a running SD format or bulk + // delete is never navigated out from under. + else if (m9NavPop()) { /* returned to the previous screen */ } + // History exhausted (cold boot onto a restored tab, or already fully + // unwound): fall back to Home — the rung pagerNavGoBack has always had + // and this ladder did not. On Home with an empty history ESC stays a + // genuine no-op, which is correct: Home is the root of the tree. + else if (getActiveTab() != HOME_TAB_INDEX) { + s_m9_map_pan = false; + // Unwinding, NOT new navigation: without suppressing the push, this + // jump would record the tab we are leaving, and the very next Back + // would pop straight back to it — Back ping-ponging Home/tab forever. + s_m9_nav_replaying = true; + navGoToMainTab(HOME_TAB_INDEX); + s_m9_nav_replaying = false; + } else navPushTap(LV_KEY_ESC); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_GPS_LONG: @@ -38449,16 +38824,34 @@ static bool m9HandleNavKey(int key) { } if (getActiveTab() == HOME_TAB_INDEX) { const bool was_open = s_home_drawer_mode; // read BEFORE dismissing anything - for (int i = 0; i < 8 && anyPopupOpen(); i++) hwKeyDismissTopPopup(); // still closes anything else on top (registry untouched, Back/etc. keep working) + // Stop on a key-blocker row (SD format / bulk delete progress) instead of + // spinning eight times closing nothing and then toggling the drawer out + // from under a running operation. + for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } + if (anyPopupOpen()) { s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; } setHomeDrawer(!was_open); // decide from the snapshot, not the now-mutated flag } else { s_m9_map_pan = false; // leaving the Map: pan must not outlive the tab (a stale flag ate the next Back press) - navGoToMainTab(HOME_TAB_INDEX); + if (!navGoToMainTab(HOME_TAB_INDEX)) { // a blocker popup refused: nothing moved, so keep the trail + s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; + } } + // HOME means "go to the root", so the trail behind it is spent: without + // this, Back from Home would jump back out to wherever you were before, + // which reads as a bug and makes the Home-tab ESC no-op unreachable. + // MSG / MAP are lateral jumps and deliberately keep pushing. + m9NavClear(); s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_LEFT_MESSAGE: if (s_setup_root) return true; s_m9_map_pan = false; // leaving the Map: same stale-pan clear as HOME + // Blocker check BEFORE the app page is destroyed — same order as + // SUB_MESSAGE / SUB_MAP below. navGoToMainTab now refuses to switch while + // a null-close progress row (SD format, bulk delete) owns the screen, so + // closing the page first would leave the user with neither the page nor + // the jump. + for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } + if (anyPopupOpen()) { if (g_lv.task) g_lv.task->noteUserInput(); return true; } if (s_apppage_close) s_apppage_close(); // close an open app page — else the jump lands invisibly beneath it navGoToMainTab(CHAT_INBOX_TAB_INDEX); if (g_lv.task) g_lv.task->noteUserInput(); return true; @@ -38485,6 +38878,9 @@ static bool m9HandleNavKey(int key) { : TR("Map pan off"), 1200); } else { s_m9_map_pan = false; // fresh entry always starts in nav mode + // Blocker check before destroying the page — see M9_KEY_LEFT_MESSAGE. + for (int i = 0; i < 8 && anyPopupOpen(); i++) { if (!hwKeyDismissTopPopup()) break; } + if (anyPopupOpen()) { if (g_lv.task) g_lv.task->noteUserInput(); return true; } if (s_apppage_close) s_apppage_close(); // close an open app page — else the jump lands invisibly beneath it navGoToMainTab(MAP_TAB_INDEX); } @@ -39319,6 +39715,12 @@ static void showSubtleNotifyLvgl(const char* text, uint32_t duration_ms) { lv_obj_clear_flag(s_notify_chip, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(s_notify_chip, LV_OBJ_FLAG_CLICKABLE); // taps pass through lv_obj_add_flag(s_notify_chip, LV_OBJ_FLAG_FLOATING); + // …and pass through keyboard nav too. Without this the chip counts as a + // visible lv_layer_top child, so navCollect re-roots the focus group onto it + // — and finds nothing focusable inside, because it is not CLICKABLE. The + // group came up EMPTY for the chip's ~1.1 s life: d-pad and OK inert, and + // the status-bar actions skipped, on every incoming message. + lv_obj_add_flag(s_notify_chip, NAV_SKIP_FLAG); lv_obj_t* l = lv_label_create(s_notify_chip); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); @@ -42347,6 +42749,10 @@ static void openAppGridSheet() { { const int ty = hdr + 2 * (btn_h + gap); lv_obj_t* hl = lv_label_create(card); + // Keep clear of the right-aligned switch below. With no width the label + // sizes to its text, so a longer translation ran straight under the switch. + lv_label_set_long_mode(hl, LV_LABEL_LONG_DOT); + lv_obj_set_width(hl, lv_pct(68)); lv_label_set_text(hl, TR("App drawer as home")); lv_obj_set_style_text_font(hl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); @@ -44554,7 +44960,11 @@ static int setupHeader(const char* title, const char* blurb, const char* step_ta lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_pos(t, 12, 12); - int y = 12 + 24; + // The title has LV_LABEL_LONG_WRAP at width sw-84, so it is NOT always one + // line — a longer translation wraps and the hardcoded 24 px advance put the + // blurb (and every step's content under it) straight through the title. + lv_obj_update_layout(t); + int y = 12 + LV_MAX(24, lv_obj_get_height(t) + 2); if (step_tag && step_tag[0]) { lv_obj_t* s = lv_label_create(s_setup_root); lv_label_set_text(s, step_tag); @@ -44580,6 +44990,9 @@ static int setupHeader(const char* title, const char* blurb, const char* step_ta // No reboot — used for Skip and (implicitly) anything that doesn't change the // radio/Wi-Fi. The status bar (hidden while the wizard owns the screen) returns. static void setupWizardClose() { +#if defined(HAS_M9_KEYBOARD) + m9NavClear(); // the wizard owned the whole screen; start the history fresh behind it +#endif g_set_modal.wifi_ssid_ta = nullptr; // we borrowed these for the scan popup g_set_modal.wifi_pwd_ta = nullptr; hideKb(); @@ -44806,6 +45219,9 @@ static void setupShowStep(int step) { static void setupWizardOpen() { if (s_setup_root) return; +#if defined(HAS_M9_KEYBOARD) + m9NavClear(); // no popping back to a pre-wizard tab from inside first-boot setup +#endif if (g_statusbar.root) lv_obj_add_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); // own the whole screen const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); @@ -45356,7 +45772,7 @@ static void openBlockedUsersModal() { for (int j = 0; j < nc; ++j) { if (the_mesh.getContactByIdx((uint32_t)j, c) && memcmp(c.id.pub_key, pub6, TOUCH_IGNORE_KEY_BYTES) == 0) { - snprintf(nm, sizeof nm, "%.24s", c.name); named = true; break; + snprintf(nm, sizeof nm, "%.31s", c.name); named = true; break; // full name width } } if (!named) snprintf(nm, sizeof nm, "%02X%02X%02X%02X%02X%02X", @@ -45405,7 +45821,7 @@ static void openBlockedUsersModal() { lv_obj_t* lbl = lv_label_create(row); char shown[40]; - snprintf(shown, sizeof shown, "%.24s", nm); // name-only entry + snprintf(shown, sizeof shown, "%.31s", nm); // name-only entry, full name width lv_label_set_text(lbl, shown); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); lv_obj_set_width(lbl, sw - 16 - 96); @@ -45463,6 +45879,13 @@ static void openChannelScopeModal(int slot, const char* name) { lv_obj_set_pos(nm, SC(8), SC(36)); lv_obj_t* hint = lv_label_create(s_chanscope_modal); + // One line, ellipsized. With no width the label sizes to its text and its tail + // ran past the modal edge; wrapping is not an option here either, because the + // textarea below is pinned at SC(72) and this sits at SC(54) — 18 px of room, + // less than a second line. A longer translation loses its tail rather than + // printing over the input. + lv_label_set_long_mode(hint, LV_LABEL_LONG_DOT); + lv_obj_set_width(hint, sw - SC(16)); lv_label_set_text(hint, TR("Region scope (#tag), blank = default.")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN); @@ -46104,11 +46527,19 @@ static void buildUiTree() { #endif #endif // !HAS_THINKNODE_M9 — tab-bar chrome - // Tab labels: icons-only on touch targets; the 480px-wide Pager prefixes each - // icon with its physical-keyboard mnemonic so the shortcuts are discoverable. + // Tab labels: icons-only on touch targets; the 480px-wide Pager appends each + // physical-keyboard mnemonic so the shortcuts are discoverable. // Add order == index order: Chats(0), Contacts(1), Home(2, middle), Map(3), Settings(4). #if defined(TLORA_PAGER) - lv_obj_t* tab_chats = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_ENVELOPE " M"); + const char* const pager_chats_tab_label = LV_SYMBOL_ENVELOPE " M"; + const char* const pager_settings_tab_label = LV_SYMBOL_SETTINGS " S"; + auto pagerTabIconBadgeX = [](int tab_index, const char* label, const char* icon) -> lv_coord_t { + const lv_coord_t cell_w = lv_disp_get_hor_res(nullptr) / 5; + const lv_coord_t label_w = lv_txt_get_width(label, strlen(label), &g_font_tab, 0, LV_TEXT_FLAG_NONE); + const lv_coord_t icon_w = lv_txt_get_width(icon, strlen(icon), &g_font_tab, 0, LV_TEXT_FLAG_NONE); + return cell_w * tab_index + cell_w / 2 - (label_w - icon_w) / 2 - 8; + }; + lv_obj_t* tab_chats = lv_tabview_add_tab(g_lv.tabview, pager_chats_tab_label); lv_obj_t* tab_contacts = lv_tabview_add_tab(g_lv.tabview, TOUCH_SYM_PERSON " C"); lv_obj_t* tab_home = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_HOME " H"); lv_obj_t* tab_map = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_GPS " A"); @@ -46136,7 +46567,7 @@ static void buildUiTree() { } #endif #if defined(TLORA_PAGER) - lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS " S"); + lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, pager_settings_tab_label); #else lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS); #endif @@ -46169,6 +46600,23 @@ static void buildUiTree() { if (tab_sensors) makeSensorsTab(tab_sensors); #endif +#if defined(HAS_THINKNODE_M9) + const lv_coord_t m9_notice_slot_w = lv_disp_get_hor_res(nullptr) / 5; + auto makeM9Notice = [&](const char* glyph, int slot) -> lv_obj_t* { + lv_obj_t* icon = lv_label_create(lv_layer_top()); + lv_label_set_text(icon, glyph); + lv_obj_set_size(icon, m9_notice_slot_w, 20); + lv_obj_set_style_text_align(icon, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_obj_set_style_text_font(icon, &g_font_16, LV_PART_MAIN); + lv_obj_align(icon, LV_ALIGN_BOTTOM_LEFT, slot * m9_notice_slot_w, -5); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(icon, LV_OBJ_FLAG_HIDDEN | NAV_SKIP_FLAG); + return icon; + }; + s_m9_mail_indicator = makeM9Notice(LV_SYMBOL_ENVELOPE, 0); + s_m9_contact_indicator = makeM9Notice(TOUCH_SYM_PERSON, 1); +#else // Red "!" update badge over the Settings gear (rightmost bottom tab). A child // of the screen (so it has no layout overriding its alignment) created BEFORE // the chat-detail overlays + above the tabview — it floats over the gear on @@ -46186,8 +46634,9 @@ static void buildUiTree() { lv_obj_set_style_text_font(s_update_badge, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_text_align(s_update_badge, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_pad_all(s_update_badge, 0, LV_PART_MAIN); -#if defined(HAS_THINKNODE_M9) - lv_obj_align(s_update_badge, LV_ALIGN_BOTTOM_RIGHT, -8, -4); // no tab bar on this board +#if defined(TLORA_PAGER) + lv_obj_align(s_update_badge, LV_ALIGN_BOTTOM_LEFT, + pagerTabIconBadgeX(4, pager_settings_tab_label, LV_SYMBOL_SETTINGS), -(TABBAR_H - 16)); #else lv_obj_align(s_update_badge, LV_ALIGN_BOTTOM_RIGHT, -8, -(TABBAR_H - 16)); #endif @@ -46209,14 +46658,15 @@ static void buildUiTree() { lv_obj_set_style_pad_hor(s_chat_unread_badge, 3, LV_PART_MAIN); lv_obj_set_style_pad_ver(s_chat_unread_badge, 0, LV_PART_MAIN); lv_obj_align(s_chat_unread_badge, LV_ALIGN_BOTTOM_LEFT, -#if defined(HAS_THINKNODE_M9) - 8, -4); // no tab bar on this board — bottom-left corner +#if defined(TLORA_PAGER) + pagerTabIconBadgeX(0, pager_chats_tab_label, LV_SYMBOL_ENVELOPE), -(TABBAR_H - 16)); #elif defined(HAS_EXPANSION_KIT) lv_disp_get_hor_res(nullptr) / 12 + 7, -(TABBAR_H - 16)); // 6 tabs: half-cell over Chats #else lv_disp_get_hor_res(nullptr) / 10 + 7, -(TABBAR_H - 16)); // 5 tabs: half-cell over Chats #endif lv_obj_add_flag(s_chat_unread_badge, LV_OBJ_FLAG_HIDDEN); +#endif #if !defined(HAS_THINKNODE_M9) // Thin rounded accent "glow" bar that marks the active tab. A child of the @@ -49385,8 +49835,8 @@ bool UITask::loadMsgsFromStorage() { d.rssi = m.rssi; strncpy(d.thread, m.thread, MAX_THREAD_NAME); d.thread[MAX_THREAD_NAME] = '\0'; - strncpy(d.sender, m.sender, MAX_SENDER_NAME); - d.sender[MAX_SENDER_NAME] = '\0'; + strncpy(d.sender, m.sender, sizeof(m.sender) - 1); // source is the frozen disk width + d.sender[sizeof(m.sender) - 1] = '\0'; strncpy(d.text, m.text, MAX_MSG_TEXT); d.text[MAX_MSG_TEXT] = '\0'; } @@ -49465,6 +49915,17 @@ bool UITask::loadMsgsFromSegments() { info.disk_recs = nrec; info.live_recs = nrec; info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz; + // A segment written at a DIFFERENT record width can be READ fine (the header is + // self-describing, and readHistoryRec prefix-reads or skips the surplus), but it + // must never be APPENDED to: the append writes sizeof(UiSegMsg) while the header + // still says rec_sz, so every record after the appended one is read at the wrong + // stride — silent garbage bubbles, a poisoned seq (which then drops every later + // segment), and the next compaction makes it permanent. Only a NON-FULL segment + // can ever receive an append, so flag just those for a full rewrite; step 0 of + // segBuildJob runs that before any append, and the rewrite lays down a fresh + // header at the current width. A full old-width segment is left alone — it is + // read correctly by its own header and costs nothing. + if ((size_t)rec_sz != sizeof(UiSegMsg) && nrec < k_ui_seg_records) info.rewrite_open = 1; // The file holds records the table doesn't account for — rewrite it so disk // and table agree again (also drops the dead weight for good). if (skipped) info.rewrite_open = 1; @@ -49706,8 +50167,8 @@ bool UITask::loadLegacyHistoryFromStorage() { _ui_msgs[i].rssi = m.rssi; strncpy(_ui_msgs[i].thread, m.thread, MAX_THREAD_NAME); _ui_msgs[i].thread[MAX_THREAD_NAME] = '\0'; - strncpy(_ui_msgs[i].sender, m.sender, MAX_SENDER_NAME); - _ui_msgs[i].sender[MAX_SENDER_NAME] = '\0'; + strncpy(_ui_msgs[i].sender, m.sender, sizeof(m.sender) - 1); // frozen disk width + _ui_msgs[i].sender[sizeof(m.sender) - 1] = '\0'; strncpy(_ui_msgs[i].text, m.text, MAX_MSG_TEXT); _ui_msgs[i].text[MAX_MSG_TEXT] = '\0'; } else { @@ -49925,8 +50386,8 @@ static void uiSegMarshal(const UITask::UIMessage& s, UiSegMsg* d) { d->m.rssi = s.rssi; strncpy(d->m.thread, s.thread, sizeof(d->m.thread) - 1); d->m.thread[sizeof(d->m.thread) - 1] = '\0'; - strncpy(d->m.sender, s.sender, sizeof(d->m.sender) - 1); - d->m.sender[sizeof(d->m.sender) - 1] = '\0'; + SenderExtField::store(s.sender, d->m.sender, sizeof(d->m.sender), + d->sender_ext, sizeof(d->sender_ext)); strncpy(d->m.text, s.text, sizeof(d->m.text) - 1); d->m.text[sizeof(d->m.text) - 1] = '\0'; d->seq = s.seq; @@ -50124,8 +50585,9 @@ static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint out->rssi = rec.m.rssi; strncpy(out->thread, rec.m.thread, UITask::MAX_THREAD_NAME); out->thread[UITask::MAX_THREAD_NAME] = '\0'; - strncpy(out->sender, rec.m.sender, UITask::MAX_SENDER_NAME); - out->sender[UITask::MAX_SENDER_NAME] = '\0'; + SenderExtField::load(rec.m.sender, sizeof(rec.m.sender), + rec.sender_ext, sizeof(rec.sender_ext), + out->sender, sizeof(out->sender)); strncpy(out->text, rec.m.text, UITask::MAX_MSG_TEXT); out->text[UITask::MAX_MSG_TEXT] = '\0'; out->seq = rec.seq; @@ -51452,6 +51914,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // reliably starts the NMEA reader. if (_sensors && _node_prefs && _node_prefs->gps_enabled) { _sensors->setSettingValue("gps", "1"); + // Same reason as toggleGPS: without this the acquisition clock would start + // whenever the user first opened the GPS page, not at the boot that actually + // started the receiver. + { const uint32_t t = millis(); s_gps_acq_since = t ? t : 1u; } } // Map options exposes the same source toggle on T-Deck and T-Pager. Restore // that choice on both boards; forcing it off on Pager made a selected SD pack @@ -52669,6 +53135,26 @@ void UITask::openMeshContactDm(uint32_t mesh_contact_index) { } hideKb(); closeSettingsModal(); + // Switch to the Chats tab HERE, and fire the event the way goToTab does. + // This used to sit at the END of this function as a bare lv_tabview_set_act() + // with no LV_EVENT_VALUE_CHANGED, so tabChangedCb never ran for it: it left + // s_lv_tab_prev stale, which made the NEXT real tab change compute + // leaving_inbox as false and skip the only code that hides this DM overlay — + // the chat then stayed painted over whatever tab the user moved to. Running it + // BEFORE the overlay is un-hidden and before showKb() also means + // tabChangedCb's own hideKb() + overlay cleanup can't undo the setup below, + // which is what keeping it at the end was really working around. + if (g_lv.tabview && getActiveTab() != CHAT_INBOX_TAB_INDEX) { +#if defined(HAS_M9_KEYBOARD) + m9NavPush(getActiveTab()); // so Back leaves the chat and returns to where it was opened from +#endif + // Keep LV_ANIM_OFF: the chat detail overlay covers the tabview so the 150 ms + // slide is invisible anyway, and it was stealing refresh ticks from the first + // composer tap + keyboard show — a ~300-500 ms perceived lag on the + // map → marker → Send msg path. + lv_tabview_set_act(g_lv.tabview, CHAT_INBOX_TAB_INDEX, LV_ANIM_OFF); + lv_event_send(g_lv.tabview, LV_EVENT_VALUE_CHANGED, nullptr); + } if (g_lv.ch.detail_open && g_lv.ch.overlay) { lv_obj_add_flag(g_lv.ch.overlay, LV_OBJ_FLAG_HIDDEN); g_lv.ch.detail_open = false; @@ -52688,14 +53174,7 @@ void UITask::openMeshContactDm(uint32_t mesh_contact_index) { // Physical keyboard: focus the composer on open so typing goes straight in. showKb(&g_lv.dm); #endif - // Skip the LV_ANIM_ON tab transition (150 ms slide) — the chat detail - // overlay covers the tabview so the slide is invisible anyway, and the - // animation was stealing refresh ticks from the first composer tap + - // keyboard show, causing a ~300-500 ms perceived lag on the map → - // marker → Send msg path. - if (g_lv.tabview) { - lv_tabview_set_act(g_lv.tabview, CHAT_INBOX_TAB_INDEX, LV_ANIM_OFF); - } + // (The tab switch moved ABOVE, before the overlay setup — see the note there.) g_lv.dirty_threads = true; #endif } @@ -52952,6 +53431,15 @@ void UITask::toggleGPS() { // still consistent — same behaviour as before. (void)hw_ok; _node_prefs->gps_enabled = target_on ? 1 : 0; + // Start the acquisition clock HERE. It used to be stamped lazily on the first + // gpsStatusStr() call, and that function only runs from the GPS settings page + // and the Control Center chip — so the "searching Nm" figure measured time + // since the user first LOOKED at it, not since the GPS was switched on. A + // receiver that had been searching for twenty minutes reported "0m02s" the + // moment you opened the page, which reads as "it only starts when I navigate + // here". Non-zero sentinel: millis() can legitimately be 0 at boot, and + // gpsStatusStr treats 0 as "not started yet". + { const uint32_t t = millis(); s_gps_acq_since = target_on ? (t ? t : 1u) : 0u; } the_mesh.savePrefs(); } @@ -53697,17 +54185,8 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex char parsed_sender[MAX_SENDER_NAME + 1]; parsed_sender[0] = '\0'; const char* body = text ? text : ""; - if (channel && text && !have_sender_override) { - const char* colon = strstr(text, ": "); - if (colon) { - int slen = static_cast(colon - text); - if (slen > 0 && slen <= MAX_SENDER_NAME) { - strncpy(parsed_sender, text, slen); - parsed_sender[slen] = '\0'; - body = colon + 2; - } - } - } + if (channel && text && !have_sender_override) + body = ChannelSenderSplit::split(text, parsed_sender, sizeof(parsed_sender)); const char* sender = have_sender_override ? sender_override : (channel @@ -55076,6 +55555,31 @@ void UITask::loop() { uiCp("ui:status"); if (now >= _next_refresh) { refreshStatusLabels(); +#if defined(HAS_THINKNODE_M9) + if (s_m9_mail_indicator && s_m9_contact_indicator) { + lv_obj_t* top = lv_layer_top(); + const bool top_has_content = navTopHasVisibleChild(top); + const bool drawer_front = top_has_content && s_appdrawer_root && + navTopFrontmostChild(top) == s_appdrawer_root; + const bool notice_surface = !_screen_off && !_manual_lock && !s_remote_mode && + !s_setup_root && !s_settings_sheet && + !s_apppage_title && !s_chat_title[0] && + (!anyPopupOpen() || drawer_front); + const bool mail_pending = notice_surface && getUnreadTotal() > 0; + const bool contact_pending = notice_surface && discoveredCount() > 0; + const bool blink_on = ((now / 500u) & 1u) == 0; + if (mail_pending || contact_pending) { + lv_obj_move_foreground(s_m9_mail_indicator); + lv_obj_move_foreground(s_m9_contact_indicator); + } + lv_obj_set_style_text_color(s_m9_mail_indicator, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_obj_set_style_text_color(s_m9_contact_indicator, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + if (mail_pending && blink_on) lv_obj_clear_flag(s_m9_mail_indicator, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(s_m9_mail_indicator, LV_OBJ_FLAG_HIDDEN); + if (contact_pending && blink_on) lv_obj_clear_flag(s_m9_contact_indicator, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(s_m9_contact_indicator, LV_OBJ_FLAG_HIDDEN); + } +#endif // Unread-count badge over the Chats tab icon (bottom bar). if (s_chat_unread_badge) { const int u = getUnreadTotal(); @@ -55241,6 +55745,15 @@ void UITask::loop() { serviceLockscreen(); } #elif defined(HAS_M9_KEYBOARD) + // Own the stall buckets for everything a keypress triggers. The "ui:input" + // checkpoint further up is inside #if CAP_TRACKBALL, which is 0 on this board + // (device_caps.h) — so with no checkpoint here, every keypress-driven action + // on the M9 (a map open, a tab jump, a Back press: the d-pad is the ONLY + // input this board has) was billed to whichever bucket happened to be open, + // which is "ui:threads". That made the stall ring actively misleading on the + // one board where it matters most: a 2.5 s cold map open was reported as a + // slow chat-threads refresh. The three checkpoints below tag it honestly AND + // split it — see the note at ui:navreb. // M9's keyboard controller is on its OWN bus (Wire1) — no touch task shares // it, so polling happens right here on the UI thread rather than from a // separate core-0 task (the poll itself rate-limits to ~15 ms inside @@ -55252,7 +55765,20 @@ void UITask::loop() { // s_nav_objs still mirroring the screen behind the modal — the intermittent // "modal navigation breaks out to the screen behind" bug. Sync the group to // the current tree before draining (cheap: sig-compare early-out). + // + // The three checkpoints below split what used to be one "ui:input" bucket. It + // spanned the focus-group rebuild, the I2C keyboard/sensor polls AND every + // action handleHwKey dispatches, so a ~1.2 s stall reported as "ui:input" said + // almost nothing about where the time went. Measure the split before + // optimising: on the map, the obvious suspect (JPEG decode) turned out to cost + // less than the compositing nobody had counted. uiCp() is two loads and a + // compare, so these checkpoints are free. + // ui:navreb — nav focus-group rebuild (walks the widget tree) + // ui:kbpoll — I2C keyboard drain + compass/IMU idle ticks + // ui:keys — handleHwKey: the actual action (page builds, list rebuilds, …) + uiCp("ui:navreb"); if (s_kbd_nav || s_tb_nav) navMaybeRebuild(); + uiCp("ui:kbpoll"); m9KeyboardPoll(); #if defined(HAS_M9_COMPASS) m9CompassIdleTick(); // park the magnetometer when no app is reading it @@ -55260,6 +55786,7 @@ void UITask::loop() { #if defined(HAS_M9_IMU) m9ImuIdleTick(); // and the accelerometer #endif + uiCp("ui:keys"); for (int kbi = 0; kbi < 12; ++kbi) { int key = m9KeyboardReadKey(); if (key <= 0) break; @@ -55648,6 +56175,7 @@ void UITask::loop() { if (s_sd_format_pending && --s_sd_format_pending == 0) { s_sd_format_wait_until = 0; bool ok = false; + bool mkfs_ok = false; // the format ITSELF succeeded, independent of the remount below { LoopWdtGuard loop_wdt_guard; SPIClass* spi = sdSharedSPI(); // this board's SD bus (T-Deck: LoRa SPI; R8: TFT FSPI; M9: shared SPI) @@ -55665,13 +56193,28 @@ void UITask::loop() { if (work) { if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { sdWriteFatLabel(pdrv, "MESHCOMOD"); + mkfs_ok = true; ok = true; } free(work); } sdcard_uninit(pdrv); } - if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); + // Remount through the FULL mount ladder, not a bare single-shot begin. + // sdcard_uninit() above issues GO_IDLE_STATE, so the card is back in SPI + // idle and has to re-run its whole power-up handshake — exactly the case + // fmSdTryMount's ladder exists for (7 rungs, 4 MHz down to 400 kHz with + // 40-900 ms settles, and an upward renegotiation after a slow success). + // A single 4 MHz SD.begin with no settle made a card that HAD just been + // formatted fall into the failure branch and report itself as ABSENT — + // upstream #343, "SD card reported missing when formatting". That + // ladder's own comment flags it as unverified on M9 timing, which is + // precisely why the format path must not hand-roll a weaker version of + // it. Clear the backoff first: this is an explicit user action, and the + // preceding SD.end()/uninit may have stamped one. + // (fmSdTryMount takes its own LoopWdtGuard; nesting is a no-op because + // the inner guard sees the WDT already unsubscribed.) + if (ok) { s_sd_retry_after_ms = 0; ok = fmSdTryMount(); } } } fmHideFormatOverlay(); @@ -55695,7 +56238,19 @@ void UITask::loop() { // The backend handshake waits for any current File and preserves queued // requests, then drops the failed SD backend without leaving a pause set. mapNoteStorageChanged(); - showAlert(TR("Format failed (no card / SD error)"), 3500); + // Do NOT say "no card" when the card was in fact formatted successfully + // and only the remount missed — that reads as "the device cannot see my + // card" and sends people hunting a hardware fault that isn't there + // (upstream #343). Separate the two outcomes: f_mkfs failing is a real + // format failure; mkfs_ok with a failed remount is a card that is fine and + // freshly FAT32, just not answering yet. + // A failed remount stamped a 10 s backoff inside fmSdTryMount. Clear it: + // fmShowRoots() runs immediately below and gates the SD row on that same + // backoff, so leaving it set hides the card for 10 s while the user is + // reading a toast telling them to reinsert it. + s_sd_retry_after_ms = 0; + showAlert(mkfs_ok ? TR("Formatted OK - card did not remount, reinsert it") + : TR("Format failed (no card / SD error)"), 3500); } if (s_fm_list) fmShowRoots(); } diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 3a631fb6..a7fbc611 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -36,7 +36,11 @@ public: static const int MAX_UI_MESSAGES_SD = 5000; static const int MAX_UI_THREADS = 48; static const int MAX_THREAD_NAME = 32; - static const int MAX_SENDER_NAME = 24; + // Full MeshCore name width: ContactInfo::name / ChannelDetails::name / NodePrefs:: + // node_name are all char[32], so 31 chars + NUL is exactly lossless. Was 24, which + // silently dropped every longer name. The PERSISTED width is frozen separately + // (k_ui_disk_sender_len in UITask.cpp) — raising this does not move any on-disk field. + static const int MAX_SENDER_NAME = 31; static const int MAX_MSG_TEXT = 160; // full LoRa text length (was 96 -> cut long msgs ~3 lines) static const int MAX_UI_PATH = 32; // inbound-route bytes/message for the Info popup (covers deep + multi-byte-hash routes) diff --git a/test/test_channel_sender_split.cpp b/test/test_channel_sender_split.cpp new file mode 100644 index 00000000..78fecbe6 --- /dev/null +++ b/test/test_channel_sender_split.cpp @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "ui-touch/ChannelSenderSplit.h" + +// UIMessage::sender is char[MAX_SENDER_NAME + 1] with MAX_SENDER_NAME == 31 — the full +// MeshCore name width (ContactInfo::name[32]), so every real name now fits and the +// truncation path is unreachable from the firmware's own callers. It is still exercised +// below against narrower destinations: the helper is documented to truncate to whatever +// it is handed, and the beta that shipped a 24-char field relies on exactly that. +static const size_t kSenderCap = 32; + +// The frozen on-disk width (k_ui_disk_sender_len) — and what the previous beta's +// UIMessage::sender was. The truncation + UTF-8 back-off cases run against this. +static const size_t kNarrowCap = 25; + +int main() { + char sender[kSenderCap]; + + // Ordinary channel post: author out, body in. + const char* body = ChannelSenderSplit::split("nick: hello there", sender, sizeof sender); + assert(strcmp(sender, "nick") == 0); + assert(strcmp(body, "hello there") == 0); + + // The regression this all started from: a 27-char name. It used to fail the split + // outright, so the bubble header showed the channel and the body kept reading + // "nick: message". At the full width it is stored whole, with no marker. + const char* long_name = "Ouderkerk Repeater Node Two: on air"; + body = ChannelSenderSplit::split(long_name, sender, sizeof sender); + assert(strcmp(sender, "Ouderkerk Repeater Node Two") == 0); + assert(strlen(sender) == 27); + assert(strcmp(body, "on air") == 0); + + // The longest name the wire can carry: 31 chars, stored exactly, no marker. + const char* longest = "1234567890123456789012345678901: body"; + body = ChannelSenderSplit::split(longest, sender, sizeof sender); + assert(strlen(sender) == 31); + assert(strcmp(sender, "1234567890123456789012345678901") == 0); + assert(strcmp(body, "body") == 0); + + // One char PAST the wire width is not a name at all — no node can be called this — so + // the ": " belongs to the message. Leave the text whole rather than inventing an author + // and hiding the start of the body. + const char* not_a_name = "12345678901234567890123456789012: body"; + assert(strlen(not_a_name) == 38); + body = ChannelSenderSplit::split(not_a_name, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == not_a_name); + + // The realistic shape of that: an unprefixed post whose body just happens to contain + // ": ". It must survive intact, whole, with the caller's own sender label. + const char* prose = "the repeater at Ouderkerk is back up: full quieting again"; + body = ChannelSenderSplit::split(prose, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == prose); + + // ---- Narrower destination: truncate the label, never reject the split ------------- + char narrow[kNarrowCap]; + + // The 27-char name again. Too long for this field, so it is cut and marked with "..." + // — a shortened name must not read as a different node. + body = ChannelSenderSplit::split(long_name, narrow, sizeof narrow); + assert(strlen(narrow) == 24); + assert(strcmp(narrow, "Ouderkerk Repeater No...") == 0); + assert(strcmp(body, "on air") == 0); + + // The widest name the wire can carry still splits here — cut, but the author is out + // of the body, which is the whole point. + body = ChannelSenderSplit::split(longest, narrow, sizeof narrow); + assert(strcmp(narrow, "123456789012345678901...") == 0); + assert(strcmp(body, "body") == 0); + + // One char over the cap still gets the marker (name budget 21, not 24). + const char* one_over = "1234567890123456789012345: body"; + body = ChannelSenderSplit::split(one_over, narrow, sizeof narrow); + assert(strcmp(narrow, "123456789012345678901...") == 0); + assert(strcmp(body, "body") == 0); + + // Exactly at the cap: 24 chars, no truncation, no marker. + const char* exact = "123456789012345678901234: body"; + body = ChannelSenderSplit::split(exact, narrow, sizeof narrow); + assert(strcmp(narrow, "123456789012345678901234") == 0); + assert(strcmp(body, "body") == 0); + + // Truncation lands on a character boundary, never inside a multi-byte sequence. + // "Ouderkerkk" (10) + U+2600 (3 bytes) x 5 = 25 bytes; the 21-byte name budget + // (24 minus the marker) falls inside the fourth sun, so it backs off to 19. + const char* utf8_name = "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi"; + body = ChannelSenderSplit::split(utf8_name, narrow, sizeof narrow); + assert(strcmp(narrow, "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0); + assert(strlen(narrow) == 22); + assert(strcmp(body, "hi") == 0); + + // Same shape one char shorter, so the budget already sits on a boundary: the back-off + // must not eat a whole character that fits. + const char* utf8_fits = "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi"; + body = ChannelSenderSplit::split(utf8_fits, narrow, sizeof narrow); + assert(strcmp(narrow, "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0); + assert(strlen(narrow) == 24); + assert(strcmp(body, "hi") == 0); + + // Destination too small to hold a marker: truncate plainly rather than spending the + // whole label on dots. + char tiny[5]; + body = ChannelSenderSplit::split("nickname: body", tiny, sizeof tiny); + assert(strcmp(tiny, "nick") == 0); + assert(strcmp(body, "body") == 0); + + // ---- Nothing to split -------------------------------------------------------------- + + // No author prefix — text passes through untouched, sender stays empty so the + // caller falls back to from_name. + const char* plain = "just a message"; + body = ChannelSenderSplit::split(plain, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == plain); + + // A bare colon without the space is not a separator. + const char* no_space = "nick:hello"; + body = ChannelSenderSplit::split(no_space, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == no_space); + + // Leading separator: empty author is not a name. + const char* leading = ": body"; + body = ChannelSenderSplit::split(leading, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == leading); + + // First separator wins — a colon in the body does not re-split. + body = ChannelSenderSplit::split("nick: ratio 3: 1", sender, sizeof sender); + assert(strcmp(sender, "nick") == 0); + assert(strcmp(body, "ratio 3: 1") == 0); + + // Degenerate inputs. + assert(strcmp(ChannelSenderSplit::split(nullptr, sender, sizeof sender), "") == 0); + assert(sender[0] == '\0'); + const char* keep = "nick: hi"; + assert(ChannelSenderSplit::split(keep, nullptr, 0) == keep); + return 0; +} diff --git a/test/test_sender_ext_field.cpp b/test/test_sender_ext_field.cpp new file mode 100644 index 00000000..45adb591 --- /dev/null +++ b/test/test_sender_ext_field.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "ui-touch/SenderExtField.h" + +// The on-disk shape: base is the frozen v6 field (24 chars + NUL), ext is the appended +// tail, and together they hold MAX_SENDER_NAME == 31 chars. +static const size_t kBaseCap = 25; +static const size_t kExtCap = 7; +static const size_t kOutCap = 32; + +int main() { + char base[kBaseCap], ext[kExtCap], out[kOutCap]; + + // Short name: fits the base field, ext stays empty. + SenderExtField::store("nick", base, sizeof base, ext, sizeof ext); + assert(strcmp(base, "nick") == 0); + for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0'); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, "nick") == 0); + + // Exactly 24: fills the base field with no spill. The tail must stay empty, or the + // "base is full" test below would resurrect garbage. + const char* exactly24 = "123456789012345678901234"; + assert(strlen(exactly24) == 24); + SenderExtField::store(exactly24, base, sizeof base, ext, sizeof ext); + assert(strcmp(base, exactly24) == 0); + for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0'); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, exactly24) == 0); + + // A full 31-char name — the case the whole split exists for. Round-trips intact. + const char* full = "Ouderkerk Repeater Node Twelve"; + assert(strlen(full) == 30); + SenderExtField::store(full, base, sizeof base, ext, sizeof ext); + assert(strncmp(base, full, 24) == 0 && base[24] == '\0'); + assert(memcmp(ext, full + 24, 6) == 0); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, full) == 0); + + // The longest possible name fills ext completely, leaving it unterminated. load() is + // bounded by the field size, so it must not read past. + const char* longest = "1234567890123456789012345678901"; + assert(strlen(longest) == 31); + SenderExtField::store(longest, base, sizeof base, ext, sizeof ext); + assert(memcmp(ext, longest + 24, kExtCap) == 0); // no NUL anywhere in ext + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, longest) == 0); + + // An OLD record: base written by a pre-widening build, ext zero-filled by the loader's + // prefix read. Must yield exactly the stored short name. + memset(ext, 0, sizeof ext); + memset(base, 0, sizeof base); + strcpy(base, "olde name"); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, "olde name") == 0); + + // Garbage in ext behind a NON-full base must be ignored — that is the corrupt-record + // case, and appending it would splice junk onto a valid short name. + memset(ext, 'X', sizeof ext); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, "olde name") == 0); + + // An unterminated base field (corrupt record) must not read into the message body that + // follows it on disk: the copy stops at the field width. + char raw_base[kBaseCap]; + memset(raw_base, 'A', sizeof raw_base); // no NUL at all + memset(ext, 0, sizeof ext); + SenderExtField::load(raw_base, sizeof raw_base, ext, sizeof ext, out, sizeof out); + assert(strlen(out) == 24); + + // A multi-byte character straddling the boundary: split anywhere, rejoin exact. The + // halves are never used apart, so the byte-level split is deliberately not aligned. + const char* utf8 = "Ouderkerkkkkkkkkkkkkkkk\xE2\x98\x80"; // 23 ASCII + 3-byte U+2600 + assert(strlen(utf8) == 26); + SenderExtField::store(utf8, base, sizeof base, ext, sizeof ext); + assert((unsigned char)base[23] == 0xE2); // sequence is cut mid-character on disk + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(strcmp(out, utf8) == 0); // and comes back whole + + // Degenerate inputs. + SenderExtField::store(nullptr, base, sizeof base, ext, sizeof ext); + assert(base[0] == '\0'); + SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out); + assert(out[0] == '\0'); + return 0; +} diff --git a/variants/thinknode_m9/M9_PORT.md b/variants/thinknode_m9/M9_PORT.md index 5de385c5..23009863 100644 --- a/variants/thinknode_m9/M9_PORT.md +++ b/variants/thinknode_m9/M9_PORT.md @@ -363,8 +363,9 @@ ruled out the tile-slot pool, the tab-bar removal, and the key-drain timing: touchless board it was uncontrollable dead space. `TABBAR_H` is now 0 for M9 and the btnmatrix is hidden; every tab remains reachable via the dedicated HOME/MESSAGE/MAP keys and the app drawer's Chats/Contacts/Map/Settings tiles. - Content gains the 30 px row; the update / chat-unread badges anchor to the - bottom corners instead of the (gone) bar. + Content gains the 30 px row. The M9 draws no notification badges there; + unread messages and newly discovered contacts instead blink small accent-colour + envelope/person glyphs in the former Chats and Contacts nav slots. - **Spectrum sweep crawl fixed (LR1110-specific)**: `LR11x0::getRSSI(false)` is not the SX126x's cheap register read — it internally re-arms RX and drops to standby around EVERY call, so the 6-read peak-hold per bin paid ~960 extra @@ -667,6 +668,211 @@ Gotchas for anyone revisiting this: onMapTabActivated). The open levers are decoding on core 0, or the M9's SD clock (still 4 MHz — see the deferred list below). + **SUPERSEDED by measurement, 2026-08-28.** The decode-vs-read framing above is + directionally right but was reasoning about the two SMALLER halves. Instrumented + on hardware (`[MAPPROF]`, audit pass 3 below), a 9-tile cold open split: + + ``` + total=2189ms probe=0ms render=2080ms + [read=497ms decode=539ms other=1042ms tiles=9] markers=10ms + ``` + + `other` — the per-tile `lv_refr_now()` progressive paint — cost **more than the + SD reads and the JPEG decodes combined**. On the immersive full-screen map each + call composites the whole 240x320 panel *and* alpha-blends the transparent + status + tab bars over it: ~115 ms a call, nine times. Throttling that to one + paint per 350 ms (`k_map_progressive_ms`) is a bigger win than either lever + named above, and it is board-independent. Measure before optimising here: the + intuition that "it must be the decode" was wrong by a factor of two. + +## Audit pass 3 (2026-08-28) — cold map open + a real Back history + +Third full sweep, prompted by two field reports: "the map doesn't load fast the +first time" and "Back should go back to the previous navigation". Both were +real; both had one identifiable cause. All eight PlatformIO envs compile-verified +(the shared `UITask.cpp` is the blast radius); on-device validation still wanted. + +**Map, cold open.** The 2026-08-22 pass above fixed the *re-open* cost and left +cold open at 2598 ms, correctly attributing the bulk to the SJPG decode. What it +did not catch is what runs *in front* of that decode: + +- **`bestAvailableZoom()` was called unconditionally** (`onMapTabActivated`) and + its result discarded one line later whenever a zoom was persisted — which it + is as soon as the user touches +/- once. It walks z19→z3 probing the card at + each level, returning at the first hit. Now skipped when the answer cannot be + used. It has no other caller, so this is pure dead-work removal. +- **The no-fix case was never first-open-only.** `s_map_view_inited` only latches + once a non-zero centre exists, so with no GPS fix and no Profile location the + recenter guard never closed: the full 17-level walk ran against lat/lon 0,0 — + tiles that cannot exist — on *every* map open, forever, and `renderMapTiles` + then bailed to the placeholder and freed the slots anyway. The centre is not + persisted across reboots either (only the zoom is), so every boot starts here. +- **The "Loading map…" hint could not paint.** It was set *after* the probe, so + the whole scan happened with the outgoing tab still frozen on the panel. Moved + to the top of the function, and `goToTab` now uses `LV_ANIM_OFF` for the map + tab so the flush is not racing a 200-400 ms slide. Zero ms saved, but it turns + a frozen screen into visible progress — which is most of the reported bug. +- **`tileExistsAt` probed `.png`, the generic loader only opens `.jpg`.** The + zoom guard therefore advertised, and the auto-snap selected, levels that render + blank. The `.png` leg is gone: the guard now mirrors the loader, and every + probe costs half as much. + +**Map, data loss (ship this one regardless).** `tileCacheRemove(path_png)` in the +fetch worker — "drop any stale .png from an older build" — was written for the +firmware's private LittleFS partition. `UITask::begin` re-points `s_tile_fs` at +the SD **root** on this board with `s_tile_root = ""`, so it resolved to +`SD.remove("/tiles///.png")`: the user's own offline map pack. The +skip-check above it only ever tests the `.jpg`, so every queued tile whose `.jpg` +was absent erased the co-located `.png`. Now gated on `s_tile_fs == &s_tiles_fs`, +the same discriminator `tileCacheMarkSdIo`/`tilesFsLowSpace` use. Wi-Fi-only +trigger, permanent loss. + +**Back.** The ladder was a faithful copy of `pagerNavGoBack` **minus one rung** — +`else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX);`. +Without it Back fell through to `navPushTap(LV_KEY_ESC)`, and nothing in this +build consumes `LV_KEY_ESC`: the sole consumer is `lv_dropdown`, peeled several +rungs above, and `navMaybeRebuild` deliberately keeps the tab bar out of the +focus group. The press was consumed by the `return true` and nothing happened, on +every bare tab. + +Restoring that rung alone gives *Home*, not *back*, so this pass also adds the +history that never existed anywhere in this UI (`s_lv_tab_prev` is a single +transition latch; `s_apppage_close` is depth 1; the popup registry walks in +static table order): + +- **`s_m9_nav[]` — 12 entries, 24 B.** Records main-tab switches only. Pushed + from `goToTab` (the single choke point, and the only place that fires + `LV_EVENT_VALUE_CHANGED` explicitly), popped by a new rung placed **below** + every peel rung, so modals still close innermost-first and a null-close blocker + row still stops Back at the registry rung. +- **Deliberately not stacked:** the app drawer (a registry row rung 6 already + closes, whose mode survives a tab switch and is restored on return to Home) and + app pages / chats / settings sheets (all peeled above). Duplicating those here + would double-close. +- **Never store an `lv_obj_t*`** — settings sheets, app pages and the drawer are + destroyed, often via `del_async`. Entries are tab indices; one that no longer + applies is skipped, never a dead press. +- **`s_m9_nav_replaying`** suppresses the re-push from a pop's own `goToTab`. + The Home-fallback rung needs it too: without it that jump recorded the tab it + was leaving and the next Back popped straight back — Back ping-ponging forever. +- **HOME clears the trail** (it means "go to root"); MSG/MAP are lateral jumps + and keep pushing. + +Also closed in this pass, all confirmed and all reachable on this board: + +- **`openMeshContactDm` bypassed the choke point** — a bare `lv_tabview_set_act` + with no event send, so `tabChangedCb` never ran, `s_lv_tab_prev` went stale, + and the *next* real tab change computed `leaving_inbox` false and skipped the + only code that hides the DM overlay. The chat stayed painted over whatever tab + you moved to. The switch now fires the event, and moved above the overlay + setup so `tabChangedCb`'s own `hideKb()` cannot undo it. +- **Setup wizard Back was a dead key** for the whole wizard, even on the three + steps that draw a working on-screen Back wired to `setupBackCb`. The comment + claimed "nothing to back out of"; the wizard is a four-step stack. +- **Contacts select mode outlived its tab.** Its registry row has `flags=0`, so + `anyPopupOpen()` never reported it and nothing cleared it on tab change — + leaving Back to close it *invisibly* from another tab. `ctExitSelectMode()` now + runs on leaving Contacts, the same discipline `s_m9_map_pan` already gets. +- **`navGoToMainTab` ignored key-blocker rows** — the unconditional 8-iteration + drain spun closing nothing and switched tabs out from under a running SD format + or bulk delete. Now returns bool and stops. (The board's SUB_MESSAGE/SUB_MAP + cases already did this correctly; HOME and `navGoToMainTab` did not.) +- **Two focus-group blackouts** that made Back's *result* undrivable: the + notification chip was a non-`NAV_SKIP` `lv_layer_top` child with nothing + focusable inside, emptying the group for its ~1.1 s life on every incoming + message; and the Spectrum page's `NAV_SKIP_FLAG` root hid it from + `navTopHasVisibleChild`, re-rooting the group onto the app drawer still open + beneath it (d-pad moved an unseen ring over hidden tiles, OK launched them). + + **The Spectrum fix needs BOTH halves — `NAV_PASSTHRU_FLAG` alone is a trap.** + Passthru excludes only the container; `navCollect` still recurses into the + subtree. `lv_chart` does **not** clear `LV_OBJ_FLAG_CLICKABLE` (LVGL 8.4 sets + it in the base `lv_obj` constructor; only `lv_label` and `lv_img`/`lv_canvas` + clear it), and `lv_obj_remove_style_all()` on the five colour-ramp swatches + removes styles, not flags. So passthru-on-root alone handed the page six focus + stops where it previously had zero, and `navFocusCb`'s reverse-video fill + painted a solid white block straight over the live trace — caught on hardware + within a minute of flashing. The chart and each swatch now carry + `NAV_SKIP_FLAG` so the group ends up genuinely empty, which is what the + original comment always claimed. Anything added to this page later must do the + same, or it becomes a focus stop by default. + + Known consequence: with the page as the focus container, `useTop` is true and + `navAddStatusBarActions()` is skipped, so the "‹ Spectrum" chevron is no + longer a d-pad target. Back (hardware key / registry row) still closes the + page on every board, which is the documented exit. + +- **`ctExitSelectMode` must not go through `ctSetSelectMode(false)`.** That + setter ends in `s_ct_list_force = true; refreshContactsList();` — the full + teardown+rebuild measured at ~1.3 s with a large list (#82). Called from + `tabChangedCb` as the user LEAVES Contacts, inside the tabview's + `VALUE_CHANGED` chain, that freezes the tab they are moving *to*, on every + board. It now restores the toolbars and only ARMS `s_ct_list_force`; the + rebuild lands on the way back in, where `tabChangedCb` already calls + `refreshContactsList()` and the user can see it. + +- **Blocker check must precede the app-page close.** With `navGoToMainTab` now + refusing to switch while a null-close progress row owns the screen, + `M9_KEY_LEFT_MESSAGE` and `M9_KEY_MAP` — which called `s_apppage_close()` + *first* — would have destroyed the page and then not jumped, leaving the user + with neither. Both now drain-and-bail before closing anything, the order + `SUB_MESSAGE` / `SUB_MAP` already used. + +**Measured on hardware after the above** (`[MAPPROF]`, a `HAS_M9_KEYBOARD`-only +`Serial.printf` in `onMapTabActivated` with `micros()` accumulators around +`loadTileJpeg` and the decode — **diagnostic scaffolding, strip or gate it before +release**): + +| | cold open (first after boot) | re-open | +|---|---|---| +| before this pass | 2598 ms | 245 ms | +| after dead-work removal | 2189 ms | 141-165 ms | +| **after repaint throttle** | **1507 ms** | **~150-190 ms** | +| split, before throttle | read 497 / decode 539 / **other 1042** / markers 10 | render 5-6, tiles=0 | +| split, after throttle | read 499 / decode 539 / other **358** / markers 10 | render 7, tiles=0 | + +The throttle moved `other` and nothing else (read 497->499, decode 539->539 are +run-to-run noise), which is the confirmation that it cut compositing rather than +starving the load path. Cold open is down **42%** from the 2598 ms baseline; the +`[STALL] ui:lvgl` bucket for the same action fell 2497 ms -> 1822 ms. + +With compositing no longer dominant, the two levers named in the superseded note +above finally ARE the top of the list, and they are now roughly equal in size: +decode 539 ms (core-0 decoding) and read 499 ms (the 4 MHz SD clock — one line, +`SD_SPI_FAST_HZ`, which only the V4-R8 env sets today). Treat the SD clock with +care: this board shares one SPI peripheral across radio + display + card, and +deferred item 4 records that the M9's mount-ladder timing margins were never +characterised. + +Three things this settled that static reading could not: + +1. **`probe=0ms` confirms the `bestAvailableZoom` guard fires.** The 2598→2189 + delta (~410 ms) is the scan that used to run and be discarded — the top of the + 100-500 ms band the audit estimated. +2. **`other` dominates.** See the correction under "Map re-open cost" above. The + progressive per-tile repaint, not decode and not SD, was the biggest single + cost. Now throttled via `k_map_progressive_ms`. +3. **The retained-slot path works exactly as designed** — a re-open reports + `tiles=0`, `render=5-6ms`: a true pure blit. Note that ~135 ms of the ~150 ms + re-open is now the "Loading map…" hint's `lv_refr_now`, which fires even when + the render will not block. Suppressing it when a pure blit is predictable + (same zoom + centre as the last render, and `s_map_last_missing == 0`) is the + obvious next trim, and is NOT done. + +Stall attribution had to be fixed first, or none of these numbers meant what they +said: `uiCp("ui:input")` lives inside `#if CAP_TRACKBALL`, which is **0** on the +M9, while the keyboard drain sits between the `ui:threads` and `ui:diag` +checkpoints. So on the one board whose *only* input is the d-pad, every +keypress-driven action — map opens included — was billed to `ui:threads`. A cold +map open was being reported as a slow chat-threads refresh. The M9 now has its own +`uiCp("ui:input")`. (Work that reaches LVGL via `navPushTap` still lands in +`ui:lvgl`, since the handler runs inside `lv_timer_handler`; that is correct.) + +Still open on the map, in win/risk order: a priority lane for visible tiles +(`xQueueSendToFront` — today the nine tiles you are looking at queue *behind* the +29-tile background prefetch pyramid on every pan), a retry pump so a dropped +fetch heals without a manual pan, and the SD clock below. + ## Deferred — hardware-verify list These are left intentionally unset/unwired rather than guessed: