ensurePageOrderInit returned early after inserting FAVOURITES without
checking whether TOOLS/QUICK_MSG were present. Old page_order saves that
predated those pages had CLOCK+FAVOURITES but no Messages entry, so
movePageInOrder always found cur==-1 and silently did nothing.
Now recount valid entries after any insertion, then walk the full list of
required page bits and append any that are absent (if room exists).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four findings from a code review pass on the Trail feature:
- showAlert overlay was gated to `curr == home`, so feedback like
"Tracking started", "Trail saved", "GPX N B (USB)" never rendered on
the Trail screen (or QuickMsg / Settings / Auto-Advert, for that
matter). Drop the gate — the overlay shows on whichever screen the
caller invoked from.
- TrailStore::writeTo only verified the first write; subsequent point
writes could partially fail (full filesystem, mid-write power loss)
and the method would still return true. Check every write return
value so handleSave's "Trail saved" only fires on actual success.
- gpxPoint: clamp snprintf's intended-length return against the buffer
size and skip the point if gmtime returns null. Avoids any chance of
out.write reading past buf[120].
- TrailScreen::_act_map sized exactly to today's 8 actions — bump to 12
so adding another popup item later doesn't cause an OOB write into
the surrounding members.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adding the public DataStore::openWrite(const char*) member shadowed
the file-static openWrite(FILESYSTEM*, const char*) helper inside
DataStore.cpp's method bodies, so every internal call broke ("no
matching function" with two args). Qualify each internal call with
::openWrite so name lookup picks the static helper. The new public
member's own implementation already does that.
Trail.h's gpxPoint template called gmtime unqualified; ADL couldn't
find it from the template definition because no argument was
template-dependent. Include <time.h> and call ::gmtime — both passes
the dependency rule and avoids the -fpermissive-only acceptance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an "Export saved" item in the action popup (shown when /trail
exists). Streams GPX directly from the saved file to USB Serial,
skipping the RAM ring entirely — useful for dumping an old recording
after reboot when the live ring is empty (or busy with another
recording you don't want to overwrite via Load).
Refactors exportGpx into three helpers (gpxHeader, gpxPoint, gpxFooter)
shared by the RAM and file paths so the markup stays consistent. The
file path validates the same TRAL magic + version it accepts on Load.
handleExport and the new handleExportSaved both route through
showExportAlert so the "GPX N B (USB)" / "GPX N B - disc. app" hint
keeps reflecting the BLE-app-collision state described last time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocking the export when BLE isn't connected also blocked the no-app
case (user attached only a terminal). Dump unconditionally and let the
confirm alert tell the user what just happened:
- BLE connected → "GPX N B (USB)" — USB receive is parked, no collision
- BLE absent → "GPX N B - disc. app" — reminder that the same USB
pipe the app would use just carried raw XML, so
reconnect/restart the app if you had one running
User can also reach the "no app, terminal-only" workflow naturally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Wio L1 DualSerialInterface multiplexes BLE + USB on the same frame
protocol: when BLE is connected, the USB receive path is ignored, so we
can safely push raw GPX bytes to USB without confusing the companion
app. When BLE is *not* connected the app is talking over USB itself —
the dump would land mid-frame and the app would parse garbage.
Block the export when hasConnection() (BLE link to the app) is false
and show "Connect BLE or disconnect app". When it's true the dump runs
and the confirm alert reads "GPX N B over USB" so the user knows which
pipe to read from.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-slot persistence as agreed. Action popup grows three entries
(conditional on what makes sense for the current state):
- "Save trail" — writes the current RAM ring to /trail in LittleFS.
- "Load trail" — only shown when /trail exists; replaces the live ring
with the snapshot (any active session ends first).
- "Export GPX" — dumps the trail as a minimal GPX 1.1 document over
Serial so the user can capture it from a USB host.
Plumbing:
- TrailStore gains writeTo / readFrom (template on the file handle, so
the platform-specific File flavour stays out of Trail.h) plus
exportGpx(Stream&) for the GPX serializer. Header carries magic
"TRAL", a version byte, point count, and the accumulated active time
so elapsed survives the round trip.
- DataStore::openWrite is exposed publicly so callers outside DataStore
don't have to duplicate the platform open-mode dance.
- MyMesh gains a public getDataStore() accessor.
After Load, _pending_seg_break is set so any subsequent Start opens a
new segment instead of joining the loaded last point.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The hint row plus its separator ate ~12 px on OLED. View counter moves
into the title bar ("TRAIL 1/3", "TRAIL MAP 2/3", "TRAIL LIST 3/3") so
the same context is available without a dedicated row, and the freed
space goes to whatever the view is rendering.
Short Enter now never toggles tracking. Both start and stop are only
reachable through the Hold-Enter popup — symmetric, no stray-tap risk.
Short Enter on the screen just shows "Hold Enter for menu" so the user
who tapped finds out where to look.
Available area for Summary / List recomputed without the hint
reservation (avail = height − y0 − 2); map's bottom edge moves down to
height − 2 too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per feedback the dedicated Config view added too much navigation. Roll
back to three views (Summary, Map, List) cyclable with LEFT/RIGHT, and
move every knob and action into the Hold-Enter (KEY_CONTEXT_MENU)
popup:
Min dist: <value> ← LEFT/RIGHT cycles, popup stays open
Units: <value> ← same
Start/Stop tracking ← Enter executes, popup closes
Reset trail ← Enter executes, only listed when not empty
Short Enter only starts a stopped trail; on an active trail it shows
"Hold Enter for menu" instead of stopping. Stop is therefore reachable
only through the popup, so a stray tap can never end a recording.
PopupMenu lacks LEFT/RIGHT semantics, so the screen intercepts those
keys when the focused row is a settings row, refreshes the label
in-place (same buffer pointer the menu already holds), and keeps the
popup open. Enter on a settings row re-opens the popup with focus
preserved on that row so the user can keep cycling with Enter too if
they prefer. Settings edits batch in _cfg_dirty and commit once on
popup close, Start, or Back.
Hint line shows current view N/3, the short-Enter action ("start" or
"act" depending on state), and a [Hold]menu reminder. Separator above
the hint keeps the row clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
User feedback drove three changes:
1. Browsing after stop. The old V_CONFIG-snap pinned the user to the
settings screen when tracking ended; you could see the map only while
active. Drop the snap: Summary/Map/List are reachable from Config
and from each other at any time, LEFT/RIGHT cycles all four. Enter
from any view toggles tracking; the alert reads "Tracking
started/stopped" / "Waiting for GPS fix" as before. Hint shows
`<>N/4` so the user knows which view they're on.
2. Hint overlap. A 1-px horizontal separator sits two pixels above the
hint line; Summary, List and Map are sized to leave that band clear
(`avail` shrinks by an extra 2 px; the map's bottom moves up by 2).
No more text touching the hint on OLED.
3. KEY_CONTEXT_MENU (Hold Enter) splits off from KEY_CANCEL and now
opens an action popup instead of going back to Tools. Cancel keeps
the back-to-Tools role. First action available: "Reset trail" (only
when the ring isn't empty). Phase 4 will add Save / Load / Export
GPX to the same menu.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The projection is already north-up (positive latitude maps to smaller
y), but until now nothing on the screen confirmed which way is which.
Add a tiny 5×7 arrowhead + shaft with an "N" label above, anchored in
the top-right of the map area. Drawn after the polyline so it sits
above any path that crosses the corner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The badge was rendered at cy + 1 while the name centered around cy + ~8;
on OLED with line_h = 8 the two ended up touching, and the name's
drawTextEllipsized used the full cell width so any "..." would land
under the digit anyway.
Compute the badge width up-front, subtract it (plus a 3 px gap) from
the name's max width, and render both on the same baseline (name_y).
The name now shortens to "Nam…" before the badge instead of running
through it. E-ink with its wider tiles already had room, so this is a
strict improvement there too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Second row in the Config view: UP/DOWN moves focus between Min dist
and Units, LEFT/RIGHT cycles the focused row's value. Units propagates
to the Summary view, which now shows either "Avg: N km/h" / "N mph"
or "Pace: M:SS /km" / "/mi" depending on the choice.
Pace renders as "--:-- /unit" when distance or elapsed time is zero
(avoids divide-by-zero glitches with one-point trails). mph conversion
uses the 0.621371 ratio with rounding; pace works in floating point
off raw metres and seconds for accuracy at low speeds.
NodePrefs gains trail_units_idx as another reserved-ish byte; old saves
load with 0 = km/h, no schema bump needed since the rd() lambda already
defaults missing fields to zero.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Hold-Enter popup was inconsistent with how every other Tool screen
(Auto-Advert, Ringtone Editor, etc.) presents its settings. Reshape
the entry point:
- New V_CONFIG view is the resting screen. Shows "Min dist: <value>",
the stored points count and distance (so the user can see what
pressing Enter will resume), and a hint "<>dist [Ent] start/resume".
LEFT/RIGHT cycles min-dist; Enter saves any pending change and
starts/resumes tracking. Tracks open straight into V_SUMMARY.
- The three live views (Summary, Map, List) are only reachable while
tracking. Enter on any of them stops and drops back to V_CONFIG;
LEFT/RIGHT cycles between the three.
- Esc/Hold from any view writes the pending min-dist change (if any)
and returns to Tools.
Popup state (_opts_open / _opts_dirty / renderOptions) is gone, along
with all writes-on-every-cycle. Min-dist now batches in _cfg_dirty and
commits once on Start or Back.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Earlier commits deleted the SECTION_GPS enum entry along with its only
remaining child (Trail dist moved to the Trail tool), but isSection()
and sectionName() still referenced it under the ENV_INCLUDE_GPS guard.
Both helpers no longer need a GPS case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both call sites of buildVisibleOrder wrote into a fixed-size 11-int
stack buffer. After FAVOURITES landed, HomePage::Count became 12; in
the common case where all pages are visible, buildVisibleOrder writes
12 entries — one past the end. That OOB write trashed whatever lived
next on the stack (typically the loop variable in navPage), so LEFT
wrap-around behaved randomly: sometimes locked at the first page,
sometimes jumped to a wrong one. User-visible as "menu doesn't wrap
to the left".
Size the buffer to `(int)Count` so it tracks the enum and grows with
any future home page additions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per-tool config belongs in the tool, matching Auto-Advert's pattern.
SettingsScreen drops:
- TRAIL_MIN_DELTA enum entry + the entire SECTION_GPS gate (it was its
only remaining item after GPS upd / Trail int were removed)
- Render and input handler blocks
TrailScreen gains a small modal popup opened with Hold Enter (the
KEY_CONTEXT_MENU input). Enter cycles through the four min-dist values
(5/10/25/100 m), Esc closes the popup. The popup overlays whichever
view is active; savePrefs runs on each cycle so the value persists.
NodePrefs::trail_min_delta_idx and the TrailStore::MIN_DELTA_* helpers
are unchanged — only the entry point shifted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation derived "Time" and "Avg speed" from the
RTC: first().ts and rtc_clock.getCurrentTime(). When the RTC isn't
synced yet (no GPS time fix, no host sync), every point ts is 0, the
current time is 0 too, and elapsed stays at 0 — Time never moves and
Avg speed reads 0 even with the map filling in. Real-world breakage.
Switch the elapsed/avg-speed timebase to millis():
- TrailStore gains _session_start_ms (millis() when the current
start→stop window opened) and _accumulated_ms (banked time across
previous windows).
- setActive(true) records the start; setActive(false) banks the
delta into the accumulator.
- elapsedSeconds = (_accumulated_ms + active session delta) / 1000.
- clear() zeroes both.
Per-point ts stays RTC-derived (used for the HH:MM labels in List view
where a wallclock is what the user actually wants).
TrailScreen drops the now_ts argument it was passing in, and bumps the
refresh interval to 1 s while active so the m:ss counter actually
ticks every second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.5 min of raw 1 s samples (vs 4.25 min) — with the typical 5 m
min-delta gate that's ~30 min of walking or ~10 min of cycling worth
of points before the ring wraps. Cost is one 16-byte TrailPoint per
slot = +4 KB RAM; nRF52840 build was at 64.7 % so ample headroom.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GPS NMEA polling is cheap, and a slower cadence just hides motion from
the trail; the min-delta gate already filters jitter without throttling
the GPS. Both knobs that exposed cadence-style controls to the user are
removed:
- Trail sampling is fixed at TrailStore::SAMPLING_SECS = 1 s, matching
the sensor manager's built-in GPS update default. The
intervalSecs / intervalLabel / INTERVAL_COUNT helpers go with it.
- GPS update rate ("GPS upd" entry) leaves the sensor manager on its
built-in 1 s default. applyGPSInterval and the GPS_INTERVAL_OPTS /
LABELS tables / gpsIntervalIndex helper are deleted.
Both NodePrefs fields (gps_interval, trail_interval_idx) are retained
as reserved so the schema sentinel doesn't have to bump — older saves
load cleanly, the bytes are just ignored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A 3×3 filled square is drawn at the last point of each segment before
a break, and a 3×3 outline square at the first point after the break.
The user can now see where tracking paused and resumed at a glance,
without confusing the gap with sparse sampling.
Start (`+`) and current (`×`) markers unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two visible cues when the user starts tracking without a fix:
1. The Enter alert now reads "Waiting for GPS fix" instead of
"Tracking started" when the LocationProvider isn't valid at the
moment of toggling. Tracking is still armed — sampling starts as
soon as the GPS lock comes up.
2. The Summary Status field shows "waiting fix" while active and the
ring is still empty (no point ever sampled). It flips to "tracking"
once the first point lands. The detection is "any point recorded
yet?" rather than "is the fix valid right this second", which is
stable across the cadence of UITask::loop sampling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two new entries in the existing GPS section of SettingsScreen, gated by
the same ENV_INCLUDE_GPS guard. LEFT/RIGHT cycles through the option
table from Trail.h:
- "Trail int" : 30 s / 10 s / 20 s / 1 min / 5 min / 15 min
- "Trail dist": 5 m / 10 m / 25 m / 100 m
Changes are stored to p->trail_interval_idx / trail_min_delta_idx and
take effect on the next sampling tick in UITask::loop (already gated on
those fields), so no separate apply* helper is needed. _dirty triggers
the normal savePrefs on settings exit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Third view in TrailScreen, reached by LEFT/RIGHT from Summary/Map.
Newest-first, each row shows `HH:MM +N m` (or `+N.N km` past 1 km).
The first point of each segment shows `start` instead of a delta —
matches the map renderer's segment-aware drawing.
UP/DOWN scrolls within the list; ^/v arrows on the right edge appear
when more rows hide above/below. Time is local (tz_offset_hours
applied via gmtime on (ts + offset)).
Empty trail shows "No trail yet" centred in the content area.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test feedback revealed four issues:
1. Sampling too sparse — defaults were 60 s interval + 25 m min-delta,
so a walking pace dropped ~80% of samples. New defaults: 30 s + 5 m.
Interval options expanded to { 30, 10, 20, 60, 300, 900 } s and
min-delta to { 5, 10, 25, 100 } m so the user can dial it further
from Settings (phase 5).
2. "Speed" was current-from-last-pair, misleading next to a Time field
that grows monotonically. Switch to avgSpeedKmh = total / elapsed,
labeled "Avg speed".
3. Time advanced only when a new sample landed (it used
last().ts - first().ts). Now elapsedSeconds takes an optional
now_ts and uses it whenever the trail is active, so the Time field
in Summary ticks every render cycle. Sub-1h shown as m:ss for
visible seconds.
4. Stop → start drew a straight line across the dead time. TrailPoint
gains a flags byte; addPoint flags the first point and the first
point after a re-arm as SEG_START. The map renderer skips the line
from the predecessor for SEG_START points (still draws the start
pixel). totalDistanceMeters also skips segment boundaries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The status-bar G/A indicators both flip every 2 s (millis() % 4000 <
2000), but with only auto-advert forcing the 1 s refresh, a screen with
G alone repainted every 5 s — the blink looked sparse vs A. Treat any
blinking indicator (auto_adv OR trail active) as needing the 1 s
cadence on OLED.
E-ink builds keep the 30 s home refresh — blink is gated to "always on"
there via Features::BLINK_INDICATORS = false.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OLED (64 px height) couldn't fit 5 summary items plus the bottom hint
row — Time/Speed lines overlapped the [Ent]/[Esc] hint. Convert Summary
to a scrolling list: visible-count derived from the available area
(height - listStart - hint - margin) divided by lineStep, scroll arrows
"^"/"v" on the right edge when more items hide above/below. UP/DOWN
scroll within Summary; LEFT/RIGHT still switch views.
E-ink portrait and OLED both benefit; e-ink landscape fits all 5 items
without ever showing the arrows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LEFT/RIGHT cycles between Summary (existing) and the new Map view.
Pixel-by-pixel polyline auto-fits the bounding box of the points to
the visible area, with longitude scaled by cos(avg_lat) so trails at
higher latitudes don't smear east/west. Picks the limiting axis and
centres the non-limiting dimension, so the trail fills as much screen
as possible without distorting.
Markers:
- Start point: a 5×5 plus sign
- Current/last point: a 5×5 diagonal cross
Edge cases:
- Empty trail → "No trail yet" centred
- Single point or all samples colocated → single current marker at centre
- Degenerate area_w/h (very small screen) → no draw
Lines are Bresenham via 1×1 fillRect — a few-tens-of-ms render which is
fine for an on-demand screen. A drawPixel override on the two drivers
would speed this up, deferred until the cost becomes visible.
Bottom hint shows `<>` for view nav, "N/2" current view, and the
Enter-toggle label.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Tools › Breadcrumb. Enter on the screen toggles tracking; while
active, UITask::loop samples GPS at the cadence chosen in settings
(default 1 min) and drops the point into a 256-entry RAM ring guarded
by a min-distance gate (default 25 m). A "G" indicator joins "A" in
the status bar with the same blink convention.
New storage:
- examples/companion_radio/Breadcrumb.h — BreadcrumbStore class
- 256 × 12 B = 3 KB RAM (survives auto-off, lost on reboot — explicit
flash-slot save comes in phase 4)
- Haversine min-delta gate, total distance, elapsed seconds, current
km/h, bounding-box helper
New screen:
- examples/companion_radio/ui-new/BreadcrumbScreen.h — Summary view
with Status, Points, Dist (m or km), Time (h:mm), Speed; Enter
toggles tracking, Esc returns to Tools
Schema bump 0xC0DE0002 → 0xC0DE0003 for two new NodePrefs bytes:
breadcrumb_interval_idx (1min default) and breadcrumb_min_delta_idx
(25m default). Older saves leave both at zero, which matches the new
defaults. DataStore::loadPrefsInt/savePrefs read/write the pair.
Logging on/off is a runtime flag inside BreadcrumbStore — not a
preference — so reboot returns to the off state cleanly; the user
will be able to persist a snapshot to a slot in phase 4.
Phase 2 (map view), 3 (point list), 4 (slot save/load, GPX export over
USB), and 5 (settings UI) follow incrementally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.
Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.
Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
(12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
Contact-options menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QuickMsgScreen::enterDM (used by openContactDM from the Favourites dial)
now sets _dm_direct_entry so KEY_CANCEL in DM_HIST jumps back to the
home screen instead of falling through CONTACT_PICK → MODE_SELECT. The
flag clears on every reset() and after the cancel returns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>