Follow the global Units preference in the two remaining Trail distance
spots that were still metric-only:
- Min dist gate: the index is now a unit-agnostic level (0=finest..3).
Metric stays 5/10/25/100 m; imperial uses round 15/30/75/300 ft (applied
as their metre equivalents in the sampling gate).
- Map scale grid: imperial mode uses round ft/mi steps (10 ft … 100 mi)
instead of metric steps with a converted label. Step storage moved to
float metres so sub-metre imperial steps keep their geometry; labels come
from a parallel table so the scale bar reads an exact round value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a single "Units" choice (Settings › System: Metric / Imperial) that
governs every distance and speed shown in Tools — Nearby Nodes, the Trail
views, and the navigate-to-point screen.
- NodePrefs: units_imperial (global) + trail_show_pace (Trail readout mode),
schema bumped 0xC0DE0005 → 0006. The old combined trail_units_idx is kept
for file-layout stability but no longer drives anything; upgraders default
to metric + speed.
- geo::fmtDist gains an imperial mode (ft below ~1000 ft, then miles).
- NavView / Nearby / Trail (waypoint list, Summary distance, List deltas,
map scale-bar label) all format through the global preference.
- Trail's action-menu "Units" row becomes a Speed/Pace readout toggle; the
km-vs-mi unit now follows the global setting instead of being one of four
combined options.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last stray getLocationProvider/isValid duplicate (with its own
ENV_INCLUDE_GPS guard). Fold it into ownPos so every GPS lookup in the
nav screens goes through UITask::currentLocation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The static haversineKm/bearingDeg/bearingCardinal/fmtDist members were
thin pass-throughs to GeoUtils. Remove them and qualify the call sites
with geo:: so there's no parallel local API to keep in sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
drawLine was duplicated verbatim in TrailScreen and CompassScreen, and
drawCircle lived only in CompassScreen. Lift both into a gfx:: namespace
header so the map and compass share one copy of each 1-px primitive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UITask::currentLocation() becomes the one place that reads the
LocationProvider fix. TrailScreen::ownPos, CompassScreen::gpsValid and
NearbyScreen::refresh now delegate to it instead of each duplicating the
getLocationProvider/isValid dance (and the ENV_INCLUDE_GPS guard).
pushCogFix now restarts the course-over-ground ring when the gap since the
previous fix exceeds 15 s: after losing GPS for a while, a window spanning
the stale old fixes would compute a bogus "teleport" heading. The
last-good heading is kept for display continuity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One character was too ambiguous to tell adjacent waypoints apart on the
map. Draw the first two characters of the label (falling back to one when
the label is a single char) in both the normal and degenerate map paths,
and widen the right-edge fit guard to the rendered text width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the map collapses to one coordinate (e.g. a single waypoint with no
trail and no GPS fix yet), the degenerate branch drew the waypoint marker
but never its label — so it appeared as a bare diamond. Draw the first
character of the waypoint's label beside the centred marker, matching the
normal map path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Trail Map is now useful as a plain "you are here + waypoints" view even
when no trail is being recorded:
- The bounding box folds in the live GPS fix (via ownPos) alongside the
trail and waypoints, so the current position is always in frame.
- The current-position marker is drawn at the live fix when available
(falling back to the last recorded trail point otherwise), instead of
only at the trail's end — so it shows with an empty trail too.
- Empty-everything message is now "No GPS / no trail" and only appears when
there is genuinely nothing to show (no trail, no waypoints, no fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a bulk clear for saved waypoints (Trail → Hold Enter → Clear
waypoints), shown only when at least one waypoint exists. Independent of
the trail — Reset trail still leaves waypoints intact; this is the
explicit way to wipe them all instead of deleting one by one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compass: switch to a heads-up card. The direction of travel is now the
fixed "up" index at the top and the compass card rotates underneath it —
a North needle plus N/E/S/W letters placed at screen angle (bearing - cog),
so North spins as you turn rather than the arrow pointing at your course.
Trail waypoints: the nav list now always starts with a synthetic
"Trail start" row whenever a trail exists, so you can navigate back to
where you began without having marked it. The "Waypoints" action appears
when there are saved waypoints OR a trail to backtrack to. Rename/Delete
apply only to saved waypoints; the start row is navigate-only. List/nav
indexing goes through rowTarget()/wpIndex() helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Tools menu rendered all items from listStart() with no scroll, so the
6th entry (Compass) fell off the bottom on the OLED's short panel and was
unreachable. Add a scroll offset that keeps the selection in view, with
the usual ^/v indicators.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
startPingForKey() set the "RTT: ..." line but didn't rebuild the ping
menu, and updatePingMenuState only rebuilt on a string-count change it
computed after the mutation — so the menu stayed at a lone "Send" row and
nothing visibly happened after pressing Send, whether or not a reply came.
- Rebuild the menu right after starting the ping so "RTT: ..." shows at
once; on send failure show "RTT: send fail" instead of a blank.
- Sync the menu against its actual row count (_ping_menu._count) as result
lines populate, so RTT/SNR rows appear when the reply lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tools › Compass shows the device's course over ground (from the GPS COG
ring — no magnetometer) as a rotating arrow in a ring with a north tick,
plus a large degrees + cardinal readout. "No GPS fix" without a fix;
"move to set heading" when stationary (course undefined). Reuses
UITask::currentCourse, so it works without recording a trail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes from on-device testing:
- Node navigation was bound to a bare Enter in the detail view, which was
undiscoverable. Hold Enter now opens an Options popup (Navigate / Ping),
consistent with the rest of the UI. Navigate opens the shared NavView;
Ping runs the ping and shows its result.
- The ping popup always carried three result rows (RTT / SNR out / SNR
back) even before a ping, so the user could scroll onto blank rows. The
menu is now rebuilt to contain only "Send" plus the result lines that are
actually populated, growing in place as the reply arrives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review fixes:
- NearbyScreen: when the periodic detail refresh can't find the selected
contact any more, clear _nav as well as _detail so the nav view and the
input handler don't disagree about state.
- TrailScreen: bound the label keyboard to WAYPOINT_LABEL_LEN-1 (11) chars
for both Mark-here and Rename, so the user can't type more than the stored
label holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6. In a node's detail view, Enter (when the ping popup is closed)
opens the shared NavView targeting that node's last-known advert position
— same To/Hdg/distance screen used by waypoints. Nodes with no position
report "No node GPS". This turns Nearby from a static snapshot into a
"walk toward this person" view and folds in the old compass-to-contact idea.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5b. The Map view now overlays saved waypoints as a hollow-diamond
marker with the label's first character beside it. The bounding box folds
in every waypoint so off-track ones stay in frame, and the map renders
even when the trail is empty but waypoints exist. project() generalised to
projectLL(lat, lon) so the same projection draws track points and waypoints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5a. Trail → Hold Enter gains two rows:
- "Mark here": captures the current GPS fix and opens the keyboard for a
short label (empty → auto "WP<n>"); saved to the persistent table.
- "Waypoints" (shown when any exist): a list of saved waypoints with live
distance. Enter opens the shared NavView (To/Hdg/distance); Hold Enter
offers Rename / Delete.
All waypoint sub-screens layer over the trail views via _wp_mode and are
dismissed with Cancel. Map overlay comes next (5b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4. Header-only navview::draw() takes a target (lat, lon, label) plus
the caller's own fix and course-over-ground, and renders distance + two
absolute bearings (To / Hdg). Source-agnostic so Waypoints, Backtrack and
Nearby node nav all reuse it. Not wired in yet (phases 5-6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3. Fixed 16-entry table of (lat, lon, ts, label[12]) persisted to
/waypoints with a magic+version header (mirrors TrailStore's format).
Unlike the RAM-only trail, waypoints are loaded in UITask::begin() and
rewritten on change via UITask::saveWaypoints(), so they survive reboots.
add/remove/rename/clear operate on the in-RAM table; the screen layer
(phase 5) drives persistence after each edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2. A 5-fix GPS ring sampled every ~1 s in UITask::loop, filled
whenever there's a valid fix — independent of trail recording, so the
heading is available to navigation at all times, not only while logging
a trail.
- pushCogFix() rejects gross outliers (a fix implying > 50 m/s since the
previous one) so a single bad GPS reading can't swing the heading.
- currentCourse(deg) returns the bearing across the window (oldest→newest)
once the window spans ≥ 6 m of real movement; below that it holds the
last good heading, and returns false only until the first valid heading
exists. This is the "two absolute bearings (To / Hdg)" heading source
for the upcoming nav view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the navigation feature. The geographic helpers were private
statics in NearbyScreen; move them to a header-inline geo:: namespace so
the upcoming Waypoints / course-over-ground code can reuse them. NearbyScreen
keeps thin forwarders so its call sites are unchanged. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a tall portrait e-ink panel the clock and lock screens wasted most of
the vertical space on a small inline "HH:MM". Render HH and MM on two
lines in a new size-4 font (built-in GFX scaled 7×, ~42×56 px) so the
digits roughly double in height and fill the narrow width. Wide panels
(OLED, landscape e-ink) keep the classic single-line size-2 layout.
- New shared drawClockTime() helper used by both the Clock home page and
the lock screen; returns the y below the time block so the date and
dashboard rows flow beneath it.
- GxEPDDisplay: size 4 = built-in font × BIG_TEXT_SCALE (7); getCharWidth /
getLineHeight / setTextSize handle it; fontAscender stays 0 (built-in is
top-left origin).
- Centre the big digits on their visible glyph width — the built-in font
advances 6 px per char but the glyph is 5 px wide, so getTextWidth over-
reports by one trailing column and the digits would sit ~half a column
left of centre. AM/PM rendered one size larger (size 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before this commit, there was no way to set a different max hop count
for unscoped messages.
Now with this change, by defaul it tracks the flood.max setting, until
a user provides a flood.max.unscoped value, which tax precidence for
packets if ROUTE_TYPE_FLOOD is true.
The channel history is a single ring shared by all channels (was 32
entries), while unread counts live in a separate per-channel array.
Once 32 messages had accumulated across channels, the oldest entry was
evicted on each new message — but the matching _ch_unread[] counter was
left untouched. The Messages badge then claimed unread messages the ring
could no longer surface, so a channel showed "N unread" but opened empty.
(The companion app stores its own copy over the protocol, which is why
the phone could still read them.)
Two changes:
- addChannelMsg() now decrements _ch_unread[evicted.ch_idx] when an entry
is pushed out of the ring, keeping the badge consistent with what is
actually displayable.
- CH_HIST_MAX raised 32 → 96 (~14 KB RAM) so eviction is far less frequent
under normal multi-channel traffic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A very elongated trail (lat_span << lon_span or vice-versa) shares an
isotropic scale, so the visible window in the *short* direction can span
many grid_m. The previous code computed lat_n / lon_n once and bailed
with `return` if either exceeded 40 — leaving the user with no grid at
all on east-west or north-south runs.
Now: after picking the initial step via the MIN_GRID_PX and shorter/2
loops, also bump the step up further until both lat_n and lon_n fit
within MAX_GRID_LINES (40). Falls back to `return` only if even the
largest STEPS[] entry (100 km) can't produce a small-enough grid — which
shouldn't happen in practice on terrestrial trails.
Also fixed: comment claimed "~4 intervals across shorter dim" while the
code divides by 3.0f. Updated to "~3 intervals" to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
M2: CMD_SET_DEFAULT_FLOOD_SCOPE used strlen() on the frame's 31-byte name
slot, which is not required to be NUL-terminated. Switched to strnlen()
so the search can't run past the field into the 16-byte key.
M4: NearbyScreen::renderDiscoverDetail computed strncpy(b64, ..., max_chars - 3)
where max_chars came from display width. On very narrow displays (width < ~28
at 6 px font) this became negative. Skip the pub-key line entirely when
max_chars < 4 so we don't risk a negative count and a bogus terminator.
L1: SNR was shown as truncated integer dB. Switched the detail view and
the 2-line discover cards to %.1f so they keep the 0.25 dB resolution
(consistent with the ping popup, which already used %.1f).
L4: Two fallback "?" sender placeholders used strncpy(buf, "?", sizeof(buf))
— functional but it memsets 21 unused bytes for a one-character string.
Replaced with strcpy.
M1 marked as not-a-bug after re-check: default_scope_name is char[31],
so the n < 31 guard correctly admits the max 30-char string + NUL.
FEATURES.md audit section updated with current status.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C1/C2: onChannelMessageRecv and onChannelDataRecv both passed
findChannelIdx() through (uint8_t), so an unknown-secret packet
turned -1 into 255 and the message flowed into the offline queue,
UI history, notification and bot reply with a bogus channel index.
Drop such packets at the recv path; also harden addChannelMsg with
an MAX_GROUP_CHANNELS bounds check so a future caller can't poison
the channel-history ring buffer either.
H4: loadPrefsInt always reset trail_units_idx on sentinel mismatch,
even for jumps where the field was saved correctly (e.g. 0xC0DE0004
→ 0xC0DE0005). Scope the reset to sentinel == 0xC0DE0003, which is
the only case where the byte at that offset is actually the stale
sentinel low byte.
FEATURES.md: mark these three items as ✅ in the audit backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Ping popup mixed an action row ("Ping") with three read-only result
rows (RTT, SNR out, SNR back). UP/DOWN scrolled through all four, so the
highlight could land on a result row and ENTER did nothing useful.
- Swallow UP/DOWN while the ping popup is open; selection stays on the
action row so ENTER always triggers a new ping.
- Rename the action row "Ping" → "Send" and the RTT result prefix
"Ping: …" → "RTT: …" so the word "Ping" only appears once (in the
popup title), instead of three times.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
An all-zero secret in /channels2 means the entry is uninitialised or the
file format was corrupted by a previous firmware (e.g. different on-disk
layout from an older fork). Loading such a channel makes findChannelIdx()
match the wrong slot for incoming messages — they end up associated with
the bogus channel index and never display, even when the companion app
receives them fine over BLE.
Skip empty-secret entries during load and log how many were dropped.
Also defensively null-terminate the loaded name so callers can treat it
as a C string regardless of how the file was written.
Reported via user feedback: "only public visible, hashtag channels not;
public was empty; phone could read messages; works after wipe".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
README:
- Fix typo "offical" → "official"
- File names in Firmware Variants table now match workflow output
(solo-<version>-oled.uf2 / solo-<version>-eink.uf2)
- Un-comment the E-ink Display section (now fully supported)
- Clock Screen: "sensor values" → "data fields" (covers Batt%, Nodes, Msgs)
- Tools Screen: mention nearby nodes and auto-advert in the highlight
- Promote Screenshot Tool to its own top-level section (was buried under
Contributing); fix `uv run tools/screenshot.py` invocation path
Docs:
- favourites_dial: document the 3rd pin-picker tier (all chat contacts
fallback) added in 0e0e5b93
- clock_screen: Altitude source clarified — onboard sensor (GPS or
barometric), not always GPS
- message_screen: blank lines around image tables that were inlined
Code:
- UITask.cpp: rename leftover plus_y/plus_label locals to solo_y/solo_label
in the splash-screen Solo banner
Workflow:
- workflow_dispatch builds previously labelled firmware with the branch
name (GITHUB_REF_NAME). Resolve BUILD_VERSION to the tag when run on a
tag push, else dev-<short-sha> so manual runs produce
solo-dev-abc1234-oled.uf2 etc.
Cleanup:
- remove stray docs/solo_features/settings_screen/set_scr_1_eink copy.png
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AUTO_OFF_MILLIS is a power-save feature aimed at battery use. When the
board reports isExternalPowered() == true (USB or other DC source),
blanking the screen serves no purpose — there's nothing to conserve.
But OLEDs are vulnerable to burn-in with static content, so this
behaviour is gated behind a new build flag KEEP_DISPLAY_ON_USB. Default
is unchanged from upstream — the display blanks after AUTO_OFF_MILLIS
on USB or battery. Variants that ship with an LCD instead of an OLED
(e.g. heltec_t096) can opt in by adding -D KEEP_DISPLAY_ON_USB to
their env, gaining always-on-while-powered without exposing OLED users
to burn-in risk.
When the flag is enabled, the implementation refreshes _auto_off every
loop iteration while externally powered, so the timer naturally counts
a fresh AUTO_OFF_MILLIS window from the moment power is removed —
no instantaneous-blank-on-unplug.
Applied to all three companion_radio UI flavours (ui-new, ui-tiny,
ui-orig). Boards without an isExternalPowered() override use the
base-class default in MeshCore.h (returns false), so battery-powered
behaviour is unchanged everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>