Commit Graph
466 Commits
Author SHA1 Message Date
Tesso M Costa 3307d87e9c touch: add master "At a glance" toggle, move while-locked switch under it
Options > Display previously only had "At a glance while locked"
(opt-in, off by default). Add a master "At a glance" switch above it
(on by default, matching the previously-unconditional behavior) that
gates the whole feature, and move the while-locked switch out of
Options > Lock screen to sit under it.

The while-locked switch's own saved preference is left alone when the
master is toggled off -- only its editability follows the master, so
re-enabling the master always restores its true prior state rather
than resetting it.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-08-24 09:03:41 -06:00
Tesso M Costa 808ee6e1b4 Merge remote-tracking branch 'origin/main' into at-glance 2026-07-30 10:32:23 -06:00
Kaj SchittecatandClaude Opus 4.8 1863ebdd2c touch: beta_53 release notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
beta_53
2026-07-30 17:39:18 +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
Tesso M Costa 14d5fdb8b3 touch: add "at a glance while locked" opt-in, fix glance multi-line body
New Options > Lock screen toggle lets the at-a-glance overlay fire while
the device is manually locked too, not just unlocked+idle-dimmed -- off
by default since it makes message text readable off a locked device.
Persisted via TouchPrefsStore (touchPrefsGetGlanceWhenLocked). When it
fires from a locked state, mirrors lockscreenReveal()'s peek path (panel
lit, _manual_lock left alone) rather than wakeScreen() (which would
silently clear the lock).

Also fixes the glance body label: LV_LABEL_LONG_DOT only wraps/dot-
ellipsizes across multiple lines when the label has a fixed height, so
left at auto-height it was effectively single-line everywhere, cropping
longer messages instead of continuing to a second line. Now sized to
the actual space available below it for the current board's resolution
and font.

