mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-15 03:59:55 +00:00
Merge branch 'main' into pisti87-hungarian-lang
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
]
|
||||
|
||||
+19
-8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
+120
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+646
-19
@@ -13,6 +13,7 @@
|
||||
#endif
|
||||
#endif
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include "helpers/CompanionRetryPolicy.h"
|
||||
#include <helpers/HttpOtaDisplayState.h>
|
||||
#include <helpers/RepeaterTcpOtaEmit.h>
|
||||
#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<uint8_t>(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<const uint8_t*>(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<uint8_t>(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<ContactInfo&>(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<ContactInfo&>(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;
|
||||
}
|
||||
|
||||
+110
-16
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
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<int32_t>(deadline_millis - now_millis);
|
||||
return signed_delay > 0 ? static_cast<uint32_t>(signed_delay) : 0;
|
||||
}
|
||||
|
||||
} // namespace CompanionRetryPolicy
|
||||
@@ -4,6 +4,12 @@
|
||||
#include <FS.h>
|
||||
#include <SD.h>
|
||||
#include <SPIFFS.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
#include <new>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// ----------------------------- 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<SdNvsPrefs::Kv> 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<SdNvsPrefs::Kv> kv;
|
||||
uint32_t generation = 0;
|
||||
uint32_t revision = 0;
|
||||
char slot = 'a';
|
||||
};
|
||||
|
||||
static std::vector<FileCache> 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<const uint8_t*>(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<SdNvsPrefs::Kv>& kv, std::vector<uint8_t>& 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<SdNvsPrefs::Kv>& 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<SdNvsPrefs::Kv>& 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<uint8_t> 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<SdNvsPrefs::Kv>& 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<uint8_t> 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<SdNvsPrefs::Kv>& 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<uint8_t> 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<SdNvsPrefs::Kv> 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<uint8_t> 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<uint8_t> 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<Kv> 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<Kv> 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<uint8_t> 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<SdNvsPrefs::Kv>& 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<SdNvsPrefs::Kv> 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> 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> 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 "<dir>/<ns>.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<Kv> 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
|
||||
|
||||
@@ -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 <dir>/<ns>.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/<ns>.kv on SD — the previous behaviour.
|
||||
// storage decision): every namespace is held in RAM and committed to alternating
|
||||
// <dir>/<ns>.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 <dir>/<ns>.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 <dir>/<ns>.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<uint8_t> 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<uint8_t> val; };
|
||||
std::vector<Kv> _sd; // in-RAM mirror of <dir>/<ns>.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);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <SPIFFS.h>
|
||||
#include <stddef.h> // offsetof
|
||||
#include <string.h> // memcpy
|
||||
#include <vector> // 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<uint8_t> v; };
|
||||
std::vector<Rec> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,13 +27,62 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
#if defined(ESP32)
|
||||
#include <Arduino.h> // disableCore0WDT / enableCore0WDT
|
||||
#include <esp_task_wdt.h>
|
||||
#include <atomic>
|
||||
|
||||
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<int>& _wdtHeavyDepth() { static std::atomic<int> 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<int>& 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(); } };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+34
-5
@@ -48,7 +48,7 @@ static uint32_t _atoi(const char* sp) {
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER)
|
||||
#include <SD.h>
|
||||
#include <Preferences.h>
|
||||
#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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <lvgl.h>
|
||||
|
||||
// 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 "< <title>"; 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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
+48
-36
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3042
-459
File diff suppressed because it is too large
Load Diff
+58
-10
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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)")
|
||||
|
||||
+180
-29
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user