Commit Graph
356 Commits
Author SHA1 Message Date
Pixel Perfect a92afa191c fix(storage): gate loop watchdog APIs in IDF apps
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-08-01 21:59:17 -07:00
Pixel Perfect 1e6a6b4b35 fix(storage): inspect the Arduino loop task watchdog
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-08-01 21:20:48 -07:00
Pixel Perfect 9202ad0a10 fix(storage): preserve loop watchdog state
Signed-off-by: Pixel Perfect <me@pixp.cc>
2026-08-01 20:33:06 -07: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 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
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
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 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 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 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 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 4942fcf5aa touch: configurable per-chat history limit, default 250, warns when turned off
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>
2026-07-30 17:27:32 +02:00
Kaj SchittecatandClaude Opus 4.8 b846dc62fc touch: chat-list preview stuck on an old message (T-Display P4)
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>
2026-07-30 17:22:16 +02:00
Kaj SchittecatandClaude Opus 4.8 4058f8dcd9 touch: stop the inbox rescanning the whole message ring per thread
Field report: a public channel that had accumulated 3800 messages made the
interface "slow down drastically", and clearing it restored normal speed. The
reporter also noticed it was still noticeable at only ~300 messages and asked
whether something loops over every message. It does.

threadHasMessageHistory() answered "does this thread have any stored message?"
by walking the ring via getThreadMessageIndexes(). That scan stops early once it
finds a match, so a thread WITH recent messages is cheap — but a thread with
none walks every record in the ring doing a strncmp per record, and "has no
history" is precisely what the callers are testing for. getCombinedInboxCount()
and getUnreadTotal() both call it once PER THREAD, so a full ring turned every
inbox refresh into threads x messages string compares. That is the reported
slowdown, and it explains why it is felt well below a full ring.

Now cached per thread:
  - an append sets the owning thread's flag directly, so the common path never
    scans at all;
  - anything that REMOVES messages (ring eviction, clearThreadHistory,
    removeThread, any history load) marks the cache dirty, and the next reader
    rebuilds every thread's flag in ONE ring pass instead of one pass per
    thread, stopping as soon as all live threads are resolved.

clearThreadHistory clears just its own flag (no other thread is touched), so the
common "clear one chat" action stays O(ring) once rather than forcing a rebuild.

Behaviour is unchanged; this only removes repeated work. The separate request
from the same thread — a configurable per-channel history limit with a lower
default and a warning — is NOT in this commit.

Builds clean on V4 + T-Deck; flashed to the T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:13:05 +02:00
Kaj SchittecatandClaude Opus 4.8 471a522d8f touch: put Airtime + Snake on the Spectrum app-page pattern
Both were built from the same template and carried the same three defects, none of
which were really about their own code: they hand-rolled the page chrome that every
other app page gets from the shared machinery.

New src/ui-touch/AppPage.h exports that machinery (appPageCreateRoot / Begin / End /
DeleteRootAsync) so a self-contained module can build the same page the in-file tool
pages do, and both modules now use it.

  1. GEOMETRY. Both positioned their overlay against a local `kTopBar = 22`.
     STATUSBAR_H is a RUNTIME value: SC(22) once the UI scale is above 100%, and
     SB_TOP_PAD + SB_ROW*2 on the T-Display P4's two-row bar. On those boards the
     page sat too high and its own title + close button landed UNDERNEATH the real
     status bar, which is what "the airtime app has issues" looked like. Geometry now
     comes from the live bar height, so a page cannot disagree with the bar.

  2. NAVIGATION. Neither installed s_apppage_title / s_apppage_close, so each needed
     its own case in statusBarTapCb and its own clause in the bar-foreground gate, and
     neither reached statusBarReaderBackCb — the PRESSED fallback that exists because
     the cap-touch swipe detector can drop the lone CLICKED. On a touch-only board
     that left no reliable exit at all. They now install the standard hook, get the
     tall "< Airtime" / "< Snake" bar, and both special cases in UITask are deleted.

  3. TEARDOWN. closeCb ran `lv_obj_del(root_)` and `delete this` synchronously from a
     child button's own event callback, freeing the object LVGL was still dispatching
     to. Teardown is now async throughout: lv_obj_del_async for the root, lv_async_call
     for the instance, one single dismiss() path. Snake's canvas buffer moves to a
     destructor so it is freed after the queued root delete released the canvas.

