diff --git a/deploy/meshamerica-catalog.json b/deploy/meshamerica-catalog.json index 1cb2595..0c76e19 100644 --- a/deploy/meshamerica-catalog.json +++ b/deploy/meshamerica-catalog.json @@ -17,19 +17,19 @@ "role": "gui", "title": "Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-heltec-v4-tft-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-heltec-v4-tft.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft.bin", "title": "Update (app only)" } ] @@ -49,19 +49,19 @@ "role": "gui", "title": "Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tdeck-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tdeck.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck.bin", "title": "Update (app only)" } ] @@ -81,19 +81,19 @@ "role": "gui", "title": "[LR1121] Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tlora-pager-lr1121-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tlora-pager-lr1121.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121.bin", "title": "Update (app only)" } ] @@ -104,19 +104,19 @@ "role": "gui", "title": "[SX1262] Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tlora-pager-sx1262-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tlora-pager-sx1262.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262.bin", "title": "Update (app only)" } ] diff --git a/platformio.ini b/platformio.ini index dc10360..824c84b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -272,7 +272,12 @@ build_flags = -D PIN_BOARD_SDA=17 -D PIN_BOARD_SCL=18 -D PIN_SD_CS=3 - -D HELTEC_V4_BUZZER_PIN=-1 + ; Buzzer: the Expansion Kit V2 integrates a piezo behind a PAM8904 driver (U9). Per Heltec's + ; Expansion_board_V2.03.pdf schematic: DIN = net "4" = GPIO4 (P2 header pin 15); EN1/EN2 are + ; NOT GPIOs -- strapped high via 10K pull-ups (3x boost, always enabled) with DIP switch SW1 + ; on the expansion board as a HARDWARE MUTE (closed = EN grounded = silent). So the stock V4 + ; tone() chime works as-is on DIN. If a tester still hears nothing: check SW1 on their kit. + -D HELTEC_V4_BUZZER_PIN=4 -D FIRMWARE_OTA_ENV='"heltec_v4_r8_tft_companion_radio_usb_tcp_touch"' lib_deps = ${env:heltec_v4_tft_companion_radio_usb_tcp_touch.lib_deps} @@ -665,6 +670,9 @@ build_flags = ; --- T-LoRa Pager board --- -I variants/lilygo_tlora_pager -D TLORA_PAGER=1 + -D LV_FONT_MONTSERRAT_18=1 + -D LV_FONT_MONTSERRAT_20=1 + -D LV_FONT_MONTSERRAT_24=1 -D BOARD_HAS_PSRAM=1 ; 0 = silence ARDUHAL [E] spam, same rationale as the other two envs. -D CORE_DEBUG_LEVEL=0 @@ -887,6 +895,9 @@ build_flags = ; --- T-LoRa Pager board (SX1262 variant) --- -I variants/lilygo_tlora_pager -D TLORA_PAGER=1 + -D LV_FONT_MONTSERRAT_18=1 + -D LV_FONT_MONTSERRAT_20=1 + -D LV_FONT_MONTSERRAT_24=1 -D BOARD_HAS_PSRAM=1 ; 0 = silence ARDUHAL [E] spam, same rationale as the other envs. -D CORE_DEBUG_LEVEL=0 @@ -911,13 +922,13 @@ build_flags = -D SX126X_DIO2_AS_RF_SWITCH=true -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 - ; Deliberately NOT setting SX126X_DIO3_TCXO_VOLTAGE (unlike the T-Deck's - ; 1.8f) -- that IS a board-specific analog value (this board's actual TCXO - ; circuit), and trail-mate's own working reference for this exact PCB never - ; overrides it either, relying on CustomSX1262::std_init()'s built-in 1.6V - ; fallback (RadioLib's own SX1262::begin() default) -- which also already - ; auto-retries at 0.0f on an SPI-cmd failure if 1.6V doesn't suit this board's - ; real hardware. Revisit with a measured value if bring-up shows otherwise. + ; LilyGoLib's SX1262 transmit/receive examples for this exact Pager explicitly + ; drive the module's DIO3 TCXO supply at 3.0 V. The generic CustomSX1262 + ; fallback is only 1.6 V; that can leave this board's oscillator outside its + ; specified supply range while still working at very short range. Pass the + ; board value into begin() so calibration and all later RX/TX use the correct + ; reference from the start. + -D SX126X_DIO3_TCXO_VOLTAGE=3.0f -D P_LORA_DIO_1=14 -D P_LORA_NSS=36 -D P_LORA_RESET=47 diff --git a/release-notes/beta_51.txt b/release-notes/beta_51.txt new file mode 100644 index 0000000..3ca31c4 --- /dev/null +++ b/release-notes/beta_51.txt @@ -0,0 +1,14 @@ +# beta_51 - chat layout, an Airtime tool, and a lockscreen fix +# One user-facing note per non-blank, non-# line; # lines are section comments. + +A community drop. Thanks to Yazutsu, scratchdiver and jacobpretorius. + +# Chat +Message bubbles now show the time on the top row next to the sender, so more messages fit on screen. Applies to channels, DMs and room-server threads. (scratchdiver) + +# New: Airtime tool +New Airtime tool in the Apps drawer measures how busy the LoRa channel really is, so you can check congestion instead of guessing. It shows channel use over time, packets lost at the radio, live signal and noise, the LoRa preset, and a plain verdict on whether congestion is likely. (jacobpretorius) + +# Fixed +Lockscreen wallpaper no longer turns into coloured noise after you use the map. A custom JPG wallpaper could show garbage until a reboot. (Yazutsu, fixes #127) +T-Display P4: the text size setting works again. It was stuck at 100% no matter what you picked. diff --git a/release-notes/beta_52.txt b/release-notes/beta_52.txt new file mode 100644 index 0000000..166e1f2 --- /dev/null +++ b/release-notes/beta_52.txt @@ -0,0 +1,13 @@ +# beta_52 - major SD chat-history storage rework (community) - RISKY, NEEDS TESTING +# One user-facing note per non-blank, non-# line; # lines are section comments. + +WARNING - RISKY TEST BUILD. This beta reworks how chat history is stored on the SD card. It has NOT been tested over long uptime yet and may have data-loss bugs. +WARNING - The chat-history format CHANGES and the change is ONE-WAY. If you flash an older firmware after running this, your saved chat history is lost. Back it up first if it matters to you. +WARNING - Do not run this as your only or daily device unless you are OK risking your chat history. We specifically need people to run it for hours or days and report any SD errors or history-not-saving problems. + +# What changed +Major SD storage rework, contributed by the community (Vybo): the device now recovers from an SD card that wedges or is pulled at runtime instead of needing a reboot; the About page shows chat-store health (backend, save time, failures); and chat history is now written as small append-friendly segment files instead of rewriting the whole file on every save. +Fixed the follow-on bug where SD writes started failing after a few hours of uptime once the segment store's table filled up. + +# Everything from beta_51 is also included +Chat bubbles show the time on the top row, the new Airtime channel-utilization tool, the lockscreen wallpaper corruption fix, and the T-Display P4 text-size fix. diff --git a/release-notes/beta_53.txt b/release-notes/beta_53.txt new file mode 100644 index 0000000..2af6992 --- /dev/null +++ b/release-notes/beta_53.txt @@ -0,0 +1,36 @@ +# beta_53 - speed with a big chat history, blocking that actually works, T-Display P4 fixes +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Read this first ------------------------------------------------------------------- +HEADS UP - chats now keep the newest 250 messages each by default. If you have a chat with more than that, the older ones are dropped the next time it receives a message. Change it or turn it off under Settings > Chats > "Keep per chat (messages)" BEFORE you let a busy channel run, if you want to keep a deep backlog. + +# --- Speed ---------------------------------------------------------------------------- +Fixed the big one: a large chat history no longer slows the whole interface down. With a busy channel the chat list was re-scanning the entire message store once for every chat, every time it refreshed - so the more history you had, the slower everything got. A device with 3800 messages in one channel was crawling. That work is now done once instead of once per chat. +New setting: "Keep per chat (messages)" under Settings > Chats. Choose 100, 250, 500, 1000, 2000 or No limit. The default is 250. Older messages in a chat are dropped past the limit, which stops one busy channel filling the whole message store and starving your other chats of history. +Turning the limit off asks you to confirm first, because an unlimited history is exactly what made things slow. + +# --- Blocking ------------------------------------------------------------------------- +Blocking someone now actually works. Three separate bugs: blocking a sender in a channel never saved properly so it did not stick, blocking a person inside a room server silenced the ENTIRE room instead of that one person, and blocked names were only filtered in channels so room posts still came through. Thanks to D-Melhede for reporting. +Unblocking now fully clears a block. Blocks were stored in two places and only one was being removed, so someone could stay silently blocked after you unblocked them. + +# --- Chats ---------------------------------------------------------------------------- +Fixed the chat list preview showing an old message and never updating. It picked the "newest" message by timestamp, which goes wrong on a device whose clock restarts at boot - so a message from an earlier session could outrank everything new forever. It now uses the actual arrival order. Mostly seen on the T-Display P4. +Notification sound works again. A failed map-tile fetch could leave an internal counter stuck, and that counter was silently muting every chime. Thanks to ropafiro for reporting (#184). + +# --- Stability + connection ------------------------------------------------------------ +Fixed multi-second freezes on Heltec V4 units in a busy mesh. Every advert heard was triggering a redundant write to internal flash, and contact saves were not rate-limited, so the interface could lock up for seconds at a time with nothing obviously happening. Thanks to Raph for the detailed report and video. +Wi-Fi scanning no longer gets stuck off. If the connection dropped at the wrong moment the scan gate stayed shut for good; it now clears itself and re-enables auto-reconnect (#171). +The phone app gets its replies again. Ping and telemetry responses could be swallowed before reaching the app (#124). +Typing into the web control panel no longer drops characters. Thanks to VE2CCK for reporting. + +# --- Apps ----------------------------------------------------------------------------- +The Airtime and Snake apps now work like the Spectrum app: a proper "< back" title bar, and you can no longer accidentally tap or swipe through to the screen behind them. They also no longer risk a crash when closing. +Swiping inside any full-screen app (Airtime, Spectrum, RF Monitor, Discover, Web, VNC, Remote) no longer jumps you to another tab. +Fixed three older reports from dreirund: the crash-restart dialog text overlapping its buttons (#97), the settings sheet opened from "Discovered" being cut off (#109), and room servers now showing their own icon in the contacts list (#106). + +# --- T-Display P4 ---------------------------------------------------------------------- +The clock is fixed. The P4 showed the wrong time on screen while sent messages carried the correct one, and using "Sync clock from system" then broke the good clock too. Its real-time-clock chip was being set but the system clock never was. +New: LoRa antenna setting under Settings > Radio. The on-board antenna is selected on every boot, and switching to the external socket asks you to confirm an antenna is actually connected first - transmitting into an empty connector can damage the radio. +If sending has been much weaker than receiving on your P4, this is why: the firmware was transmitting on the on-board antenna while listening on the external one. Pick the antenna you actually have fitted. +Reduced a source of the whole-screen flicker (#167) by keeping the display's working buffer off the shared memory bus. Please report whether you still see it. +Text size and the control-centre layout fixes from earlier betas also apply. diff --git a/release-notes/beta_54.txt b/release-notes/beta_54.txt new file mode 100644 index 0000000..4613e48 --- /dev/null +++ b/release-notes/beta_54.txt @@ -0,0 +1,19 @@ +# beta_54 - T-Display P4 screen flash fixed, P4 GPS fixed, Romanian, V4-R8 landscape rescue +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- T-Display P4 ---------------------------------------------------------------------- +The whole-screen blue flash (#167) is fixed. It was never the display: writing to the SD card delays the interrupt that restarts the screen's frame timer, and the panel shows the late frame as a flash. The fix moves your contacts, channels and chat history to fast internal storage - the SD card now carries only map tiles, backups and exports. Your data migrates across automatically on first boot; the card is not modified and everything on it stays as a backup. +Because the card no longer holds your identity, swapping or removing the SD card can never again change who your node is or lose your contacts. +GPS is really fixed this time: the firmware never actually read what the GPS module was saying (the sensor loop was missing on this board). It reads it now - fixes confirmed outdoors, and the T-Deck-style quick fix behaviour returns after the module has had sky time. +The GPS settings page now shows live satellite info (in view, in use, signal strength) and updates while open, so "Searching" is no longer a black box. +Storage writes now run on a dedicated background worker, so a big contact save cannot make the interface stutter. + +# --- Romanian (community) --------------------------------------------------------------- +Romanian is the 13th interface language, with its own keyboard layout: ă on the home row, ş/ţ on the deck, â/î via the accent popups. Contributed by @jcyrio (#188, #189) - mulţumim! + +# --- Heltec V4-R8 ----------------------------------------------------------------------- +Fixed landscape mode registering every touch in the wrong place, which could leave the device stuck in landscape. The R8's screen sits rotated differently from the V4's, and the touch mapping assumed the V4. If you were stuck: this build boots back to portrait automatically. +For safety the R8 returns to portrait on every boot for now; landscape can be re-tested from Settings and we will make it stick once a tester confirms the fix. + +# --- General ---------------------------------------------------------------------------- +The Airtime and Snake apps got a crash fix when closing. diff --git a/release-notes/beta_55.txt b/release-notes/beta_55.txt new file mode 100644 index 0000000..583b0a7 --- /dev/null +++ b/release-notes/beta_55.txt @@ -0,0 +1,17 @@ +# beta_55 - settings that stay put, fast SD boots, Pager text sizes, R8 sound +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Reliability (community) ----------------------------------------------------------- +Settings can no longer silently reset themselves. An interrupted settings write could truncate the settings file - the sound toggle turning itself back on, the setup wizard reappearing, a mystery #unknown channel. Settings now save through paired snapshots with checksums, written in the background: a torn write can only lose the very last change, never your configuration. By @PixPMusic (#195, #196). +Settings changes also no longer cause interface hitches - saves happen off the interface thread. +Note if you downgrade after running this build: the older firmware reads the settings file which can be one save behind. +Fixed the 3-minute boot with an SD card inserted (T-Deck, mostly Launcher installs). A card that woke up at a low speed kept that speed for the whole session, so contacts and history loaded at ~25 KB/s - and the file manager showed the card as 0KB/empty. The mount now raises the clock back to full speed after a slow wake, the standard way. Thanks to scsirob for the report and testing (#194). + +# --- T-Pager (community) --------------------------------------------------------------- +New UI size presets for the T-Pager: Small (current look, default), Medium and Large - bigger text without pushing controls off the short screen, with encoder scrolling for tall content. By @PixPMusic (#197). Please try Medium/Large and report anything that clips. + +# --- Heltec V4-R8 ----------------------------------------------------------------------- +The buzzer works. The Expansion Kit V2's buzzer sat behind a driver chip whose wiring was unconfirmed, so it shipped disabled - R8 users have never heard a chime. Heltec's schematic settled it. If it is still silent on your kit, check the small SW1 switch on the expansion board: it is a hardware mute. + +# --- General ---------------------------------------------------------------------------- +Long-pressing the GPS tile in the control centre now opens the GPS settings page instead of the radio page. diff --git a/release-notes/beta_56.txt b/release-notes/beta_56.txt new file mode 100644 index 0000000..83ceb32 --- /dev/null +++ b/release-notes/beta_56.txt @@ -0,0 +1,13 @@ +# beta_56 - duty-cycle fix (important), Pager SD storage, issue-sweep fixes +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Read this first ------------------------------------------------------------------- +IMPORTANT - if you ever picked a region preset, your radio was transmitting at roughly 9x the duty limit (about 91% instead of 10%). The preset math set the airtime limit 10x too loose; the home-screen meter showing "91% max duty cycle" was correctly reporting it (#161). This build fixes the presets AND automatically corrects devices whose settings exactly match a preset. If you tuned your airtime factor by hand, nothing is touched. + +# --- T-Pager ---------------------------------------------------------------------------- +The SD card is now actually used (#193). The card mounted and browsed fine but nothing stored to it - the storage feature was never enabled for this board. The Pager now gets the same "Store data on SD" option, boot storage selection and migration as the T-Deck. + +# --- Fixes across boards ---------------------------------------------------------------- +Message details now show the region scope for scoped direct messages, and no longer show "Scope 0000" for senders that carry the region in the alternate slot (#157). +The Discover app keeps the last-heard time on screen with long node names - names are trimmed and node types shown as single letters (#191). +The screen-unlock animation says WADAMESH instead of the old MESHCOMOD (#165). diff --git a/release-notes/beta_57.txt b/release-notes/beta_57.txt new file mode 100644 index 0000000..b08ab6f --- /dev/null +++ b/release-notes/beta_57.txt @@ -0,0 +1,20 @@ +# beta_57 - sends retry until heard, SD settings-save fix, Pager radio fix +# One user-facing note per non-blank, non-# line; # lines are section comments. +# Community beta: PixPMusic (storage + Pager) and mikecarper (send retry). + +# --- Sending -------------------------------------------------------------------------- +Sends now retry until the mesh has actually heard them. Until now a message was transmitted exactly once - if that one transmission collided or a repeater was mid-hop, it was gone while the app showed it as sent. The device now listens for its own message coming back (a repeater echo or the delivery ACK) and retransmits with a growing backoff until something confirms it was heard. Works for direct and flood sends, including sends from the phone app. Built by mikecarper (PR #207). +New toggle under Settings > Radio & Mesh: "Retry sends until heard" (on by default) - turn it off if you want the old single-shot behaviour. + +# --- Storage -------------------------------------------------------------------------- +Saving settings on SD storage works again. On devices that keep their data on the SD card (T-Deck, V4-R8, Pager, Launcher installs) every settings save failed with "Couldn't save name to storage" - the commit step renamed a file onto itself, so it could never succeed. Found and fixed by PixPMusic (PR #211). +Watchdog state is preserved around long SD and flash work instead of being blindly re-armed afterwards - removes a cause of spurious watchdog reboots (PixPMusic, PR #213). +Settings reads no longer hand out pointers into a shared cache another task could invalidate mid-read (PixPMusic, PR #212). + +# --- Pager ---------------------------------------------------------------------------- +T-Lora Pager (SX1262): the radio oscillator is now driven at the 3.0 V LilyGo's own reference code uses, instead of a generic 1.6 V fallback. Weak transmissions and missing ACKs on this board came down to this - confirmed on hardware by PixPMusic (PR #209). + +# --- Fixes ---------------------------------------------------------------------------- +Discover no longer freezes when a node's name contains an emoji or a "#". A name cut mid-emoji broke the list's colour markup for every row after it, so the list looked frozen from that point on. Names are cleaned and escaped before display, here and in the message-info repeat list. Thanks to 4nuts for reporting with the exact culprit name (#223). +Formatting an SD card on the V4-R8 or ThinkNode M9 no longer hangs forever. The Format button existed on both boards but the code doing the format was only compiled for the T-Deck, so the notice stayed up with nothing running behind it. Thanks to TrustYourTechnoLust (#172). +Files app: Copy and Paste now work on every board - outside the T-Deck they showed the busy overlay forever for the same reason as the format hang. diff --git a/src/DataStore.cpp b/src/DataStore.cpp index 89d302e..55b1395 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -46,6 +46,9 @@ const char* DataStore::_rp(const char* name) { } File DataStore::openWrite(FILESYSTEM* fs, const char* filename) { +#if defined(TDP4_POKE_TRACE) + printf("[SDW] %lu core%d store w %s\n", (unsigned long)millis(), xPortGetCoreID(), filename); +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove(_rp(filename)); return fs->open(_rp(filename), FILE_O_WRITE); @@ -192,6 +195,9 @@ File DataStore::openRead(const char* filename) { } File DataStore::openRead(FILESYSTEM* fs, const char* filename) { +#if defined(TDP4_POKE_TRACE) + printf("[SDR] %lu core%d store r %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), filename); +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return fs->open(_rp(filename), FILE_O_READ); #elif defined(RP2040_PLATFORM) @@ -365,6 +371,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no } bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; const NodePrefs* p; double la, lo; bool r; } a{ this, &_prefs, node_lat, node_lon, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->savePrefs(*a->p, a->la, a->lo); }, &a); + return a.r; + } +#endif // Write to a temp file first and swap it in afterwards: a reboot / power cut // mid-write can then never destroy the only copy (the loader recovers from // whichever file survived). SPIFFS has no atomic rename-over, so the swap is @@ -413,8 +426,17 @@ bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ _fs->remove(_rp("/new_prefs.tmp")); // short write (storage full?) — keep the old main file return false; } - _fs->remove(_rp("/new_prefs")); - return _fs->rename(_rp("/new_prefs.tmp"), _rp("/new_prefs")); + // _rp() returns a pointer to one shared scratch buffer when the store has + // a root prefix (for example SD:/meshcomod). Calling it twice in rename() + // therefore aliases both arguments to whichever path was formatted last, + // leaving new_prefs.tmp stranded even though the data itself was written. + // Keep the source and destination in independent buffers for the swap. + char tmp_path[48]; + char live_path[48]; + snprintf(tmp_path, sizeof(tmp_path), "%s/new_prefs.tmp", _root); + snprintf(live_path, sizeof(live_path), "%s/new_prefs", _root); + _fs->remove(live_path); + return _fs->rename(tmp_path, live_path); } return false; } @@ -468,6 +490,13 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3"); } void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; DataStoreHost* h; bool (*f)(const ContactInfo&); } a{ this, host, filter }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveContacts(a->h, a->f); }, &a); + return; + } +#endif #if defined(ESP32) // A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC // pass that disables the flash cache and starves core 0's idle task, tripping @@ -608,6 +637,13 @@ void DataStore::loadChannels(DataStoreHost* host) { } void DataStore::saveChannels(DataStoreHost* host) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; DataStoreHost* h; } a{ this, host }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveChannels(a->h); }, &a); + return; + } +#endif File file = openWrite(_getContactsChannelsFS(), "/channels2"); if (file) { uint8_t channel_idx = 0; @@ -778,6 +814,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b } bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); + return a.r; + } +#endif if (len < PUB_KEY_SIZE+4+SIGNATURE_SIZE || len > MAX_ADVERT_PKT_LEN) return false; checkAdvBlobFile(); File file = _getContactsChannelsFS()->open("/adv_blobs", FILE_O_WRITE); @@ -815,6 +858,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src return false; // error } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); + return a.r; + } +#endif return true; // this is just a stub on NRF52/STM32 platforms } #else @@ -841,6 +891,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b } bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); + return a.r; + } +#endif char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); @@ -856,6 +913,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { + struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); + return a.r; + } +#endif char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); @@ -912,3 +976,57 @@ bool DataStore::useSdMmcStorage() { return true; } #endif + +#if defined(HAS_TDISPLAY_P4) +// #167: core-0 storage dispatcher. The DSI panel's frame-restart ISR lives on core 1 (where +// Arduino's loop task also runs); SDMMC/FATFS write paths entered FROM core 1 mask that core's +// interrupts inside their critical sections long enough to drop a display frame -- the +// whole-screen flash. Tiles write the same card from the core-0 tile task without ever +// flashing; this moves every hot store write onto the same quiet core. The caller BLOCKS until +// the write completes, so call-site semantics are exactly as before. +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" + +struct P4StoreJob { void (*fn)(void*); void* arg; SemaphoreHandle_t done; }; +static QueueHandle_t s_p4store_q = nullptr; + +static void p4StoreTaskFn(void*) { + P4StoreJob j; + for (;;) { + if (xQueueReceive(s_p4store_q, &j, portMAX_DELAY) == pdTRUE) { + j.fn(j.arg); + xSemaphoreGive(j.done); + } + } +} + +static TaskHandle_t s_p4store_task = nullptr; + +bool p4OnStorageTask() { return xTaskGetCurrentTaskHandle() == s_p4store_task && s_p4store_task != nullptr; } + +void p4StorageCall(void (*fn)(void*), void* arg) { + // Guard on TASK IDENTITY, not core. The original core guard was written believing the main + // task ran on core 1; it runs on core 0 (ESP_MAIN_TASK_AFFINITY_CPU0), so the guard + // short-circuited and every "hopped" write actually ran inline on the main task -- proven by + // the sector-level trace (all SDIO on task 'main'). The storage task is pinned to CORE 1, + // which is essentially empty on this board: the DSI frame-restart ISR, the main (UI+mesh) + // task, the SDMMC ISR and the tile task all live on core 0, so SD critical sections executed + // on core 1 can never mask the display's interrupt regardless of what the UI is rendering. + if (p4OnStorageTask()) { fn(arg); return; } // re-entry on the storage task + static SemaphoreHandle_t s_mtx = nullptr; + if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); // races only at first-ever call during boot (single-threaded) + xSemaphoreTake(s_mtx, portMAX_DELAY); + if (!s_p4store_q) { + s_p4store_q = xQueueCreate(1, sizeof(P4StoreJob)); + xTaskCreatePinnedToCore(p4StoreTaskFn, "store1", 8192, nullptr, 3, &s_p4store_task, 0); // core 0: the SDMMC completion ISR lives there; core-1 I/O crawls on cross-core wakeups + } + static SemaphoreHandle_t s_done = nullptr; // one in flight (serialized by s_mtx) + if (!s_done) s_done = xSemaphoreCreateBinary(); + P4StoreJob j{ fn, arg, s_done }; + xQueueSend(s_p4store_q, &j, portMAX_DELAY); + xSemaphoreTake(s_done, portMAX_DELAY); + xSemaphoreGive(s_mtx); +} +#endif diff --git a/src/DataStore.h b/src/DataStore.h index 478c096..884d12f 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -88,3 +88,9 @@ public: private: FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;}; }; + +#if defined(HAS_TDISPLAY_P4) +// #167: run fn(arg) on the dedicated CORE-1 storage task, blocking the caller. See DataStore.cpp. +void p4StorageCall(void (*fn)(void*), void* arg); +bool p4OnStorageTask(); // true when the current task IS the storage task (wrapper re-entry guard) +#endif diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index fe32a3c..929521e 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -13,6 +13,7 @@ #endif #endif #include +#include "helpers/CompanionRetryPolicy.h" #include #include #include "WiFiConfig.h" @@ -142,6 +143,9 @@ static int s_companion_ota_pinned_reply_target = -1; // that freezes the loop, and re-adverts (last-heard/path refreshes) otherwise churn // it constantly. A change in the contact SET still saves promptly (see MyMesh::loop). #define CONTACTS_REFRESH_SAVE_INTERVAL 300000 +// Minimum gap between contacts saves that were triggered by an ADD/REMOVE (count change). +// Coalesces a burst of newly-heard nodes into one atomic rewrite instead of one per node. +#define CONTACTS_ADD_SAVE_MIN_INTERVAL 30000 // Our self-chosen room-session keep-alive interval (secs). Room servers zero the // legacy suggested-interval field in LOGIN_OK, so the client picks. 128 is the // value upstream itself recommended while the field was live (CLIENT_KEEP_ALIVE_SECS @@ -1388,7 +1392,501 @@ static inline bool isMsgFloodType(uint8_t t) { return t == PAYLOAD_TYPE_TXT_MSG || t == PAYLOAD_TYPE_GRP_TXT; } +uint8_t MyMesh::companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[], + uint32_t scheduled_for[]) { + uint8_t count = 0; + int queue_idx = 0; + const uint32_t now = _ms->getMillis(); + while (queue_idx < _mgr->getOutboundTotal() + && count < COMPANION_TEXT_QUEUE_CAPACITY) { + mesh::Packet* packet = _mgr->getOutboundByIdx(queue_idx); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) { + queue_idx++; + continue; + } + + packet = _mgr->removeOutboundByIdx(queue_idx); + if (!packet) continue; + + packets[count] = packet; + priorities[count] = packet->isRouteDirect() ? 0 : 1; + scheduled_for[count] = now; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && slot.queued_packet == packet) { + priorities[count] = slot.priority; + scheduled_for[count] = slot.retry_at; + break; + } + } + count++; + } + return count; +} + +bool MyMesh::companionRestoreQueuedText(mesh::Packet* packets[], const uint8_t priorities[], + const uint32_t scheduled_for[], uint8_t count) { + bool restored_all = true; + for (uint8_t i = 0; i < count; i++) { + mesh::Packet* packet = packets[i]; + if (!packet) continue; + + _mgr->queueOutbound(packet, priorities[i], scheduled_for[i]); + bool found = false; + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) == packet) { + found = true; + break; + } + } + if (found) continue; + + // queueOutbound() returned a rejected packet to the pool. Retire any + // retry metadata that still referred to that pool object. + restored_all = false; + for (int j = 0; j < COMPANION_RETRY_SLOTS; j++) { + if (_companion_retries[j].active + && _companion_retries[j].queued_packet == packet) { + companionRetryResetSlot(j); + } + } + } + return restored_all; +} + +mesh::Packet* MyMesh::companionDetachQueuedTextByHash4( + uint32_t packet_hash4, uint8_t retry_key[MAX_HASH_SIZE]) { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + mesh::Packet* packet = _mgr->getOutboundByIdx(i); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) continue; + + uint8_t candidate_key[MAX_HASH_SIZE]; + uint32_t candidate_hash4 = 0; + packet->calculatePacketHash(candidate_key); + memcpy(&candidate_hash4, candidate_key, sizeof(candidate_hash4)); + if (candidate_hash4 != packet_hash4) continue; + + memcpy(retry_key, candidate_key, MAX_HASH_SIZE); + return _mgr->removeOutboundByIdx(i); + } + return nullptr; +} + +int MyMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint32_t* out_packet_hash4, TxtTxDebugInfo* out_dbg) { + if (!text) { + expected_ack = 0; + est_timeout = 0; + return MSG_SEND_FAILED; + } + + mesh::Packet* held[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint8_t held_priorities[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint32_t held_schedules[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + const uint8_t held_count = companionDetachQueuedText( + held, held_priorities, held_schedules); + + _last_plain_tx_meta_valid = false; + uint32_t packet_hash4 = 0; + int result = BaseChatMesh::sendMessage(recipient, timestamp, attempt, text, + expected_ack, est_timeout, + &packet_hash4, out_dbg); + + uint8_t retry_key[MAX_HASH_SIZE] = {}; + mesh::Packet* newest = result == MSG_SEND_FAILED + ? nullptr + : companionDetachQueuedTextByHash4(packet_hash4, retry_key); + + const bool restored_old = companionRestoreQueuedText( + held, held_priorities, held_schedules, held_count); + bool restored_new = newest != nullptr; + if (newest) { + mesh::Packet* one[] = {newest}; + const uint8_t priority[] = {static_cast(newest->isRouteDirect() ? 0 : 1)}; + const uint32_t scheduled[] = {_ms->getMillis()}; + restored_new = companionRestoreQueuedText(one, priority, scheduled, 1); + } + + if (!restored_old) { + MESH_DEBUG_PRINTLN("%s MyMesh::sendMessage(): failed to restore queued TXT", getLogDateTime()); + } + if (result != MSG_SEND_FAILED && !restored_new) { + expected_ack = 0; + est_timeout = 0; + result = MSG_SEND_FAILED; + } + + if (out_packet_hash4) *out_packet_hash4 = packet_hash4; + if (result != MSG_SEND_FAILED) { + _last_plain_tx_ack = expected_ack; + memcpy(_last_plain_tx_retry_key, retry_key, sizeof(_last_plain_tx_retry_key)); + mesh::Utils::sha256(_last_plain_tx_fingerprint, + sizeof(_last_plain_tx_fingerprint), + recipient.id.pub_key, PUB_KEY_SIZE, + reinterpret_cast(text), strlen(text)); + _last_plain_tx_meta_valid = true; + } + return result; +} + +int MyMesh::sendCommandData(const ContactInfo& recipient, uint32_t timestamp, + uint8_t attempt, const char* text, uint32_t& est_timeout, + uint32_t* out_packet_hash4, TxtTxDebugInfo* out_dbg) { + if (!text) { + est_timeout = 0; + return MSG_SEND_FAILED; + } + + mesh::Packet* held[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint8_t held_priorities[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint32_t held_schedules[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + const uint8_t held_count = companionDetachQueuedText( + held, held_priorities, held_schedules); + + _last_plain_tx_meta_valid = false; + uint32_t packet_hash4 = 0; + int result = BaseChatMesh::sendCommandData(recipient, timestamp, attempt, text, + est_timeout, &packet_hash4, out_dbg); + + uint8_t retry_key[MAX_HASH_SIZE] = {}; + mesh::Packet* newest = result == MSG_SEND_FAILED + ? nullptr + : companionDetachQueuedTextByHash4(packet_hash4, retry_key); + + const bool restored_old = companionRestoreQueuedText( + held, held_priorities, held_schedules, held_count); + bool restored_new = newest != nullptr; + if (newest) { + mesh::Packet* one[] = {newest}; + const uint8_t priority[] = {static_cast(newest->isRouteDirect() ? 0 : 1)}; + const uint32_t scheduled[] = {_ms->getMillis()}; + restored_new = companionRestoreQueuedText(one, priority, scheduled, 1); + } + + if (!restored_old) { + MESH_DEBUG_PRINTLN("%s MyMesh::sendCommandData(): failed to restore queued TXT", getLogDateTime()); + } + if (result != MSG_SEND_FAILED && !restored_new) { + est_timeout = 0; + result = MSG_SEND_FAILED; + } + if (out_packet_hash4) *out_packet_hash4 = packet_hash4; + return result; +} + +uint32_t MyMesh::companionRetryDelay(const mesh::Packet* packet, bool direct, + uint8_t attempt_idx) { + if (!packet || !_radio) return 0; + + const uint32_t packet_airtime = _radio->getEstAirtimeFor(packet->getRawLength()); + if (direct) { + return CompanionRetryPolicy::directDelay(packet_airtime, attempt_idx); + } + + const uint32_t max_packet_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT); + const uint32_t jitter_percent = getRNG()->nextInt(0, 201); + return CompanionRetryPolicy::floodDelay(max_packet_airtime, packet_airtime, + jitter_percent); +} + +void MyMesh::companionRetryResetSlot(int slot_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + if (slot.active && _active_companion_retries > 0) { + _active_companion_retries--; + } + slot.queued_packet = nullptr; + slot.retry_at = 0; + slot.retry_delay = 0; + slot.missing_since = 0; + memset(slot.retry_key, 0, sizeof(slot.retry_key)); + slot.attempts_sent = 0; + slot.max_attempts = 0; + slot.priority = 0; + slot.progress_marker = 0; + slot.payload_type = 0; + slot.direct = false; + slot.waiting_final_echo = false; + slot.active = false; +} + +void MyMesh::companionRetryCancelSlot(int slot_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + mesh::Packet* queued = slot.queued_packet; + if (slot.active && queued) { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) != queued) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + queued->calculatePacketHash(queued_key); + if (memcmp(queued_key, slot.retry_key, sizeof(queued_key)) == 0) { + mesh::Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) releasePacket(removed); + } + break; + } + } + companionRetryResetSlot(slot_idx); +} + +void MyMesh::companionRetryCancelKey(const uint8_t retry_key[MAX_HASH_SIZE]) { + if (!CompanionRetryPolicy::keyIsSet(retry_key, MAX_HASH_SIZE)) return; + + // First retire tracked retry trains. companionRetryCancelSlot() also removes + // their queued clone when it has not entered the radio yet. + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && CompanionRetryPolicy::keysEqual( + slot.retry_key, retry_key, MAX_HASH_SIZE)) { + companionRetryCancelSlot(i); + } + } + + // A semantic replacement can arrive before the original packet's first TX, + // before a CompanionRetrySlot exists. Remove that exact queued packet too. + for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) { + mesh::Packet* packet = _mgr->getOutboundByIdx(i); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + packet->calculatePacketHash(queued_key); + if (!CompanionRetryPolicy::keysEqual( + queued_key, retry_key, MAX_HASH_SIZE)) continue; + + mesh::Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) releasePacket(removed); + } +} + +bool MyMesh::companionRetryQueueClone(int slot_idx, const mesh::Packet* packet, + uint8_t attempt_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS || !packet) return false; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + mesh::Packet* retry = obtainNewPacket(); + if (!retry) return false; + + *retry = *packet; // exact duplicate: timestamp and ciphertext stay fixed + slot.retry_delay = companionRetryDelay(packet, slot.direct, attempt_idx); + slot.retry_at = _ms->getMillis() + slot.retry_delay; + _mgr->queueOutbound(retry, slot.priority, slot.retry_at); + + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) == retry) { + slot.queued_packet = retry; + slot.missing_since = 0; + return true; + } + } + + // StaticPoolPacketManager already returned a rejected packet to its pool. + return false; +} + +void MyMesh::companionRetryStart(const mesh::Packet* packet, + const uint8_t retry_key[MAX_HASH_SIZE]) { + if (!_companion_retry_enabled) return; // Radio & Mesh toggle (default ON) — no train, no retries + if (!packet || !retry_key) return; + + const uint8_t payload_type = packet->getPayloadType(); + const uint8_t path_count = packet->getPathHashCount(); + const bool text_from_self = packet->payload_len >= 2U * PATH_HASH_SIZE + && self_id.isHashMatch(&packet->payload[PATH_HASH_SIZE], PATH_HASH_SIZE); + + bool direct = false; + uint8_t max_attempts = 0; + uint8_t priority = 0; + if (packet->isRouteDirect() && payload_type == PAYLOAD_TYPE_TXT_MSG + && path_count > 0 && text_from_self) { + direct = true; + max_attempts = CompanionRetryPolicy::DIRECT_MAX_ATTEMPTS; + } else if (packet->isRouteFlood() && path_count == 0 + && ((payload_type == PAYLOAD_TYPE_TXT_MSG && text_from_self) + || payload_type == PAYLOAD_TYPE_GRP_TXT)) { + max_attempts = CompanionRetryPolicy::FLOOD_MAX_ATTEMPTS; + priority = 1; + } else { + return; + } + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && slot.direct == direct + && memcmp(slot.retry_key, retry_key, MAX_HASH_SIZE) == 0) { + return; + } + } + + int slot_idx = -1; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + if (!_companion_retries[i].active) { + slot_idx = i; + break; + } + } + if (slot_idx < 0) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + slot.queued_packet = nullptr; + slot.missing_since = 0; + memcpy(slot.retry_key, retry_key, MAX_HASH_SIZE); + slot.attempts_sent = 0; + slot.max_attempts = max_attempts; + slot.priority = priority; + slot.progress_marker = path_count; + slot.payload_type = payload_type; + slot.direct = direct; + slot.waiting_final_echo = false; + slot.active = true; + _active_companion_retries++; + if (!companionRetryQueueClone(slot_idx, packet, 0)) { + companionRetryResetSlot(slot_idx); + } +} + +void MyMesh::logTx(mesh::Packet* packet, int len) { + (void)len; + if (!packet) return; + + uint8_t packet_key[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_key); + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.queued_packet != packet) continue; + + // If a pool object was unexpectedly reused, do not mistake the new packet + // for this retry. + if (memcmp(slot.retry_key, packet_key, MAX_HASH_SIZE) != 0) { + companionRetryResetSlot(i); + break; + } + + slot.queued_packet = nullptr; + slot.missing_since = 0; + slot.attempts_sent++; + if (slot.attempts_sent >= slot.max_attempts) { + // Keep the metadata for one last echo window. Dispatcher releases the + // just-transmitted pool packet after this hook returns. + slot.waiting_final_echo = true; + slot.retry_at = _ms->getMillis() + slot.retry_delay; + } else if (!companionRetryQueueClone(i, packet, slot.attempts_sent)) { + companionRetryResetSlot(i); + } + return; + } + + companionRetryStart(packet, packet_key); +} + +void MyMesh::logTxFail(mesh::Packet* packet, int len) { + (void)len; + if (!packet) return; + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.queued_packet != packet) continue; + + // Dispatcher owns and releases this in-flight packet after the hook. + slot.queued_packet = nullptr; + companionRetryResetSlot(i); + return; + } +} + +void MyMesh::companionRetryObserveRaw(const uint8_t raw[], int len) { + if (_active_companion_retries == 0 || !raw || len <= 0) return; + + const uint8_t payload_type = (raw[0] >> PH_TYPE_SHIFT) & PH_TYPE_MASK; + if (payload_type != PAYLOAD_TYPE_TXT_MSG && payload_type != PAYLOAD_TYPE_GRP_TXT) { + return; + } + + mesh::Packet packet; + if (!tryParsePacket(&packet, raw, len)) return; + + const bool direct = packet.isRouteDirect(); + if ((!direct && !packet.isRouteFlood()) + || (direct && payload_type != PAYLOAD_TYPE_TXT_MSG)) { + return; + } + + uint8_t retry_key[MAX_HASH_SIZE]; + packet.calculatePacketHash(retry_key); + const uint8_t received_path_count = packet.getPathHashCount(); + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.direct != direct + || memcmp(slot.retry_key, retry_key, MAX_HASH_SIZE) != 0) { + continue; + } + + const bool is_echo = direct + ? CompanionRetryPolicy::isDirectEcho(slot.progress_marker, received_path_count) + : CompanionRetryPolicy::isFloodEcho(slot.progress_marker, received_path_count); + if (is_echo) companionRetryCancelSlot(i); + } +} + +void MyMesh::companionRetryService() { + if (_active_companion_retries == 0) return; + + const uint32_t now = _ms->getMillis(); + const uint32_t missing_grace = 1000UL + + (2UL * _radio->getEstAirtimeFor(MAX_TRANS_UNIT)); + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active) continue; + + if (slot.waiting_final_echo) { + if (millisHasNowPassed(slot.retry_at)) companionRetryResetSlot(i); + continue; + } + + if (slot.queued_packet) { + bool found = false; + bool hash_matches = false; + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + mesh::Packet* queued = _mgr->getOutboundByIdx(j); + if (queued != slot.queued_packet) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + queued->calculatePacketHash(queued_key); + found = true; + hash_matches = memcmp(queued_key, slot.retry_key, MAX_HASH_SIZE) == 0; + break; + } + + if (found) { + if (!hash_matches) { + // The pool object was reused after another send removed our retry. + companionRetryResetSlot(i); + } else { + slot.missing_since = 0; + } + } else if (slot.missing_since == 0) { + // Usually this means Dispatcher moved the packet from the queue to the + // radio. Allow enough time for its normal TX-complete/fail callback. + slot.missing_since = now == 0 ? 1 : now; + } else if ((uint32_t)(now - slot.missing_since) > missing_grace) { + // The pool object is no longer ours, so only retire the metadata here. + companionRetryResetSlot(i); + } + continue; + } + + // A non-final active slot always owns one queued or in-flight clone. + companionRetryResetSlot(i); + } +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { + companionRetryObserveRaw(raw, len); + const int8_t snr_q4 = (int8_t)(snr * 4.0f); const uint32_t now_ms = millis(); // Parse route + path length (hop count). Header byte layout is @@ -1841,6 +2339,74 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); } +void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retry) { + if (cancel_retry) companionRetryCancelKey(entry.retry_key); + memset(&entry, 0, sizeof(entry)); +} + +MyMesh::AckTableEntry* MyMesh::findPendingTextMessage( + const uint8_t text_fingerprint[MAX_HASH_SIZE]) { + if (!text_fingerprint) return nullptr; + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + AckTableEntry& entry = expected_ack_table[i]; + if (CompanionRetryPolicy::shouldReplacePendingText( + entry.ack, entry.text_fingerprint, text_fingerprint, MAX_HASH_SIZE)) { + return &entry; + } + } + return nullptr; +} + +void MyMesh::uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]) { + if (expected_ack == 0 || !pub_key) return; + + // Transport retries of the same command frame reuse the ACK. Do not create + // another table entry or disturb the packet's existing retry ownership. + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + if (expected_ack_table[i].ack == expected_ack) { + _last_plain_tx_meta_valid = false; + return; + } + } + + ContactInfo* contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (!contact) { + _last_plain_tx_meta_valid = false; + return; + } + + const bool has_send_meta = _last_plain_tx_meta_valid + && _last_plain_tx_ack == expected_ack; + AckTableEntry* replacement = has_send_meta + ? findPendingTextMessage(_last_plain_tx_fingerprint) + : nullptr; + + AckTableEntry* entry; + if (replacement) { + // Only the same recipient+text supersedes an older pending message. + // Unrelated messages retain both their ACK record and retry train. + clearExpectedAck(*replacement, true); + entry = replacement; + } else { + entry = &expected_ack_table[next_ack_idx]; + // Circular-table eviction is bookkeeping only: do not cancel an unrelated + // message's lower-level retries merely because its ACK slot is reused. + clearExpectedAck(*entry, false); + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + } + + entry->msg_sent = _ms->getMillis(); + entry->ack = expected_ack; + entry->contact = contact; + if (has_send_meta) { + memcpy(entry->text_fingerprint, _last_plain_tx_fingerprint, + sizeof(entry->text_fingerprint)); + memcpy(entry->retry_key, _last_plain_tx_retry_key, + sizeof(entry->retry_key)); + } + _last_plain_tx_meta_valid = false; +} + ContactInfo* MyMesh::processAck(const uint8_t *data) { #if defined(DISPLAY_CLASS) // Diag: log every processAck call so we can see whether the ACK matching @@ -1855,7 +2421,7 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { #endif // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { - if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient + if (CompanionRetryPolicy::ackMatches(expected_ack_table[i].ack, data)) { out_frame[0] = PUSH_CODE_SEND_CONFIRMED; memcpy(&out_frame[1], data, 4); uint32_t trip_time = _ms->getMillis() - expected_ack_table[i].msg_sent; @@ -1871,9 +2437,11 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { } #endif - // NOTE: the same ACK can be received multiple times! - expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK - return expected_ack_table[i].contact; + // An ACK is stronger delivery evidence than a local repeater echo. Stop + // the exact retry train before clearing its bookkeeping entry. + ContactInfo* contact = expected_ack_table[i].contact; + clearExpectedAck(expected_ack_table[i], true); + return contact; } } return checkConnectionsAck(data); @@ -2241,14 +2809,30 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, if (_ui_login_then && len > 4 && memcmp(&_ui_login_then, contact.id.pub_key, 4) == 0) { const bool login_ok = (data[4] == RESP_SERVER_LOGIN_OK) || (len > 5 && memcmp(&data[4], "OK", 2) == 0); - const UiReqKind k = _ui_login_then_kind; - cancelUIDeferredLogin(); - if (login_ok) { - ContactInfo& rc = const_cast(contact); - if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc); - else sendStatusPingForUI(rc); + // The comment above assumes ANY response arriving while armed must be our login reply. + // That holds for the UI, but the COMPANION APP can have its own STATUS / TELEMETRY / + // BINARY request in flight to this same node, and there is no distinct "login failed" + // code on the wire (a failure is merely "not RESP_SERVER_LOGIN_OK"), so a non-OK frame + // is indistinguishable from the app's reply. Swallowing it here ate the app's response + // and returned, leaving the phone waiting forever — a contributor to the "no ping or + // telemetry response, from the device AND the app" reports (#124). + // A LOGIN_OK is unambiguously ours, so always take it. Otherwise, if the app is waiting + // on this same contact, fall through and let its matcher have the frame; our own reply + // deadline still disarms us, which is all the early disarm here ever bought. + const bool app_waiting = + (pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0) || + (pending_telemetry && memcmp(&pending_telemetry, contact.id.pub_key, 4) == 0) || + (pending_req && memcmp(&pending_req, contact.id.pub_key, 4) == 0); + if (login_ok || !app_waiting) { + const UiReqKind k = _ui_login_then_kind; + cancelUIDeferredLogin(); + if (login_ok) { + ContactInfo& rc = const_cast(contact); + if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc); + else sendStatusPingForUI(rc); + } + return; // login frame consumed (OK fired the REQ; fail disarmed) } - return; // login frame consumed (OK fired the REQ; fail disarmed) } #endif @@ -2570,6 +3154,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe app_target_ver = 0; clearPendingReqs(); _ui_pending_status = 0; + memset(expected_ack_table, 0, sizeof(expected_ack_table)); next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; @@ -3002,7 +3587,6 @@ void MyMesh::handleCmdFrame(size_t len) { _ui->appendDiag(line); } } - // TODO: add expected ACK to table if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { @@ -3011,10 +3595,7 @@ void MyMesh::handleCmdFrame(size_t len) { s_last_cmd_txt_est_timeout = est_timeout; } if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = recipient; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + uiRegisterExpectedAck(expected_ack, recipient->id.pub_key); } out_frame[0] = RESP_CODE_SENT; @@ -4072,6 +4653,7 @@ void MyMesh::saveContacts() { // freshly-written contact set + resets the refresh window. _last_saved_contacts_n = getNumContacts(); _next_contacts_refresh_save = futureMillis(CONTACTS_REFRESH_SAVE_INTERVAL); + _next_contacts_add_save = futureMillis(CONTACTS_ADD_SAVE_MIN_INTERVAL); } void MyMesh::enterCLIRescue() { @@ -4476,6 +5058,7 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); + companionRetryService(); // Session keep-alives for logged-in servers (rooms). The core pinger sends the // 9-byte REQ_TYPE_KEEP_ALIVE (+ our sync_since) a room server expects; the ACK @@ -4534,6 +5117,20 @@ void MyMesh::loop() { && !millisHasNowPassed(_next_contacts_refresh_save)) { defer = true; // no add/remove + still inside the refresh window on GC-prone flash } + // ...but a CHANGED contact count used to bypass the window entirely and rewrite the whole + // table immediately, every time. On a busy mesh that is the common case, not the rare one: + // each newly-heard node auto-added (and every transient/anon slot) bumps the count, so a + // burst of new nodes = a burst of full 30 KB rewrites 5 s apart, which is what drives the + // GC stalls a reporter measured at 11-19 s on two card-less V4s. Give add/remove its own + // short floor so a burst coalesces into ONE rewrite. Deliberately short (30 s, vs 5 min for + // pure refreshes): a new node still needs to reach flash promptly, and an unclean power loss + // inside the window only costs a rediscovery on that node's next advert, whereas the stall + // it prevents freezes the entire UI. The write stays ATOMIC (tmp + rename) — important on + // hardware that browns out, which is exactly what these reporters' boards do. + if (!defer && _store->contactsOnInternalFlash() + && !millisHasNowPassed(_next_contacts_add_save)) { + defer = true; + } #endif if (defer) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // re-check soon; don't rewrite yet @@ -4628,7 +5225,37 @@ bool MyMesh::sendAdvert(bool flood) { return true; } -// To check if there is pending work -bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; +bool MyMesh::getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const { + if (_active_companion_retries == 0) return false; + + const uint32_t now = _ms->getMillis(); + bool found = false; + uint32_t shortest_delay = 0; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active) continue; + + const uint32_t candidate = CompanionRetryPolicy::wakeDelay(now, slot.retry_at); + if (!found || candidate < shortest_delay) { + shortest_delay = candidate; + found = true; + } + } + if (found) delay_millis = shortest_delay; + return found; +} + +// Future queue entries, retry echo windows, and contact-write timers are wake +// deadlines; they should not prevent the MCU's idle power-saving path. Report +// only work that is due now. An in-flight retry keeps retry_at in the past until +// its TX callback runs, so the CPU remains awake while the radio is transmitting. +bool MyMesh::hasPendingWork() const { + const uint32_t now = _ms->getMillis(); + if (_mgr->getOutboundCount(now) > 0) return true; + if (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) { + return true; + } + + uint32_t retry_delay = 0; + return getNextCompanionRetryWakeDelay(retry_delay) && retry_delay == 0; } diff --git a/src/MyMesh.h b/src/MyMesh.h index 8d43522..127e7e5 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -130,6 +130,17 @@ public: void begin(bool has_display); void startInterface(BaseSerialInterface &serial); + // Keep WadaMesh's queued text packets intact around the pinned core's + // sendMessage/sendCommandData implementations. That core currently drops + // every queued TXT before enqueueing a new one; these wrappers restore FIFO + // behavior and let MyMesh replace only the same logical private message. + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr); + int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& est_timeout, + uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr); + const char *getNodeName(); NodePrefs *getNodePrefs(); uint32_t getBLEPin(); @@ -245,6 +256,8 @@ protected: void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logTx(mesh::Packet* packet, int len) override; + void logTxFail(mesh::Packet* packet, int len) override; bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -434,18 +447,7 @@ public: * onMessageAcked back to the UI. The companion-serial CMD_SEND_TXT_MSG * handler already does this for app-originated messages; the touch UI * reaches sendMessage directly and skipped this until now. */ - void uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]) { - if (expected_ack == 0) return; - ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (!c) return; - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = c; - // EXPECTED_ACK_TABLE_SIZE is #defined further down in this header next - // to the table itself; hard-code 8 here so this inline helper compiles - // wherever it's used. - next_ack_idx = (next_ack_idx + 1) % 8; - } + void uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]); // ---- "Repeats heard" for sent floods + route of the last received flood ---- // When we originate a flood TXT, repeaters re-broadcast it and our own radio @@ -602,7 +604,12 @@ public: } const uint8_t rt = pkt ? pkt->getRouteType() : 0xFF; _last_rx_has_scope = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT); - _last_rx_scope = (_last_rx_has_scope && pkt) ? pkt->transport_codes[0] : 0; + // #157: some senders carry the region in transport_codes[1] (reply-region hint) with + // codes[0] zero -- the Info popup then showed "Scope 0000" for a genuinely scoped message. + // Show whichever code is set; [0] (the scope proper) wins when both are. + _last_rx_scope = 0; + if (_last_rx_has_scope && pkt) + _last_rx_scope = pkt->transport_codes[0] ? pkt->transport_codes[0] : pkt->transport_codes[1]; } /** Track a freshly-sent flood TXT fingerprint (called from sendFloodScoped). */ @@ -965,6 +972,14 @@ public: // To check if there is pending work bool hasPendingWork() const; + /** Return the delay until the next companion retry/final-echo deadline. + * Future retries are wake deadlines, not work that should hold the CPU + * awake. Returns false when no retry train is active. */ + bool getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const; + /** Radio & Mesh toggle: auto-retry sends until the mesh echoes/ACKs them (default ON). + * Live-settable; OFF stops NEW retry trains, an in-flight train finishes its schedule. */ + void setCompanionRetryEnabled(bool on) { _companion_retry_enabled = on; } + // Number of companion clients currently connected on any transport. // Used by the idle light-sleep gate (TouchSleep) to confirm no one is // actively talking to us before the node parks in light sleep. @@ -976,6 +991,25 @@ public: bool isRadioReceiving() const { return _radio && _radio->isReceiving(); } private: + bool _companion_retry_enabled = true; // see setCompanionRetryEnabled + static const uint8_t COMPANION_TEXT_QUEUE_CAPACITY = 16; + uint8_t companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[], + uint32_t scheduled_for[]); + bool companionRestoreQueuedText(mesh::Packet* packets[], const uint8_t priorities[], + const uint32_t scheduled_for[], uint8_t count); + mesh::Packet* companionDetachQueuedTextByHash4(uint32_t packet_hash4, + uint8_t retry_key[MAX_HASH_SIZE]); + void companionRetryCancelKey(const uint8_t retry_key[MAX_HASH_SIZE]); + + void companionRetryObserveRaw(const uint8_t raw[], int len); + void companionRetryStart(const mesh::Packet* packet, const uint8_t retry_key[MAX_HASH_SIZE]); + void companionRetryService(); + void companionRetryCancelSlot(int slot_idx); + void companionRetryResetSlot(int slot_idx); + bool companionRetryQueueClone(int slot_idx, const mesh::Packet* packet, + uint8_t attempt_idx); + uint32_t companionRetryDelay(const mesh::Packet* packet, bool direct, uint8_t attempt_idx); + void writeOKFrame(); void writeErrFrame(uint8_t err_code); void writeDisabledFrame(); @@ -1001,7 +1035,29 @@ private: int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } + // The core calls this on EVERY received advert (BaseChatMesh::onAdvertRecv, outside the + // auto-add block, so for known contacts too) and each call is a full create+truncate+write + // of a small file. On a card-less board that file lives on SPIFFS, so ~200 known nodes + // re-advertising means a flash write every few seconds forever — churn that keeps the + // partition permanently GC-prone and feeds the multi-second "mesh" loop stalls. + // + // The blob is the raw advert packet and its ONLY consumer is Share/export contact + // (BaseChatMesh::shareContact via getBlobByKey). Its content for a given node is + // effectively static — name, type, location. So persist it ONCE PER KEY PER BOOT and skip + // the redundant rewrites: after the first advert from each node the packet path does no + // flash I/O at all. Reads are unaffected because getBlobByKey above still reads the file we + // already wrote, so there is no cache to keep coherent. A node that RENAMES itself keeps its + // old shared blob until the next reboot, which is a fair price for removing the churn. + // Cost: kBlobSeenSlots * 4 bytes of DRAM. Move to PSRAM if static DRAM ever gets tight. bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override { + if (key_len >= 4) { + uint32_t pfx; + memcpy(&pfx, key, 4); + if (pfx == 0) pfx = 1; // 0 is the empty-slot marker + const uint16_t slot = (uint16_t)((pfx ^ (pfx >> 13) ^ (pfx >> 23)) & (kBlobSeenSlots - 1)); + if (_blob_seen[slot] == pfx) return true; // already on flash this boot — skip the write + _blob_seen[slot] = pfx; // collision just costs one extra write later + } return _store->putBlobByKey(key, key_len, src_buf, len); } @@ -1064,6 +1120,14 @@ private: // the first save. Kept in sync by every MyMesh::saveContacts() call. int _last_saved_contacts_n = -1; uint32_t _next_contacts_refresh_save = 0; + // Separate, much SHORTER floor for saves caused by an add/remove. Those used to bypass the + // refresh window completely, so on a growing mesh every newly-heard node forced its own full + // rewrite. See CONTACTS_ADD_SAVE_MIN_INTERVAL. + uint32_t _next_contacts_add_save = 0; + // Pubkey prefixes whose advert blob has already been persisted this boot — see + // putBlobByKey. Sized above a full contact list so the common case never thrashes. + static const uint16_t kBlobSeenSlots = 512; // power of two (mask), 2 KB total + uint32_t _blob_seen[kBlobSeenSlots] = {0}; TransportKey send_scope; TransportKey _chan_scope_saved; // push/popChannelScope stash @@ -1108,17 +1172,47 @@ private: int proto_num_clients; struct AckTableEntry { - unsigned long msg_sent; - uint32_t ack; - ContactInfo* contact; + unsigned long msg_sent = 0; + uint32_t ack = 0; + ContactInfo* contact = nullptr; + uint8_t text_fingerprint[MAX_HASH_SIZE] = {}; + uint8_t retry_key[MAX_HASH_SIZE] = {}; }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; + AckTableEntry* findPendingTextMessage(const uint8_t text_fingerprint[MAX_HASH_SIZE]); + void clearExpectedAck(AckTableEntry& entry, bool cancel_retry); + + uint32_t _last_plain_tx_ack = 0; + uint8_t _last_plain_tx_fingerprint[MAX_HASH_SIZE] = {}; + uint8_t _last_plain_tx_retry_key[MAX_HASH_SIZE] = {}; + bool _last_plain_tx_meta_valid = false; #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table + // Client-side retry-until-echo state. Only one exact packet clone is retained + // in the outbound queue per active slot, matching the companion behavior. + static const uint8_t COMPANION_RETRY_SLOTS = 6; + struct CompanionRetrySlot { + mesh::Packet* queued_packet = nullptr; + uint32_t retry_at = 0; + uint32_t retry_delay = 0; + uint32_t missing_since = 0; + uint8_t retry_key[MAX_HASH_SIZE] = {}; + uint8_t attempts_sent = 0; + uint8_t max_attempts = 0; + uint8_t priority = 0; + uint8_t progress_marker = 0; + uint8_t payload_type = 0; + bool direct = false; + bool waiting_final_echo = false; + bool active = false; + }; + CompanionRetrySlot _companion_retries[COMPANION_RETRY_SLOTS]; + uint8_t _active_companion_retries = 0; + // One-shot auto-advert on boot. Recipients with auto-add ON pick up our // current pubkey, which is critical when the touch firmware regenerates // identity after a SPIFFS wipe (otherwise old contacts have stale pubkey diff --git a/src/helpers/ClockFloorRTC.h b/src/helpers/ClockFloorRTC.h index 7163786..934e985 100644 --- a/src/helpers/ClockFloorRTC.h +++ b/src/helpers/ClockFloorRTC.h @@ -1,6 +1,7 @@ #pragma once #include +#include // Monotonic clock floor for outgoing protocol timestamps (issue #89 follow-up). // @@ -60,9 +61,43 @@ public: void setCurrentTime(uint32_t time) override { if (time < MIN_VALID_EPOCH || time > MAX_PLAUSIBLE_EPOCH) return; // garbage set — ignore, whatever the source AutoDiscoverRTCClock::setCurrentTime(time); + pushSystemClock(time); if (_floor > time + TRUSTED_BACK_CAP) _floor = time; } + // The UI reads the ESP32 *system* clock for every displayed time (status bar, chat + // bubbles, logs — see the note at addMessage()), while protocol timestamps come from + // this class. On a board with NO RTC chip they are the same clock: AutoDiscoverRTCClock + // falls through to ESP32RTCClock, whose set IS settimeofday(), so the two can never + // disagree. With a chip present (the T-Display P4 and the ThinkNode M9 both carry a + // PCF8563 at 0x51 and call rtc_clock.begin(); the T-LoRa Pager has one too but + // deliberately skips begin(), since its PCF85063A is register-incompatible with the + // core's 8563 driver) the base class writes ONLY the chip, so the system clock keeps + // ESP32RTCClock::begin()'s power-on seed forever. That seed is + // exactly MIN_VALID_EPOCH, which is why the P4 showed 15 May 2024 in the UI while sent + // messages carried the correct time — and why "Sync clock from system" then poisoned + // the good clock: it fed that seed back in as a real set, clearing the MIN check by + // being precisely equal to it. + // + // So mirror every ACCEPTED value into the system clock. The validation above has + // already run, so 1902/2043-class garbage never reaches the display either. + static void pushSystemClock(uint32_t t) { + struct timeval tv; + tv.tv_sec = (time_t)t; + tv.tv_usec = 0; + settimeofday(&tv, nullptr); + } + + // Boot seed. An RTC chip is battery-backed, so it already knows the time while the + // system clock is still on the power-on seed — pull it across once so the UI reads + // correctly with no network and no GPS fix. Goes through getCurrentTime(), so the + // garbage-future hatch and the persisted floor both apply. No-op on the chipless + // boards (it reads the system clock and writes the same value back). + void seedSystemClock() { + const uint32_t t = getCurrentTime(); + if (t >= MIN_VALID_EPOCH && t <= MAX_PLAUSIBLE_EPOCH) pushSystemClock(t); + } + // Boot-time seed from the persisted floor (only ever raises), and the getter // the persister reads back. Benign u32 races: both cores may touch _floor. // A poisoned persisted value (saved while the hw clock asserted garbage) is refused. diff --git a/src/helpers/CompanionRetryPolicy.h b/src/helpers/CompanionRetryPolicy.h new file mode 100644 index 0000000..826d120 --- /dev/null +++ b/src/helpers/CompanionRetryPolicy.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +namespace CompanionRetryPolicy { + +// These are the companion defaults from MeshCore's keymindCascade branch. +static const uint8_t DIRECT_MAX_ATTEMPTS = 21; +static const uint8_t FLOOD_MAX_ATTEMPTS = 15; + +inline uint32_t directDelay(uint32_t packet_airtime_ms, uint8_t attempt_idx) { + return 200UL + (7UL * packet_airtime_ms) + (100UL * attempt_idx); +} + +inline uint32_t floodDelay(uint32_t max_packet_airtime_ms, + uint32_t packet_airtime_ms, + uint32_t jitter_percent) { + return max_packet_airtime_ms + (20UL * packet_airtime_ms) + + ((packet_airtime_ms * jitter_percent) / 100UL); +} + +inline bool isDirectEcho(uint8_t original_path_count, uint8_t received_path_count) { + return received_path_count < original_path_count; +} + +inline bool isFloodEcho(uint8_t original_path_count, uint8_t received_path_count) { + return received_path_count > original_path_count; +} + +inline bool keyIsSet(const uint8_t* key, size_t key_size) { + if (!key) return false; + for (size_t i = 0; i < key_size; i++) { + if (key[i] != 0) return true; + } + return false; +} + +inline bool keysEqual(const uint8_t* first, const uint8_t* second, size_t key_size) { + return first && second && memcmp(first, second, key_size) == 0; +} + +inline bool shouldReplacePendingText(uint32_t pending_ack, + const uint8_t* pending_fingerprint, + const uint8_t* new_fingerprint, + size_t fingerprint_size) { + return pending_ack != 0 + && keysEqual(pending_fingerprint, new_fingerprint, fingerprint_size); +} + +inline bool ackMatches(uint32_t expected_ack, const uint8_t received_ack[4]) { + return expected_ack != 0 && received_ack + && memcmp(received_ack, &expected_ack, sizeof(expected_ack)) == 0; +} + +// Wrap-safe for deadlines less than INT32_MAX milliseconds away. Retry delays +// are measured in seconds, so they remain comfortably inside that window. +inline uint32_t wakeDelay(uint32_t now_millis, uint32_t deadline_millis) { + const int32_t signed_delay = static_cast(deadline_millis - now_millis); + return signed_delay > 0 ? static_cast(signed_delay) : 0; +} + +} // namespace CompanionRetryPolicy diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 9d69a59..19f5808 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -4,6 +4,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include // ----------------------------- backend selection ----------------------------- @@ -18,12 +24,274 @@ static bool s_file_mode = false; static int s_legacy = -1; // legacy probe cache: -1 undecided, 0 SD, 1 NVS static int s_migrate = -1; // -1 undecided, 0 = no NVS legacy data, 1 = yes +static constexpr uint32_t PREFS_MAGIC = 0x32504657u; // "WFP2" +static constexpr uint16_t PREFS_FORMAT = 1; +static constexpr uint32_t PREFS_QUIET_MS = 750; +static constexpr uint32_t PREFS_MAX_WAIT_MS = 5000; +// These are settings namespaces, not a general object store. A hard ceiling +// bounds the RAM snapshot + worker copy on boards without PSRAM; current touch +// and discovered payloads are both well below 16 KB. +static constexpr size_t PREFS_MAX_PAYLOAD = 64u * 1024u; + +struct __attribute__((packed)) SnapshotFooter { + uint32_t magic; + uint16_t format; + uint16_t footer_size; + uint32_t generation; + uint32_t payload_size; + uint32_t payload_crc; + uint32_t footer_crc; +}; + +struct FileCache { + fs::FS* fs = nullptr; + char legacy_path[40] = {0}; + std::vector kv; + uint32_t generation = 0; + uint32_t revision = 0; + uint32_t dirty_first_ms = 0; + uint32_t dirty_last_ms = 0; + uint32_t retry_after_ms = 0; + char active_slot = 0; + bool loaded = false; + bool dirty = false; + bool writing = false; + bool legacy_present = false; +}; + +struct WriteJob { + fs::FS* fs = nullptr; + char legacy_path[40] = {0}; + std::vector kv; + uint32_t generation = 0; + uint32_t revision = 0; + char slot = 'a'; +}; + +static std::vector s_caches; +static SemaphoreHandle_t s_cache_mutex = nullptr; +static QueueHandle_t s_write_queue = nullptr; +static TaskHandle_t s_write_task = nullptr; + +static uint32_t crc32Update(uint32_t crc, const uint8_t* data, size_t len) { + crc = ~crc; + while (len--) { + crc ^= *data++; + for (int bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + return ~crc; +} + +static uint32_t crc32(const void* data, size_t len) { + return crc32Update(0, static_cast(data), len); +} + +static bool slotPath(const char* legacy_path, char slot, char* out, size_t cap) { + if (!legacy_path || !out || cap == 0) return false; + if (slot == 'a') return snprintf(out, cap, "%s", legacy_path) < (int)cap; + const size_t n = strlen(legacy_path); + if (n >= 3 && strcmp(legacy_path + n - 3, ".kv") == 0) + return snprintf(out, cap, "%.*s.alt", (int)(n - 3), legacy_path) < (int)cap; + return snprintf(out, cap, "%s.alt", legacy_path) < (int)cap; +} + +static bool serializeKv(const std::vector& kv, std::vector& out) { + size_t total = 0; + for (const auto& e : kv) { + const size_t kl = strnlen(e.key, sizeof(e.key)); + if (kl == 0 || kl > 15 || e.val.size() > 2048) return false; + total += 1 + kl + 2 + e.val.size(); + if (total > PREFS_MAX_PAYLOAD) return false; + } + out.clear(); + out.reserve(total); + for (const auto& e : kv) { + const size_t kl = strnlen(e.key, sizeof(e.key)); + const size_t vl = e.val.size(); + out.push_back((uint8_t)kl); + out.insert(out.end(), e.key, e.key + kl); + out.push_back((uint8_t)(vl & 0xFF)); + out.push_back((uint8_t)((vl >> 8) & 0xFF)); + out.insert(out.end(), e.val.begin(), e.val.end()); + } + return true; +} + +static bool parsePayload(const uint8_t* data, size_t len, std::vector& out) { + out.clear(); + size_t pos = 0; + while (pos < len) { + if (out.size() >= 256 || pos + 1 > len) return false; + const size_t kl = data[pos++]; + if (kl == 0 || kl > 15 || pos + kl + 2 > len) return false; + SdNvsPrefs::Kv e{}; + memcpy(e.key, data + pos, kl); + e.key[kl] = '\0'; + pos += kl; + const size_t vl = (size_t)data[pos] | ((size_t)data[pos + 1] << 8); + pos += 2; + if (vl > 2048 || pos + vl > len) return false; + e.val.assign(data + pos, data + pos + vl); + pos += vl; + out.push_back(std::move(e)); + } + return true; +} + +static bool readLegacyFile(fs::FS* fs, const char* path, std::vector& out) { + if (!fs || !fs->exists(path)) return false; + File f = fs->open(path, FILE_READ); + if (!f) return false; + const size_t size = f.size(); + if (size > PREFS_MAX_PAYLOAD) { f.close(); return false; } + std::vector payload(size); + const bool ok = (size == 0 || f.read(payload.data(), size) == (int)size); + f.close(); + return ok && parsePayload(payload.data(), payload.size(), out); +} + +static bool readSnapshot(fs::FS* fs, const char* path, std::vector& out, + uint32_t& generation) { + if (!fs || !fs->exists(path)) return false; + File f = fs->open(path, FILE_READ); + if (!f) return false; + const size_t size = f.size(); + if (size < sizeof(SnapshotFooter) || size - sizeof(SnapshotFooter) > PREFS_MAX_PAYLOAD) { + f.close(); + return false; + } + const size_t payload_size = size - sizeof(SnapshotFooter); + std::vector payload(payload_size); + SnapshotFooter footer{}; + bool ok = (payload_size == 0 || f.read(payload.data(), payload_size) == (int)payload_size) && + f.read((uint8_t*)&footer, sizeof(footer)) == (int)sizeof(footer); + f.close(); + if (!ok || footer.magic != PREFS_MAGIC || footer.format != PREFS_FORMAT || + footer.footer_size != sizeof(footer) || footer.payload_size != payload_size || + footer.payload_crc != crc32(payload.data(), payload.size()) || + footer.footer_crc != crc32(&footer, offsetof(SnapshotFooter, footer_crc))) return false; + if (!parsePayload(payload.data(), payload.size(), out)) return false; + generation = footer.generation; + return true; +} + +static bool writeSnapshot(fs::FS* fs, const char* legacy_path, char slot, uint32_t generation, + const std::vector& kv) { + if (!fs) return false; + char path[44]; + if (!slotPath(legacy_path, slot, path, sizeof(path))) return false; + char dir[40]; + strncpy(dir, legacy_path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0'; + char* slash = strrchr(dir, '/'); + if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); } + + std::vector payload; + if (!serializeKv(kv, payload)) return false; + SnapshotFooter footer{PREFS_MAGIC, PREFS_FORMAT, (uint16_t)sizeof(SnapshotFooter), + generation, (uint32_t)payload.size(), crc32(payload.data(), payload.size()), 0}; + footer.footer_crc = crc32(&footer, offsetof(SnapshotFooter, footer_crc)); + +#if defined(TDP4_POKE_TRACE) + printf("[SDW] %lu core%d prefs %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), path); +#endif + File f = fs->open(path, FILE_WRITE); + if (!f) return false; + bool ok = (payload.empty() || f.write(payload.data(), payload.size()) == payload.size()) && + f.write((const uint8_t*)&footer, sizeof(footer)) == sizeof(footer); + f.flush(); + f.close(); + if (!ok) return false; + + std::vector verify; + uint32_t verify_generation = 0; + return readSnapshot(fs, path, verify, verify_generation) && verify_generation == generation; +} + +static bool newerGeneration(uint32_t lhs, uint32_t rhs) { + return (int32_t)(lhs - rhs) > 0; +} + +static FileCache* findCacheLocked(const char* path) { + for (auto& cache : s_caches) + if (strncmp(cache.legacy_path, path, sizeof(cache.legacy_path)) == 0) return &cache; + return nullptr; +} + +// File-mode caches are shared by every SdNvsPrefs instance and by both the UI +// and worker cores. Copy into caller-owned storage while holding the cache +// mutex; returning a Kv* would let a concurrent setter invalidate the vector +// element or its payload as soon as the lock was released. Scalar and buffer +// getters stay allocation-free, avoiding tiny heap churn on every preference +// read. value_size reports the stored length even when the output is truncated. +static bool copyCachedValue(const char* path, const char* key, void* out, + size_t out_size, size_t& value_size) { + value_size = 0; + if (!s_cache_mutex || !path || !key) return false; + bool found = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(path)) { + for (const auto& e : cache->kv) { + if (strncmp(e.key, key, sizeof(e.key)) != 0) continue; + value_size = e.val.size(); + const size_t copy_size = value_size < out_size ? value_size : out_size; + if (out && copy_size) memcpy(out, e.val.data(), copy_size); + found = true; + break; + } + } + xSemaphoreGive(s_cache_mutex); + return found; +} + +static void prefsWriterTask(void*) { + for (;;) { + WriteJob* job = nullptr; + if (xQueueReceive(s_write_queue, &job, portMAX_DELAY) != pdTRUE || !job) continue; + const uint32_t started = millis(); + const bool ok = writeSnapshot(job->fs, job->legacy_path, job->slot, job->generation, job->kv); + const uint32_t elapsed = millis() - started; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(job->legacy_path)) { + cache->writing = false; + if (ok) { + cache->generation = job->generation; + cache->active_slot = job->slot; + if (cache->revision == job->revision) cache->dirty = false; + cache->retry_after_ms = 0; + } else { + cache->dirty = true; + cache->dirty_first_ms = cache->dirty_last_ms = millis(); + cache->retry_after_ms = millis() + 30000; + Serial.printf("[PREFS] snapshot write failed %s.%c\n", job->legacy_path, job->slot); + } + } + xSemaphoreGive(s_cache_mutex); + if (ok && elapsed >= 200) + Serial.printf("[PREFS] slow snapshot %s slot %c: %lums\n", job->legacy_path, + job->slot, (unsigned long)elapsed); + delete job; + } +} + +static bool ensureWorker() { + if (!s_cache_mutex) s_cache_mutex = xSemaphoreCreateMutex(); + if (!s_write_queue) s_write_queue = xQueueCreate(2, sizeof(WriteJob*)); + if (!s_cache_mutex || !s_write_queue) return false; + if (!s_write_task) { + if (xTaskCreatePinnedToCore(prefsWriterTask, "prefs_writer", 6144, nullptr, 2, + &s_write_task, 0) != pdPASS) s_write_task = nullptr; + } + return s_write_task != nullptr; +} + void SdNvsPrefs::useFile(fs::FS* fs, const char* dir) { if (!fs) return; s_file_fs = fs; strncpy(s_file_dir, (dir && dir[0]) ? dir : "/prefs", sizeof(s_file_dir) - 1); s_file_dir[sizeof(s_file_dir) - 1] = '\0'; s_file_mode = true; + ensureWorker(); Serial.printf("[PREFS] backend = FILE %s\n", s_file_dir); } @@ -59,23 +327,28 @@ bool SdNvsPrefs::useNvs() { // ----------------------------- begin / end ----------------------------- bool SdNvsPrefs::begin(const char* ns, bool readOnly) { _read_only = readOnly; - _sd.clear(); - _sd_loaded = false; if (_nvs_open) { _nvs.end(); _nvs_open = false; } if (fileMode()) { - snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns); - sdLoad(); - _sd_loaded = true; + if (!ns || snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns) >= (int)sizeof(_path)) + return false; + const bool loaded = sdLoad(); if (nvsHasLegacyData()) _nvs_open = _nvs.begin(ns, true); // RO migration source - return true; + return loaded; } - if (useNvs()) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; } - snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns); - sdLoad(); - _sd_loaded = true; - return true; + if (useNvs()) { + _sd.clear(); + _sd_loaded = false; + _nvs_open = _nvs.begin(ns, readOnly); + return _nvs_open; + } + if (!ns || snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns) >= (int)sizeof(_path)) + return false; + const bool had_cache = _sd_loaded; + const bool loaded = sdLoad(); + if (loaded) _sd_loaded = true; + return loaded || (readOnly && had_cache); } void SdNvsPrefs::end() { @@ -84,74 +357,311 @@ void SdNvsPrefs::end() { // ----------------------------- file helpers ----------------------------- SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) { + // Shared file-mode entries must never escape s_cache_mutex. File-mode + // callers use copyCachedValue() instead; this pointer helper is only for the + // legacy per-instance vector below. + if (fileMode()) return nullptr; for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e; return nullptr; } -void SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { +bool SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { + if (_read_only || !key || key[0] == '\0' || strlen(key) > 15 || len > 2048 || + (len > 0 && !data)) return false; + if (fileMode()) { + if (!s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache || !cache->loaded) { xSemaphoreGive(s_cache_mutex); return false; } + Kv* e = nullptr; + for (auto& item : cache->kv) + if (strncmp(item.key, key, sizeof(item.key)) == 0) { e = &item; break; } + if (!e) { + if (cache->kv.size() >= 256) { xSemaphoreGive(s_cache_mutex); return false; } + cache->kv.push_back(Kv{}); + e = &cache->kv.back(); + strncpy(e->key, key, sizeof(e->key) - 1); + } + if (len == 0) e->val.clear(); else e->val.assign(data, data + len); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); + return true; + } Kv* e = sdFind(key); + bool added = false; + std::vector old; if (!e) { _sd.push_back(Kv{}); e = &_sd.back(); + added = true; strncpy(e->key, key, sizeof(e->key) - 1); e->key[sizeof(e->key) - 1] = '\0'; + } else { + old = e->val; } - e->val.assign(data, data + len); - sdSave(); + if (len == 0) e->val.clear(); + else e->val.assign(data, data + len); + if (sdSave()) return true; + if (added) _sd.pop_back(); + else e->val = std::move(old); + return false; } -void SdNvsPrefs::sdLoad() { - _sd.clear(); +bool SdNvsPrefs::sdLoad() { fs::FS* fs = activeFs(); - if (!fs) return; - File f = fs->open(_path, FILE_READ); - if (!f) return; - // record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Sanity caps stop a - // corrupt file from a huge alloc; on any bad record we stop and keep what loaded. - while (f.available() > 0 && _sd.size() < 256) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - char k[16] = {0}; - if (f.read((uint8_t*)k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - std::vector v(vl); - if (vl && f.read(v.data(), vl) != (int)vl) break; - Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0'; - e.val = std::move(v); - _sd.push_back(std::move(e)); + if (!fs) return false; + if (!fileMode()) { + // Legacy fallback before useFile(): retain the old single-file behaviour. + std::vector loaded; + if (!fs->exists(_path)) { _sd.clear(); return true; } + if (!readLegacyFile(fs, _path, loaded)) return false; + _sd = std::move(loaded); + return true; } - f.close(); + + if (!ensureWorker()) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* existing = findCacheLocked(_path)) { + const bool ok = existing->loaded; + xSemaphoreGive(s_cache_mutex); + return ok; + } + xSemaphoreGive(s_cache_mutex); + + char path_a[44], path_b[44]; + if (!slotPath(_path, 'a', path_a, sizeof(path_a)) || + !slotPath(_path, 'b', path_b, sizeof(path_b))) return false; + std::vector a, b, loaded; + uint32_t gen_a = 0, gen_b = 0; + const bool ok_a = readSnapshot(fs, path_a, a, gen_a); + const bool ok_b = readSnapshot(fs, path_b, b, gen_b); + uint32_t generation = 0; + char active = 0; + bool legacy_present = false; + bool migrate = false; + if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) { + loaded = std::move(a); generation = gen_a; active = 'a'; + } else if (ok_b) { + loaded = std::move(b); generation = gen_b; active = 'b'; + } else { + // Migrate the released single-file format. Parsing is all-or-nothing; + // defaults never replace a corrupt namespace. + if (readLegacyFile(fs, _path, loaded)) { + legacy_present = true; + migrate = true; + } else if (fs->exists(_path) || fs->exists(path_a) || fs->exists(path_b)) { + Serial.printf("[PREFS] no valid snapshot for %s\n", _path); + return false; + } + // No file at all is a valid fresh namespace. + } + + FileCache cache; + cache.fs = fs; + strncpy(cache.legacy_path, _path, sizeof(cache.legacy_path) - 1); + cache.kv = std::move(loaded); + cache.generation = generation; + cache.active_slot = active; + cache.loaded = true; + cache.legacy_present = legacy_present; + if (migrate) { + cache.dirty = true; + cache.dirty_first_ms = cache.dirty_last_ms = millis(); + ++cache.revision; + } + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + // Another task may have loaded this namespace while file I/O ran outside the + // mutex. Keep a single authoritative cache entry instead of inserting an + // invisible duplicate that the writer would still iterate. + if (FileCache* existing = findCacheLocked(_path)) { + const bool ok = existing->loaded; + xSemaphoreGive(s_cache_mutex); + return ok; + } + s_caches.push_back(std::move(cache)); + xSemaphoreGive(s_cache_mutex); + return true; } -void SdNvsPrefs::sdSave() { - if (_read_only) return; +bool SdNvsPrefs::sdSave() { + if (_read_only) return false; + if (fileMode()) return true; // sdSet/remove/clear already dirtied the shared cache fs::FS* fs = activeFs(); - if (!fs) return; - // Create the parent dir (SD needs it; SPIFFS is flat and treats the whole path - // as a filename, so mkdir is a harmless no-op there). - char dir[40]; - strncpy(dir, _path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0'; - char* slash = strrchr(dir, '/'); - if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); } - File f = fs->open(_path, FILE_WRITE); // truncate + rewrite the whole file - if (!f) return; - for (auto& e : _sd) { - size_t kl = strnlen(e.key, sizeof(e.key)); - size_t vl = e.val.size(); - f.write((uint8_t)kl); - f.write((const uint8_t*)e.key, kl); - f.write((uint8_t)(vl & 0xFF)); - f.write((uint8_t)((vl >> 8) & 0xFF)); - if (vl) f.write(e.val.data(), vl); - } + if (!fs) return false; + std::vector payload; + if (!serializeKv(_sd, payload)) return false; + File f = fs->open(_path, FILE_WRITE); + if (!f) return false; + const bool ok = payload.empty() || f.write(payload.data(), payload.size()) == payload.size(); f.close(); + return ok; } -uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) { +static bool scheduleOne(uint32_t now, bool force) { + if (!fileMode() || !ensureWorker()) return false; + WriteJob* job = nullptr; + char armed_path[40] = {0}; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (auto& cache : s_caches) { + if (!cache.loaded || !cache.dirty || cache.writing) continue; + if (!force && cache.retry_after_ms && (int32_t)(now - cache.retry_after_ms) < 0) continue; + const bool due = force || (uint32_t)(now - cache.dirty_last_ms) >= PREFS_QUIET_MS || + (uint32_t)(now - cache.dirty_first_ms) >= PREFS_MAX_WAIT_MS; + if (!due) continue; + job = new (std::nothrow) WriteJob(); + if (!job) break; + job->fs = cache.fs; + strncpy(job->legacy_path, cache.legacy_path, sizeof(job->legacy_path) - 1); + job->kv = cache.kv; + job->generation = cache.generation + 1; + job->revision = cache.revision; + job->slot = cache.active_slot == 'a' ? 'b' + : cache.active_slot == 'b' ? 'a' + : cache.legacy_present ? 'b' : 'a'; + cache.writing = true; + strncpy(armed_path, cache.legacy_path, sizeof(armed_path) - 1); + break; + } + xSemaphoreGive(s_cache_mutex); + if (!job) return false; + if (xQueueSend(s_write_queue, &job, 0) != pdTRUE) { + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(armed_path)) cache->writing = false; + xSemaphoreGive(s_cache_mutex); + delete job; + return false; + } + return true; +} + +void SdNvsPrefs::tick(uint32_t now_ms) { + if (!fileMode()) return; + if (now_ms == 0) now_ms = millis(); + scheduleOne(now_ms, false); +} + +bool SdNvsPrefs::flush(uint32_t timeout_ms) { + if (!fileMode()) return true; + if (!ensureWorker()) { + // A writer-allocation failure is only a flush failure when RAM contains + // work that still needs landing. A clean store has nothing to wait for and + // must not permanently block unrelated migration/reboot workflows. + if (!s_cache_mutex) return true; + bool pending = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) { + if (cache.dirty || cache.writing) { pending = true; break; } + } + xSemaphoreGive(s_cache_mutex); + return !pending; + } + const uint32_t start = millis(); + for (;;) { + scheduleOne(millis(), true); + bool pending = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) + if (cache.dirty || cache.writing) { pending = true; break; } + xSemaphoreGive(s_cache_mutex); + if (!pending) return true; + if ((uint32_t)(millis() - start) >= timeout_ms) return false; + delay(10); + } +} + +bool SdNvsPrefs::busy() { + if (!fileMode() || !s_cache_mutex) return false; + bool result = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) + if (cache.writing) { result = true; break; } + xSemaphoreGive(s_cache_mutex); + return result; +} + +static bool explicitNamespacePath(const char* dir, const char* ns, char* out, size_t cap) { + if (!dir || !ns || !out) return false; + return snprintf(out, cap, "%s/%s.kv", dir, ns) < (int)cap; +} + +static bool loadExplicitNamespace(fs::FS* fs, const char* legacy_path, + std::vector& kv, + uint32_t& generation, char& active) { + char path_a[44], path_b[44]; + if (!slotPath(legacy_path, 'a', path_a, sizeof(path_a)) || + !slotPath(legacy_path, 'b', path_b, sizeof(path_b))) return false; + std::vector a, b; + uint32_t gen_a = 0, gen_b = 0; + const bool ok_a = readSnapshot(fs, path_a, a, gen_a); + const bool ok_b = readSnapshot(fs, path_b, b, gen_b); + if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) { + kv = std::move(a); generation = gen_a; active = 'a'; return true; + } + if (ok_b) { kv = std::move(b); generation = gen_b; active = 'b'; return true; } + generation = 0; + active = 0; + return readLegacyFile(fs, legacy_path, kv); +} + +bool SdNvsPrefs::readFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool& value) { + char legacy_path[40]; + if (!fs || !key || !explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false; + std::vector kv; + uint32_t generation = 0; + char active = 0; + if (!loadExplicitNamespace(fs, legacy_path, kv, generation, active)) return false; + for (const auto& e : kv) { + if (strncmp(e.key, key, sizeof(e.key)) != 0) continue; + if (e.val.empty()) return false; + value = e.val[0] != 0; + return true; + } + return false; +} + +bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool value) { + char legacy_path[40]; + if (!fs || !key || key[0] == '\0' || strlen(key) > 15 || + !explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false; + std::vector kv; + uint32_t generation = 0; + char active = 0; + const bool existed = loadExplicitNamespace(fs, legacy_path, kv, generation, active); + Kv* found = nullptr; + for (auto& e : kv) + if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } + if (!found) { + kv.push_back(Kv{}); + found = &kv.back(); + strncpy(found->key, key, sizeof(found->key) - 1); + } + found->val.assign(1, value ? 1 : 0); + const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : existed ? 'b' : 'a'; + if (!writeSnapshot(fs, legacy_path, slot, generation + 1, kv)) return false; + return true; +} + +uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width, bool* found) { + uint8_t cached[8] = {}; + if (fileMode()) { + size_t value_size = 0; + const bool exists = copyCachedValue(_path, key, cached, sizeof cached, value_size); + if (found) *found = exists; + if (!exists || value_size == 0) return def; + uint64_t v = 0; + int n = (int)value_size; if (n > width) n = width; + for (int i = 0; i < n; i++) v |= (uint64_t)cached[i] << (8 * i); + return v; + } Kv* e = sdFind(key); + if (found) *found = e != nullptr; if (!e || e->val.empty()) return def; uint64_t v = 0; int n = (int)e->val.size(); if (n > width) n = width; @@ -162,8 +672,7 @@ uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) { size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) { uint8_t b[8]; for (int i = 0; i < width; i++) b[i] = (uint8_t)(v >> (8 * i)); - sdSet(key, b, width); - return width; + return sdSet(key, b, width) ? width : 0; } // ----------------------------- API ----------------------------- @@ -172,21 +681,61 @@ size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) { // ever touch the file — nothing new is written to NVS. bool SdNvsPrefs::isKey(const char* key) { - if (fileMode()) return sdFind(key) != nullptr || (_nvs_open && _nvs.isKey(key)); + if (fileMode()) { + size_t value_size = 0; + return copyCachedValue(_path, key, nullptr, 0, value_size) || + (_nvs_open && _nvs.isKey(key)); + } return useNvs() ? (_nvs_open && _nvs.isKey(key)) : (sdFind(key) != nullptr); } bool SdNvsPrefs::remove(const char* key) { if (!fileMode() && useNvs()) return _nvs_open && _nvs.remove(key); - for (size_t i = 0; i < _sd.size(); i++) - if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) == 0) { _sd.erase(_sd.begin() + i); sdSave(); return true; } + if (fileMode()) { + if (_read_only || !s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache) { xSemaphoreGive(s_cache_mutex); return false; } + for (size_t i = 0; i < cache->kv.size(); ++i) { + if (strncmp(cache->kv[i].key, key, sizeof(cache->kv[i].key)) != 0) continue; + cache->kv.erase(cache->kv.begin() + i); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); + return true; + } + xSemaphoreGive(s_cache_mutex); + return false; + } + for (size_t i = 0; i < _sd.size(); i++) { + if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) != 0) continue; + Kv old = _sd[i]; + _sd.erase(_sd.begin() + i); + if (sdSave()) return true; + _sd.insert(_sd.begin() + i, std::move(old)); + return false; + } return false; } bool SdNvsPrefs::clear() { if (fileMode()) { - _sd.clear(); - sdSave(); + if (_read_only || !s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache) { xSemaphoreGive(s_cache_mutex); return false; } + cache->kv.clear(); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); // Wipe any legacy NVS copy too, so a factory reset is permanent. Derive the // namespace from "/.kv". if (nvsHasLegacyData()) { @@ -201,12 +750,18 @@ bool SdNvsPrefs::clear() { return true; } if (useNvs()) return _nvs_open && _nvs.clear(); - _sd.clear(); sdSave(); return true; + std::vector old = _sd; + _sd.clear(); + if (sdSave()) return true; + _sd = std::move(old); + return false; } uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) { if (fileMode()) { - if (sdFind(k)) return (uint8_t)sdGetInt(k, def, 1); + bool found = false; + const uint8_t value = (uint8_t)sdGetInt(k, def, 1, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUChar(k, def); return def; } @@ -218,7 +773,9 @@ size_t SdNvsPrefs::putUChar(const char* k, uint8_t v) { } int8_t SdNvsPrefs::getChar(const char* k, int8_t def) { if (fileMode()) { - if (sdFind(k)) return (int8_t)sdGetInt(k, (uint8_t)def, 1); + bool found = false; + const int8_t value = (int8_t)sdGetInt(k, (uint8_t)def, 1, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getChar(k, def); return def; } @@ -230,7 +787,9 @@ size_t SdNvsPrefs::putChar(const char* k, int8_t v) { } uint16_t SdNvsPrefs::getUShort(const char* k, uint16_t def) { if (fileMode()) { - if (sdFind(k)) return (uint16_t)sdGetInt(k, def, 2); + bool found = false; + const uint16_t value = (uint16_t)sdGetInt(k, def, 2, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUShort(k, def); return def; } @@ -242,7 +801,9 @@ size_t SdNvsPrefs::putUShort(const char* k, uint16_t v) { } uint32_t SdNvsPrefs::getUInt(const char* k, uint32_t def) { if (fileMode()) { - if (sdFind(k)) return (uint32_t)sdGetInt(k, def, 4); + bool found = false; + const uint32_t value = (uint32_t)sdGetInt(k, def, 4, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUInt(k, def); return def; } @@ -254,7 +815,9 @@ size_t SdNvsPrefs::putUInt(const char* k, uint32_t v) { } bool SdNvsPrefs::getBool(const char* k, bool def) { if (fileMode()) { - if (sdFind(k)) return sdGetInt(k, def ? 1 : 0, 1) != 0; + bool found = false; + const bool value = sdGetInt(k, def ? 1 : 0, 1, &found) != 0; + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getBool(k, def); return def; } @@ -266,29 +829,48 @@ size_t SdNvsPrefs::putBool(const char* k, bool v) { } String SdNvsPrefs::getString(const char* k, const String& def) { - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; - } else { - if (useNvs()) return _nvs.getString(k, def); - e = sdFind(k); - if (!e) return def; + if (!s_cache_mutex) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; + String value; + bool found = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(_path)) { + for (const auto& e : cache->kv) { + if (strncmp(e.key, k, sizeof(e.key)) != 0) continue; + value.reserve(e.val.size()); + for (uint8_t c : e.val) value += (char)c; + found = true; + break; + } + } + xSemaphoreGive(s_cache_mutex); + if (!found) + return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; + return value; } + if (useNvs()) return _nvs.getString(k, def); + Kv* e = sdFind(k); + if (!e) return def; String s; s.reserve(e->val.size()); for (uint8_t c : e->val) s += (char)c; return s; } size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { if (!buf || !maxLen) return 0; - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) { if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); buf[0] = '\0'; return 0; } - } else { - if (useNvs()) return _nvs.getString(k, buf, maxLen); - e = sdFind(k); + size_t value_size = 0; + if (!copyCachedValue(_path, k, buf, maxLen - 1, value_size)) { + if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); + buf[0] = '\0'; + return 0; + } + size_t n = value_size; + if (n > maxLen - 1) n = maxLen - 1; + buf[n] = '\0'; + return n; } + if (useNvs()) return _nvs.getString(k, buf, maxLen); + Kv* e = sdFind(k); size_t n = e ? e->val.size() : 0; if (n > maxLen - 1) n = maxLen - 1; if (e && n) memcpy(buf, e->val.data(), n); @@ -296,29 +878,29 @@ size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { return n; } size_t SdNvsPrefs::putString(const char* k, const char* v) { - if (fileMode()) { size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; } + if (fileMode()) { size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0; } if (useNvs()) return _nvs.putString(k, v); - size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; + size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0; } size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0; - } else { - if (useNvs()) return _nvs.getBytes(k, buf, maxLen); - e = sdFind(k); - if (!e) return 0; + size_t value_size = 0; + if (!copyCachedValue(_path, k, buf, maxLen, value_size)) + return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0; + return value_size; } + if (useNvs()) return _nvs.getBytes(k, buf, maxLen); + Kv* e = sdFind(k); + if (!e) return 0; size_t n = e->val.size(); if (buf && maxLen) { size_t c = n > maxLen ? maxLen : n; memcpy(buf, e->val.data(), c); } return n; } size_t SdNvsPrefs::putBytes(const char* k, const void* buf, size_t len) { - if (fileMode()) { sdSet(k, (const uint8_t*)buf, len); return len; } + if (fileMode()) return sdSet(k, (const uint8_t*)buf, len) ? len : 0; if (useNvs()) return _nvs.putBytes(k, buf, len); - sdSet(k, (const uint8_t*)buf, len); return len; + return sdSet(k, (const uint8_t*)buf, len) ? len : 0; } #endif // ESP32 diff --git a/src/helpers/esp32/SdNvsPrefs.h b/src/helpers/esp32/SdNvsPrefs.h index f2d914d..7be0f36 100644 --- a/src/helpers/esp32/SdNvsPrefs.h +++ b/src/helpers/esp32/SdNvsPrefs.h @@ -11,12 +11,12 @@ namespace fs { class FS; } // forward decl only — keep this header's layout // keep the touch app's settings OFF NVS. // // File mode (set by SdNvsPrefs::useFile() once at boot, after the SD/SPIFFS -// storage decision): every namespace lives in a flat /.kv file on the -// chosen filesystem — SD (/meshcomod) when a card is the active data store, else -// SPIFFS (/prefs). Nothing new is written to NVS; NVS is read-only, used only to -// migrate settings written by an older NVS build (they move to the file on their -// next save). Legacy mode (before useFile, e.g. the early boot-rotation read): -// NVS if it works, else /meshcomod/.kv on SD — the previous behaviour. +// storage decision): every namespace is held in RAM and committed to alternating +// /.kv / .alt snapshots. Setters only dirty the RAM copy; a worker writes +// the inactive slot after a short quiet period, keeping filesystem latency off +// the UI task. Each snapshot has a generation and CRC, so a reset can only lose +// the newest pending update — it cannot turn an interrupted write into defaults. +// Legacy /.kv and NVS data are read for migration. // // IMPORTANT: the monorepo lib ships a STALE copy of this header that other // translation units may pick up via an angle include. To avoid an ODR/layout @@ -25,11 +25,20 @@ namespace fs { class FS; } // forward decl only — keep this header's layout // The file backend therefore uses file-static state in the .cpp, not a member. // Only the layout-neutral static useFile() is new. // -// On-disk file format is unchanged: [keylen u8][key][vallen u16 LE][val]. class SdNvsPrefs { public: static void useFile(fs::FS* fs, const char* dir); // route prefs to /.kv static fs::FS* fileFs(); // the active file-mode fs, or nullptr when NVS-backed + static void tick(uint32_t now_ms = 0); // schedule due snapshots; never blocks on I/O + static bool flush(uint32_t timeout_ms = 12000); // force all queued snapshots before reset/sleep + static bool busy(); // a worker currently owns a filesystem handle + + // Early boot needs `use_sd` before the regular backend is selected. These + // helpers use the same A/B format on an explicitly supplied filesystem. + static bool readFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool& value); + static bool writeFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool value); bool begin(const char* ns, bool readOnly = false); void end(); @@ -57,6 +66,10 @@ public: size_t getBytes(const char* key, void* buf, size_t maxLen); size_t putBytes(const char* key, const void* buf, size_t len); + // Public only so the file-static snapshot worker can preserve this class's + // ABI while sharing namespace caches across short-lived instances. + struct Kv { char key[16]; std::vector val; }; + private: // --- NVS-backed path --- (LAYOUT MUST MATCH the lib's stale header — see above) Preferences _nvs; @@ -64,17 +77,16 @@ private: bool _read_only = false; // --- file-backed path --- - struct Kv { char key[16]; std::vector val; }; std::vector _sd; // in-RAM mirror of /.kv char _path[40] = {0}; bool _sd_loaded = false; bool useNvs(); // legacy probe (NVS vs SD), cached globally Kv* sdFind(const char* key); - void sdSet(const char* key, const uint8_t* data, size_t len); - void sdLoad(); - void sdSave(); - uint64_t sdGetInt(const char* key, uint64_t def, int width); + bool sdSet(const char* key, const uint8_t* data, size_t len); + bool sdLoad(); + bool sdSave(); // queue current RAM mirror (no filesystem I/O) + uint64_t sdGetInt(const char* key, uint64_t def, int width, bool* found = nullptr); size_t sdPutInt(const char* key, uint64_t v, int width); }; diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index c2952b9..5837aab 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -10,7 +10,6 @@ #include #include // offsetof #include // memcpy -#include // writeUseSdToSpiffsKv record list static const char* TOUCH_NS = "touch"; @@ -30,15 +29,14 @@ static bool s_begun = false; // keys too. "use_sd" is mirrored to NVS on every UI toggle (main.cpp reads it at // boot via touchPrefsReadUseSdAtBoot); "setup_ok" is still NVS-only. // -// On first run with the blob absent we read every legacy per-key into s_cfg, write -// "cfg" ONCE, and only after that durable write do we remove() the legacy keys to -// reclaim their entries. The write-before-remove ordering makes the migration -// crash-safe and idempotent (a power-cut before the write just re-migrates next -// boot; one after it finds "cfg" present and skips). `magic` rejects a garbage / +// On first run with the blob absent we read every legacy per-key into s_cfg, add +// "cfg", and remove the superseded file keys in the same queued RAM snapshot. +// The released .kv remains the fallback until that complete A/B snapshot verifies, +// making the migration crash-safe and idempotent. `magic` rejects a garbage / // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF) +static const uint8_t TOUCH_CFG_VER = 44; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once); v44 retry_echo (auto-retry sends until the mesh echoes/ACKs them, default ON; opt-out toggle in Radio & Mesh) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -83,7 +81,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t map_show_tilexyz; // show the zoom + tile z/x/y line on the map (bool) — v6 uint8_t map_show_contacts;// show contact markers on the map (bool) — v6 uint8_t app_grid_large; // app drawer: large grid (one fewer column, bigger icons) — v7 - uint8_t ui_scale; // UI resolution scale: 0=100% 1=150% 2=200% (Tanmatsu, applied at boot) — v8 + uint8_t ui_scale; // UI-size preset 0..2 (board-specific font/geometry mapping, applied at boot) — v8 uint8_t kbd_nav; // T-Deck keyboard ESDFX nav: 0=off (default), 1=on (E/X/S/F move focus, D select, Q back) — v9 (was tb_keypad) uint8_t sleep_idle; // idle light-sleep feature on/off (bool) — v10 (trailing so existing blobs default it OFF) uint8_t nav_keys[5]; // keyboard-nav tab hotkeys (ASCII), one per main tab [chat,contacts,home,map,settings] — v11 (trailing) @@ -106,11 +104,15 @@ struct __attribute__((packed)) TouchCfg { uint8_t compact_chat; // IRC-style dense chat rows instead of bubbles (bool, 0=off) — v31 (trailing) uint32_t clock_floor; // highest epoch this device handed out (ClockFloorRTC persistence) — v32 (trailing) uint8_t rx_queue; // buffered LoRa receive: drain task + packet ring (bool, 0=off, experimental) — v33 (trailing) + uint8_t retry_echo; // auto-retry sends until echo/ACK heard (bool, default ON) — v44 (trailing) uint8_t web_mirror; // web control panel: mirror the live UI to a phone browser + inject taps (bool, 0=off) — v34 (trailing) uint8_t remote_mode; // render the UI off-screen at a web resolution instead of the panel (bool, 0=off) — v35 (trailing) uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing) uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) + uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing) + uint16_t hist_per_chat; // max stored messages per chat/thread; 0 = no per-chat cap (ring only) - v42 (trailing) + uint8_t p4_antenna; // RESERVED (was: T-Display P4 antenna select). The antenna choice is session-only by design and is never stored — see the note further down — v41 (trailing) }; static TouchCfg s_cfg; @@ -157,6 +159,8 @@ static void cfgSetDefaults(TouchCfg& c) { c.lock_color = DEFAULT_LOCK_COLOR; c.accent = DEFAULT_ACCENT; c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback + c.hist_per_chat = 250; // keep the newest 250 per chat: enough to scroll back, small enough to stay quick + c.p4_antenna = 0; // reserved, unused: the P4 antenna choice is never persisted c.sig_probe_en = DEFAULT_SIG_PROBE_EN; c.sig_poll_min = DEFAULT_SIG_POLL_MIN; c.tz_zone = 0; // 0 = Europe (CET/CEST) — preserves prior behaviour @@ -167,7 +171,11 @@ static void cfgSetDefaults(TouchCfg& c) { c.map_show_tilexyz = 1; c.map_show_contacts = 1; c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols) - c.ui_scale = 1; // default: 150% "Large" UI scale (Tanmatsu; S3 boards ignore this) +#if defined(TLORA_PAGER) + c.ui_scale = 0; // Pager: Small/current typography by default +#else + c.ui_scale = 1; // large-screen boards keep their existing 150% default +#endif #if defined(HAS_TANMATSU) c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on #else @@ -184,6 +192,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.compact_chat = 0; // OFF: bubble chat layout (opt-in IRC-style dense rows) c.clock_floor = 0; // no persisted send-timestamp floor yet c.rx_queue = 1; // ON: buffered receive (test-channel default; opt-out toggle in Radio & Mesh) + c.retry_echo = 1; // ON: retry sends until the mesh confirms them (opt-out toggle in Radio & Mesh) c.sleep_idle = 0; // default: idle light-sleep OFF { const char* d = "ertui"; for (int i = 0; i < 5; i++) c.nav_keys[i] = (uint8_t)d[i]; } // default tab hotkeys E/R/T/U/I c.map_zoom_buttons = 0; // default: map zoom = slider @@ -207,10 +216,11 @@ static void cfgSetDefaults(TouchCfg& c) { c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC) c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer) + c.hist_sync_after = 2; // chat flush: 2 failed background writes -> synchronous loop-task fallback } -// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) -// discipline every setter in this file uses. Returns true on a durable write. +// Update the whole blob using the same end()/begin(RW)/put/end()/begin(RO) +// discipline every setter in this file uses. File mode queues a coalesced write. static bool cfgFlush() { s_prefs.end(); if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = false; return false; } @@ -272,12 +282,19 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default if (s_cfg.ver < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89) if (s_cfg.ver < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh) + if (s_cfg.ver < 44) s_cfg.retry_echo = 1; // auto-retry ON by default (opt-out toggle in Radio & Mesh) if (s_cfg.ver < 34) s_cfg.web_mirror = 0; // new trailing field: web control panel off by default (opt-in remote control) if (s_cfg.ver < 35) s_cfg.remote_mode = 0; // new trailing field: remote mode off by default (opt-in, reboots to apply) if (s_cfg.ver < 36) s_cfg.remote_landscape = 0; if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle) if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in) if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default +#if defined(TLORA_PAGER) + // ui_scale existed before the Pager exposed the selector, so every old + // Pager inherited the unrelated large-screen default (1) while ignoring + // it. Reset it once so upgrading cannot enlarge the UI without consent. + if (s_cfg.ver < 43) s_cfg.ui_scale = 0; +#endif s_cfg.ver = TOUCH_CFG_VER; s_cfg.magic = TOUCH_CFG_MAGIC; cfgFlush(); // rewrite with new fields defaulted-in @@ -321,9 +338,8 @@ static void cfgLoadOrMigrate() { } } - // Write "cfg" ONCE. Only after a durable write do we reclaim the legacy keys. - // If the write fails (e.g. NVS full / SD missing) we keep the legacy keys - // intact and retry the whole migration on the next boot. + // Add "cfg" and retire the old file keys in one RAM transaction. The A/B + // worker commits the complete result; until then the released .kv is intact. if (cfgFlush()) { s_prefs.end(); if (s_prefs.begin(TOUCH_NS, false)) { @@ -371,6 +387,10 @@ void touchPrefsReload() { touchPrefsBegin(); } +void touchPrefsTick(uint32_t now_ms) { SdNvsPrefs::tick(now_ms); } +bool touchPrefsFlush(uint32_t timeout_ms) { return SdNvsPrefs::flush(timeout_ms); } +bool touchPrefsIoBusy() { return SdNvsPrefs::busy(); } + // Arduino's Preferences::getString()/getBytes() emit an [E] nvs_get_* "NOT_FOUND" // log every time a key is absent — which floods the (USB-CDC) console on a fresh // device and on every empty Wi-Fi-slot read. isKey() (getType → raw nvs probes) @@ -392,12 +412,45 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) { } // --- Mesh signal auto-discover probe (toggle + poll interval) --------------- -// The interval is entered in whole minutes; clamp >1 min (one flood a minute is -// already aggressive on shared airtime) .. 1 day so a bad/blank entry can't make -// the probe hammer the mesh or effectively never run. +// The interval is entered in whole minutes; clamp 1 min .. 1 day so a bad or blank entry +// can't make the probe run hot or effectively never run. +// +// NOT A FLOOD. This comment used to describe the probe as a flood, which is where the +// "wadamesh spams the mesh every 5 minutes" worry came from (issue #80). It is a ZERO-HOP +// CTL_TYPE_NODE_DISCOVER_REQ (MyMesh::uiSendSignalProbe) — the same node-discovery packet +// the other MeshCore GUIs use. Repeaters answer it DIRECTLY and never re-broadcast it, so +// nothing propagates beyond our immediate neighbours; the fallback when no repeater path is +// known is sendAdvert(false), also zero-hop. The caller additionally SKIPS the probe +// whenever a direct neighbour was heard inside the poll window, so the busier the mesh, the +// less this transmits. static const uint16_t SIG_POLL_MIN_MINS = 1; // 1 min = 60 s (the old fixed cadence) static const uint16_t SIG_POLL_MAX_MINS = 1440; +// --- T-Display P4 LoRa antenna select — deliberately NOT persisted ----------- +// XL9535 IO1 drives the board's SKY13453 antenna switch (full reasoning in Xl9535.h). The +// getter/setter that used to live here are gone on purpose: the choice is session-only, so +// there is nothing to store. Every boot forces the on-board antenna, in two places — the park +// in Xl9535::powerOnSequence() and the re-assert in UITask::begin() — because the external +// MMCX may have no antenna fitted, and keying a PA into an open connector damages it. That +// safety property only holds if a power cycle cannot restore "external", which means the +// choice must never reach flash. p4_antenna stays as a reserved trailing byte: dropping it +// would rewind TOUCH_CFG_VER on devices already carrying a v41 blob, for no gain. + +// --- Per-chat history cap ----------------------------------------------------- +// A single busy channel could previously fill the entire shared message ring, which both +// starved every other chat of history and made the inbox slow (see the thread-history cache +// in UITask). This bounds each chat independently. 0 = no per-chat cap, i.e. the old +// behaviour, which the settings UI warns about rather than hiding. +uint16_t touchPrefsGetHistPerChat() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.hist_per_chat; +} +bool touchPrefsSetHistPerChat(uint16_t n) { + if (!s_begun) touchPrefsBegin(); + s_cfg.hist_per_chat = n; + return cfgFlush(); +} + bool touchPrefsGetSigProbeEnabled() { if (!s_begun) touchPrefsBegin(); return s_cfg.sig_probe_en != 0; @@ -928,6 +981,15 @@ bool touchPrefsSetMapNight(bool on) { s_cfg.map_night = on ? 1 : 0; return cfgFlush(); } +uint8_t touchPrefsGetHistSyncAfter() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.hist_sync_after > 9 ? 9 : s_cfg.hist_sync_after; +} +bool touchPrefsSetHistSyncAfter(uint8_t n) { + if (!s_begun) touchPrefsBegin(); + s_cfg.hist_sync_after = n > 9 ? 9 : n; + return cfgFlush(); +} uint8_t touchPrefsGetMapZoom() { if (!s_begun) touchPrefsBegin(); return s_cfg.map_zoom; @@ -1004,7 +1066,7 @@ bool touchPrefsSetAppGridLarge(bool on) { uint8_t touchPrefsGetUiScale() { if (!s_begun) touchPrefsBegin(); - return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; // 0=100% 1=150% 2=200% + return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; } bool touchPrefsSetUiScale(uint8_t scale) { if (!s_begun) touchPrefsBegin(); @@ -1075,6 +1137,15 @@ bool touchPrefsGetRxQueue() { if (!s_begun) touchPrefsBegin(); return s_cfg.rx_queue != 0; } +bool touchPrefsGetRetryEcho() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.retry_echo != 0; +} +bool touchPrefsSetRetryEcho(bool on) { + if (!s_begun) touchPrefsBegin(); + s_cfg.retry_echo = on ? 1 : 0; + return cfgFlush(); +} bool touchPrefsSetRxQueue(bool on) { if (!s_begun) touchPrefsBegin(); s_cfg.rx_queue = on ? 1 : 0; @@ -1186,36 +1257,23 @@ bool touchPrefsSetHomeIsDrawer(bool on) { // the data loads, so changing it needs a reboot. Key "use_sd" in the "touch" // namespace — main.cpp must see the same value the UI toggle writes. static const char* KEY_USE_SD_STORAGE = "use_sd"; -static const char* TOUCH_KV_BOOT_PATH = "/prefs/touch.kv"; - -// SdNvsPrefs file mode writes touch.kv only — parse a bool for boot migration. -static bool readBoolFromTouchKvFile(const char* want_key) { - if (!SPIFFS.exists(TOUCH_KV_BOOT_PATH)) return false; - File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ); - if (!f) return false; - while (f.available() > 0) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - char k[16] = {0}; - if (f.read((uint8_t*)k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - if (strncmp(k, want_key, sizeof k) == 0) { - const bool on = (vl >= 1) && (f.read() != 0); - f.close(); - return on; - } - for (size_t i = 0; i < vl; ++i) { - if (f.read() < 0) { f.close(); return false; } - } - } - f.close(); - return false; -} +static const char* BOOT_PREFS_NS = "bootprefs"; bool touchPrefsReadUseSdAtBoot() { + bool file_val = false; + bool found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS, + KEY_USE_SD_STORAGE, file_val); + // Once present, the explicit boot namespace is authoritative. This lets a + // Launcher device recover even if its best-effort NVS mirror is stale. + if (found) { + Preferences mirror; + if (mirror.begin(TOUCH_NS, false)) { + mirror.putBool(KEY_USE_SD_STORAGE, file_val); + mirror.end(); + } + return file_val; + } + bool nvs_val = false; Preferences p; if (p.begin(TOUCH_NS, true)) { @@ -1223,15 +1281,17 @@ bool touchPrefsReadUseSdAtBoot() { p.end(); } if (nvs_val) return true; - const bool file_val = readBoolFromTouchKvFile(KEY_USE_SD_STORAGE); - if (file_val) { - Serial.println("[BOOT] use_sd read from /prefs/touch.kv (syncing to NVS)"); + // One-time migration for builds that stored the boot flag inside touch.kv. + found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", TOUCH_NS, + KEY_USE_SD_STORAGE, file_val); + if (found && file_val) { + Serial.println("[BOOT] use_sd read from SPIFFS boot prefs (syncing to NVS)"); if (p.begin(TOUCH_NS, false)) { p.putBool(KEY_USE_SD_STORAGE, true); p.end(); } } - return file_val; + return found && file_val; } bool touchPrefsGetUseSdStorage() { @@ -1239,55 +1299,6 @@ bool touchPrefsGetUseSdStorage() { return s_prefs.getBool(KEY_USE_SD_STORAGE, false); // default = SPIFFS } -// Rewrite (or create) the SPIFFS copy of touch.kv with use_sd set, keeping every -// other record. Needed because the boot storage decision can only read SPIFFS -// (SD isn't mounted yet, see touchPrefsReadUseSdAtBoot): when prefs actively -// live on the SD card, the toggle must land in BOTH file copies — otherwise -// installs with unusable NVS (Launcher) could turn SD storage on but never OFF -// again (the stale SPIFFS true would win every boot). PR #123 follow-up. -static void writeUseSdToSpiffsKv(bool on) { - struct Rec { char k[16]; std::vector v; }; - std::vector recs; - File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ); - if (f) { - while (f.available() > 0 && recs.size() < 256) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - Rec r{}; - if (f.read((uint8_t*)r.k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - r.v.resize(vl); - if (vl && f.read(r.v.data(), vl) != (int)vl) break; - recs.push_back(std::move(r)); - } - f.close(); - } - bool found = false; - for (auto& r : recs) { - if (strncmp(r.k, KEY_USE_SD_STORAGE, sizeof r.k) == 0) { r.v.assign(1, on ? 1 : 0); found = true; } - } - if (!found) { - Rec r{}; - strncpy(r.k, KEY_USE_SD_STORAGE, sizeof r.k - 1); - r.v.assign(1, on ? 1 : 0); - recs.push_back(std::move(r)); - } - File w = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_WRITE); // truncate + rewrite - if (!w) return; - for (auto& r : recs) { - size_t kl = strnlen(r.k, sizeof r.k), vl = r.v.size(); - w.write((uint8_t)kl); - w.write((const uint8_t*)r.k, kl); - w.write((uint8_t)(vl & 0xFF)); - w.write((uint8_t)((vl >> 8) & 0xFF)); - if (vl) w.write(r.v.data(), vl); - } - w.close(); -} - bool touchPrefsSetUseSdStorage(bool use_sd) { if (!s_begun) touchPrefsBegin(); s_prefs.end(); @@ -1303,11 +1314,12 @@ bool touchPrefsSetUseSdStorage(bool use_sd) { nvs.putBool(KEY_USE_SD_STORAGE, use_sd); nvs.end(); } - // When prefs live on the SD card, boot's fallback still reads the SPIFFS - // copy — keep it in sync so the toggle works in BOTH directions there. - fs::FS* ffs = SdNvsPrefs::fileFs(); - if (ffs && ffs != (fs::FS*)&SPIFFS) writeUseSdToSpiffsKv(use_sd); - return ok; + // The boot storage decision happens before SD is mounted. Keep a tiny, + // crash-safe SPIFFS A/B namespace as the Launcher-safe fallback regardless + // of where the rest of the preferences currently live. + const bool boot_ok = SdNvsPrefs::writeFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS, + KEY_USE_SD_STORAGE, use_sd); + return ok && boot_ok; } // UI language index (UiLang enum in i18n.h; 0 = English). Read at boot to pick diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 8cd41eb..7823b65 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -13,6 +13,11 @@ void touchPrefsBegin(); // Force a fresh load of the settings blob. Call after SdNvsPrefs::useFile() at // boot: an earlier pref read may have cached the blob from the legacy backend. void touchPrefsReload(); +// Drive/force the deferred file-backed snapshot writer without exposing the +// vendored core's stale SdNvsPrefs header to UI translation units. +void touchPrefsTick(uint32_t now_ms); +bool touchPrefsFlush(uint32_t timeout_ms = 12000); +bool touchPrefsIoBusy(); /** Screen timeout in seconds; 0 = never sleep. Default 20. */ uint16_t touchPrefsGetScreenTimeoutSecs(); @@ -103,6 +108,18 @@ bool touchPrefsSetTilesFromSd(bool from_sd); bool touchPrefsGetMapNight(); bool touchPrefsSetMapNight(bool on); +// Chat-history flush: consecutive failed off-thread (background) writes before +// the flush falls back to the blocking loop-task write (reliable, but the UI +// hitches for the write's duration). 0 = never fall back. Default 2. +uint8_t touchPrefsGetHistSyncAfter(); +bool touchPrefsSetHistSyncAfter(uint8_t n); + +/** Max stored messages kept per chat; 0 = no per-chat cap. Default 250. + * Without a cap one busy channel can fill the whole shared ring, starving every other + * chat of history and slowing the UI down as the ring fills. */ +uint16_t touchPrefsGetHistPerChat(); +bool touchPrefsSetHistPerChat(uint16_t n); + /** Last map zoom level, persisted so the map reopens where the user left it. * 0 = unset (let the auto-snap pick a level for the available tile pack). */ uint8_t touchPrefsGetMapZoom(); @@ -134,8 +151,8 @@ bool touchPrefsSetAppGridLarge(bool on); bool touchPrefsGetSleepIdle(); bool touchPrefsSetSleepIdle(bool on); -/* UI resolution scale (Tanmatsu): 0=100% (native 800x480), 1=150%, 2=200%. Applied at boot — - * LVGL renders at a lower resolution and the flush upscales to the panel. Reboot to apply. */ +/* UI-size preset, 0..2. Large-screen boards retain their percentage mapping; + * the T-Pager exposes Small/Medium/Large font presets. Reboot to apply. */ uint8_t touchPrefsGetUiScale(); bool touchPrefsSetUiScale(uint8_t scale); @@ -183,6 +200,8 @@ bool touchPrefsSetCompactChat(bool on); * (the missed-messages class). Opt-in, default off = stock receive path. */ bool touchPrefsGetRxQueue(); bool touchPrefsSetRxQueue(bool on); +bool touchPrefsGetRetryEcho(); // v44: auto-retry sends until echoed/ACKed (default ON) +bool touchPrefsSetRetryEcho(bool on); uint32_t touchPrefsGetClockFloor(); // monotonic send-timestamp floor (#89) bool touchPrefsSetClockFloor(uint32_t epoch); // only ever grows; no-op below current @@ -546,6 +565,9 @@ bool touchPrefsSetGpsBaud(uint32_t baud); bool touchPrefsGetSigProbeEnabled(); bool touchPrefsSetSigProbeEnabled(bool on); uint16_t touchPrefsGetSigPollMins(); + +/* NB: there is deliberately no touchPrefsGet/SetP4Antenna(). The T-Display P4 antenna choice is + * session-only so that every boot comes up on the on-board antenna — see the note in the .cpp. */ bool touchPrefsSetSigPollMins(uint16_t mins); #endif diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index f7fbe4c..94b95b6 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -27,13 +27,62 @@ // ----------------------------------------------------------------------------- #if defined(ESP32) #include // disableCore0WDT / enableCore0WDT +#include +#include -inline int& _wdtHeavyDepth() { static int d = 0; return d; } -inline void wdtHeavyBegin() { int& d = _wdtHeavyDepth(); if (d++ == 0) disableCore0WDT(); } -inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) enableCore0WDT(); } +// The depth is shared: the UI/loop task and the core-1 history worker both take +// this guard around multi-second flash bursts, and they run at the same priority +// on the same core, so plain tick round-robin can preempt between the load and +// the store of a d++/--d. rebootDevice() makes that collision likely rather than +// theoretical -- it takes the guard immediately before waiting up to 9 s on the +// very worker that also takes it. A lost update either strands the depth above +// zero (IDLE0 never re-subscribed to the task watchdog for the rest of the +// session) or drops it to zero while a guard is still held (watchdog re-armed +// during the burst the guard exists to cover, which panics and loses the +// unflushed chat history rebootDevice() was written to save). +inline std::atomic& _wdtHeavyDepth() { static std::atomic d{0}; return d; } +inline void wdtHeavyBegin() { + if (_wdtHeavyDepth().fetch_add(1, std::memory_order_acq_rel) == 0) disableCore0WDT(); +} +inline void wdtHeavyEnd() { + // Keep the original underflow guard rather than a bare fetch_sub: + // doFactoryReset() calls wdtHeavyBegin() with no matching end (it reboots), + // so the counter can legitimately stay elevated and an unbalanced end must + // never drive it negative. + std::atomic& d = _wdtHeavyDepth(); + int prev = d.load(std::memory_order_relaxed); + while (prev > 0 && !d.compare_exchange_weak(prev, prev - 1, + std::memory_order_acq_rel, + std::memory_order_relaxed)) {} + if (prev == 1) enableCore0WDT(); +} + +// Arduino's enableLoopWDT() subscribes loopTask even when it was not watched +// before. Preserve the caller's actual task-WDT state around slow filesystem +// work instead of silently arming a new watchdog on the way out. The guard can +// be constructed by a storage worker, so query Arduino's loopTask handle rather +// than `nullptr` (which means the current worker task to ESP-IDF). +#if defined(CONFIG_AUTOSTART_ARDUINO) +extern TaskHandle_t loopTaskHandle; +struct LoopWdtGuard { + bool subscribed; + LoopWdtGuard() : subscribed(loopTaskHandle != nullptr && + esp_task_wdt_status(loopTaskHandle) == ESP_OK) { + if (subscribed) disableLoopWDT(); + } + ~LoopWdtGuard() { + if (subscribed) enableLoopWDT(); + } +}; +#else +// Tanmatsu and T-Display-P4 supply app_main() themselves. Their Arduino +// component has no loopTaskHandle or loop-WDT helpers to preserve. +struct LoopWdtGuard { LoopWdtGuard() {} ~LoopWdtGuard() {} }; +#endif #else inline void wdtHeavyBegin() {} inline void wdtHeavyEnd() {} +struct LoopWdtGuard { LoopWdtGuard() {} ~LoopWdtGuard() {} }; #endif struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } }; diff --git a/src/helpers/esp32/WebMirror.h b/src/helpers/esp32/WebMirror.h index e01a8eb..c9a2aee 100644 --- a/src/helpers/esp32/WebMirror.h +++ b/src/helpers/esp32/WebMirror.h @@ -116,7 +116,15 @@ private: volatile uint8_t _ppressed = 0; volatile uint8_t _pknown = 0; - static const int KEY_RING = 32; // typed keys awaiting the UI thread (SPSC) + // Typed keys awaiting the UI thread (SPSC). 32 was far too small: the browser pushes from + // the network thread while the UI drains only a handful per loop iteration, and one + // iteration can be tens of ms (LVGL frame, SD write, radio). Anything arriving FASTER than + // that drain overflowed, and pushKey discards the overflow SILENTLY -- which is exactly + // what a phone autocomplete or a paste looks like, because it delivers a whole word at + // once. Reported over the web mirror as "missing a lot of characters while typing". + // 256 entries costs 512 bytes and is the MAXIMUM this ring can hold while _khead/_ktail + // stay uint8_t; anything larger REQUIRES widening those two or the modulo silently wraps. + static const int KEY_RING = 256; uint16_t _keys[KEY_RING]; volatile uint8_t _khead = 0, _ktail = 0; volatile bool _kb_focused = false, _kb_dirty = false; // editable-field focus signal diff --git a/src/helpers/input/HeltecV4CapTouch.cpp b/src/helpers/input/HeltecV4CapTouch.cpp index 053b233..2e1bf94 100644 --- a/src/helpers/input/HeltecV4CapTouch.cpp +++ b/src/helpers/input/HeltecV4CapTouch.cpp @@ -84,12 +84,22 @@ void heltecV4CapTouchSetRotation(uint8_t lvgl_rot) { s_ui_rotation = lvgl_rot & // identity. (Verified on hardware — without the flip, taps landed at the // diagonally-opposite point.) static void rotateSwipeDelta(int dx, int dy, int* odx, int* ody) { +#if defined(HELTEC_LORA_V4_R8) + // R8: no +180 panel baseline (see applyPointRotation) -- plain rotations, V4 cases swapped. + switch (s_ui_rotation) { + case 1: *odx = dy; *ody = -dx; break; // ROT_90 (baseline-0 panel) + case 2: *odx = -dx; *ody = -dy; break; // 180 + case 3: *odx = -dy; *ody = dx; break; // ROT_270 + default: *odx = dx; *ody = dy; break; // portrait + } +#else switch (s_ui_rotation) { case 1: *odx = -dy; *ody = dx; break; // ROT_90 (panel-rot-2 baseline) case 2: *odx = -dx; *ody = -dy; break; // 180 case 3: *odx = dy; *ody = -dx; break; // ROT_270 default: *odx = dx; *ody = dy; break; // portrait } +#endif } // Hardware-rotation touch-point transform. When the panel is rotated in @@ -107,6 +117,20 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) { const int H = 320; // V4 panel portrait height const int px = *x, py = *y; int lx, ly; +#if defined(HELTEC_LORA_V4_R8) + // R8 (LovyanGFX panel, offset_rotation 0, portrait = setRotation(0)): the panel has NO +180 + // baseline, so the plain 90/270 maps apply -- exactly the V4's two landscape cases swapped. + // The V4-tuned maps below landed every landscape touch point-mirrored on this board (the + // stuck-in-landscape report). TESTER-VERIFY: confirmed geometry, unconfirmed sign -- if + // landscape touch is still mirrored, swap case 1 and case 3 back. Boot forces portrait on + // this board (UITask), so a wrong guess costs a reboot, not a stuck device. + switch (s_point_rotation) { + case 1: lx = py; ly = (W - 1) - px; break; // ROT_90 -> 320x240 (plain 90) + case 2: lx = (W - 1) - px; ly = (H - 1) - py; break; // 180 + case 3: lx = (H - 1) - py; ly = px; break; // ROT_270 -> 320x240 (plain 270) + default: lx = px; ly = py; break; + } +#else // Same +180 baseline as rotateSwipeDelta: ROT_90 uses the 270-degree map. switch (s_point_rotation) { case 1: lx = (H - 1) - py; ly = px; break; // ROT_90 -> 320x240 @@ -114,6 +138,7 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) { case 3: lx = py; ly = (W - 1) - px; break; // ROT_270 -> 320x240 default: lx = px; ly = py; break; } +#endif if (lx < 0) lx = 0; if (ly < 0) ly = 0; *x = (uint16_t)lx; diff --git a/src/main.cpp b/src/main.cpp index 6b481de..b70a7d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -48,7 +48,7 @@ static uint32_t _atoi(const char* sp) { DataStore store(LittleFS, rtc_clock); #elif defined(ESP32) #include - #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) + #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) #include #include #ifndef PIN_SD_CS @@ -196,7 +196,7 @@ void halt() { #include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56) -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) // ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ---- // Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED // (the flag never survived a reboot), so their identity/prefs/contacts kept living @@ -215,6 +215,8 @@ bool meshcomodMigrateSpiffsToSd(bool force) { SD.mkdir("/meshcomod/identity"); SD.mkdir("/meshcomod/bl"); SD.mkdir("/meshcomod/lock"); + SD.mkdir("/meshcomod/msgs"); // chat segments: SPIFFS names them flat ("/msgs/seg_*.bin"), + // but the FAT card needs the real parent dir or every copy fails bool identity_ok = false; int copied = 0, failed = 0; static uint8_t buf[4096]; @@ -464,10 +466,12 @@ void setup() { // failure falls back to SPIFFS so the device always boots. bool spiffs_ok = SPIFFS.begin(false); // try first WITHOUT auto-format bool sd_storage = false; -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) { #if defined(HELTEC_LORA_V4_R8) extern SPIClass* heltecV4R8SharedSPI(); // FSPI, shared with the TFT (CS=3) + #elif defined(TLORA_PAGER) + extern SPIClass* tloraPagerSharedSPI(); // the display/radio's shared SPIClass (#193) #else extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus #endif @@ -495,6 +499,8 @@ void setup() { #if defined(HELTEC_LORA_V4_R8) SPIClass* _spi = heltecV4R8SharedSPI(); + #elif defined(TLORA_PAGER) + SPIClass* _spi = tloraPagerSharedSPI(); #else SPIClass* _spi = tdeckSharedSPI(); #endif @@ -517,11 +523,34 @@ void setup() { { 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 }, }; int tries = want_full_sd ? 7 : 3; + uint32_t mounted_hz = 0; for (int a = 0; a < tries && !sd_mounted; ++a) { SD.end(); delay(kBootMount[a].settle_ms); - if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 3) && SD.cardType() != CARD_NONE) + if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { sd_mounted = true; + mounted_hz = kBootMount[a].hz; + } + } + // RENEGOTIATE UPWARD after a slow-rung success (GH #194). Standard SD bring-up is + // "initialise at a conservative clock, then raise it" — but SD.begin's clock is the + // operating clock for the whole session, so a card that only WOKE at 400 kHz then ran + // its entire life at 400 kHz (~25 KB/s). With the card as the primary store that was a + // ~3-minute boot (contacts + history at modem speed), runtime f_getfree timeouts (the + // file manager showing an inserted card as 0 KB/empty), and wedged backups. An + // initialised card almost always sustains 4 MHz even when its power-up handshake needed + // 400 kHz; if the fast re-begin fails, fall back to the clock that just worked. + if (sd_mounted && mounted_hz < 4000000) { + SD.end(); + delay(60); + if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE) { + Serial.printf("[BOOT] SD renegotiated %lu -> 4000000 Hz\n", (unsigned long)mounted_hz); + } else { + SD.end(); + delay(120); + sd_mounted = SD.begin(PIN_SD_CS, *_spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + if (sd_mounted) Serial.printf("[BOOT] SD stays at %lu Hz (4 MHz renegotiation failed)\n", (unsigned long)mounted_hz); + } } } if (sd_mounted) { @@ -588,7 +617,7 @@ void setup() { // Route touch settings + Wi-Fi creds to the active filesystem (SD when that's // the data store, else SPIFFS) instead of NVS. Old NVS values still load and // migrate on their next save, so this is a transparent in-place upgrade. - #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) + #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS, sd_storage ? "/meshcomod" : "/prefs"); #else diff --git a/src/ui-touch/AppPage.h b/src/ui-touch/AppPage.h new file mode 100644 index 0000000..d95f253 --- /dev/null +++ b/src/ui-touch/AppPage.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include + +// Shared chrome for full-screen "app pages" — Spectrum, RF Monitor, Discover, Airtime, +// Snake, VNC, Remote, Reader. Every one of them must look and behave identically: +// +// * the GLOBAL status bar goes tall and paints "< "; a tap on it closes the page +// (on both CLICKED and PRESSED, so the cap-touch swipe detector dropping a lone +// CLICKED can't trap the user on a touch-only board) +// * the page root covers everything BELOW that bar, opaque and clickable, so the tab +// bar and whatever was on screen underneath cannot be touched through the page +// * the root is deleted ASYNCHRONOUSLY, never synchronously from inside an event +// callback on one of its own children +// +// These exist because the two self-contained modules (ChannelUtil, SnakeGame) hand-rolled +// the chrome against a hardcoded `kTopBar = 22`. STATUSBAR_H is a runtime value — SC(22) +// once the UI scale is above 100%, and SB_TOP_PAD + SB_ROW*2 on the T-Display P4's +// two-row bar — so on those boards the pages were positioned too high, which put their +// own title and close button UNDERNEATH the real status bar with no reachable exit. Going +// through here instead means a page can never disagree with the bar about its own height. + +/** y of the first usable content row: immediately under the (possibly tall) status bar. */ +lv_coord_t appPageContentTop(); + +/** Usable content height below the status bar. */ +lv_coord_t appPageContentH(); + +/** + * Create a page root: full width, from just under the status bar to the bottom of the + * screen, on lv_layer_top, opaque `bg_color`, non-scrollable and CLICKABLE so no press + * reaches the UI underneath. + */ +lv_obj_t* appPageCreateRoot(uint32_t bg_color); + +/** Take the status bar over: tall, "< title", tap anywhere on it calls close_fn. */ +void appPageBegin(const char* title, void (*close_fn)()); + +/** + * Hand the status bar back — but only if `close_fn` is still the installed hook, so a + * page closing after another one already opened cannot steal the new page's chrome. + * Safe to call more than once (idempotent). + */ +void appPageEnd(void (*close_fn)()); + +/** Queue `root` for deletion after the current event finishes. Null-safe. */ +void appPageDeleteRootAsync(lv_obj_t* root); diff --git a/src/ui-touch/ChannelUtil.cpp b/src/ui-touch/ChannelUtil.cpp new file mode 100644 index 0000000..abae218 --- /dev/null +++ b/src/ui-touch/ChannelUtil.cpp @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ChannelUtil.h" +#include "AppPage.h" // shared app-page chrome: root, tall "< Airtime" bar, async teardown +#include "i18n.h" + +#include <Arduino.h> // millis() +#include <stdio.h> // snprintf +#include "MyMesh.h" // extern MyMesh& the_mesh; (+ pulls target.h -> extern radio_driver) + +// The globals the rest of the UI already talks to. `the_mesh` derives from +// mesh::Dispatcher (airtime + packet-count accumulators) and `radio_driver` is +// the RadioLib wrapper (live RSSI, noise floor, RX event/error/drop counts). +extern MyMesh& the_mesh; +// radio_driver is declared `extern WRAPPER_CLASS radio_driver;` in the board's +// target.h, reached transitively through MyMesh.h above. + +// The page geometry + the tall "< Airtime" bar come from AppPage (shared with Spectrum +// and every other tool page). This module used to mirror the bar height with a local +// `kTopBar = 22`, which is wrong on any board where STATUSBAR_H isn't 22 — the P4's +// two-row bar, or any UI scale above 100% — and left this page's own title and X +// underneath the real bar with no way out. +static constexpr uint32_t kTickMs = 1000; // 1 Hz sample +static constexpr int kBusyPct = 40; // congestion threshold line / verdict pivot + +// Palette (hex literals so the module is self-contained). +static constexpr uint32_t kColBg = 0x0E1216; +static constexpr uint32_t kColText = 0xE6EAEE; +static constexpr uint32_t kColDim = 0x8A929A; +static constexpr uint32_t kColGood = 0x6FCF6F; // green (idle / not congested) +static constexpr uint32_t kColWarn = 0xE0B84C; // amber (moderate) +static constexpr uint32_t kColBusy = 0xE0584C; // red (busy / congested) +static constexpr uint32_t kColUtil = 0x35C9C9; // chart util series (cyan) +static constexpr uint32_t kColThresh = 0x5A6166; // chart threshold line (grey) + +ChannelUtil* ChannelUtil::s_active = nullptr; + +bool ChannelUtil::isOpen() { return s_active != nullptr && s_active->root_ != nullptr; } + +void ChannelUtil::launch() { + if (s_active) return; + ChannelUtil* c = new ChannelUtil(); + s_active = c; + if (!c->open()) { s_active = nullptr; delete c; } +} + +void ChannelUtil::destroyAsync(void* p) { delete static_cast<ChannelUtil*>(p); } + +void ChannelUtil::dismiss() { + // THE single close path — the status bar's back hook, the backdrop tap and any future + // caller all land here, so teardown happens exactly once and in one order. + // + // Both halves are deferred on purpose. close() queues the root with lv_obj_del_async, + // and the instance itself is freed from an lv_async_call: dismiss() runs from inside an + // LVGL event callback, and the old code deleted both the root AND `this` synchronously + // from a child button's own handler — freeing the object LVGL was still dispatching to. + // Async calls run FIFO after the event completes, so the root dies first, then us. + if (!s_active) return; + ChannelUtil* c = s_active; + s_active = nullptr; // isOpen() reads false from here on + c->close(); + lv_async_call(destroyAsync, c); +} + +static uint32_t utilColor(int pct) { + if (pct >= kBusyPct) return kColBusy; + if (pct >= kBusyPct / 2) return kColWarn; + return kColGood; +} + +void ChannelUtil::snapshotBaseline() { + base_ms_ = millis(); + base_rx_air_ = the_mesh.getReceiveAirTime(); + base_tx_air_ = the_mesh.getTotalAirTime(); + base_rx_pkts_ = radio_driver.getPacketsRecv(); + base_rx_err_ = radio_driver.getPacketsRecvErrors(); + base_rx_evt_ = radio_driver.getRxEvents(); + base_rx_drop_ = radio_driver.getRxQueueDrops(); + have_prev_ = false; // windowed numbers restart too +} + +void ChannelUtil::tick() { + if (!root_ || !chart_) return; + + const uint32_t now = millis(); + const unsigned long rx_air = the_mesh.getReceiveAirTime(); + const unsigned long tx_air = the_mesh.getTotalAirTime(); + const uint32_t rx_pk = radio_driver.getPacketsRecv(); + const uint32_t rx_err = radio_driver.getPacketsRecvErrors(); + const uint32_t rx_evt = radio_driver.getRxEvents(); + const uint32_t rx_drp = radio_driver.getRxQueueDrops(); + + // --- Windowed (instantaneous, last tick) --------------------------------- + int inst_util = 0, inst_rx = 0, inst_tx = 0, rate = 0; + if (have_prev_ && now > prev_ms_) { + const uint32_t dt = now - prev_ms_; // ms of wall clock + const uint32_t d_rx = (uint32_t)(rx_air - prev_rx_air_); + const uint32_t d_tx = (uint32_t)(tx_air - prev_tx_air_); + inst_rx = (int)((uint64_t)d_rx * 100 / dt); + inst_tx = (int)((uint64_t)d_tx * 100 / dt); + inst_util = inst_rx + inst_tx; + if (inst_util > 100) inst_util = 100; + if (inst_rx > 100) inst_rx = 100; + if (inst_tx > 100) inst_tx = 100; + rate = (int)((uint64_t)(rx_pk - prev_rx_pkts_) * 60000 / dt); + } + prev_ms_ = now; prev_rx_air_ = rx_air; prev_tx_air_ = tx_air; + prev_rx_pkts_ = rx_pk; have_prev_ = true; + + // --- Session (since open / last Reset) ----------------------------------- + const uint32_t sess_ms = now - base_ms_; + int avg_util = 0; + if (sess_ms > 0) { + const uint64_t busy = (uint64_t)(rx_air - base_rx_air_) + (uint64_t)(tx_air - base_tx_air_); + avg_util = (int)(busy * 100 / sess_ms); + if (avg_util > 100) avg_util = 100; + } + const uint32_t sess_rx = rx_pk - base_rx_pkts_; + const uint32_t sess_err = rx_err - base_rx_err_; + const uint32_t sess_drop = rx_drp - base_rx_drop_; + // Radio heard it (RX-done ISR) but we never delivered it: events - recv - err. + // Can dip transiently negative by the 1-2 packets currently queued/unread. + long late = (long)(rx_evt - base_rx_evt_) - (long)sess_rx - (long)sess_err; + if (late < 0) late = 0; + const uint32_t sess_lost = (uint32_t)late + sess_drop; + + // --- Chart --------------------------------------------------------------- + lv_chart_set_next_value(chart_, ser_util_, (lv_coord_t)inst_util); + + // --- Headline ------------------------------------------------------------ + lv_obj_set_style_text_color(headline_, lv_color_hex(utilColor(inst_util)), LV_PART_MAIN); + lv_label_set_text_fmt(headline_, TR("CHANNEL %d%% BUSY"), inst_util); + + // --- Signal + preset ----------------------------------------------------- + const int now_rssi = (int)radio_driver.getCurrentRSSI(); + const int nf = radio_driver.getNoiseFloor(); + NodePrefs* np = the_mesh.getNodePrefs(); + const int sf = np ? (int)np->sf : 0; + const int bw_khz = np ? (int)np->bw : 0; + const int cr = np ? (int)np->cr : 0; + + const uint32_t h = sess_ms / 3600000UL; + const uint32_t m = (sess_ms / 60000UL) % 60; + const uint32_t s = (sess_ms / 1000UL) % 60; + + // Metrics block (recolour markup: #RRGGBB text#). + const uint32_t lossCol = sess_lost ? kColBusy : kColGood; + char b[420]; + snprintf(b, sizeof b, + "Now #%06X %d%%# (RX %d / TX %d) avg #%06X %d%%#\n" + "RX #%06X %d/min# %lu pkt sig %d / nf %d\n" + "Lost #%06X %lu# (crc %lu late %ld qf %lu)\n" + "SF%d BW%d CR%d run %02lu:%02lu:%02lu", + (unsigned)utilColor(inst_util), inst_util, inst_rx, inst_tx, + (unsigned)utilColor(avg_util), avg_util, + (unsigned)kColUtil, rate, (unsigned long)sess_rx, now_rssi, nf, + (unsigned)lossCol, (unsigned long)sess_lost, + (unsigned long)sess_err, late, (unsigned long)sess_drop, + sf, bw_khz, cr, + (unsigned long)h, (unsigned long)m, (unsigned long)s); + lv_label_set_text(metrics_, b); + + // --- Verdict ------------------------------------------------------------- + // Two independent axes: is the air busy, and are we losing packets. Report the + // honest combination so a tester can copy a real conclusion, not a guess. + const bool busy = avg_util >= kBusyPct; + const bool losing = sess_lost > 0; + char v[200]; uint32_t vc; + if (!busy && !losing) { + vc = kColGood; + snprintf(v, sizeof v, TR("Air only %d%% busy, 0 lost - congestion unlikely."), avg_util); + } else if (busy && losing) { + vc = kColBusy; + snprintf(v, sizeof v, TR("Air %d%% busy and %lu lost - congestion is plausible."), avg_util, (unsigned long)sess_lost); + } else if (!busy && losing) { + vc = kColWarn; + snprintf(v, sizeof v, TR("Air only %d%% but %lu lost - not congestion (weak signal or stall?)."), avg_util, (unsigned long)sess_lost); + } else { + vc = kColWarn; + snprintf(v, sizeof v, TR("Air %d%% busy but 0 lost - handling the load fine."), avg_util); + } + lv_obj_set_style_text_color(verdict_, lv_color_hex(vc), LV_PART_MAIN); + lv_label_set_text(verdict_, v); + + // Re-anchor the verdict under the now-4-line metrics block (the placeholder it + // was aligned to at open() was a single line). Layout must be current first. + lv_obj_update_layout(metrics_); + lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); +} + +bool ChannelUtil::open() { + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + const bool narrow = sw < 280; + + // Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing + // under it can be touched, sized/positioned from the LIVE status-bar height, and the + // global bar carries "< Airtime" as the one and only way back. + root_ = appPageCreateRoot(kColBg); + lv_obj_set_style_pad_all(root_, 6, LV_PART_MAIN); + lv_obj_add_event_cb(root_, backdropCb, LV_EVENT_CLICKED, this); +// The bar title is stored by POINTER for as long as the page is up, so it must own its +// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled +// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string +// later can't turn the status-bar title into a dangling read. + static char s_bar_title[24]; + snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Airtime")); + appPageBegin(s_bar_title, &ChannelUtil::dismiss); + + // No in-page title or X any more — the tall bar provides both. That chrome was the bug: + // it was laid out against a 22 px bar assumption and disappeared under the real one. + + // Reset button, top-right of the content area. + lv_obj_t* rst = lv_btn_create(root_); + lv_obj_set_size(rst, 30, 24); + lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_add_event_cb(rst, resetCb, LV_EVENT_CLICKED, this); + lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_center(rl); + + // Headline utilization %. + headline_ = lv_label_create(root_); + lv_obj_set_style_text_color(headline_, lv_color_hex(kColText), LV_PART_MAIN); + lv_label_set_text(headline_, TR("CHANNEL --% BUSY")); + lv_obj_align(headline_, LV_ALIGN_TOP_MID, 0, narrow ? 30 : 26); + + // Rolling utilization chart. + chart_ = lv_chart_create(root_); + const int chart_w = sw - 12; + const int chart_h = narrow ? 46 : 50; + const int chart_y = narrow ? 50 : 46; + lv_obj_set_size(chart_, chart_w, chart_h); + lv_obj_align(chart_, LV_ALIGN_TOP_MID, 0, chart_y); + lv_chart_set_type(chart_, LV_CHART_TYPE_LINE); + lv_chart_set_point_count(chart_, kPoints); + lv_chart_set_range(chart_, LV_CHART_AXIS_PRIMARY_Y, 0, 100); + lv_chart_set_update_mode(chart_, LV_CHART_UPDATE_MODE_SHIFT); + lv_obj_set_style_size(chart_, 0, LV_PART_INDICATOR); // no point dots + lv_obj_set_style_bg_color(chart_, lv_color_hex(0x11161B), LV_PART_MAIN); + lv_obj_set_style_border_width(chart_, 0, LV_PART_MAIN); + ser_thresh_ = lv_chart_add_series(chart_, lv_color_hex(kColThresh), LV_CHART_AXIS_PRIMARY_Y); + ser_util_ = lv_chart_add_series(chart_, lv_color_hex(kColUtil), LV_CHART_AXIS_PRIMARY_Y); + lv_chart_set_all_value(chart_, ser_thresh_, kBusyPct); // flat congestion reference + lv_chart_set_all_value(chart_, ser_util_, 0); + + // Metrics block (recolour markup). + metrics_ = lv_label_create(root_); + lv_label_set_recolor(metrics_, true); + lv_obj_set_style_text_color(metrics_, lv_color_hex(kColText), LV_PART_MAIN); + lv_obj_set_width(metrics_, sw - 12); + lv_label_set_long_mode(metrics_, LV_LABEL_LONG_WRAP); + lv_label_set_text(metrics_, TR("Sampling\xE2\x80\xA6")); + lv_obj_align(metrics_, LV_ALIGN_TOP_LEFT, 2, chart_y + chart_h + 6); + + // Verdict line, anchored directly under the (fixed 4-line) metrics block so it + // can never overlap it, whatever the board height. + verdict_ = lv_label_create(root_); + lv_label_set_recolor(verdict_, false); + lv_obj_set_style_text_color(verdict_, lv_color_hex(kColDim), LV_PART_MAIN); + lv_obj_set_width(verdict_, sw - 12); + lv_label_set_long_mode(verdict_, LV_LABEL_LONG_WRAP); + lv_label_set_text(verdict_, TR("Measuring\xE2\x80\xA6 give it a few seconds of traffic.")); + lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 8); + + snapshotBaseline(); + timer_ = lv_timer_create(timerCb, kTickMs, this); + lv_timer_ready(timer_); // first sample right away (seeds prev_, shows preset) + return true; +} + +void ChannelUtil::close() { + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop sampling before the UI goes + appPageEnd(&ChannelUtil::dismiss); // hand the status bar back + appPageDeleteRootAsync(root_); // never a sync del from a callback + root_ = nullptr; + headline_ = nullptr; chart_ = nullptr; ser_util_ = nullptr; ser_thresh_ = nullptr; + metrics_ = nullptr; verdict_ = nullptr; +} + +// ---- LVGL C-callback trampolines (user_data = the instance) ---- +void ChannelUtil::timerCb(lv_timer_t* t) { + auto* self = static_cast<ChannelUtil*>(t->user_data); + if (self) self->tick(); +} +void ChannelUtil::resetCb(lv_event_t* e) { + auto* self = static_cast<ChannelUtil*>(lv_event_get_user_data(e)); + if (!self) return; + self->snapshotBaseline(); + if (self->chart_ && self->ser_util_) lv_chart_set_all_value(self->chart_, self->ser_util_, 0); +} +// Backdrop tap = back, exactly as on the Spectrum page. Only a press on the root itself +// closes; taps on children (Reset) are theirs. wait_release swallows the rest of the +// gesture so the press can't land on whatever the page was covering. +void ChannelUtil::backdropCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; + lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); + dismiss(); +} diff --git a/src/ui-touch/ChannelUtil.h b/src/ui-touch/ChannelUtil.h new file mode 100644 index 0000000..1644d27 --- /dev/null +++ b/src/ui-touch/ChannelUtil.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include <lvgl.h> + +// Self-contained "Channel Utilization" tool launched from the Apps drawer. +// +// Purpose: give field testers a single screen that proves or DISPROVES the +// "the mesh is congested" theory for lost messages. It separates two distinct +// questions that get conflated: +// +// 1. Is the SPECTRUM actually busy? -> channel utilization % = +// (RX airtime + TX airtime) / wall-clock time. This is the real +// "is the air full" number, sampled live and averaged over the session. +// +// 2. Are packets actually being LOST at the radio? -> CRC errors +// (collisions), packets the radio heard but the loop read too late +// (getRxEvents - packetsRecv - recvErrors), and RX-queue drops. +// +// A mesh can be busy without losing anything, and can lose packets while the +// air is nearly idle (weak signal, SW stalls). Showing both side by side lets a +// tester report a concrete verdict instead of a hunch. +// +// Owns a full-screen overlay on lv_layer_top (mirrors SnakeGame): a title row +// with a Reset + close button, a big headline utilization %, a rolling chart of +// utilization over time, a metrics block, and a plain-language verdict line. A +// 1 Hz lv_timer samples the counters. One instance at a time. Decoupled from +// UITask internals — reaches the radio via the file-scope `the_mesh` / +// `radio_driver` globals the rest of the UI already uses. +class ChannelUtil { +public: + static void launch(); // open (no-op if already open) + static bool isOpen(); // UITask: is this page up? + // THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall + // "< Airtime" bar closes the page; also used by the backdrop tap. Signature is a plain + // void() on purpose — that is what appPageBegin takes. Safe from an event callback: + // both the root and the instance are torn down asynchronously. + static void dismiss(); + +private: + static constexpr int kPoints = 60; // chart history (~60 s at 1 Hz) + + lv_obj_t* root_ = nullptr; // full-screen overlay (owns all below) + lv_obj_t* headline_ = nullptr; // big "12%" + BUSY/idle word + lv_obj_t* chart_ = nullptr; // utilization % over time + lv_chart_series_t* ser_util_ = nullptr; // total channel util (RX+TX) + lv_chart_series_t* ser_thresh_ = nullptr; // flat congestion-threshold line + lv_obj_t* metrics_ = nullptr; // recolour label: rate / loss / signal / preset + lv_obj_t* verdict_ = nullptr; // plain-language conclusion + lv_timer_t* timer_ = nullptr; + + // Baselines snapshotted at open() (and re-snapshotted by Reset) so the + // session average and loss totals are measured from a known start, without + // touching the core's lifetime counters (other UI reads them too). + uint32_t base_ms_ = 0; + unsigned long base_rx_air_ = 0; // ms + unsigned long base_tx_air_ = 0; // ms + uint32_t base_rx_pkts_ = 0; + uint32_t base_rx_err_ = 0; + uint32_t base_rx_evt_ = 0; + uint32_t base_rx_drop_ = 0; + + // Previous-tick snapshot for the windowed (instantaneous) numbers. + uint32_t prev_ms_ = 0; + unsigned long prev_rx_air_ = 0; + unsigned long prev_tx_air_ = 0; + uint32_t prev_rx_pkts_ = 0; + bool have_prev_ = false; + + bool open(); // build UI; false on failure + void close(); + void snapshotBaseline(); + void tick(); // 1 Hz sample -> chart + labels + + static ChannelUtil* s_active; // the one live instance (or nullptr) + static void timerCb(lv_timer_t* t); + static void backdropCb(lv_event_t* e); // tap the page background = back + static void resetCb(lv_event_t* e); + static void destroyAsync(void* p); // lv_async_call target: delete the instance +}; diff --git a/src/ui-touch/KeyboardLayouts.cpp b/src/ui-touch/KeyboardLayouts.cpp index ef34685..3a39878 100644 --- a/src/ui-touch/KeyboardLayouts.cpp +++ b/src/ui-touch/KeyboardLayouts.cpp @@ -370,6 +370,39 @@ static const lv_btnmatrix_ctrl_t kb_it_upper_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; +/* Romanian keeps QWERTY and promotes the three letters the accent popup can't + * offer today straight onto the deck: ă on the home row (like Spanish's ñ) and + * ş / ţ on the bottom row (like Italian's è / ì). â and î stay reachable from + * the a / i accent popups. Cedilla forms (ş/ţ) match the bundled font coverage; + * switch to comma-below (ș/ț) if the extras_* fonts ever gain U+0218-021B. */ +static const char* const kb_ro_lower[] = { + "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", + "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "ă", LV_SYMBOL_NEW_LINE, "\n", + "ş", "ţ", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", + LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" +}; + +static const lv_btnmatrix_ctrl_t kb_ro_lower_ctrl[] = { + LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7, + LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7, + EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), + LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 +}; + +static const char* const kb_ro_upper[] = { + "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", + "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "Ă", LV_SYMBOL_NEW_LINE, "\n", + "Ş", "Ţ", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", + LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" +}; + +static const lv_btnmatrix_ctrl_t kb_ro_upper_ctrl[] = { + LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7, + LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7, + EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), + LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 +}; + struct OsKeyboardLayout { KeyboardLayoutId id; const char* name; @@ -416,6 +449,9 @@ static const OsKeyboardLayout k_os_layouts[] = { { KeyboardLayoutId::IT, "IT", kb_it_lower, kb_it_lower_ctrl, kb_it_upper, kb_it_upper_ctrl }, + { KeyboardLayoutId::RO, "RO", + kb_ro_lower, kb_ro_lower_ctrl, + kb_ro_upper, kb_ro_upper_ctrl }, }; /* ================================================================ @@ -440,6 +476,7 @@ static const HwKeyboardLayout k_hw_layouts[] = { { KeyboardLayoutId::DE, "DE" }, { KeyboardLayoutId::ES, "ES" }, { KeyboardLayoutId::IT, "IT" }, + { KeyboardLayoutId::RO, "RO" }, }; /* Bulgarian phonetic mapping for T-Deck. @@ -671,6 +708,21 @@ static const char* hw_it_upper[26] = { static const char* hw_it_digits[10] = { nullptr, "à", "è", "ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }; static const char* hw_it_digits_shift[10] = { nullptr, "À", "È", "Ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }; +/* Romanian keeps the standard alpha matrix; the number row surfaces the five + * special letters on compact hardware (same trick as Italian's accented vowels). */ +static const char* hw_ro_lower[26] = { + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z" +}; +static const char* hw_ro_upper[26] = { + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z" +}; +static const char* hw_ro_digits[10] = { nullptr, "ă", "â", "î", "ş", "ţ", nullptr, nullptr, nullptr, nullptr }; +static const char* hw_ro_digits_shift[10] = { nullptr, "Ă", "Â", "Î", "Ş", "Ţ", nullptr, nullptr, nullptr, nullptr }; + /* ================================================================ * Runtime state * ================================================================ */ @@ -755,6 +807,7 @@ static const HwPhoneticMap k_hw_maps[KEYBOARD_LAYOUT_COUNT] = { /* DE */ { hw_de_lower, hw_de_upper, hw_de_digits, hw_de_digits_shift }, /* ES */ { hw_es_lower, hw_es_upper, hw_es_digits, hw_es_digits_shift }, /* IT */ { hw_it_lower, hw_it_upper, hw_it_digits, hw_it_digits_shift }, + /* RO */ { hw_ro_lower, hw_ro_upper, hw_ro_digits, hw_ro_digits_shift }, }; const char* keyboardLayoutMapHwKey(KeyboardLayoutId id, int key, bool shifted) { diff --git a/src/ui-touch/KeyboardLayouts.h b/src/ui-touch/KeyboardLayouts.h index 38c9b5f..36215a7 100644 --- a/src/ui-touch/KeyboardLayouts.h +++ b/src/ui-touch/KeyboardLayouts.h @@ -24,6 +24,7 @@ enum class KeyboardLayoutId : uint8_t { DE = 9, // German (QWERTZ) ES = 10, // Spanish (QWERTY + accent popups) IT = 11, // Italian (QWERTY + accent popups) + RO = 12, // Romanian (QWERTY, ă/ş/ţ on the deck; â/î via accent popups) Count }; diff --git a/src/ui-touch/SnakeGame.cpp b/src/ui-touch/SnakeGame.cpp index e8d16d1..a4a73ce 100644 --- a/src/ui-touch/SnakeGame.cpp +++ b/src/ui-touch/SnakeGame.cpp @@ -1,14 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "SnakeGame.h" +#include "AppPage.h" // shared app-page chrome: root, tall "< Snake" bar, async teardown #include "i18n.h" #include <Arduino.h> // random() #include <string.h> // memmove +#include <stdio.h> // snprintf (bar title) #include <LvglPsramAlloc.h> // PSRAM-preferred buffer for the canvas -// Mirrors the app's status-bar height; the overlay starts below it so the bar -// stays visible. Kept local so this module needs nothing from UITask. -static constexpr lv_coord_t kTopBar = 22; +// Page geometry + the tall "< Snake" bar come from AppPage (shared with Spectrum and the +// other tool pages). This used to mirror the bar height with a local `kTopBar = 22`, which +// is wrong wherever STATUSBAR_H isn't 22 (the P4's two-row bar, any UI scale above 100%) +// and buried the game's own X under the real bar with no reachable exit. static constexpr uint32_t kStepMs = 300; // tick period (a touch slower than before) // Palette (hex literals so the module is self-contained). @@ -37,16 +40,30 @@ void SnakeGame::launch() { if (!g->open()) { s_active = nullptr; delete g; } } +void SnakeGame::destroyAsync(void* p) { delete static_cast<SnakeGame*>(p); } + void SnakeGame::dismiss() { - // Close from outside the game (a status-bar tap). The in-game X can sit UNDER a taller status - // bar on big-screen boards (the P4's bar is set taller at boot + raised over the game), leaving - // no reachable exit — this is the guaranteed way out. Mirrors closeCb's teardown. Safe to del - // synchronously here: the triggering event target is the status bar, not a Snake object. + // THE single close path — the tall bar's back hook (installed via appPageBegin) and any + // other caller land here, so teardown happens once, in one order. + // + // Both halves are deferred deliberately. close() queues the root with lv_obj_del_async and + // the instance is freed from an lv_async_call: this can run from inside an LVGL event + // callback, and the old code deleted the root AND `this` synchronously from a child + // button's own handler — freeing what LVGL was still dispatching to. Async calls run FIFO, + // so the root (and with it the canvas) dies before ~SnakeGame() frees the canvas buffer. if (!s_active) return; SnakeGame* g = s_active; s_active = nullptr; g->close(); - delete g; + lv_async_call(destroyAsync, g); +} + +// The canvas pixel buffer must outlive the canvas object. close() only QUEUES the root for +// deletion, so freeing buf_ there would leave a live canvas pointing at freed PSRAM for one +// more frame. It is freed here instead: the destructor runs from the lv_async_call queued +// after lv_obj_del_async, by which point the canvas is really gone. +SnakeGame::~SnakeGame() { + if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } } void SnakeGame::placeFood() { @@ -138,20 +155,24 @@ void SnakeGame::togglePause() { bool SnakeGame::open() { const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); - root_ = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(root_); - lv_obj_set_size(root_, sw, sh - kTopBar); - lv_obj_set_pos(root_, 0, kTopBar); - lv_obj_set_style_bg_color(root_, lv_color_hex(0x0E1216), LV_PART_MAIN); - lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE); + // Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing + // underneath can be touched, geometry from the LIVE status-bar height, and the global bar + // carries "< Snake" as the way back. NOTE: no backdrop-tap-to-close here (unlike Airtime) + // — a tap on the playfield restarts after a game over, so the bar is the only exit. + root_ = appPageCreateRoot(0x0E1216); lv_obj_add_event_cb(root_, gestureCb, LV_EVENT_GESTURE, this); lv_obj_add_event_cb(root_, tapCb, LV_EVENT_CLICKED, this); +// The bar title is stored by POINTER for as long as the page is up, so it must own its +// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled +// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string +// later can't turn the status-bar title into a dangling read. + static char s_bar_title[24]; + snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Snake")); + appPageBegin(s_bar_title, &SnakeGame::dismiss); - // Fill the unoccupied screen below a 24-px score/close row. Whole cells only. + // Fill the unoccupied screen below a 24-px score row. Whole cells only. const int avail_w = (int)sw; - const int avail_h = (int)(sh - kTopBar) - 24; + const int avail_h = (int)appPageContentH() - 24; cols_ = avail_w / kCellPx; if (cols_ > kMaxCols) cols_ = kMaxCols; if (cols_ < 6) cols_ = 6; rows_ = avail_h / kCellPx; if (rows_ > kMaxRows) rows_ = kMaxRows; if (rows_ < 6) rows_ = 6; const int pw = cols_ * kCellPx, ph = rows_ * kCellPx; @@ -165,16 +186,12 @@ bool SnakeGame::open() { lv_obj_set_style_text_color(score_, lv_color_hex(kColText), LV_PART_MAIN); lv_obj_align(score_, LV_ALIGN_TOP_LEFT, 6, 4); - lv_obj_t* x = lv_btn_create(root_); - lv_obj_set_size(x, 30, 24); - lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0); - lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this); - lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); - - // Pause/resume toggle, left of the X. Hidden until the game starts. + // No in-page X any more — the tall bar's "<" is the exit. That button was the bug: laid + // out against a 22 px bar assumption, it vanished under the real one on the P4. + // Pause/resume toggle, top-right. Hidden until the game starts. pause_btn_ = lv_btn_create(root_); lv_obj_set_size(pause_btn_, 30, 24); - lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -40, 0); + lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -4, 0); lv_obj_add_event_cb(pause_btn_, pauseCb, LV_EVENT_CLICKED, this); pause_lbl_ = lv_label_create(pause_btn_); lv_label_set_text(pause_lbl_, LV_SYMBOL_PAUSE); lv_obj_center(pause_lbl_); lv_obj_add_flag(pause_btn_, LV_OBJ_FLAG_HIDDEN); @@ -194,11 +211,13 @@ bool SnakeGame::open() { } void SnakeGame::close() { - if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } - if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes canvas + button children - if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } // free AFTER the canvas is gone + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop stepping before the UI goes + appPageEnd(&SnakeGame::dismiss); // hand the status bar back + appPageDeleteRootAsync(root_); // never a sync del from a callback + root_ = nullptr; canvas_ = nullptr; score_ = nullptr; start_btn_ = nullptr; pause_btn_ = nullptr; pause_lbl_ = nullptr; + // buf_ is freed in ~SnakeGame(), after the queued root delete has taken the canvas with it. } // ---- LVGL C-callback trampolines (user_data = the instance) ---- @@ -229,10 +248,3 @@ void SnakeGame::pauseCb(lv_event_t* e) { auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e)); if (self) self->togglePause(); } -void SnakeGame::closeCb(lv_event_t* e) { - auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e)); - if (!self) return; - self->close(); - if (s_active == self) s_active = nullptr; - delete self; -} diff --git a/src/ui-touch/SnakeGame.h b/src/ui-touch/SnakeGame.h index b096dbf..426ffe7 100644 --- a/src/ui-touch/SnakeGame.h +++ b/src/ui-touch/SnakeGame.h @@ -17,9 +17,14 @@ public: static void launch(); // open (no-op if already open) static bool isOpen(); // UITask: gate the trackball + tab bar static void steer(int dx, int dy); // UITask: trackball motion -> direction - static void dismiss(); // close from outside (status-bar tap) — guaranteed exit + // THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall + // "< Snake" bar closes the game. Plain void() on purpose — that is what appPageBegin + // takes. Safe from an event callback: root and instance are torn down asynchronously. + static void dismiss(); private: + ~SnakeGame(); // frees the canvas buffer, after the queued root delete released the canvas + // The playfield grid is sized to the screen at open() (cols_ x rows_), capped // by these maxima so the body arrays are fixed-size. static constexpr int kCellPx = 14; // px per cell @@ -62,7 +67,7 @@ private: static void timerCb(lv_timer_t* t); static void gestureCb(lv_event_t* e); static void tapCb(lv_event_t* e); - static void closeCb(lv_event_t* e); static void startCb(lv_event_t* e); static void pauseCb(lv_event_t* e); + static void destroyAsync(void* p); // lv_async_call target: delete the instance }; diff --git a/src/ui-touch/TouchSleep.cpp b/src/ui-touch/TouchSleep.cpp index 85102c1..1a571b2 100644 --- a/src/ui-touch/TouchSleep.cpp +++ b/src/ui-touch/TouchSleep.cpp @@ -47,7 +47,7 @@ void onTransition(TransitionCb cb) { g_transition = cb; } void setEnabled(bool on) { g_enabled = on; } bool enabled() { return g_enabled; } -void loopEnd(uint32_t /*now_ms*/) { +void loopEnd(uint32_t now_ms) { const bool pass = gatePasses(); if (!pass) { if (g_asleep_regime) { g_asleep_regime = false; emitTransition(false); } // sun — resumed activity @@ -55,10 +55,23 @@ void loopEnd(uint32_t /*now_ms*/) { } if (!g_asleep_regime) { g_asleep_regime = true; emitTransition(true); } // moon — parked - // Yield the CPU to the idle task for THROTTLE_MS (replaces esp_light_sleep_start — - // see the note above). Standard FreeRTOS call: cannot starve a watchdog or hang. + // Yield the CPU to the idle task, capped by the earliest retry/UI deadline. + // This is the wake timer for these builds: unlike manual esp_light_sleep_start, + // a timed FreeRTOS block remains watchdog-safe and resumes the loop on schedule. + uint32_t park_ms = THROTTLE_MS; + if (g_hooks.nextWakeForcingDueMs) { + const uint32_t wake_due_ms = g_hooks.nextWakeForcingDueMs(now_ms); + if (wake_due_ms < park_ms) park_ms = wake_due_ms; + } + if (park_ms == 0) { + g_last_reason = WakeReason::Timer; + return; + } + const uint64_t t0 = esp_timer_get_time(); - vTaskDelay(pdMS_TO_TICKS(THROTTLE_MS)); + TickType_t park_ticks = pdMS_TO_TICKS(park_ms); + if (park_ticks == 0) park_ticks = 1; + vTaskDelay(park_ticks); g_acc_idle_us += (uint64_t)(esp_timer_get_time() - t0); g_cycle_count++; g_last_reason = WakeReason::Timer; // a throttle is a timed yield diff --git a/src/ui-touch/TouchSleep.h b/src/ui-touch/TouchSleep.h index 1360718..f93622c 100644 --- a/src/ui-touch/TouchSleep.h +++ b/src/ui-touch/TouchSleep.h @@ -20,7 +20,7 @@ struct Hooks { bool (*bleOff)(); // BLE fully off bool (*onBattery)(); // running on battery (no USB / charge source) bool (*meshIdle)(); // radio not mid-RX AND send queue empty - // ms until the soonest wake-forcing deadline (advert / clock alarm), + // ms until the soonest wake-forcing deadline (retry / advert / clock alarm), // or UINT32_MAX when nothing must wake us: uint32_t (*nextWakeForcingDueMs)(uint32_t now_ms); uint32_t (*epochNow)(); // wall-clock epoch seconds (for the event log) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..1b393a5 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -18,6 +18,7 @@ #include <cstdio> #include <cstdlib> #include <cmath> +#include <cerrno> // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO) #if defined(ESP32) #include <time.h> #include <SPIFFS.h> @@ -105,6 +106,8 @@ #include <Utils.h> #include <LvglPsramAlloc.h> // PSRAM-preferred alloc helpers for the map tile cache #include "SnakeGame.h" // Apps → Snake (self-contained game module) +#include "ChannelUtil.h" // Apps → Airtime (self-contained channel-utilization tool) +#include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) #if defined(HAS_TOUCH_UI) #include <lvgl.h> @@ -224,6 +227,13 @@ constexpr uint16_t k_ui_history_min_version = 6; // v4/v5 used 96-char records constexpr const char* k_ui_threads_path = "/ui_threads_v1.bin"; constexpr const char* k_ui_msgs_path = "/ui_msgs_v1.bin"; constexpr const char* k_ui_msgs_tmp_path = "/ui_msgs_v1.bin.tmp"; +// Separate temp for the SYNCHRONOUS (shutdown/reboot/no-PSRAM) writer. The +// core-0 worker and the loop task can overlap when a stalled worker outlives +// uiHistWaitWorkerIdle's 9 s cap — two truncating opens of ONE tmp interleave +// into garbage, and whichever rename lands last installs a corrupt file that +// the next boot quarantines (total history loss). Distinct tmp names make the +// overlap last-rename-wins with each candidate internally consistent. +constexpr const char* k_ui_msgs_tmp2_path = "/ui_msgs_v1.bin.tm2"; constexpr uint32_t k_ui_threads_magic = 0x55495448; // "UITH" constexpr uint32_t k_ui_msgs_magic = 0x55494D53; // "UIMS" @@ -303,6 +313,50 @@ struct __attribute__((packed)) UiMsgFileHeader { uint16_t msg_rec_size; uint8_t _pad[2]; }; + +// ---- Segmented message store (generation 3) ------------------------------ +// The ring is persisted as fixed-capacity SEGMENT files under <data root>/msgs: +// seg_<first_seq>.bin, k_ui_seg_records records each. New messages APPEND one +// record to the newest ("active") segment — a ~240 B write instead of the old +// full-ring rewrite (515 KB+ at 2300 messages), which shrinks the hard-cut +// loss window, the SPI bus-collision window, and flash wear all at once. +// Deletes tombstone in RAM and mark the owning segment dirty; compaction +// rewrites JUST that segment from the RAM ring (disk holds exactly the ring's +// record set, so no read pass is ever needed). Corruption quarantines one +// segment instead of the whole history. On FAT backends /msgs is a real +// subdirectory (single child under /meshcomod — stays inside the factory +// wipe's 24-entry cap); on SPIFFS the same name is a legal FLAT file name +// (31-char limit: "/msgs/seg_4294967295.bin" = 24 chars), so the layout is +// identical everywhere and no mkdir is required there. +constexpr const char* k_ui_seg_dir = "/msgs"; +// Commit marker. Written (last) only once a migration has been fully verified, +// so its ABSENCE means "the segment set on disk is a partial migration" — the +// loader then wipes those segments and falls back to the still-intact +// old-format file. Without it, a power cut between chunk writes left a valid +// oldest-first PREFIX that shadowed the old file forever: the UI came up +// showing only the oldest few hundred messages, permanently. +constexpr const char* k_ui_seg_ok = "/msgs/store.ok"; +constexpr uint32_t k_ui_seg_magic = 0x55495347; // "UISG" +constexpr uint16_t k_ui_seg_version = 1; +constexpr int k_ui_seg_records = 256; // records per segment (~60 KB at v1 size) +constexpr int k_ui_seg_max = 24; // table cap: 5000/256 = 20 live segments + slack + +struct __attribute__((packed)) UiSegHeader { + uint32_t magic; // k_ui_seg_magic + uint16_t version; // k_ui_seg_version + uint16_t msg_rec_size; // sizeof(UiSegMsg) at write time — self-describing so + // future fields append at the END (same rule as v5+) + uint32_t first_seq; // seq of this segment's first-ever record (== filename key) + uint8_t _pad[4]; +}; + +// On-disk segment record: the v6 record layout + the monotonic seq appended +// (append-at-end evolution — a reader of either size copies min(rec_size) and +// zero-fills the rest, so seq reads 0 from a hypothetical older record). +struct __attribute__((packed)) UiSegMsg { + UiHistoryMsg m; + uint32_t seq; +}; } // namespace #endif @@ -498,8 +552,8 @@ static bool emojiImgfontPathCb(const lv_font_t* /*font*/, void* img_src, uint16_ static lv_font_t* s_emoji_font[3] = { nullptr, nullptr, nullptr }; // one per text size #endif -// UI scale (font-based, at NATIVE resolution so it stays crisp). 100 / 130 / 160 percent — set in -// initTouchFontFallbacks() from the saved pref. SC() scales a layout dimension to match. The bottom +// UI size (font-based, at native resolution so it stays crisp) is set in +// initTouchFontFallbacks() from the saved preset. SC() scales geometry where a board supports it. The bottom // tab bar is deliberately NOT scaled (g_font_tab stays 16 px + TABBAR_H is fixed) — by request it // stays the Normal size at every level. static int s_ui_fscale = 100; @@ -524,25 +578,45 @@ static inline lv_coord_t PCW(int px) { lv_coord_t cap = lv_disp_get_hor_res(nullptr) - SC(12); return (cap > 0 && w > cap) ? cap : w; } -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE static lv_font_t g_font_tab; // fixed 16 px tab-bar icon font (montserrat_16 + person glyph) #endif static void initTouchFontFallbacks() { -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // The Pager is wide but only 222 px tall. Grow the semantic text roles while + // leaving SC() at 100%; globally scaling every row/card made content + // unreachable. Layouts that need more room are bounded individually below. + s_ui_fscale = 100; + switch (touchPrefsGetUiScale()) { + case 1: + g_font_12 = lv_font_montserrat_16; + g_font_14 = lv_font_montserrat_18; + g_font_16 = lv_font_montserrat_20; + break; + case 2: + g_font_12 = lv_font_montserrat_18; + g_font_14 = lv_font_montserrat_20; + g_font_16 = lv_font_montserrat_24; + break; + default: + g_font_12 = lv_font_montserrat_12; + g_font_14 = lv_font_montserrat_14; + g_font_16 = lv_font_montserrat_16; + break; + } + g_font_tab = lv_font_montserrat_16; +#elif CAP_LARGE_SCREEN // Crisp "UI size": render bigger by swapping in larger built-in Montserrat fonts (NOT by // upscaling a low-res frame). g_font_12/14/16 are what the whole UI draws with, so this scales // every screen at once. The colour-emoji + non-Latin fallbacks stay their baked sizes (they don't // grow), which is fine for Latin text. g_font_tab is pinned to 16 px so the bottom bar never grows. -#if defined(HAS_TDISPLAY_P4) - // The P4 is a NARROW 284 px portrait: bigger fonts overflow the fixed-height/width chrome (the - // status bar, home grid, and list/settings rows all use unscaled dims), so 100% is the only size - // that fits. Ignore any Large/Huge pref (a unit can inherit one from adopted T-Deck prefs). The - // Tanmatsu is 800 px landscape and keeps the option. GH: P4 text overflow above 100%. - s_ui_fscale = 100; -#else + // UI scale from the saved pref (Normal/Large/Huge -> 100/140/170%). The P4 honours it like the + // Tanmatsu — its "UI size" dropdown is shown (CAP_LARGE_SCREEN), so pinning 100% here made that + // setting a dead no-op (reported: "changing text size doesn't work"). At Large/Huge some P4 chrome + // that still uses unscaled dims (status bar, home grid, list rows) can be tight; that is the lesser + // problem and a per-screen SC() follow-up, not a reason to disable scaling outright. switch (touchPrefsGetUiScale()) { case 1: s_ui_fscale = 140; break; case 2: s_ui_fscale = 170; break; default: s_ui_fscale = 100; break; } -#endif switch (s_ui_fscale) { case 140: g_font_12 = lv_font_montserrat_16; g_font_14 = lv_font_montserrat_20; g_font_16 = lv_font_montserrat_24; break; // Large ~1.4x case 170: g_font_12 = lv_font_montserrat_20; g_font_14 = lv_font_montserrat_24; g_font_16 = lv_font_montserrat_28; break; // Huge ~1.7x @@ -602,7 +676,7 @@ static void initTouchFontFallbacks() { s_cc_icons_font = cc_icons_16; s_cc_icons_font.fallback = g_font_16.fallback; g_font_16.fallback = &s_cc_icons_font; -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE // The fixed-size tab bar font needs the person glyph too (Contacts tab icon, U+F007) at 16 px. static lv_font_t s_tab_person; s_tab_person = person_font; s_tab_person.fallback = nullptr; g_font_tab.fallback = &s_tab_person; @@ -833,7 +907,12 @@ static bool wavOpen(const char* prefpath, File& f) { if (!prefpath || !prefpath[0]) return false; fs::FS* fsp = &SPIFFS; const char* fp = prefpath; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) // only the T-Deck/pager sound picker ever writes an "sd:"-prefixed pref - if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; fmSdTryMount(); } + // No fmSdTryMount() here: this runs on the throwaway notify task, and walking + // the SD.end()+SD.begin() mount ladder from a second task races the loop + // task's own SD use (the one documented single-task assumption). If the card + // isn't mounted the open below fails fast and the chime falls back; mounting + // is owned by boot adoption / sdHealthTick's reinsert watch / the FM paths. + if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; } #endif f = fsp->open(fp, FILE_READ); if (!f || f.isDirectory()) { if (f) f.close(); return false; } @@ -1242,6 +1321,46 @@ static void statusBarSetTall(bool tall) { // (updateGlobalStatusBar drives this every tick + refreshes the left zone; it must // NOT be called from here — it calls back into statusBarSetTall = recursion.) } + +// ---- AppPage: the shared full-screen app-page chrome (see AppPage.h) -------------- +// Thin wrappers over the machinery just above, exported so the self-contained app +// modules (ChannelUtil = Airtime, SnakeGame) build the same page as the in-file tool +// pages instead of hand-rolling it against a hardcoded bar height. +lv_coord_t appPageContentTop() { return STATUSBAR_H; } +lv_coord_t appPageContentH() { return (lv_coord_t)(lv_disp_get_ver_res(nullptr) - STATUSBAR_H); } + +lv_obj_t* appPageCreateRoot(uint32_t bg_color) { + lv_obj_t* root = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(root); + lv_obj_set_size(root, lv_disp_get_hor_res(nullptr), appPageContentH()); + lv_obj_set_pos(root, 0, appPageContentTop()); + lv_obj_set_style_bg_color(root, lv_color_hex(bg_color), LV_PART_MAIN); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); + // CLICKABLE is what makes the page modal: every press lands here instead of falling + // through to the tab bar / list underneath. + lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); + return root; +} + +void appPageBegin(const char* title, void (*close_fn)()) { + s_apppage_title = title; + s_apppage_close = close_fn; + statusBarSetTall(true); + updateGlobalStatusBar(); +} + +void appPageEnd(void (*close_fn)()) { + if (s_apppage_close != close_fn) return; // a different page owns the bar now — leave it + s_apppage_title = nullptr; + s_apppage_close = nullptr; + statusBarSetTall(false); + updateGlobalStatusBar(); +} + +void appPageDeleteRootAsync(lv_obj_t* root) { + if (root) lv_obj_del_async(root); +} static const char* tsBlockReason(); // fwd decl (defined near idle-sleep hooks) static const char* tsWakeReasonStr(touchSleep::WakeReason r); // fwd decl (defined near idle-sleep hooks) static void batteryTapCb(lv_event_t* e); // fwd decl (defined near the battery chart) — Settings "Battery" button reuses it @@ -1711,13 +1830,16 @@ static void purgeDiscovered() { for (int i = 0; i < DISCOVERED_MAX; ++i) s_discovered[i].used = false; } -// ---- Persist the discovered ring across reboots (NVS blob "disc") ---- +// ---- Persist the discovered ring across reboots --------------------------- +// High-churn discovery data has its own namespace so an advert burst never +// rewrites the critical touch settings snapshot. static const char* KDISC = "disc"; // chunk 0 (older firmware wrote ≤24 records here) +static const char* DISC_NS = "discovered"; +static const char* DISC_MARKER = "ver"; static bool s_disc_dirty = false; static unsigned long s_disc_save_at = 0; -// Compact on-disk record (84 B/entry). SdNvsPrefs's SD .kv backend (used under -// Launcher) silently DROPS any single value > 2048 B on load (sdLoad bails on -// the oversize record), so we cap each stored blob at DISC_PER_CHUNK records +// Compact on-disk record (84 B/entry). SdNvsPrefs caps one value at 2048 B, so +// we cap each stored blob at DISC_PER_CHUNK records // (2 + 24*84 = 2018 B) and split the ring across as many chunk keys ("disc", // "disc1", …) as DISCOVERED_MAX needs. We persist only what the Discovered list // redraws — the bulky parts of ContactInfo (out_path[64], shared_secret, …) are @@ -1741,6 +1863,9 @@ static void discChunkKey(int chunk, char* out, size_t cap) { } static void saveDiscovered() { if (!s_discovered || !discBlob()) return; + SdNvsPrefs prefs; + if (!prefs.begin(DISC_NS, false)) return; + prefs.putUChar(DISC_MARKER, DISC_BLOB_VER); int src = 0; // walk the ring once, emitting used entries into successive chunk blobs for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { uint8_t* p = s_disc_blob; @@ -1764,18 +1889,23 @@ static void saveDiscovered() { } *pcount = count; char key[8]; discChunkKey(chunk, key, sizeof key); - if (count > 0) touchPrefsSetBlob(key, s_disc_blob, (size_t)(p - s_disc_blob)); - else touchPrefsSetBlob(key, nullptr, 0); // clear an empty trailing chunk + if (count > 0) prefs.putBytes(key, s_disc_blob, (size_t)(p - s_disc_blob)); + else prefs.remove(key); // clear an empty trailing chunk } + prefs.end(); s_disc_dirty = false; } static void loadDiscovered() { if (!s_discovered || !discBlob()) return; memset(s_discovered, 0, sizeof(LvDiscoveredEntry) * DISCOVERED_MAX); + SdNvsPrefs prefs; + const bool prefs_open = prefs.begin(DISC_NS, true); + const bool have_new = prefs_open && prefs.isKey(DISC_MARKER); int dst = 0; for (int chunk = 0; chunk < DISC_CHUNKS && dst < DISCOVERED_MAX; ++chunk) { char key[8]; discChunkKey(chunk, key, sizeof key); - size_t got = touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ); + size_t got = have_new ? prefs.getBytes(key, s_disc_blob, DISC_BLOB_SZ) + : touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ); if (got < 2 || s_disc_blob[0] != DISC_BLOB_VER) continue; // missing / garbage chunk -> skip uint8_t count = s_disc_blob[1]; const uint8_t* p = s_disc_blob + 2; @@ -1798,6 +1928,20 @@ static void loadDiscovered() { e.ci.shared_secret_valid = false; // force secret recompute on first use } } + prefs.end(); + if (!have_new) { + // First boot after the split: queue the new namespace but leave legacy + // chunks intact until a later boot proves the new snapshot is durable. + saveDiscovered(); + } else { + // The separate snapshot survived a reboot, so the old copies are now safe + // to retire from the critical touch namespace. + for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { + char key[8]; discChunkKey(chunk, key, sizeof key); + if (touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ) > 0) + touchPrefsSetBlob(key, nullptr, 0); + } + } } // Coalesce a burst of adverts into one write ~20 s later (flash-churn guard). static void markDiscoveredDirty() { s_disc_dirty = true; s_disc_save_at = millis() + 20000; } @@ -1943,7 +2087,9 @@ static void copyUtf8ReplacingMissingGlyphs(const lv_font_t* font, char* out, siz } if (uiFontHasGlyph(font, cp)) { - for (const char* q = seq_beg; q < p && w + 1 < out_cap; ++q) out[w++] = *q; + // Whole sequence or nothing: a glyph split at the cap leaves invalid UTF-8. + if (w + (size_t)(p - seq_beg) + 1 > out_cap) break; + for (const char* q = seq_beg; q < p; ++q) out[w++] = *q; } else { out[w++] = '*'; } @@ -2070,6 +2216,14 @@ static bool g_radio_preset_cb_silent = false; static void kbMirrorSyncToReal(); // keyboard mirror strip; defined below +// #161: duty% -> the core's airtime_factor (Dispatcher: duty = 1/(1+af)), clamped to the +// core's 0..9 range (af 9 = 10% duty, the regulatory floor the UI offers). +static float meshPresetAirtimeFactor(uint8_t pct) { + if (pct == 0 || pct >= 100) return 0.0f; // unlimited + float af = (100.0f / (float)pct) - 1.0f; + return af > 9.0f ? 9.0f : af; +} + static int findMatchingMeshRadioPreset(const NodePrefs* prefs) { if (!prefs) return -1; for (size_t i = 0; i < k_mesh_radio_preset_count; ++i) { @@ -2079,13 +2233,34 @@ static int findMatchingMeshRadioPreset(const NodePrefs* prefs) { if (prefs->sf != p.sf) continue; if (prefs->cr != p.cr) continue; if (prefs->tx_power_dbm != p.tx_dbm) continue; - const double exp_af = static_cast<double>(p.airtime_limit_pct) / 100.0; - if (std::fabs(static_cast<double>(prefs->airtime_factor) - exp_af) > 0.05) continue; + const double exp_af = (double)meshPresetAirtimeFactor(p.airtime_limit_pct); + const double old_af = static_cast<double>(p.airtime_limit_pct) / 100.0; // pre-#161 buggy write + const double cur = static_cast<double>(prefs->airtime_factor); + if (std::fabs(cur - exp_af) > 0.05 && std::fabs(cur - old_af) > 0.05) continue; return static_cast<int>(i); } return -1; } +// #161 boot heal: devices configured by the OLD preset code carry af = pct/100 (~91% duty on a +// 10% preset). If the radio params match a preset exactly and the stored factor matches the OLD +// buggy value (and NOT a deliberate user choice near it -- the exact-preset match is the guard), +// rewrite it to the correct factor once and persist. +static void healPresetAirtimeFactor() { + NodePrefs* prefs = the_mesh.getNodePrefs(); + if (!prefs) return; + const int m = findMatchingMeshRadioPreset(prefs); + if (m < 0) return; + const MeshRadioPreset& p = k_mesh_radio_presets[m]; + const float good = meshPresetAirtimeFactor(p.airtime_limit_pct); + const float bad = (float)p.airtime_limit_pct / 100.0f; + if (std::fabs(prefs->airtime_factor - bad) <= 0.05f && + std::fabs(prefs->airtime_factor - good) > 0.05f) { + prefs->airtime_factor = good; + the_mesh.savePrefs(); + } +} + static void applyMeshRadioPresetFields(unsigned preset_idx) { if (preset_idx >= k_mesh_radio_preset_count || !g_set_modal.freq_ta) return; kbMirrorSyncToReal(); @@ -2101,7 +2276,10 @@ static void applyMeshRadioPresetFields(unsigned preset_idx) { lv_textarea_set_text(g_set_modal.cr_ta, buf); snprintf(buf, sizeof(buf), "%d", static_cast<int>(p.tx_dbm)); lv_textarea_set_text(g_set_modal.tx_ta, buf); - const float af = static_cast<float>(p.airtime_limit_pct) / 100.0f; + // #161: MeshCore's airtime_factor is a TX pacing multiplier -- the core's Dispatcher runs at + // duty = 1/(1+af). A 10% duty limit therefore needs af = 9, NOT 0.10: the old pct/100 write + // configured every preset user's radio to ~91% duty (and the Home meter honestly said so). + const float af = meshPresetAirtimeFactor(p.airtime_limit_pct); snprintf(buf, sizeof(buf), "%.2f", static_cast<double>(af)); lv_textarea_set_text(g_set_modal.airtime_ta, buf); } @@ -2141,18 +2319,54 @@ static bool settingsModalIsOpen() { return g_set_modal.root != nullptr; } // Compact one-line GPS status for the Device settings panel + control center. // TR("GPS: off") / "GPS: searching · N sats" / "GPS: fix · N sats <lat>, <lon>". -static const char* gpsStatusStr() { - static char s[72]; - if (!g_lv.task || !g_lv.task->getGPSState()) { snprintf(s, sizeof s, TR("GPS: off")); return s; } - // satellitesCount() is satellites USED IN THE FIX (0 until a lock), so during - // cold acquisition it stays 0 — show "acquiring…" rather than a misleading - // "0 sats". On lock, show the sat count + the (auto-populated) coordinates. - if (!g_lv.task->getGpsFix()) { snprintf(s, sizeof s, TR("GPS: acquiring...")); return s; } +// Stamped when acquisition starts so the page can show how long it has been searching; a stuck +// receiver is then obvious from the elapsed time alone. Cleared on fix and when GPS is off. +static uint32_t s_gps_acq_since = 0; + +// compact = the top-bar dropdown, which must stay ONE line (it shares this string with the GPS +// settings page, and a two-line version pushed the whole dropdown taller). The settings page asks +// for the full text, where there is room to explain what is happening. +static const char* gpsStatusStr(bool compact = false) { + static char s[200]; + if (!g_lv.task || !g_lv.task->getGPSState()) { + s_gps_acq_since = 0; + snprintf(s, sizeof s, TR("GPS: off")); + return s; + } + // satellitesCount() is satellites USED IN THE FIX, so it reads 0 for the whole cold acquisition + // and a bare "0 sats" looks like a fault. All the other reachable fields (lat/lon/alt/valid) are + // likewise empty until a fix lands, so from up here a receiver that is happily tracking + // satellites and one that is not powered look EXACTLY the same. + // + // That is a real limitation, not an oversight: the core's MicroNMEALocationProvider keeps its + // MicroNMEA object private and LocationProvider exposes no satellites-in-view, no SNR and no + // HDOP. Deducing "is it alive" from the decoded GPS time does NOT work either — MicroNMEA parses + // RMC left to right and bails out at the EMPTY latitude field of an unfixed sentence, so it + // never reaches parseDate and the time stays unset until there is already a fix. So do not claim + // to know whether data is arriving; report the honest thing (how long it has been trying) and + // point at the actual remedy. The serial boot probe prints the real sky view meanwhile. + if (!g_lv.task->getGpsFix()) { + const uint32_t now = millis(); + if (!s_gps_acq_since) s_gps_acq_since = now; + const uint32_t secs = (now - s_gps_acq_since) / 1000u; + int n = snprintf(s, sizeof s, TR("GPS: searching")); + if (n < (int)sizeof s) { + if (secs < 600) n += snprintf(s + n, sizeof s - n, " %lum%02lus", (unsigned long)(secs / 60), (unsigned long)(secs % 60)); + else n += snprintf(s + n, sizeof s - n, " %lum", (unsigned long)(secs / 60)); + } + if (!compact && n < (int)sizeof s) + snprintf(s + n, sizeof s - n, "\n%s", + TR("No position yet. A cold start needs a clear view of the sky and can take several minutes.")); + return s; + } + s_gps_acq_since = 0; const int sats = g_lv.task->getGpsSats(); + const int alt = g_lv.task->getGpsAltitude(); int n = snprintf(s, sizeof s, TR("GPS: fix")); - if (sats >= 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d sats", sats); + if (sats > 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d sats", sats); + if (!compact && alt && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d m", alt); if (n < (int)sizeof s) - snprintf(s + n, sizeof s - n, " %.5f, %.5f", + snprintf(s + n, sizeof s - n, "%s%.5f, %.5f", compact ? " " : "\n", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); return s; } @@ -4362,6 +4576,7 @@ static void refreshMapInfoLabel(); static void renderMapTiles(); static void renderMapMarkers(); static void freeMapTiles(); +static void mapNoteStorageChanged(); // tile storage appeared/vanished: adopt, invalidate, re-render // Single entry point used by tabChangedCb. Recenters on self GPS and // rebuilds the tile grid. Defined alongside the map state below. static void onMapTabActivated(); @@ -4534,10 +4749,166 @@ static volatile bool s_sdinfo_request = false; // UI -> worker: rescan SD usag // (Heltec V4); on the loop thread that froze the whole UI, incl. touch wake // (the "ui:hist 6140ms" field stall). The loop thread snapshots the ring, the // worker writes the snapshot; shutdown/reboot still write synchronously. -static volatile bool s_hist_flush_req = false; // snapshot armed, waiting for the worker -static volatile bool s_hist_flush_busy = false; // worker owns the snapshot + the history file +static volatile bool s_hist_flush_req = false; // job armed, waiting for the worker +static volatile bool s_hist_flush_busy = false; // worker owns the job snapshot + its segment file static volatile bool s_hist_flush_ok = true; // last worker write result (retry on false) -static bool uiHistWorkerFlush(); // defined with the storage code below +// Message-ring write health. A failing ui_msgs write used to be 100% silent — +// the user only found out on the next reboot as "messages from the last N +// minutes vanished" (the thread list still showed fresh times because the +// small threads file kept writing while the big ring write failed). Updated +// inside the segment writers from EITHER task (volatile stores only); read by +// the About page + the repeated-failure toast in flushHistoryIfDue. +static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) +static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write +// Wall-clock epochs for the same two events, so the readouts can show WHEN a +// save happened instead of how long ago. 0 = never, or the system clock wasn't +// set yet at the time (a relative age would be meaningless anyway). +static volatile uint32_t s_msgs_write_ok_epoch = 0; +static volatile uint32_t s_msgs_write_fail_epoch = 0; +static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success +static volatile uint8_t s_msgs_write_stage = 0; // where the last failure hit: 'o' open, 'h' header, 'b' body, 'r' rename +static volatile int s_msgs_write_errno = 0; // errno at that failure (ENFILE/EMFILE = VFS file table full, ENOSPC = card full, EIO = card error) +// Human-readable diagnosis of the last chat-store write failure — shown on the +// About panel and in the failure alerts, so a tester doesn't need an errno +// table. Diagnostic vocabulary stays English on purpose (it's what ends up in +// bug reports verbatim). +static void chatSaveFailText(char* out, size_t cap) { + const char* stage; + switch ((char)s_msgs_write_stage) { + case 'o': stage = "open"; break; + case 'h': stage = "header"; break; + case 'b': stage = "write"; break; + case 'r': stage = "rename"; break; + case 'a': stage = "append"; break; // segmented store: active-segment append + case 'c': stage = "compact"; break; // segmented store: one-segment rewrite + case 'd': stage = "mkdir"; break; // segmented store: data-dir create + case 's': stage = "scan"; break; // segmented store: segment discovery + case 'm': stage = "commit"; break; // segmented store: migration commit marker + default: stage = "save"; break; + } + const int e = s_msgs_write_errno; + const char* why; + switch (e) { + case ENFILE: + case EMFILE: why = "too many open files"; break; + case ENOSPC: why = "card full"; break; + case EIO: why = "card I/O error"; break; + case ENOENT: why = "folder missing"; break; + case EROFS: why = "write-protected"; break; + case EACCES: why = "access denied"; break; + case 0: why = "no errno"; break; + default: why = strerror(e); break; // newlib carries the full table + } + snprintf(out, cap, "%s failed: %s (e%d)", stage, why, e); +} +static void fmtClockHM(char* buf, size_t cap, const struct tm* t); // fwd: 12/24h-aware HH:MM +// "When did the chat store last save" as a CLOCK TIME rather than an age: +// HH:MM (honouring the 12/24h pref), with a "-<N>D" suffix once it is a day or +// more old, so a stale save is obvious at a glance instead of having to read a +// growing seconds counter. "--:--" when the event never happened, or happened +// before the system clock was set (no meaningful timestamp exists). +static void chatSaveStamp(char* out, size_t cap, uint32_t epoch) { + if (!epoch) { snprintf(out, cap, "--:--"); return; } + const time_t t = (time_t)epoch; + struct tm tmv; + if (!localtime_r(&t, &tmv)) { snprintf(out, cap, "--:--"); return; } + char hm[12]; + fmtClockHM(hm, sizeof hm, &tmv); + const time_t now_t = time(nullptr); + const uint32_t days = (now_t > (time_t)epoch) ? (uint32_t)((now_t - (time_t)epoch) / 86400) : 0; + if (days) snprintf(out, cap, "%s-%uD", hm, (unsigned)days); + else snprintf(out, cap, "%s", hm); +} +static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend +static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size + +// ---- Segmented store: runtime segment table (loop-task-owned) -------------- +// Oldest-first view of the on-disk segment set. Rebuilt by the boot loader, +// consulted and updated by the flush scheduler. The hist_flush worker never +// touches it — it only executes fully-described jobs and reports ok/fail. +struct UiSegInfo { + uint32_t first_seq; // filename key + first record's seq + uint32_t last_seq; // newest seq physically in the FILE + uint32_t bytes; // file size (header + records) — arithmetic, never stat() + uint16_t disk_recs; // records physically in the file + uint16_t live_recs; // of those, how many are still live in the RAM ring + uint8_t compact_dirty; // RAM tombstones/drops within this segment -> rewrite due + // The FILE's content is untrusted: it may be missing, partially written (a + // failed append), or stale (post-resync). Repair = a full rewrite from the + // ring that also ABSORBS the unflushed tail, so no append may target it + // until that lands. Set on append failure and by segRetableFromRing. + uint8_t rewrite_open; +}; +static UiSegInfo s_seg[k_ui_seg_max]; +static int s_seg_count = 0; +static volatile uint32_t s_seg_total_bytes = 0; // writer-maintained; About page reads it +static volatile bool s_seg_resync = false; // card swapped/remounted: rewrite everything from RAM +// Set by segRetableFromRing: on-disk segment files that are NOT part of the +// rebuilt table must be unlinked — but only once the re-land has landed, so a +// same-card recovery keeps its durable history readable the whole time +// (deleting up front turned a recovery into a multi-minute window where a +// power cut lost everything that had been safe on disk). +static bool s_seg_stale_purge = false; +// False until the boot loader read segments OR the one-time migration landed. +// While false the flush scheduler must not append segments (they would shadow +// the still-authoritative old-format file on the next boot) — it retries the +// migration instead. +static bool s_seg_store_ready = false; +// Durability watermark: highest seq that has landed in a segment file. Ring +// records with seq above it are the pending-append backlog. Loop-task-owned; +// advanced only when a write is COMMITTED (worker job observed ok, or a sync +// write returned true). +static uint32_t s_seg_flushed_seq = 0; + +// One flush job in flight at a time (the existing busy/req protocol). Built +// and committed on the loop task; the hist_flush worker (or the sync path) +// only executes it. The snapshot buffer holds at most one segment's records +// (~80 KB PSRAM) — it replaces the old whole-ring snapshot (1.3 MB). +enum : uint8_t { SEGJOB_NONE = 0, SEGJOB_APPEND, SEGJOB_COMPACT }; +struct SegJob { + uint8_t kind = SEGJOB_NONE; + uint32_t first_seq = 0; // target segment (filename key) + uint32_t last_seq = 0; // newest seq covered by the job + bool create = false; // APPEND: file doesn't exist yet (write header) + bool repair = false; // COMPACT of an untrusted file: also absorbs the unflushed tail + int n = 0; +}; +// The WORKER's armed job. Written only while !(busy || req); the sync drain +// uses its own local SegJob + its own buffer so a worker stalled past the 9 s +// idle-wait cap can never observe a rebuilt descriptor or a reused buffer. +static uint8_t s_segjob_kind = SEGJOB_NONE; +static uint32_t s_segjob_first_seq = 0; +static uint32_t s_segjob_last_seq = 0; +static bool s_segjob_create = false; +static int s_segjob_n = 0; +static UITask::UIMessage* s_segjob_buf = nullptr; // worker jobs only +static UITask::UIMessage* s_segsync_buf = nullptr; // sync-drain jobs only +// A delete landed inside the segment a COMPACT job is currently rewriting — +// its snapshot predates the tombstone, so the segment must stay dirty when +// the job commits (a second compaction picks the deletion up). +static bool s_segjob_redirty = false; +// fwd decls — scheduler helpers live with the storage code below. +static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, + UITask::UIMessage* buf, SegJob* out); +static void segCommitJob(const SegJob& job); +static bool segMoreWorkPending(uint32_t newest_seq); +static bool uiSegRunArmedJob(); +static void segMarkSeqDirty(uint32_t seq); +static void segNoteEvicted(uint32_t seq); +static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq); +static void segPurgeStaleFiles(); +static bool uiMsgsWriteFail(char stage); // fwd — failure stage/errno bookkeeping (writer block below) +static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out); +static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot); +// fwd decls — the ops live with the storage code far below. +static void uiDataEnsureDirs(); +static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps); +static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out); +static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out); +static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer); +static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n); +static void uiSegRemoveFile(uint32_t first_seq); static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists static volatile bool s_sdinfo_ok = false; // card present + sizes valid static uint64_t s_sdinfo_tot = 0; @@ -4986,6 +5357,15 @@ static void applySwipeGesture(int8_t swipe_x, int8_t swipe_y) { else if (swipe_y != 0) SnakeGame::steer(0, swipe_y < 0 ? -1 : 1); return; } + // Every OTHER full-screen app page (Airtime, Spectrum, RF Monitor, Discover, VNC, Remote, + // Reader) also owns the screen, so swallow the swipe instead of letting it reach the tab + // switcher behind the page. Making the page root opaque + CLICKABLE only stops CLICKS: + // this detector reads the per-board touch hardware directly and never consults LVGL + // hit-testing, which is why a swipe inside Airtime still jumped to the Map tab. Settings + // sub-sheets borrow s_apppage_title for their back chevron too, but they always open from + // inside a category and have their own rightward swipe-back further down, so exclude them. + // (Vertical swipes are a no-op at the end of this function, so this only kills tab swipes.) + if (s_apppage_title && s_settings_open_cat < 0) return; // A slider was just being dragged — its horizontal drag must NOT be read as a // tab/back swipe (e.g. raising the volume slider rightward kept triggering the // settings "swipe right = back"). Ignore swipes briefly after any slider touch. @@ -5237,6 +5617,7 @@ static KeyboardLayoutId kbDefaultLayoutForUiLang(uint8_t lang) { case LANG_SR: return KeyboardLayoutId::SR; case LANG_EL: return KeyboardLayoutId::EL; case LANG_FR: return KeyboardLayoutId::FR; + case LANG_RO: return KeyboardLayoutId::RO; default: return KeyboardLayoutId::EN; } } @@ -5568,8 +5949,8 @@ static void composerAutoGrowCb(lv_event_t* e) { // the extras_* fallback fonts (Latin-1 + Latin-Extended-A), so they render in // the textarea and chat. Keys carry LV_BTNMATRIX_CTRL_NO_REPEAT (KeyboardLayouts // .cpp) so a hold doesn't auto-repeat — it cleanly long-presses instead. -static const char* const kAccA[] = {"à","á","â","ä","ã","å","ą"}; -static const char* const kAccA_u[] = {"À","Á","Â","Ä","Ã","Å","Ą"}; +static const char* const kAccA[] = {"à","á","â","ă","ä","ã","å","ą"}; +static const char* const kAccA_u[] = {"À","Á","Â","Ă","Ä","Ã","Å","Ą"}; static const char* const kAccE[] = {"è","é","ê","ë","ě","ę"}; static const char* const kAccE_u[] = {"È","É","Ê","Ë","Ě","Ę"}; static const char* const kAccI[] = {"ì","í","î","ï"}; @@ -5582,25 +5963,25 @@ static const char* const kAccN[] = {"ñ","ń"}; static const char* const kAccN_u[] = {"Ñ","Ń"}; static const char* const kAccC[] = {"ç","č","ć"}; static const char* const kAccC_u[] = {"Ç","Č","Ć"}; -static const char* const kAccS[] = {"ß","ś","š"}; +static const char* const kAccS[] = {"ß","ś","š","ş"}; static const char* const kAccY[] = {"ý","ÿ"}; // Czech carons / ring — base letters that otherwise carry no Latin-1 accent. -static const char* const kAccT[] = {"ť"}; -static const char* const kAccT_u[] = {"Ť"}; +static const char* const kAccT[] = {"ť","ţ"}; +static const char* const kAccT_u[] = {"Ť","Ţ"}; static const char* const kAccZ[] = {"ž","ż","ź"}; static const char* const kAccZ_u[] = {"Ž","Ż","Ź"}; static const char* const kAccR[] = {"ř"}; static const char* const kAccR_u[] = {"Ř"}; static const char* const kAccL[] = {"ł"}; static const char* const kAccL_u[] = {"Ł"}; -static const char* const kAccS_u[] = {"Ś","Š"}; +static const char* const kAccS_u[] = {"Ś","Š","Ş"}; struct AccentSet { char key; const char* const* v; uint8_t n; }; static const AccentSet kAccentSets[] = { - {'a',kAccA,7},{'A',kAccA_u,7},{'e',kAccE,6},{'E',kAccE_u,6}, + {'a',kAccA,8},{'A',kAccA_u,8},{'e',kAccE,6},{'E',kAccE_u,6}, {'i',kAccI,4},{'I',kAccI_u,4},{'o',kAccO,6},{'O',kAccO_u,6}, {'u',kAccU,5},{'U',kAccU_u,5},{'n',kAccN,2},{'N',kAccN_u,2}, - {'c',kAccC,3},{'C',kAccC_u,3},{'s',kAccS,3},{'S',kAccS_u,2},{'y',kAccY,2}, - {'t',kAccT,1},{'T',kAccT_u,1},{'z',kAccZ,3},{'Z',kAccZ_u,3}, + {'c',kAccC,3},{'C',kAccC_u,3},{'s',kAccS,4},{'S',kAccS_u,3},{'y',kAccY,2}, + {'t',kAccT,2},{'T',kAccT_u,2},{'z',kAccZ,3},{'Z',kAccZ_u,3}, {'l',kAccL,1},{'L',kAccL_u,1},{'r',kAccR,1},{'R',kAccR_u,1}, }; static const AccentSet* accentLookup(const char* key) { @@ -5848,7 +6229,7 @@ static lv_obj_t* s_accbox_ta = nullptr; // the field the box edits // (Alt)+Space jumps in (handleHwKey()); the rotary encoder then walks // s_accbox_cells (updatePagerEncoder()); Enter (the encoder's own click) // confirms via accentNavConfirm(); Backspace cancels. -static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (7, 'a'/'A') +static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (8, 'a'/'A') static lv_obj_t* s_accbox_cells[kAccentNavMax]; static uint8_t s_accbox_cell_n = 0; static bool s_accentnav_active = false; @@ -8384,8 +8765,53 @@ static void saveExperimentalCb(lv_event_t* e) { static void syncClockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; - g_lv.task->setDeviceTimeFromSystemClock(); - g_lv.task->showAlert(TR("Clock synced"), 900); + const bool ok = g_lv.task->setDeviceTimeFromSystemClock(); + g_lv.task->showAlert(ok ? TR("Clock synced") : TR("No system time yet"), 900); +} + +// Chat-save fallback dropdown (Settings -> General): selection index -> number +// of consecutive failed background history writes before the flush retries +// synchronously on the UI task (0 = background retries only, never hitch). +// Per-chat history cap (Settings -> Chats). "No limit" is last and warns, because an +// uncapped busy channel is exactly what fills the shared ring and slows the UI down. +static const uint16_t k_hist_cap_opts[] = { 100, 250, 500, 1000, 2000, 0 }; +static lv_obj_t* s_hist_cap_dd = nullptr; +static uint16_t s_hist_cap_pending = 0; + +static void histCapApplyUncapped() { + touchPrefsSetHistPerChat(0); + if (s_hist_cap_dd) lv_dropdown_set_selected(s_hist_cap_dd, sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]) - 1); + if (g_lv.task) g_lv.task->showAlert(TR("History limit off"), 1200); +} + +static void histCapSelectCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + lv_obj_t* dd = lv_event_get_target(e); + const uint16_t sel = lv_dropdown_get_selected(dd); + if (sel >= sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0])) return; + const uint16_t v = k_hist_cap_opts[sel]; + if (v == 0) { + // Revert first and only commit from the confirm handler: showConfirm has no cancel + // callback, so a dismissed dialog must leave the old limit in place. + s_hist_cap_dd = dd; + uint16_t cur = touchPrefsGetHistPerChat(); + uint16_t cur_sel = 1; + for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) + if (k_hist_cap_opts[i] == cur) { cur_sel = i; break; } + lv_dropdown_set_selected(dd, cur_sel); + showConfirm(TR("Turn the history limit off?\n\nA busy channel can then fill the whole message " + "store, which uses more space and makes the chat list and app noticeably slower. " + "Only do this if you really need the full backlog."), + TR("Turn off"), histCapApplyUncapped); + return; + } + touchPrefsSetHistPerChat(v); +} + +static const uint8_t k_hist_sync_opts[] = { 0, 1, 2, 3, 5, 8 }; +static void histSyncSelectCb(lv_event_t* e) { + const uint16_t sel = lv_dropdown_get_selected(lv_event_get_target(e)); + if (sel < sizeof(k_hist_sync_opts)) touchPrefsSetHistSyncAfter(k_hist_sync_opts[sel]); } static void advertNowCb(lv_event_t* e) { @@ -8787,8 +9213,18 @@ static void openDiscoveredSettingsSheetCb(lv_event_t* e) { #endif lv_obj_t* card = lv_obj_create(s_disc_settings_root); lv_obj_remove_style_all(card); - lv_obj_set_size(card, card_w, PSC(188)); - lv_obj_align(card, LV_ALIGN_CENTER, 0, -46); // shifted up so the keyboard clears the hop field + // #109: centring the card and then lifting it a fixed -46 pushed its TOP off-screen on + // the 240px-tall T-Deck (only 218px visible under the bar), so the sheet looked truncated + // and could not be scrolled to reach the rest. Clamp the height to what the screen + // actually has, and clamp the lift to the leftover slack so the top can never go negative. + const lv_coord_t dsc_avail = lv_disp_get_ver_res(nullptr) - STATUSBAR_H; + lv_coord_t dsc_h = PSC(188); + if (dsc_h > dsc_avail - 8) dsc_h = dsc_avail - 8; + lv_coord_t dsc_lift = -46; + const lv_coord_t dsc_slack = (dsc_avail - dsc_h) / 2; + if (-dsc_lift > dsc_slack) dsc_lift = (lv_coord_t)-dsc_slack; + lv_obj_set_size(card, card_w, dsc_h); + lv_obj_align(card, LV_ALIGN_CENTER, 0, dsc_lift); // lift (clamped) so the keyboard clears the hop field lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); @@ -9318,6 +9754,50 @@ static void radioScopeDirectToggleCb(lv_event_t* e) { touchPrefsSetScopeDirect(on); the_mesh.setScopeDirectFloods(on); } +#if defined(HAS_TDISPLAY_P4) +// T-Display P4 LoRa antenna select. Applies live (no reboot) but is deliberately SESSION-ONLY: +// every boot re-forces the on-board antenna (see the boot-apply in begin() and the park in +// Xl9535::powerOnSequence). Transmitting into an external connector with no antenna fitted is +// what kills a PA, so the safe state has to be the one you get for free after a power cycle, +// and picking the external antenna has to be a fresh, explicit decision each time. +static lv_obj_t* s_p4_ant_dd = nullptr; // showConfirm takes a bare callback, so stash the widget +static uint8_t s_p4_ant_pending = Xl9535::ANT_INTERNAL; + +static void radioP4AntennaConfirmApply() { + xl9535.setAntennaMode(s_p4_ant_pending); // takes effect on the very next transmit + if (s_p4_ant_dd) lv_dropdown_set_selected(s_p4_ant_dd, s_p4_ant_pending); + if (g_lv.task) { + g_lv.task->showAlert(s_p4_ant_pending == Xl9535::ANT_EXTERNAL ? TR("External antenna selected") + : TR("Per-transmit switching on"), 1200); + } +} + +static void radioP4AntennaSelectCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + lv_obj_t* dd = lv_event_get_target(e); + const uint8_t m = (uint8_t)lv_dropdown_get_selected(dd); + if (m == Xl9535::ANT_INTERNAL) { xl9535.setAntennaMode(m); return; } // always safe, no prompt + + // External moves BOTH directions onto the external socket, so it needs an antenna actually fitted + // there — that is the one real hardware risk and it gets the damage warning. Auto (the legacy + // per-TX toggle) transmits on the on-board antenna and only listens on the external socket, so it + // is not a PA hazard, just permanently lopsided; it gets an accurate performance warning instead + // of a scary one. Revert the widget FIRST and only re-select from the confirm handler: showConfirm + // has no cancel callback, so a dismissed dialog must leave UI and hardware where they were. + s_p4_ant_dd = dd; + s_p4_ant_pending = m; + lv_dropdown_set_selected(dd, xl9535.antennaMode()); + showConfirm(m == Xl9535::ANT_EXTERNAL + ? TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " + "to the external antenna socket first. Transmitting with nothing attached can " + "damage the radio.\n\nResets to the on-board antenna on every reboot.") + : TR("Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison " + "only. It transmits on the on-board antenna and listens on the external one, " + "so your outbound signal will be much weaker than your inbound.\n\nResets to " + "the on-board antenna on every reboot."), + TR("Switch"), radioP4AntennaConfirmApply); +} +#endif #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 high-gain FEM LNA (~17 dB external receive amp). Persists + applies live. static void radioFemLnaToggleCb(lv_event_t* e) { @@ -9336,6 +9816,14 @@ static void radioRxQueueToggleCb(lv_event_t* e) { touchPrefsSetRxQueue(on); radio_driver.rxQueueEnable(on); } +// Auto-retry sends until the mesh confirms them (echo/ACK) — mikecarper's #207 retry train. +// Persists + applies live; OFF stops new trains, an in-flight one finishes its schedule. +static void radioRetryEchoToggleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); + touchPrefsSetRetryEcho(on); + the_mesh.setCompanionRetryEnabled(on); +} static void radioSigPollSaveCb(lv_event_t* e) { const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !s_radio_sig_poll_ta) return; @@ -9595,6 +10083,38 @@ static void buildRadioSettings() { mk_section("SIGNAL"); +#if defined(HAS_TDISPLAY_P4) + // T-Display P4 antenna select: the SKY13453 on XL9535 IO1. Confirmed against LilyGo's own driver + // (HIGH = RF1 = on-board, LOW = RF2 = external socket) — see the note on Xl9535.h. Internal is the + // default and is re-forced at every boot; external is session-only and gated behind a confirmation, + // because keying the PA into an empty socket is how you destroy one. "Auto" keeps the old + // per-transmit toggle purely for comparison: it sends internal and listens external, so Trace SNR + // reads lopsided on it and roughly equal on a correctly pinned antenna. + mk_label("LoRa antenna (P4)"); + { + lv_obj_t* dd = lv_dropdown_create(body); + lv_obj_set_size(dd, lv_pct(100), SC(34)); + lv_obj_set_pos(dd, 2, y); + lv_dropdown_set_options(dd, TR("Internal (on-board)\nExternal (MMCX)\nAuto (legacy, per transmit)")); + lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); + lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); + lv_obj_t* antlist = lv_dropdown_get_list(dd); + lv_obj_set_style_bg_color(antlist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); + lv_obj_set_style_text_color(antlist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_set_style_text_font(antlist, &g_font_12, LV_PART_MAIN); + lv_dropdown_set_selected(dd, xl9535.antennaMode()); // live state, not a stored pref + lv_obj_add_event_cb(dd, radioP4AntennaSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); + y += SC(44); + y += settingsRowLabel(body, y, 0, + TR("Always starts on the on-board antenna after a reboot. Only pick External " + "with an antenna fitted to the external socket."), + COLOR_SUB, &g_font_12, 0) + 2; + } +#endif + #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 only: the external FEM's high-gain receive amplifier (~17 dB). Bypassed by // default; a big win in quiet/remote sites, but can desensitize in noisy areas. This is @@ -9623,6 +10143,18 @@ static void buildRadioSettings() { COLOR_SUB, &g_font_12, 0) + 2; } + // Auto-retry until heard (#207): resend until the mesh echoes/ACKs the packet. + { + int rh = settingsRowLabel(body, y, 4, TR("Retry sends until heard"), COLOR_TEXT, &g_font_12, 56); + lv_obj_t* sw = lv_switch_create(body); + lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); + if (touchPrefsGetRetryEcho()) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, radioRetryEchoToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(34, rh + 10); + y += settingsRowLabel(body, y, 0, TR("Resends a message until a repeater echoes it or the recipient ACKs. Turn off to send once only."), + COLOR_SUB, &g_font_12, 0) + 2; + } + // Signal probe — same setting as the home-graph Signal popup. A periodic // discover advert keeps the signal bars / graph fresh. Poll interval in whole // minutes (1..1440); the toggle saves immediately, the field on its Set button. @@ -10020,6 +10552,42 @@ static void sysInfoTextLive(char* buf, size_t cap) { (unsigned long)rx_evt, (unsigned long)rx_ok, (unsigned long)rx_err, (unsigned long)rx_lost, (unsigned long)radio_driver.getRxQueueDrops(), radio_driver.rxQueueEnabled() ? "on" : "off"); + + // Chat-history store health. Two failure modes this exposes that are + // otherwise invisible: (a) the "wrong backend" trap — a boot whose SD mount + // failed resolves the store to internal SPIFFS and every message written + // that session is invisible on the next carded boot; (b) persistent ring + // write failures (full card / damaged FAT) — the small threads file keeps + // succeeding, so the thread list looks fresh while messages silently stop + // persisting. + { + // Segmented store: the byte total is writer-maintained (volatile), so this + // 1 Hz label costs ZERO file I/O — the old single-file size probe blocked + // on the FatFs volume lock behind a sick write and froze the UI just by + // WATCHING this page. + const uint32_t nowms = (uint32_t)millis(); + char stat[112]; + char when[16]; + if (s_msgs_write_fails) { + char why[64]; + chatSaveFailText(why, sizeof why); + chatSaveStamp(when, sizeof when, s_msgs_write_fail_epoch); + snprintf(stat, sizeof stat, "FAIL x%u (%s)\n %s", + (unsigned)s_msgs_write_fails, when, why); + } + else if (s_msgs_write_ok_ms) { + chatSaveStamp(when, sizeof when, s_msgs_write_ok_epoch); + snprintf(stat, sizeof stat, "OK %s", when); + } + else + snprintf(stat, sizeof stat, "none this session"); + p += snprintf(buf + p, cap - p, + "Chat store\n %s %d seg / %u KB%s\n last save: %s\n\n", + uiDataFsIsSdCard() ? "SD /meshcomod" : "Internal flash", + s_seg_count, (unsigned)(s_seg_total_bytes / 1024u), + s_seg_store_ready ? "" : " (migration pending!)", + stat); + } #endif (void)p; (void)cap; } @@ -10277,9 +10845,14 @@ static void sdRestoreApply() { if (!fmSdTryMount()) { g_lv.task->showAlert(TR("No SD card"), 1600); return; } g_lv.task->showAlert(TR("Copying internal data to SD..."), 6000); lv_refr_now(nullptr); // paint the notice before the blocking copy - disableLoopWDT(); - const bool ok = meshcomodMigrateSpiffsToSd(true); - enableLoopWDT(); + // Land everything in RAM first — this path used to reboot WITHOUT persisting, + // silently dropping every message since the last lazy flush. + g_lv.task->persistHistoryNow(); + bool ok = false; + { + LoopWdtGuard loop_wdt_guard; + ok = meshcomodMigrateSpiffsToSd(true); + } if (!ok) { g_lv.task->showAlert(TR("Copy failed (no internal identity)"), 2200); return; } meshcomodClearSdMigLatch(); // manual copy succeeded -> re-arm boot auto-adoption (GH #142/#148) touchPrefsSetUseSdStorage(true); // make sure the reboot adopts the card @@ -10345,9 +10918,9 @@ static void clock12hToggleCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(on ? TR("Clock: 12-hour") : TR("Clock: 24-hour"), 900); } -#if CAP_LARGE_SCREEN -// UI size (resolution scale) selector — saves the pref; applied at the next boot (the LVGL -// resolution + flush upscaler are set up in begin()), so prompt for a restart. +#if CAP_UI_SIZE +// UI-size selector — saves the board-specific preset. Fonts and any matching +// geometry are selected while the UI tree is built, so prompt for a restart. static void uiScaleSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetUiScale((uint8_t)lv_dropdown_get_selected(lv_event_get_target(e))); @@ -11066,7 +11639,8 @@ static void buildDeviceSettings(int sec) { 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()); - y += SC(22); + lv_obj_update_layout(g_set_modal.gps_status); + y += LV_MAX(SC(22), lv_obj_get_height(g_set_modal.gps_status) + SC(6)); // status is 2 lines now // (Expansion Kit moved to the Sensors page — see the DSEC_SENSORS block below.) @@ -11308,6 +11882,50 @@ static void buildDeviceSettings(int sec) { lv_label_set_text(l_adv, TR("Send advert now")); lv_obj_center(l_adv); y += SC(40); + + /* Chat-save fallback: after how many consecutive failed BACKGROUND history + writes the flush retries synchronously on the UI task. The sync write is + the empirically reliable path on the shared SPI bus, but the screen + hitches for its duration — users who prefer a never-hitching UI pick Off + (background retries only; the RAM ring keeps everything meanwhile). */ + { + // Per-chat history limit. Sits above the save-fallback control because it is the one + // people actually need: without it a single busy channel fills the shared message store, + // which is what makes the chat list crawl. + y += settingsRowLabel(body, y, 0, TR("Keep per chat (messages)"), COLOR_SUB, &g_font_12, 0) + 2; + { + lv_obj_t* cd = lv_dropdown_create(body); + lv_dropdown_set_options(cd, TR("100\n250\n500\n1000\n2000\nNo limit")); + const uint16_t cur_cap = touchPrefsGetHistPerChat(); + uint16_t cap_sel = 1; // index of 250 — the default when the stored value isn't listed + for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) + if (k_hist_cap_opts[i] == cur_cap) { cap_sel = i; break; } + lv_dropdown_set_selected(cd, cap_sel); + lv_obj_set_width(cd, lv_pct(100)); + lv_obj_set_pos(cd, 2, y); + lv_obj_add_event_cb(cd, histCapSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(cd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); + y += SC(44); + y += settingsRowLabel(body, y, 0, + TR("Older messages in a chat are dropped past this. A very large or " + "unlimited history makes the chat list slower."), + COLOR_SUB, &g_font_12, 0) + 6; + } + + y += settingsRowLabel(body, y, 0, TR("Chat save fallback (failed tries)"), COLOR_SUB, &g_font_12, 0) + 2; + lv_obj_t* dd = lv_dropdown_create(body); + lv_dropdown_set_options(dd, TR("Off\n1\n2\n3\n5\n8")); + const uint8_t cur = touchPrefsGetHistSyncAfter(); + uint16_t sel = 2; // index of "2" — the default when the stored value isn't a listed option + for (uint16_t i = 0; i < sizeof(k_hist_sync_opts); ++i) + if (k_hist_sync_opts[i] == cur) { sel = i; break; } + lv_dropdown_set_selected(dd, sel); + lv_obj_set_width(dd, lv_pct(100)); + lv_obj_set_pos(dd, 2, y); + lv_obj_add_event_cb(dd, histSyncSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); // open list on top of the bar + y += SC(44); + } } if (sec == DSEC_DISPLAY) { // --- Display --- @@ -11329,13 +11947,16 @@ static void buildDeviceSettings(int sec) { y += SC(38); } -#if CAP_LARGE_SCREEN - /* UI size (resolution scale): Normal 100% / Large 150% / Huge 200%. Renders the whole UI at a - lower resolution and upscales to the panel, so EVERYTHING grows. Applied at boot → restart. */ +#if CAP_UI_SIZE + /* Board-specific UI-size presets. Applied while the UI tree is built, so a restart is required. */ { y += settingsRowLabel(body, y, 0, TR("UI size (restart to apply)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* dd = lv_dropdown_create(body); +#if defined(TLORA_PAGER) + lv_dropdown_set_options(dd, "Small\nMedium\nLarge"); +#else lv_dropdown_set_options(dd, TR("Normal (100%)\nLarge (150%)\nHuge (200%)")); +#endif lv_dropdown_set_selected(dd, touchPrefsGetUiScale()); lv_obj_set_width(dd, lv_pct(100)); lv_obj_set_pos(dd, 2, y); @@ -11483,7 +12104,7 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, useSdStorageToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) /* Where contacts ACTUALLY live this boot. The toggle above is only an intent — if the card failed to mount at boot (cold/slow card), contacts silently stay on internal flash even with it ON. This line shows the truth and flags that mismatch. */ @@ -11609,7 +12230,7 @@ static void buildDeviceSettings(int sec) { non-EN layout or the loop below reads past the end. */ static const char* k_kb_disp[] = { "Bulgarian", "Russian", "Ukrainian", "Serbian", "Greek", "Arabic (experimental)", - "French", "Dutch", "German", "Spanish", "Italian" + "French", "Dutch", "German", "Spanish", "Italian", "Romanian" }; for (int id = 1; id < KEYBOARD_LAYOUT_COUNT; ++id) { int h = settingsRowLabel(body, y, 4, k_kb_disp[id - 1], COLOR_TEXT, &g_font_12, 56); @@ -12260,10 +12881,28 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi lv_obj_add_flag(s_confirm_modal, LV_OBJ_FLAG_FLOATING); lv_obj_move_foreground(s_confirm_modal); - // Card + // Card — the height FOLLOWS the wrapped message. It used to be a fixed PSC(160) with a + // freely-wrapping label, so a long message (the crash-report prompt is the worst case) + // simply ran down over the Cancel/OK buttons (#97). Grow to fit the text, keep the old + // 160 as a floor so every short dialog looks exactly as before, and cap to the screen so + // the card can never overflow; the message area below scrolls if the cap bit. lv_obj_t* card = lv_obj_create(s_confirm_modal); lv_obj_remove_style_all(card); - lv_obj_set_size(card, PCW(210), PSC(160)); +#if CAP_LARGE_SCREEN + const lv_coord_t cf_lblw = PSC(186 - 32); +#else + const lv_coord_t cf_lblw = 186 - 32; +#endif + lv_point_t cf_tsz; + lv_txt_get_size(&cf_tsz, TR(msg), &g_font_14, 0, 2, cf_lblw, LV_TEXT_FLAG_NONE); + const lv_coord_t cf_chrome = (lv_coord_t)(PSC(12) * 2 + PSC(14) + PSC(34)); // pads + gap + buttons + lv_coord_t cf_h = (lv_coord_t)(cf_tsz.y + cf_chrome); + if (cf_h < PSC(160)) cf_h = PSC(160); + const lv_coord_t cf_max = lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12; + if (cf_h > cf_max) cf_h = cf_max; + lv_coord_t cf_msgh = (lv_coord_t)(cf_h - cf_chrome); + if (cf_msgh < PSC(20)) cf_msgh = PSC(20); // never let a tall UI scale invert this + lv_obj_set_size(card, PCW(210), cf_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 12); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); @@ -12272,16 +12911,17 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, confirmCancelEvt); // X behaves like Cancel - lv_obj_t* lbl = lv_label_create(card); + // Message area: its own box occupying exactly the space ABOVE the buttons, so the text + // physically cannot reach them; scrolls vertically when the card hit the screen cap. + // Width is still shortened (cf_lblw) so the first line doesn't slide under the X badge. + lv_obj_t* msg_box = lv_obj_create(card); + lv_obj_remove_style_all(msg_box); + lv_obj_set_size(msg_box, cf_lblw, cf_msgh); + lv_obj_align(msg_box, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_scroll_dir(msg_box, LV_DIR_VER); + lv_obj_t* lbl = lv_label_create(msg_box); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); - // Shrink width so the first line doesn't slide under the top-right X. - // Push the label down 4 px so the X glyph and the start of the text - // baseline don't touch optically. -#if CAP_LARGE_SCREEN - lv_obj_set_width(lbl, PSC(186 - 32)); -#else - lv_obj_set_width(lbl, 186 - 32); -#endif + lv_obj_set_width(lbl, cf_lblw); lv_label_set_text(lbl, TR(msg)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); @@ -12778,6 +13418,9 @@ static void wifiKickScan() { // here (sequential with main.cpp's wifi loop on the same task — no worker race, and // never eraseap, which wedges this radio). Drop the link, let the disassoc settle, // then kick the worker; wifiScanService rejoins once results land. +// Wall-clock deadline for the scan gate below (0 = not armed). See the recovery +// branch in wifiScanMainService for why this exists. +static uint32_t s_wifiscan_guard_ms = 0; static void wifiScanMainService() { if (s_wifiscan_drop_req) { s_wifiscan_drop_req = false; @@ -12786,6 +13429,7 @@ static void wifiScanMainService() { WiFi.setAutoReconnect(false); // don't auto-rejoin mid-sweep WiFi.disconnect(false, false); // leave the BSS (keep cred + stored AP; NO eraseap) s_wifiscan_drop_ms = millis(); + s_wifiscan_guard_ms = millis(); // arm the stuck-gate deadline (recovery below) s_wifiscan_reconnect_after = true; } else { s_wifiscan_request = true; // already disconnected — scan now @@ -12794,6 +13438,26 @@ static void wifiScanMainService() { s_wifiscan_drop_ms = 0; s_wifiscan_request = true; // disassoc settled -> run the sweep } + // RECOVERY: opening the Wi-Fi page drops the link and raises the scan gate, which is + // normally lowered in wifiScanService() once the worker reports s_wifiscan_done. But + // that flag is set ONLY by the tile-fetch worker, and that worker can fail to start at + // all (its 8 KB contiguous-DRAM stack allocation is best-effort) or sit blocked in a + // long tile/LOS/OTA job. If it never reports, the gate LATCHES: autoreconnect is off + // and main.cpp's 10 s reconnect retry stays suppressed (main.cpp gates it on + // !wifiScanIsActive()), so Wi-Fi never comes back until a reboot — a plausible source + // of "connects on a fresh flash, never reconnects after a reboot" (issue #171). The + // deadline is deliberately far longer than any legitimate sweep (the worker's own scan + // path allows several seconds plus retries) so this can only fire when the normal + // completion path is genuinely lost, never mid-scan. + if (s_wifiscan_guard_ms && wifiScanIsActive() && + (uint32_t)(millis() - s_wifiscan_guard_ms) >= 30000UL) { + s_wifiscan_guard_ms = 0; + s_wifiscan_reconnect_after = false; + s_wifiscan_drop_ms = 0; + wifiScanSetActive(false); // un-gate main.cpp's reconnect retry + WiFi.setAutoReconnect(true); + wifiConfigRequestApply(); // re-begin with the stored cred (main task) + } } #endif @@ -12810,6 +13474,7 @@ static void wifiScanService() { #if !defined(HAS_TANMATSU) if (s_wifiscan_reconnect_after) { // we dropped the link for this sweep — rejoin now s_wifiscan_reconnect_after = false; + s_wifiscan_guard_ms = 0; // normal completion — disarm the stuck-gate deadline wifiScanSetActive(false); WiFi.setAutoReconnect(true); wifiConfigRequestApply(); // main.cpp re-begins with the stored cred (main task) @@ -14590,6 +15255,7 @@ static void actionSheetRangeTestCb(lv_event_t* e) { uint32_t ack_hash = 0; int r = the_mesh.sendMessage(c, ts, 0, "RangeTest \xe2\x80\x94 ACK?", ack_hash, est, &hash4); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { + the_mesh.uiRegisterExpectedAck(ack_hash, c.id.pub_key); markMeshRequest(); // status-bar async spinner g_lv.task->showAlert(r == MSG_SEND_SENT_DIRECT ? TR("RangeTest sent (direct)") : TR("RangeTest sent (flood)"), 1400); @@ -16192,6 +16858,7 @@ static lv_obj_t* s_home_adv_btn = nullptr; #endif // Compact legend label above the chart showing live TX/RX totals. static lv_obj_t* s_home_chart_legend = nullptr; +static lv_obj_t* s_home_store = nullptr; // chat-store chip, right half of the legend row static lv_obj_t* s_home_chart_sig = nullptr; // live signal chip drawn inside the graph box #if CAP_LARGE_SCREEN static lv_obj_t* s_home_info = nullptr; // Commander info-panel values column (big screen) — refreshed live @@ -16335,6 +17002,21 @@ static void calibrateBatteryCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(b, 3000); } +// ----- SD wedge detection (flag side; the probe/remount lives in sdHealthTick) ----- +// A runtime SD failure is a one-way door in arduino-esp32: one timed-out command +// latches STA_NOINIT (or a data-phase error leaves FR_DISK_ERR everywhere), while +// SD.cardType() keeps returning the type CACHED at mount — so every +// `cardType() != CARD_NONE` gate still passes, I/O fails silently, and nothing +// ever re-runs the mount ladder until a reboot power-cycles the card. Any SD +// user (loop task, core-0 worker, notify task) that sees SD I/O fail while the +// card is supposedly mounted stamps this; UITask::loop verifies with real I/O +// and remounts if the card wedged. Volatile store only — safe from any task. +static volatile uint32_t s_sd_fail_note_ms = 0; +static inline void sdNoteIoFailure() { + const uint32_t m = (uint32_t)millis(); + s_sd_fail_note_ms = m ? m : 1; +} + // ----- Battery history: 5-minute log to SD + a 24h chart popup (T-Deck only) ----- // Logging piggybacks the always-on UITask::loop (no extra wakeup): every // k_batt_log_period_ms a line is appended to /meshcomod/battery.log and the file @@ -16347,7 +17029,12 @@ static void calibrateBatteryCb(lv_event_t* e) { // SPIFFS (flat) uses a top-level path. static fs::FS& battLogFs() { #if CAP_SD - if (SD.cardType() != CARD_NONE && SD.exists("/meshcomod")) return SD; + if (SD.cardType() != CARD_NONE) { + if (SD.exists("/meshcomod")) return SD; + // cardType() says present (cached) but real I/O failed — wedge/removal tell. + // (Or just a card without /meshcomod; sdHealthTick's probe arbitrates cheaply.) + sdNoteIoFailure(); + } #endif return SPIFFS; } @@ -16374,7 +17061,7 @@ static void batteryLogAppend(uint32_t epoch, uint16_t mv, int pct, uint16_t cpu_ if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = fs.open(battLogPath(), FILE_READ); File wf = fs.open(battLogTmp(), FILE_WRITE); // truncate/create - if (!wf) { if (rf) rf.close(); return; } + if (!wf) { if (rf) rf.close(); if (battLogOnSd()) sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -16734,7 +17421,7 @@ static void telemetryLogAppend(const uint8_t* key, uint32_t epoch, int mv, int t if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = SD.open(path, FILE_READ); File wf = SD.open(tmp, FILE_WRITE); - if (!wf) { if (rf) rf.close(); return; } + if (!wf) { if (rf) rf.close(); sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -18022,6 +18709,12 @@ static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); } // already initialised by the radio/display, so those pins are untouched. static bool fmSdTryMount() { if (s_sd_mounted) return true; + // Fast-fail inside the post-failure backoff window. Hot paths call this + // directly (map tile lookups on the loop task, history writes via + // uiDataFsReady, WAV chimes) — without this gate every such call re-walks + // the multi-second mount ladder while the card is out, freezing the UI. + // Explicit user retries clear the backoff first (fmSdMountOrFormatCb). + if (s_sd_retry_after_ms && millis() < s_sd_retry_after_ms) return false; #if defined(TLORA_PAGER) if (!pagerSdCardPresent()) return false; #endif @@ -18037,8 +18730,11 @@ static bool fmSdTryMount() { // mounts on the first attempt and returns immediately; only a flaky/cold card // walks the whole ladder (~1.1 s worst case). The cumulative delay can // approach the loop watchdog window, so drop the WDT around the loop. 4 MHz - // is reliable on the shared LoRa bus; max_files=3 keeps the VFS footprint - // small (dir + at most 2 files). + // is reliable on the shared LoRa bus. max_files=6 (was 3): the card now + // serves telemetry log rewrites (2 handles), battery log rewrites (2), + // the history flush, tile reads/writes, WAV chimes and the About readout + // CONCURRENTLY — with only 3 VFS slots the history flush's open("w") lost + // that race and chat saves failed with ENFILE while the card was fine. // Ladder of (settle, clock) attempts. The first three use the fast 4 MHz the // shared LoRa bus handles well; a cold / cheap card that won't wake at 4 MHz // then gets progressively longer settles AND a lower clock (1 MHz, then @@ -18059,16 +18755,32 @@ static bool fmSdTryMount() { }; const int kAttempts = (int)(sizeof(kMountLadder) / sizeof(kMountLadder[0])); bool mounted = false; - disableLoopWDT(); - for (int attempt = 0; attempt < kAttempts; ++attempt) { - SD.end(); - delay(kMountLadder[attempt].settle_ms); - if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 3) && SD.cardType() != CARD_NONE) { - mounted = true; - break; + uint32_t mounted_hz = 0; + { + LoopWdtGuard loop_wdt_guard; + for (int attempt = 0; attempt < kAttempts; ++attempt) { + SD.end(); + delay(kMountLadder[attempt].settle_ms); + if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { + mounted = true; + mounted_hz = kMountLadder[attempt].hz; + break; + } + } + // Renegotiate upward after a slow-rung success (GH #194): the succeeding rung's clock used + // to become the SESSION clock, so a card whose wake-up needed 400 kHz served contacts, + // history and tiles at ~25 KB/s forever after ("slower is fine" above predates the SD being + // the primary store). Init slow, then raise — fall back to the proven clock if 4 MHz fails. + if (mounted && mounted_hz < 4000000) { + SD.end(); + delay(60); + if (!(SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE)) { + SD.end(); + delay(120); + mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + } } } - enableLoopWDT(); if (mounted) { s_sd_mounted = true; s_sd_size = SD.cardSize(); @@ -18084,6 +18796,35 @@ static void fmSdUnmount() { if (s_sd_mounted) { SD.end(); s_sd_mounted = false; s_sd_size = 0; } s_sd_retry_after_ms = 0; // a reinsert should be able to mount right away } +// True when the card still answers real I/O. SD.cardType() is useless for this: +// arduino-esp32 caches the type at mount and only an explicit SD.end() ever +// returns CARD_NONE again — a wedged (or even physically removed) card keeps +// reporting its mount-time type forever. Opening the root dir DOES hit the card: +// the root `stat` is answered from RAM (vfs_fat special-cases "/"), but the +// opendir behind it runs f_opendir -> disk_status = a real SEND_STATUS +// transaction, and a NOINIT'd card then gets one full re-init handshake — so a +// recoverable card self-heals right here, a dead one returns an invalid File +// (opendir fails -> operator bool false). Healthy-card cost is sub-ms; a dead +// card rides out the SPI timeouts once, so callers rate-limit the probe. +static bool sdProbeAlive() { + File d = SD.open("/"); + const bool ok = d && d.isDirectory(); + if (d) d.close(); + return ok; +} +// Discriminator for hot READ paths (map tile lookups run up to five SD opens +// per tile on the loop task): a missing FILE on a healthy card must stay cheap +// and silent, while a dead card must stamp the failure AND let the caller +// short-circuit — otherwise one render pass serializes behind sd_diskio's +// per-op timeout/re-init and the whole UI freezes while a card is out. Call +// after a failed SD read/open; returns true when the card itself is dead +// (bail out of the whole SD pass), false when it was just a missing file. +static bool sdReadFailedCardDead() { + if (s_sd_fail_note_ms) return true; // already suspected — sdHealthTick arbitrates within ~5 s + if (sdProbeAlive()) return false; // card fine: genuinely missing file + sdNoteIoFailure(); + return true; +} static void fmSdClickCb(lv_event_t* e) { // SHORT_CLICKED (not CLICKED) so a long-press — which formats the card — does // not also fall through and open it. @@ -18132,6 +18873,7 @@ static void fmSdDoFormat() { // (e.g. exFAT, which this build can't read — only FAT16/FAT32) offer to format. static void fmSdMountOrFormatCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + s_sd_retry_after_ms = 0; // explicit user retry — bypass the mount backoff if (fmSdTryMount()) { fmShowRoots(); return; } showConfirm(TR("Format SD as MESHCOMOD (FAT32)?\nAll data on the card will be erased."), "Format", fmSdDoFormat); @@ -18142,7 +18884,11 @@ static void fmSdMountOrFormatCb(lv_event_t* e) { // SETTINGS (exported config), plus MAPS/LOGS. Idempotent (mkdir-if-absent), so // it's also safe to re-run on an already-set-up card. static void sdEnsureMeshcomodFolders() { - static const char* const folders[] = { "/BINS", "/UPDATES", "/RECBCK", "/SETTINGS", "/MAPS", "/LOGS" }; + // /meshcomod + the chat-segment dir were MISSING here for years: after an + // in-session FAT32 format every chat/telemetry write failed at open (ENOENT) + // until a remount or reboot recreated the data root. + static const char* const folders[] = { "/BINS", "/UPDATES", "/RECBCK", "/SETTINGS", "/MAPS", "/LOGS", + "/meshcomod", "/meshcomod/msgs" }; for (unsigned i = 0; i < sizeof(folders) / sizeof(folders[0]); ++i) if (!SD.exists(folders[i])) SD.mkdir(folders[i]); if (!SD.exists("/README.TXT")) { @@ -20490,6 +21236,17 @@ static const char* discTypeName(uint8_t t) { default: return "node"; } } +// Single-letter type for the LIST rows (#191): the full word plus a long node name pushed the +// trailing last-heard age off the 320px line. The summary header keeps the full words. +static const char* discTypeShort(uint8_t t) { + switch (t) { + case ADV_TYPE_REPEATER: return "R"; + case ADV_TYPE_CHAT: return "C"; + case ADV_TYPE_ROOM: return "Rm"; + case ADV_TYPE_SENSOR: return "S"; + default: return "?"; + } +} // ---- Wardriving: log GPS-tagged sightings as we scan (a standalone, on-device MeshMapper) ---- // While the Discover app scans, sample our GPS position; at each sample, log every currently-heard @@ -20512,7 +21269,7 @@ static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::Di SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/discover"); File f = SD.open("/meshcomod/discover/wardrive.csv", FILE_APPEND); - if (!f) return; + if (!f) { sdNoteIoFailure(); return; } if (f.size() == 0) f.print("epoch,lat,lon,type,pubkey,rssi,snr,hops\n"); uint32_t epoch = (uint32_t)rtc_clock.getCurrentTime(); f.printf("%lu,%.6f,%.6f,%s,%02X%02X%02X%02X,%d,%.1f,%u\n", @@ -20571,6 +21328,8 @@ static uint8_t s_disc_row_type[DISC_MAXROWS]; static char s_disc_row_name[DISC_MAXROWS][26]; static int s_disc_row_n = 0; +static void recolorEscape(char* dst, size_t cap, const char* src); // defined with the chat helpers + // Rebuild the feed label from the engine's _discover[] table, strongest-signal first. static void discoverBuildFeed() { if (!s_discover_feed) return; @@ -20594,9 +21353,14 @@ static void discoverBuildFeed() { const MyMesh::DiscoverHit& h = hits[idx[k]]; if (h.node_type == ADV_TYPE_REPEATER) rpt++; else if (h.node_type == ADV_TYPE_CHAT) comp++; + // #223: a name can carry emoji (multi-byte UTF-8) or a literal '#'. Cutting it at + // a byte count splits a sequence, and an unescaped '#' toggles this label's + // recolor mode — either desyncs the '#RRGGBB …#' runs and visually kills every + // row after this one. Keep a structurally-clean copy here (font=nullptr: emoji + // preserved for the tap-to-add snapshot), and escape a capped display copy below. char name[26]; ContactInfo* c = the_mesh.lookupContactByPubKey((uint8_t*)h.pubkey, 6); - if (c && c->name[0]) snprintf(name, sizeof name, "%s", c->name); + if (c && c->name[0]) copyUtf8ReplacingMissingGlyphs(nullptr, name, sizeof name, c->name); else snprintf(name, sizeof name, "Node \xC2\xB7%02X%02X", h.pubkey[0], h.pubkey[1]); memcpy(s_disc_row_key[k], h.pubkey, 32); // snapshot this row for tap-to-add s_disc_row_type[k] = h.node_type; @@ -20607,9 +21371,15 @@ static void discoverBuildFeed() { if (age < 60) snprintf(ago, sizeof ago, "%us", (unsigned)age); else snprintf(ago, sizeof ago, "%um", (unsigned)(age / 60)); const char* direct = (h.path_len == 0) ? " #53C06B direct#" : ""; + // #191: cap the name to 14 bytes (on a codepoint boundary, glyphs mapped to the + // feed's font) + single-letter type so the trailing age ALWAYS fits the line. + // recolorEscape doubles '#' so a hash in a name can't break the colour run. + char disp[15], disp_esc[30]; + copyUtf8ReplacingMissingGlyphs(&g_font_12, disp, sizeof disp, name); + recolorEscape(disp_esc, sizeof disp_esc, disp); q += snprintf(buf + q, sizeof(buf) - q, "#%06X %s# %s %ddBm %.1f%s \xC2\xB7 %s\n", - (unsigned)discTypeColor(h.node_type), name, discTypeName(h.node_type), + (unsigned)discTypeColor(h.node_type), disp_esc, discTypeShort(h.node_type), (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, direct, ago); } if (q == 0) snprintf(buf, sizeof buf, "#7A7F87 Scanning\xE2\x80\xA6 nothing has answered yet#"); @@ -21800,6 +22570,32 @@ static void homeControlPanelCb(lv_event_t* e) { // "Control panel" launcher -> if (lv_event_get_code(e) == LV_EVENT_CLICKED) toggleControlCenter(); } +// Chat-store chip for the Commander legend row (right of the TX/RX totals). +// Deliberately terse — it shares one line with the traffic counts and must fit +// half the chart width at g_font_12. The full story (backend, failure stage + +// errno) stays on the About page's "Chat store" panel; this chip only has to +// make a sick store impossible to miss from the home screen. Returns the text +// colour to use. +static uint32_t homeStoreChipText(char* out, size_t cap) { + if (s_msgs_write_fails) { // saves are failing right now + snprintf(out, cap, LV_SYMBOL_SAVE " FAIL x%u", (unsigned)s_msgs_write_fails); + return 0xE05252; // red — history is NOT landing + } + if (!s_seg_store_ready) { // old-format file not converted yet + snprintf(out, cap, LV_SYMBOL_SAVE " migrating"); + return 0xF5A623; // amber + } + // Healthy: WHEN the store last saved. A clock time answers "is my history + // safe right now?" directly, where a size doesn't — and the segment count / + // byte total (plus the failure stage + errno) stay one tap away on the About + // page. Older than a day carries a "-<N>D" suffix so a store that quietly + // stopped saving yesterday can't read as fresh. + char when[16]; + chatSaveStamp(when, sizeof when, s_msgs_write_ok_epoch); + snprintf(out, cap, LV_SYMBOL_SAVE " %s", when); + return COLOR_SUB; +} + static void makeHome(lv_obj_t* tab) { // Layout (240 wide × 282 tall): title + heartbeat + battery at top, status // lines, TX/RX chart in the middle, Send Advert button at the bottom. @@ -21833,6 +22629,7 @@ static void makeHome(lv_obj_t* tab) { s_home_batt_pct = nullptr; s_home_batt_icon = nullptr; s_home_chart_sig = nullptr; + s_home_store = nullptr; // created with the legend row below g_lv.home_apps = nullptr; // set below iff the right-hand launcher column is built (landscape) #if defined(HAS_EXPANSION_KIT) s_home_env_chart = nullptr; @@ -21963,6 +22760,23 @@ static void makeHome(lv_obj_t* tab) { #else const int chart_w = cw; #endif + // Chat-store chip: shares the legend row with the TX/RX totals, parked on the + // RIGHT HALF of the chart width (fixed width + right-aligned + CLIP, so it can + // never grow into the traffic counts on the left, at any UI scale). Not + // clickable — the legend owns this row's taps (signal/traffic popup). + s_home_store = lv_label_create(tab); + lv_label_set_long_mode(s_home_store, LV_LABEL_LONG_CLIP); + lv_obj_set_width(s_home_store, chart_w / 2); + lv_obj_set_style_text_align(s_home_store, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); + lv_obj_set_style_text_font(s_home_store, &g_font_12, LV_PART_MAIN); + { + char chip[24]; + const uint32_t col = homeStoreChipText(chip, sizeof chip); + lv_label_set_text(s_home_store, chip); + lv_obj_set_style_text_color(s_home_store, lv_color_hex(col), LV_PART_MAIN); + } + lv_obj_align(s_home_store, LV_ALIGN_TOP_LEFT, chart_w - chart_w / 2, chart_y); + // Fit the chart in the remaining vertical space: content height minus the // tab padding, the chart's top offset, and the Send-advert button + gaps. // Portrait keeps the full 96 px; landscape (short screen) shrinks it so the @@ -23993,16 +24807,11 @@ static void tileFetchTaskFn(void* arg) { s_los_result_ready = true; continue; } - // Chat-history flush: write the loop thread's snapshot. busy is raised BEFORE - // req is cleared so the loop-side gate (busy || req) never sees a gap in which - // it could overwrite the snapshot mid-write. - if (s_hist_flush_req) { - s_hist_flush_busy = true; - s_hist_flush_req = false; - s_hist_flush_ok = uiHistWorkerFlush(); - s_hist_flush_busy = false; - continue; - } + // (Chat-history flush moved to its own core-1 task — histFlushTaskFn. On + // THIS worker it shared core 0 with the Wi-Fi driver, whose prio-23 tasks + // starve a prio-1 task for hundreds of ms; sd_diskio's busy-waits measure + // wall time, so healthy SD writes surfaced as timeouts/EIO here while the + // identical write from core 1 succeeded.) // Firmware update check (one-shot, infrequent). Reuses this worker's stack. if (s_verchk_request) { s_verchk_request = false; @@ -24255,11 +25064,17 @@ static void tileFetchTaskFn(void* arg) { } else { ++s_tile_fetch_short_wr; s_tile_fetch_last_wr = 'P'; // short/failed disk write (card full or SD error) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) +#endif } } } else { ++s_tile_fetch_open_fail; s_tile_fetch_last_wr = 'O'; // open("w") failed: dir missing / write-protect / SD bus +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) +#endif } } else { s_tile_fetch_last_wr = 'Z'; // bad / oversized content-length @@ -24363,6 +25178,54 @@ static bool ensureTileFetchTaskRunning() { return s_tile_fetch_task != nullptr; } +// ---- Chat-history flush worker (core 1) -------------------------------------- +// The flush used to ride the core-0 tile_fetch worker — the Wi-Fi core. Under +// Wi-Fi load its prio-23 driver tasks starve a prio-1 task for hundreds of ms +// at a time, and sd_diskio's busy-waits measure WALL time: the timeout expires +// while the task simply wasn't scheduled, so perfectly good SD writes surface +// as EIO ("card I/O error") — while the identical write from the core-1 loop +// task (the reboot-time persist) succeeds every time. Chat persistence has no +// Wi-Fi affinity, so it gets its own tiny task pinned to core 1 at the loop +// task's priority (equal-priority time-slicing keeps the UI serviced during a +// write). The task is persistent; the 100 ms poll of the volatile req flag +// costs nothing measurable and mirrors the old worker's pickup latency. +static TaskHandle_t s_hist_flush_task = nullptr; +static StaticTask_t s_hist_flush_tcb; +static StackType_t* s_hist_flush_stack = nullptr; +static size_t s_hist_flush_stack_bytes = 0; +static void histFlushTaskFn(void*) { + for (;;) { + // busy is raised BEFORE req is cleared so the loop-side gate (busy || req) + // never sees a gap in which it could overwrite the snapshot mid-write. + if (s_hist_flush_req) { + s_hist_flush_busy = true; + s_hist_flush_req = false; + s_hist_flush_ok = uiSegRunArmedJob(); // segmented store: one append/compact per job + s_hist_flush_busy = false; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } +} +static bool ensureHistFlushTaskRunning() { + if (s_hist_flush_task != nullptr) return true; + if (!s_hist_flush_stack) { + // The segment writers keep their bulk buffers on the heap; stack use is + // File objects + FatFs path work. 6 KB leaves ample headroom (the tile + // worker's overflow-into-globals history earned the caution). + static const size_t k_sz = 6 * 1024; + s_hist_flush_stack = (StackType_t*)heap_caps_malloc(k_sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!s_hist_flush_stack) return false; + s_hist_flush_stack_bytes = k_sz; + } + s_hist_flush_task = xTaskCreateStaticPinnedToCore( + histFlushTaskFn, "hist_flush", + s_hist_flush_stack_bytes / sizeof(StackType_t), + nullptr, 1, + s_hist_flush_stack, &s_hist_flush_tcb, + 1 /*core 1 — NOT the Wi-Fi core; see block comment*/); + return s_hist_flush_task != nullptr; +} + // Queue a missing tile for download. No-op when Wi-Fi is down or the // tile was queued recently. Called from renderMapTiles after a SPIFFS // miss; the actual fetch happens off-thread. @@ -24381,7 +25244,15 @@ static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { #endif if (WiFi.status() != WL_CONNECTED) return; if (tileFetchSeen(z, x, y)) return; - ensureTileFetchTaskRunning(); + // HONOR the result: ensureTileFetchTaskRunning() creates the QUEUE first and can + // still fail afterwards on the worker's 8 KB contiguous-DRAM stack. Ignoring it left + // a queue with no consumer, so every send below incremented s_tile_fetch_pending with + // nothing to ever drain it. That counter is only ever ++/--, never reset, and it gates + // ALL notification sounds (tdeckPlayNotifySlot and friends bail while it is > 0), so a + // single failed spawn silently killed the chime for the rest of the boot — the "lost + // sound until I restart" report (issue #184). No tile is lost by returning here: with + // no worker the fetch could never have happened anyway. + if (!ensureTileFetchTaskRunning()) return; if (!s_tile_fetch_queue) return; TileFetchReq req = {z, x, y}; if (xQueueSend(s_tile_fetch_queue, &req, 0) == pdTRUE) { @@ -24506,7 +25377,12 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, #if CAP_SD if (s_tiles_from_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). - if (!fmSdTryMount()) return false; + if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) + // Mounted check only — a render loop must never walk the multi-second + // mount ladder per tile (with the card out that read as recurring UI + // freezes). Mounting is owned by boot adoption, sdHealthTick's reinsert + // watch, the FM poll, and the SD-tiles toggle (mapOptTilesSdCb mounts). + if (!s_sd_mounted) return false; markSdIo(); // SD read activity -> status-bar LED // Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png // (decoded via lodepng); fall back to the legacy /tiles/{z}/{x}/{y}.jpg. @@ -24529,14 +25405,14 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, fsd = SD.open(ppath, FILE_READ); } if (!fsd) fsd = SD.open(path, FILE_READ); // legacy /tiles/<z>/<x>/<y>.jpg - if (!fsd) return false; + if (!fsd) { sdReadFailedCardDead(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit) const size_t szsd = fsd.size(); if (szsd == 0 || szsd > 256 * 1024) { fsd.close(); return false; } // PNG tiles run larger than JPEG uint8_t* bufsd = (uint8_t*)lvglPsramAlloc(szsd); if (!bufsd) { fsd.close(); return false; } const size_t nsd = fsd.read(bufsd, szsd); fsd.close(); - if (nsd != szsd) { lvglPsramFree(bufsd); return false; } + if (nsd != szsd) { lvglPsramFree(bufsd); sdReadFailedCardDead(); return false; } // short read mid-tile = card died under us *out_data = bufsd; *out_len = szsd; return true; } @@ -24548,8 +25424,18 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, // re-downloaded forever and rendered nothing (#tiles). open() is the real existence test; read up to // the 100 KB writer cap and use the ACTUAL bytes read. (S3 boards: f.size() works there, but this is // equally correct — a transient 100 KB PSRAM buffer per tile, freed right after decode.) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + // Launcher installs cache tiles on the raw SD (s_tile_fs == &SD) — same + // dead-card short-circuit as the SD-pack path above. + if (s_tile_fs == &SD && s_sd_fail_note_ms) return false; +#endif File f = tileCacheOpen(path, "r"); - if (!f) return false; + if (!f) { +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdReadFailedCardDead(); +#endif + return false; + } const size_t CAP = 100 * 1024; uint8_t* buf = (uint8_t*)lvglPsramAlloc(CAP); if (!buf) { f.close(); return false; } @@ -24582,6 +25468,35 @@ static void freeMapTiles() { for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) freeMapTileSlot(s_map_tiles[_ti]); } +// The tile storage changed underneath us: a card was remounted (wedge recovery, +// reinsert watch, file-manager insert poll, post-format) or lost. The map keeps +// decoded tiles in s_map_tiles across renders and only renders on pan / zoom / +// tab-open, so without this a card that came back showed a permanently blank +// map — the chat store re-landed (flushHistorySoon) while the map was never +// told anything had happened. +// +// * adopts the card as the tile cache when this boot had no backend at all +// (booted card-less, or the "tiles" partition is absent — Launcher installs) +// * drops the decoded tiles, since a swapped card holds different content +// * clears the fetch dedup ring so tiles whose download failed during the +// outage are retried instead of being remembered as in-flight forever +// * re-renders immediately when the map is the visible tab +static void mapNoteStorageChanged() { +#if CAP_SD + if (s_sd_mounted && !s_tiles_fs_ready) { + // No tile backend was resolved at boot; the card can serve as one now + // (same layout the boot fallback uses: SD ROOT /tiles/<z>/<x>/<y>). + s_tile_fs = &SD; + s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + } +#endif + for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; + s_tile_fetch_dedup_head = 0; + freeMapTiles(); + if (g_lv.tabview && lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX) renderMapTiles(); +} + #if defined(ESP32) // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { @@ -24598,7 +25513,8 @@ static bool mapTileSourceReady() { static bool tileExistsAt(uint8_t z, long x, long y) { #if CAP_SD if (s_tiles_from_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below - if (!fmSdTryMount()) return false; + if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) + if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg) char p[56]; snprintf(p, sizeof p, "/maps/osm/%u/%ld/%ld.png", (unsigned)z, x, y); if (SD.exists(p)) return true; @@ -24609,7 +25525,9 @@ static bool tileExistsAt(uint8_t z, long x, long y) { snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.PNG", (unsigned)z, x, y); if (SD.exists(p)) return true; snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.jpg", (unsigned)z, x, y); - return SD.exists(p); + if (SD.exists(p)) return true; + sdReadFailedCardDead(); // all five missing: either past the pack edge (cheap, silent) or the card died + return false; } #endif if (!s_tiles_fs_ready) return false; @@ -26882,6 +27800,15 @@ static void applyMapChrome(bool on) { } static void makeMapTab(lv_obj_t* tab) { +#if defined(TLORA_PAGER) + // Diagnostic/map chrome stays compact at every Pager UI-size preset so the + // overlays do not hide the map itself. + const lv_font_t* map_info_font = &lv_font_montserrat_14; + const lv_font_t* map_control_font = &lv_font_montserrat_20; +#else + const lv_font_t* map_info_font = &g_font_12; + const lv_font_t* map_control_font = &g_font_16; +#endif lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); @@ -26929,7 +27856,7 @@ static void makeMapTab(lv_obj_t* tab) { lv_label_set_long_mode(s_map_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_map_status_lbl, k_map_canvas_w - 20); lv_obj_set_style_text_color(s_map_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); - lv_obj_set_style_text_font(s_map_status_lbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_font(s_map_status_lbl, map_info_font, LV_PART_MAIN); lv_obj_set_style_text_align(s_map_status_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_label_set_text(s_map_status_lbl, TR("Map — no tile pack on SPIFFS yet.\n\n" @@ -26943,7 +27870,7 @@ static void makeMapTab(lv_obj_t* tab) { // dark map patches. auto style_corner = [&](lv_obj_t* l) { lv_obj_set_style_text_color(l, lv_color_hex(0x000000), LV_PART_MAIN); - lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_font(l, map_info_font, LV_PART_MAIN); lv_obj_set_style_bg_color(l, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_bg_opa(l, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_pad_hor(l, 4, LV_PART_MAIN); @@ -27001,7 +27928,7 @@ static void makeMapTab(lv_obj_t* tab) { lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, sym); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); - lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); + lv_obj_set_style_text_font(l, map_control_font, LV_PART_MAIN); lv_obj_center(l); return b; }; @@ -28653,7 +29580,14 @@ static void openMessageActionMenu(int msg_idx) { // smaller boards keep plain integers (PSC is a no-op there). Buttons sit TWO // per row — with Delete the menu carries up to 7 actions, and a single column // no longer fit the 240-px screens without scrolling. -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // Spend the Pager's horizontal room instead of its scarce vertical room. + const int card_w = sw - 80; + const int btn_h = 30; + const int gap = 6; + const int pad = 10; + const int hdr_h = 24; +#elif CAP_LARGE_SCREEN const int card_w = PCW(220); const int btn_h = PSC(30); const int gap = PSC(4); @@ -28716,7 +29650,11 @@ static void openMessageActionMenu(int msg_idx) { if (can_ack) mk_btn(LV_SYMBOL_OK " Ack", msgMenuAckCb); if (can_mention) { char ml[UITask::MAX_SENDER_NAME + 16]; +#if defined(TLORA_PAGER) + snprintf(ml, sizeof ml, "@%.16s", m.sender); // wide Pager cells can keep more of the name +#else snprintf(ml, sizeof ml, "@%.10s", m.sender); // "Mention" is implied by the @; half-width cell +#endif mk_btn(ml, msgMenuMentionCb); } mk_btn(LV_SYMBOL_COPY " Copy", msgMenuCopyCb); @@ -28793,7 +29731,12 @@ static void openMessageInfoPopup(int msg_idx) { const bool show_trace = !m.channel; const int route_pts = buildRouteFromMessage(m); const bool show_route = (route_pts >= 2); -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // Reduce wrapping across the wide, short Pager. The independently scrollable + // body handles routes and ACK details that exceed the available height. + const int card_w = sw - 28; + int card_h = sh - STATUSBAR_H - 8; +#elif CAP_LARGE_SCREEN const int card_w = PCW(220); int card_h = (show_trace || show_route) ? PSC(290) : PSC(250); #else @@ -28915,7 +29858,7 @@ static void openMessageInfoPopup(int msg_idx) { // Bounded (<= ECHO_MAX_HOPS) + hard buffer guard + read-only lookup -> no risk. uint8_t rhc = the_mesh.uiRepeatHopCount(m.sent_fp); for (uint8_t r = 0; r < rhc; r++) { - if (blen >= (int)sizeof(body) - 56) break; + if (blen >= (int)sizeof(body) - 96) break; // escaped name (<=64B) + markers must fit whole uint8_t hh[4]; uint8_t hsz = the_mesh.uiRepeatHop(m.sent_fp, r, hh, sizeof(hh)); if (hsz == 0) continue; @@ -28923,10 +29866,15 @@ static void openMessageInfoPopup(int msg_idx) { for (uint8_t b = 0; b < hsz && b < 4; ++b) snprintf(hashstr + b * 2, sizeof(hashstr) - b * 2, "%02X", hh[b]); char nm[33]; // ContactInfo.name is 32B; hold the full name (emoji eat 4B each) - if (the_mesh.uiHopName(hh, hsz, nm, sizeof(nm))) + if (the_mesh.uiHopName(hh, hsz, nm, sizeof(nm))) { + // #223 class: this name sits inside a '#RRGGBB …#' run — a literal '#' or a + // broken UTF-8 tail in it desyncs every colour block after this line. + char nm_c[33], nm_esc[66]; + copyUtf8ReplacingMissingGlyphs(nullptr, nm_c, sizeof nm_c, nm); + recolorEscape(nm_esc, sizeof nm_esc, nm_c); blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", - (unsigned)nodeSigColorHex(nm), nm); // name resolved -> no redundant hash - else + (unsigned)nodeSigColorHex(nm), nm_esc); // name resolved -> no redundant hash + } else blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", (unsigned)nodeSigColorHex(hashstr), hashstr); } @@ -28998,7 +29946,14 @@ static void openMessageInfoPopup(int msg_idx) { lv_obj_set_pos(bodywrap, 0, body_top); lv_obj_set_style_bg_opa(bodywrap, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_scroll_dir(bodywrap, LV_DIR_VER); - lv_obj_set_scrollbar_mode(bodywrap, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_scrollbar_mode(bodywrap, +#if defined(TLORA_PAGER) + LV_SCROLLBAR_MODE_ON +#else + LV_SCROLLBAR_MODE_AUTO +#endif + ); + lv_obj_add_event_cb(bodywrap, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); // Thin visible scrollbar (remove_style_all wiped the theme's). lv_obj_set_style_width(bodywrap, 4, LV_PART_SCROLLBAR); lv_obj_set_style_bg_color(bodywrap, lv_color_hex(0x6FA8DA), LV_PART_SCROLLBAR); @@ -29560,33 +30515,26 @@ static void chatVirtCheckStoreEdges(LvChatPanel* p) { } #endif -static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, - bool thread_is_room, lv_coord_t bubble_max_w) { - ChatBubbleDisplay d{}; - chatParseMessageDisplay(m, channel_mode, thread_is_room, d); - const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH; - lv_coord_t inner_y = 0; - if ((channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]) - inner_y += lv_font_get_line_height(&g_font_12); - - lv_point_t txt_size; - lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w; - lv_point_t wrapped_size; - lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, - txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); +static void chatBuildBubbleMeta(const UITask::UIMessage& m, bool channel_mode, + char* out, size_t out_len, uint32_t* out_fg) { + if (out && out_len > 0) out[0] = '\0'; + if (out_fg) *out_fg = COLOR_SUB; + if (!out || out_len == 0) return; char ts_buf[20]; formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf)); + const char* deliv_glyph = ""; + uint32_t deliv_fg = COLOR_SUB; if (m.outgoing && !channel_mode && m.deliv_state != UITask::DELIV_NONE) { switch (m.deliv_state) { - case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; break; - case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; break; - case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE; break; + case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break; + case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break; + case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break; default: break; } } + char rep_buf[12] = ""; if (m.outgoing && m.sent_fp) { const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp); @@ -29597,15 +30545,67 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); } - lv_coord_t body_h = inner_y + wrapped_size.y; - if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) { - char footer[40]; - snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf); - lv_point_t foot_size; - lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - body_h += 1 + foot_size.y; + snprintf(out, out_len, "%s%s%s", ts_buf, deliv_glyph, rep_buf); + if (out_fg) *out_fg = deliv_glyph[0] ? deliv_fg : COLOR_SUB; +} + +static lv_coord_t chatTextWidth(const char* s) { + if (!s || !s[0]) return 0; + lv_point_t size; + lv_txt_get_size(&size, s, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return size.x; +} + +static bool chatUtf8Continuation(char c) { + return (static_cast<uint8_t>(c) & 0xC0) == 0x80; +} + +static void chatFitLeadingEllipsis(const char* src, lv_coord_t max_w, char* out, size_t out_len) { + if (!out || out_len == 0) return; + out[0] = '\0'; + if (!src || !src[0] || max_w <= 0) return; + if (chatTextWidth(src) <= max_w) { + snprintf(out, out_len, "%s", src); + return; } - return kChatBubblePadV * 2 + body_h; + + const char* ell = "..."; + if (chatTextWidth(ell) > max_w) return; + + const size_t len = strlen(src); + for (size_t i = 0; i < len; ++i) { + if (chatUtf8Continuation(src[i])) continue; + char cand[48]; + snprintf(cand, sizeof(cand), "%s%s", ell, src + i); + if (chatTextWidth(cand) <= max_w) { + snprintf(out, out_len, "%s", cand); + return; + } + } + snprintf(out, out_len, "%s", ell); +} + +static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, + bool thread_is_room, lv_coord_t bubble_max_w) { + ChatBubbleDisplay d{}; + chatParseMessageDisplay(m, channel_mode, thread_is_room, d); + const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH; + lv_coord_t inner_y = 0; + char meta_buf[48]; + chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); + // Bubble layout: timestamp/meta always share the top row (channels, DMs, rooms). + const bool show_sender = (channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]; + if (show_sender || meta_buf[0]) + inner_y += lv_font_get_line_height(&g_font_12); + + lv_point_t txt_size; + lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w; + lv_point_t wrapped_size; + lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, + txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); + + return kChatBubblePadV * 2 + inner_y + wrapped_size.y; } static void chatBuildCompactLine(const UITask::UIMessage& m, LvChatPanel* p, int logical_i, @@ -30314,17 +31314,67 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_set_style_pad_ver(bubble, kChatBubblePadV, LV_PART_MAIN); lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH; + lv_point_t txt_size; + lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW; + + char meta_buf[48]; + uint32_t meta_fg = COLOR_SUB; + chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); + // All bubble-style threads (channel / DM / room): timestamp + delivery meta on the top row. + const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && + !m.outgoing && d.san_sender[0]; + + const lv_coord_t sender_w = show_sender_line ? chatTextWidth(d.san_sender) : 0; + const lv_coord_t meta_w = meta_buf[0] ? chatTextWidth(meta_buf) : 0; + lv_coord_t inner_w = txt_w_used > 0 ? txt_w_used : 1; + { + lv_coord_t header_w = (show_sender_line ? sender_w : 0) + meta_w; + if (show_sender_line && meta_w) header_w += 6; + if (header_w > kInnerMaxW) header_w = kInnerMaxW; + if (header_w > inner_w) inner_w = header_w; + } + int inner_y = 0; - lv_coord_t inner_w = 0; // widest child (sender/text/footer) -> analytic bubble width for x-alignment - if ((p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]) { - lv_obj_t* slbl = lv_label_create(bubble); - lv_label_set_text(slbl, d.san_sender); - lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); - lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); - lv_obj_set_pos(slbl, 0, inner_y); - inner_y += lv_font_get_line_height(&g_font_12); - lv_point_t ss; lv_txt_get_size(&ss, d.san_sender, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - if (ss.x > inner_w) inner_w = ss.x; + // Analytic bubble width for x-alignment (widest of sender/text/meta) — do not use + // lv_obj_get_width() right after create; unsettled layout can mis-place outgoing bubbles. + if (show_sender_line || meta_buf[0]) { + const lv_coord_t line_h = lv_font_get_line_height(&g_font_12); + char meta_fit[48] = ""; + lv_coord_t meta_fit_w = 0; + lv_coord_t sender_label_w = sender_w; + + if (meta_buf[0]) { + lv_coord_t available_meta_w = inner_w - (show_sender_line ? (sender_w + 6) : 0); + const lv_coord_t ell_w = chatTextWidth("..."); + if (show_sender_line && available_meta_w < ell_w && inner_w > ell_w + 6) { + sender_label_w = inner_w - ell_w - 6; + available_meta_w = ell_w; + } + chatFitLeadingEllipsis(meta_buf, available_meta_w, meta_fit, sizeof(meta_fit)); + meta_fit_w = chatTextWidth(meta_fit); + } + + if (show_sender_line && sender_label_w > 0) { + lv_obj_t* slbl = lv_label_create(bubble); + lv_label_set_text(slbl, d.san_sender); + lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); + if (sender_label_w < sender_w) { + lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(slbl, sender_label_w); + } + lv_obj_set_pos(slbl, 0, inner_y); + } + if (meta_fit[0]) { + lv_obj_t* mlbl = lv_label_create(bubble); + lv_label_set_text(mlbl, meta_fit); + lv_obj_set_style_text_font(mlbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(mlbl, lv_color_hex(meta_fg), LV_PART_MAIN); + lv_obj_set_pos(mlbl, inner_w - meta_fit_w, inner_y); + } + inner_y += line_h; } lv_obj_t* tlbl = lv_label_create(bubble); @@ -30338,10 +31388,6 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i char rc[UITask::MAX_MSG_TEXT + 40]; if (chatRecolorUrls(d.san_text, rc, sizeof rc)) { lv_label_set_recolor(tlbl, true); lv_label_set_text(tlbl, rc); } } - const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH; - lv_point_t txt_size; - lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW; if (txt_size.x > kInnerMaxW) lv_label_set_long_mode(tlbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(tlbl, txt_w_used); if (txt_w_used > inner_w) inner_w = txt_w_used; @@ -30356,53 +31402,14 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_add_event_cb(bubble, bubbleUrlTapCb, LV_EVENT_SHORT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); // Failed sends keep the pre-virtualization one-tap resend (the compact path - // already has it); the footer below spells the affordance out. + // already has it); delivery status on the top meta row spells the affordance out. if (m.outgoing && m.deliv_state == UITask::DELIV_FAILED) lv_obj_add_event_cb(bubble, bubbleRetryTapCb, LV_EVENT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx))); - char ts_buf[20]; - formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf)); - const char* deliv_glyph = ""; - uint32_t deliv_fg = COLOR_SUB; - if (m.outgoing && !p->channel_mode && m.deliv_state != UITask::DELIV_NONE) { - switch (m.deliv_state) { - case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break; - case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break; - case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break; - default: break; - } - } - char rep_buf[12] = ""; - if (m.outgoing && m.sent_fp) { - const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp); - if (reps > 0) snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_REFRESH "%u", (unsigned)reps); - } else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX) - && (m.meta_flags & UITask::MSG_META_IS_FLOOD)) { - const uint8_t hops = static_cast<uint8_t>(m.path_len & 0x3F); - snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); - } - if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) { - char footer[40]; - snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf); - lv_obj_t* foot = lv_label_create(bubble); - lv_label_set_text(foot, footer); - lv_obj_set_style_text_font(foot, &g_font_12, LV_PART_MAIN); - lv_obj_set_style_text_color(foot, lv_color_hex(deliv_glyph[0] ? deliv_fg : COLOR_SUB), LV_PART_MAIN); - lv_point_t wrapped_size; - lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, - txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); - lv_point_t foot_size; - lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - if (foot_size.x > inner_w) inner_w = foot_size.x; - const int foot_x = (txt_w_used > foot_size.x) ? (txt_w_used - foot_size.x) : 0; - const int foot_y = inner_y + wrapped_size.y + 1; - lv_obj_set_pos(foot, foot_x, foot_y); - } - lv_obj_update_layout(bubble); lv_coord_t bh = lv_obj_get_height(bubble); - // Width for x-alignment is computed analytically from the widest child (text/sender/footer), + // Width for x-alignment is computed analytically from the widest child (text/sender/meta), // NOT lv_obj_get_width(): right after a send the layout isn't settled and get_width reads wide, // which put the (actually narrow) outgoing bubble on the LEFT until the next rebuild. lv_coord_t bw = inner_w + 2 * kChatBubblePadH; @@ -31152,8 +32159,15 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); - lv_obj_set_style_min_height(btn, 56, LV_PART_MAIN); - lv_obj_set_height(btn, 56); +#if defined(TLORA_PAGER) + static constexpr lv_coord_t kThreadRowH = 48; + static constexpr lv_coord_t kThreadAvatar = 34; +#else + static constexpr lv_coord_t kThreadRowH = 56; + static constexpr lv_coord_t kThreadAvatar = 40; +#endif + lv_obj_set_style_min_height(btn, kThreadRowH, LV_PART_MAIN); + lv_obj_set_height(btn, kThreadRowH); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // Avatar: same FNV-1a hue family as the chat-bubble colours (see @@ -31164,7 +32178,7 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_remove_style_all(av); lv_obj_add_flag(av, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(av, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); // taps fall through to the row - lv_obj_set_size(av, 40, 40); + lv_obj_set_size(av, kThreadAvatar, kThreadAvatar); lv_obj_set_style_radius(av, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(av, lv_color_hsv_to_rgb((uint16_t)(hh % 360u), 55, 42), LV_PART_MAIN); lv_obj_set_style_bg_opa(av, LV_OPA_COVER, LV_PART_MAIN); @@ -31213,7 +32227,7 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_remove_style_all(gear); lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_add_flag(gear, NAV_HMOVE_FLAG); - lv_obj_set_size(gear, gear_w, 40); + lv_obj_set_size(gear, gear_w, kThreadRowH - 8); lv_obj_align(gear, LV_ALIGN_RIGHT_MID, -2, 0); lv_obj_add_event_cb(gear, threadGearCb, LV_EVENT_CLICKED, &p.ctx_store[i]); lv_obj_t* gl = lv_label_create(gear); @@ -31241,7 +32255,7 @@ static void refreshChatList(LvChatPanel& p) { } // Name (top line) + last-message preview (bottom line), right of the avatar. - const lv_coord_t text_x = 8 + 40 + 8; + const lv_coord_t text_x = 8 + kThreadAvatar + 8; const lv_coord_t name_w = (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - time_w - 24); lv_obj_t* nm2 = lv_label_create(btn); lv_obj_add_flag(nm2, LV_OBJ_FLAG_IGNORE_LAYOUT); @@ -31251,8 +32265,20 @@ static void refreshChatList(LvChatPanel& p) { lv_label_set_long_mode(nm2, LV_LABEL_LONG_DOT); // Fixed ONE-LINE height: with only a width, LONG_DOT lets a long name wrap to // a second line (never truncating) and it overlapped the preview underneath. - lv_obj_set_size(nm2, name_w, 18); - lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, 9); + lv_obj_set_size(nm2, name_w, +#if defined(TLORA_PAGER) + lv_font_get_line_height(&g_font_14) +#else + 18 +#endif + ); + lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, +#if defined(TLORA_PAGER) + 3 +#else + 9 +#endif + ); // Preview: "sender: text" for channels, "You: text" for own DMs, plain text otherwise. char psender[UITask::MAX_SENDER_NAME + 1] = ""; @@ -31275,8 +32301,20 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_set_style_text_font(pv, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(pv, LV_LABEL_LONG_DOT); - lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), 16); // one line, ellipsized - lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, -8); + lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), +#if defined(TLORA_PAGER) + lv_font_get_line_height(&g_font_12) +#else + 16 +#endif + ); // one line, ellipsized + lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, +#if defined(TLORA_PAGER) + -3 +#else + -8 +#endif + ); // Unread pill bottom-right (under the time), @ to its left on a mention. if (unread > 0) { @@ -31673,8 +32711,12 @@ static void refreshContactsList() { // scrolling the name (rejected) or gluing the value columns. TWO-LINE rows instead: // line 1: the full name (one line, dot-ellipsized — never scrolls) // line 2: heard-age and distance, small + dim under the name - const int ROW_H = mid_cols ? 46 : 34; const int name_line_h_row = lv_font_get_line_height(&g_font_14); +#if defined(TLORA_PAGER) + const int ROW_H = 5 + name_line_h_row + 2 + lv_font_get_line_height(&g_font_12) + 5; +#else + const int ROW_H = mid_cols ? 46 : 34; +#endif const int row2_y = 5 + name_line_h_row + 2; // line 2 top (below the name line) int name_w = heard_x - name_x - 6; if (name_w < 50) name_w = 50; @@ -31740,7 +32782,9 @@ static void refreshContactsList() { // must render in g_font_16 for the PUA codepoints to resolve. Blocked // contacts get a RED person icon so they stand out at a glance. lv_obj_t* ic = lv_label_create(rb); - lv_label_set_text(ic, e.is_blocked ? TOUCH_SYM_PERSON : (is_rep ? TOUCH_SYM_ANTENNA : TOUCH_SYM_PERSON)); + lv_label_set_text(ic, e.is_blocked ? TOUCH_SYM_PERSON + : (is_rep ? TOUCH_SYM_ANTENNA + : (e.type == ADV_TYPE_ROOM ? LV_SYMBOL_LOOP : TOUCH_SYM_PERSON))); // rooms discernible at a glance (#106) lv_obj_set_style_text_font(ic, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(ic, lv_color_hex(e.is_blocked ? 0xD7574E : COLOR_SUB), LV_PART_MAIN); lv_obj_align(ic, LV_ALIGN_LEFT_MID, icon_x, 0); @@ -32391,6 +33435,42 @@ static void updatePagerKbBacklight(unsigned long now) { #endif #if defined(HAS_PAGER_ENCODER) +// A focus step normally asks LVGL to reveal the next object. On the Pager that +// makes a message or popup body taller than the viewport impossible to read: +// the next detent abandons it and reveals the following object. Consume detents +// as page-scrolls until the focused oversized object reaches its edge. +static bool pagerEncoderScrollOversizedFocused(bool up) { + lv_obj_t* focused = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; + if (!focused || !lv_obj_is_valid(focused)) return false; + + for (lv_obj_t* scroller = lv_obj_get_parent(focused); scroller; + scroller = lv_obj_get_parent(scroller)) { + if (!lv_obj_has_flag(scroller, LV_OBJ_FLAG_SCROLLABLE)) continue; + + lv_area_t viewport; + lv_area_t item; + lv_obj_get_coords(scroller, &viewport); + lv_obj_get_coords(focused, &item); + viewport.y1 += lv_obj_get_style_pad_top(scroller, LV_PART_MAIN); + viewport.y2 -= lv_obj_get_style_pad_bottom(scroller, LV_PART_MAIN); + const lv_coord_t view_h = viewport.y2 - viewport.y1 + 1; + const lv_coord_t item_h = item.y2 - item.y1 + 1; + if (view_h <= 0 || item_h <= view_h) return false; + + const bool unread_edge = up ? (item.y1 < viewport.y1) : (item.y2 > viewport.y2); + const lv_coord_t room = up ? lv_obj_get_scroll_top(scroller) + : lv_obj_get_scroll_bottom(scroller); + if (!unread_edge || room <= 0) return false; + + lv_coord_t step = view_h * 2 / 3; + if (step < 24) step = 24; + if (step > room) step = room; + lv_obj_scroll_by(scroller, 0, up ? step : -step, LV_ANIM_ON); + return true; + } + return false; +} + // T-LoRa Pager rotary encoder: a single linear nav axis (not 2D like the // trackball, so none of updateTrackball()'s game/emoji-grid/cursor special // cases apply) — each detent moves focus one step via the same navFifo the @@ -32644,8 +33724,16 @@ static void updatePagerEncoder(unsigned long now) { // top-bar items / back into the bubbles (pagerChatComposerNav), and the // edge bubble of a still-loading history scrolls the list instead of // stepping focus out (pagerEncoderChatEdgeScroll). - for (; delta > 0; delta--) { if (!pagerChatComposerNav(false) && !pagerEncoderChatEdgeScroll(false)) navPushTap(LV_KEY_NEXT); } - for (; delta < 0; delta++) { if (!pagerChatComposerNav(true) && !pagerEncoderChatEdgeScroll(true)) navPushTap(LV_KEY_PREV); } + for (; delta > 0; delta--) { + if (!pagerEncoderScrollOversizedFocused(false) && + !pagerChatComposerNav(false) && !pagerEncoderChatEdgeScroll(false)) + navPushTap(LV_KEY_NEXT); + } + for (; delta < 0; delta++) { + if (!pagerEncoderScrollOversizedFocused(true) && + !pagerChatComposerNav(true) && !pagerEncoderChatEdgeScroll(true)) + navPushTap(LV_KEY_PREV); + } } static constexpr uint32_t kLongPressMs = 1000; @@ -32931,7 +34019,18 @@ static void lockscreenShow() { // Wallpaper, scaled to cover the screen (crop overflow, never letterbox). int ww = 0, wh = 0; const uint8_t* wall_data = nullptr; - if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } + if (s_lock_wall) { + // Invalidate LVGL's image cache entry for s_lock_wall_dsc BEFORE freeing the + // buffer it points at. s_lock_wall_dsc is a static var (stable address), and + // LVGL's img cache is keyed by that src pointer -- without this, the next + // lockscreenShow() can hit a stale cache entry pointing at freed PSRAM that + // has since been reused elsewhere (e.g. the map's 128 KB tile buffers), + // rendering as RGB565 noise on wake. Same bug class already fixed for map + // tiles in freeMapTileSlot() -- see the comment there (issue #127). + lv_img_cache_invalidate_src(&s_lock_wall_dsc); + lvglPsramFree(s_lock_wall); + s_lock_wall = nullptr; + } char wpath[TOUCH_LOCK_WALLPAPER_MAXLEN]; touchPrefsGetLockWallpaper(wpath, sizeof wpath); if (!strcmp(wpath, "/lock/placeholder.jpg")) { @@ -33075,7 +34174,7 @@ static void lockscreenHide() { s_lock_status = nullptr; s_lock_hint = nullptr; #endif } - if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } + if (s_lock_wall) { lv_img_cache_invalidate_src(&s_lock_wall_dsc); lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } s_lock_clock_min = -1; s_lock_unread_n = -1; // Restore the status bar's normal opaque background + accent border. @@ -33715,6 +34814,7 @@ static void doFactoryReset() { lv_refr_now(nullptr); delay(150); // let the overlay actually hit the panel wdtHeavyBegin(); // SPIFFS format is a long flash burst + touchPrefsFlush(); // do not format under an open prefs worker handle #if CAP_SD factoryWipeSdData(); // SD /meshcomod data (tiles at /tiles root are kept) #endif @@ -34740,11 +35840,19 @@ static void refreshSettingsSectionSubtitles() { } } - // Live GPS fix status on the Device-settings line while it's open. (The - // control-center drop-down's GPS + system line are refreshed from the periodic - // refreshStatusLabels tick instead — this function only runs while Settings is.) - if (g_set_modal.root && g_set_modal.gps_status && - g_set_modal.kind == SettingsModalKind::Device) { + // Live GPS status while the GPS page (or the legacy Device modal) is open. (The control-center + // drop-down's GPS + system line are refreshed from the periodic refreshStatusLabels tick + // instead — this function only runs while Settings is.) + // + // This used to require kind == SettingsModalKind::Device, which stopped being true when the + // settings reorg moved the GPS block into its own PAGE (CAT_GPS -> buildDeviceSettings(DSEC_GPS)). + // The condition then never matched, so the label was written once at build time and never again: + // the page looked frozen and only "updated" when you closed and reopened it — including the + // elapsed-search timer, which made a working receiver look stuck. Accept either host, and guard + // with lv_obj_is_valid so a stale pointer from a closed page can never be written. + if (g_set_modal.gps_status && lv_obj_is_valid(g_set_modal.gps_status) && + (s_settings_open_cat == CAT_GPS || + (g_set_modal.root && g_set_modal.kind == SettingsModalKind::Device))) { lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); } @@ -34932,10 +36040,13 @@ static void powerOffCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh + touchPrefsFlush(); // and all queued A/B preference snapshots g_lv.task->showAlert(TR("Powering off\xE2\x80\xA6 click trackball to wake"), 1500); } // Let the toast paint, then enter deep sleep. @@ -34981,9 +36092,12 @@ static void powerDownloadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring + touchPrefsFlush(); // and all queued A/B preference snapshots g_lv.task->showAlert(TR("Download mode\xE2\x80\xA6 reflash over USB"), 1500); } lv_refr_now(NULL); @@ -35476,7 +36590,7 @@ static void openControlCenter() { s_cc_gps_label = lv_label_create(card); lv_label_set_long_mode(s_cc_gps_label, LV_LABEL_LONG_DOT); lv_obj_set_width(s_cc_gps_label, card_w - 20); - lv_label_set_text(s_cc_gps_label, gpsStatusStr()); + lv_label_set_text(s_cc_gps_label, gpsStatusStr(true)); // one line — keeps the dropdown short lv_obj_set_style_text_font(s_cc_gps_label, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_cc_gps_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(s_cc_gps_label, LV_ALIGN_TOP_LEFT, 0, gps_y); @@ -35677,7 +36791,9 @@ static void openControlCenter() { #if !CAP_GPS ccToggle(row, LV_SYMBOL_GPS, "GPS", false, ccGpsNoneCb, tw, th, -1); // no onboard GPS — info-only, untoggable #else - ccToggle(row, LV_SYMBOL_GPS, "GPS", gps_on, ccGpsCb, tw, th, CAT_RADIO); + // Long-press opens the GPS settings PAGE. CAT_RADIO here was a leftover from before the + // settings reorg gave GPS its own category (the GPS block used to live under radio/device). + ccToggle(row, LV_SYMBOL_GPS, "GPS", gps_on, ccGpsCb, tw, th, CAT_GPS); #endif ccToggle(row, LV_SYMBOL_TINT, "Theme", false, ccThemeCb, tw, th, CAT_DISPLAY); #if CAP_KEYBOARD @@ -35711,7 +36827,7 @@ static void openControlCenter() { enum AppDrawerAction { APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS, APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL, - APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, + APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_AIRTIME, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, APPACT_DISCOVER, }; @@ -35957,6 +37073,7 @@ static void appTileCb(lv_event_t* e) { case APPACT_SIGNAL: openSignalInfoPopup(); return; // signal/traffic + auto-discover settings case APPACT_MONITOR: openMonitorPage(); return; // RF activity graph + repeater-style radio stats case APPACT_SPECTRUM: openSpectrumPage(); return; // swept RF spectrum analyzer (borrows the radio) + case APPACT_AIRTIME: ChannelUtil::launch(); return; // channel-utilization / congestion proof tool case APPACT_DISCOVER: openDiscoverPage(); return; // active node-discovery sweep + nearby list #if !defined(HAS_TANMATSU) case APPACT_VNC: openVncPage(); return; // screen mirror + remote control from a browser @@ -36010,7 +37127,7 @@ static void addAppTile(lv_obj_t* parent, int x, int y, int w, int h, // Rounded-square chip (iOS-style squircle), tinted with the app's accent // colour, centred up top. Bigger + a small proportional corner radius so it // reads as a SQUARE app icon, not a circle. -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE // Reserve the ACTUAL label line height (it grows with the UI-scale font) so the // name never overlaps the icon chip on the big panel. int chip = h - (lv_font_get_line_height(big ? &g_font_14 : &g_font_12) + 10); @@ -36317,6 +37434,7 @@ static void openAppDrawer() { { nullptr, "Signal", APPACT_SIGNAL, 0, COLOR_ACCENT }, // theme (drawn signal bars) { TOUCH_SYM_ANTENNA, "Monitor", APPACT_MONITOR, 0, 0x35C9C9 }, // RF monitor cyan { TOUCH_SYM_ANTENNA, "Spectrum", APPACT_SPECTRUM, 0, 0xE8A33D }, // RF spectrum analyzer amber + { "%", "Airtime", APPACT_AIRTIME, 0, 0xF2793C }, // channel-utilization / congestion proof (orange) { "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber { LV_SYMBOL_SETTINGS, "Settings", APPACT_SETTINGS, 0, 0x9AA3AD }, // neutral gear grey #if defined(HAS_TOUCH_UI) @@ -36408,11 +37526,11 @@ static void statusBarHoldCb(lv_event_t* e) { static void statusBarTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; - // While Snake is up the bar is raised to the FRONT of lv_layer_top (see the - // edge-trigger in updateGlobalStatusBar), so it stays tappable over the game. The - // game's own X can sit UNDER a taller status bar on big-screen boards (the P4), - // leaving no reachable exit — so a bar tap closes Snake here (the guaranteed way out). - if (SnakeGame::isOpen()) { SnakeGame::dismiss(); return; } + // Snake and Airtime used to need their own cases here because they hand-rolled their + // chrome; they now install s_apppage_close via appPageBegin like every other app page, + // so the generic hook below covers them — including the PRESSED fallback in + // statusBarReaderBackCb, which they never got and which is the only reliable exit on a + // cap-touch board that drops the lone CLICKED. if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold // A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back // chevron + title, so a tap goes back (closes the page) just like settings. @@ -36626,15 +37744,23 @@ static bool tsBleOff() { // — batteryIsCharging(batteryMvSmoothed())). static bool tsOnBattery() { return !batteryIsCharging(batteryMvSmoothed()); } // tsMeshIdle: true when the radio is NOT mid-receive (preamble→RxDone race guarded), -// AND no outbound packets are queued / no dirty contacts expiry is pending. -// hasPendingWork() checks _mgr->getOutboundTotal() + dirty_contacts_expiry. +// AND no outbound packet / retry / contact write is due now. Future retry and +// write deadlines are allowed to use the timed idle-power-saving path. // isRadioReceiving() delegates to Dispatcher::_radio->isReceiving() (RadioLibWrapper override). static bool tsMeshIdle() { return !the_mesh.hasPendingWork() && !the_mesh.isRadioReceiving(); } -// tsNextWakeForcingDueMs: advert/sig-probe is the only wake-forcing deadline today; -// clock alarms TBD. s_sig_probe_at is promoted to file scope so we can read it here. +// Bound the timed idle park by the earliest UI probe or companion retry. static uint32_t tsNextWakeForcingDueMs(uint32_t now_ms) { - if (s_sig_probe_at == 0) return UINT32_MAX; - return (uint32_t)(s_sig_probe_at > now_ms ? (s_sig_probe_at - now_ms) : 0); + uint32_t next_delay = UINT32_MAX; + if (s_sig_probe_at != 0) { + next_delay = (uint32_t)(s_sig_probe_at > now_ms ? (s_sig_probe_at - now_ms) : 0); + } + + uint32_t retry_delay = 0; + if (the_mesh.getNextCompanionRetryWakeDelay(retry_delay) + && retry_delay < next_delay) { + next_delay = retry_delay; + } + return next_delay; } static uint32_t tsEpochNow() { return (uint32_t)time(nullptr); } @@ -36821,6 +37947,12 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_color(g_statusbar.left_label, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.left_label, &g_font_14, LV_PART_MAIN); lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 6, 0); +#if defined(TLORA_PAGER) + // Keep the large-text title out of the clock/status cluster. Individual home + // names may opt into a marquee; other titles truncate inside this window. + lv_obj_set_width(g_statusbar.left_label, 200); + lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); +#endif // Tapping the unread badge (✉ N) jumps to the Chats inbox. The CLICKABLE flag is // toggled per-tick in updateGlobalStatusBar so only the badge state intercepts the // tap; every other left-zone state falls through to the bar's control-center tap. @@ -36870,7 +38002,13 @@ static void buildGlobalStatusBar() { lv_label_set_text(g_statusbar.batt_pct, "?"); lv_obj_set_style_text_color(g_statusbar.batt_pct, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.batt_pct, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_RIGHT_MID, -SC(22), 0); + lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -32, +#else + -SC(22), +#endif + 0); // Tapping the battery (icon or %) opens the 24h battery-history chart (logged to // SD on T-Deck, else internal SPIFFS — works on both builds). Generous ext-click @@ -36895,14 +38033,26 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_font(g_statusbar.clock, &g_font_12, LV_PART_MAIN); // Unified across all boards: extra slot reserved for the DND/sleep moon glyph // (T-Deck's sleep_icon and the all-board dnd_icon below both live at -SC(105)). - lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, -SC(160), 0); + lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -210, +#else + -SC(160), +#endif + 0); // Wi-Fi glyph (right of the Bluetooth glyph, left of the signal bars). g_statusbar.conn_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.conn_icon, ""); lv_obj_set_style_text_color(g_statusbar.conn_icon, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.conn_icon, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -SC(73), 0); + lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -104, +#else + -SC(73), +#endif + 0); // Bluetooth glyph (left of the SD LED). Unified offset across all boards (see clock above). g_statusbar.ble_icon = lv_label_create(g_statusbar.root); @@ -36911,7 +38061,13 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_font(g_statusbar.ble_icon, &g_font_12, LV_PART_MAIN); // Narrow bars (V4 portrait) have no DND slot beside BLE and their clock sits at -126, so BLE // stays at -111 there; wide bars keep -SC(127) (tight to the DND moon at -144). - lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, (lv_disp_get_hor_res(nullptr) < 300) ? -111 : -SC(127), 0); + lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -142, +#else + (lv_disp_get_hor_res(nullptr) < 300) ? -111 : -SC(127), +#endif + 0); #if defined(HAS_TDECK_GT911) // Idle power-save readiness indicator — moon glyph, left of the SD LED. T-Deck only, @@ -36937,7 +38093,13 @@ static void buildGlobalStatusBar() { lv_label_set_text(g_statusbar.dnd_icon, TOUCH_SYM_MOON); lv_obj_set_style_text_color(g_statusbar.dnd_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.dnd_icon, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144), 0); + lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -164, +#else + -SC(144), +#endif + 0); lv_obj_add_flag(g_statusbar.dnd_icon, LV_OBJ_FLAG_HIDDEN); // shown only while DND is active lv_obj_add_flag(g_statusbar.dnd_icon, NAV_SKIP_FLAG); // passive status glyph, not a focus-nav target @@ -36952,7 +38114,13 @@ static void buildGlobalStatusBar() { lv_obj_set_style_bg_color(g_statusbar.sd_icon, lv_color_hex(0xF5A623), LV_PART_MAIN); // amber = SD activity lv_obj_set_style_bg_opa(g_statusbar.sd_icon, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -SC(91), 0); + lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -124, +#else + -SC(91), +#endif + 0); lv_obj_add_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_HIDDEN); // shown only during SD I/O // Async mesh-request spinner — a refresh glyph centred in the bar, blinking @@ -36972,7 +38140,13 @@ static void buildGlobalStatusBar() { lv_obj_t* sb = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(sb); lv_obj_set_size(sb, 15, 12); - lv_obj_align(sb, LV_ALIGN_RIGHT_MID, -SC(54), 0); + lv_obj_align(sb, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -82, +#else + -SC(54), +#endif + 0); lv_obj_clear_flag(sb, LV_OBJ_FLAG_SCROLLABLE); g_statusbar.sig_box = sb; const int bw = 3, gap = 1; @@ -37063,12 +38237,12 @@ static void updateGlobalStatusBar() { // is the Snake overlay — the bar swallows taps over the game. { static bool s_bar_fg = false; - // Raise the bar to the FRONT of lv_layer_top for (a) Snake (it swallows taps over the - // game) and (b) a tool page (RF Monitor / Spectrum) — those roots are also on - // lv_layer_top and moved foreground, so without this the TALL bar's lower half (back - // chevron + title) is covered by the page. Settings sheets sit on lv_scr_act so they - // never need this. - const bool want_fg = SnakeGame::isOpen() || (s_apppage_title != nullptr); + // Raise the bar to the FRONT of lv_layer_top for any app page (Spectrum, RF Monitor, + // Snake, Airtime, ...): those roots are also on lv_layer_top and moved foreground, so + // without this the TALL bar's lower half (back chevron + title) is covered by the page. + // Settings sheets sit on lv_scr_act so they never need this. One condition covers every + // page now that Snake and Airtime set s_apppage_title too. + const bool want_fg = (s_apppage_title != nullptr); if (want_fg) { // Keep the bar the TOPMOST lv_layer_top child so its back tap is always hittable — // re-assert every tick (a page opened over a chat can otherwise let the chat's @@ -37222,8 +38396,13 @@ static void updateGlobalStatusBar() { if (s_fullscreen_view && s_fullscreen_title[0]) home_zone = false; #endif if (!home_zone && s_left_home_cfg) { +#if defined(TLORA_PAGER) + lv_obj_set_width(g_statusbar.left_label, 200); + lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); +#else lv_obj_set_width(g_statusbar.left_label, LV_SIZE_CONTENT); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_WRAP); +#endif s_left_home_cfg = false; s_left_home_name[0] = '\0'; // force re-config on the next home entry } @@ -37302,7 +38481,12 @@ static void updateGlobalStatusBar() { // smaller font — otherwise on the narrow V4 portrait bar its end ("…Map") // runs into the Wi-Fi icon. Restore the normal font on other tabs. lv_obj_set_style_text_font(g_statusbar.left_label, - tab == MAP_TAB_INDEX ? &g_font_12 : &g_font_14, LV_PART_MAIN); +#if defined(TLORA_PAGER) + tab == MAP_TAB_INDEX ? &lv_font_montserrat_14 : &g_font_14, +#else + tab == MAP_TAB_INDEX ? &g_font_12 : &g_font_14, +#endif + LV_PART_MAIN); if (tab == MAP_TAB_INDEX) { // On the immersive map the left zone carries the required OSM attribution. lv_label_set_text(g_statusbar.left_label, s_map_style == 1 @@ -37339,6 +38523,8 @@ static void updateGlobalStatusBar() { // runs from the left inset up to just before the leftmost icon (Bluetooth, whose // right anchor is 126+inset from the right edge — reserve its glyph + a margin). lv_obj_set_width(g_statusbar.left_label, sb_w - (SB_INSET_X + 126 + 44)); +#elif defined(TLORA_PAGER) + lv_obj_set_width(g_statusbar.left_label, 200); #else lv_obj_set_width(g_statusbar.left_label, (sb_w >= 600) ? (sb_w - SC(190)) : (sb_w >= 300) ? 100 : 66); // #47a: narrower window on the 240px V4 bar so a long name can't run into the clock @@ -37427,8 +38613,16 @@ static void updateGlobalStatusBar() { // Dedicated slot — re-assert on entry/return so a CAP_ROTATABLE board // (V4) rotating out of portrait mid-DND self-heals from the borrowed // signal-bars position instead of staying stranded there. - const int d = batteryIsCharging(batteryMvSmoothed()) ? 32 : 0; -#if CAP_LARGE_SCREEN + const int d = batteryIsCharging(batteryMvSmoothed()) ? +#if defined(TLORA_PAGER) + 45 +#else + 32 +#endif + : 0; +#if defined(TLORA_PAGER) + lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); +#elif CAP_LARGE_SCREEN lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144) + SC(d), 0); #else lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -144 + d, 0); @@ -37489,6 +38683,14 @@ static void updateGlobalStatusBar() { // Round panel: re-apply the two-row layout, sliding the row-2 sub-battery cluster // right by the hidden %-column width so it stays snug against the bolt. statusBarLayoutTwoRow(charging ? 32 : 0); +#elif defined(TLORA_PAGER) + const int d = charging ? 45 : 0; + if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_RIGHT_MID, -82 + d, 0); + if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -104 + d, 0); + if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -124 + d, 0); + if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, -142 + d, 0); + if (g_statusbar.dnd_icon) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); + if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -232 + d, 0); #elif CAP_LARGE_SCREEN // Tanmatsu: the cluster is built with SC() UI-scaling, so this slide MUST scale too — the raw // offsets below marched the scaled glyphs (incl. the BLE icon) into each other at Large/Huge the @@ -37608,8 +38810,13 @@ static void updateGlobalStatusBar() { // Wide bar shifted 18px further left of its old -142/-110 to open a clean // ~16-17px gap for the DND moon icon (now at -SC(144)) on Bluetooth's left // side, instead of the old cramped 15px gap that used to sit here. - const int clk_x = narrow_bar ? (charging ? -94 : -126) + const int clk_x = +#if defined(TLORA_PAGER) + charging ? -165 : -210; +#else + narrow_bar ? (charging ? -94 : -126) : (charging ? -SC(128) : -SC(160)); +#endif lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, clk_x, 0); } // Park the async-request spinner just LEFT of the clock wherever it lands, @@ -37733,6 +38940,10 @@ static void relayoutHomeCharts() { const int legend_y = env_chart_y + SC(34) + SC(12); lv_obj_set_pos(s_home_chart_legend, 0, legend_y); + if (s_home_store) { // chat-store chip rides the same row + lv_obj_set_width(s_home_store, chart_w / 2); + lv_obj_set_pos(s_home_store, chart_w - chart_w / 2, legend_y); + } const int home_avail = tabContentH() - 20; int chart_h = home_avail - (legend_y + 16) - 4 - (home_land ? 0 : (8 + 36)); @@ -37768,7 +38979,7 @@ static void refreshStatusLabels() { // CPU/RAM/PSRAM/IP line current — e.g. the IP appears/clears as Wi-Fi // connects/drops, without having to close and reopen the panel. if (s_cc_root) { - if (s_cc_gps_label) setLabelIfChanged(s_cc_gps_label, gpsStatusStr()); + if (s_cc_gps_label) setLabelIfChanged(s_cc_gps_label, gpsStatusStr(true)); #if defined(HAS_EXPANSION_KIT) if (s_cc_env_label) setLabelIfChanged(s_cc_env_label, localEnvStatusStr()); #endif @@ -37782,6 +38993,19 @@ static void refreshStatusLabels() { if (g_lv.tabview) active_tab = lv_tabview_get_tab_act(g_lv.tabview); const bool home_active = (active_tab == HOME_TAB_INDEX); if (home_active) refreshHomeBattery(); + // Chat-store chip (legend row). Updated independently of the TX/RX chart — + // the big-screen scaled layout drops the chart entirely, and the store state + // still has to be visible there. + if (home_active && s_home_store) { + char chip[24]; + const uint32_t col = homeStoreChipText(chip, sizeof chip); + setLabelIfChanged(s_home_store, chip); + static uint32_t s_home_store_col = 0; // avoid re-styling (and invalidating) every tick + if (col != s_home_store_col) { + s_home_store_col = col; + lv_obj_set_style_text_color(s_home_store, lv_color_hex(col), LV_PART_MAIN); + } + } // Push a TX/RX sample onto the home chart: delta packets since last tick. if (home_active && s_home_chart && s_home_chart_tx && s_home_chart_rx) { static uint32_t last_tx = 0; @@ -38087,7 +39311,7 @@ static void buildBootSplash() { // ---- Product line: MESHCOMOD (small, same size as the channel line, between // the WADAMESH wordmark and TOUCH BETA) ---- lv_obj_t* mc = lv_label_create(s_splash_root); - lv_label_set_text(mc, "MESHCOMOD"); + lv_label_set_text(mc, "WADAMESH"); // #165: the unlock overlay still said MESHCOMOD after the rebrand lv_obj_set_style_text_font(mc, &lv_font_unscii_8, LV_PART_MAIN); lv_obj_set_style_text_color(mc, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_letter_space(mc, 4, LV_PART_MAIN); @@ -38728,18 +39952,47 @@ static void blockedModalClose() { } } static bool blockedModalIsOpen() { return s_blocked_modal != nullptr; } +// Unblock is PAIRED with the block: blocking a channel sender who is also a saved +// contact writes BOTH a name entry (what the channel RX path matches) and a pubkey +// entry (what the DM path matches), so clearing only one would leave the sender +// still silenced on the other path — an "I unblocked them and they are still gone" +// bug. Each callback therefore clears its own entry and its twin. static void blockedUnblockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_MAX) return; - touchPrefsSetIgnored(&s_blocked_snap[idx * TOUCH_IGNORE_KEY_BYTES], false); + uint8_t pub6[TOUCH_IGNORE_KEY_BYTES]; + memcpy(pub6, &s_blocked_snap[idx * TOUCH_IGNORE_KEY_BYTES], sizeof pub6); + touchPrefsSetIgnored(pub6, false); + // Twin: this pubkey's contact name may also hold a name entry (same prefix match + // the modal uses to label the row). + ContactInfo c; + const int nc = the_mesh.getNumContacts(); + for (int j = 0; j < nc; ++j) { + if (the_mesh.getContactByIdx((uint32_t)j, c) && + memcmp(c.id.pub_key, pub6, TOUCH_IGNORE_KEY_BYTES) == 0) { + if (c.name[0]) touchPrefsSetNameIgnored(c.name, false); + break; + } + } openBlockedUsersModal(); // rebuild the list } static void blockedUnblockNameCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_NAMES_MAX) return; - touchPrefsSetNameIgnored(&s_blocked_names_snap[idx * TOUCH_IGNORED_NAME_LEN], false); + char nm[TOUCH_IGNORED_NAME_LEN]; + snprintf(nm, sizeof nm, "%s", &s_blocked_names_snap[idx * TOUCH_IGNORED_NAME_LEN]); + touchPrefsSetNameIgnored(nm, false); + // Twin: a saved contact with this name may also hold a pubkey entry. + ContactInfo c; + const int nc = the_mesh.getNumContacts(); + for (int j = 0; j < nc; ++j) { + if (the_mesh.getContactByIdx((uint32_t)j, c) && strcmp(c.name, nm) == 0) { + touchPrefsSetIgnored(c.id.pub_key, false); + break; + } + } openBlockedUsersModal(); // rebuild the list } static void openBlockedUsersModal() { @@ -39556,7 +40809,11 @@ static void buildUiTree() { #endif lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS); // Slightly larger font for icons so they're easy to tap. +#if CAP_UI_SIZE + lv_obj_set_style_text_font(tab_btns, &g_font_tab, LV_PART_MAIN); +#else lv_obj_set_style_text_font(tab_btns, &g_font_16, LV_PART_MAIN); +#endif // Home and Set tabs scroll vertically at the tab page level for (lv_obj_t* t : {tab_home, tab_settings}) { @@ -40770,15 +42027,12 @@ void UITask::markThreadsDirty(unsigned long delay_ms) { } void UITask::markMsgsDirty(unsigned long delay_ms) { - // Every flush rewrites the WHOLE ring file on the loop thread, so space the - // writes out: at least 30 s for the deep SD ring (~1.1 MB at 5000 slots) and at - // least 10 s for the small internal-flash ring (~115 KB) — a busy channel used - // to trigger a full rewrite (+ possible SPIFFS GC) ~2 s after EVERY message, - // which read as "the device freezes 1-2 s every ~10 s" on stable beta_31. - // persistHistoryNow() still forces a write on reboot/shutdown, so a hard crash - // costs at most the last few seconds of messages. - if (_ui_msg_cap > MAX_UI_MESSAGES) { if (delay_ms < 30000) delay_ms = 30000; } - else { if (delay_ms < 10000) delay_ms = 10000; } + // Segmented store: a flush is now a small APPEND to the active segment + // (~240 B per message, off-thread), so the old 10/30 s whole-ring-rewrite + // clamps are gone — the hard-cut loss window shrinks to this coalesce. + // SPIFFS keeps a slightly higher floor purely as flash-wear pacing. + if (uiDataFsIsSdCard()) { if (delay_ms < 2000) delay_ms = 2000; } + else { if (delay_ms < 5000) delay_ms = 5000; } const unsigned long deadline = millis() + delay_ms; if (!_msgs_dirty || deadline < _next_msgs_flush_ms) _next_msgs_flush_ms = deadline; @@ -40786,18 +42040,73 @@ void UITask::markMsgsDirty(unsigned long delay_ms) { markThreadsDirty(1500); // thread state (last_ts, unread) changed too — coalesce a message burst into one write } -// Snapshot of the message ring handed to the core-0 worker for the off-thread -// write. Allocated once in PSRAM (ring-sized: ~115 KB SPIFFS ring / ~1.1 MB SD -// ring); the worker only ever touches the snapshot, never the live ring. -static UITask::UIMessage* s_hist_snap = nullptr; -static int s_hist_snap_cap = 0; -static uint16_t s_hist_snap_count = 0; -static uint16_t s_hist_snap_head = 0; -static uint32_t s_hist_snap_msgcount = 0; +// Arm an immediate flush of both history files WITHOUT the min-delay clamp and +// WITHOUT blocking this task: the actual writes happen on the hist_flush +// worker (or the sync fallback inside flushHistoryIfDue). Called only from the +// SD remount paths — the card may be a DIFFERENT one now, so the segment +// table is rebuilt from the ring (s_seg_resync) and the full history re-lands +// one bounded segment at a time. +void UITask::flushHistorySoon() { + s_seg_resync = true; + _msgs_dirty = true; + _next_msgs_flush_ms = millis(); + _threads_dirty = true; + _next_threads_flush_ms = millis(); +} void UITask::flushHistoryIfDue(unsigned long now) { - // A worker write failed (storage hiccup): re-arm and try again. - if (!s_hist_flush_ok) { s_hist_flush_ok = true; markMsgsDirty(5000); } + // Observe a finished worker job FIRST — and strictly BEFORE the failure + // branch below resets s_hist_flush_ok, or a FAILED job would read as ok + // here and get committed, advancing the durability watermark over records + // that never reached disk (silent permanent loss). + if (s_segjob_kind != SEGJOB_NONE && !s_hist_flush_busy && !s_hist_flush_req) { + if (s_hist_flush_ok) { + SegJob done{}; + done.kind = s_segjob_kind; + done.first_seq = s_segjob_first_seq; + done.last_seq = s_segjob_last_seq; + done.create = s_segjob_create; + done.n = s_segjob_n; + const uint32_t pre_wm = s_seg_flushed_seq; + segCommitJob(done); + // Deletes that raced the in-flight APPEND: segMarkSeqDirty skipped them + // while their seq was above the watermark ("never reaches disk") — but + // this job just put them on disk. Give their segments the compaction + // mark now. + if (s_seg_flushed_seq > pre_wm) { + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[slot]; + if (!m.thread[0] && m.seq > pre_wm && m.seq <= s_seg_flushed_seq) { + segMarkSeqDirty(m.seq); + _msgs_dirty = true; // schedule the follow-up compaction + } + } + } + } + s_segjob_kind = SEGJOB_NONE; // failed job: data stays pending below the watermark + s_segjob_redirty = false; + } + // A worker write failed (storage hiccup): re-arm and try again. Surface + // repeated failures — a persistently failing ring write was previously + // 100% silent and read as "messages from the last N minutes vanished" on + // the next reboot (the small threads file kept writing, so the thread + // list still showed fresh last-message times). + if (!s_hist_flush_ok) { + s_hist_flush_ok = true; + // Back off a hopelessly failing store: each retry against a card that + // times out on large writes costs seconds of bus stalls (felt as UI + // freezes through the FatFs volume lock). After 5 straight failures + // retry every 5 min instead of every 30 s — the RAM ring keeps + // everything meanwhile, and a successful write resets the counter. + markMsgsDirty(s_msgs_write_fails >= 5 ? 300000 : 5000); + if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) { + char why[64]; chatSaveFailText(why, sizeof why); + char msg[128]; + snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history is NOT saving"), why); + showAlert(msg, 3200); + } + } // Thread metadata (~4 KB) flushes on a short delay; the message ring // (scales with MAX_UI_MESSAGES) flushes lazily to reduce flash write pressure. if (_threads_dirty && now >= _next_threads_flush_ms) { @@ -40810,44 +42119,86 @@ void UITask::flushHistoryIfDue(unsigned long now) { } if (_msgs_dirty && now >= _next_msgs_flush_ms) { if (s_hist_flush_busy || s_hist_flush_req) { - // The worker only exists while the tile-fetch task is spawned (map / Wi-Fi - // scan / LOS / …). Without this kick a pending flush can sit armed forever - // while ui_threads_v1.bin keeps updating — messages live only in RAM. - ensureTileFetchTaskRunning(); - _next_msgs_flush_ms = now + 1000; // one flush in flight; new messages ride the next one + // A request is armed but the flush task may not be up yet — kick it so a + // pending flush can't sit armed forever while messages live only in RAM. + ensureHistFlushTaskRunning(); + _next_msgs_flush_ms = now + 1000; // one job in flight; new messages ride the next one return; } #if defined(ESP32) - // Snapshot the ring (a few ms of PSRAM memcpy) and hand the write to the - // core-0 worker. Messages arriving while the worker writes stay in the live - // ring and re-arm the dirty flag, so they land in the next flush. - if (!s_hist_snap || s_hist_snap_cap != _ui_msg_cap) { - if (s_hist_snap) { heap_caps_free(s_hist_snap); s_hist_snap = nullptr; } - s_hist_snap = (UITask::UIMessage*)heap_caps_malloc(sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap, - MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - s_hist_snap_cap = s_hist_snap ? _ui_msg_cap : 0; + // Off-thread writes keep failing? Fall back to the LOOP-TASK write — the + // empirically reliable path (the reboot-time persist always succeeds): + // when this task writes, nothing else interleaves radio/display traffic + // on the shared SPI bus between the SD transactions. Costs one UI hitch + // per flush while degraded; a single success resets the counter and the + // next flush goes back to the async task. The threshold is a setting + // (Settings -> General; default 2, 0 = never fall back). + const uint8_t sync_after = touchPrefsGetHistSyncAfter(); + if (sync_after && s_msgs_write_fails >= sync_after) { + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + (s_msgs_write_fails >= 5 ? 300000 : 5000); + return; } - if (s_hist_snap) { - memcpy(s_hist_snap, _ui_msgs, sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap); - s_hist_snap_count = (uint16_t)_ui_msg_count; - s_hist_snap_head = (uint16_t)_ui_msg_head; - s_hist_snap_msgcount = (uint32_t)_msgcount; - if (!ensureTileFetchTaskRunning()) { - // No worker — fall back to a synchronous write instead of clearing dirty - // and losing the snapshot (the whole point of the worker is to stay off - // the loop thread; a rare spawn failure must not drop hours of chat). - if (saveMsgsToStorage()) _msgs_dirty = false; - else _next_msgs_flush_ms = now + 2000; - return; - } - s_hist_flush_req = true; // worker picks it up + // A failed boot migration means the old-format file is still the on-disk + // truth — segments must not be written until it converts (they would + // shadow it in the loader's precedence order). The sync drain retries + // the migration. + if (!s_seg_store_ready) { + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + 60000; // store is sick; don't thrash the bus + return; + } + // Card remounted (possibly a different/blank card): rebuild the segment + // table from the ring — stale on-disk segments are removed and every + // chunk is marked compact-dirty, so the normal job flow below re-lands + // the whole history one bounded segment at a time, off-thread. + if (s_seg_resync) { + segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + _ui_seq_next ? _ui_seq_next - 1 : 0); + s_seg_resync = false; + } + // Build the next off-thread job: pending-append batch first, else the + // oldest compact-dirty segment. The snapshot is at most ONE segment's + // records (~80 KB) — not the old 1.3 MB whole-ring copy. + SegJob job{}; + const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + segEnsureBuf(&s_segjob_buf), &job); + if (armed == 0) { + // Everything durable. A resync's leftover files can go now that every + // table entry has a real, current file on disk. + if (s_seg_stale_purge) segPurgeStaleFiles(); _msgs_dirty = false; return; } -#endif - // No PSRAM for a snapshot: fall back to the old synchronous write. + if (armed < 0 || !ensureHistFlushTaskRunning()) { + // No snapshot buffer / no worker — synchronous drain instead of losing + // the flush (a rare spawn failure must not drop hours of chat). + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + 2000; + return; + } + // Arm the worker: descriptor written strictly before req (the worker + // reads it only after seeing req). + s_segjob_kind = job.kind; + s_segjob_first_seq = job.first_seq; + s_segjob_last_seq = job.last_seq; + s_segjob_create = job.create; + s_segjob_n = job.n; + s_segjob_redirty = false; + s_hist_flush_req = true; + // One job armed for the worker. A multi-segment backlog (burst bigger + // than one segment, or several dirty segments) keeps _msgs_dirty set and + // reschedules shortly; each cycle moves one segment. + if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) { + _next_msgs_flush_ms = now + 1500; + return; + } + _msgs_dirty = false; + return; +#else if (saveMsgsToStorage()) _msgs_dirty = false; else _next_msgs_flush_ms = now + 2000; +#endif } } @@ -40860,7 +42211,6 @@ void UITask::flushHistoryIfDue(unsigned long now) { // the chat to the SD card. static fs::FS* s_ui_data_fs = nullptr; static char s_ui_data_root[16] = ""; -static bool s_ui_data_resolved = false; static bool uiDataFsReady() { if (s_ui_data_fs != nullptr) return true; // cache SUCCESS only — a failed resolve MUST stay retryable #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) @@ -40870,6 +42220,12 @@ static bool uiDataFsReady() { // T-Deck's /meshcomod root; fall back to FFat only when no card is present. Both are mounted at // boot in main.cpp (g_sd_ok / g_fs_ok). (The P4 used to fall into the #else SPIFFS branch below — // no SPIFFS partition exists there, so history was never persisted and vanished on every reboot.) + // #167: hot UI data (chat history) lives on the INTERNAL LittleFS -- SD write bursts + // electrically disturb this board's AMOLED, and the P4's internal FAT layer is broken + // (see the storage note in tdisplay_p4/main/main.cpp). SD = degraded fallback only; + // tiles keep using the SD via their own selector. + extern bool g_fs_ok; + if (g_fs_ok) { s_ui_data_fs = &LittleFS; s_ui_data_root[0] = '\0'; return true; } extern bool g_sd_ok; if (g_sd_ok) { SD_MMC.mkdir("/meshcomod"); @@ -40877,8 +42233,6 @@ static bool uiDataFsReady() { strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } - extern bool g_fs_ok; - if (g_fs_ok) { s_ui_data_fs = &FFat; s_ui_data_root[0] = '\0'; return true; } #elif defined(HAS_TDECK_GT911) // T-Deck: the SD card is the persistent user-data store (large, removable, // survives a reflash) and is where chat history already lives. Prefer it; only @@ -40919,9 +42273,23 @@ static bool uiDataFsIsSdCard() { #endif } static File uiDataOpen(const char* name, const char* mode) { +#if defined(TDP4_POKE_TRACE) + // #167 hunt: every ui-data open with its issuing core. Writes ([SDW]) were all correctly on + // core 0 during a flashing manual flow -- so now READS are traced too ([SDR]): a read is the + // same FATFS/SDMMC critical-section machinery, and UI flows read constantly from core 1. + if (mode && mode[0] != 'r') printf("[SDW] %lu core%d uiData %s %s\n", (unsigned long)millis(), xPortGetCoreID(), mode, name); + else printf("[SDR] %lu core%d uiData r %s\n", (unsigned long)millis(), xPortGetCoreID(), name); +#endif if (!uiDataFsReady()) return File(); char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); - return s_ui_data_fs->open(p, mode); + File f = s_ui_data_fs->open(p, mode); +#if defined(HAS_TDECK_GT911) + // A failed WRITE open on the SD-backed history store is the wedge tell (reads + // fail legitimately on first boot). Called from the loop task AND the core-0 + // history worker — sdNoteIoFailure is a volatile stamp, safe from both. + if (!f && mode && mode[0] == 'w' && s_ui_data_fs == &SD) sdNoteIoFailure(); +#endif + return f; } // Delete a ui-data file — used to quarantine a blob that fails to load so a // corrupt/truncated file (interrupted SD/FAT write, brownout) can't wedge the @@ -40966,6 +42334,10 @@ bool UITask::loadThreadsFromStorage() { const size_t disk_sz = (hdr.version >= 5 && hdr.thread_rec_size) ? hdr.thread_rec_size : sizeof(UiHistoryThread); if (disk_sz == 0 || disk_sz > 4096) { f.close(); uiDataRemove(k_ui_threads_path); return false; } + // Restore the companion message counter carried here for the segmented msgs + // store (older writers left it 0; the msgs-file loaders overwrite it anyway + // when they run, and the mesh re-asserts it with every message). + if (hdr.msgcount) _msgcount = static_cast<int>(hdr.msgcount); UiHistoryThread t{}; for (int i = 0; i < MAX_UI_THREADS; ++i) { @@ -41016,6 +42388,18 @@ bool UITask::loadMsgsFromStorage() { // lets a 500-slot file load into a 5000 ring (SD upgrade) and a 5000-slot file // shrink into a 500 ring (card pulled), instead of quarantining real history. const int file_slots = (int)(((size_t)f.size() - sizeof(hdr)) / disk_sz); + if (file_slots == 0 && hdr.ui_msg_count == 0) { + // Legitimately empty (every message was deleted, so the compacting writer + // emitted a header-only file) — NOT corruption. Load the empty ring; the + // old quarantine here deleted the file and made the boot fall through to + // stale fallbacks. + f.close(); + _ui_msg_count = 0; + _ui_msg_head = 0; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read + _msgcount = static_cast<int>(hdr.msgcount); + return true; + } if (file_slots <= 0 || hdr.ui_msg_count > file_slots || hdr.ui_msg_head >= file_slots) { f.close(); uiDataRemove(k_ui_msgs_path); return false; // header disagrees with the file body @@ -41053,6 +42437,7 @@ bool UITask::loadMsgsFromStorage() { _ui_msg_count = n_keep; // ring is now linear: slots 0..n_keep-1, oldest first _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast<int>(hdr.msgcount); return true; #else @@ -41060,6 +42445,245 @@ bool UITask::loadMsgsFromStorage() { #endif } +// Segmented-store loader (generation 3). Reads every discovered segment +// oldest-first into the ring, quarantining corrupt segments INDIVIDUALLY (the +// single-file store threw the entire history away on one bad record), keeping +// the newest <= cap records when the on-disk set is larger than this boot's +// ring (card written under the deep cap, loaded under the small one), and +// leaving the ring LINEAR (slot 0 = oldest, head = n) — the same post-load +// invariant every ring consumer relies on. Returns false when no segment +// files exist at all, so the caller can fall back to the older formats. +bool UITask::loadMsgsFromSegments() { +#if defined(ESP32) + uint32_t seqs[k_ui_seg_max]; + const int nseg = uiSegScan(seqs, k_ui_seg_max, true /*sweep orphaned tmps*/); + s_seg_count = 0; + s_seg_total_bytes = 0; + if (nseg <= 0) return false; + // Commit marker missing => this segment set is an unfinished migration (a + // power cut between chunk writes). Wipe it and let the caller fall back to + // the old-format file, which migrateRingToSegments only deletes after full + // verification. + { + File okf = uiDataOpen(k_ui_seg_ok, FILE_READ); + const bool have_marker = (bool)okf; + if (okf) okf.close(); + if (!have_marker) { + for (int i = 0; i < nseg; ++i) uiSegRemoveFile(seqs[i]); + return false; + } + } + + WdtHeavyGuard _wg; // ~20 segment reads on a cold card can take a while + int w = 0; // running chronological index; ring slot = w % cap + uint32_t max_seq = 0; + for (int si = 0; si < nseg; ++si) { + File f; + uint16_t rec_sz = 0; + if (!uiSegOpenValidated(seqs[si], f, &rec_sz)) { + uiSegRemoveFile(seqs[si]); // quarantine THIS segment only + continue; + } + UiSegInfo info{}; + info.first_seq = seqs[si]; + UIMessage rec; + uint32_t seq = 0; + uint16_t nrec = 0; + bool skipped = false; // duplicates dropped -> file != table -> rewrite it + while (uiSegReadRec(f, rec_sz, &rec, &seq)) { // stops at EOF or a crash-ragged tail + // Strictly monotonic seq filter. Segments are read in ascending + // first_seq order, so a record that does not advance the sequence is a + // duplicate or an out-of-order leftover (an interrupted repair, a stale + // file a crash left behind, a re-appended batch) — dropping it keeps the + // ring chronological and idempotent no matter what the disk holds. + if (rec.seq != 0 && rec.seq <= max_seq) { skipped = true; continue; } + if (rec.seq == 0) rec.seq = max_seq + 1; // defensive backfill (pre-seq record) + max_seq = rec.seq; + info.last_seq = rec.seq; + _ui_msgs[w % _ui_msg_cap] = rec; + ++w; + ++nrec; + } + f.close(); + info.disk_recs = nrec; + info.live_recs = nrec; + info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz; + // The file holds records the table doesn't account for — rewrite it so disk + // and table agree again (also drops the dead weight for good). + if (skipped) info.rewrite_open = 1; + if (nrec == 0) { uiSegRemoveFile(seqs[si]); continue; } // header-only husk — reclaim + if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = info; + } + + const int total = w; + const int n_keep = total > _ui_msg_cap ? _ui_msg_cap : total; + if (total > _ui_msg_cap) { + // The ring wrapped while loading: the oldest KEPT record sits at slot + // total % cap. Rotate left by that amount so the ring is linear again. + // Three-reversal rotation: O(cap) record swaps, no large temp buffer. + const int cap = _ui_msg_cap; + const int k = total % cap; + if (k > 0) { + auto rev = [this](int a, int b) { + while (a < b) { + UIMessage t = _ui_msgs[a]; + _ui_msgs[a] = _ui_msgs[b]; + _ui_msgs[b] = t; + ++a; --b; + } + }; + rev(0, k - 1); + rev(k, cap - 1); + rev(0, cap - 1); + } + } + + // Reconcile the table with any dropped-oldest records: fully-dropped + // segments violate the disk == ring invariant and are deleted outright; the + // boundary segment keeps its file but is marked compact-dirty so the next + // compaction (rewritten from the RAM ring) trims it to the kept records. + const uint32_t min_kept_seq = n_keep > 0 ? _ui_msgs[0].seq : 0; + int out = 0; + uint32_t total_bytes = 0; + for (int i = 0; i < s_seg_count; ++i) { + if (n_keep == 0 || s_seg[i].last_seq < min_kept_seq) { + uiSegRemoveFile(s_seg[i].first_seq); + continue; + } + if (s_seg[i].first_seq < min_kept_seq) s_seg[i].compact_dirty = 1; + total_bytes += s_seg[i].bytes; + s_seg[out++] = s_seg[i]; + } + s_seg_count = out; + s_seg_total_bytes = total_bytes; + + _ui_msg_count = n_keep; + _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read + _ui_seq_next = max_seq + 1; + s_seg_flushed_seq = max_seq; // everything loaded IS on disk by definition + // _msgcount isn't stored in segments; the threads-file header carries it and + // the mesh layer re-asserts it with every message. Keep it monotonic vs seq. + if ((int)max_seq > _msgcount) _msgcount = (int)max_seq; + s_seg_store_ready = true; + return true; +#else + return false; +#endif +} + +// One-time migration: the ring was just loaded from an OLD format (split v6 +// single file or the legacy combined file) — write it out as segments, read +// every segment back and verify counts + seq bounds, and only then delete the +// old msgs file. Any failure rolls the segment set back completely so the +// intact old file stays authoritative on the next boot (a partial segment set +// would otherwise shadow it in the loader's precedence order). +bool UITask::migrateRingToSegments() { +#if defined(ESP32) + uiDataEnsureDirs(); + // The marker goes away FIRST: from here until the verified end of this + // migration the on-disk segment set is provisional, and a boot that finds it + // without the marker wipes it and re-reads the (still intact) old file. + uiDataRemove(k_ui_seg_ok); + { // clear any earlier/partial set so leftovers can't survive as extra segments + uint32_t old_seqs[k_ui_seg_max]; + const int old_n = uiSegScan(old_seqs, k_ui_seg_max, true); + for (int i = 0; i < old_n; ++i) uiSegRemoveFile(old_seqs[i]); + } + s_seg_count = 0; + s_seg_total_bytes = 0; + UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); + if (!buf) return false; + + // Walk the ring CHRONOLOGICALLY and skip tombstones. Both matter: this runs + // not only at boot (ring linear) but also as a retry after a failed boot + // migration, by which time appendMessage may have WRAPPED the ring and the + // user may have deleted messages. Slicing raw slots there produced segments + // in slot order (first_seq > last_seq, scrambled history that read-back + // verification cannot detect) and persisted tombstones as ghost records. + bool ok = true; + int in_chunk = 0; + for (int i = 0; i <= _ui_msg_count && ok; ++i) { + const bool flush_chunk = (i == _ui_msg_count) || (in_chunk == k_ui_seg_records); + if (flush_chunk && in_chunk > 0) { + const uint32_t fseq = buf[0].seq; + ok = uiSegCompactWrite(fseq, buf, in_chunk, true /*sync-writer tmp*/); + if (ok && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = fseq; + info.last_seq = buf[in_chunk - 1].seq; + info.disk_recs = info.live_recs = (uint16_t)in_chunk; + info.bytes = (uint32_t)sizeof(UiSegHeader) + + (uint32_t)in_chunk * (uint32_t)sizeof(UiSegMsg); + s_seg[s_seg_count++] = info; + } + in_chunk = 0; + } + if (i == _ui_msg_count) break; + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + if (!_ui_msgs[slot].thread[0]) continue; // tombstone — never persisted + buf[in_chunk++] = _ui_msgs[slot]; + } + if (ok) { + // Read-back verification: every segment must reproduce its exact record + // count and seq bounds before the old file may be deleted. + for (int i = 0; ok && i < s_seg_count; ++i) { + File f; + uint16_t rec_sz = 0; + if (!uiSegOpenValidated(s_seg[i].first_seq, f, &rec_sz)) { ok = false; break; } + UIMessage r; + uint32_t seq = 0, first = 0, last = 0; + uint16_t cnt = 0; + bool ordered = true; + while (uiSegReadRec(f, rec_sz, &r, &seq)) { + if (cnt == 0) first = seq; + else if (seq <= last) ordered = false; // seqs must strictly increase within a segment + last = seq; + ++cnt; + } + f.close(); + ok = (ordered && cnt == s_seg[i].disk_recs && + first == s_seg[i].first_seq && + last == s_seg[i].last_seq); + } + // Segments must also be ordered relative to each other. + for (int i = 1; ok && i < s_seg_count; ++i) + ok = (s_seg[i].first_seq > s_seg[i - 1].last_seq); + } + if (!ok) { + for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); + s_seg_count = 0; + s_seg_total_bytes = 0; + return false; // old file untouched; the scheduler retries the migration later + } + // Verified. Commit: marker, then drop the old-format files. + { + File okf = uiDataOpen(k_ui_seg_ok, "w"); + if (!okf) { // can't commit -> leave the old file authoritative and retry later + for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); + s_seg_count = 0; + s_seg_total_bytes = 0; + return uiMsgsWriteFail('m'); + } + okf.print("segstore v1\n"); + okf.close(); + } + uint32_t total_bytes = 0; + for (int i = 0; i < s_seg_count; ++i) total_bytes += s_seg[i].bytes; + s_seg_total_bytes = total_bytes; + uiDataRemove(k_ui_msgs_path); + uiDataRemove(k_ui_msgs_tmp_path); + uiDataRemove(k_ui_msgs_tmp2_path); + s_seg_flushed_seq = s_seg_count ? s_seg[s_seg_count - 1].last_seq : 0; + s_seg_resync = false; // a full write IS the resync + s_seg_stale_purge = false; + s_seg_store_ready = true; + return true; +#else + return false; +#endif +} + bool UITask::loadLegacyHistoryFromStorage() { #if defined(ESP32) File f = uiDataOpen(k_ui_history_path, "r"); @@ -41154,13 +42778,27 @@ bool UITask::loadLegacyHistoryFromStorage() { bool UITask::loadHistoryFromStorage() { #if defined(ESP32) const bool have_threads = loadThreadsFromStorage(); - const bool have_msgs = loadMsgsFromStorage(); - if (!have_threads && !have_msgs) { - // Neither split file found — try the legacy combined format for migration. - if (!loadLegacyHistoryFromStorage()) return false; - // Schedule writes of the new split files; old file is left in place. - markThreadsDirty(500); - markMsgsDirty(500); + // Format precedence: segments (generation 3) > split v6 single file > + // legacy combined file. The older loaders are retained as migration + // readers; whenever one of them supplies the ring, migrateRingToSegments() + // below converts it (verify-then-delete, rollback on any failure). + const bool from_segments = loadMsgsFromSegments(); + bool have_msgs = from_segments; + if (!from_segments) { + // Single-file-era hygiene: a crash mid-flush can orphan a temp file; + // they're never read, just reclaim the space. (Segment tmps are swept by + // the scan inside loadMsgsFromSegments.) + uiDataRemove(k_ui_msgs_tmp_path); + uiDataRemove(k_ui_msgs_tmp2_path); + have_msgs = loadMsgsFromStorage(); + if (!have_threads && !have_msgs) { + // Neither split file found — try the legacy combined format. + if (!loadLegacyHistoryFromStorage()) return false; + // Schedule the split threads-file write; messages land in segments via + // the migration below (the legacy combined file is left in place, as + // the split migration always did). + markThreadsDirty(500); + } } // Clear persisted unread counts for threads whose messages are no longer in // the ring. The display ring is a fixed-size cache independent of the unread @@ -41175,6 +42813,20 @@ bool UITask::loadHistoryFromStorage() { _ui_threads[i].has_mention = false; } } + if (!from_segments) { + // Backfill per-record sequence numbers for records loaded from the + // pre-segment formats (their records carry no seq on disk): stamp them in + // chronological order, seed the generator past them, then convert the + // ring to segments. A failed migration leaves the old file authoritative + // and s_seg_store_ready false — the flush scheduler retries it before it + // will write any segment. + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + _ui_msgs[slot].seq = (uint32_t)(i + 1); + } + _ui_seq_next = (uint32_t)_ui_msg_count + 1; + migrateRingToSegments(); + } return true; #else return false; @@ -41183,6 +42835,15 @@ bool UITask::loadHistoryFromStorage() { bool UITask::saveThreadsToStorage() { #if defined(ESP32) +#if defined(HAS_TDISPLAY_P4) + // #167: the threads file is rewritten on every contact add/delete and chat-state change -- + // hop the write to the core-0 storage task like every other hot writer (see p4StorageCall). + if (!p4OnStorageTask()) { + struct A { UITask* t; bool ok; } a{ this, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = a->t->saveThreadsToStorage(); }, &a); + return a.ok; + } +#endif WdtHeavyGuard _wg; File f = uiDataOpen(k_ui_threads_path, "w"); if (!f) return false; @@ -41193,6 +42854,9 @@ bool UITask::saveThreadsToStorage() { hdr.thread_rec_size = static_cast<uint16_t>(sizeof(UiHistoryThread)); hdr.active_thread_idx = static_cast<int16_t>(_active_thread_idx); hdr.active_thread_is_channel = _active_thread_is_channel ? 1u : 0u; + // The segmented msgs store doesn't carry the companion message counter — + // this (frequently-rewritten, small) file does instead. + hdr.msgcount = static_cast<uint32_t>(_msgcount); if (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; } @@ -41229,57 +42893,100 @@ bool UITask::saveThreadsToStorage() { // write from PSRAM" that regressed before: the chunk lives in internal RAM (the // flash driver never bounces a PSRAM source). Alloc failure falls back to the // original per-record writes. -static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, - uint16_t count, uint16_t head, uint32_t msgcount) { -#if defined(ESP32) - WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC - File f = uiDataOpen(k_ui_msgs_tmp_path, "w"); - if (!f) return false; - - UiMsgFileHeader hdr{}; - hdr.magic = k_ui_msgs_magic; - hdr.version = k_ui_history_version; - hdr.msg_rec_size = static_cast<uint16_t>(sizeof(UiHistoryMsg)); - // Compact write: emit only the `count` USED records, oldest-first, instead of one - // record per ring slot (cap). A user with 200 messages writes ~48 KB, not the full - // ~1.2 MB ring, so the delete/clear/reboot flush no longer hitches the UI (that - // full-ring rewrite was the "deleting a message is slow" cause). The reader derives - // its slot count from the file size and linearizes by (head,count), so an - // oldest-first file with head=0 loads byte-for-byte identically. - const int wcap = cap > 0 ? cap : 1; - const int used = (int)count > cap ? cap : (int)count; - const int oldst = (((int)head - used) % wcap + wcap) % wcap; - hdr.ui_msg_count = static_cast<uint16_t>(used); - hdr.ui_msg_head = 0; // written oldest-first => the file's ring head sits at 0 - hdr.msgcount = msgcount; - if (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) != sizeof(hdr)) { - f.close(); return false; +// Record a ring-write outcome (runs on the loop task OR the core-0 worker — +// volatile stores only, LVGL-free) and stamp the SD health probe on failure so +// a wedged card gets arbitrated instead of the write failing silently forever. +static bool uiMsgsWriteResult(bool ok) { + const uint32_t m = (uint32_t)millis(); + const time_t now_t = time(nullptr); + const uint32_t ep = (now_t > 1700000000) ? (uint32_t)now_t : 0; // 0 when the clock is unset + if (ok) { + s_msgs_write_ok_ms = m ? m : 1; + s_msgs_write_ok_epoch = ep; + s_msgs_write_fails = 0; + } else { + s_msgs_write_fail_ms = m ? m : 1; + s_msgs_write_fail_epoch = ep; + if (s_msgs_write_fails < 0xFFFFu) s_msgs_write_fails = s_msgs_write_fails + 1; +#if defined(HAS_TDECK_GT911) + if (s_ui_data_fs == &SD) sdNoteIoFailure(); +#endif } + return ok; +} +// Failure wrapper: record WHERE the write died and the errno — 'open failed +// with ENFILE' (VFS file-handle table full) needs a completely different fix +// than 'body write failed with EIO' (card), and without this they were +// indistinguishable on a device with no readable serial. +static bool uiMsgsWriteFail(char stage) { + s_msgs_write_stage = (uint8_t)stage; + s_msgs_write_errno = errno; + return uiMsgsWriteResult(false); +} - const size_t REC = sizeof(UiHistoryMsg); +// ---- Segmented store: low-level file ops -------------------------------- +// Pure file-layer primitives (no scheduling, no table state — that lives with +// the flush scheduler). All take explicit record arrays (snapshots or the +// ring under the loop task's ownership) and report through the same +// uiMsgsWriteResult/uiMsgsWriteFail health machinery as the old writer, so +// the About diagnostics, failure toasts, sync-fallback and SD-wedge +// arbitration keep working unchanged. Stage codes: 'a' append, 'c' compact, +// 'd' data-dir create, 's' segment scan (plus errno). + +// Root-relative segment file name: "/msgs/seg_<first_seq>.bin<suffix>". +// Worst case "/msgs/seg_4294967295.bin.tm2" = 28 chars — fits uiDataOpen's +// 80-byte path buffer and SPIFFS' 31-char flat-name limit. +static void uiSegName(uint32_t first_seq, const char* suffix, char* out, size_t cap) { + snprintf(out, cap, "%s/seg_%lu.bin%s", k_ui_seg_dir, (unsigned long)first_seq, suffix ? suffix : ""); +} + +// Ensure the data root + segment dir exist. FAT backends (SD /meshcomod, +// SD_MMC /meshcomod, FFat) need real directories; SPIFFS has a flat namespace +// where mkdir fails harmlessly and slash-in-name files just work — so this is +// best-effort by design. Called at backend resolve and from every remount +// path (a fresh replacement card has neither directory). +static void uiDataEnsureDirs() { + if (!uiDataFsReady()) return; + char p[80]; + if (s_ui_data_root[0]) s_ui_data_fs->mkdir(s_ui_data_root); + snprintf(p, sizeof p, "%s%s", s_ui_data_root, k_ui_seg_dir); + s_ui_data_fs->mkdir(p); // no-op/failure on SPIFFS is fine (flat names) +} + +// Marshal one RAM record into the on-disk segment record (drops the RAM-only +// fields, carries seq). Mirrors the old writer's field list exactly. +static void uiSegMarshal(const UITask::UIMessage& s, UiSegMsg* d) { + memset(d, 0, sizeof(*d)); + d->m.ts = s.ts; + d->m.channel = s.channel ? 1u : 0u; + d->m.outgoing = s.outgoing ? 1u : 0u; + d->m.meta_flags = s.meta_flags; + d->m.path_len = s.path_len; + d->m.snr_q4 = s.snr_q4; + d->m.rssi = s.rssi; + strncpy(d->m.thread, s.thread, sizeof(d->m.thread) - 1); + d->m.thread[sizeof(d->m.thread) - 1] = '\0'; + strncpy(d->m.sender, s.sender, sizeof(d->m.sender) - 1); + d->m.sender[sizeof(d->m.sender) - 1] = '\0'; + strncpy(d->m.text, s.text, sizeof(d->m.text) - 1); + d->m.text[sizeof(d->m.text) - 1] = '\0'; + d->seq = s.seq; +} + +// Chunked record writer shared by append + compact: marshals through a small +// INTERNAL-RAM buffer (the flash/SD drivers must never see a PSRAM source +// pointer), per-record fallback when the heap is tight. Returns false on any +// short write (errno left for the caller's stage report). +static bool uiSegWriteRecords(File& f, const UITask::UIMessage* recs, int n) { + const size_t REC = sizeof(UiSegMsg); size_t chunk_recs = 6144 / REC; if (chunk_recs < 1) chunk_recs = 1; uint8_t* buf = (uint8_t*)malloc(REC * chunk_recs); // internal RAM by default bool ok = true; if (buf) { size_t fill = 0; - for (int k = 0; ok && k < used; ++k) { - const int i = (oldst + k) % wcap; - UiHistoryMsg* m = reinterpret_cast<UiHistoryMsg*>(buf + fill); - memset(m, 0, REC); - m->ts = msgs[i].ts; - m->channel = msgs[i].channel ? 1u : 0u; - m->outgoing = msgs[i].outgoing ? 1u : 0u; - m->meta_flags = msgs[i].meta_flags; - m->path_len = msgs[i].path_len; - m->snr_q4 = msgs[i].snr_q4; - m->rssi = msgs[i].rssi; - strncpy(m->thread, msgs[i].thread, sizeof(m->thread) - 1); - m->thread[sizeof(m->thread) - 1] = '\0'; - strncpy(m->sender, msgs[i].sender, sizeof(m->sender) - 1); - m->sender[sizeof(m->sender) - 1] = '\0'; - strncpy(m->text, msgs[i].text, sizeof(m->text) - 1); - m->text[sizeof(m->text) - 1] = '\0'; + for (int k = 0; ok && k < n; ++k) { + uiSegMarshal(recs[k], reinterpret_cast<UiSegMsg*>(buf + fill)); fill += REC; if (fill == REC * chunk_recs) { ok = (f.write(buf, fill) == fill); @@ -41289,51 +42996,606 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, if (ok && fill > 0) ok = (f.write(buf, fill) == fill); free(buf); } else { - UiHistoryMsg m{}; - for (int k = 0; ok && k < used; ++k) { - const int i = (oldst + k) % wcap; - memset(&m, 0, sizeof(m)); - m.ts = msgs[i].ts; - m.channel = msgs[i].channel ? 1u : 0u; - m.outgoing = msgs[i].outgoing ? 1u : 0u; - m.meta_flags = msgs[i].meta_flags; - m.path_len = msgs[i].path_len; - m.snr_q4 = msgs[i].snr_q4; - m.rssi = msgs[i].rssi; - strncpy(m.thread, msgs[i].thread, sizeof(m.thread) - 1); - m.thread[sizeof(m.thread) - 1] = '\0'; - strncpy(m.sender, msgs[i].sender, sizeof(m.sender) - 1); - m.sender[sizeof(m.sender) - 1] = '\0'; - strncpy(m.text, msgs[i].text, sizeof(m.text) - 1); - m.text[sizeof(m.text) - 1] = '\0'; - ok = (f.write(reinterpret_cast<const uint8_t*>(&m), sizeof(m)) == sizeof(m)); + UiSegMsg rec; + for (int k = 0; ok && k < n; ++k) { + uiSegMarshal(recs[k], &rec); + ok = (f.write(reinterpret_cast<const uint8_t*>(&rec), sizeof(rec)) == sizeof(rec)); } } - f.close(); - if (!ok) { uiDataRemove(k_ui_msgs_tmp_path); return false; } - return uiDataReplaceFile(k_ui_msgs_path, k_ui_msgs_tmp_path); -#else - (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; - return false; -#endif + return ok; } -// Worker-side entry: write the snapshot the loop thread armed (core-0 task; the -// loop thread never waits on this, which is the whole point). -static bool uiHistWorkerFlush() { - if (!s_hist_snap || s_hist_snap_cap <= 0) return false; // armed without a snapshot — retry - return uiWriteMsgsFile(s_hist_snap, s_hist_snap_cap, - s_hist_snap_count, s_hist_snap_head, s_hist_snap_msgcount); +// Append records to a segment file. `create` = the file does not exist yet +// (write the header first). Driven by table state, NOT by File::size() — the +// Tanmatsu FFat metadata layer lies about sizes. A crash mid-append leaves a +// ragged tail the loader truncates to the record boundary; nothing else is at +// risk (the previous records and every other segment are untouched — this is +// the whole point of the segmented layout). +static bool uiSegAppendRecords(uint32_t first_seq, bool create, + const UITask::UIMessage* recs, int n) { +#if defined(HAS_TDISPLAY_P4) + // #167: history writes hop to the core-0 storage task (see p4StorageCall in DataStore.cpp) -- + // a core-1 SD write can mask the DSI frame-restart ISR long enough to drop a display frame. + if (!p4OnStorageTask()) { + struct A { uint32_t fs; bool c; const UITask::UIMessage* r; int n; bool ok; } a{ first_seq, create, recs, n, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegAppendRecords(a->fs, a->c, a->r, a->n); }, &a); + return a.ok; + } +#endif + WdtHeavyGuard _wg; + errno = 0; + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + // create: open truncating — a stale file under the same key (crash residue) + // must not end up with a second header appended mid-file. + const char* mode = create ? "w" : FILE_APPEND; + File f = uiDataOpen(name, mode); + if (!f) { + // First failure on a fresh card/dir is usually a missing parent dir — + // create it and retry once before reporting. + uiDataEnsureDirs(); + errno = 0; + f = uiDataOpen(name, mode); + if (!f) return uiMsgsWriteFail('a'); + } + bool ok = true; + if (create) { + UiSegHeader hdr{}; + hdr.magic = k_ui_seg_magic; + hdr.version = k_ui_seg_version; + hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); + hdr.first_seq = first_seq; + ok = (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) == sizeof(hdr)); + } + if (ok) ok = uiSegWriteRecords(f, recs, n); + f.close(); + if (!ok) { + // A short write can have landed whole records already. The file content is + // now untrusted: appending the retry after that tail would duplicate (or, + // if the failure split a record, MISALIGN) records the loader accepts as + // history. Flag the segment so the scheduler repairs it with a full + // rewrite before any further append. Volatile-only work — safe from the + // worker task; the entry lookup runs on the loop task's table but only + // writes a byte the loop task re-reads. + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].first_seq == first_seq) { s_seg[i].rewrite_open = 1; break; } + if (create) uiSegRemoveFile(first_seq); // nothing valid in it yet + return uiMsgsWriteFail('a'); + } + return uiMsgsWriteResult(true); +} + +// Rewrite one segment from the given (live, tombstone-free) records — tmp + +// rename, same crash discipline as the old writer but with a one-segment +// blast radius. n == 0 removes the segment file outright (everything in it +// was deleted). `sync_writer` picks the tmp namespace: the loop-task sync +// writer must never share a tmp with a possibly-stalled worker write (two +// truncating opens of one path interleave into garbage — the .tmp/.tm2 +// lesson from the single-file store). +static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, + bool sync_writer) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) + struct A { uint32_t fs; const UITask::UIMessage* r; int n; bool sw; bool ok; } a{ first_seq, recs, n, sync_writer, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegCompactWrite(a->fs, a->r, a->n, a->sw); }, &a); + return a.ok; + } +#endif + WdtHeavyGuard _wg; + errno = 0; + char fin[48], tmp[48]; + uiSegName(first_seq, "", fin, sizeof fin); + if (n <= 0) { + uiDataRemove(fin); + return uiMsgsWriteResult(true); + } + uiSegName(first_seq, sync_writer ? ".tm2" : ".tmp", tmp, sizeof tmp); + File f = uiDataOpen(tmp, "w"); + if (!f) { + uiDataEnsureDirs(); + errno = 0; + f = uiDataOpen(tmp, "w"); + if (!f) return uiMsgsWriteFail('c'); + } + UiSegHeader hdr{}; + hdr.magic = k_ui_seg_magic; + hdr.version = k_ui_seg_version; + hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); + hdr.first_seq = first_seq; + bool ok = (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) == sizeof(hdr)); + if (ok) ok = uiSegWriteRecords(f, recs, n); + f.close(); + if (!ok) { uiDataRemove(tmp); return uiMsgsWriteFail('c'); } + errno = 0; + if (!uiDataReplaceFile(fin, tmp)) return uiMsgsWriteFail('c'); + return uiMsgsWriteResult(true); +} + +// Remove a retired segment (ring grew past retention; its records are gone +// from RAM too). +static void uiSegRemoveFile(uint32_t first_seq) { +#if defined(HAS_TDISPLAY_P4) + if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) + p4StorageCall([](void* p){ uiSegRemoveFile((uint32_t)(uintptr_t)p); }, (void*)(uintptr_t)first_seq); + return; + } +#endif + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + uiDataRemove(name); +} + +// Open + validate a segment for reading. Returns the on-disk record size via +// *rec_size_out (self-describing header) and leaves the File positioned at +// the first record. Returns false on a corrupt header — the caller +// quarantines THAT segment only. +static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out) { + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + f = uiDataOpen(name, FILE_READ); + if (!f) return false; + UiSegHeader hdr{}; + if (f.readBytes(reinterpret_cast<char*>(&hdr), sizeof(hdr)) != (int)sizeof(hdr) || + hdr.magic != k_ui_seg_magic || + hdr.version == 0 || hdr.version > k_ui_seg_version || + hdr.msg_rec_size == 0 || hdr.msg_rec_size > 4096 || + hdr.first_seq != first_seq) { + f.close(); + return false; + } + *rec_size_out = hdr.msg_rec_size; + return true; +} + +// Read ONE record from an open segment into a RAM record. Returns false at a +// clean EOF or a ragged (crash-truncated) tail — the caller just stops there; +// records already read are valid. Size-agnostic via readHistoryRec (older +// shorter records zero-fill their tail, so a missing seq field reads 0 and +// the loader backfills it). +static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out) { + UiSegMsg rec{}; + if (!readHistoryRec(f, &rec, sizeof(rec), disk_sz)) return false; + memset(out, 0, sizeof(*out)); + out->ts = rec.m.ts; + out->channel = rec.m.channel != 0; + out->outgoing = rec.m.outgoing != 0; + out->meta_flags = rec.m.meta_flags; + out->path_len = rec.m.path_len; + out->snr_q4 = rec.m.snr_q4; + out->rssi = rec.m.rssi; + strncpy(out->thread, rec.m.thread, UITask::MAX_THREAD_NAME); + out->thread[UITask::MAX_THREAD_NAME] = '\0'; + strncpy(out->sender, rec.m.sender, UITask::MAX_SENDER_NAME); + out->sender[UITask::MAX_SENDER_NAME] = '\0'; + strncpy(out->text, rec.m.text, UITask::MAX_MSG_TEXT); + out->text[UITask::MAX_MSG_TEXT] = '\0'; + out->seq = rec.seq; + *seq_out = rec.seq; + return true; +} + +// Discover segment files: fills out_first_seqs sorted ascending, returns the +// count (or -1 when even the scan location can't be opened — distinct from +// "no segments yet"). Handles both directory layouts: a real <root>/msgs dir +// (FAT backends) and SPIFFS' flat namespace (scan "/" and prefix-match the +// name). When sweep_tmps is set, orphaned .tmp/.tm2 leftovers from a crash +// mid-compact are deleted along the way (boot hygiene). +static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps) { + if (!uiDataFsReady()) return -1; + char dirpath[80]; + snprintf(dirpath, sizeof dirpath, "%s%s", s_ui_data_root, k_ui_seg_dir); + File dir = s_ui_data_fs->open(dirpath); + bool flat = false; + if (!dir || !dir.isDirectory()) { + // SPIFFS (flat namespace): enumerate the root and match the name prefix. + if (dir) dir.close(); + flat = true; + dir = s_ui_data_fs->open(s_ui_data_root[0] ? s_ui_data_root : "/"); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return -1; + } + } + const size_t root_len = strlen(s_ui_data_root); + int n = 0; + for (;;) { + String path = dir.getNextFileName(); + if (path.length() == 0) break; + // Normalize to a root-relative name so uiDataRemove/uiDataOpen accept it. + const char* rel = path.c_str(); + if (root_len && strncmp(rel, s_ui_data_root, root_len) == 0) rel += root_len; + const char* base = strrchr(rel, '/'); + base = base ? base + 1 : rel; + if (flat) { + // Flat scan sees every file — keep only "/msgs/seg_*" names. + if (strncmp(rel, k_ui_seg_dir, strlen(k_ui_seg_dir)) != 0) continue; + } + if (strncmp(base, "seg_", 4) != 0) continue; + char* end = nullptr; + const unsigned long fs_val = strtoul(base + 4, &end, 10); + if (!end || end == base + 4) continue; + if (strcmp(end, ".bin") == 0) { + if (n < max_out) out_first_seqs[n++] = (uint32_t)fs_val; + } else if (sweep_tmps && (strcmp(end, ".bin.tmp") == 0 || strcmp(end, ".bin.tm2") == 0)) { + uiDataRemove(rel); + } + } + dir.close(); + // Insertion sort ascending — n is tiny (<= k_ui_seg_max). + for (int i = 1; i < n; ++i) { + const uint32_t v = out_first_seqs[i]; + int j = i - 1; + while (j >= 0 && out_first_seqs[j] > v) { out_first_seqs[j + 1] = out_first_seqs[j]; --j; } + out_first_seqs[j + 1] = v; + } + return n; +} + +// ---- Segmented store: flush scheduling ------------------------------------ +// All of this state is LOOP-TASK-owned; the hist_flush worker only runs +// uiSegRunArmedJob() and reports through s_hist_flush_ok. + +// Snapshot buffer for one job (<= one segment of records). Lazy, kept for the +// session — two exist (worker vs sync drain) so the writers can never share. +static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot) { + if (!*slot) { + const size_t sz = sizeof(UITask::UIMessage) * (size_t)k_ui_seg_records; + *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!*slot) *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_8BIT); + } + return *slot; +} + +// Live records with seq in [lo, hi], chronological, from the ring. +static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out) { + int n = 0; + for (int i = 0; i < count && n < max_out; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (!m.thread[0]) continue; // tombstone + if (m.seq < lo || m.seq > hi) continue; + out[n++] = m; + } + return n; +} + +// Build the next job into `buf` + `out`. PURE with respect to the worker +// descriptor — arming (copying into the statics + raising req) is the async +// caller's step, so the sync drain can use this with its own buffer and a +// local job without the worker ever seeing it. Returns 1 = job built, +// 0 = nothing pending (all durable), -1 = no buffer. +static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, + UITask::UIMessage* buf, SegJob* out) { + if (!buf) return -1; + // 0) REPAIR FIRST. A segment whose file content is untrusted (failed append + // left a partial tail, or a resync left it stale/absent) must be fully + // rewritten before anything appends to it — otherwise a retry appends the + // same batch after the partial tail (duplicate or misaligned records the + // loader would ingest as history) or creates a HEADERLESS file. The + // rewrite absorbs the unflushed tail too, bounded by the segment size; + // whatever doesn't fit stays above the watermark and lands as a normal + // append into the NEXT segment afterwards. + for (int i = 0; i < s_seg_count; ++i) { + if (!s_seg[i].rewrite_open) continue; + const uint32_t hi = (i + 1 < s_seg_count) ? (s_seg[i + 1].first_seq - 1) : 0xFFFFFFFFu; + const int rn = segGatherRange(ring, cap, count, head, s_seg[i].first_seq, hi, + buf, k_ui_seg_records); + out->kind = SEGJOB_COMPACT; // a compact IS the repair (tmp + rename, header written) + out->first_seq = s_seg[i].first_seq; + out->last_seq = rn > 0 ? buf[rn - 1].seq : 0; + out->create = false; + out->n = rn; // 0 -> unlink (every record in the range is gone) + out->repair = true; // commit also clears rewrite_open + advances the watermark + return 1; + } + // 1) Pending appends: live records newer than the durability watermark, + // chronological, capped to the room left in the active segment. + bool create = true; + uint32_t target = 0; + int room = k_ui_seg_records; + if (s_seg_count > 0 && s_seg[s_seg_count - 1].disk_recs < k_ui_seg_records) { + create = false; + target = s_seg[s_seg_count - 1].first_seq; + room = k_ui_seg_records - (int)s_seg[s_seg_count - 1].disk_recs; + } + // At the table cap (s_seg_count == k_ui_seg_max) a CREATE-append cannot be + // recorded: segCommitJob only tables a create while s_seg_count < the cap, so + // the file it writes becomes an untabled orphan AND the durability watermark + // never advances -> the same append re-arms every cycle (permanent write-spin), + // orphaned seg_*.bin pile up as the ring shifts the target first_seq, and the + // card eventually fills ("SD I/O fails after a few hours"). DEFER the append: + // skip step 1 entirely (crucially, do NOT run the gather/watermark advance + // below on un-written records) and fall through to step 2, which retires the + // oldest segment first. At the cap the oldest 256 records have aged out of the + // 5000-record ring (24*256 disk slots > ring), so that segment is fully + // evicted -> compact_dirty (segNoteEvicted) -> step 2 unlinks it and frees a + // slot with NO live-record loss. The deferred records are the NEWEST (above + // the watermark, so the ring evicts them last); they wait safely in RAM and + // land next cycle once a slot is free. Below the cap this is a no-op. + if (!(create && s_seg_count >= k_ui_seg_max)) { + int n = 0; + uint32_t last = s_seg_flushed_seq; + for (int i = 0; i < count; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (m.seq <= s_seg_flushed_seq) continue; + if (m.thread[0]) { + if (n >= room) break; // segment full — the rest rides the next cycle + buf[n++] = m; + } + if (m.seq > last) last = m.seq; // deleted-before-flush records advance the + // watermark with no write (never hit disk) + } + if (n > 0) { + out->kind = SEGJOB_APPEND; + out->first_seq = create ? buf[0].seq : target; + out->last_seq = last; + out->create = create; + out->n = n; + return 1; + } + if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail + } + // 2) Oldest compact-dirty segment: rewrite it from the ring's live records. + for (int i = 0; i < s_seg_count; ++i) { + if (!s_seg[i].compact_dirty) continue; + const int cn = segGatherRange(ring, cap, count, head, + s_seg[i].first_seq, s_seg[i].last_seq, + buf, k_ui_seg_records); + out->kind = SEGJOB_COMPACT; + out->first_seq = s_seg[i].first_seq; + out->last_seq = cn > 0 ? buf[cn - 1].seq : 0; + out->create = false; + out->n = cn; // 0 = every record gone -> unlink the file + return 1; + } + return 0; +} + +// Apply a successfully-executed job to the table (loop task only). +static void segCommitJob(const SegJob& job) { + if (job.kind == SEGJOB_APPEND) { + if (job.create && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = job.first_seq; + info.bytes = (uint32_t)sizeof(UiSegHeader); + s_seg[s_seg_count++] = info; + s_seg_total_bytes = s_seg_total_bytes + (uint32_t)sizeof(UiSegHeader); + } + // Credit the job to ITS OWN segment (looked up by key), never blindly to + // the last table entry — the table can have changed between arm and commit. + for (int i = 0; i < s_seg_count; ++i) { + if (s_seg[i].first_seq != job.first_seq) continue; + UiSegInfo& a = s_seg[i]; + a.disk_recs = (uint16_t)(a.disk_recs + job.n); + a.live_recs = (uint16_t)(a.live_recs + job.n); + if (job.last_seq > a.last_seq) a.last_seq = job.last_seq; + const uint32_t add = (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); + a.bytes += add; + s_seg_total_bytes = s_seg_total_bytes + add; + if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; + break; + } + } else if (job.kind == SEGJOB_COMPACT) { + for (int i = 0; i < s_seg_count; ++i) { + if (s_seg[i].first_seq != job.first_seq) continue; + s_seg_total_bytes = (s_seg_total_bytes >= s_seg[i].bytes) + ? s_seg_total_bytes - s_seg[i].bytes : 0; + if (job.n == 0) { + for (int k = i; k + 1 < s_seg_count; ++k) s_seg[k] = s_seg[k + 1]; + --s_seg_count; + } else { + s_seg[i].disk_recs = s_seg[i].live_recs = (uint16_t)job.n; + s_seg[i].last_seq = job.last_seq; + s_seg[i].bytes = (uint32_t)sizeof(UiSegHeader) + + (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); + // A delete that landed while this compact was writing predates + // nothing: the snapshot was taken BEFORE it, so the record is still + // in the file — keep the segment dirty for a follow-up pass. + s_seg[i].compact_dirty = s_segjob_redirty ? 1 : 0; + s_seg_total_bytes = s_seg_total_bytes + s_seg[i].bytes; + if (job.repair) { + // The file now matches the table exactly, so it is trustworthy again + // and the records it absorbed from the unflushed tail are durable. + s_seg[i].rewrite_open = 0; + if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; + } + } + break; + } + } +} + +static bool segMoreWorkPending(uint32_t newest_seq) { + if (newest_seq > s_seg_flushed_seq) return true; + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].compact_dirty || s_seg[i].rewrite_open) return true; + return false; +} + +// Executed by the hist_flush worker (or inline by the sync drain's caller). +static bool uiSegRunArmedJob() { + switch (s_segjob_kind) { + case SEGJOB_APPEND: + return uiSegAppendRecords(s_segjob_first_seq, s_segjob_create, s_segjob_buf, s_segjob_n); + case SEGJOB_COMPACT: + return uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, s_segjob_n, false); + default: + return true; + } +} + +// A record with this seq was tombstoned (deleted) — mark its segment for +// compaction. Unflushed records (seq above the watermark) need nothing: the +// append builder skips tombstones, so they simply never reach disk. +static void segMarkSeqDirty(uint32_t seq) { + if (seq == 0 || seq > s_seg_flushed_seq) return; + for (int i = 0; i < s_seg_count; ++i) { + if (seq >= s_seg[i].first_seq && seq <= s_seg[i].last_seq) { + s_seg[i].compact_dirty = 1; + // Racing an in-flight compact of this very segment: its snapshot was + // taken before this delete, so the commit must not clear the flag. + if (s_segjob_kind == SEGJOB_COMPACT && s_segjob_first_seq == s_seg[i].first_seq && + (s_hist_flush_busy || s_hist_flush_req)) + s_segjob_redirty = true; + return; + } + } +} + +// The ring overwrote its oldest record (capacity eviction). Deliberately does +// NOT trigger a rewrite per eviction (that would be write-amplification per +// message all over again): the segment file only gets unlinked once ALL its +// records have aged out; a partially-evicted boundary segment keeps its stale +// tail on disk until the next boot's loader trims it (bounded: one segment). +static void segNoteEvicted(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < s_seg_count; ++i) { + if (seq < s_seg[i].first_seq || seq > s_seg[i].last_seq) continue; + if (s_seg[i].live_recs > 0) --s_seg[i].live_recs; + if (s_seg[i].live_recs == 0) s_seg[i].compact_dirty = 1; // job -> unlink (n gathers to 0) + return; + } +} + +// Card remounted (wedge recovery / reinsert / FM insert / post-format): the +// on-disk segment set may be stale, foreign, or gone. Rebuild the table from +// the ring's live records with every chunk marked UNTRUSTED, so the normal job +// flow rewrites each one (header + records, tmp + rename) one bounded segment +// at a time. +// +// Deliberately does NOT delete anything up front: on a same-card recovery the +// existing files still hold the very history we are re-landing, and unlinking +// them first turned recovery into a multi-minute window where a power cut lost +// data that had been perfectly durable. Same-key files are simply replaced by +// each repair's rename; anything left over (different chunk boundaries, or a +// genuinely foreign card) is swept once the re-land is complete — and the +// loader's seq-monotonic filter makes a leftover harmless even if we crash +// before that sweep. +static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t newest_seq) { + s_seg_count = 0; + s_seg_total_bytes = 0; + UiSegInfo cur{}; + int in_chunk = 0; + for (int i = 0; i < count; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (!m.thread[0]) continue; + if (in_chunk == 0) { + cur = UiSegInfo{}; + cur.first_seq = m.seq; + cur.rewrite_open = 1; // file content untrusted -> repair rewrites it + } + cur.last_seq = m.seq; + ++in_chunk; + if (in_chunk == k_ui_seg_records) { + cur.live_recs = (uint16_t)in_chunk; + if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = cur; + in_chunk = 0; + } + } + if (in_chunk > 0 && s_seg_count < k_ui_seg_max) { + cur.live_recs = (uint16_t)in_chunk; + s_seg[s_seg_count++] = cur; + } + s_seg_flushed_seq = newest_seq; + s_seg_stale_purge = true; // sweep leftovers once every chunk has landed +} + +// Unlink segment files that are not part of the table. Runs only when no work +// is pending (so every table entry has a real, current file) — see the purge +// rationale on segRetableFromRing. +static void segPurgeStaleFiles() { + uint32_t seqs[k_ui_seg_max]; + const int nseg = uiSegScan(seqs, k_ui_seg_max, true); + if (nseg < 0) return; // scan location unreadable — try again later + for (int i = 0; i < nseg; ++i) { + bool keep = false; + for (int k = 0; k < s_seg_count; ++k) + if (s_seg[k].first_seq == seqs[i]) { keep = true; break; } + if (!keep) uiSegRemoveFile(seqs[i]); + } + s_seg_stale_purge = false; } bool UITask::saveMsgsToStorage() { #if defined(ESP32) - // Synchronous write of the LIVE ring — shutdown/reboot and the no-PSRAM - // fallback only; the periodic flush goes through the worker snapshot. - return uiWriteMsgsFile(_ui_msgs, _ui_msg_cap, - static_cast<uint16_t>(_ui_msg_count), - static_cast<uint16_t>(_ui_msg_head), - static_cast<uint32_t>(_msgcount)); + // Synchronous drain of ALL outstanding message-store work on the LOOP task — + // shutdown/reboot, the delete flows (persistHistoryNow), the sync-fallback + // and the no-worker fallback all land here. Loop-task writes are the + // empirically reliable path on the shared SPI bus. Returns true only when + // everything is durable. + if (!uiDataFsReady()) return false; + // A worker that outlived uiHistWaitWorkerIdle's 9 s cap still holds a segment + // file OPEN. Writing here would remove/rename a path under that open handle: + // with FF_FS_LOCK=0 the stalled handle keeps writing into clusters the FS has + // freed and may re-allocate, cross-linking chains and corrupting the volume. + // Report failure instead (the caller surfaces "Chat history save FAILED"); + // the data stays in the RAM ring and the segment is already flagged for a + // repair rewrite by uiHistWaitWorkerIdle. + if (s_hist_flush_busy) return false; + if (!s_seg_store_ready) { + // A failed boot migration left the old-format file authoritative — + // converting it IS the flush (verify-then-delete inside). + return migrateRingToSegments(); + } + if (s_seg_resync) { + segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + _ui_seq_next ? _ui_seq_next - 1 : 0); + s_seg_resync = false; + } + // Drain job by job, executing each here. Appends are NEVER FILE_APPEND on + // this path: a worker stalled past uiHistWaitWorkerIdle's 9 s cap could + // still hold an append handle on the same file, and two appenders + // interleave records — instead the whole (bounded, <= ~60 KB) active + // segment is rewritten from the ring via the sync tmp namespace, where + // rename-over is last-writer-wins with each candidate internally + // consistent. + UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); // NEVER the worker's buffer: + // a worker stalled past the 9 s idle-wait cap may still be reading its own. + for (int guard = 0; guard < k_ui_seg_max + 4; ++guard) { + SegJob job{}; + const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, buf, &job); + if (armed == 0) { + // segBuildJob returns 0 for "all durable" AND for a create-append it had to + // DEFER at the segment cap with no slot freeable this pass (rare + // fragmentation edge — the common at-cap path retires the oldest evicted + // segment inside this loop and resolves). Only the former is saved; report + // the latter as a failure so the caller surfaces it and retries, instead of + // dropping the newest records on a false success. Normal case: + // segMoreWorkPending is false here, so this is a no-op. + if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) return false; + if (s_seg_stale_purge) segPurgeStaleFiles(); // everything landed — drop leftovers + return true; + } + if (armed < 0) return false; // no snapshot buffer + bool ok; + if (job.kind == SEGJOB_APPEND) { + if (job.create && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = job.first_seq; + s_seg[s_seg_count++] = info; + } + const int n2 = segGatherRange(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + job.first_seq, job.last_seq, + buf, k_ui_seg_records); + ok = uiSegCompactWrite(job.first_seq, buf, n2, true); + if (ok) { + const uint32_t newest = job.last_seq; + job.kind = SEGJOB_COMPACT; // commit with absolute-value semantics + job.n = n2; + segCommitJob(job); + if (newest > s_seg_flushed_seq) s_seg_flushed_seq = newest; + } + } else { + ok = uiSegCompactWrite(job.first_seq, buf, job.n, true); + if (ok) segCommitJob(job); + } + if (!ok) return false; + } + return !segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0); #else return false; #endif @@ -41369,6 +43631,7 @@ int UITask::findOrCreateThread(const char* name, bool channel) { bool UITask::removeThread(int idx) { if (idx < 0 || idx >= MAX_UI_THREADS) return false; if (!_ui_threads[idx].used) return false; + _thread_hist_dirty = true; // slot is being freed + its ring records dropped // Match the thread by stored name (channel/DM) before wiping the slot so // we can drop any ring messages that belonged to it. Without this the // entries would still occupy the bounded _ui_msgs ring and confuse a @@ -41386,6 +43649,7 @@ bool UITask::removeThread(int idx) { _ui_msgs[i].thread[0] = '\0'; _ui_msgs[i].text[0] = '\0'; _ui_msgs[i].sender[0] = '\0'; + segMarkSeqDirty(_ui_msgs[i].seq); purged_any = true; } // Persist the purge — without this the deleted thread's messages survive in @@ -41608,6 +43872,9 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te int t_idx = findOrCreateThread(thread, channel); if (t_idx < 0) return -1; UIMessage& m = _ui_msgs[_ui_msg_head]; + // Full ring: this append overwrites the oldest record — tell the segment + // table so a fully-aged-out segment file gets unlinked. + if (_ui_msg_count == _ui_msg_cap) segNoteEvicted(m.seq); // Bubble timestamps must match the status-bar clock, which reads the NTP-synced ESP32 // system clock (time(nullptr)) — NOT the mesh RTC (getRTCClock()). On boards whose // hardware RTC chip reads BCD/century garbage, getCurrentTime() returned wrong epochs @@ -41618,6 +43885,7 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te time_t sys = time(nullptr); m.ts = (sys > 1700000000) ? (uint32_t)sys : (uint32_t)(millis() / 1000); } + m.seq = _ui_seq_next++; // monotonic record id — the segment store keys on it m.channel = channel; m.outgoing = outgoing; m.ack_hash = ack_hash; @@ -41636,9 +43904,15 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te m.sender[MAX_SENDER_NAME] = '\0'; strncpy(m.text, text ? text : "", MAX_MSG_TEXT); m.text[MAX_MSG_TEXT] = '\0'; + // An append is the ONLY mutation that needs no scan: this thread now demonstrably has + // history. A full ring also EVICTED the oldest record above, which may have been some other + // thread's last message, so the cached flags have to be rebuilt before they are trusted again. + if (_ui_msg_count == _ui_msg_cap) _thread_hist_dirty = true; + if (!_thread_hist_dirty && _thread_msgs[t_idx] < 0xFFFF) ++_thread_msgs[t_idx]; if (_ui_msg_count < _ui_msg_cap) ++_ui_msg_count; _ui_msg_head = (_ui_msg_head + 1) % _ui_msg_cap; _ui_threads[t_idx].last_ts = m.ts; + enforceHistoryCap(t_idx); // keep this chat within the per-chat cap (Settings -> Chats) if (mark_unread) _ui_threads[t_idx].unread++; if (mark_unread && channel && !outgoing && textMentionsMe(text)) { _ui_threads[t_idx].has_mention = true; // blue @ on the inbox row @@ -41994,12 +44268,26 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _sensors = sensors; _node_prefs = node_prefs; + // #161 one-time heal: presets used to write airtime_factor = pct/100 (a 10% preset + // configured ~91% duty). Exact-preset matches with the old value get the correct + // factor rewritten + persisted; see healPresetAirtimeFactor for the guard. + healPresetAirtimeFactor(); + + // #207 auto-retry: apply the persisted Radio & Mesh toggle (default ON). + the_mesh.setCompanionRetryEnabled(touchPrefsGetRetryEcho()); + #if defined(ESP32) // Clock floor (#89): restore the highest epoch this device ever handed out so // a power cycle without a time source cannot step protocol timestamps // backwards (server replay guards silently drop those). Seed before the UI // generates traffic; written back rate-capped in loop() + on shutdown(). rtc_clock.seedFloor(touchPrefsGetClockFloor()); + // ...then push the resulting time into the ESP32 system clock, which is what every + // displayed timestamp reads. On a board with a battery-backed RTC chip (P4) the chip + // knows the time but the system clock is still on its power-on seed, so the UI would + // otherwise show 15 May 2024 until a network/GPS sync landed. Order matters: after + // seedFloor(), so a dead chip still yields the persisted floor instead of the seed. + rtc_clock.seedSystemClock(); #endif // GPS resume: initBasicGPS() always leaves the module stopped at boot, so a @@ -42340,6 +44628,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // linearizes a history file written under either capacity. #if defined(ESP32) _ui_msg_cap = uiDataFsIsSdCard() ? MAX_UI_MESSAGES_SD : MAX_UI_MESSAGES; + uiDataEnsureDirs(); // segment dir exists before the loader scans / the first append #endif size_t msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap; const size_t threads_bytes = sizeof(UIThread) * MAX_UI_THREADS; @@ -42457,6 +44746,19 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // is the single point where orientation is established for the session. #if defined(ESP32) s_ui_rotation = touchPrefsGetUiRotation(); +#if defined(HELTEC_LORA_V4_R8) + // V4-R8: force PORTRAIT at every boot. A tester who switched to landscape got stuck: the + // shared cap-touch landscape transform was tuned on the V4's ST7789 driver, whose portrait + // baseline is panel rotation 2 (+180) -- the R8's LovyanGFX panel runs baseline 0, so the + // same map landed every touch point-mirrored and the setting could not be reached to undo + // it. The R8-specific map in HeltecV4CapTouch.cpp is the candidate fix (tester-verify); + // until a tester confirms it, booting always returns to the known-good portrait, so the + // worst case of trying landscape is a reboot. + if (s_ui_rotation != LV_DISP_ROT_NONE) { + s_ui_rotation = LV_DISP_ROT_NONE; + touchPrefsSetUiRotation(LV_DISP_ROT_NONE); + } +#endif #if defined(HAS_TDECK_GT911) // The T-Deck panel is landscape-native (320x240) — the early boot wordmark // already renders upright at panel rotation 3. Always run the UI in @@ -42744,6 +45046,16 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // matching the hardware). No-op on a V4.2 board (femLnaControllable() == false). if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna()); #endif +#if defined(HAS_TDISPLAY_P4) + // T-Display P4 antenna select: force the on-board antenna (RF1 / IO1 HIGH) on EVERY boot, + // deliberately ignoring whatever was chosen last session. The external socket may have + // nothing screwed onto it, and transmitting into an open connector is what damages the PA — + // so the state you get for free after any power cycle, crash or OTA has to be the safe one. + // LilyGo's own driver preloads the same level for the same reason. Choosing external is a + // per-session confirmed action; this re-asserts powerOnSequence()'s park in case anything + // touched IO1 in between. + xl9535.setAntennaMode(Xl9535::ANT_INTERNAL); +#endif // Buffered LoRa receive (experimental, default OFF): apply the saved opt-in. if (touchPrefsGetRxQueue()) radio_driver.rxQueueEnable(true); @@ -42883,10 +45195,57 @@ int UITask::getThreadCount(bool channel_mode, int out_indexes[], int max_out) co return copy_n; } +// One ring pass that answers "has any message?" for EVERY thread at once (see the note on +// _thread_hist). Stops as soon as every live thread is accounted for, so a device whose +// threads all have recent traffic barely reads the ring at all. +void UITask::rebuildThreadHistoryFlags() const { + for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_msgs[i] = 0; + for (int i = 0; i < _ui_msg_count; ++i) { + const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[idx]; + if (!m.thread[0]) continue; // blanked by a clear / cap trim + for (int t = 0; t < MAX_UI_THREADS; ++t) { + if (!_ui_threads[t].used) continue; + if (m.channel == _ui_threads[t].channel && + strncmp(m.thread, _ui_threads[t].name, MAX_THREAD_NAME) == 0) { + if (_thread_msgs[t] < 0xFFFF) ++_thread_msgs[t]; + break; + } + } + } + _thread_hist_dirty = false; +} + +// Trim this thread back to the per-chat cap by blanking its OLDEST records. Walking from the +// oldest end finds them immediately for the chat that is actually over the cap (it is the one +// filling the ring), so the common case is a couple of steps, not a full scan. +void UITask::enforceHistoryCap(int thread_idx) { + const uint16_t cap = touchPrefsGetHistPerChat(); + if (!cap || thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return; + if (_thread_hist_dirty) rebuildThreadHistoryFlags(); + if (_thread_msgs[thread_idx] <= cap) return; + const bool ch = _ui_threads[thread_idx].channel; + const char* nm = _ui_threads[thread_idx].name; + int trimmed = 0; + for (int i = _ui_msg_count - 1; i >= 0 && _thread_msgs[thread_idx] > cap; --i) { + const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; // i = oldest .. 0 = newest + UIMessage& m = _ui_msgs[idx]; + if (!m.thread[0] || m.channel != ch) continue; + if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue; + m.thread[0] = '\0'; + m.text[0] = '\0'; + m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); + --_thread_msgs[thread_idx]; + ++trimmed; + } + if (trimmed) _msgs_dirty = true; +} + bool UITask::threadHasMessageHistory(int thread_idx) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return false; - int tmp[8]; - return getThreadMessageIndexes(thread_idx, tmp, 8, false) > 0; + if (_thread_hist_dirty) rebuildThreadHistoryFlags(); + return _thread_msgs[thread_idx] > 0; } int UITask::getCombinedInboxCount(int out_indexes[], int max_out) const { @@ -42945,6 +45304,7 @@ bool UITask::deleteMessageBySlot(int msg_idx) { m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); // the owning segment needs a compaction pass _msgs_dirty = true; return true; } @@ -42961,11 +45321,13 @@ int UITask::clearThreadHistory(int thread_idx) { m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); ++cleared; } } _ui_threads[thread_idx].unread = 0; _ui_threads[thread_idx].has_mention = false; + _thread_msgs[thread_idx] = 0; // emptied, and no other thread was touched _threads_dirty = true; if (cleared) _msgs_dirty = true; return cleared; @@ -42985,13 +45347,32 @@ bool UITask::ignoreSenderInActiveThread(const char* sender_name) { uint8_t pub[32]; bool have = false; if (!_active_thread_is_channel) { - // DM: the active thread's contact IS the sender. - if (hasContactPub(_ui_threads[_active_thread_idx].mesh_contact_pub)) { - memcpy(pub, _ui_threads[_active_thread_idx].mesh_contact_pub, sizeof(pub)); + // DM *or* ROOM — both are contact threads. For a ROOM the thread's contact is the + // SERVER, not the person who posted, so blocking its pubkey silenced the ENTIRE room + // (newRoomMsgFromPubWithMeta drops on the room pubkey). Blocking one loudmouth should + // never cost you the whole room. Room posts carry only an author display name (passed + // as the sender override), so block the AUTHOR by name and leave the room intact. If + // the tapped sender IS the room's own name, fall through to the old pubkey block so + // muting a whole room from inside it still works. + const uint8_t* tpub = _ui_threads[_active_thread_idx].mesh_contact_pub; + if (sender_name && sender_name[0] && hasContactPub(tpub)) { + ContactInfo* rc = the_mesh.lookupContactByPubKey(tpub, PUB_KEY_SIZE); + if (rc && rc->type == ADV_TYPE_ROOM && strcmp(rc->name, sender_name) != 0) + return touchPrefsSetNameIgnored(sender_name, true); + } + if (hasContactPub(tpub)) { + memcpy(pub, tpub, sizeof(pub)); have = true; } } else if (sender_name && sender_name[0]) { - // Channel / room: resolve the sender display name -> a contact pubkey. + // Channel: block by NAME, ALWAYS — this is the only thing the channel RX path + // can actually match. A group packet carries no per-sender pubkey (MyMesh's + // channel branch calls newMsgFromPubWithMeta with from_pub = nullptr), so the + // prefix ignore-list is never consulted for channel traffic and a pubkey-only + // block is a SILENT NO-OP. That was issue #177: the sender showed up in the + // blocked list, yet their posts kept arriving, because a sender who happened to + // be a saved contact took the pubkey path below and never got a name entry. + const bool name_ok = touchPrefsSetNameIgnored(sender_name, true); ContactInfo c; const int n = the_mesh.getNumContacts(); for (int i = 0; i < n; ++i) { @@ -43001,13 +45382,11 @@ bool UITask::ignoreSenderInActiveThread(const char* sender_name) { break; } } - if (!have) { - // Not a saved contact — e.g. a bot posting inside a joined room, whose - // post carries only a display name (no pubkey to put in the prefix - // ignore-list). Block by NAME instead so it can actually be silenced; - // newMsgImpl drops future posts whose parsed sender matches. - return touchPrefsSetNameIgnored(sender_name, true); - } + // Not a saved contact (e.g. a bot posting in a joined room): the name entry is + // the whole block. A saved contact ALSO gets the pubkey entry below, so their + // DMs stay blocked exactly as before — the name entry is purely additive and is + // what silences their channel posts. + if (!have) return name_ok; } if (!have) return false; touchPrefsSetIgnored(pub, true); @@ -43088,13 +45467,6 @@ void UITask::onLvTabChanged(int tab_index) { if (tab_index > k_last) tab_index = k_last; } _touch_screen = static_cast<TouchUiScreen>(static_cast<uint8_t>(tab_index)); -#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI) - SdNvsPrefs prefs; - if (prefs.begin("meshTouch", false)) { - prefs.putUChar("tab", static_cast<uint8_t>(tab_index)); - prefs.end(); - } -#endif } void UITask::appendDiag(const char* message) { @@ -43319,6 +45691,24 @@ int UITask::getGpsSats() { return lp ? (int)lp->satellitesCount() : -1; } +uint32_t UITask::getGpsTime() { + if (!_sensors) return 0; + LocationProvider* lp = _sensors->getLocationProvider(); + if (!lp) return 0; + const long t = lp->getTimestamp(); + // The provider builds this from the NMEA date/time fields, which read as year 0 (epoch far in + // the past) until the receiver has actually decoded them off the air. Anything plausibly recent + // means real satellite time. + return (t > 1700000000L) ? (uint32_t)t : 0; +} + +int UITask::getGpsAltitude() { + if (!_sensors) return 0; + LocationProvider* lp = _sensors->getLocationProvider(); + if (!lp || !lp->isValid()) return 0; + return (int)(lp->getAltitude() / 1000); // provider reports millimetres +} + // Keep the node's advertised location synced to GPS once we have a fix, and // persist it occasionally so a fix survives reboot ("had a fix at least once"). // Called every UITask::loop(). No-op on boards without GPS (provider == NULL). @@ -43404,13 +45794,21 @@ bool UITask::setWifiRadio(bool on) { return false; } -void UITask::setDeviceTimeFromSystemClock() { +bool UITask::setDeviceTimeFromSystemClock() { #if defined(ESP32) - time_t t = time(nullptr); - if (t < 100000) return; - the_mesh.getRTCClock()->setCurrentTime((uint32_t)t); + const uint32_t t = (uint32_t)time(nullptr); + // ESP32RTCClock::begin() seeds the system clock to exactly MIN_VALID_EPOCH on power-on, + // so "still at the seed" means never synced. Pushing that into the mesh clock is a + // two-year step backwards that setCurrentTime()'s own MIN check cannot catch, because + // the value IS the constant it compares against — that is how a P4 (whose RTC chip left + // the system clock on the seed) could sync 2024 over a perfectly good clock. The old + // `t < 100000` guard only caught a clock counting up from zero, which never happens here. + if (t <= ClockFloorRTC::MIN_VALID_EPOCH) return false; + the_mesh.getRTCClock()->setCurrentTime(t); + return true; #else the_mesh.getRTCClock()->setCurrentTime((uint32_t)(millis() / 1000)); + return true; #endif } @@ -43678,9 +46076,25 @@ bool UITask::sendSignalProbe() { // was never written, so the caller must treat the ring as dirty and write it. static bool uiHistWaitWorkerIdle() { const bool cancelled = s_hist_flush_req; + const uint8_t had_kind = s_segjob_kind; + const uint32_t had_seq = s_segjob_first_seq; s_hist_flush_req = false; const uint32_t t0 = millis(); while (s_hist_flush_busy && (uint32_t)(millis() - t0) < 9000) delay(10); + // Drop the worker descriptor UNCONDITIONALLY: the caller is about to write + // synchronously from the ring, and leaving it armed let the next + // flushHistoryIfDue observe a stale job and commit it a SECOND time + // (double-counted record/byte totals, or a duplicate table entry). + s_segjob_kind = SEGJOB_NONE; + s_segjob_redirty = false; + // If the job was already in the worker's hands its fate is now unknown — it + // may have written some or all of its records without being credited. Flag + // the target segment untrusted so the synchronous drain REWRITES it from the + // ring instead of appending on top (which would duplicate whatever landed). + if (had_kind != SEGJOB_NONE && !cancelled) { + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].first_seq == had_seq) { s_seg[i].rewrite_open = 1; break; } + } return cancelled; } @@ -43704,14 +46118,30 @@ void UITask::persistHistoryNow() { } void UITask::rebootDevice() { + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; // Persist chat history synchronously before we reboot — the periodic // flush is rate-capped, so without this a reboot could drop the most // recent chat history. - if (uiHistWaitWorkerIdle()) _msgs_dirty = true; // cancelled snapshot = unwritten data + // A worker still busy after the 9 s wait cap counts as unwritten data too: + // its write may never land (stalled on a dying card), and _msgs_dirty was + // already cleared when its snapshot was armed — skipping the sync save here + // silently dropped everything since the last successful flush. + if (uiHistWaitWorkerIdle() || s_hist_flush_busy) _msgs_dirty = true; if (_threads_dirty) saveThreadsToStorage(); - if (_msgs_dirty) saveMsgsToStorage(); + if (_msgs_dirty && !saveMsgsToStorage()) { + // Last-chance save failed — say so (with the diagnosis) instead of + // rebooting into silent loss. + char why[64]; chatSaveFailText(why, sizeof why); + char msg[128]; + snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history save FAILED"), why); + showAlert(msg, 1800); + lv_refr_now(NULL); + delay(1500); // let the warning actually paint before the reset + } discoveredFlushNow(); // persist the Discovered ring before we go down the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices) + touchPrefsFlush(); // finish queued A/B snapshots before reset if (_board) _board->reboot(); } @@ -43762,7 +46192,13 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex // entirely — no bubble, no notification, no chime. Set from the message // long-press "Block" when the sender isn't a saved contact (gubbinsgalore's // self-advertising room bot). - if (channel && touchPrefsIsNameIgnored(sender)) return; + // Also applies to ROOM posts: those arrive with channel == false but WITH a sender + // override (the resolved author), and a room author can only ever be blocked by name + // since the post carries no per-author pubkey. Without have_sender_override here, a + // room name-block was stored but never enforced. A plain DM has neither flag set, so it + // keeps using the pubkey filter and is unaffected — important, because a DM contact + // sharing a display name with a blocked room bot must not vanish. + if ((channel || have_sender_override) && touchPrefsIsNameIgnored(sender)) return; #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) @@ -43785,8 +46221,12 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex uint8_t in_path[MAX_UI_PATH]; uint8_t in_path_n = 0; uint16_t in_scope = 0; - if ((meta_flags & MSG_META_HAS_RX) && (meta_flags & MSG_META_IS_FLOOD)) { - in_path_n = the_mesh.lastRxPath(in_path, sizeof(in_path)); + if (meta_flags & MSG_META_HAS_RX) { + // Route hashes only exist on floods, but the transport SCOPE also rides scoped DIRECT + // messages (the "Scope direct msgs to region" feature) -- reading it only under IS_FLOOD + // meant a scoped DM's Info popup showed no scope at all (#157). + if (meta_flags & MSG_META_IS_FLOOD) + in_path_n = the_mesh.lastRxPath(in_path, sizeof(in_path)); bool has_scope = false; in_scope = the_mesh.lastRxScope(&has_scope); if (has_scope) meta_flags |= MSG_META_HAS_SCOPE; @@ -44009,8 +46449,116 @@ static inline void uiCp(const char* next) { s_ui_cp_t0 = nowms; } +#if CAP_SD || defined(TLORA_PAGER) +// Runtime SD wedge detect + recover. Some writer saw an SD write fail while the +// card was supposedly mounted (sdNoteIoFailure) — verify with real I/O and, +// if the card stopped answering, drop the mount and re-run the full mount +// ladder (SD.end() + staged SD.begin): exactly what the next reboot would do, +// minus the reboot. Without this the firmware is blind to a wedged card: +// SD.cardType() keeps returning its cached mount-time type, s_sd_mounted stays +// true so fmSdTryMount() no-ops, and every SD feature fails silently until the +// user power-cycles (the "SD stops working until reboot" report). +static void sdHealthTick() { + static uint32_t s_next_probe_ms = 0; + static uint32_t s_next_bg_probe_ms = 0; + const uint32_t now = (uint32_t)millis(); + if (!s_sd_mounted) { + s_sd_fail_note_ms = 0; + // Reinsert watch. After a failed remount ("SD card lost") the SD VFS is + // unregistered and NOTHING outside the file manager ever re-runs a mount + // — a reinserted card then LOOKED recovered (battery quietly falls back + // to SPIFFS) while chat-history writes kept failing silently and new + // messages were never persisted. One light single-attempt mount (no + // ladder; an empty slot fails in well under a second) every 30 s while + // awake — a healthy warm card mounts on the first 4 MHz attempt. The FM + // poll / SD-row tap still provide the full ladder for stubborn cards. + if (touchSleep::isSleeping()) return; + if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; + s_next_bg_probe_ms = now + 30000; +#if defined(TLORA_PAGER) + if (!pagerSdCardPresent()) return; // card-detect says the slot is empty +#endif + if (s_fm_list) return; // FM open: its 2 s poll owns remounting (full ladder) + SPIClass* spi = sdSharedSPI(); + if (!spi) return; + SD.end(); + if (SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE) { + s_sd_mounted = true; + s_sd_size = SD.cardSize(); + s_sd_retry_after_ms = 0; + markSdIo(); + if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); +#if defined(HAS_TDECK_GT911) + // Land the RAM ring on the card promptly, not up to 30+ s later: every + // message received while the card was out is only in RAM. Armed as an + // OFF-THREAD flush — a synchronous write here froze the UI for >30 s on + // a card that mounts fine but times out on large writes. mkdir first — + // a fresh replacement card has no /meshcomod yet, and without it every + // history flush fails silently. + SD.mkdir("/meshcomod"); + uiDataEnsureDirs(); // segment dir too — a fresh replacement card has neither + if (g_lv.task) g_lv.task->flushHistorySoon(); +#endif + mapNoteStorageChanged(); // the map layer has to be told too, or it stays blank + } else { + SD.end(); // leave cardType() honestly CARD_NONE + } + return; + } + if (s_sd_format_pending) return; // format owns the card right now + // Hold off while the core-0 worker is (or may be about to be) on the card — + // SD.end() under an open worker file handle is the crash we must not add. + // On a truly wedged card the worker's own ops fail fast, so this clears. + if (s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request || touchPrefsIoBusy()) return; +#if defined(HAS_TDECK_GT911) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA + if (s_sdfw_request) return; // worker streaming a firmware download to /BINS (T-Deck only) +#endif + // Probe when some SD user flagged a failure (rate-limited to 1/5 s), and + // also on a slow 30 s background cadence — a card yanked while nothing is + // writing (idle home screen) would otherwise never be noticed at all, since + // detection is failure-driven and reads fall back silently. A healthy-card + // probe is one SEND_STATUS transaction (sub-ms), so the idle cost is nil. + // The background probe pauses in the sleep regime (screen off, loop + // throttled): nobody sees the toast, an SD poke keeps the card out of its + // own power-down, and a yank-while-asleep is caught by the first probe + // after wake. The FAILURE-driven probe stays active asleep on purpose — + // scheduled telemetry keeps logging to SD during sleep, and a wedge there + // must still recover. + if (s_sd_fail_note_ms) { + if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; + } else { + if (touchSleep::isSleeping()) return; + if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; + } + s_next_probe_ms = now + 5000; + s_next_bg_probe_ms = now + 30000; + s_sd_fail_note_ms = 0; + if (sdProbeAlive()) return; // transient failure (card full, bad path, ...) — not a wedge + fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake + if (fmSdTryMount()) { + if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); +#if defined(HAS_TDECK_GT911) + SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + uiDataEnsureDirs(); // segment dir too + if (g_lv.task) g_lv.task->flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) +#endif + mapNoteStorageChanged(); + } else { + // Ladder failed — card needs a real power cycle (reinsert). s_sd_mounted is + // now false and SD.cardType() reads CARD_NONE, so features degrade honestly + // instead of silently failing, and the usual remount paths keep retrying. + if (g_lv.task) g_lv.task->showAlert(TR("SD card lost - reinsert to recover"), 2600); + mapNoteStorageChanged(); // drop tiles read off the card that just went away + } +} +#endif + void UITask::loop() { unsigned long now = millis(); +#if defined(ESP32) + // Snapshot copying is quick; all filesystem I/O runs on the core-0 worker. + touchPrefsTick(now); +#endif #if !defined(HAS_TANMATSU) // REMOTE mode: draw/refresh the physical-panel placeholder (first pass via the IP // sentinel, then whenever the IP changes), and clear the bootloop guard once this @@ -44056,9 +46604,8 @@ void UITask::loop() { #endif g_ui_stall_max = 0; g_ui_stall_tag = ""; s_ui_cp_tag = "ui:head"; s_ui_cp_t0 = now; #if defined(ESP32) - // Persist the clock floor every 15 min (small prefs-blob write; SdNvsPrefs - // rewrites a file on the Launcher-SD backend, so keep the cadence low). Power - // loss costs at most this window of floor progress — a soft reset keeps the + // Persist the clock floor every 15 min (coalesced into the next prefs snapshot). + // Power loss costs at most this window of floor progress — a soft reset keeps the // ESP32 RTC domain ticking, so only true power-off needs the persisted copy. { static unsigned long s_floor_due = 0; if (now >= s_floor_due) { @@ -44441,6 +46988,7 @@ void UITask::loop() { else s_jump_dimmed = true; // nothing open: park the state } #if CAP_SD || defined(TLORA_PAGER) + sdHealthTick(); // wedge detect + remount, driven by writer-flagged failures (any task) // microSD insert/remove detection — only while the file manager is open, so // there's no idle SPI traffic. SD.begin runs on this loop task (never // concurrent with the radio's SPI), and reuses the radio's already-begun bus. @@ -44453,10 +47001,24 @@ void UITask::loop() { // unmountable card spikes current / churns the bus and can reset the board. if (now >= s_sd_retry_after_ms && fmSdTryMount()) { showAlert(TR("SD card inserted"), 1500); +#if defined(HAS_TDECK_GT911) + SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + uiDataEnsureDirs(); // segment dir too + flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) +#endif + mapNoteStorageChanged(); if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } - } else if (SD.cardType() == CARD_NONE) { + } else if (!s_hist_flush_busy && !s_hist_flush_req && !sdProbeAlive()) { + // Real-I/O probe, NOT `SD.cardType() == CARD_NONE`: cardType() is cached + // at mount and only SD.end() ever resets it, so the old check could never + // fire — neither a yanked card nor a wedged one was ever detected here. + // Never probe/unmount under an in-flight history flush: SD.end() while + // the flush holds its open temp file leaves a stale FIL, and every + // later write on it fails FR_INVALID_OBJECT -> EBADF ("bad file + // number") — recovery must not sabotage the very write it protects. fmSdUnmount(); + mapNoteStorageChanged(); showAlert(TR("SD card removed"), 1500); if (s_fm_fs == &SD || !s_fm_fs) fmShowRoots(); } @@ -44693,7 +47255,10 @@ void UITask::loop() { // injected via the same path as the hardware keys (printable chars type in; 0x08 = // backspace, 0x0D = enter/send). uint16_t wk; - for (int i = 0; i < 16 && g_web_mirror.popKey(&wk); ++i) + // Drain generously: a paste or a phone autocomplete arrives as one burst, and a low cap + // left the rest sitting in the ring until the next iteration (where it could be pushed + // out by newer keys and silently lost). Each key here is cheap. + for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) handleHwKey((int)wk); #else // On-screen-keyboard boards (V4/RAK): the browser's laptop keyboard should type too, not @@ -44702,7 +47267,8 @@ void UITask::loop() { // on-screen key tap and syncs to the real field on Enter. Enter fires the keyboard's // ready cb (send/next); backspace deletes; other printables type in. uint16_t wk; - for (int i = 0; i < 16 && g_web_mirror.popKey(&wk); ++i) { + // Same generous drain as the physical-keyboard branch above (burst paste / autocomplete). + for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) { if (!fta) continue; // no editable field focused -> drain + drop if (wk == 0x0D || wk == 0x0A) lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr); else if (wk == 0x08 || wk == 0x7F) lv_textarea_del_char(fta); @@ -44829,7 +47395,7 @@ void UITask::loop() { } // Also hard-lock the tab bar while Snake is open so a swipe/tap near the // bottom can't slip through to the app switcher (e.g. into the map). - const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen(); + const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen() || ChannelUtil::isOpen(); if (want_lock != s_tabbar_locked && g_lv.tabview) { lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview); if (tab_btns) { @@ -44914,43 +47480,55 @@ void UITask::loop() { lv_group_focus_obj(s_nav_last); } #endif -#if defined(HAS_TDECK_GT911) +#if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Deferred microSD FAT32 format (runs a couple ticks after the notice paints). // f_mkfs blocks the loop for tens of seconds on a big card, so drop the loop // watchdog around it (CPU0 idle keeps running, so no reset) to avoid a // mid-format reset that would corrupt the card. + // Gate matches fmSdDoFormat (T-Deck + M9 + V4-R8): the Format button existed on + // all three, but this worker was T-Deck-only — on the others the formatting + // notice stayed up forever with no format running, and the pending latch even + // blocked remounting until reboot (#172). if (s_sd_format_pending && --s_sd_format_pending == 0) { - disableLoopWDT(); - SPIClass* spi = tdeckSharedSPI(); bool ok = false; - if (spi) { - // Explicit reformat (not SD.begin's format_if_empty, which only formats an - // already-unreadable card): init the card to register the FatFs diskio at - // pdrv (no mount), f_mkfs to FAT32, write the MESHCOMOD label by hand while - // unmounted, release, then remount the fresh FS via SD.begin. Works on any - // card (FAT32, exFAT, blank) since f_mkfs hardware-inits + overwrites it. - SD.end(); // drop any existing mount - uint8_t pdrv = sdcard_init(PIN_SD_CS, spi, 4000000); - if (pdrv != 0xFF) { - char drv[3] = { (char)('0' + pdrv), ':', 0 }; - void* work = malloc(4096); // f_mkfs scratch (>= FF_MAX_SS) - if (work) { - if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { - sdWriteFatLabel(pdrv, "MESHCOMOD"); - ok = true; + { + LoopWdtGuard loop_wdt_guard; + SPIClass* spi = sdSharedSPI(); // this board's SD bus (T-Deck: LoRa SPI; R8: TFT FSPI; M9: shared SPI) + if (spi) { + // Explicit reformat (not SD.begin's format_if_empty, which only formats an + // already-unreadable card): init the card to register the FatFs diskio at + // pdrv (no mount), f_mkfs to FAT32, write the MESHCOMOD label by hand while + // unmounted, release, then remount the fresh FS via SD.begin. Works on any + // card (FAT32, exFAT, blank) since f_mkfs hardware-inits + overwrites it. + SD.end(); // drop any existing mount + uint8_t pdrv = sdcard_init(PIN_SD_CS, spi, 4000000); + if (pdrv != 0xFF) { + char drv[3] = { (char)('0' + pdrv), ':', 0 }; + void* work = malloc(4096); // f_mkfs scratch (>= FF_MAX_SS) + if (work) { + if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { + sdWriteFatLabel(pdrv, "MESHCOMOD"); + ok = true; + } + free(work); } - free(work); + sdcard_uninit(pdrv); } - sdcard_uninit(pdrv); + if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); } - if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 3); } - enableLoopWDT(); fmHideFormatOverlay(); if (ok && SD.cardType() != CARD_NONE) { s_sd_mounted = true; s_sd_size = SD.cardSize(); - sdEnsureMeshcomodFolders(); // BINS / RECBCK / SETTINGS / MAPS / LOGS + sdEnsureMeshcomodFolders(); // BINS / RECBCK / SETTINGS / MAPS / LOGS / meshcomod(+msgs) + // The format just erased every chat segment while the table still + // claims they exist — without a resync the durability watermark keeps + // pretending the old records are on disk (permanently lost) and the + // next append targets a segment file that is gone. Rebuild + re-land. + uiDataEnsureDirs(); + flushHistorySoon(); + mapNoteStorageChanged(); char done[56], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(done, sizeof done, TR("SD formatted - %s (MESHCOMOD)"), cs); @@ -44961,17 +47539,21 @@ void UITask::loop() { } if (s_fm_list) fmShowRoots(); } - // Deferred copy/move (paste). WDT off — a big tree can take a while. +#endif + // Deferred copy/move (paste). WDT off — a big tree can take a while. Ungated + // like fmActPasteCb: every board with the Files app can paste, and with this + // worker T-Deck-only the busy overlay stayed up forever on the rest (#172 class). if (s_fm_paste_pending && --s_fm_paste_pending == 0) { - disableLoopWDT(); - bool ok = fmDoPaste(); - enableLoopWDT(); + bool ok = false; + { + LoopWdtGuard loop_wdt_guard; + ok = fmDoPaste(); + } fmHideFormatOverlay(); showAlert(ok ? TR("Pasted") : TR("Paste failed"), ok ? 1500 : 2400); if (s_fm_list) fmRefresh(); } #endif -#endif } void UITask::setBootPhase(const char* label) { @@ -44984,7 +47566,8 @@ void UITask::shutdown(bool restart) { if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty) saveMsgsToStorage(); #if defined(ESP32) - touchPrefsSetClockFloor(rtc_clock.getFloor()); // clock floor: final synchronous save (#89) + touchPrefsSetClockFloor(rtc_clock.getFloor()); // queue final clock-floor update (#89) + touchPrefsFlush(); // finish queued A/B snapshots #endif if (_display) { _display->startFrame(); diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index a838f77..e7acb2c 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -60,6 +60,12 @@ public: struct UIMessage { uint32_t ts; + // Monotonic per-record sequence number, assigned at append and persisted by + // the segmented store (its record-to-segment mapping keys on seq ranges). + // NEVER reset mid-session; the boot loader seeds the generator from + // max(loaded seq)+1. Distinct from _msgcount, which the companion protocol + // may overwrite (msgRead) and therefore cannot be a unique key. + uint32_t seq; bool channel; bool outgoing; uint32_t ack_hash; // expected-ack for outgoing DMs (0 if none / channel / incoming) @@ -132,12 +138,35 @@ private: char _alert[80]; unsigned long _alert_expiry; int _msgcount; + /** Next UIMessage::seq to hand out. Seeded by the loader (max loaded seq + 1), + * strictly monotonic for the session. See UIMessage::seq. */ + uint32_t _ui_seq_next = 1; int _ui_msg_count; int _ui_msg_head; /** Runtime ring capacity: MAX_UI_MESSAGES_SD when history lives on an SD card, * else MAX_UI_MESSAGES. Fixed for the whole boot (chosen before the PSRAM * alloc in begin()); the loader linearizes files written under another cap. */ int _ui_msg_cap = MAX_UI_MESSAGES; + /** "Does this thread have any stored message?", answered in O(1). + * + * threadHasMessageHistory() used to answer it by walking the message ring looking for a + * match, and the inbox/unread paths call it once PER THREAD. A thread that HAS recent + * messages exits that scan early; a thread with none scans every record in the ring doing + * a strncmp each — and "no history" is exactly what those callers are testing for. With a + * busy public channel filling the ring (a field report: 3800 messages) each inbox refresh + * became tens of thousands of string compares, which is the reported "interface slows down + * drastically", and why it was still felt at only ~300 messages. + * + * Appends set the owning thread's flag directly, so the common path never scans. Anything + * that REMOVES messages (ring eviction, delete, clear, a fresh load) can only invalidate + * the flags, so it just marks them dirty and the next reader rebuilds all threads in ONE + * ring pass instead of one pass per thread. */ + mutable uint16_t _thread_msgs[MAX_UI_THREADS] = { 0 }; + mutable bool _thread_hist_dirty = true; + void rebuildThreadHistoryFlags() const; + /** Drop this thread's oldest stored messages until it is back within the per-chat cap + * (touchPrefsGetHistPerChat; 0 = uncapped). Called after an append. */ + void enforceHistoryCap(int thread_idx); unsigned long _next_thread_seed; UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin() UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin() @@ -234,6 +263,8 @@ private: bool loadThreadsFromStorage(); bool loadMsgsFromStorage(); bool loadLegacyHistoryFromStorage(); + bool loadMsgsFromSegments(); // segmented store (generation 3) loader + bool migrateRingToSegments(); // one-time old-format -> segments migration (verify-then-delete) bool saveThreadsToStorage(); bool saveMsgsToStorage(); @@ -353,18 +384,26 @@ public: if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_msgs) return false; const bool ch = _ui_threads[idx].channel; const char* nm = _ui_threads[idx].name; - int best = -1; uint32_t best_ts = 0; - for (int i = 0; i < _ui_msg_cap; ++i) { - const UIMessage& m = _ui_msgs[i]; + // Newest by RING ORDER, not by timestamp. m.ts is taken from the ESP32 system clock, + // which is not monotonic across reboots: it restarts from ESP32RTCClock::begin()'s + // power-on seed every boot and only climbs with uptime until a real time source lands. + // Picking max(ts) therefore let a message received hours into an EARLIER boot outrank + // every message from this one, so the chat-list preview stuck on an old message and + // never updated (reported on the T-Display P4, whose system clock stayed on that seed + // because it has an RTC chip — see the mirror in ClockFloorRTC). Ring order is the + // actual arrival order and cannot be wrong, so walk back from the head and take the + // first match; that also exits immediately instead of scanning the whole ring. + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[slot]; if (!m.text[0] || m.channel != ch) continue; if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue; - if (best < 0 || m.ts >= best_ts) { best = i; best_ts = m.ts; } // >= : a later slot wins ts ties (newer in the ring) + if (sender && sender_cap) { strncpy(sender, m.sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; } + if (text && text_cap) { strncpy(text, m.text, text_cap - 1); text[text_cap - 1] = '\0'; } + if (outgoing) *outgoing = m.outgoing; + return true; } - if (best < 0) return false; - if (sender && sender_cap) { strncpy(sender, _ui_msgs[best].sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; } - if (text && text_cap) { strncpy(text, _ui_msgs[best].text, text_cap - 1); text[text_cap - 1] = '\0'; } - if (outgoing) *outgoing = _ui_msgs[best].outgoing; - return true; + return false; } int threadScroll() const { return _thread_scroll; } void setThreadScroll(int v) { _thread_scroll = v; } @@ -420,6 +459,13 @@ public: bool getGpsFix(); /** Satellites currently in view, or -1 if unknown / no GPS hardware. */ int getGpsSats(); + /** UTC epoch the GPS itself has decoded, or 0 if it has none yet. A receiver decodes TIME from + * the satellite stream well before it can solve a POSITION, so a valid time with no fix is + * positive proof the module is alive and tracking, not dead. That is the distinction the GPS + * page could not previously show, and it is what "acquiring..." was hiding. */ + uint32_t getGpsTime(); + /** Altitude in metres from the last fix (0 when there is no fix). */ + int getGpsAltitude(); /** True once a valid fix has been seen this session. */ bool getGpsHadFix() const { return _gps_had_fix; } double getNodeLat() const { return _sensors ? _sensors->node_lat : 0.0; } @@ -453,7 +499,8 @@ public: bool enableBle(); void disableBle() { if (_serial) _serial->disableBle(); } int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; } - void setDeviceTimeFromSystemClock(); + /** Push the ESP32 system clock into the mesh RTC. false = never synced, mesh clock untouched. */ + bool setDeviceTimeFromSystemClock(); /** Mark recent user input — call when touch / hw button is detected. */ void noteUserInput(); /** Get / set the screen-off-after-idle timeout (0 = never). Persists in NVS. */ @@ -486,6 +533,7 @@ public: // lost — the periodic flush is off-thread and rate-capped. Overrides the // AbstractUITask hook so the companion CMD_REBOOT path flushes too. void persistHistoryNow() override; + void flushHistorySoon(); // arm an immediate OFF-THREAD flush (worker) — no min-delay clamp, no UI stall // from AbstractUITask void msgRead(int msgcount) override; diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index c451b48..0b5e2ee 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -44,11 +44,12 @@ // file manager's SD-vs-FFat backend selection) is still hardcoded to // HAS_TDECK_GT911/HAS_TANMATSU specifically, never migrated to be CAP_SD- // generic — turning these on here just hits "SD"/"CARD_NONE"/"fmSdTryMount" - // undeclared, not real SD support. A real mount needs pager-specific wiring - // (CS 21, its own shared-SPI helper), which is unscheduled follow-up work, - // not part of this milestone. - #define CAP_SD 0 - #define CAP_FILESYSTEM 0 + // SD support shipped after this note was written: fmSdTryMount has a pager branch + // (TFT_eSPI shared SPIClass + XL9555 card-detect) and the tile cache falls back to the + // card under Launcher. Flags flipped for #193 so the Files app + "Store data on SD" + // machinery light up like the T-Deck's. + #define CAP_SD 1 + #define CAP_FILESYSTEM 1 #define CAP_GPS 1 // u-blox MIA-M10Q #define CAP_OTA 1 // dual-OTA partition layout, same shape as the T-Deck #define CAP_LOCK_SCREEN 1 @@ -139,6 +140,15 @@ #define CAP_LOCK_SCREEN 0 #endif +// Persisted, restart-to-apply UI-size selector. Large-screen boards already +// expose it; the Pager adds font-only presets because its 480x222 viewport is +// wide enough for larger type but too short for global geometry scaling. +#if CAP_LARGE_SCREEN || defined(TLORA_PAGER) + #define CAP_UI_SIZE 1 +#else + #define CAP_UI_SIZE 0 +#endif + // ---- Derived input capabilities --------------------------------------------- // Physical keyboard: T-Deck matrix, Tanmatsu keypad, the pager's TCA8418, or // the ThinkNode M9 keyboard. diff --git a/tdisplay_p4/build.sh b/tdisplay_p4/build.sh index 44d1d83..6011aee 100755 --- a/tdisplay_p4/build.sh +++ b/tdisplay_p4/build.sh @@ -71,6 +71,6 @@ WADA_FW_DATE="$(date '+%-d %b %Y')" exec idf.py -B build/tdisplay_p4 \ -DDEVICE=tdisplay_p4 \ - -DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/tdisplay_p4;sdkconfigs/wadamesh" \ + -DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/wadamesh;sdkconfigs/tdisplay_p4" \ -DWADA_FW_TAG="$WADA_FW_TAG" -DWADA_FW_DATE="$WADA_FW_DATE" \ -DIDF_TARGET=esp32p4 "$@" diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 2d4ccde..b15483f 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -25,6 +25,14 @@ idf_component_register( # -Wno-error does not cancel it, so strip it outright (warnings stay visible, non-fatal). idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-variable" APPEND) idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-const-variable" APPEND) +# -O2 (the #167 underrun fix stack) enables deeper flow analysis that trips new warnings in +# VENDORED code (esp-modbus and friends) which builds clean at -Os. Same policy as above: +# keep the warnings visible, never fatal, for code we consume as a library. +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-truncation" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-overflow" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=array-bounds" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=restrict" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=maybe-uninitialized" APPEND) idf_build_get_property(_co COMPILE_OPTIONS) list(REMOVE_ITEM _co "-Werror=all") idf_build_set_property(COMPILE_OPTIONS "${_co}") @@ -103,6 +111,46 @@ endforeach() # One-off C6-reflash helper: build with `C6_FLASH_HELPER=1 ./build.sh build` to boot a P4 image that # never touches the C6 (no C6_EN reset pulse, no AT worker), so the C6 can be held in ROM download mode # and reflashed over its own USB. Harmless when the env var is unset (normal builds are unaffected). +# #167 SD-coupling experiments: WADA_SD_KHZ=<khz> / WADA_SD_1BIT=1 derate the primary SD mount +# (clock and/or bus width) to isolate + mitigate the SD-activity screen flash. +if(DEFINED ENV{WADA_SD_KHZ}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_KHZ=$ENV{WADA_SD_KHZ}" APPEND) + message(STATUS "wadamesh: SD primary rung clock = $ENV{WADA_SD_KHZ} kHz") +endif() +if(DEFINED ENV{WADA_SD_1BIT}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_1BIT=true" APPEND) + message(STATUS "wadamesh: SD primary rung = 1-bit bus") +endif() + +# #167: WADA_DSI_MBPS=<n> overrides the RM69A10 DSI lane bit rate (default 750 -- see the +# RM_BITRATE note in RM69A10Display.cpp; 1000 = the LilyGo/Meck value with the thin eye). +if(DEFINED ENV{WADA_DSI_MBPS}) + idf_build_set_property(COMPILE_OPTIONS "-DRM_BITRATE_MBPS=$ENV{WADA_DSI_MBPS}" APPEND) + message(STATUS "wadamesh: DSI lane rate = $ENV{WADA_DSI_MBPS} Mbps") +endif() + +# #167: WADA_SD_STORE_TEST=1 puts the store back on the SD card (writes hop to core 0). +if(DEFINED ENV{WADA_SD_STORE_TEST}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_STORE_TEST=1" APPEND) + message(STATUS "wadamesh: SD-STORE-ON-CORE-0 experiment build") +endif() + +# Companion diagnostic (#167): `WADA_P4_POKE_TRACE=1 ./build.sh build` logs every runtime poke at +# the panel or the XL9535 expander (DCS brightness writes, disp on/off, expander bit writes) so +# visible flashes can be correlated against them on the console. +if(DEFINED ENV{WADA_P4_POKE_TRACE}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_POKE_TRACE=1" APPEND) + message(STATUS "wadamesh: POKE TRACE enabled (diagnostic build, do not ship)") +endif() + +# Diagnostic build for the whole-screen colour flash (#167): `WADA_P4_FLUSH_TRACE=1 ./build.sh build` +# logs every large, mostly-single-colour band pushed to the panel, with its RGB565 value. If the +# screen flashes and nothing logs, the cause is below LVGL and the UI is the wrong place to look. +if(DEFINED ENV{WADA_P4_FLUSH_TRACE}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_FLUSH_TRACE=1" APPEND) + message(STATUS "wadamesh: FLUSH TRACE enabled (diagnostic build, do not ship)") +endif() + if(DEFINED ENV{C6_FLASH_HELPER}) idf_build_set_property(COMPILE_OPTIONS "-DTDP4_C6_FLASH_HELPER=1" APPEND) message(STATUS "wadamesh: BUILDING C6-FLASH HELPER (P4 leaves the C6 free)") diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index eb1e27f..3160a3f 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -14,7 +14,12 @@ #include "esp_heap_caps.h" #include "UITask.h" #include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init() -#include <FFat.h> // internal 'storage' FAT partition (no-card fallback) +#include <LittleFS.h> // internal 'storage' partition, mounted as LittleFS (label lookup is + // subtype-agnostic, so the ex-FAT partition converts in place -- no + // partition-table change, OTA-safe). The P4's FAT-on-flash layer has + // documented broken metadata (exists/size/f_getfree lie) and v3 of the + // #167 migration proved even its WRITES cannot be trusted; LittleFS is + // the same battle-proven backend every S3 board uses internally. #include <SD_MMC.h> // microSD on SDMMC slot 0 (primary store) #include "esp_partition.h" #include <WiFi.h> @@ -72,8 +77,8 @@ volatile int g_boot_phase = 0; extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; } // App globals — src/main.cpp declares these; we own them here (that file is excluded from the build). -DataStore store(FFat, rtc_clock); -bool g_fs_ok = false; // FFat('storage') mounted (extern — UITask file browser) +DataStore store(LittleFS, rtc_clock); +bool g_fs_ok = false; // internal 'storage' (LittleFS) mounted (extern — UITask file browser) bool g_sd_ok = false; // microSD mounted (extern) MultiTransportCompanionInterface serial_interface; StdRNG fast_rng; @@ -99,9 +104,11 @@ static void hostedConnectC6() { // Recursive FS→FS copy for the one-time SD adoption migration (FFat store → SD /meshcomod). // Skips files that already exist at the destination, so a partial earlier run just completes. -static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp) { - File probe = dst.open(dp, FILE_READ); - if (probe) { probe.close(); return; } +static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp, bool overwrite = false) { + if (!overwrite) { + File probe = dst.open(dp, FILE_READ); + if (probe) { probe.close(); return; } + } File in = src.open(sp, FILE_READ); if (!in) return; File out = dst.open(dp, FILE_WRITE); @@ -112,7 +119,7 @@ static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const ch out.close(); in.close(); printf("[storage] migrated %s\n", sp); } -static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir) { +static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir, bool overwrite = false) { File d = src.open(sdir); if (!d || !d.isDirectory()) { if (d) d.close(); return; } dst.mkdir(ddir); @@ -126,8 +133,8 @@ static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char snprintf(dp, sizeof dp, "%s/%s", ddir, leaf); bool is_dir = f.isDirectory(); f.close(); - if (is_dir) migrateFsDir(src, sp, dst, dp); - else migrateFsCopyFile(src, sp, dst, dp); + if (is_dir) migrateFsDir(src, sp, dst, dp, overwrite); + else migrateFsCopyFile(src, sp, dst, dp, overwrite); } d.close(); } @@ -164,9 +171,12 @@ static void wadameshSetup() { DisplayDriver* disp = &display; // 6. Storage — microSD (SDMMC slot 0) primary, internal FFat 'storage' as the no-card fallback. - g_fs_ok = FFat.begin(true, "/ffat", 10, "storage"); - printf("[storage] FFat(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); - if (g_fs_ok) { FFat.mkdir("/identity"); FFat.mkdir("/bl"); } + // formatOnFail=true: the first boot of this build finds FAT data in the partition, fails the + // littlefs mount, and formats it — the deliberate one-way conversion (store contents come back + // from the SD card via the v4 migration below). + g_fs_ok = LittleFS.begin(true, "/lfs", 10, "storage"); + printf("[storage] LittleFS(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); + if (g_fs_ok) { LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); } // The slot's VDD is gated by the XL9535's SD_EN (IO15), ACTIVE-LOW — powerOnSequence() drives it // low. (Root-caused 2026-07-15 with a GPIO pad sweep: SD_EN high = all six SD pads clamped LOW // through the unpowered card's ESD diodes -> 0x107/0x109 on every mount; SD_EN low = pads high, @@ -180,9 +190,24 @@ static void wadameshSetup() { for (int p : {44, 39, 40, 41, 42}) gpio_pullup_en((gpio_num_t)p); // Mount ladder: 4-bit@20 MHz normally succeeds first try now that the slot is powered; the // fallbacks stay for marginal cards. (1-bit skips D1..D3; 5 MHz derates signal margin.) + // + // #167: SD activity IS the whole-screen flash. Proven by elimination on-device: identical + // firmware with the card removed never flashes, while every reported trigger (message arrives, + // opening an unread chat, contact add/delete, boot pre-splash) ends in an SD write burst or the + // mount probe. The display stack itself measured clean throughout (framebuffer content, bridge + // underrun, host protocol errors, DCS traffic, expander writes -- all silent during visible + // flashes; DSI config + panel init identical to the flash-free Meck-P4). Remaining coupling is + // electrical: the 4-bit 20 MHz SDMMC bus and/or the card's write-current spikes on the shared + // rail. TDP4_SD_KHZ / TDP4_SD_1BIT (build-time) tune the first mount rung to test/derate. { +#ifndef TDP4_SD_KHZ +#define TDP4_SD_KHZ SDMMC_FREQ_DEFAULT // 20 MHz +#endif +#ifndef TDP4_SD_1BIT +#define TDP4_SD_1BIT false +#endif struct { bool onebit; int khz; const char *tag; } tries[] = { - { false, SDMMC_FREQ_DEFAULT, "4-bit 20MHz" }, + { TDP4_SD_1BIT, TDP4_SD_KHZ, "primary (TDP4_SD_KHZ)" }, { false, 5000, "4-bit 5MHz" }, { true, SDMMC_FREQ_DEFAULT, "1-bit 20MHz" }, { true, 5000, "1-bit 5MHz" }, @@ -197,20 +222,79 @@ static void wadameshSetup() { } printf("[storage] SD_MMC = %s\n", g_sd_ok ? "OK" : "no card"); if (g_sd_ok) { - SD_MMC.mkdir("/meshcomod"); SD_MMC.mkdir("/meshcomod/identity"); SD_MMC.mkdir("/meshcomod/bl"); - // One-time ADOPTION MIGRATION (the beta_36 lesson: never flip a storage root without migrating). - // This device has been running with the store on FFat while the SD init was broken — if the card - // has no identity yet but FFat does, copy the whole FFat store across BEFORE switching the root, - // or the node would boot with a fresh identity and "lose" contacts/prefs/history. - File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); - bool sd_has_id = (bool)sp; if (sp) sp.close(); - File fp = FFat.open("/identity/_main.id", FILE_READ); - bool ff_has_id = (bool)fp; if (fp) fp.close(); - if (!sd_has_id && ff_has_id) { - printf("[storage] first SD adoption -> migrating FFat store to SD /meshcomod\n"); - migrateFsDir(FFat, "/", SD_MMC, "/meshcomod"); + // #167 RESOLUTION -- the hot store lives on INTERNAL FFat; the SD carries only bulk, gentle + // writers (map tiles, backups). Root cause, established by on-device elimination: SD-card + // WRITE bursts electrically disturb the AMOLED (whole-screen blue flash). The display stack + // measured clean throughout -- framebuffer content, DSI bridge underrun, DSI host protocol + // errors, DCS traffic, expander writes: all silent during visible flashes -- and the flash + // survived every digital derate (4->1-bit bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane + // rate) on EVERY card tried, at any brightness, yet vanished the moment the card was removed + // or left unwritten. The card's supply rail is shared with the panel (XL9535 SD_EN switches + // it off the same 3V3 domain), so its program-current spikes reach the panel and firmware + // cannot filter them -- it can only stop hammering the card. Streaming tile writes never + // flashed, so the SD keeps those. This is also why the flash-free Meck build proves nothing + // about the board: it keeps its store internal and never writes the SD at all. + // + // One-time REVERSE migration: earlier P4 builds kept the live store on SD /meshcomod, so if + // the card carries an identity and this build hasn't migrated yet, copy the store back to + // FFat, OVERWRITING FFat's stale pre-adoption copies (the SD is the current truth -- booting + // cardless on the old FFat set showed long-deleted contacts). SD data is left in place, + // untouched, as a rollback. + // v4 migration -- one lesson per predecessor: v1 trusted whichever card was in and copied a + // blank store; v2 copied the whole SD tree and filled the partition before contacts3 landed; + // v3 did format+whitelist correctly and STILL read back empty -- because the P4's FAT-on-flash + // driver itself is broken (its stat layer lying was already documented; v3 proved writes are + // no better). v4 = the same clean-slate whitelist copy, onto LittleFS. Card is never written. + File mk = LittleFS.open("/.hot_store_on_lfs_v4", FILE_READ); + bool migrated = (bool)mk; if (mk) mk.close(); + if (!migrated) { + File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); + bool sd_has_id = (bool)sp; if (sp) sp.close(); + auto fsize = [](fs::FS &f, const char *path) -> size_t { + File h = f.open(path, FILE_READ); size_t n = h ? h.size() : 0; if (h) h.close(); return n; + }; + size_t sd_meshcomod = fsize(SD_MMC, "/meshcomod/contacts3"); + size_t sd_root = fsize(SD_MMC, "/contacts3"); + size_t lf_contacts = fsize(LittleFS, "/contacts3"); + size_t sd_contacts = sd_meshcomod > sd_root ? sd_meshcomod : sd_root; + if (sd_has_id && sd_contacts > lf_contacts) { + printf("[storage] #167 v4: store SD -> LittleFS (contacts3 /meshcomod=%uB root=%uB, lfs had %uB)\n", + (unsigned)sd_meshcomod, (unsigned)sd_root, (unsigned)lf_contacts); + LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); + const char *sub = sd_meshcomod >= sd_root ? "/meshcomod" : ""; // live layout root on the card + char sp2[64]; + migrateFsDir(SD_MMC, "/meshcomod/identity", LittleFS, "/identity", true); + migrateFsDir(SD_MMC, "/meshcomod/bl", LittleFS, "/bl", true); + static const char *k_files[] = { "/contacts3", "/channels2", "/adv_blobs", "/new_prefs", + "/new_prefs.tmp", "/regions2", "/ui_chat_history_v1.bin" }; + for (const char *nm : k_files) { + snprintf(sp2, sizeof sp2, "%s%s", sub, nm); + File probe = SD_MMC.open(sp2, FILE_READ); bool have = (bool)probe; if (probe) probe.close(); + if (!have && sub[0]) { snprintf(sp2, sizeof sp2, "%s", nm); } + else if (!have) { snprintf(sp2, sizeof sp2, "/meshcomod%s", nm); } + migrateFsCopyFile(SD_MMC, sp2, LittleFS, nm, true); + } + printf("[storage] #167 v4: done -- lfs used %u / %u KB, contacts3 readback %uB\n", + (unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024), + (unsigned)fsize(LittleFS, "/contacts3")); + File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); + if (w) { w.write((const uint8_t*)"1", 1); w.close(); } + } else { + printf("[storage] #167 v4: no migration (sd_id=%d sd=%uB lfs=%uB) -- LittleFS is the store\n", + (int)sd_has_id, (unsigned)sd_contacts, (unsigned)lf_contacts); + File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); + if (w) { w.write((const uint8_t*)"1", 1); w.close(); } + } } +#if defined(TDP4_SD_STORE_TEST) + // #167 EXPERIMENT: store back on the SD card with all hot writes hopped to core 0 (the tile + // task's core, which has never flashed). One variable vs the known-flashing config: the + // executing core. Uses the card's existing (slightly stale) store; migration skipped. + printf("[storage] TDP4_SD_STORE_TEST: store on SD, writes on core 0\n"); store.useSdMmcStorage(); +#else + // Deliberately NOT calling store.useSdMmcStorage(): the internal store stays the DataStore root. +#endif } store.begin(); @@ -237,11 +321,67 @@ static void wadameshSetup() { // here — the P4 has RAM to spare, and GPS detection itself needs an intact first second. Serial1.setRxBufferSize(4096); sensors.begin(); - { // GPS detect visibility (the "gps" setting is only registered when NMEA was heard) - bool has_gps = false; + { // ---- GPS reality check ------------------------------------------------------------- + // The line that used to be here reported whether the "gps" SETTING got registered, which on + // this board is FORCED true by ENV_SKIP_GPS_DETECT (we set that because the P4's heavy boot + // misses the core's 1 s detect window, and a false negative hides the GPS toggle entirely). + // So it printed DETECTED unconditionally and proved nothing, including across several rounds + // of "GPS still isn't working". Read the UART directly instead and report what arrives. + bool has_setting = false; for (int i = 0; i < sensors.getNumSettings(); i++) - if (strcmp(sensors.getSettingName(i), "gps") == 0) has_gps = true; - printf("[GPS] L76K %s\n", has_gps ? "DETECTED (NMEA on GPIO22)" : "NOT detected (no NMEA in the 1s window)"); + if (strcmp(sensors.getSettingName(i), "gps") == 0) has_setting = true; + + // Drain a SHORT window of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and + // a stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead. + // 1.2 s, not longer: this runs on every boot and it CONSUMES bytes the NMEA parser would + // otherwise see, so it is deliberately just long enough to catch one of each sentence type + // (the L76K repeats the full set about once a second). + uint32_t bytes = 0, lines = 0; + char first[96]; first[0] = '\0'; + char cur[96]; size_t cl = 0; + // Keep one example of each distinct GSV/GGA/RMC talker so every constellation is represented + // without dumping the whole stream (the same 5 sentence types repeat every second). + char kept[10][96]; int kept_n = 0; + const uint32_t until = millis() + 1200; + while ((int32_t)(millis() - until) < 0) { + while (Serial1.available() > 0) { + const int c = Serial1.read(); + if (c < 0) break; + ++bytes; + if (c == '\n' || c == '\r') { + if (cl) { + cur[cl] = '\0'; + ++lines; + if (!first[0] && cur[0] == '$') { strncpy(first, cur, sizeof first - 1); first[sizeof first - 1] = '\0'; } + // Keep the first of each sentence TYPE (talker + type, e.g. "$GPGSV", "$GNGGA"). + if (cur[0] == '$' && cl >= 6 && kept_n < 10) { + bool seen = false; + for (int k = 0; k < kept_n; ++k) if (strncmp(kept[k], cur, 6) == 0) { seen = true; break; } + if (!seen) { strncpy(kept[kept_n], cur, sizeof kept[0] - 1); kept[kept_n][sizeof kept[0] - 1] = '\0'; ++kept_n; } + } + cl = 0; + } + } else if (cl < sizeof(cur) - 1) { + cur[cl++] = (char)c; + } + } + delay(5); + } + printf("[GPS] setting_registered=%d (forced by ENV_SKIP_GPS_DETECT, proves nothing)\n", (int)has_setting); + printf("[GPS] UART probe: %lu bytes, %lu lines in 1.2s @ %d baud, module TX -> GPIO%d\n", + (unsigned long)bytes, (unsigned long)lines, (int)GPS_BAUD_RATE, (int)PIN_GPS_TX); + if (first[0]) printf("[GPS] first sentence: %s\n", first); + else if (bytes) printf("[GPS] bytes but no complete '$' sentence -> wrong baud or line noise\n"); + else printf("[GPS] NOTHING on the UART -> check wake (XL9535 IO11 HIGH), rails, RX pin\n"); + // Satellite visibility across ALL constellations, plus the fix line. One GxGSV is not enough: + // GLGSV is GLONASS only, so "00" there says nothing about GPS/Galileo/BeiDou. GSV field 3 is + // sats-in-view for that constellation; GGA field 6 is fix quality (0 = no fix) and field 7 the + // sats used. Non-zero in-view with quality 0 means it IS hearing satellites and just needs + // time or better sky. All-zero in-view points at the antenna or shielding. + if (kept_n) { + printf("[GPS] --- satellite view (%d sentences) ---\n", kept_n); + for (int i = 0; i < kept_n; ++i) printf("[GPS] %s\n", kept[i]); + } } ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); board.onBootComplete(); @@ -384,6 +524,17 @@ extern "C" void app_main(void) { } serial_interface.tickWebSocketHandshake(); + // Service the sensor manager. This was MISSING on this board: sensors.begin() ran at boot and + // then nothing ever pumped it again, while every S3 board calls this each iteration (see + // src/main.cpp, which is excluded from this build — that is how the call got lost in the port). + // + // sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser never sees a + // byte, so isValid() stays false forever and the UI sits on "searching" no matter how good the + // sky is — while the module itself is perfectly happy and does acquire a fix (proven with a raw + // UART probe: a valid 3D fix at HDOP 2.2 that the firmware never knew about). It also starves + // everything else the manager provides: telemetry sensors and the GPS->RTC time sync. + sensors.loop(); + the_mesh.loop(); vTaskDelay(1); } diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 index 70d14bc..f8f3750 100644 --- a/tdisplay_p4/sdkconfigs/tdisplay_p4 +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -47,3 +47,53 @@ CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=64 + +# --- Whole-screen flicker (#167) ------------------------------------------------------------- +# Execute .text/.rodata from PSRAM instead of memory-mapped flash. +# +# This board's MIPI-DSI panel is a DPI/video one: its single ~1.4 MB framebuffer can only live in +# PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. PSRAM sits behind the same cache as +# flash, and any flash write has to DISABLE that cache while it runs -- so for the duration of the +# write the DMA cannot fetch the framebuffer, the panel underruns, and one frame comes out as a +# whole-screen colour flash. +# +# That is why the reported triggers are all storage writes and nothing to do with what is drawn: +# every received message (history write), opening a channel with unread messages (mark-read write), +# and deleting a contact -- which writes repeatedly and therefore flickers continuously until it +# finishes. It also explains why it appears on every screen and has been there since the port +# began, and why moving the flush scratch buffer out of PSRAM (7aaff80) did not help: that reduced +# bandwidth contention, but the cache is disabled outright here, so bandwidth was never the issue. +# +# With XIP enabled, code and constants are relocated into PSRAM at startup so the cache no longer +# has to be dropped for a flash write. Supported on this SoC (CONFIG_SOC_SPIRAM_XIP_SUPPORTED=y); +# IDF ships the same pairing as a P4 reference config +# (esp_mm/test_apps/mm/sdkconfig.ci.xip_psram_esp32p4). +CONFIG_SPIRAM_XIP_FROM_PSRAM=y + +# --- esp-hosted C6 reset: callback, NOT a GPIO ------------------------------------------------- +# These were only ever present in the GENERATED sdkconfig, never in this tracked fragment, so a +# regeneration silently dropped them and esp-hosted fell back to resetting the slave via GPIO 54. +# There is no such reset line on this board: the C6's enable/reset is C6_EN on the XL9535 expander +# (see Xl9535::powerOnSequence), which can only be driven through a callback. Currently inert +# because TDP4_C6_READY is 0 and the C6 runs factory ESP-AT via c6_at rather than esp-hosted, but +# pinned here so the config is reproducible from the fragments and a future C6 bring-up does not +# inherit a bogus GPIO reset. +CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y +CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y + +# --- #167 whole-screen blue flash: Espressif's documented DPI-underrun mitigations. ------------- +# The esp_lcd DSI driver's own ISR comment says an underrun turns the display blue, and the +# esp-iot-solution MIPI-DSI guide prescribes exactly these three for "can't fetch data from +# external memory fast enough": a 256 KB L2 cache with 128 B lines (fewer, larger PSRAM +# transactions -> more headroom for the continuous ~120 MB/s DSI fetch), and perf optimization. +# The shared 'wadamesh' fragment sets -Os for the Tanmatsu's AppFS size ceiling; this board is a +# plain 4 MB OTA slot with ~1.1 MB free, so it can afford -O2 (build.sh orders this fragment last +# so board overrides win). SPIRAM_SPEED_200M + XIP_FROM_PSRAM, the other two recommendations, +# were already set. +CONFIG_COMPILER_OPTIMIZATION_SIZE=n +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_CACHE_L2_CACHE_128KB=n +CONFIG_CACHE_L2_CACHE_256KB=y +CONFIG_CACHE_L2_CACHE_LINE_64B=n +CONFIG_CACHE_L2_CACHE_LINE_128B=y + diff --git a/test/test_companion_retry_policy.cpp b/test/test_companion_retry_policy.cpp new file mode 100644 index 0000000..96da551 --- /dev/null +++ b/test/test_companion_retry_policy.cpp @@ -0,0 +1,55 @@ +#include <assert.h> + +#include "helpers/CompanionRetryPolicy.h" + +int main() { + using namespace CompanionRetryPolicy; + + static_assert(DIRECT_MAX_ATTEMPTS == 21, "direct retry count changed"); + static_assert(FLOOD_MAX_ATTEMPTS == 15, "flood retry count changed"); + + assert(directDelay(100, 0) == 900); + assert(directDelay(100, 20) == 2900); + + assert(floodDelay(300, 100, 0) == 2300); + assert(floodDelay(300, 100, 100) == 2400); + assert(floodDelay(300, 100, 200) == 2500); + + assert(isDirectEcho(5, 4)); + assert(isDirectEcho(5, 0)); + assert(!isDirectEcho(5, 5)); + assert(!isDirectEcho(5, 6)); + + assert(isFloodEcho(0, 1)); + assert(isFloodEcho(2, 5)); + assert(!isFloodEcho(2, 2)); + assert(!isFloodEcho(2, 1)); + + const uint8_t empty_key[8] = {}; + const uint8_t retry_key[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + const uint8_t same_retry_key[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + const uint8_t prefix_collision[8] = {1, 2, 3, 4, 5, 6, 7, 9}; + assert(!keyIsSet(empty_key, sizeof(empty_key))); + assert(keyIsSet(retry_key, sizeof(retry_key))); + assert(keysEqual(retry_key, same_retry_key, sizeof(retry_key))); + assert(!keysEqual(retry_key, prefix_collision, sizeof(retry_key))); + + assert(shouldReplacePendingText(123, retry_key, same_retry_key, + sizeof(retry_key))); + assert(!shouldReplacePendingText(123, retry_key, prefix_collision, + sizeof(retry_key))); + assert(!shouldReplacePendingText(0, retry_key, same_retry_key, + sizeof(retry_key))); + + uint32_t expected_ack = 0x12345678UL; + uint8_t received_ack[4]; + memcpy(received_ack, &expected_ack, sizeof(received_ack)); + assert(ackMatches(expected_ack, received_ack)); + assert(!ackMatches(0, empty_key)); + + assert(wakeDelay(1000, 1500) == 500); + assert(wakeDelay(1500, 1500) == 0); + assert(wakeDelay(1600, 1500) == 0); + assert(wakeDelay(0xFFFFFFF0UL, 0x00000020UL) == 48); + return 0; +} diff --git a/variants/lilygo_tlora_pager/target.cpp b/variants/lilygo_tlora_pager/target.cpp index 99b2009..49e7d43 100644 --- a/variants/lilygo_tlora_pager/target.cpp +++ b/variants/lilygo_tlora_pager/target.cpp @@ -125,3 +125,10 @@ mesh::LocalIdentity radio_new_identity() { RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity } + +// Shared-SPI accessor for main.cpp's boot-time SD storage mount (#193): the micro-SD rides the +// SAME SPIClass the display/radio already own (see the bus-sharing note at the top of this file). +// UITask's runtime mount uses TFT_eSPI::getSPIinstance() directly; main.cpp cannot cheaply +// include TFT_eSPI, so it links this instead. +#include <TFT_eSPI.h> +SPIClass* tloraPagerSharedSPI() { return &TFT_eSPI::getSPIinstance(); } diff --git a/variants/tdisplay_p4/HI8561Display.cpp b/variants/tdisplay_p4/HI8561Display.cpp index ba6d390..6fbe2fc 100644 --- a/variants/tdisplay_p4/HI8561Display.cpp +++ b/variants/tdisplay_p4/HI8561Display.cpp @@ -185,11 +185,21 @@ void HI8561Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes // an HI_UI_SCALE x HI_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). const int S = HI_UI_SCALE, dw = w * S, dh = h * S; + // INTERNAL DMA RAM, not PSRAM — same reasoning as the AMOLED SKU (long note in + // RM69A10Display::writePixelsRGB565). This is a DPI/DSI panel whose single framebuffer must + // live in PSRAM and is streamed to the glass continuously, so keeping the upscale scratch in + // PSRAM too put three streams on one bus: the scratch read, the framebuffer write, and the + // DSI's own read. Any other PSRAM consumer — the priority-10 "lora_rx" drain task fires on + // every received packet — could then starve the DSI, and a DPI underrun shows as a + // whole-screen colour flash for one frame (#167). One band is 270x24 upscaled 2x = + // 540*48*2 = ~51 KB out of 768 KB SRAM. PSRAM stays the fallback. static uint16_t* s_up = nullptr; static size_t s_up_px = 0; size_t need = (size_t)dw * dh; if (need > s_up_px) { if (s_up) heap_caps_free(s_up); - s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); + s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + if (!s_up) s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); s_up_px = s_up ? need : 0; } if (s_up) { diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp index 4deece1..4cc8b35 100644 --- a/variants/tdisplay_p4/RM69A10Display.cpp +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -3,12 +3,17 @@ #include "RM69A10Display.h" #include <Arduino.h> #include "esp_heap_caps.h" +#include "esp_cache.h" // esp_cache_msync — flush the memset framebuffer out to the DSI DMA #include "esp_ldo_regulator.h" #include "Xl9535.h" // SCREEN_RST lives on the expander (reset_gpio_num = -1) #include "esp_lcd_mipi_dsi.h" #include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51) #include "esp_lcd_panel_ops.h" #include "esp_lcd_rm69a10.h" +#if defined(TDP4_POKE_TRACE) +#include "soc/mipi_dsi_host_struct.h" // MIPI_DSI_HOST — raw host error status (poke-trace poller) +#include "freertos/task.h" +#endif #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -19,6 +24,22 @@ // the frame buffer as horizontal black/garbage bands, worst during transitions (many rapid // flushes). This binary semaphore makes each flush synchronous: draw_bitmap → wait for done. static SemaphoreHandle_t s_flush_sem = nullptr; +#if defined(TDP4_POKE_TRACE) +// #167 hunt, round 3: the esp_lcd driver watches ONLY the bridge-FIFO underrun (proven silent +// during visible flashes). The DSI HOST has its own never-read error latches: int_st0 = the +// panel acking packets WITH ERRORS, int_st1 = ECC/CRC/timeout/payload-write errors INCLUDING +// dpi_buff_pld_under (the host payload buffer underflowing -- a different FIFO than the bridge). +// Both are read-clear, so a 25 ms poll gives per-event capture. If these latch when the screen +// flashes, we have a hardware flash-detector and can bisect subsystems without eyes on the panel. +static void dsiErrPollTask(void*) { + for (;;) { + const uint32_t st0 = MIPI_DSI_HOST.int_st0.val; // RC + const uint32_t st1 = MIPI_DSI_HOST.int_st1.val; // RC + if (st0 || st1) printf("[DSIERR] %lu st0=%08X st1=%08X\n", (unsigned long)millis(), (unsigned)st0, (unsigned)st1); + vTaskDelay(pdMS_TO_TICKS(25)); + } +} +#endif static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel_event_data_t*, void*) { BaseType_t hpw = pdFALSE; if (s_flush_sem) xSemaphoreGiveFromISR(s_flush_sem, &hpw); @@ -37,7 +58,19 @@ static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel #endif #define RM_DPI_MHZ 60 #define RM_LANES 2 -#define RM_BITRATE 1000 // Mbps per lane — TODO(device): confirm vs LilyGo RM69A10 define +// Lane bit rate. LilyGo/Meck ship 1000 Mbps (confirmed against both sources 2026-07-31), and the +// panel needs only ~550 Mbps of it at DPI 60 MHz / RGB565 / 2 lanes -- the rest is eye margin. +// #167 made that margin matter: SD-card write bursts load the P4's on-chip LDO4 and disturb the +// sibling LDO3 that powers the DSI PHY, and at a 1 ns unit interval that wobble was enough to +// glitch the link -- the panel loses a frame to a whole-screen blue flash (proven by elimination: +// no flash with the card out or unwritten; framebuffer/underrun/host-error/DCS all measured clean; +// identical timings+init to the flash-free Meck build). A 750 Mbps derate was tried against #167 and changed nothing +// (the disturbance reaches the panel around the link, not through it), so this stays at the +// vendor value. Overridable per build: -DRM_BITRATE_MBPS=<n>. +#ifndef RM_BITRATE_MBPS +#define RM_BITRATE_MBPS 1000 +#endif +#define RM_BITRATE RM_BITRATE_MBPS #define RM_HSYNC 50 #define RM_HBP 150 #define RM_HFP 50 @@ -109,6 +142,19 @@ bool RM69A10Display::begin() { esp_lcd_panel_reset(_panel); if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[RM69A10] panel_init fail"); return false; } + + // Blank the framebuffer BEFORE switching the panel on. It is freshly allocated PSRAM, so it holds + // garbage; turning the panel on first meant the DSI streamed that garbage to the screen until the + // clear below finished -- the colour flash testers see before the boot splash (#167). Write the + // buffer directly (memset + a cache flush to make it visible to the DSI's DMA) instead of pushing + // strips through draw_bitmap: it is one pass with the panel still dark, and it avoids a burst of + // 39 back-to-back DMA transfers into a buffer the panel is scanning out. + void* fb = nullptr; + if (esp_lcd_dpi_panel_get_frame_buffer(_panel, 1, &fb) == ESP_OK && fb) { + const size_t fb_bytes = (size_t)RM_W * RM_H * 2; + memset(fb, 0, fb_bytes); + esp_cache_msync(fb, fb_bytes, ESP_CACHE_MSYNC_FLAG_DIR_C2M); + } esp_lcd_panel_disp_on_off(_panel, true); // Brightness: the init array ships 0x51=0 (placeholder), and RM69A10 brightness is driven at @@ -125,35 +171,69 @@ bool RM69A10Display::begin() { cbs.on_color_trans_done = rm69a10TransDone; esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr); - // Clear the panel framebuffer to black once, so nothing stale shows before LVGL's first flush. - { - const int SH = 32; - uint16_t* strip = (uint16_t*)heap_caps_calloc((size_t)RM_W * SH, 2, MALLOC_CAP_SPIRAM); - if (strip) { - for (int y0 = 0; y0 < RM_H; y0 += SH) { - int hh = (y0 + SH <= RM_H) ? SH : (RM_H - y0); - esp_lcd_panel_draw_bitmap(_panel, 0, y0, RM_W, y0 + hh, strip); - if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // let the copy finish before reusing/freeing strip - } - free(strip); - } - } - + #if defined(TDP4_POKE_TRACE) + xTaskCreatePinnedToCore(dsiErrPollTask, "dsierr", 3072, nullptr, 3, nullptr, 1); +#endif Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H); return true; } void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) { if (!_panel || !pixels || w <= 0 || h <= 0) return; +#if defined(TDP4_FLUSH_TRACE) + // Opt-in flush tracer for whole-screen colour-flash reports (#167). OFF unless the build adds + // -DTDP4_FLUSH_TRACE, so it costs nothing normally. Logs any large, mostly-single-colour band + // with its RGB565 value: if the flash is LVGL painting something you see a burst carrying that + // colour (brand teal 0x15B6A6 -> 0x15B4); if the screen visibly flashes and nothing logs, the + // cause is below LVGL (panel/DSI) and hunting in the UI is wasted effort. Note when using it + // that the per-band uniformity scan slows the flush path, which can mask a timing-sensitive + // bug — always confirm a fix on a build WITHOUT this enabled. + { + const long px = (long)w * h; + if (px > 1500) { // one full LVGL band is 284*24 = 6816 px + const uint16_t c0 = pixels[0]; + long same = 0; + for (long i = 0; i < px; i += 37) { if (pixels[i] == c0) ++same; } // sparse uniformity probe + const long probes = (px + 36) / 37; + // Log the colour whenever a large band is mostly ONE colour. A whole-screen flash shows + // up as a burst of these in the same millisecond range, all carrying the flash colour + // (brand teal 0x15B6A6 -> rgb565 0x15B4). Rate-limited so a normal repaint storm can't + // flood the console and perturb what we are measuring. + static uint32_t s_last_log = 0; static int s_burst = 0; + const uint32_t now_ms = (uint32_t)millis(); + if (now_ms - s_last_log > 500) { s_burst = 0; s_last_log = now_ms; } + if (same * 10 >= probes * 9 && s_burst < 12) { + ++s_burst; + Serial.printf("[FLUSHTRACE] %lu x=%d y=%d w=%d h=%d rgb565=0x%04X\n", + (unsigned long)now_ms, x, y, w, h, (unsigned)c0); + } + } + } +#endif #if RM_UI_SCALE > 1 // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes // an RM_UI_SCALE x RM_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). const int S = RM_UI_SCALE, dw = w * S, dh = h * S; + // The scratch band lives in INTERNAL DMA RAM, not PSRAM. This panel is a DPI/DSI one with a + // single ~1.4 MB framebuffer that can only live in PSRAM, and the DSI DMA streams that + // framebuffer out to the glass CONTINUOUSLY. Every draw_bitmap below is a copy INTO that + // framebuffer, so with the scratch also in PSRAM a flush put three streams on the same bus at + // once: the scratch read, the framebuffer write, and the DSI's own read. Anything else that + // wants PSRAM at that moment — notably the priority-10 "lora_rx" drain task, which wakes on + // every received packet — can then starve the DSI read, and a DPI underrun shows up as a + // whole-screen colour flash for one frame. That is the P4 "flicker on every RX" report + // (issue #167): it appears on any screen, because it is a display-bus problem and nothing to + // do with what is being drawn. Internal RAM removes the scratch read from the PSRAM bus. + // Cost is bounded and small: one LVGL band is 284x24 (LV_DRAW_BUF_LINES), so upscaled 2x this + // is 568*48*2 = ~53 KB out of the P4's 768 KB SRAM. PSRAM stays as the fallback so a + // fragmented heap degrades to the old behaviour instead of dropping to the unscaled path. static uint16_t* s_up = nullptr; static size_t s_up_px = 0; size_t need = (size_t)dw * dh; if (need > s_up_px) { if (s_up) heap_caps_free(s_up); - s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); + s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + if (!s_up) s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); s_up_px = s_up ? need : 0; } if (s_up) { @@ -176,10 +256,20 @@ void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_ void RM69A10Display::setBrightness(uint8_t b) { if (!_dbi_io) return; +#if defined(TDP4_POKE_TRACE) + // #167 hunt: DCS 0x51 is a low-power command injected into the LIVE video stream -- if the + // panel glitches a frame per command, these lines will pace the visible flashes exactly. + printf("[BRI] %lu val=%u\n", (unsigned long)millis(), b); +#endif esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS } +#if defined(TDP4_POKE_TRACE) +void RM69A10Display::turnOn() { printf("[DSP] %lu on\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; } +void RM69A10Display::turnOff() { printf("[DSP] %lu off\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; } +#else void RM69A10Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; } void RM69A10Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; } +#endif #endif // HAS_TDISPLAY_P4 diff --git a/variants/tdisplay_p4/Xl9535.cpp b/variants/tdisplay_p4/Xl9535.cpp index b7bbc39..1c5a0cd 100644 --- a/variants/tdisplay_p4/Xl9535.cpp +++ b/variants/tdisplay_p4/Xl9535.cpp @@ -51,6 +51,11 @@ void Xl9535::write(uint8_t io, bool high) { uint8_t port, bit; split(io, port, bit); if (high) _out[port] |= (1 << bit); else _out[port] &= ~(1 << bit); +#if defined(TDP4_POKE_TRACE) + // #167 hunt: any expander write while the panel streams is a glitch suspect (this is an + // UNLOCKED shadow RMW -- a cross-task race can transiently drop SCREEN_RST or a rail bit). + printf("[XLW] %lu io=%u v=%d out=%02X%02X\n", (unsigned long)millis(), io, (int)high, _out[1], _out[0]); +#endif writeReg(REG_OUT0 + port, _out[port]); } @@ -99,7 +104,11 @@ void Xl9535::powerOnSequence() { // OFF and made every mount fail (0x107 OCR timeout / 0x109 CRC). Drive it LOW, always. write(IO_SD_EN, false); // SD slot power ON (active-low) write(IO_SX1262_RST, true); // park high; radio_init pulses it - write(IO_RF_SWITCH, false); // RX path + // Antenna select: park on the on-board antenna (RF1) before the radio exists, so the first + // transmit after any boot cannot key the PA into a possibly-empty external socket. LilyGo's own + // XL9535 init preloads this same level as its safe state. Choosing the external antenna is a + // deliberate, confirmed, session-only action from Settings -> Radio. + write(IO_RF_SWITCH, INTERNAL_LEVEL); write(IO_TOUCH_RST, true); // release touch (HI8561 driver probes it) // NB: SCREEN_RST is intentionally NOT touched here — RM69A10Display::begin() sequences it with // the DSI-PHY LDO (HIGH->LOW->HIGH, 200 ms each), exactly as the working Meck-P4 app_main does. diff --git a/variants/tdisplay_p4/Xl9535.h b/variants/tdisplay_p4/Xl9535.h index 2ee13c3..ddd0b06 100644 --- a/variants/tdisplay_p4/Xl9535.h +++ b/variants/tdisplay_p4/Xl9535.h @@ -53,11 +53,56 @@ public: // Convenience for the SX1262 glue (RESET + DIO1 live here). void sx1262Reset(); // active-low pulse on IO_SX1262_RST bool sx1262Dio1() { return read(IO_SX1262_DIO1); } - void rfSwitchTx(bool tx) { write(IO_RF_SWITCH, tx); } // polarity TBD on-device + // IO1 drives the board's SKY13453 SP2T. It is the LoRa ANTENNA select — on-board antenna vs the + // external socket — and NOT a TX/RX path switch. CONFIRMED against LilyGo's own sources (the + // vendor names this exact pin kSky13453Vctl on IO1): + // lilygo_device_driver src/device/t_display_p4/t_display_p4_config.h + // lilygobox-espidf main/hal/device/t_display_p4/t_display_p4_device.cpp + // In ActivateRadio() they set it once per radio bring-up from an AntennaType enum: + // const uint8_t antenna_level = config.antenna == radio::AntennaType::kExternal ? 0 : 1; + // ... GpioWrite(gpio::xl9535::kSky13453Vctl, antenna_level); + // and log it as kExternal ? "RF2" : "RF1". So: + // + // HIGH (1) = RF1 = INTERNAL / on-board antenna + // LOW (0) = RF2 = EXTERNAL socket + // + // LilyGo also preloads VCTL = 1 in their XL9535 init as the deliberate safe state, i.e. the + // vendor boots on the internal antenna too. Their driver NEVER toggles this line per transmit — + // it cannot be a TX/RX switch anyway, since such a switch must settle within microseconds of the + // PA ramping and an I2C expander write takes hundreds of microseconds on a bus shared with the + // touch panel, the RTC and the fuel gauge (the SX1262 has DIO2 for that job). + // + // This also explains the field report of -10 dB outbound against +12 dB inbound exactly: our old + // per-TX toggle idled LOW to receive (= EXTERNAL, where the user's antenna is fitted, hence the + // healthy +12 dB in) and drove HIGH to transmit (= the tiny on-board antenna, hence ~22 dB down + // at the repeater). It was sending on the internal antenna and listening on the external one. + static constexpr bool INTERNAL_LEVEL = true; + + enum AntMode : uint8_t { + ANT_INTERNAL = 0, // RF1, on-board antenna — always attached, the safe default, forced every boot + ANT_EXTERNAL = 1, // RF2, external socket — needs an antenna fitted; session-only, never persisted + ANT_AUTO = 2, // legacy per-TX toggle: transmits internal, receives external. Diagnostic only. + }; + + void setAntennaMode(uint8_t mode) { + _ant_mode = (mode > ANT_AUTO) ? ANT_INTERNAL : mode; + if (_ant_mode == ANT_EXTERNAL) write(IO_RF_SWITCH, !INTERNAL_LEVEL); + else write(IO_RF_SWITCH, INTERNAL_LEVEL); // internal, and auto's idle state + } + uint8_t antennaMode() const { return _ant_mode; } + void rfSwitchTx(bool tx) { + if (_ant_mode != ANT_AUTO) return; // pinned — the antenna must NOT differ between TX and RX + // Legacy behaviour, preserved verbatim for comparison: HIGH on transmit, LOW at rest. Now that + // the mapping is known that reads as "send on the on-board antenna, listen on the external one", + // which is the bug, not a feature. Not a PA hazard (transmit lands on the attached on-board + // antenna) but it guarantees a weak outbound signal whenever an external antenna is fitted. + write(IO_RF_SWITCH, tx); + } private: uint8_t _addr = 0x20; bool _ok = false; + uint8_t _ant_mode = 0; // 0 = auto/legacy per-TX toggle, 1 = pinned LOW, 2 = pinned HIGH uint8_t _out[2] = {0xFF, 0xFF}; // shadow of output regs (port0, port1) uint8_t _cfg[2] = {0xFF, 0xFF}; // shadow of config regs (1=input) void writeReg(uint8_t reg, uint8_t val);