Compare commits

...
38 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 a68ea66805 touch: beta_56 — duty-cycle preset fix, Pager SD storage, issue-sweep batch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 12:28:58 +02:00
Kaj SchittecatandClaude Opus 4.8 bcc084820c touch: radio presets set a REAL duty limit — airtime factor was 10x off (#161)
MeshCore's airtime_factor is the Dispatcher's TX pacing multiplier: duty =
1/(1+af), so a 10% duty limit needs af = 9. The presets wrote af = pct/100
(0.10), which configured every preset user's radio to ~91% duty -- the Home
meter's 'always 91%' was truthfully reporting the misconfiguration, on every
region preset including UK/EU.

- applyMeshRadioPresetFields converts duty% -> factor correctly (clamped to
  the core's 0..9).
- findMatchingMeshRadioPreset accepts both the correct and the old buggy value
  so previously-configured devices still show their preset in the dropdown.
- healPresetAirtimeFactor (run once at UI begin) rewrites the factor for
  devices whose params exactly match a preset and whose stored factor matches
  the OLD buggy write -- the exact-match requirement is the guard against
  touching deliberate user-set values.

NOTE for the web client: Meshcomod-client/shared/radio_presets.ts documents
the same pct/100 convention and needs the matching fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 12:23:18 +02:00
Kaj SchittecatandClaude Opus 4.8 a2548c39ab touch: T-Pager gets the full store-on-SD machinery (#193)
The pager's SD support shipped in pieces (mount ladder with XL9555 card-detect,
tile-cache fallback, shared TFT_eSPI SPIClass) but CAP_SD/CAP_FILESYSTEM were
never flipped from the pre-wiring 0, so the Files app gates and the whole
'Store data on SD' machinery stayed compiled out -- an inserted, mounted,
browsable card that nothing would store to. Flipped the caps, extended the
boot storage selection + SPIFFS->SD migration + SdNvsPrefs routing gates to
TLORA_PAGER, and exported tloraPagerSharedSPI() from the variant so main.cpp
can mount the card at boot on the shared bus like the T-Deck.

Both pager envs + T-Deck + V4 + R8 build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 12:19:46 +02:00
Kaj SchittecatandClaude Opus 4.8 badad25305 touch: issue batch — unlock branding (#165), Discover row overflow (#191), scope in details (#157)
- #165: the animated post-unlock overlay still said MESHCOMOD; now WADAMESH.
- #191: Discover app rows cap the name and use single-letter node types so the
  trailing last-heard age always fits the line.
- #157: the Info popup only read the transport scope for FLOOD messages, so a
  region-scoped DIRECT message showed no scope; and senders that carry the
  region in transport_codes[1] with codes[0]=0 showed Scope 0000. The scope is
  now read for any RX and falls back to codes[1] when codes[0] is zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 12:13:08 +02:00
Kaj SchittecatandClaude Opus 4.8 f0fc356a05 touch: beta_55 — prefs crash-safety + Pager UI sizes (PixPMusic), SD clock renegotiation, R8 buzzer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:34 +02:00
Kaj SchittecatandClaude Opus 4.8 51617b404e touch: SD mount ladders renegotiate the clock upward after a slow-rung init (#194)
A card whose power-up handshake only completed at 400 kHz kept 400 kHz as its
SESSION clock — SD.begin's frequency is the operating clock, and neither the
boot ladder (main.cpp) nor the runtime ladder (fmSdTryMount) ever raised it
after a slow success. With the SD as the T-Deck's primary store that meant
loading contacts + history at ~25 KB/s: the reported ~3-minute boot, f_getfree
timeouts making an inserted card show as 0 KB/empty in the file manager, and
backups wedging. Launcher installs hit it most (the Launcher's own SD use
leaves the card needing the slow wake path more often).

Standard SD bring-up is init-slow-then-raise: after any sub-4MHz success both
ladders now re-begin at 4 MHz, falling back to the clock that just worked if
the fast re-begin fails. A healthy card is unaffected (mounts at 4 MHz on the
first rung). T-Deck + V4-R8 + M9 (the shared-SPI SD boards) build.

Likely also behind the long-standing Launcher-mode weirdness cluster together
with the torn-prefs fix (#195/#196): slow-clock sessions made every write
window enormous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 11:36:24 +02:00
Kaj Schittecat 67b863d93e Merge branch 'main' of https://github.com/ALLFATHER-BV/wadamesh 2026-08-01 11:29:52 +02:00
Kaj 44a4a354b0 Merge pull request #197 from PixPMusic/pixpmusic/pager-ui-size
feat(touch): add T-Pager UI size presets
2026-08-01 11:29:50 +02:00
Kaj Schittecat a3b535237e Merge branch 'main' of https://github.com/ALLFATHER-BV/wadamesh 2026-08-01 11:18:54 +02:00
Kaj 7a7b70c93f Merge pull request #196 from PixPMusic/pixpmusic/atomic-touch-prefs
fix(esp32): persist preferences safely off the UI task
2026-08-01 11:18:52 +02:00
Pixel Perfect 05f4ba6542 feat(touch): add T-Pager UI size presets
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-08-01 02:14:24 -07:00
Kaj SchittecatandClaude Opus 4.8 c0d702a8a7 touch: control-centre GPS tile long-press opens the GPS page, not Radio
Stale CAT_RADIO from before the settings reorg gave GPS its own category —
every other tile already pointed at its own page. All boards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 10:15:26 +02:00
Kaj SchittecatandClaude Opus 4.8 cdd2f21cbb touch: V4-R8 buzzer — wire the Expansion Kit V2's PAM8904 DIN (GPIO4)
The R8 env shipped HELTEC_V4_BUZZER_PIN=-1 because the Kit V2's PAM8904 wiring
was unconfirmed, so R8 users have never heard a notification. Heltec's
Expansion_board_V2.03.pdf schematic settles it: DIN = GPIO4, and EN1/EN2 are
hardware-strapped high (10K pull-ups, 3x boost) with DIP switch SW1 as a
hardware mute -- no enable code needed, the stock V4 tone() chime drives DIN
directly. GPIO4 is unclaimed on the R8 (board I2C moved to 17/18).

Tester-verify: chime on message. If still silent, SW1 on the expansion board
is the hardware mute -- check it is not switched to ground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 09:59:03 +02:00
Pixel Perfect 2001ef72dd refactor(esp32): persist preferences off the UI task
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-07-31 18:08:27 -07:00
Pixel Perfect 540084a718 fix(touch): stop persisting unused tab state 2026-07-31 17:10:55 -07:00
Pixel Perfect 1c761e7846 fix(esp32): make file-backed preferences crash-safe
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-07-31 09:57:44 -07:00
Kaj SchittecatandClaude Opus 4.8 d52fcc47a5 touch: beta_54 — P4 screen-flash + GPS fixes, Romanian (jcyrio), V4-R8 landscape rescue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 17:45:18 +02:00
Kaj SchittecatandClaude Opus 4.8 40c4f205d1 touch: V4-R8 landscape touch was point-mirrored; boot forces portrait as rescue
A tester who switched the V4-R8 to landscape got stuck: every touch registered
in the wrong place, so the setting could not be reached to switch back.

Cause: the shared cap-touch landscape transform (applyPointRotation +
rotateSwipeDelta in HeltecV4CapTouch.cpp) was tuned on the V4's ST7789 driver,
whose PORTRAIT baseline is panel rotation 2 -- a +180 offset baked into the
landscape maps ("+180 baseline" in the comments). The R8's LovyanGFX panel runs
baseline 0 (offset_rotation = 0, portrait = setRotation(0)), so
applyHardwarePanelRotation lands the R8 panel 180 degrees from where the V4
lands for the same requested rotation, and the shared maps point-mirror every
landscape touch.

Fixes:
- HeltecV4CapTouch.cpp: R8-gated landscape maps without the +180 baseline (the
  V4's two landscape cases swapped) for both the touch-point transform and the
  swipe-delta transform. Marked TESTER-VERIFY: geometry is confirmed, sign is
  not -- if landscape is still mirrored on-device, cases 1 and 3 swap back.
- UITask.cpp: the V4-R8 forces PORTRAIT at every boot (and persists it), so the
  stuck reporter recovers on their next reboot and a wrong sign guess in the
  new maps costs a reboot instead of a stuck device.

V4 and T-Deck behavior unchanged (verified: both build; the new maps are
HELTEC_LORA_V4_R8-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 17:44:11 +02:00
Kaj Schittecat d285e85d72 Merge branch 'main' of https://github.com/ALLFATHER-BV/wadamesh 2026-07-31 17:31:50 +02:00
Kaj 72231fd5aa Merge pull request #189 from jcyrio/romanian-keyboard
Add Romanian keyboard
2026-07-31 17:31:47 +02:00
Kaj 3c5a6aa658 Merge pull request #188 from jcyrio/romanian-translation
Add Romanian (Română) UI translation
2026-07-31 17:31:44 +02:00
Kaj SchittecatandClaude Opus 4.8 83887976f0 p4: fix the storage dispatcher (it never engaged) + the measured #167 mechanism
The p4StorageCall dispatcher committed in 099ccd8 had an inverted guard: it
skipped marshalling when already on core 0 -- written believing the main task
ran on core 1, but ESP_MAIN_TASK_AFFINITY_CPU0 puts the UI+mesh main task on
core 0, so every "hopped" write actually ran inline on the main task (proven by
a sector-level SDMMC trace: all card I/O on task 'main'). Fixed: the guard is
task-identity (p4OnStorageTask), all 11 write wrappers use it, and the storage
task runs at priority 3 on core 0 -- core 1 was tried and is pathological for
SD I/O (the SDMMC completion ISR lives on core 0; cross-core wakeups made a
contact save take ~20 s).

With the dispatcher REALLY engaging, the mechanism of the SD-store flash was
finally measured instead of theorized: a gap tracker in the DSI frame-restart
ISR recorded 35-39 ms inter-frame gaps (vs the 20 ms cadence) exactly
interleaved with SD sector transactions. The panel's frame DMA stops after
every frame and waits for an ISR to re-arm it; anything that masks interrupts
long enough (SD-transaction critical sections -- and on RISC-V a critical
section masks ALL levels, which is why an ESP_INTR_FLAG_LEVEL3 experiment
changed nothing) makes the restart late, and the panel displays the extended
blanking as the whole-screen flash. No error register ever latches because
nothing fails; frames are merely late. This also finally explains the tile
asymmetry: a tile is one large sequential transfer (one interrupt burst per
file), a FATFS store save is dozens of small transactions and metadata
round-trips -- dozens of chances per save to push a frame restart late.

Ship state stays: hot store on internal LittleFS (no SD transactions on the
hot path -- no late frames), SD for tiles/bulk. The promising future fix for
restoring the T-Deck-style SD store is making the panel autonomous: a CIRCULAR
single-FB DMA descriptor (dw_gdma_lli_set_next(item, item), is_last=false) so
the hardware loops frames with no ISR in the path at all -- prototyped against
the local IDF's esp_lcd_panel_dpi.c and reverted with the other diagnostics
(sdmmc sector trace, DSILATE gap tracker, LEVEL3); it needs its own verified
session and a durable patch mechanism before it can ship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 17:00:27 +02:00
Kaj SchittecatandClaude Opus 4.8 099ccd8e3c p4: core-0 storage dispatcher + the completed #167 experiment matrix
Follow-up to 3bf56cd, driven by the (correct) challenge that "SD writes upset
the panel electrically" could not be the whole story when tile writes never
flash and other P4 firmwares use their cards freely. The completed on-device
matrix now separates two distinct mechanisms:

  M1  An SD write EXECUTING on core 1 (where display.begin() allocated the DSI
      frame-restart ISR, and where the Arduino loop task runs) masks that
      core's interrupts inside the SDMMC/FATFS critical sections long enough
      to drop a display frame. Proven: 80 contact saves to SD from core 1
      flash every time; the identical 80 saves hopped to core 0 never flash.
      NOT electrical -- the challenge was right.

  M2  An SD write CONCURRENT with live UI rendering still flashes even from
      core 0 (manual add/delete flows pump LVGL while the hopped write runs;
      the write-open trace shows those runs performed only correct core-0
      writes and flashed anyway). The same manual flow with the card mounted
      but ZERO SD writes (internal LittleFS store) is clean.

Shipped design therefore stays: hot store on internal LittleFS (immune to both
mechanisms, verified across every flow), SD for tiles/bulk. Tiles' long-standing
immunity is consistent: brief core-0 writes vs the store's long ones.

New in this commit, kept for good:
- p4StorageCall (DataStore.cpp): a core-0 storage task; every hot write entry
  (saveContacts/saveChannels/savePrefs/blobs, history segs, threads file) hops
  onto it, blocking the caller -- semantics unchanged, execution core moved.
  With the internal store this is still wanted hygiene: no storage write ever
  runs on the render core again.
- WADA_SD_STORE_TEST build gate: puts the store back on the SD card for future
  M2 investigation (known to still flash on manual flows; do not ship).
- TDP4_POKE_TRACE now also logs every write-open with its issuing core ([SDW]),
  which is what pinned M2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 15:37:05 +02:00
Kaj SchittecatandClaude Opus 4.8 3bf56cdd37 p4: #167 resolved -- SD writes flash the AMOLED; hot store moved to internal LittleFS
Root cause, established by on-device elimination over one long session: SD-card
WRITE bursts electrically disturb the T-Display P4's AMOLED -- the whole-screen
blue flash of #167. The display stack itself measured clean at every layer while
flashes were visible: framebuffer content (flush tracer: every band black), DSI
bridge underrun interrupt (never fired), DSI host protocol errors incl. the host
payload-buffer underflow (never latched), DCS/brightness traffic and XL9535
expander writes (silent), disp on/off (never). The flash survived every digital
derate -- 4->1-bit SD bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane rate --
on every card tried, at any brightness (including near-zero, i.e. the panel is
being upset at the hardware level, not showing content), yet vanished the moment
the card was removed or left unwritten. The card's rail is switched off the same
3V3 domain as the panel (XL9535 SD_EN), so its program-current spikes reach the
panel and no firmware setting can filter them. Streaming tile writes never
flashed; rapid open-write-close store bursts always did. Meck-P4 not flashing
proves nothing about the board: it never writes the SD.

Fix: the hot store (contacts/channels/identity/blobs/prefs-file/chat history)
lives on the INTERNAL 'storage' partition; the SD keeps bulk, gentle writers
(map tiles, backups). The internal partition is mounted as LittleFS, not FAT:
the P4's FAT layer has documented broken metadata and the v3 migration proved
its writes are no better (a 102 KB contacts3 "copied" onto it read back as 0 B;
on LittleFS the same copy reads back byte-exact and loads all 689 contacts).
esp_littlefs looks partitions up by label with SUBTYPE_ANY, so the ex-FAT
partition converts in place on first boot (formatOnFail) -- no partition-table
change, OTA-safe.

One-time v4 migration (lessons v1-v3 encoded): substance-gated (only from a
card whose contacts3 beats the internal one -- a fresh test card once donated a
blank store), whitelist-only copy (v2 filled the partition with junk before
contacts3 landed), both historical card layouts probed (root vs /meshcomod),
marker written only after the copy, card never written (it remains the
rollback).

Also in this change, kept as opt-in diagnostics (dormant without their env
gates): WADA_P4_POKE_TRACE (DCS/expander/disp-on-off tracing + a DSI host
error-register poller), WADA_SD_KHZ / WADA_SD_1BIT (SD primary-rung derates),
WADA_DSI_MBPS (lane-rate override; default back at the vendor 1000 -- the 750
experiment changed nothing and is documented in the driver).

Verified on device: no flash on the contact-churn trigger with the store
internal (the previously 100%-reproducing trigger), 689/689 contacts after
migration, tiles still on SD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 14:50:37 +02:00
Kaj SchittecatandClaude Opus 4.8 c99997ea9a p4: Espressif's documented DPI-underrun fix stack (#167)
Root-cause work, not another guess. The esp_lcd DSI driver's own ISR says it:

  // when an underrun happens, the LCD display may already becomes blue
  ESP_DRAM_LOGE(TAG, "can't fetch data from external memory fast enough, ...")

A DSI bridge FIFO underrun displays as exactly one blue frame -- the reported
whole-screen split-second blue flash -- and the driver logs an error line every
time it happens. Every serial capture until now grepped for tile/storage/trace
patterns that this line does not match, so it was never looked for.

The esp-iot-solution MIPI-DSI guide prescribes, for this exact error: L2 cache
256 KB, 128 B cache lines, and perf optimization (plus SPIRAM 200M + XIP from
PSRAM, already set). Applied all three for this board only:

- build.sh: sdkconfig fragment order general;wadamesh;tdisplay_p4 so the BOARD
  fragment wins over the shared ones (it was last-loser before; the shared
  wadamesh fragment's -Os, needed for the Tanmatsu AppFS ceiling, was
  unoverridable). No net config change from the reorder itself: the board
  fragment previously duplicated no shared keys.
- sdkconfigs/tdisplay_p4: COMPILER_OPTIMIZATION_PERF, CACHE_L2_CACHE_256KB,
  CACHE_L2_CACHE_LINE_128B. App 2.99 -> 3.35 MB of the 4 MB slot.
- main/CMakeLists.txt: -O2's deeper analysis trips new warnings in vendored
  components (esp-modbus etc.); same keep-visible-never-fatal policy as the
  existing strips.

Verification harness (scratchpad, not shipped): the companion protocol driven
over the USB console (contact add/remove = the 100% reported trigger, pure
local DB ops, no LoRa TX) while the same session captures every console line.
Since the driver logs each underrun unconditionally, one watched run now gives
a decisive verdict whichever way it goes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 12:32:45 +02:00
Kaj SchittecatandClaude Opus 4.8 c1d166e5e7 Revert: p4 prefs-on-SD, wider LVGL bands, XIP-off (#167)
None of the three fixed the flash, and each carried a cost:

- prefs-on-SD (1bc0c7a) also moved main.cpp onto a different set of headers than
  it had been compiling against. SdNvsPrefs.h's own comment warns that exact
  mismatch previously caused a free()-of-garbage bootloop, and a map tile
  regression showed up on these builds. Not worth the risk for no benefit.
- LVGL bands 24 -> 64 (1e6e121): a full-screen repaint went from ~26 transfers
  to ~10 with no change on device, so burst count is not the mechanism.
- XIP-off (0695ca6): no change on device, so PSRAM bandwidth starving the DSI is
  not the mechanism either.

Back to the configuration where map tiles were confirmed working. Kept the two
changes that stand on their own: the framebuffer is blanked before the panel is
switched on (it was streaming uninitialised PSRAM to the screen), and the
WADA_P4_FLUSH_TRACE build gate.

Standing evidence for whoever picks this up: the tracer shows every large band
LVGL pushes is 0x0000 black, including through the boot window where the flash
occurs, so the colour is never in the framebuffer and the UI layer is excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 12:02:57 +02:00
Kaj SchittecatandClaude Opus 4.8 0695ca6596 p4: flush-trace build gate + take code/rodata off the PSRAM bus (#167)
Ran the flush tracer instead of theorising further. Result is decisive: every
large uniform band LVGL pushes to the panel is rgb565=0x0000 (black), across the
whole boot window where the flash occurs. LVGL never paints the flash colour, so
the panel is displaying something that was never written to the framebuffer.
That excludes the entire UI layer, and with it the redraw/repaint theories.

Adds WADA_P4_FLUSH_TRACE=1 as a build gate so the tracer can be run on demand
without shipping it (it slows the flush path, so never confirm a fix with it on).

With the UI excluded the remaining candidate is the DSI starving. The panel
streams ~84 MB/s out of PSRAM continuously, and XIP put every instruction fetch
and rodata read on that same bus. The triggers are all bursts of code and data
churn (rebuilding the contact/chat list); panning the map reuses a warm tile
cache and never flashes. Turning XIP off leaves the PSRAM bus essentially to the
display. Experiment, not a claimed fix.

Also supersedes the burst-count theory from 1e6e121: widening LVGL bands from 24
to 64 lines (26 transfers -> 10 for a full repaint) changed nothing on device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 11:51:06 +02:00
Kaj SchittecatandClaude Opus 4.8 1e6e121c85 p4: blank the framebuffer before display-on; widen LVGL bands (#167)
Two changes against the whole-screen colour flash.

1. Real fix for the flash testers see BEFORE the boot splash. The panel was
   switched on and only then was the framebuffer cleared, so the DSI streamed a
   freshly-allocated (garbage) PSRAM buffer to the screen until the clear caught
   up. Blank it with a memset + cache flush while the panel is still dark. That
   also removes 39 back-to-back draw_bitmap transfers into a buffer the panel is
   scanning out.

2. Diagnostic, P4 only: LVGL bands 24 -> 64 lines. This panel is DSI video mode
   with a SINGLE framebuffer, so every band is copied into the buffer being
   actively scanned. At 24 lines a full-screen repaint is ~26 back-to-back
   transfers into that live buffer. The reported triggers (message arrives,
   opening a chat with unread, adding/deleting a contact) all rebuild the whole
   screen; panning the map repaints only its own region and never flashes, which
   is the shape this predicts. Wider bands cut the same repaint to ~10
   transfers. Sized so the 2x upscale scratch still fits internal SRAM, so burst
   count is the only variable that moves.

Supersedes the write-path theory: the flash still occurs with contacts, history
and prefs all on SD, and the pre-splash case involves no write at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 11:40:40 +02:00
Kaj SchittecatandClaude Opus 4.8 1bc0c7ab39 p4: route prefs off internal-flash NVS onto the SD card (#167)
The T-Display P4 never called SdNvsPrefs::useFile, so although contacts and chat
history correctly moved to the SD card, every settings/thread-state write still
landed in internal-flash NVS. On this board that is not just wear: the DSI panel
streams its framebuffer continuously out of PSRAM, and an internal-flash write
visibly disturbs it -- the whole-screen flash reported as #167. The trigger list
matches exactly: a message arriving, opening a chat with unread messages, and
deleting a contact (which writes repeatedly, hence flickering for the whole
operation). Map panning writes tiles to SD continuously and never flickers,
which is what pinned it down.

Only when a card is mounted. With no card there is nowhere better to put prefs,
and moving them from NVS to the internal FAT partition would trade one flash
write for another while needlessly relocating existing users' settings. Old NVS
values still load and migrate on their next save, so this is transparent.

Fixing it also required an include-path fix. The vendored core component
publishes core/src publicly and still carries OLD copies of the headers whose
.cpp files it deliberately excludes because our src/ ships them. main.cpp's
angle includes therefore resolved to the stale core headers while the linker
bound the new objects, so anything added since the core tag looked like it did
not exist (SdNvsPrefs::useFile, touchPrefsReload). Putting the repo's src/ ahead
of the core dir in main's INCLUDE_DIRS makes main.cpp and the app layer agree.

Verified on device: "[PREFS] backend = FILE /meshcomod".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 11:28:57 +02:00
Kaj SchittecatandClaude Opus 4.8 f5fee0c986 touch: P4 whole-screen flicker (#167) — run code from PSRAM so flash writes stop starving the DSI
Three independent 100%-reproducible triggers finally pinned this down, and they
all turn out to be the same thing: a write to flash.

  - every received message      -> chat-history write
  - opening a channel that has unread messages -> mark-read write
    ("if you open them back up and there aren't unread messages, there's no flash")
  - deleting a contact          -> the chunked bulk delete writes repeatedly, and
    it flickers CONTINUOUSLY for the whole operation

Mechanism. This panel is a MIPI-DSI 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 a flash write has to DISABLE that cache
while it runs. For the duration of the write the DMA cannot fetch the framebuffer,
the panel underruns, and a frame comes out as a whole-screen colour flash. Nothing
to do with what is being drawn, which is why it appeared on every screen, on every
board revision, and from the very start of the port.

Fix: CONFIG_SPIRAM_XIP_FROM_PSRAM=y. Code and constants are relocated into PSRAM
at startup so the cache no longer has to be dropped for a flash write. The SoC
supports it (CONFIG_SOC_SPIRAM_XIP_SUPPORTED=y) and IDF ships the same pairing as
a P4 reference config (esp_mm/test_apps/mm/sdkconfig.ci.xip_psram_esp32p4). It
pulls in SPIRAM_FETCH_INSTRUCTIONS / SPIRAM_RODATA / SPIRAM_FLASH_LOAD_TO_PSRAM.

This also explains why 7aaff80 (moving the flush scratch out of PSRAM) did not
help: that reduced PSRAM BANDWIDTH contention, but the cache is disabled outright
during a flash write, so bandwidth was never the constraint. That commit is still
worth keeping on its own merits, but it was not the cause.

Also restores CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK and its SDIO twin to
this fragment. They existed only in the GENERATED sdkconfig, so regenerating it
for the XIP change silently dropped them and esp-hosted fell back to resetting the
slave over GPIO 54 — a line this board does not have (C6_EN is on the XL9535, so
it can only be driven by callback). Inert today because TDP4_C6_READY is 0, but
the config is now reproducible from its fragments instead of depending on an
untracked file nobody could regenerate.

Boots clean with XIP enabled: display up, GPS probe reading, only rst:0x1
(POWERON). Needs field confirmation against the contact-delete trigger, which is
the reliable one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 11:06:59 +02:00
Kaj SchittecatandClaude Opus 4.8 e3c0d98967 touch: P4 never serviced the sensor manager, so GPS could never fix
Root cause of "GPS on the P4 stays on searching forever", reported repeatedly and
chased through pins, baud, wake polarity, antennas and a comparison against the
Meck-P4 port. None of those were wrong. The loop simply never called
sensors.loop().

Every S3 board calls it once per iteration (src/main.cpp: STALL_SCOPE("sensors",
sensors.loop())). That file is excluded from this build and the P4 has its own
main loop, which is where the call went missing during the port: sensors.begin()
ran at boot and nothing ever pumped the manager again.

sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser
never receives a byte, so isValid() is false forever and the UI shows "searching"
regardless of signal. It also starves the telemetry sensors and the GPS->RTC time
sync that hang off the same manager.

The evidence lines up exactly:
  - a T-Deck with the same L76K fixes almost instantly, the P4 never does;
  - 15 minutes under clear sky changed nothing, because no amount of signal helps
    a parser that is never run;
  - and a raw UART probe showed the MODULE reaching a valid 3D fix
    ($GNGGA,...,1,05,2.2 at HDOP 2.2, plus $GPTXT ANTENNA OK) that the firmware
    had no idea about. That reading came from the probe, not the provider, which
    is precisely why it looked like the GPS "worked" while the device disagreed.

Builds clean and boots clean on the P4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:52:01 +02:00
Kaj SchittecatandClaude Opus 4.8 1001ff7ce9 touch: GPS settings page never refreshed while open
Reported: the GPS page only updated when you closed and reopened it.

refreshSettingsSectionSubtitles() gated the GPS label update on
g_set_modal.kind == SettingsModalKind::Device. That stopped being true when the
settings reorg moved the GPS block into its own PAGE (CAT_GPS ->
buildDeviceSettings(DSEC_GPS)) — the label is created there, but the refresh
condition still described the old Device modal, so it matched nothing and the
text was written once at build time and never again.

Everything on that page was therefore frozen at whatever was true the instant it
opened, including the elapsed-search timer added in 5c9f9cf. That is worse than
useless for GPS specifically: a receiver that is acquiring normally looks
completely stuck, which is exactly the "8 minutes, still nothing" reading, since
the seconds counter could not move.

Now refreshes when either host is open, guarded with lv_obj_is_valid so a stale
pointer left behind by a closed page can never be written.

Builds clean on V4, T-Deck and P4; flashed to the P4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:27:04 +02:00
Kaj SchittecatandClaude Opus 4.8 55e9484234 touch: keep the GPS line one-line in the top-bar dropdown, and stop over-claiming
Two corrections to 5c9f9cf, both reported from the device.

1. The dropdown got taller. gpsStatusStr() is shared: the top-bar control centre
   uses it as well as the GPS settings page, so making it two lines pushed the
   whole dropdown out. It now takes a `compact` flag — the dropdown gets one line
   (and drops the altitude), the settings page keeps the full text.

2. "No satellite data yet" was a claim we cannot support. It was gated on the
   receiver having decoded GPS time, on the reasoning that a receiver decodes time
   before it can solve a position. That reasoning is right in general but wrong for
   this parser: MicroNMEA reads RMC left to right and returns at the EMPTY latitude
   field of an unfixed sentence, so it never reaches parseDate and the time stays
   unset until a fix already exists. The branch was therefore always false while
   acquiring, and the page told a user with a perfectly healthy receiver that there
   was no satellite data. Removed.

What is left is what we can actually stand behind: how long it has been searching,
and that a cold start needs sky view and some minutes.

Recording the limitation properly in the comment, because it has now cost time
twice: from the UI layer a tracking receiver and an unpowered one are
indistinguishable. LocationProvider exposes only fix / sats-used / lat / lon / alt
/ timestamp, all empty until a fix, and the core keeps its MicroNMEA object
private. Satellites-in-view, SNR and HDOP need a monorepo core change plus a
core-* tag bump. Until then the serial boot probe (bdc0c7f) is the way to see the
real sky view.

Builds clean on V4, T-Deck and P4; flashed to the P4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:11:48 +02:00
Kaj SchittecatandClaude Opus 4.8 5c9f9cffef touch: GPS page says what it is actually doing, not just "acquiring..."
The GPS settings page showed "GPS: acquiring..." and nothing else until a fix
landed. That single string covers two completely different situations: a receiver
happily tracking satellites that needs another minute, and a receiver that is not
talking at all. They look identical to the user, so there is no way to tell a
working device from a broken one by looking at it. That ambiguity is what turned a
healthy T-Display P4 GPS into a long investigation.

While acquiring, the page now shows:
  - how long it has been searching, so a stuck receiver is obvious from elapsed
    time alone rather than from staring at an unchanging word;
  - whether the receiver has decoded satellite TIME. That is the useful tell: a
    GPS decodes time/date off the air well before it has enough satellites to
    solve a position, so valid time with no fix is positive proof the antenna and
    the UART are fine and it is genuinely mid-acquisition. The wording then points
    at sky view, which is the actual remedy;
  - and when there is no satellite data at all, it says so plainly instead of
    implying progress.

On a fix it now also shows altitude alongside the satellite count and position,
and the count is only printed when non-zero (it is satellites USED, so a bare
"0 sats" read as a fault).

Not included, and worth being clear about: satellites IN VIEW and per-satellite
SNR are NOT reachable from this repo. The core's MicroNMEALocationProvider keeps
its MicroNMEA object private and LocationProvider exposes only fix / sats-used /
lat / lon / alt / timestamp, so a full sky view needs a monorepo core change plus
a core-* tag bump. The boot probe added in bdc0c7f prints that data over serial in
the meantime.

Builds clean on V4, T-Deck and the P4; flashed and boot-verified on the P4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:53:52 +02:00
Kaj SchittecatandClaude Opus 4.8 bdc0c7ffb6 touch: P4 GPS probe — per-constellation view, and trim the boot cost to 1.2s
Follow-up to 6430866. Two changes.

Print one example of each distinct NMEA sentence type, not just the first line.
A single GxGSV is not enough to judge satellite visibility: the first boot showed
"$GLGSV,1,1,00" and I read that as zero satellites, but GLGSV is GLONASS ONLY and
says nothing about GPS, Galileo or BeiDou. The full set showed 10 GPS + 5 GLONASS
in view with SNR 33/35/23, plus "$GPTXT,...,ANTENNA OK" from the module's own
antenna check, i.e. a healthy receiver mid-acquisition rather than a fault.

Shorten the drain from 3s to 1.2s. It runs on EVERY boot and it consumes bytes
the NMEA parser would otherwise see, so it only needs to be long enough to catch
one of each sentence type (the L76K repeats the full set about once a second).
1.2s still captured all nine types on the verification run.

Confirmed working on hardware: a later boot reported
  $GNGGA,073620.000,5050.80018,N,00452.95932,E,1,05,2.2,36.0,M
  $GNGSA,A,3,08,22,32,...,2.2
i.e. a valid 3D fix on 5 satellites at HDOP 2.2, down from no fix / HDOP 25.5 a
few minutes earlier. The GPS path was never broken; the old diagnostic just could
not tell anyone that, because it reported a flag ENV_SKIP_GPS_DETECT forces true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:37:42 +02:00
Kaj SchittecatandClaude Opus 4.8 64308664db touch: P4 — make the GPS boot diagnostic tell the truth
The line here claimed "[GPS] L76K DETECTED (NMEA on GPIO22)" but never looked at
the UART. It reported whether the "gps" SETTING had been registered, and on this
board ENV_SKIP_GPS_DETECT forces that true unconditionally (we set it 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 on every boot whether or not a
single byte had ever arrived, and it survived several rounds of "GPS still isn't
working" while proving nothing.

Replaced with a real 3 s drain of Serial1 that reports byte count, complete NMEA
sentence count, the configured baud and RX pin, and the first sentence seen. That
splits the two cases that were being conflated: bytes arriving means pins, rails,
wake and baud are all correct and any stuck "acquiring" is a FIX problem (antenna
or sky view); zero bytes means the link itself is dead.

First run on hardware answered the open question immediately:
  [GPS] UART probe: 1985 bytes, 48 lines in 3s @ 9600 baud, module TX -> GPIO22
  [GPS] first sentence: $GLGSV,1,1,00,0*79
1985 bytes of well-formed NMEA proves the link is good, and GLGSV ",00," is zero
satellites in view, i.e. an antenna/sky-view issue and not firmware.

For the record, the config was already identical to Meck-P4's working port
(components/meshcore/variant.h: PIN_GPS_TX 22 / PIN_GPS_RX 23, wake on XL9535
IO11), and their L76K driver confirms both our other values: begin() defaults to
9600 baud, and sleep(false) drives the wake pin HIGH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:27:11 +02:00
Fr Cyril 20f8403928 Add Romanian keyboard layout + accent-popup ă/ş/ţ
Signed-off-by: Fr Cyril <frcyrilaz@gmail.com>
2026-07-29 09:31:39 -07:00
Fr Cyril dc684a763e Add Romanian (Română) UI translation
Signed-off-by: Fr Cyril <frcyrilaz@gmail.com>
2026-07-29 09:07:02 -07:00
27 changed files with 2582 additions and 1069 deletions

No files matched your search

+12 -1
View File
@@ -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
+19
View File
@@ -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.
+17
View File
@@ -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.
+13
View File
@@ -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).
+109
View File
@@ -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
@@ -468,6 +481,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 +628,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 +805,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 +849,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 +882,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 +904,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 +967,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
+6
View File
@@ -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
+6 -1
View File
@@ -602,7 +602,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). */
+565 -67
View File
@@ -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,248 @@ 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;
}
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 +301,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,70 +331,282 @@ void SdNvsPrefs::end() {
// ----------------------------- file helpers -----------------------------
SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) {
if (fileMode()) {
if (!s_cache_mutex) return nullptr;
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
FileCache* cache = findCacheLocked(_path);
Kv* found = nullptr;
if (cache) {
for (auto& e : cache->kv)
if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; }
}
xSemaphoreGive(s_cache_mutex);
return found;
}
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);
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;
}
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;
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) {
@@ -162,8 +621,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 -----------------------------
@@ -178,15 +636,51 @@ bool SdNvsPrefs::isKey(const char* key) {
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,7 +695,11 @@ 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) {
@@ -296,9 +794,9 @@ 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) {
@@ -316,9 +814,9 @@ size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) {
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
+23 -11
View File
@@ -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,16 +77,15 @@ 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();
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);
size_t sdPutInt(const char* key, uint64_t v, int width);
};
+53 -101
View File
@@ -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 = 42; // 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)
static const uint8_t TOUCH_CFG_VER = 43; // 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)
// 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)
@@ -172,7 +170,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
@@ -215,8 +217,8 @@ static void cfgSetDefaults(TouchCfg& c) {
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; }
@@ -284,6 +286,12 @@ static void cfgLoadOrMigrate() {
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
@@ -327,9 +335,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)) {
@@ -377,6 +384,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)
@@ -1052,7 +1063,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();
@@ -1234,36 +1245,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)) {
@@ -1271,15 +1269,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() {
@@ -1287,55 +1287,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();
@@ -1351,11 +1302,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
+7 -2
View File
@@ -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();
@@ -146,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);
+25
View File
@@ -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;
+32 -5
View File
@@ -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
@@ -466,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
@@ -497,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
@@ -519,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", 6) && 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) {
@@ -590,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
+53
View File
@@ -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) {
+1
View File
@@ -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
};
+537 -100
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -459,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; }
+15 -5
View File
@@ -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.
+730 -730
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -9,7 +9,7 @@
// Keep this enum in sync with kUiLangNames + the table column order in i18n.cpp.
enum UiLang : uint8_t {
LANG_EN = 0, LANG_NL, LANG_DE, LANG_FR, LANG_ES, LANG_IT,
LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_COUNT
LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_RO, LANG_COUNT
};
// Native names for the language picker (e.g. "Nederlands", "Русский", "Ελληνικά").
+1 -1
View File
@@ -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 "$@"
+48
View File
@@ -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
View File
@@ -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);
}
+50
View File
@@ -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
+7
View File
@@ -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(); }
+60 -15
View File
@@ -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,20 +171,9 @@ 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;
}
@@ -221,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
+5
View File
@@ -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]);
}