Also fixes what the rework exposed: applySwipeGesture had swallow-guards for Snake,
sliders, chats, popups, the control centre and settings sheets, but none for app pages
— so a horizontal swipe inside Airtime reached the tab switcher and jumped to the Map
(reported on device). An opaque CLICKABLE root only blocks CLICKS; that detector reads
the touch hardware directly and never consults LVGL hit-testing. One guard now covers
every full-screen page, Spectrum and the rest included, which had the same bug.

Bar titles are copied into module-owned storage rather than holding TR()'s pointer:
TR returns the static table cell for a plain key but a recycled 4-slot ring for an
icon-prefixed one, and s_apppage_title keeps the pointer for the page's lifetime.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:08:18 +02:00
Kaj SchittecatandClaude Opus 4.8 ecb506694d touch: P4 antenna — FIX INVERTED POLARITY, internal is HIGH not LOW
b066c37 guessed the antenna mapping from field symptoms and guessed it BACKWARDS,
so "Internal" was selecting the external socket and the boot park pinned the
external connector — the exact hazard the change was meant to remove. Corrected
against LilyGo's own sources, which document this pin explicitly:

  lilygo_device_driver  src/device/t_display_p4/t_display_p4_config.h
    inline constexpr auto kSky13453Vctl = cpp_bus_driver::Xl95x5::Pin::kIo1;
  lilygobox-espidf      main/hal/device/t_display_p4/t_display_p4_device.cpp
    const uint8_t antenna_level = config.antenna == AntennaType::kExternal ? 0 : 1;
    GpioWrite(gpio::xl9535::kSky13453Vctl, antenna_level);
    ... logged as   kExternal ? "RF2" : "RF1"

So HIGH = RF1 = on-board antenna, LOW = RF2 = external socket. INTERNAL_LEVEL
flips false -> true. The one-constant escape hatch written into b066c37 is what
made this a one-line correction.

Two things this settles beyond the polarity. The vendor's AntennaType enum is
literally kInternal/kExternal, so the line IS an antenna select and not a TX/RX
path — the architectural half of the earlier reasoning was right. And LilyGo
preloads VCTL = 1 in their own XL9535 init as their stated safe state, so
booting on the on-board antenna is the vendor's choice too, not just ours.

It also explains the field report properly: the legacy per-TX toggle idled LOW
to receive (= EXTERNAL, where the reporter's antenna is fitted, hence a healthy
+12 dB inbound) and drove HIGH to transmit (= the on-board antenna, hence ~22 dB
down at the repeater). It transmits internal and listens external, which is the
opposite of what a3550d2 claimed. So auto is NOT a PA hazard after all — its
transmit lands on the permanently attached on-board antenna — it is just
guaranteed lopsided. Its confirmation text now says that instead of warning
about damage, and only External keeps the damage warning. UI strings say
"external antenna socket" rather than "MMCX", since the board has two MMCX
connectors and only one of them is the LoRa path.

Verified on hardware (T-Display P4, MAC ...e1:c2:a7): clean boot, only
rst:0x1 (POWERON), antenna park runs, SNTP -> rtc 1785413952.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:19:48 +02:00
Kaj SchittecatandClaude Opus 4.8 a3550d2190 touch: P4 antenna — gate the legacy auto mode behind the same warning
Auto was still one tap away with no prompt, which contradicts the safety model
in b066c37: the legacy per-TX toggle writes the external level on EVERY single
transmit, so it keys the PA into the MMCX socket more often than "External"
does, not less. Both non-internal modes now require an explicit confirmation,
with wording that says what each one actually does to the transmit path.

Internal stays promptless (it is the safe state), and the revert-then-reapply
shape is unchanged, so a dismissed dialog still leaves the UI and the hardware
where they were. The pending mode moves into a static because showConfirm takes
a bare callback with no user data.

