The prewarm at connect filled contacts and the two slot probes, so the
first Contacts or Trace open still queued a path-hash-mode round trip
behind whichever probe was walking the slot table at the time — and the
slot walks are measured in seconds.
Warm every cached fact, cheapest first: self-info and the routing mode
(single round trips, and what every list screen needs), then contacts,
then the two probes only the channel manager waits on. The mode gets a
lock like its siblings, so a screen opening beside the warm awaits the
read in flight instead of issuing a second one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.
So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.
A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.
The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.
Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact's last_advert is stamped by the advertising node's own clock;
a mis-set one lands in the future, where the age clamp read it as a
permanent "now" and the heard sort pinned it to the top — for as long
as the bogus stamp stayed ahead of the wall clock, since the firmware
only ever advances the field (its replay filter).
advert_time (models.py) is now THE converter for every last_advert
epoch — the live contact table and the cross-session store both pass
through it — and refuses a stamp more than 5 minutes ahead as unknown.
The devstate contacts fetch then fills the gap from our own recorded
receptions (stamped by our clock), so a refused contact we really do
hear shows an honest age, and one we never heard reads never.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>