The channel manager still felt uncached: its capacity read walked all 64
slots one-by-one on firmware that never rejects an out-of-range index
(the Waymarker), on every open, outside the session cache.
Two fixes for the two slot probes:
- read_channel_slots now stops after a run of MAX_CHANNELS consecutive
empty slots. The manager packs channels from slot 0 up, so an unbroken
empty run that long means every configured channel has been seen — safe
against within-capacity gaps, which can't reach that length. This cuts
every (re)probe on never-rejecting firmware from 64 reads to ~11, felt
after each channel edit (a mutation invalidates + reprobes).
- channel_capacity deliberately keeps walking to the firmware's rejection
(it must discover a larger rejecting firmware's real ceiling — the
empty-run guess would truncate an empty 12-slot device to 8). Its only
slow case is never-rejecting firmware, bounded instead by caching: a new
DeviceState.channel_capacity() holds it for the session (a hardware
constant, dropped only on reconnect) and prewarm warms it after login,
so the manager opens from cache like every other screen.
706 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Warm opens are instant, but the first open in a session still paid the
one-time reads (contacts, the channel-slot probe) in the navigation path —
so opening the menu after login, then the first Chat, read as two separate
stalls. Fold them into one quiet wait behind the menu instead.
DeviceState.prewarm() spawns a tracked background task that fetches contacts
then the channel slots (sequential — concurrent reads collide on the BLE
UART), best-effort and cancelled on reset/close. _resume_monitor kicks it off
once the link is up (only when connected — a deferred connect warms lazily on
first use). A getter reached before the warm finishes awaits the same
in-flight fetch, so it never doubles a read.
The channel probe itself still walks every slot on firmware that doesn't
reject out-of-range indices; bounding that is a separate change, deferred on
purpose — a long one-time poll behind the menu is fine for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.
DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>