Verified on the device (T-Display P4, MAC ...e1:c2:a7): clean boot, only
rst:0x1 (POWERON) in the log, "[XL9535] power-on sequence done" so the antenna
park runs, and "[C6-AT] SNTP -> rtc 1785412930" decoding to 2026-07-30
12:02:10 UTC, which also confirms the ClockFloorRTC system-clock mirror from
fe87ff0 firing on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:02:52 +02:00
Kaj SchittecatandClaude Opus 4.8 b066c379c0 touch: P4 antenna — internal on every boot, confirm before going external
Reworks the P4 antenna setting so the failure mode it protects against is
impossible by default. Transmitting into an external connector with nothing
fitted is what destroys a PA, so the state you get for free after any power
cycle, crash or OTA has to be the on-board antenna.

  - The on-board antenna is forced at EVERY boot, in two places: the park in
    Xl9535::powerOnSequence() (before the radio object even exists) and a
    re-assert in UITask::begin() in case anything touched IO1 in between.
  - The choice is now session-only and is never written to flash. That is the
    whole point: if "external" could survive a reboot, the safety property is
    gone. touchPrefsGet/SetP4Antenna are removed; the p4_antenna byte stays in
    the struct as reserved so TOUCH_CFG_VER does not have to rewind.
  - Selecting External raises a confirmation naming the MMCX socket and the
    risk. showConfirm has no cancel hook, so the dropdown reverts immediately
    and is only re-selected from the confirm handler — a dismissed dialog
    therefore leaves both the UI and the hardware on internal.
  - The dropdown reads the live xl9535.antennaMode() rather than a stored pref,
    so it can never claim an antenna the hardware is not actually on.

Also renames the modes from the opaque Auto/Pinned A/Pinned B to Internal /
External / Auto (legacy). The reasoning for treating IO1 as an antenna select
rather than a TX/RX path switch is written up on Xl9535.h: a per-transmit
switch must settle within microseconds of the PA ramping, and an I2C expander
write on a bus shared with touch, the RTC and the fuel gauge cannot do that,
while the SX1262 has DIO2 for exactly that job. Which LEVEL is which antenna is
still unconfirmed against LilyGo's schematic, but the SAFE level is known from
the field: the legacy mode sits LOW to receive and every P4 receives fine at
+12 dB, so LOW demonstrably radiates into something connected, while HIGH was
only ever used for transmit and is the suspect. One named constant
(INTERNAL_LEVEL) flips the mapping if the schematic ever says otherwise.

Builds clean on the P4 IDF target plus the V4 and T-Deck S3 envs (the shared
TouchPrefsStore change); compile-verified only, the P4 is off the USB bus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:51:15 +02:00
Kaj SchittecatandClaude Opus 4.8 fe87ff02c5 touch: fix the P4 clock — mirror the mesh RTC into the ESP32 system clock
The UI reads the ESP32 *system* clock for every displayed timestamp (status
bar, chat bubbles) while protocol timestamps come from ClockFloorRTC. On a
board with no RTC chip those are the same clock, so they cannot disagree. The
T-Display P4 (and the ThinkNode M9) carry a PCF8563, and
AutoDiscoverRTCClock::setCurrentTime is an if/else chain: with a chip present
it writes ONLY the chip and never reaches the ESP32RTCClock fallback. So the
system clock kept ESP32RTCClock::begin()'s power-on seed forever.

That seed is 1715770351 == exactly ClockFloorRTC::MIN_VALID_EPOCH, which
explains both halves of the P4 field report:
  - the UI showed 15 May 2024 while sent messages carried the correct time
  - "Sync clock from system" then poisoned the good clock: it fed that seed
    back in as a real set and cleared the MIN check by being precisely equal
    to it (the old `t < 100000` guard only caught a clock counting up from 0)

Fixed in the one funnel every time source already goes through:
  - ClockFloorRTC::setCurrentTime mirrors every ACCEPTED value into the system
    clock, so NTP-via-C6, GPS and the phone app all correct the display too.
    Validation runs first, so 1902/2043-class garbage never reaches it either.
  - new seedSystemClock(), called at boot right after seedFloor(): a
    battery-backed chip already knows the time, so the UI reads correctly with
    no network and no GPS fix. After seedFloor, so a dead chip yields the
    persisted floor rather than the seed.
  - the sync button rejects an untouched seed and now reports honestly instead
    of always claiming "Clock synced".

