The prefs schema-migration block runs on every SCHEMA_SENTINEL mismatch, and
the "turn Favourites on" step was ungated. Since each firmware release bumps
the sentinel, every update re-applied `home_pages_mask |= HP_FAVOURITES`,
clobbering a user who had hidden it. Gate it to the transition that added
Favourites (sentinel < 0xC0DE0002), matching the trail_units_idx migration.
The Map/Trail carousel page shipped with no visibility toggle: pageBit(MAP)
returned -1 so it was always visible with no Settings row. Add a proper "Map"
toggle (new HPB_MAP/HP_MAP bit, pageBit/bitToPage cases, HOME_MAP settings
row) plus a gated one-time migration (sentinel < 0xC0DE0018) that keeps it
visible for upgraders. Bump SCHEMA_SENTINEL to 0xC0DE0018; page_order[] stays
a literal 11 so the persisted layout is unchanged.
Also fix the fresh-install seed, which used a stale 0x01FF literal that left
Favourites hidden on new installs despite its "all pages visible" comment;
seed NodePrefs::HP_ALL so fresh installs match upgraders (Favourites + Map).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- release-notes v1.21: add Clock tools (alarm/timer/stopwatch) to What's new,
e-ink button responsiveness + double-tap debounce to Fixes, and the input
edge-capture/key-queue and hardware-timer ringtone notes to Under the hood.
- README: note clock tools on the Clock Screen entries.
- solo_ui_framework: document the hardware->handleInput path (mandatory
begin() per button, IRQ edge capture, key queue + coalesced redraw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single button press could surface as two CLICKs on the e-ink build, most
visibly as start+stop on the stopwatch. Two contact-bounce paths fed the
IRQ edge-capture machinery a phantom press/release pair:
- a bounce edge accepted just after a clean release was replayed as a real
press — ISR_DEBOUNCE_MS (5 ms) was too short for the joystick switch; raised
to 25 ms so the settling burst is swallowed.
- the live-pin self-heal reconciled prev against a single raw digitalRead,
which can sample a bouncing contact mid-flap and synthesise a transition.
It now acts only once the divergence has been stable for ISR_DEBOUNCE_MS, so
a momentary read can't inject a click; a genuinely lost edge still heals
(~25 ms later) so the button can't stick.
Both thresholds stay far below any human tap cadence (>100 ms), so rapid
multi-tap navigation still registers every tap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The joystick directions and Back were never begin()'d, so on the e-ink build
they never claimed a GPIOTE channel — edges landing during a blocking panel
refresh were lost and the IRQ-capture work didn't reach them. begin() them.
Even with edges captured, rapid taps of one direction replayed into a single
check() and collapsed into a double/triple-click event the navigation handler
ignores, and the loop only ever dispatched one key per render. So:
- MomentaryButton: for multiclick=false buttons emit one CLICK per completed
release (one per check() call) instead of collapsing — each tap stays a
discrete key. multiclick=true buttons (double/triple) are unchanged.
- UITask: add a key FIFO; drain each direction fully into it, apply the whole
queued burst, then redraw once. N taps captured during a refresh become N
navigation steps at the cost of a single refresh. Also fixes losing a key
when two buttons fire in the same loop iteration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clock Tools: one-shot alarm, countdown timer and stopwatch on the Clock
page, with the alarm/timer/ring engine in UITask (sync-robust absolute
alarm scheduling; millis-based timer/stopwatch).
Add a Clock Tools screen (Enter on the home Clock page) with three time
utilities, plus the engine that drives them from UITask::loop() so they
work regardless of the current screen / display state:
- Alarm: a single one-shot wake alarm. Persisted in NodePrefs as a local
time-of-day; UITask schedules it as an ABSOLUTE fire instant recomputed
from that time, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump
the clock) — small corrections still fire on time, a jump over the
target still fires (late, up to 6 h). Disarms after firing. A bell
icon signals an armed alarm on the clock face and the top status bar.
- Timer: a millis-based countdown (sync-immune), big HH:MM:SS readout,
rings even when off-screen.
- Stopwatch: millis-based, keeps running in the background.
Numeric fields use the shared framework DigitEditor (digit-by-digit,
min/max enforced). The alarm/timer/ring engine lives in UITask alongside
the locator/live-share engines (no screen-cast for time-critical logic).
E-ink: live readouts refresh coarsely (and on any key); timing is exact
regardless, and the countdown's buzzer fires on time. Rings override mute
and are dismissed by any key (auto-stop after 1 min). Cannot wake from a
full Shutdown (CPU/RAM powered down).
NodePrefs: +alarm_on/alarm_hour/alarm_min (fit existing tail padding,
sizeof unchanged 2488), SCHEMA_SENTINEL 0xC0DE0016 -> 0xC0DE0017,
DataStore read/clamp/write in lockstep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the DM + channel message-history rings from QuickMsgScreen into a
self-contained MessageHistory store, dedupe the notif/melody override
accessors, share the history row-frame / [+ send] button between the two
history lists, and fix the [+ send] row vanishing in room/DM history.
Hardware-validated before merge.
The DM/room and channel history lists drew each message box's selection frame
and the bottom-left [+ send] button with byte-identical inline code. Extract
two small static helpers (drawHistRowFrame, drawComposeButton) and route both
lists through them.
Only the parts that were already identical are unified — the divergent
per-type logic (sender parsing, ACK semantics, body handling) stays inline, so
this is behaviour-preserving. The DM compose button now also gets the channel
list's border (and its always-reset-to-LIGHT), folding in the earlier
[+ send] visibility fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the DM/room message history, the message loop leaves the ink DARK when the
last drawn box is the selected one, so the unselected [+ send] row printed
dark-on-dark and vanished. Most visible in room servers, where there's always
a selected message. Reset to LIGHT before drawing the row (matching the
channel-history version). Pre-existing bug, independent of the history-store
refactor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The eight per-contact/channel notification + melody accessors were four
near-identical copies of two storage shapes. Collapse them onto two
primitives:
- maskPairGet/Set — a 3-state packed in a (presence, variant) channel-index
bitmask pair. The notif and melody uses disagree on which state the variant
bit means, so the caller passes the state value for variant-set (v_set).
- prefTableGet/Set<Entry> — the {prefix[4], value} DM table find/update/clear/
insert/overwrite-slot-0 logic, templated over the entry struct + a member
pointer to its value field (DmNotifEntry::state / DmMelodyEntry::slot).
The eight accessors are now thin wrappers; behaviour identical (verified
state↔bit mappings). QuickMsgScreen 1738 -> 1715 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the two RAM history rings (channel + DM) out of the 1988-line
QuickMsgScreen into a self-contained MessageHistory.h component, mirroring
the WaypointsView extraction from TrailScreen. The store owns the ring
buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK +
auto-resend) and the per-channel unread counters; the screen keeps all
view state (selection, scroll, fullscreen readers, the unread "viewing
session" bookkeeping) and reaches entries through accessors.
Behaviour-preserving:
- The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are
file-scope in MessageHistory.h, so the phase machine still refers to them
unqualified — only data + storage logic moved.
- addChannelMsg keeps a thin screen forwarder that computes the "viewing"
flag (a phase fact the store can't see) and returns the ring pos;
afterSend uses armChannelRelay(pos, seq) instead of poking the ring.
- The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg,
markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread,
getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as
forwarders, so no caller changes.
QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards
build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Tools › Trail → Hold Enter → Track back (shown when the trail has ≥2
points). It reuses NavView but, instead of a single fixed target, snaps onto
the route at the nearest recorded breadcrumb and walks the points in reverse,
auto-advancing the target as each is reached (within 20 m). The header shows
points remaining ("Back: 12 pt" → "Trail start"); reaching the start toasts
"Back at start" and exits. Cancel leaves at any time.
Lives in WaypointsView as a new sub-mode; advancement runs in poll()
(forwarded from TrailScreen) so it tracks GPS independently of the render
cadence. An EtaTracker drives the approach/ETA line and is reset on each
breadcrumb advance to avoid a bogus closing-speed spike.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Mark avg" trail setting (Off/5/10/30 s). When set, Tools › Trail ›
Mark here samples the GPS fix once a second for the chosen window and stores
the mean position instead of one instantaneous fix — a steadier, more
accurate mark for a precise spot. A progress screen shows time left + sample
count; Cancel aborts; the window then opens the label keyboard as usual.
Off (default) keeps marking instant.
Sampling runs in WaypointsView::poll() (forwarded from TrailScreen::poll())
so it ticks on the main loop, independent of the slow e-ink render cadence;
int64 accumulators avoid overflow over 30 samples.
Persisted as NodePrefs::gps_avg_idx (schema 0xC0DE0016): struct field +
option table, DataStore rd/write/clamp in lockstep. sizeof unchanged (the
uint8_t fits existing tail padding) so the 2488 tripwire still holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three spots lagged the recent refactors:
- §8 + §10 still taught the inline `if (_dirty) { savePrefs(); _dirty=false; }`
pattern; now point at `_task->savePrefsIfDirty(_dirty)`.
- §1 wiring contract now notes the nullptr-init / setCurrScreen null-guard
fail-safe (steps 1/3/4 are compile-checked, only a missed `new` slips through).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every drawList/AccordionList row opened with the same hand-written
display.drawSelectionRow(0, y-1, width-reserve, lineStep-1, sel) line —
14 copies of the same geometry and magic offsets. Add a drawRowSelection(d,
y, sel, reserve) helper in icons.h next to drawList and route the canonical
sites through it (Bot/LiveShare/Locator/Repeater/Settings/Tools/Nearby/
QuickMsg/Waypoints).
Rows that intentionally differ (full-width DashboardConfig/QuickMsg, the
keyboard/ringtone grids) keep their explicit drawSelectionRow() call — the
helper is opt-in rather than baked into drawList, so legitimate variants
aren't forced into one geometry. Framework doc updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was
duplicated across 9 screens with subtle divergence: some cleared the flag
after saving, some left it set and relied on onShow() to reset. Replace all
12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves
once only if dirty and always clears the flag, so the "did we touch flash?"
answer lives in one place and the reset is consistent.
Edit sites still mark the flag manually (inherent to change tracking); only
the persist-on-exit boilerplate is centralised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding a screen touches 4 sites; 3 (member decl, gotoX decl, gotoX def) are
compile-checked, but a missed `new XScreen()` in begin() left the pointer
uninitialised and crashed at first navigation. Give every screen member an
in-class nullptr initialiser and bail early in setCurrScreen(nullptr) so the
mistake is an inert no-op instead of a null deref. Document the 4-site
registration contract on the member block.
A full registry table was considered and rejected: the named gotoXScreen()
methods are a depended-upon API (~30 call sites, menu dispatch + back-nav),
so a table would add an enum + indirection without removing them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the ad-hoc enter()/markClean() entry methods (which lived outside
the UIScreen interface and were invoked via casts from each gotoX) with a
virtual onShow() lifecycle hook called centrally by setCurrScreen().
This removes the "forgot to call enter() in a new navigator" footgun and
the unchecked cast smell: 12 navigators collapse to one-line
setCurrScreen(x) calls, and override enforces signature match. Two entries
that carry a parameter/variant keep an explicit typed call after
setCurrScreen(): RingtoneEditor::selectSlot(slot) and TrailScreen::showMapView().
Behaviour-preserving: only screens that previously had enter() get an
onShow() override; Splash/Home/QuickMsg/Diag keep no reset as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ui-new/*.h screens are header fragments compiled only as part of
UITask.cpp, in include order. Two implicit rules a contributor can trip on:
include order (a static inline helper / shared scratch is visible only to
later fragments) and single-TU-only (some fragments define external-linkage
symbols at file scope, e.g. NearbyScreen::FILTER_LABELS, so reusing one from a
second .cpp is a duplicate-symbol link error).
Spell both out in a contract comment at the inclusion block, and raise the
single-TU note to a callout in the framework guide. Comments only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The onContactRemoved/onChannelRemoved hooks already clear every NodePrefs
field keyed on a contact pubkey or channel index (verified complete), but the
convention was opt-in with no reminder — new per-entity state could silently
forget to register, the class of bug fixed earlier this session.
Add a CONTRACT comment above each hook listing what it covers and instructing
additions to land there, plus a terse [del->onContactRemoved] /
[del->onChannelRemoved] tag on each of the eight keyed fields in NodePrefs.h.
Now editing either side (struct field or hook) points at the other. Comments
only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NodePrefs is written/read field-by-field in DataStore::savePrefs/loadPrefsInt;
the on-disk format is the struct's field layout, with nothing checking that the
two hand-written sequences still match the struct. A forgotten read/write
silently misaligns every later field — the highest-blast-radius convention in
the codebase.
Add a static_assert on sizeof(NodePrefs): changing a data member trips it with
a checklist (sync save/load, clamp on load, bump SCHEMA_SENTINEL, update the
size). A manual checkpoint, not a proof, but it turns silent drift into a build
break. Size is variant-stable (all arrays sized by NodePrefs' own constants),
verified on both nRF52 boards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit pass against docs/design/solo_ui_framework.md, converging deviations:
- prev/next: replace hand-rolled (c == KEY_LEFT || c == KEY_PREV) with
keyIsPrev()/keyIsNext() in QuickMsgScreen, RingtoneEditor, TrailScreen,
FullscreenMsgView. Two were real rotary-encoder gaps, not just style:
NearbyScreen's filter and WaypointsView's hemisphere toggle used raw
KEY_LEFT/RIGHT with no encoder twin, so the encoder couldn't drive them.
- NearbyScreen's list right column used a manual setCursor(width -
getTextWidth(...)) instead of the existing drawTextRightAlign() helper
(which also UTF-8-translates) — now uses it, like Diagnostics/Repeater.
- RepeaterScreen hand-rolled the whole drawList skeleton (scroll clamp +
reserve + row loop + indicator); collapsed onto drawList().
No D-pad behaviour change; more uniform encoder support. Net -19 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the reusable building blocks the solo UI has grown — the UIScreen
model and screen wiring, DisplayDriver layout metrics, drawList/AccordionList,
the popup/keyboard/digit-editor/fullscreen/nav components, the geo + state-store
+ persistence helpers, mini-icons, input conventions, and the cross-cutting
gotchas (single-thread render, e-ink pacing, reference cleanup). Ends with a
worked "add a new Tools screen" example.
No code change; the helper layer is already well-factored (the recent dedup
passes closed the remaining gaps), so this captures it for contributors rather
than extracting more. Linked from the README docs table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidation pass over the message/nearby UI, no functional change beyond
one intentional display tweak:
- Age tags: NearbyScreen::fmtAge, the nearby list's inline column, and
QuickMsgScreen::fmtMsgAge each reimplemented the same s/m/h bucket ladder
on top of geo::fmtAgeShort. All now delegate to it (fmtMsgAge removed).
Visible effect: ages over 24h render as "Nd" instead of capped hours,
matching the Locator target picker which already used fmtAgeShort.
- Reply prefix: the "@[nick] " parse was duplicated in skipReplyPrefix() and
FullscreenMsgView::render(). Extracted to one msgReplyBody() helper (body,
plus optional addressee nick) — one place to handle its edge cases.
- LiveTrack: the expiry predicate was duplicated in expire()/isActive();
extracted to a private expired() helper.
- Removed a dead M_PI define (and unused <math.h>) in NearbyScreen.h, and a
comment pointing at a CODE_REVIEW.md that doesn't exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room is viewed through DM history, where each post is stored "Author:
text" and shown attributed to that author. The reply path, though, built
the @[nick] prefix from _sel_contact.name — the room server's own name —
so replying to someone's post in a room addressed the room, not the person.
New buildDmReplyPrefix() derives the addressee from the same author the
history shows (via dmDisplayParts): the post's author in a room, the
contact name in a 1:1 DM. It also stores the nick raw (UTF-8), like the
channel reply path, instead of running it through the lossy display
transliterator — the prefix is sent over the air verbatim, so a non-ASCII
nick was previously corrupted on the wire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
begin() claimed the trampoline slot and set _isr_slot before calling
attachInterrupt(), so a button that lost the GPIOTE race (only 8 channels,
shared with the radio's DIO1) would take the ISR branch forever while its
interrupt never fired. attachInterrupt() returns 0 when channels are
exhausted — only commit the slot on success, else leave _isr_slot = -1 so
check() uses the polling path. Combined with the live-pin reconcile, an
exhausted button degrades cleanly to plain polling instead of misbehaving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interrupt path trusted the replayed edge stream alone (btn = prev) and
never sampled the live pin. Any lost edge — buffer overflow, a debounce-
dropped settling edge, or a missed GPIOTE event — left prev diverged from
the hardware until the next captured edge, i.e. a stuck button. After
draining the queue, reconcile against digitalRead(): a no-op when the edge
stream already matches, a one-call self-heal when it doesn't.
Also remove dead code in applyTransition() (a local `event` that was always
BUTTON_EVENT_NONE, making its cancel-on-click branch unreachable) carried
over verbatim from the original inline logic. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On nRF52 the buzzer advanced notes by polling millis() >= _note_end_ms in
loop(). A blocking display refresh (e-ink endFrame) starves loop(), so a
note boundary that falls inside a refresh is serviced late — the note
plays long, or the next is skipped. The keypress-time 300 ms render delay
only masked the first note.
Advance notes from a hardware TIMER1 compare interrupt instead, scheduled
for each note's exact duration, so timing is independent of render cadence.
TIMER0 is the SoftDevice's; TIMER1 is free (tone() uses PWM2, nrfx TIMER1
driver is disabled). loop() becomes a no-op on nRF52; the UITask keypress
render-delay workaround is removed.
_disarmNoteTimer() clears the latched NVIC pending IRQ (not just the event)
on stop()/_nrfBegin(), so a note-advance latched just before a stop can't
fire spuriously — which would skip a new melody's first note or blip after
an explicit stop. Event read-backs flush the write buffer per the nRF52
event anomaly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DM, room and channel history always stamped a message with receipt time
(rtc_clock.getCurrentTime()), even though the sender's real timestamp was
already available (and, for DMs/rooms, already threaded through to
addDMMsg -- just never used for display). Live-received messages hid this
because receipt lags origination by only seconds, but a room-sync replay or
an offline-queued message held by a repeater can arrive long after it was
sent, so a burst of backlog messages all showed as "just now".
storeDMMsg() now prefers msg_ts (falling back to receipt time only when
unknown). addChannelMsg() gained a timestamp parameter, threaded from
onChannelMessageRecv() down through AbstractUITask/UITask, with the same
fallback. The DM dedup check and outgoing-message timestamps are unaffected
(they use msg_ts directly, already correct).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CMD_SET_CHANNEL clearing a slot (empty secret) left bot_channel_idx,
loc_share_channel_idx, and the per-channel melody bitmasks pointing at that
index. A new channel added later at the same slot would then silently
inherit the old one's bot target, Live Share target, or notification
melody. New onChannelRemoved() hook, mirroring onContactRemoved(), turns
the bot/Live Share channel target off (fail closed) and clears the melody
bits for that index.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
onContactOverwrite() (the contact-table-full LRU eviction path) deleted the
contact's blob and notified the companion app, but never called the new
onContactRemoved() cleanup -- so a Favourites Dial slot, Locator target, or
Live Share target could still go stale, just via the silent auto-evict path
instead of an explicit removal. This is likely the main real-world cause of
the "(gone)" tile the docs described, since auto-eviction happens far more
often than an explicit CMD_REMOVE_CONTACT.
Also sync the two docs that described the old (now wrong) behaviour:
Locator's "survives delete" claim, and Favourites Dial's "(gone) until
reassigned" claim.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CMD_SET_CHANNEL saves immediately after clearing a channel slot, and
loadChannels() reassigned indices 0,1,2... sequentially on every load,
skipping gaps. Removing a channel and rebooting would then silently shift
every later channel down a slot -- anything that remembers a channel by
index (Live Share's target, the bot's channel, per-channel melody bitmasks)
could end up pointing at the wrong channel.
channels3 records now carry their original slot index instead of padding,
so a removed channel just leaves a hole. One-time migration: if channels3
doesn't exist yet, load the legacy channels2 with its old sequential
semantics, then resave as channels3 so the fallback never runs again.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deleting a waypoint left the Locator pointed at coordinates that no longer
existed (it's a coordinate snapshot, so nothing noticed). Removing a contact
was worse: nothing cleared its favourite slot, its Locator/Live Share target,
or its per-contact mute/melody entry, so all four kept referencing a pubkey
that no longer resolved to anything.
- WaypointsView's Delete now calls UITask::clearTargetIfWaypoint() first.
- New AbstractUITask::onContactRemoved() hook, called from MyMesh.cpp's
CMD_REMOVE_CONTACT handler, clears the favourite slot, the Locator target,
and dm_notif/dm_melody entries for that pubkey. Live Share's DM target
turns auto-share off instead of guessing a new recipient.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Starting trail recording with GPS switched off used to call setActive(true)
immediately -- the session timer ran while the screen sat on "Waiting for
GPS fix" forever and recorded nothing, with no hint that GPS was the
problem. Choosing "Start tracking" now opens a "GPS is off" confirmation
(Enable GPS & start / Cancel); confirming enables GPS and starts the
session. Behaviour is unchanged when GPS is already on.
Gated on a new UITask::hasGPS() so the prompt only appears on boards that
expose a toggleable GPS, not where GPS is simply absent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Rooms — logging in" section and a room context-menu entry to the
Messages screen docs: auto password prompt, passwords remembered across
reboots, self-healing on failure, re-login via Login…, app-entered
passwords also saved, and the ASCII-only keyboard caveat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The on-device login path already saved room passwords to /room_pw; do the
same for logins issued by the phone/USB app (CMD_SEND_LOGIN). The password
is stashed when the login is sent and persisted once the server confirms,
gated to ADV_TYPE_ROOM contacts; a failed login forgets any stale saved
password, mirroring the on-device path. This lets the device post to a
room standalone after a reboot without re-prompting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CMD_REMOVE_CONTACT already drops the contact's advert blob; also drop any
saved /room_pw login for that key so a deleted room doesn't leave an
orphaned (and now useless) password behind until FIFO eviction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Log in to a room server from the device UI with no phone app: picking a
room prompts for its password (blank allowed for open rooms), and a
context-menu "Login..." allows re-login. Successful passwords are
persisted to a dedicated /room_pw file so a previously-used room logs
back in after reboot without retyping; a failed login forgets the
(now-stale) saved password so the next attempt prompts again.
The room-password file is written via a temp file + atomic rename
(new DataStore::commitFile helper), matching the crash-safety of
contacts/channels persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
E-ink panel refreshes block the main loop for 500ms-3.6s (GxEPD2's
display.display() waits on the BUSY pin), and MomentaryButton::check()
only ever samples the live pin level once per loop iteration. A full
press+release that lands entirely inside a refresh is never sampled,
not just delayed - users on WioTracker L1 Eink were losing taps.
Add an opt-in ISR path (BUTTON_USE_INTERRUPTS) that latches each edge
with its own timestamp into a small ring buffer from attachInterrupt(),
independent of what the main loop is doing. check() replays buffered
edges through the existing transition/multi-click logic afterwards, so
click-duration and multi-click-window math still uses the edge's actual
capture time rather than "now".
Scoped to wio-tracker-l1-eink only for now: it's the one board this was
reported against and the only GxEPDDisplay-class variant that currently
builds clean on main. OLED boards refresh in ~20-30ms so polling already
catches every press; analog-button boards (rak4631/rak3401) can't use
attachInterrupt(CHANGE) at all.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Squash merge of feat/location-beacon-alerts-autopause (v1.21).
Features:
- Live Location Sharing — broadcast position as movement-gated [LOC]
messages to a channel or contact; live shares show as map pins with
distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is
parsed in DMs, channel messages and room messages; DM shares name the
sender.
- Locator (geofence) — arm a geofence around a saved waypoint or a person
(live [LOC] or last-known position), alert on arrive/leave or near/far,
with an optional homing beeper (gated to arrive/both modes). Arm from
Tools > Locator, Nearby Nodes, or Waypoints; target picker lists
favourites first, clearable via a "None" entry. Active target is drawn
as a flag on the map.
- One active target shared across Locator / Navigate / Map via a single
resolver (resolvePersonPos / activeTargetPos) that prefers a live
[LOC] share over the last-advertised GPS fix.
- Follow live contacts — Navigate to a live-sharing contact follows them
as they move and adds an ETA line; quick-share your own position from
the Map.
- Map & status-bar upgrades — home mini-map gets a north marker, scale
tick, and a connected trail line (was disconnected dots); status line
shows tracked-node count, an arrow + distance to the active Locator/Nav
target (falling back to the nearest live-tracked contact); GPS fix icon
in the top status bar, shown only on GPS boards with GPS enabled.
- Trail auto-pause — recording freezes on stops (banking elapsed time,
breaking the map line across the idle gap) and resumes on movement
without ending the session.
- Streaming trail simplification — GPS points are simplified in-stream
via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight
runs collapse to their endpoints, curves stay bounded to the Min-dist
tolerance, so the 512-point buffer covers a far longer route than a
flat point budget would suggest.
- Collapsible Tools (Location / Comms / System sections, fold-in-place
like Settings) and page-indicator icons on the home carousel.
- Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon
digit by digit.
Fixes:
- Critical: low-heap hang and contact loss on RAM-tight builds. Halved
message-history scrollback rings (recovering ~14 KB free heap) and
made contacts/channels/prefs persistence atomic (temp file + rename),
so an interrupted save can no longer corrupt or wipe the store.
- Serial.write() bounded so a stalled USB host can't hang the device.
- Nearby Nodes: live [LOC] senders respect the type filter, sort by
shared position, and the list refreshes so live shares bubble up.
- Map: live contacts are labelled before waypoints.
- GPS status icon hidden when GPS is off in Settings.
- Splash screen no longer truncates a pre-release tag's own dash (e.g.
v1.21-rc1) when stripping the build's commit-hash suffix.
- Null-guarded the Locator target picker; clamped loc-share channel
index on load.
Under the hood:
- -Os size optimisation on the e-ink and GAT562 30S solo envs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
constrain() is a min/max macro and NaN compares false against both bounds,
so a corrupted or layout-shifted prefs file that decodes a float field as
NaN/inf would pass it straight through to setParams() and could hang the
radio at boot (stuck after "Loading...", recoverable only by erasing flash).
Reset freq/bw/airtime_factor/rx_delay_base to safe defaults before the
existing constrain() so a non-finite value can never reach the driver.
Note: a defensive hardening of the boot path, not a confirmed root cause for
the reported brick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Incoming room-server posts fired the notification and reached the app via
the offline queue, but never appeared when the room was opened directly on
the device: the on-device history (addDMMsg) was gated to ADV_TYPE_CHAT only,
while rooms (ADV_TYPE_ROOM) share that same history list (keyed by the
server's pubkey). Include ADV_TYPE_ROOM so room posts are stored too.
A room carries many guests, so prefix each stored post with its author —
resolved from the signed message's sender pubkey prefix (fallback: short
hex) — and have the DM history view split "Sender: text" for room contacts
so each line is attributed to the guest who wrote it, instead of showing the
room server's name for every message.
Builds verified: WioTrackerL1 + GAT562 30S solo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the open items from the FEATURES.md audit backlog:
- saveChannels() now skips unused slots (all-zero secret) instead of
writing all 40 every time, so /channels2 holds only configured channels
(was always ~2.7 KB) and wears the flash less. loadChannels() already
compacted empties on read, so the loaded result is unchanged.
- findChannelIdx() returns -1 for an all-zero secret, so a corrupted/empty
channel can't match an unused all-zero slot and misroute messages.
- TrailStore gains a static_assert that CAPACITY fits the uint16_t save
header count, failing the build instead of silently truncating.
Also re-classify two audit items verified to be non-issues in current
code: the bot strstr "199-char" truncation (BOT_SCRATCH=200 >= MAX_TEXT_LEN
=160, unreachable) and the MSG_PICK reply title (rlen clamped to 20, so
title[24] never overflows).
Verified: WioTrackerL1_companion_solo_dual builds clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The manual Freq/SF/BW/CR editing was duplicated near-verbatim between
Settings › Radio and Tools › Repeater — same digit-by-digit Freq editor,
same SF/BW/CR step bounds, differing only in the target fields and the
apply call. Extract RadioParamsEditor (freq DigitEditor + static SF/BW/CR
steppers), with no dependency on UITask or the radio driver: the screen
passes the freq bounds in, the same way RadioPresetPicker stays decoupled.
Completes the preset-picker consolidation.
Behaviour preserved: a step at the range limit changes nothing and isn't
consumed (falls through), as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isValidRepeaterProfile() hard-coded the 150-960 MHz SX1262 range, which
would wrongly reject legal frequencies on any other chip. Take the freq
bounds as parameters and pass radio_driver.getFreqBounds() at both call
sites (repeaterProfileValid() and the load-time migration), so the chip's
own validated range is the single source of truth. DataStore.cpp gains a
target.h include for radio_driver (declared extern there).
DigitEditor::render() now zero-pads the integer part to int_digits: the
cursor addresses place values (100/10/1/0.1…), so a value with fewer
integer digits than int_digits would shift every glyph and misplace the
highlight. No visual change for the frequency field (always 3 digits).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the repeater filter knobs from "politeness" to "forwarding
filters" across code comments, FEATURES.md and the docs, and correct
references that hadn't followed the controls when they moved from
Settings › Radio to the dedicated Tools › Repeater screen.
Comment/doc accuracy fixes:
- NodePrefs: user_radio_presets are now written by both Settings and
Repeater (shared picker), not Settings only.
- DiagnosticsScreen: class header listed only some rows; spell out the
full set (forwarded, signal, pool/queue, error flags, reset gesture).
- RepeaterScreen header no longer claims to show live forwarding stats
(they live on Diagnostics).
- tools_screen.md: drop the stale paragraph claiming the Repeater screen
shows live stats at the bottom — it is config-only.
Comments/docs only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings › Radio and Tools › Repeater had near-identical preset-picker
logic (name/list-index lookup, save/delete, popup build, selection
handling) plus their own copy of the nearest-bandwidth search — only the
target fields (companion params vs. dedicated repeater profile) and the
apply call differed.
Extract the shared logic into RadioPresetPicker (a Target of field
pointers + a Result the screen acts on), with no dependency on UITask so
each screen keeps its own apply/dirty/keyboard/alert handling. Move the
nearest-bandwidth search to nearestBwIndex() in RadioPresets.h. Removes
~6 duplicated methods and 4 state fields from each screen.
Verified: WioTrackerL1_companion_solo_dual builds clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the text ^/v scroll arrows with a font-scaling indicator drawn in
icons.h: a proportional thumb between fixed up/down triangle mini-icon caps.
drawScrollIndicator() lives in a ~5px right-edge column and scales via
miniIconScale (1x OLED, 2x landscape e-ink).
- Mini-icons: ICON_SCROLL_UP/DOWN; miniIconDrawTop (exact placement) and
miniIconDrawHalo (shape-hugging 1px DARK halo, clip_top to spare the header
separator) so caps stay visible on the LIGHT selection bar without a box.
- Thumb is 3*s wide so it centres on the triangle column; caps are static end
markers (always drawn) so neither vanishes at the extremes.
- scrollIndicatorReserve(): right-edge gutter (0 when the list fits). Content,
the selection bar and the message-history bubbles are all narrowed by it so
nothing renders under the scrollbar. Portrait e-ink wrap width subtracts the
reserve in both the box-height pass and render so wrapped text can't spill.
- Drop the redundant ">" selection marker (the highlight bar already shows
selection) and shift rows to x=2, reclaiming left-edge space.
- Wire the indicator into every scrollable list (Bot, Settings, Nearby, Tools,
Trail, QuickMsg lists + DM/channel history) and add one to WaypointsView,
which scrolled with no indicator before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reply-bot overhaul on top of the trigger/reply auto-reply:
Robustness
- single botTriggerMatches() shared by DM/channel paths; named constants
(BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers.
- per-contact DM throttle (8-entry ring) so a second sender isn't starved
while one contact is on cooldown.
- channel anti-loop: skip only when an incoming message equals our own reply
(was: any reply containing the trigger word — which silently killed channel
replies for the common "trigger word in reply" setup).
Features
- away / reply-to-all: a lone "*" trigger matches every message.
- independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works
on the channel too, bounded by cooldown + echo guard.
- query commands: a DM or monitored-channel message is scanned for "!word"
tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one
combined reply. DM = per-contact throttle, ignores quiet hours; channel =
broadcast, per-channel cooldown, respects quiet hours. !hops uses
getPathHashCount() (0 = direct).
- quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies.
- reply counter shown in the BotScreen header.
UI / storage
- BotScreen: 9 rows, scrolling list (no more cramming), field I/O via
fieldBuf()/fieldCap().
- prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch
persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration
(bot_trigger_ch seeded from bot_trigger on upgrade).
- docs: tools_screen bot section rewritten; FEATURES.md refreshed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Unify on the shared keyboard: WaypointsView drives UITask::keyboard()
instead of its own instance; openKb() clears {loc}/{time} for literal
fields (labels, coordinates). Reclaims the duplicate widget from the heap.
- Icon glyphs on the special row: caps ⇧, space ⎵ (two halves), delete ⌫,
OK ✓ (reuses ICON_CHECK); {} and the page toggle stay text.
- Second character page (symbols), toggled by a "#@"/"abc" key. Tidy the
letters page: drop the duplicate grid space, add the comma, group
punctuation as . , ! ?
- Harden the preview buffers (KB_PREVIEW_CAP) against very wide displays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- FEATURES audit: refresh references the Nearby/Trail refactors made stale —
M4 (renderDiscoverDetail → renderScanDetail), L1 (SNR %.1f now in scan detail
+ ping; list cards show RSSI), Trail grid (round-step renderGrid + MIN_GRID_PX).
- MyMesh: drop the obsolete `// TODO: add expected ACK to table` — the code
right below already populates expected_ack_table.
- PopupMenu: render() is now the single source of truth for _scroll, clamping
the selection into view from the live-height cap, so handleInput() only moves
_sel and never depends on a stale _cap (M3). Behaviour-preserving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- tools_screen (Nearby): drop the removed "Filter…" Options action; filtering
is LEFT/RIGHT on the list only. Sort is adjusted in place via LEFT/RIGHT on
its row, stored-source only; filter/sort persist across re-entry.
- nearby_redesign / trail_redesign: mark Status implemented (branches merged);
record the Nearby deviations from the proposal.
- FEATURES: DM delivery status moved from idea to ✅ shipped, with deltas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author mini-icons as ASCII-art rows packed to bytes at compile time via
constexpr packRow(), so the glyph shape is visible in source while the
binary holds only the packed bytes (strings never reach flash, identical
to the previous hand-written hex).
Bundle each icon's width/height with its data through MiniIcon and the
MINI_ICON(name, width, ...) macro, so draw sites carry no magic numbers:
miniIconDraw(d, x, y, ICON_CHECK). Also add an unused-by-design skeleton
for full-size page icons (≤32 px): packRow32 / BigIcon / BIG_ICON /
bigIconDraw, for authoring a future big glyph the same readable way.
Existing XBM bitmaps left untouched.
Flash size unchanged; verified no ASCII-art strings reach the .elf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the many scrollable filter/sort categories and inconsistent popups
with a single list / detail / action-menu path over two sources (stored
contacts and live discover scan). Filter (type) and sort are independent
axes: LEFT/RIGHT cycle the type filter, the action menu's Sort row is
adjusted in-place with LEFT/RIGHT (not Enter). The active filter is shown
in the SCAN title and empty-list messages distinguish "no contacts" from
"filtered out". Filter/sort persist across re-entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tidies the Trail screen end to end, with no functional change beyond the
intended UX cleanups. Squashed from refactor/trail-screen.
Popup:
- Two-level action menu (Hold Enter): short main menu (Start/Stop, Mark
here, Waypoints…, Trail file…, Settings…) plus "Trail file…" and
"Settings…" submenus, replacing one flat ~12-item list that mixed
settings and actions. One interaction pattern per level; Reset lives in
Trail file…, away from a stray Enter. Settings submenu is view-aware
(Grid only on Map, Readout only on Summary).
Map:
- MapProjection (geo→pixel) built once and shared by the map, grid and
marker drawing — removes the duplicated projection math and renderGrid's
11 scalar params.
- renderMap split into computeBounds() and drawMarkers() helpers.
Grid:
- Square cells fitted to the frame: the shorter side is divided into a
whole number of equal cells (grid touches that pair of borders), and the
longer side centres the whole cells that fit — square, symmetric, no
one-sided drift. Drawn as one dot per intersection (legible on OLED).
Waypoints:
- Extracted the whole waypoint management UI (list / navigate / add-by-
coords / mark / rename / delete / send) into a self-contained
WaypointsView component that TrailScreen owns and delegates to.
- Dropped the redundant bulk "Clear all" (per-waypoint Delete covers it).
Docs updated (tools_screen.md) + design rationale (docs/design/
trail_redesign.md). Builds clean on solo envs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end delivery indicators on the device UI, drawn next to outgoing
messages and auto-scaled to the font (legible on landscape e-ink).
DM (and room servers, which share the DM path):
- Pending / delivered / failed marker driven by the real end-to-end ACK.
Pending shows a row of dots — one per send — so auto-resend progress is
visible before it resolves to ✓ / ✗.
- Auto-resend: new pref dm_resend_count (0-5, default 2), Settings ›
Messages › Resend. A pending DM whose ACK times out is re-sent (reusing
the original timestamp) until resends run out, then ✗. Driven from
UITask::loop so it completes in the background, independent of screen.
- Incoming dedup: a retry reuses the sender timestamp + text but carries a
fresh packet hash, so addDMMsg drops copies matching prefix+ts+text.
Channels (flood, no recipient ACK):
- ✓ only once a repeater echo confirms the send was relayed into the mesh;
no echo is normal, not a failure (no pending/fail shown). A small ring
tracks a burst of sends so each matches its echo. Receive-path hashing is
gated so the hot flood path is untouched when idle.
Shared:
- Markers shown in both the history list and the fullscreen message view.
- Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale);
adding a new status icon is a bitmap plus one draw call.
No changes to the upstream mesh library (src/).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Persist.h: shared writeHeader()/readHeader() for the magic+version+count
on-disk header; Waypoint and Trail snapshots now share one implementation
(byte layout unchanged; each keeps its own count clamp/reject + extra fields).
- TrailScreen: pushAction() guards _act_map / PopupMenu capacity, so adding a
new action can't silently overrun the fixed array.
- PopupMenu: add count()/setSelected() accessors; replace the remaining direct
_sel/_count field pokes in NearbyScreen, TrailScreen and QuickMsgScreen.
- Trail summary: unit-suffixed time ("1h 05m" / "5m 03s") so hours can't be
misread as minutes.
- Trail::haversineMeters delegates to geo::haversineKm — single Haversine
implementation (also trims a little flash).
Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull repeated UI idioms into DisplayDriver and remove duplicated logic:
- DisplayDriver: add drawScrollArrows() and drawInvertedHeader(); replaces 11
copy-pasted scroll-arrow blocks across 5 screens and 3 inverted title-bar
blocks (NearbyScreen x2, NavView). Standardises the detail-view separator.
- useImperial(): single source on UITask; NearbyScreen/TrailScreen delegate
instead of each re-reading NodePrefs.units_imperial.
- NearbyScreen: extract saveSelectedWaypoint() (two identical blocks -> one);
drop the redundant local label buffer (WaypointStore truncates).
- UITask::shutdown(): power GPS off through LocationProvider::stop() instead of
poking PIN_GPS_EN directly — centralises enable/reset-pin + active-level logic.
Net -58 lines. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo
builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the Waypoint::readFrom() hardening — reject a truncated trail
snapshot instead of proceeding with garbage header fields. Consistency
with the waypoint store's persistence path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UITask::shutdown(): power GPS off using !PIN_GPS_EN_ACTIVE instead of hardcoded
LOW, matching MicroNMEALocationProvider::stop() (still LOW on current active-high
devices, but correct for a future active-low GPS).
- buzzer _noteFreq(): clamp octave to 8 instead of 7 so the parser's accepted range
(4-8) is fully consumed; previously an octave-8 digit was clamped away and could
leak into the stream as the next note's duration.
- Waypoint::readFrom(): check read() return for version/reserved/count header bytes
so a truncated file is rejected instead of using garbage count.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NRF52 GPIO pins retain their last state in SYSTEMOFF. If GPS was active,
PIN_GPS_EN stays HIGH and the module keeps drawing ~20mA. gps_enabled is
already persisted to flash so applyGpsPrefs() restores the correct state
on next boot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trigger is now stored as typed (e.g. "Ping" stays "Ping").
Case-insensitive matching is preserved by lowercasing both
the trigger and incoming message at comparison time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UITask.onBLEDisconnected() sets _next_refresh=0 so the BLE icon
updates instantly instead of waiting for the next polling cycle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add build-solo-firmwares.yml: auto-detects all _solo_dual envs from platformio.ini, builds in parallel, publishes uf2 files to draft release
- Remove build-wio-tracker-l1-firmwares.yml (replaced by solo workflow)
- Add WioTrackerL1_companion_solo_dual and WioTrackerL1Eink_companion_solo_dual envs
- Wire build-solo-firmwares command in build.sh using _solo_dual suffix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>