The Format button (and its busy overlay) is compiled for T-Deck, M9 and
V4-R8, and Copy/Paste for every Files-app board, but both deferred workers
in UITask::loop were gated T-Deck-only. Elsewhere the tap set a pending
counter nothing consumed: the notice stayed up forever and the format
latch blocked SD remounting until reboot. Widen the format worker to the
button's gate (via the per-board sdSharedSPI accessor), ungate paste.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A repeater name with an emoji (multi-byte UTF-8 cut at a byte cap) or a
literal '#' desynced the LVGL recolor runs in the Discover feed, visually
killing every row after it; same class in the message-info repeat list.
Names are now UTF-8-boundary-cleaned and '#'-escaped at both sites, and
copyUtf8ReplacingMissingGlyphs no longer emits a split glyph at its cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'Retry sends until heard' switch under Radio & Mesh gates mikecarper's retry
train (#207): OFF stops new retry trains (an in-flight one finishes its
schedule), applied live and persisted (cfg v44 retry_echo, default ON).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_wdtHeavyDepth() is a plain int, but WdtHeavyGuard is taken from both the
UI/loop task and the core-1 history worker (histFlushTaskFn ->
uiSegRunArmedJob -> uiSegAppendRecords/uiSegCompactWrite, each of which holds
one). Both run at priority 1 on core 1, so ordinary tick round-robin can
preempt between the load and the store of d++ / --d.
That race predates this PR, but this PR makes it likely rather than
theoretical: the new guard in rebootDevice() is constructed immediately
before uiHistWaitWorkerIdle(), a deliberate wait of up to 9 s on the very
worker that also takes the guard. Interleave the two and either
- the depth strands above zero, so enableCore0WDT() is never called again
and IDLE0 stays unsubscribed from the task watchdog for the session, or
- the depth drops to zero while a guard is still held, re-arming the
watchdog during the flash burst it exists to cover -- which panics
mid-save and loses exactly the chat history rebootDevice() runs to
persist.
Use std::atomic with an acq_rel fetch_add and a compare-exchange decrement.
The CAS preserves the original `d > 0` underflow guard, which still matters:
doFactoryReset() calls wdtHeavyBegin() with no matching end because it
reboots, so the counter can legitimately stay elevated and an unbalanced end
must not drive it negative.
Signed-off-by: Pixel Perfect <me@pixp.cc>
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>
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>
- #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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Second half of the "large message history slows the interface down" report. The
first half (4058f8d) removed the repeated ring scan; this bounds how much history
a single chat can accumulate in the first place, which is what was asked for in
the channel: a configurable limit, with a lower default, and a warning.
Until now the message store was one shared ring with no per-chat bound, so a busy
public channel could occupy essentially all of it. That both starved every other
chat of its history and kept the ring permanently full, which is the condition the
slow paths were suffering under. The reporter had 3800 messages in one channel.
- New pref hist_per_chat (touch-cfg v42), default 250 messages per chat. Chosen
to be deep enough to scroll back through a conversation while staying far below
the 5000-record SD ring, so no single chat can dominate it.
- Enforced on append: enforceHistoryCap() blanks that chat's oldest records until
it is back within the limit. It walks from the oldest end, so for the chat that
is actually over the cap (the one filling the ring) the records it wants are
found immediately rather than after a full scan.
- Settings -> Chats: "Keep per chat (messages)" — 100 / 250 / 500 / 1000 / 2000 /
No limit, with a line explaining that older messages are dropped past the limit.
- "No limit" is a deliberate choice, not a silent one: it raises a confirmation
saying a busy channel can then fill the whole store and make things slower. The
dropdown reverts first and only commits from the confirm handler, so dismissing
the dialog leaves the previous limit in place.
The thread-history cache from 4058f8d becomes a per-thread COUNT rather than a
bool, since the cap needs to know how many records a chat holds; "has history" is
just count > 0, so the O(1) inbox behaviour is unchanged.
Existing installs pick up the 250 default and will trim on the next message in an
over-long chat. Anyone who wants the old unbounded behaviour can select No limit.
Builds clean on V4 + T-Deck; flashed to the T-Deck.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported on the P4: the preview line under a channel in the chats list showed an
older message instead of the latest, and never moved on.
getThreadLastMessage() chose the message with the highest m.ts, and m.ts is the
ESP32 SYSTEM clock. That clock is not monotonic across reboots — it restarts
from ESP32RTCClock::begin()'s power-on seed on every boot and then only climbs
with uptime until a real time source arrives. So a message received hours into
an earlier boot carries a LARGER timestamp than one received a minute into this
boot, wins the max(ts) comparison forever, and pins the preview to itself.
It showed up on the P4 because that board has an RTC chip, so the core wrote
time to the chip and never to the system clock, which sat on the seed
indefinitely (fixed separately in fe87ff0 by mirroring the mesh RTC into the
system clock). Any board can hit a milder version of this after a reboot with no
time source, so the selection itself is what needed fixing.
Now the newest message is taken by RING ORDER — walk back from the head and take
the first match. Ring order is the real arrival order and cannot be skewed by a
wrong clock. It is also faster: it exits on the first hit instead of scanning
every slot in the ring, which matters on the same busy-channel devices as the
inbox-scan fix in 4058f8d.
Builds clean on V4 + T-Deck.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>