No-op on the seven chipless boards: the fallback already wrote the same value
through settimeofday. GPS re-syncs are rate-capped to ~30 min, so the mirror
adds no measurable work. Builds clean on all 8 S3 envs + the P4 IDF target;
compile-verified only, the P4 is still off the USB bus pending a power cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:39:08 +02:00
Kaj SchittecatandClaude Opus 4.8 d6c0321472 touch: P4 LoRa antenna control, to settle the outbound-signal deficit
Field reports say a T-Display P4 transmits far weaker than it receives: one user's
repeater hears his P4 at -10 dB while the P4 hears the repeater at +12, and his
T-Deck is +12/+12. Decisively, the SAME P4 under other P4 firmware gives symmetric
numbers, so it is not his hardware, and that firmware also offers an
internal/external antenna choice which we do not.

Reading the variant, our whole RF path rests on one never-validated assumption.
XL9535 IO1 is labelled "SKY13453 VCTL (LoRa TX/RX path)" with the comment "polarity
TBD on-device", and we flip it around every transmit. But this expander has NO other
antenna-select line -- all 16 IOs are power rails, screen, touch, ethernet, C6, SD
and the SX1262 reset/DIO1. If IO1 is actually the internal/external ANTENNA select,
then toggling it per transmit means we send on one antenna and listen on the other,
which is exactly a large outbound-only deficit on a unit with an external antenna
fitted. A second user reporting symmetric +11.8/+12.0 does not refute it: that was a
two-floor link where SNR saturates in both directions and would mask the loss.

I will not gamble on that assumption: if IO1 really is a TX/RX switch, pinning it
would break transmit or receive outright. So this adds a Radio setting (P4 only):
  Auto (switch per transmit)  - the existing behaviour, and the DEFAULT, so an
                                untouched device is bit-for-bit unchanged
  Pinned A / Pinned B         - hold the line in one state for both TX and RX
Whichever pinned option gives symmetric Trace SNR both ways is the correct antenna,
and that result also tells us what IO1 actually is. The labels are deliberately A/B
rather than Internal/External because we do not yet know which is which.

Applied live on change and re-applied at boot, next to the V4 FEM-LNA precedent.
Stored as touch-cfg v41, a trailing field defaulting to 0, so existing installs
migrate with no behaviour change.

Builds clean on all 8 S3 envs plus the T-Display P4. Cannot be verified here: no P4
is on USB, and the answer needs an on-air SNR comparison anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:19:13 +02:00
Kaj SchittecatandClaude Opus 4.8 162ec43bfa touch: cut the advert-driven SPIFFS churn behind the multi-second mesh stalls
Two reporters on card-less Heltec V4s saw the whole UI freeze for 5-60 s at a time
with BLE, WiFi and GPS all off and no user activity, and BOTH of a reporter's two
devices froze at the same moment. About -> "Loop stalls" named the culprit
unambiguously: tag `mesh`, with peaks of 11451 / 12175 / 14592 / 18063 / 19113 ms.
That is inside the_mesh.loop(), i.e. the advert receive path, and the cost is SPIFFS
garbage collection on a partition kept permanently churned by writes that happen per
advert. The simultaneity across two units is the giveaway: the trigger is a flooded
advert both of them receive, after which each runs the same deterministic write.

This is the residue of beta_40's "card-less contacts-save stall fix", which capped how
OFTEN the contacts rewrite happens without reducing its cost, and left the
highest-frequency writer untouched.

Two frequency reductions, both keeping writes ATOMIC:

1. putBlobByKey now persists an advert blob once per key per boot. The core calls it on
   EVERY advert (outside the auto-add block, so for already-known contacts too) and each
   call is a full create+truncate+write. With ~200 known nodes re-advertising that is a
   flash write every few seconds, forever, purely to keep the raw advert packet fresh for
   the Share-contact feature. The content is effectively static, and getBlobByKey still
   reads the file we already wrote, so there is no cache to keep coherent. Steady state
   on the packet path becomes zero flash I/O. Costs 2 KB of DRAM.

2. A save triggered by an add/remove now has its own 30 s floor. It previously bypassed
   the 5-minute refresh window entirely, so on a growing mesh every newly-heard node
   forced its own full 30 KB rewrite (200 contacts x 152 B). A burst of new nodes now
   coalesces into one rewrite. Kept short on purpose: a new contact still needs to reach
   flash promptly, and losing one to an unclean power cut only costs a rediscovery on that
   node's next advert.