Adds a T-Deck-only experiment shrinking the glance body font from 28px
to 20px (extras_lat_20 gate widened past Tanmatsu-only to cover it);
easy to revert in isolation per the comments at each site.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-30 09:12:05 -06:00
Kaj SchittecatandClaude Opus 4.8 3705a66288 touch: P4 — opt-in flush tracer for whole-screen colour-flash reports (#167)
Diagnostic scaffolding kept from chasing the "screen flashes teal on every
message" report, behind -DTDP4_FLUSH_TRACE so it is compiled out of normal
builds (verified: no FLUSHTRACE string in the shipped binary).

It logs any large, mostly-single-colour flush band with its RGB565 value. That
answers the one question that was otherwise unanswerable from the outside: is
the flash something LVGL painted, or something below it? A UI-painted flash
appears as a burst of bands carrying that colour; if the screen visibly flashes
and nothing logs, the cause is in the panel/DSI layer and hunting through UI
widgets is wasted effort.

Two notes recorded in the comment because both cost time here. One LVGL band on
this board is 284x24 = 6816 px, so a size gate has to sit well below that — an
earlier 8000 px threshold silently disabled the whole tracer and looked like
"nothing is drawing". And the per-band uniformity scan measurably slows the
flush path, so it can mask a timing-sensitive bug: any fix must be confirmed on
a build with the tracer OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:07:38 +02:00
Kaj SchittecatandClaude Opus 4.8 7aaff80742 touch: P4 — move the flush upscale scratch out of PSRAM (#167 teal flash on RX)
Both P4 SKUs render at half resolution and nearest-neighbour-upscale each LVGL band
into a scratch buffer before handing it to the panel. That scratch was allocated with
MALLOC_CAP_SPIRAM, which put it on the worst possible bus.

These are DPI/DSI panels with num_fbs = 1: the ~1.4 MB framebuffer can only live in
PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. Every draw_bitmap is a
copy INTO that framebuffer, so with the scratch also in PSRAM a single flush put three
streams on one bus at once — the scratch read, the framebuffer write, and the DSI's own
read. Anything else wanting PSRAM in that window can starve the DSI read, and a DPI
underrun presents as a whole-screen colour flash for one frame.

That is the reported "screen flashes teal on every RX" (#167 flicker): it happens on
ANY screen and was there from the start of the port, because it is a display-bus
problem and has nothing to do with what is being drawn. The RX correlation is the
priority-10 "lora_rx" drain task, which wakes on every received packet and is ON by
default (rx_queue defaults to 1).

Allocating the scratch in internal DMA RAM removes the scratch read from the PSRAM bus
entirely. The cost is bounded and small: one LVGL band is LV_DRAW_BUF_LINES = 24 rows,
so AMOLED 284x24 -> 568*48*2 = ~53 KB and LCD 270x24 -> 540*48*2 = ~51 KB, against a
348 KiB internal region. PSRAM stays as the fallback so a fragmented heap degrades to
the previous behaviour rather than dropping to the unscaled draw path.

Not done here: lowering the drain task's priority. It is created in the vendored core
(RadioLibWrappers.cpp), which is not tracked in this repo, so a change there cannot
ship from wadamesh — it needs a monorepo core change plus a core-* tag bump. It is also
the weaker lever: the core pins that task to core 0 while LVGL and loop run on core 1,
so it is not preempting the UI thread, and the contention this fixes is the memory bus.

Builds clean and boot-verified on the device (T-Display P4, MAC ...e1:c2:a7); no
allocation failures, only rst:0x1 (POWERON).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:35:09 +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
Kaj Schittecat 8fff2811ed release-notes: beta_52 (RISKY - #179 storage rework, needs testing) beta_52 2026-07-29 17:08:50 +02: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 Schittecat 077bc12f24 chore: refresh Mesh America catalog for beta_50 2026-07-29 15:11:46 +02:00
Kaj Schittecat 0a51e7e504 release-notes: beta_51 beta_51 2026-07-29 14:32:47 +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
Tesso M Costa 61ed43bc40 touch: add "at a glance" idle-dim notification overlay
While unlocked but idle-dimmed to off, an incoming message (DND
permitting) briefly lights a plain black overlay with the sender/
channel name and message text, then auto-dims after a fixed window
without opening the chat or marking the message read. Board-agnostic,
distinct from the T-Deck's existing opt-in msgFlash.

extras_lat_28 (accented Latin/em-dash/ellipsis at 28px) moves from
Tanmatsu-only to every board, since the glance body needs that
coverage everywhere.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-28 09:36:36 -06:00
Dan Vybiral f2c1b73636 touch: tell the map when SD storage comes back
After a reinsert the chat store re-landed but the map stayed blank: the
tile layer was never told the storage changed. It keeps decoded tiles in
s_map_tiles across renders and only renders on pan / zoom / tab-open, so
a card that came back produced no reload; a boot with no card never
adopted a later-inserted one as the tile cache at all.

mapNoteStorageChanged() now runs from every mount-state transition
(wedge-recovery remount, 30 s reinsert watch, file-manager insert poll,
post-format, and both card-lost paths), right next to the existing
flushHistorySoon(). It adopts the card as the tile backend when this
boot resolved none, drops the decoded tiles (a swapped card holds
different content), clears the fetch dedup ring so downloads that failed
during the outage are retried instead of being remembered as in-flight
forever, and re-renders immediately when the map is the visible tab.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 15:28:25 +02:00
Dan Vybiral 5627c90bb3 touch: chat-store readouts show the save TIME, not an age
The home chip now carries the clock time of the last successful save
(HH:MM, 12/24h-aware, with a '-<N>D' suffix once it is a day or more
old) instead of the segment count and byte total, and the About page's
'last save' line switches from 'OK 12s ago' / 'FAIL xN (12s ago)' to the
same timestamp form. A clock time answers 'is my history safe right
now?' directly, and the day suffix stops a store that quietly stopped
saving yesterday from reading as fresh. Segment count, byte total and
the failure stage + errno stay on the About panel.

Backed by new wall-clock stamps taken alongside the existing millis
ones in uiMsgsWriteResult; they read 0 (rendered '--:--') when the
system clock wasn't set at save time, since no meaningful timestamp
exists then.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 14:07:27 +02:00
Dan Vybiral 41eb4a559f touch: close eleven crash/failure-path holes in the segmented store
A second adversarial review pass (11 confirmed findings, 2 critical) hit
the crash and failure paths the on-device happy path never touches. Six
root causes, fixed at the root rather than per-symptom:

1. Migration was not crash-atomic (CRITICAL). Chunks landed one rename
   at a time, so a power cut mid-migration left a valid oldest-first
   PREFIX that the loader preferred over the still-intact old file --
   permanently showing only the oldest few hundred messages. There is
   now a commit marker (/msgs/store.ok) written only after full
   verification: its absence means the segment set is provisional, and
   the loader wipes those segments and re-reads the old file. The 60 s
   migration retry on a sick card therefore no longer risks history.

2. Migration assumed a linear ring (CRITICAL) but its retry paths run
   mid-session, after appendMessage may have wrapped it -- producing
   segments in slot order (first_seq > last_seq, scrambled history that
   per-segment read-back could not detect) before deleting the old
   file. It now walks the ring chronologically, skips tombstones (which
   it used to persist as ghost records), and verification additionally
   requires strictly increasing seqs within and across segments.

3. A partially-failed append was retried by appending the same batch
   again, duplicating records (or misaligning every later record if the
   failure split one). Segments now carry a rewrite_open flag meaning
   "file content untrusted": the scheduler REPAIRS such a segment with
   a full rewrite (header + records, tmp+rename) before any append can
   target it, and the repair absorbs the unflushed tail.

4. Post-resync appends targeted deleted files (headerless segments) and
   could push a chunk's range past the 256-record gather cap, silently
   dropping records the watermark already called durable. Fixed by the
   same repair-first rule: retable marks every chunk rewrite_open.

5. Resync deleted every on-disk segment up front, so a same-card wedge
   recovery left the whole history RAM-only for minutes. It no longer
   deletes anything: same-key files are replaced by each repair's
   rename, and leftovers are swept only once the re-land is complete.

6. A stale worker descriptor could be committed twice after
   persistHistoryNow drained past a busy worker (double-counted totals,
   duplicate table entries), and a leaked redirty flag could make the
   drain re-compact one segment ~28 times and then report failure.
   uiHistWaitWorkerIdle now always drops the descriptor and the flag,
   marking the abandoned job's segment for repair instead. The drain
   also refuses to run at all while a worker is still writing -- with
   FF_FS_LOCK=0, removing or renaming a path under a stalled open
   handle can cross-link clusters and corrupt the volume.

Also: the loader applies a strictly-monotonic seq filter, so duplicate
or out-of-order records from any interrupted write are dropped at load
and the affected segment is rewritten; append commits credit their own
segment by key instead of blindly crediting the last table entry.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 12:29:41 +02:00
Dan Vybiral 8bb2b47cd7 touch: chat-store chip on the Commander legend row
Adds a compact store-status chip sharing the TX/RX legend row above the
home chart, parked on the right half of the chart width (fixed width +
right-aligned + CLIP, so it can never grow into the traffic counts at
any UI scale). Small font (g_font_12), not clickable — the legend still
owns this row's taps.

Shows a floppy glyph plus, in order of precedence: 'FAIL xN' in red
while saves are failing, 'migrating' in amber while an old-format store
hasn't converted yet, else the segment count and total bytes ('9s 512K'
/ '21s 1.1M') in the normal subdued colour. Refreshed independently of
the TX/RX chart so the big-screen scaled layout (which drops the chart)
still shows it, and re-flowed with the legend on Expansion-Kit boards.
The full diagnosis (backend, failure stage + errno) stays on the About
page's Chat store panel.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 11:30:14 +02:00
Dan Vybiral eeefa47fa2 touch: fix seven review findings in the segmented-store scheduler
An adversarial review pass over the refactor surfaced these; the first
is a silent-permanent-loss bug:

- CRITICAL: the finished-job check ran AFTER the failure branch had
  already reset s_hist_flush_ok, so a FAILED worker job read as ok and
  got committed — advancing the durability watermark over records that
  never reached disk. The completion check now runs strictly first, and
  a failed job just drops its descriptor (data stays pending).
- A message deleted while its APPEND job was in flight resurrected on
  reboot: segMarkSeqDirty skipped it (seq above the watermark = 'never
  reaches disk') but the in-flight job then put it on disk. After a
  committed append the ring's tombstones inside the newly-flushed range
  now get their compaction marks.
- segCommitJob cleared compact_dirty unconditionally, erasing marks for
  deletes that landed WHILE the compact job was writing (its snapshot
  predates them). A redirty flag keeps the segment dirty for a
  follow-up pass.
- The sync drain shared the worker's job descriptor, snapshot buffer
  and req flag: a worker stalled past the 9 s idle-wait cap could read
  a torn buffer, and the raised req let the worker steal and
  double-execute a job the drain was running inline. segBuildJob is now
  pure (fills a caller buffer + local SegJob); only the async path arms
  the worker statics, and the drain owns a separate buffer.
- An in-session FAT32 format erased all segments while the table (and
  watermark) still claimed them — old records permanently lost and the
  next append targeted a missing file. The format path now runs the
  same resync as a card swap (retable + re-land off-thread).
- uiSegAppendRecords with create=true used FILE_APPEND, so a stale
  same-key file (crash residue) got a second header mid-file; create
  now opens truncating.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 23:37:43 +02:00
Dan Vybiral 1f01a267dd touch: segmented-store integration + lifecycle fixes (phase 5)
- About 'Chat store' shows the segment count + writer-maintained byte
  total (zero file I/O at 1 Hz — the old size probe blocked on the FatFs
  volume lock behind a sick write) and flags a pending migration.
- uiDataEnsureDirs() runs at boot (before the loader scans) and on all
  three SD remount paths, so a fresh replacement card gets the segment
  dir immediately.
- sdEnsureMeshcomodFolders (post-FAT32-format) now also recreates
  /meshcomod and /meshcomod/msgs — an in-session format used to break
  every chat/telemetry write with ENOENT until a reboot.
- The boot SPIFFS->SD adoption creates /meshcomod/msgs so flat-named
  SPIFFS segments ('/msgs/seg_*.bin') copy onto the FAT card instead of
  being silently skipped (missing parent dir).
- sdRestoreApply ('Copy internal data to SD') persists chat history
  BEFORE the copy — it used to reboot without persisting, silently
  dropping everything since the last lazy flush.
- Dead s_ui_data_resolved removed; stale writer comments updated.

Builds verified: all five SD-relevant touch envs.

Per .notes/segmented-store-plan.md phase 5.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 23:03:37 +02:00
Dan Vybiral 2c7ea98d42 touch: segmented-store flush scheduler (phase 4)
The flush pipeline now moves one bounded JOB at a time instead of the
whole ring: an append batch into the active segment (a few hundred
bytes per message burst) or a one-segment compaction rewritten from the
RAM ring. The loop task builds and COMMITS jobs (single table writer);
the core-1 hist_flush worker only executes the armed job and reports —
same busy/req protocol, same failure ladder (retry backoff, warning
toast, hist_sync_after sync-fallback), all ported verbatim.

- Durability watermark (s_seg_flushed_seq): ring records above it are
  the pending-append backlog. Records deleted before ever flushing
  advance it with no write. Job snapshot buffer is one segment (~80 KB
  PSRAM) replacing the whole-ring 1.3 MB snapshot.
- markMsgsDirty clamps drop from 30 s/10 s (whole-ring-rewrite pacing)
  to 2 s SD / 5 s SPIFFS — the hard-cut loss window shrinks ~15x.
- Deletes (message/thread/purge/web) mark owning segments compact-dirty;
  eviction unlinks a segment only when ALL its records aged out (no
  per-message rewrite churn; a partially-evicted boundary segment is
  trimmed by the next boot's loader).
- saveMsgsToStorage is now the synchronous drain (shutdown/reboot/
  delete flows/sync-fallback): it retries a failed migration, honors
  resync, and never uses FILE_APPEND (a worker stalled past the 9 s
  idle wait could hold an append handle on the same file — the active
  segment is rewritten via the .tm2 namespace instead, rename-over
  wins with each candidate internally consistent).
- flushHistorySoon (remount paths) now sets s_seg_resync: the segment
  table is rebuilt from the ring, stale/foreign on-disk segments are
  removed, and the full history re-lands one segment at a time
  off-thread — replacing the old 'full-ring write fixes everything'
  property without the >30 s UI freeze.
- Retired: uiWriteMsgsFile, uiHistWorkerFlush, the ring-sized snapshot.

Builds verified: all five SD-relevant touch envs.

Per .notes/segmented-store-plan.md phase 4.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:59:14 +02:00
Dan Vybiral fb0346e249 touch: segmented-store loader + verify-then-delete migration (phase 3)
loadHistoryFromStorage now prefers segments over the split v6 file over
the legacy combined file. The segment loader reads oldest-first into the
ring, quarantines corrupt segments INDIVIDUALLY (one bad record used to
discard the entire history), truncates crash-ragged tails at the record
boundary, keeps the newest <= cap records when the disk set outgrows
this boot's ring (three-reversal rotation restores the linear-ring
invariant), reconciles the segment table (fully-dropped segments deleted,
the boundary segment marked compact-dirty), restores real seqs and seeds
the generator, and sweeps orphaned segment tmps.

When an old format supplied the ring, migrateRingToSegments() converts
it: segments written from the ring, every one read back and verified
(count + seq bounds) before the old msgs file is deleted; any failure
rolls the whole segment set back so the intact old file stays
authoritative next boot, and s_seg_store_ready stays false so the flush
scheduler (phase 4) retries the migration instead of writing segments
that would shadow it.

The companion message counter rides the threads-file header now (the
segments don't store it); the mesh re-asserts it per message anyway.

Builds all five envs (loader wired; writers wire in phase 4).

Per .notes/segmented-store-plan.md phase 3.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:48:05 +02:00
Dan Vybiral 3d487aefcf touch: segmented store core primitives (phase 2)
The file-layer building blocks for the segmented chat store, no callers
yet: UiSegHeader/UiSegMsg on-disk format (v6 record + appended seq,
self-describing rec_size), segment naming (/msgs/seg_<first_seq>.bin,
real subdir on FAT backends, legal flat name on SPIFFS), uiDataEnsureDirs,
chunked internal-RAM record writer, active-segment append (header-first
on create, retry-once after ensuring dirs, ragged-tail crash semantics),
one-segment compact (tmp+rename with the .tmp/.tm2 dual-namespace
discipline, n==0 removes the file), retired-segment delete, validated
open + size-agnostic record reader (File::size()-free — the Tanmatsu FFat
metadata layer lies), and segment discovery (dir listing on FAT, flat
prefix scan on SPIFFS, orphaned-tmp sweep, sorted ascending).

All ops report through uiMsgsWriteResult/uiMsgsWriteFail so the About
diagnostics, failure toasts, sync-fallback and SD-wedge arbitration work
unchanged; new stage codes a/c/d/s join chatSaveFailText.

Builds all five envs (expected unused-function warnings until the
scheduler wires these in).

Per .notes/segmented-store-plan.md phase 2.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:40:23 +02:00
Dan Vybiral be6e1f20ff touch: per-record sequence numbers for the segmented chat store (phase 1)
Every ring record gets a monotonic uint32 seq at append time, and the
loader backfills seqs in chronological order for records loaded from the
pre-segment formats (which carry none on disk), seeding the generator
past them. seq is the segmented store's record key: segment membership
is a [first_seq, last_seq] range, stable across per-segment compaction.

Deliberately a dedicated counter (_ui_seq_next), NOT _msgcount: the
companion protocol can overwrite _msgcount (msgRead), so it cannot be a
unique key.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:34:41 +02:00
Steve GlennerandCursor f5d8cfa390 touch: put bubble timestamp on top for DMs and rooms too
Extend the channel top-row timestamp layout to every bubble-style
thread so DMs and room messages match channels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:40:05 -07:00
Dan Vybiral fdaf48402c touch: sync-fallback for failing chat saves, with a configurable threshold
EIO persisted on the core-1 flush task, which narrows the failure to
shared-SPI interleave rather than core-0 starvation alone: the one
always-working configuration is the LOOP TASK writing (reboot persist)
-- when it writes, nothing else interleaves radio traffic between the
SD transactions on the shared bus. (The pager port notes flagged
exactly this: 'keep an eye on SD-write + RX overlap during history
flush'.)

The flush now self-heals: after N consecutive failed background writes
it retries synchronously on the loop task (one UI hitch per flush while
degraded), and a single success flips it back to the async task. N is a
new setting (Settings -> General -> 'Chat save fallback', dropdown
Off/1/2/3/5/8, default 2; Off = background retries only). Stored as
TouchCfg v40 trailing field hist_sync_after with getter/setter.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 18:37:46 +02:00
Steve Glenner 3b1a07dd93 Revert "touch: add Display Settings toggle for channel timestamp on top"
This reverts commit 31cb4c38a8.
2026-07-26 08:38:11 -07:00
Steve GlennerandCursor 31cb4c38a8 touch: add Display Settings toggle for channel timestamp on top
Make the shorter channel-bubble layout optional via a persisted
"Timestamp on top" switch (default ON), so classic footer layout remains available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 16:35:40 -07:00
Steve Glenner 8dc02a2b23 Move message timestamp to top of bubble to fit more messages on screen 2026-07-25 12:02:57 -07:00
Yaz 47ba9e010c ui-touch: invalidate LVGL img cache before freeing lock wallpaper buf
s_lock_wall_dsc is a static lv_img_dsc_t (stable address). LVGL's image
cache is keyed by that src pointer, not by dsc->data, so re-decoding a
custom lockscreen wallpaper into a fresh PSRAM buffer without first
invalidating the cache entry can leave LVGL drawing from a stale,
already-freed pointer on the next lockscreenShow(). The freed PSRAM is
commonly reclaimed soon after by the map's 128 KB tile buffers, so the
stale draw shows up as colorful RGB565 noise instead of the wallpaper --
exactly the symptom in #127. Only reboot 'fixed' it because the stale
LVGL cache entry otherwise persists indefinitely.

Same bug class already fixed for map tiles in freeMapTileSlot() (see
its comment); this applies the same lv_img_cache_invalidate_src() call
to the two places that free s_lock_wall: lockscreenShow() (re-decode)
and lockscreenHide() (teardown).

Fixes #127
2026-07-25 13:32:03 +02:00
Dan Vybiral 16f18a6a11 touch: run the chat-history flush on core 1; never unmount under a live flush
Two failure signatures from on-device testing, both now explained and
closed:

- EIO ('card I/O error') on the periodic autosave while the identical
  full-ring write succeeded from the reboot path: the flush rode the
  core-0 tile_fetch worker -- the Wi-Fi core -- where the driver's
  prio-23 tasks starve a prio-1 task for hundreds of ms. sd_diskio's
  busy-waits measure WALL time, so timeouts expired while the task
  simply wasn't scheduled, and healthy writes surfaced as EIO
  (intermittently: the write sometimes won the timing, matching the
  'succeeded after a few auto tries' observation). The flush now runs
  on its own small task pinned to core 1 at the loop task's priority --
  equal-priority time-slicing keeps the UI serviced during a write, and
  chat persistence has no Wi-Fi affinity to lose.

- EBADF ('bad file number') after a shrink: FatFs FR_INVALID_OBJECT --
  a handle whose volume was remounted underneath it. The file-manager
  removal poll could probe + SD.end() while the flush held its open
  temp file. All unmount paths now hold off under an in-flight flush
  (the FM poll joins the sdHealthTick guard).

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:44:05 +02:00
Dan Vybiral 75883a62a2 touch: spell out the chat-save failure in alerts and the About readout
Replaces the '@o e23' shorthand with a human-readable diagnosis
(chatSaveFailText): stage (open/header/write/rename) plus a plain-text
errno ('too many open files', 'card full', 'card I/O error', ...,
strerror fallback), e.g. 'open failed: too many open files (e23)'.
Shown as a second line on the About Chat-store section, on the repeated
autosave-failure alert, and on the reboot-time last-chance-save alert.
Diagnostic vocabulary stays English on purpose -- it is what ends up in
bug reports verbatim.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:30:58 +02:00
Dan Vybiral f22c9decc5 touch: raise SD max_files 3 -> 6; report chat-save failure stage + errno
Field evidence reframed the chat-save failures: a soft reboot wrote the
FULL 515 KB ring to the same card without trouble, while the periodic
worker autosave could not even create the file. That rules out a
size/bad-region card problem and points at contention: the SD VFS was
mounted with max_files=3 (set when the card only served the file
manager), but the card now serves telemetry log rewrites (2 handles),
battery log rewrites (2), the history flush, tile reads/writes, WAV
chimes and the About readout concurrently -- the flush's open("w") loses
that race with ENFILE while the card itself is fine, and at reboot
(everything quiesced) the same write sails through. All four mount
sites now use max_files=6 (~+1.7 KB while mounted).

To make the next such failure self-explaining on a device with no
readable serial, the About Chat-store line now includes the failure
stage and errno ('FAIL x3 @o e23'): o/h/b/r = open/header/body/rename;
errno 23/24 = VFS file table full, 28 = card full, 5 = card I/O error.
The header-write failure path also joins the failure accounting (it
returned early without counting before) and cleans up its temp file.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:19:51 +02:00