Deliberately NOT done: making the contacts save incremental (seek + one 152-byte record).
It is tempting since the file is a flat fixed-stride array, and it would cut churn far
harder, but it abandons the tmp+rename atomicity that beta_46 added to fix real contact
data loss. Both reporting devices show "Last reset: Brownout", so non-atomic in-place
writes on that hardware would trade a stall for corrupted contact records. Frequency
first, atomicity intact.

Also seen in those same screenshots and NOT addressed here: ui:gps stalls of 3077 /
3769 / 3848 ms on a device whose owner says GPS is switched off, and ui:lvgl stalls up
to 2227 ms. Separate issues, worth their own investigation.

Verifiable by the same diagnostic that found it: the `mesh` entries in About -> Loop
stalls should stop appearing.

Builds clean on Heltec V4 TFT and T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 12:47:20 +02:00
Kaj SchittecatandClaude Opus 4.8 8ae0df9400 touch: stop the deferred-login branch eating the companion app's responses (#124)
Partial fix for "no ping and telemetry response" reported from the device AND the
phone app. onContactResponse's deferred-login branch runs before every pending_*
matcher and, while armed, consumed ANY response from that contact and returned. Its
comment justifies this with "we have sent no REQ to this contact, so any RESPONSE
here is the login reply" -- true of the UI, but the companion app can independently
have a STATUS / TELEMETRY / BINARY request in flight to the same node. There is also
no distinct login-failure code on the wire (a failure is just "not
RESP_SERVER_LOGIN_OK"), so a non-OK frame is indistinguishable from the app's reply,
and the app's response was swallowed with the phone left waiting.

A LOGIN_OK is unambiguously ours, so it is still always consumed. Otherwise the
branch now falls through when the app has a request pending on that same contact,
letting its matcher see the frame; the UI's own reply deadline disarms us, which is
all the early disarm here ever provided. Behaviour is unchanged whenever the app is
not waiting, which is the common case.

Deliberately NOT changed: sendStatusPingForUI / sendTelemetryRequestForUI zero the
app's pending_status / pending_telemetry on entry. That looks like deliberate
arbitration so a UI-initiated request routes to the UI rather than the phone, and
removing it could double-handle a response. Wants its own change and a bench test.

Note this is unlikely to be the whole of #124: the leading theory remains the
repeater's per-client replay high-water mark, which no client-side change can undo.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:38:36 +02:00
Kaj SchittecatandClaude Opus 4.8 8e6a6d5f64 touch: stop the web mirror silently dropping typed characters
VE2CCK reported losing "a lot of characters" while typing into the web mirror from
a phone, and put it down to his phone's autocomplete not travelling over the link.
It is actually our bug. Browser keystrokes land in a 32-entry SPSC ring pushed from
the network thread, pushKey DISCARDS the key when that ring is full (silently, no
counter, no log), and the UI drained only 16 per loop iteration. A loop iteration
can be tens of ms on a T-Deck (LVGL frame, SD write, radio), and an autocomplete or
a paste delivers a whole word at once, so a burst overflowed 32 easily and the rest
vanished. Typing slowly worked, which is why it read as an autocomplete quirk.

Ring 32 -> 256 (512 bytes; the ceiling while _khead/_ktail remain uint8_t, noted in
the comment so nobody raises it further without widening them), and both drain sites
16 -> 64 so a burst clears in one pass instead of trickling while newer keys push the
tail along. No API or protocol change, and nothing else reads this ring.

This matters more than it looks: he is using the mirror to screen-record how-to
guides for his radio club, so typing fidelity is the feature.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:31:34 +02:00
Kaj SchittecatandClaude Opus 4.8 e5d541c170 touch: blocking someone in a room no longer silences the whole room
Found while fixing #177. A room thread is a CONTACT thread, so it took the DM
branch of ignoreSenderInActiveThread, which blocks the thread contact's pubkey.
For a room that contact is the SERVER, not the person who posted, and
newRoomMsgFromPubWithMeta drops on exactly that pubkey. So blocking one noisy
member silently killed every message from the entire room, with nothing in the UI
explaining why. Room posts carry only an author display name (passed to newMsgImpl
as the sender override), so the author is now blocked BY NAME and the room itself
is left alone. Tapping a sender whose name is the room's own name still falls
through to the pubkey block, so muting a whole room from inside it still works,
and the contacts-list block is untouched.

Second half of the same bug: the name filter in newMsgImpl was gated on `channel`,
which is false for room posts, so a room name-block was stored and then never
enforced. It now also applies when a sender override is present, which is exactly
the room case. A plain DM has neither flag, so it keeps using the pubkey filter and
is deliberately unaffected: a DM contact who happens to share a display name with a
blocked room bot must not disappear.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:16:04 +02:00
Kaj SchittecatandClaude Opus 4.8 acdfd0a45b touch: revive three parked UI fixes from the beta_32 stash (#97, #106, #109)
These three had fixes written back at beta_32 but the batch was parked and never
shipped. The stash no longer applies (twenty betas of drift; ClockFloorRTC.h and
UITask.cpp both conflict), so they are ported by hand onto current main. The five
other fixes in that stash are deliberately NOT ported: #98/#100/#116 already
shipped in beta_48 and #93/#113 are closed, so re-applying them would duplicate
or conflict.

#97 the crash-restart dialog's text ran down over its own Cancel/OK buttons.
showConfirm used a fixed PSC(160) card with a freely-wrapping label, so any long
message overflowed; the crash-report prompt is the worst case. The card height now
follows the wrapped text, the message gets its own box sized to exactly the space
above the buttons (so it physically cannot reach them) and scrolls when the card
hits the screen cap. Keeps the old 160 as a floor, so every short dialog renders
exactly as before and only the broken long-text case changes. Added a clamp the
original port did not have, so the message area cannot invert at Large/Huge UI
scale.

#109 the settings sheet opened from the Discovered screen looked truncated and
could not be scrolled: centring the card and then lifting it a fixed -46 pushed
its top off-screen on the 240px-tall T-Deck. Height is clamped to what the screen
has and the lift to the leftover slack.

#106 room servers were indistinguishable from companion contacts in the contacts
list (same person icon). Rooms now get their own glyph.

Not ported: #112's RX-activity dot. The logic is trivial but the status bar has
since grown a second placement pass for the tall two-row layout (P4 /
round-corners) that the stash predates, so the dot would strand itself at its
single-row position there, and its fixed offset collides with the BLE icon's
hardcoded narrow-screen position. Needs a device to eyeball, not a blind port.

Verified: all 8 S3 envs plus the T-Display P4 (which exercises the
CAP_LARGE_SCREEN branch of the reworked dialog) build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:09:17 +02:00
Kaj SchittecatandClaude Opus 4.8 17651bcd90 touch: fix blocked senders, muted notification sound, and a latched Wi-Fi scan gate
Three community-reported bugs, two of which turned out to share one cause.

#177 blocking a channel sender did nothing. Group packets carry no per-sender
pubkey (MyMesh's channel branch passes from_pub = nullptr), so the prefix ignore
list is never consulted for channel traffic and the only live filter is the name
one. But the block path stored a PUBKEY whenever the sender happened to be a saved
contact, so the block was recorded, shown in the blocked list, and filtered
nothing. Channel blocks now always write the name entry; a saved contact also
keeps its pubkey entry so DMs stay blocked as before. Unblock is now paired, so
clearing one entry clears its twin instead of leaving the sender half-blocked.

#184 lost notification sound until a restart. s_tile_fetch_pending gates all four
chime paths and is only ever ++/--, never reset, so one leaked increment mutes the
device for the rest of the boot. queueTileForFetch ignored
ensureTileFetchTaskRunning()'s result, and that function creates the QUEUE before
it can still fail on the worker's 8 KB contiguous-DRAM stack: queue alive, worker
dead, every send incremented a counter nothing could drain. Honor the result.
Not a beta_50 regression, the sound path was untouched in that release.

#171 Wi-Fi never reconnecting after a reboot. Same worker. Opening the Wi-Fi page
drops the link and raises the scan gate, which is lowered only when the tile
worker reports s_wifiscan_done. If that worker never starts, the gate latches:
autoreconnect stays off and main.cpp suppresses its reconnect retry, so Wi-Fi is
dead until a reboot. Added a 30 s recovery deadline, far longer than any real
sweep, so it can only fire when the normal completion path is genuinely lost.

Also corrected the stale comment that described the signal probe as a flood. It
is a zero-hop NODE_DISCOVER_REQ that repeaters answer directly and never
re-broadcast, and it is skipped entirely when a direct neighbour was heard in the
poll window. That wording is where issue #80's "spams the mesh" concern came from.

Verified: all 8 S3 envs plus Tanmatsu build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 10:54:20 +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
33306d9fa9 Merge #179: SD failure recovery + segmented message store (Dan Vybiral / @Vybo)
Lands @Vybo's storage-stack rework from #179 — runtime SD wedge/remount recovery,
chat-store health diagnostics, and a segmented, append-friendly message store that
replaces the whole-ring file rewrite (crash-atomic migration, per-segment
corruption quarantine) — plus a fix for the segment-table-cap latch that made SD
I/O fail after a few hours of uptime: at the cap, segBuildJob emitted a create-append
segCommitJob could not table, freezing the durability watermark into a permanent
write-spin that leaked orphan segment files until the card filled.

Full credit to Dan Vybiral (@Vybo) for the design and implementation. The cap-latch
fix is the only change on top of his branch. Landed directly (his fork branch could
not carry the fix), so #179 is closed rather than merged.

Co-Authored-By: Dan Vybiral <dan.vybiral@greencode.cz>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:16:09 +02:00
Kaj SchittecatandClaude Opus 4.8 dd7ba1f9f9 touch: fix segmented-store latch at the segment-table cap (SD I/O dies after hours)
Root cause of the reported "SD writes fail after a few hours of uptime": when the
segment table is full (s_seg_count == k_ui_seg_max), segBuildJob still emits a
CREATE-append, but segCommitJob only tables a create while s_seg_count < the cap.
The new segment file is written but never recorded, the durability watermark
(s_seg_flushed_seq) never advances, so the same append re-arms every flush cycle
(permanent write-spin). As the ring evicts the frozen records the target first_seq
shifts, so a new orphan seg_*.bin is written each cycle until the card fills and
every SD write fails; records evicted before they are credited are lost.

It surfaces only after hours because s_seg_count creeps from its ~20 steady state
(5000/256) up to the 24 cap slowly, via delete-driven fragmentation and
step-1-append starving step-2 retirement.

Fix: at the cap, defer the create-append instead of emitting an untableable one.
segBuildJob skips step 1 (before the gather/watermark advance, so the watermark is
never moved past un-written records) and falls through to step 2, which retires the
oldest segment first. At the cap the oldest 256 records have aged out of the ring
(24*256 > 5000), so that segment is fully evicted -> compact_dirty -> unlinked,
freeing a slot with no live-record loss; the deferred records are the newest (above
the watermark, evicted last) and land next cycle. saveMsgsToStorage's early
"armed == 0 -> durable" return is guarded with segMoreWorkPending so a deferred
append is reported as a failure, not a false success. Below the cap: no-op.

Builds green on T-Deck + Heltec V4 TFT. Needs a multi-hour on-device soak to
confirm against the original repro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:07:53 +02:00
Kaj e4aa702251 Merge pull request #176 from jacobpretorius/channel-util-tool
feat(ui-touch): add Channel Utilization tool to Apps drawer
2026-07-29 14:16:06 +02:00
Kaj 6d1d38196e Merge pull request #182 from scratchdiver/steve/touch-chat-timestamp-top
touch: move chat bubble timestamp to top row
2026-07-29 14:15:48 +02:00
Kaj 19107bf7e0 Merge pull request #181 from Yazutsu/fix/lockscreen-wallpaper-cache
ui-touch: invalidate LVGL img cache before freeing lock wallpaper buf.  Fixes #127
2026-07-29 14:15:37 +02:00
Kaj SchittecatandClaude Opus 4.8 e26b406e02 touch: P4 honours the UI-size pref again (text size was a dead no-op)
beta_49 hard-pinned s_ui_fscale=100 on the P4 to stop >100% chrome overflow, but
the "UI size" dropdown stays shown (CAP_LARGE_SCREEN), so changing it did nothing
(reported: "changing text size doesn't work on the P4"). Honour the saved pref like
the Tanmatsu does. Large/Huge can still make some P4 chrome that uses unscaled dims
tight; that's a per-screen SC() follow-up, not a reason to disable scaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:15:17 +02:00