Commit Graph
100 Commits
Author SHA1 Message Date
JakubandClaude Opus 4.8 3e2462eaf4 docs(readme): advertise the GPS navigation suite
Add a navigation tagline to the intro and a dedicated "GPS navigation"
feature-highlights block (waypoints, compass, navigate-to-anything,
location sharing, trail, global units); note location nav in the Messages
entry and expand the Tools entry to cover waypoints/compass/navigate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:15:01 +02:00
JakubandClaude Opus 4.8 47773ef787 docs: remove release notes file (kept as GitHub release body instead)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:07:08 +02:00
JakubandClaude Opus 4.8 0c807f62c8 docs: add Solo v1.14 release notes
Release notes covering the navigation suite (waypoints, GPS compass,
navigate-to-point, mesh location share, waypoints in GPX), the global
metric/imperial Units setting, the long-message buffer fix, plus the
existing v1.14 items, under docs/releases/solo-v1.14.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:02:46 +02:00
JakubandClaude Opus 4.8 c4cea53a40 polish(nav): tidy parseLatLon precision + ping-menu edge cases
- geo::parseLatLon now rounds lat/lon in double (matching the Add-by-coords
  form) instead of through a float cast that lost ~1 m near 1e8.
- NearbyScreen: preserve the ping menu's selected row across a rebuild
  instead of snapping back to "Send" when a result line arrives.
- NearbyScreen: guard the ping-menu input path so a vanished contact
  (selectedStoredPubKey == false) closes the popup rather than acting on an
  uninitialised pub-key buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:46:57 +02:00
JakubandClaude Opus 4.8 475e3b2c84 fix(nav/map): don't pin a waypoint to centre in the degenerate trail case
When a trail exists but the framed box collapses to one point (e.g. a
single recorded point, or all points coincident, with no GPS fix), the
degenerate branch was drawing waypoint[0] at the centre — but waypoints
aren't folded into the box in trail-priority mode, so a possibly-distant
waypoint appeared at the trail's location. Only centre a waypoint in the
genuine "lone waypoint, no trail" case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:42:36 +02:00
JakubandClaude Opus 4.8 26097d7d68 feat(trail): include waypoints in the GPX export
GPX dumps previously contained only the <trk>. Emit saved waypoints as
<wpt> elements (label → <name>, plus <time> when the RTC was set) before
the track, per GPX 1.1 ordering, in both live and saved-file exports. The
emitter is duck-typed over the waypoint store so Trail.h stays decoupled
from Waypoint.h; labels are XML-escaped. They now import as pins in OsmAnd /
Garmin / GPX Studio alongside the track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:28:21 +02:00
JakubandClaude Opus 4.8 4f39203790 docs(features): add battery/power optimization backlog item
Capture the plan to multiply battery life via radio power technique (the
dominant draw is continuous RX, not the framework): CAD-based RX windowing
(~10-15mA → ~3-5mA, behind a setting due to the latency tradeoff) and
Adaptive Power Control, inspired by the ZephCore Zephyr port but stayable in
this Arduino tree. GPS power gating explicitly excluded — the device is a
live navigator/trail recorder. Deferred until trail polishing is done; prefer
adopting upstream nRF52 powersaving work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:26:48 +02:00
JakubandClaude Opus 4.8 b64eda234f fix(nav): reject non-numeric lat/lon in Add-by-coords
The form's keyboard is the full alphanumeric one, and atof() silently read
a non-numeric entry as 0.0 — so typing letters created a waypoint at
(0, 0). Validate each coordinate with strtod and require the whole field to
be numeric; bad input now reports "Lat/Lon invalid" and saves nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:49:22 +02:00
JakubandClaude Opus 4.8 08012ef54f feat(nav): show waypoint usage count in the list header
The Waypoints list header now reads "WAYPOINTS <n>/<cap>" (e.g. 3/16) so
it's clear at a glance how many waypoints are stored and how much room is
left before the table is full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:44:00 +02:00
JakubandClaude Opus 4.8 6f2bc6529d refactor(nav): move Add-by-coords inside the Waypoints list
Add-by-coords sat in the top-level Trail action popup alongside Mark here.
Move it into the Waypoints view as a trailing "+ Add by coords" row, so the
popup stays focused on trail/marking actions and waypoint management lives
in one place. "Waypoints" is now always shown in the action menu (it hosts
the list, backtrack and the add entry), so the first waypoint can be added
by coordinates even with no trail or saved points yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:43:01 +02:00
JakubandClaude Opus 4.8 cad44216eb feat(nav): Add-by-coords becomes a lat/lon/label form
The on-screen keyboard has no comma or minus, so a single "lat,lon" entry
was unworkable. Replace it with a WP_ADD form: separate Lat / Lon / Label
rows plus Save. Lat/Lon take a magnitude from the keyboard and a hemisphere
(N/S, E/W) toggled with LEFT/RIGHT, so negative coordinates need no minus
key. Save validates the range, auto-names a blank label, and stores the
point via the normal waypoint path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:39:09 +02:00
JakubandClaude Opus 4.8 80c14a75ed feat(nav): add a waypoint by typing coordinates
New Trail action "Add by coords" creates a waypoint without a GPS fix: the
keyboard takes a "lat,lon" string (decimal degrees), parsed by the shared
geo::parseLatLon; on success it falls through to the normal label keyboard
(blank → auto WP<n>). Useful for a meeting point or a spot read off a map.
Invalid input reports "Bad coordinates".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:31:53 +02:00
JakubandClaude Opus 4.8 5c1ec5ee28 docs(features): mark range test (ping), lock-screen unread, mark-read done
- Range test → : shipped as the Nearby Nodes ping (RTT + SNR, repeatable);
  the dedicated continuous-ping/sparkline screen was not built (kept as future).
- Lock-screen unread count → : total "<n> unread" badge on the lock screen.
- Mark-read at type level →  pointer to the implemented priority entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:41:11 +02:00
JakubandClaude Opus 4.8 93df3a54d0 docs(features): mark navigation cluster shipped; add heading-up backlog
- Waypoints/nav, GPS trail, Backtrack, Compass-to-contact flipped to  with a
  consolidated "shipped" summary noting deltas from the original spec
  (two-char labels, heading tape compass, global Units, mesh location sharing,
  trail-priority map framing, shared geo/gfx helpers).
- New backlog item: heading-up (track-up) map orientation, with the COG-only /
  jitter / fit / grid / e-ink caveats and a lightweight rotate-the-fit plan.
- Audit: record the message-buffer length fix (history/compose sized to
  MAX_TEXT_LEN) as .

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:37:46 +02:00
JakubandClaude Opus 4.8 a12ff50edd feat(nav/map): frame the trail, clamp distant waypoints to the edge
While a trail exists the map now fits the recorded route (plus the live
position) and no longer folds waypoints into the bounding box, so a far-off
waypoint can't zoom the view out and squash the trail. Waypoints that
project outside the framed area are clamped to the nearest edge (marker +
label) instead of being lost off-screen. With no trail, the view still
auto-fits to the waypoints as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:30:46 +02:00
JakubandClaude Opus 4.8 901c1dcd92 fix(nav/map): keep waypoint labels inside the map rect
The label was always drawn up-and-right of the marker (wx+4, wy-3) and was
dropped entirely if it would clip the right edge — so waypoints near the
top/bottom (and left) edges had their label spill off the map or vanish.
Add an edge-aware placement helper: prefer upper-right, flip to the left
when it would clip the right edge, and clamp vertically so edge/corner
waypoints keep a visible label. Used by both the normal and degenerate
single-point map paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:26:02 +02:00
JakubandClaude Opus 4.8 daaa5b1503 fix(messages): allow full-length text on the compose/send paths too
After widening the history buffers, the compose side was still the
bottleneck: the keyboard capped input at 139 bytes and the quick-message
expand buffers were 140. Raise KB_MAX_LEN to 160 (MeshCore's MAX_TEXT_LEN)
and size the expand/compose buffers to MSG_TEXT_BUF, so a message can be
typed, expanded, sent, stored and displayed at the full protocol length on
every path.

The custom-message editor now passes an explicit max bound to its 140-byte
store, so the wider keyboard buffer can't overflow it; the bot editor
already passed per-field maxima.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:15:30 +02:00
JakubandClaude Opus 4.8 606f28c34a fix(messages): stop clipping long received messages
Message history buffers were smaller than the over-the-air maximum, so long
messages lost their tail on display. Channel messages carry the sender
embedded as "Name: body" in the payload (up to MAX_TEXT_LEN = 160 B), but
ChHistEntry::text was only 140 B; DmHistEntry::text was just 80 B — and with
Polish text each accented char is two UTF-8 bytes, so the effective limit was
roughly half the visible characters.

Size both history buffers (and the fullscreen/preview split copies) to
MAX_TEXT_LEN + 1 so a full-length message is stored and shown intact. Sender
split buffers widened to the 32-char name limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:10:26 +02:00
JakubandClaude Opus 4.8 54b07a5941 feat(nav): offer Navigate / Save waypoint from the message list too
Previously a shared location was only actionable after opening the message
fullscreen; Hold Enter on a history row only ever offered Reply. Route the
DM and channel list context menus through the same buildFsMenu /
dispatchFsAction path as the fullscreen view, so Navigate / Save waypoint
appear directly on the list row when the selected message carries a
location (Navigate returns to the list on Back).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:57:29 +02:00
JakubandClaude Opus 4.8 75f9ccb34a feat(nav): share a waypoint as a message
Waypoints list → Hold Enter → Send hands the point to the Messages screen
as "[WAY]<lat>,<lon> <label>" (same text format geo::parseLatLon already
reads). The user picks a contact or channel, the text lands prefilled in
the keyboard to confirm/edit, then sends — closing the loop with the
Navigate / Save waypoint actions on the receiving end.

- QuickMsgScreen: share-compose mode (startShare/beginShareCompose). Picking
  a recipient jumps straight to the prefilled keyboard; cancel returns home;
  afterSend clears the mode.
- UITask::shareToMessage hands off from TrailScreen to the Messages screen.
- TrailScreen: "Send" added to the waypoint Rename/Delete popup; builds the
  [WAY] payload at the {loc} precision (5 dp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:38:12 +02:00
JakubandClaude Opus 4.8 b5f11f430f docs(messages): document Navigate / Save waypoint from a shared location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:28:47 +02:00
JakubandClaude Opus 4.8 35d6e8b1a3 feat(nav): navigate to / save a location shared in a message
Locations already travel as plain text ({loc} expands to "lat,lon"), so any
message carrying coordinates can now be acted on. In the fullscreen message
view, the context-menu (Hold Enter) gains Navigate and Save waypoint
entries whenever the message contains a coordinate — alongside Reply.

- geo::parseLatLon: shared scanner for an embedded "lat,lon" (the {loc}
  format), with an optional [WAY] tag that supplies a label. Range-checked,
  requires a decimal point to avoid matching plain integer pairs. Will also
  back the waypoint-share feature later.
- QuickMsgScreen: Navigate opens NavView inline (To/Hdg/distance, honouring
  the global Units setting) over the message, back returns to it; Save
  waypoint stores the point (label from [WAY], else auto-named). Works in
  both DM and channel histories, and on incoming or outgoing messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:28:17 +02:00
JakubandClaude Opus 4.8 ad89104599 feat(compass): replace dial with a horizontal heading tape
The shrunken ring left only a few-pixel needle in the OLED's short vertical
space. Switch to a linear heading tape: a fixed travel-direction pointer at
centre over a scrolling N..E..S..W scale (30° ticks, N/E/S/W labelled),
with the large degrees+cardinal readout below. The tape is purely linear
(no trig) and uses the available width, so it stays readable on the small
panel. Update the Compass docs to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:28:53 +02:00
JakubandClaude Opus 4.8 0f2399009b fix(compass): shrink ring so rotating cardinals sit outside it
The N/E/S/W letters rode on the ring radius, so they overlapped the circle
as the card rotated. Place the letters on an outer radius and draw the ring
a glyph-height smaller, leaving a clear gap between them. The forward index
now sits in that gap, just outside the ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:57:03 +02:00
JakubandClaude Opus 4.8 3bcb94ec10 fix(compass): move "move to set heading" hint below the ring
The no-heading hint was centred on the compass circle, overlapping the
ring. Draw it in the bottom readout band instead so the ring stays clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:37:37 +02:00
JakubandClaude Opus 4.8 2e7416d7ed docs(tools): note imperial min-dist levels follow the Units setting
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:50:55 +02:00
JakubandClaude Opus 4.8 d0d20989a0 feat(units): imperial min-distance gate + imperial map grid
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>
2026-06-04 00:50:42 +02:00
JakubandClaude Opus 4.8 9ae0828fcb docs: document global Units setting + Trail Readout toggle
Add the Settings › System "Units" (Metric/Imperial) row and update the
Trail action-menu entry from the old four-way km/h·mph·min/km·min/mi
"Units" cycle to a Speed/Pace "Readout" toggle within the global system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:45:47 +02:00
JakubandClaude Opus 4.8 359b5fccd7 feat(units): global metric/imperial setting for all distance displays
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>
2026-06-04 00:44:56 +02:00
JakubandClaude Opus 4.8 7c25aa1ad4 refactor(trail): route gpsHasFix through ownPos/currentLocation
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>
2026-06-04 00:16:28 +02:00
JakubandClaude Opus 4.8 1d0c655111 refactor(nearby): drop geo:: forwarding wrappers, call geo:: directly
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>
2026-06-04 00:15:36 +02:00
JakubandClaude Opus 4.8 a5d55a38b9 refactor(ui): share Bresenham line + midpoint circle in GfxUtils.h
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>
2026-06-04 00:13:44 +02:00
JakubandClaude Opus 4.8 55d164c074 refactor(nav): single GPS-position accessor + reset COG window after a gap
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>
2026-06-04 00:12:15 +02:00
JakubandClaude Opus 4.8 bd364ccfd1 docs(tools): note map always shows live position + waypoints
Clarify in both the Map view row and the Waypoints section that the
current GPS position and all waypoints are drawn continuously, so the Map
view is useful standalone without an active trail recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:04:38 +02:00
JakubandClaude Opus 4.8 459c2c3b12 feat(nav/map): show two label characters beside a waypoint marker
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>
2026-06-04 00:04:11 +02:00
JakubandClaude Opus 4.8 3ec578aad3 fix(nav/map): label the waypoint in the degenerate single-point case
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>
2026-06-03 23:56:53 +02:00
JakubandClaude Opus 4.8 eb4175c4c4 feat(nav): map shows live position + waypoints without recording
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>
2026-06-03 23:54:48 +02:00
JakubandClaude Opus 4.8 96cca4773c docs(tools): document waypoints, navigation and the Compass
- Trail action-menu table gains Mark here / Waypoints / Clear waypoints.
- New Waypoints section: dropping (Mark here + label), independence from
  the trail (/waypoints, survives reboot/Reset), map marker, the always-
  present Trail-start row, the two-bearing nav view, and rename/delete.
- New Compass section: heads-up course-over-ground (no magnetometer),
  rotating card, movement-only heading, GPS-fix gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:51:15 +02:00
JakubandClaude Opus 4.8 32ffcb18a3 feat(nav): "Clear waypoints" action in Trail menu
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>
2026-06-03 23:49:26 +02:00
JakubandClaude Opus 4.8 288a6195da feat(ui/compass): draw the ring as a circle, not a square
Replace the bounding-box stand-in with a midpoint-circle ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:54:30 +02:00
JakubandClaude Opus 4.8 98ccf13336 feat(nav): heads-up compass + always-available Trail-start waypoint
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>
2026-06-03 16:51:40 +02:00
JakubandClaude Opus 4.8 3110b59f93 fix(ui/tools): scroll the Tools list
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>
2026-06-03 14:14:28 +02:00
JakubandClaude Opus 4.8 e354b78b46 fix(nav): ping result never appeared after Send
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>
2026-06-03 14:11:03 +02:00
JakubandClaude Opus 4.8 8bf050ddb4 feat(nav): standalone Compass in Tools
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>
2026-06-03 14:07:53 +02:00
JakubandClaude Opus 4.8 ed984e437b feat(nav): node detail Options menu (Navigate/Ping); ping rows no longer blank
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>
2026-06-03 14:00:16 +02:00
JakubandClaude Opus 4.8 e578525a95 fix(nav): drop nav view when target contact disappears; cap label entry
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>
2026-06-03 08:36:41 +02:00
JakubandClaude Opus 4.8 6ca1c766ef feat(nav): navigate to a node from Nearby
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>
2026-06-03 08:32:32 +02:00
JakubandClaude Opus 4.8 6f7647481a feat(nav): draw waypoints on the trail map
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>
2026-06-03 08:30:16 +02:00
JakubandClaude Opus 4.8 99bfcb1bd7 feat(nav): waypoints in Trail — mark, list, navigate
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>
2026-06-03 08:28:15 +02:00
JakubandClaude Opus 4.8 12e47ea044 feat(nav): shared NavView — two-bearing navigate-to-point render helper
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>
2026-06-03 08:23:59 +02:00
JakubandClaude Opus 4.8 b1dfde5fcb feat(nav): WaypointStore — persistent /waypoints table
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>
2026-06-03 08:23:11 +02:00
JakubandClaude Opus 4.8 11c0b7c4a5 feat(nav): course-over-ground ring in UITask (compass without magnetometer)
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>
2026-06-03 08:20:59 +02:00
JakubandClaude Opus 4.8 acec8e94dd refactor(geo): lift bearing/haversine/fmtDist into shared GeoUtils.h
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>
2026-06-02 23:51:08 +02:00
Jakub e019afcfc0 docs(features): waypoints + node navigation design (compass via GPS COG) 2026-06-02 23:49:21 +02:00
JakubandClaude Opus 4.8 5b25d1eb32 feat(ui/clock): stacked big-digit clock on portrait e-ink
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>
2026-06-02 23:32:27 +02:00
Jakub 49536cb3a5 Merge remote-tracking branch 'upstream/main' into wio-unified 2026-06-02 22:14:33 +02:00
Jakub 6774a08000 docs(readme): add GPX Trail Export section (lost in rebase merge=ours) 2026-05-31 16:24:29 +02:00
JakubandClaude Opus 4.8 3cf381e137 feat(tools): trail_export.py — capture GPX over USB serial
Adds tools/trail_export.py: auto-detects the Wio port, waits for the
<?xml header, captures bytes until </gpx>, and writes a timestamped file
to tools/gpx/. Replaces the abandoned synthetic-FAT USB-MSC approach with
a simple host-side script.

- README: new "GPX Trail Export" section
- tools_screen.md: trail_export.py shown as the recommended download path,
  cat/PuTTY kept as a manual fallback
- .gitignore: ignore tools/gpx/ output dir (mirrors tools/pngs/)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:23:44 +02:00
JakubandClaude Opus 4.8 43d989821a fix(ui/channels): unread badge no longer outlives evicted history
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>
2026-05-31 16:23:44 +02:00
JakubandGitHub d4926da792 docs: added discord thread link
Added links to the Solo firmware thread in the README.
2026-05-31 13:04:36 +02:00
JakubandClaude Opus 4.7 5ab807a02a fix(trail): map grid no longer silently disappears on elongated trails
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>
2026-05-31 00:19:40 +02:00
JakubandClaude Opus 4.7 e578300eff fix: smaller audit items — strnlen, narrow-display guard, SNR precision
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>
2026-05-31 00:11:43 +02:00
JakubandClaude Opus 4.7 9113b7f12e fix: guard findChannelIdx == -1 + scope trail_units reset
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>
2026-05-30 23:57:12 +02:00
Jakub 321d769eb5 docs: update releases URL to MeshCore-Solo repo 2026-05-30 23:36:21 +02:00
JakubandClaude Opus 4.7 684e2b6439 fix(ui/nearby): lock ping popup highlight and dedupe Ping labels
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>
2026-05-30 23:35:07 +02:00
Jakub e2b1ccefcc docs: update readme with eink screenshot for ping option 2026-05-30 23:19:24 +02:00
JakubandGitHub 9ec55254b0 Merge pull request #12 from vanous/ping_nodes
Nearby Nodes: Add Ping
2026-05-30 23:10:33 +02:00
JakubandClaude Opus 4.7 cb37d23eb0 fix(prefs): skip channels with empty secret in loadChannels
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>
2026-05-29 19:56:25 +02:00
Jakub 8cfe4c3ac8 Merge branch 'new_docs' into wio-unified 2026-05-29 12:51:49 +02:00
JakubandClaude Opus 4.7 fb700bbaad docs+ci: pre-merge polish — README, doc accuracy, workflow
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>
2026-05-29 12:46:29 +02:00
JakubandClaude Sonnet 4.6 c7009140ce refactor: rename firmware from Plus to Solo
- FIRMWARE_VERSION: v1.15-plus.1 → v1.15-solo.1
- FIRMWARE_PLUS_BUILD → FIRMWARE_SOLO_BUILD in all platformio.ini and UITask.cpp
- docs/plus_features/ → docs/solo_features/ (all sub-docs moved)
- README: update all doc links plus new Documentation section
- GitHub Actions: extract GIT_TAG_VERSION in each build job (was missing,
  causing empty FIRMWARE_VERSION in builds); rename output files to
  solo-VERSION-oled.uf2 / solo-VERSION-eink.uf2; release title → "Solo VERSION"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:25:34 +02:00
Jakub df32f47942 docs: add settings_screen.md — full options with tables and e-ink notes
Tables for all seven sections (Display, Sound, Home Pages, Radio, System,
Contacts, Messages) with values, LEFT/RIGHT vs Enter interaction, and
e-ink-only flags. Notes: buzzer Auto mode, battery % anchor from low_batt,
Melody 1/2 link to Ringtone Editor, placeholder support in quick replies.
2026-05-29 11:26:37 +02:00
Jakub ce4d5ea1da Revert "fix(ui/favourites): DM unread badge cleared by companion app sync"
This reverts commit f6d10bb8b2.
2026-05-29 11:12:30 +02:00
JakubandClaude Sonnet 4.6 f6d10bb8b2 fix(ui/favourites): DM unread badge cleared by companion app sync
msgRead(0) was called when the companion app read the last message from
the offline queue, and it erased the entire _dm_unread_table. This wiped
Favourites Dial badges the moment the app synced — leaving a 3-second
window (the alert duration) where the badge was faintly visible behind
the incoming-message popup, then gone.

_dm_unread_table tracks per-contact UI state (Favourites Dial badges,
contact list counters). It should only be cleared by user action:
clearDMUnread (opens a DM) or clearAllDMUnread (mark-all-read). It has
no relationship to the offline queue drain state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:11:57 +02:00
JakubandClaude Sonnet 4.6 0e0e5b938a fix(ui/favourites): pin picker fallback to all contacts
When the user presses + on an empty Favourites Dial tile, the picker
previously showed 'No fav contacts' if there were no starred contacts
(flags & 0x01) AND no recent DMs in _dm_hist (empty after reboot).

Add a third fallback tier: when the first two tiers yield nothing, list
all ADV_TYPE_CHAT contacts so the user can always pin anyone. Alert
text updated to 'No contacts' for the remaining edge case (no contacts
at all in the mesh).

Known limitation: per-contact unread badges on the dial reset on reboot
(in-RAM only). Persisted unread is a separate future improvement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:59:34 +02:00
Jakub 096b6720b6 docs: rewrite favourites_dial.md — layout, navigation, badges, pin/unpin
- Describe 2×3 portrait vs 3×2 landscape grid layout
- Navigation: edge keys fall through to page nav
- Unread badge detail (ellipsis + badge coexistence)
- (gone) label for removed contacts
- Two pinning paths: empty tile picker and DM context menu
- Pin picker order: favourited contacts first, then recent DMs
- Auto-move when contact already pinned elsewhere
- Unpin via DM context menu
- Reordering via Settings › Home Pages
2026-05-29 10:45:09 +02:00
Jakub 57ac1b42b2 corrected readme path 2026-05-29 10:07:03 +02:00
Jakub ab396eb7a1 Added eink screenshot variants 2026-05-29 10:04:05 +02:00
Jakub 7243793f9b Message screen 2026-05-29 09:51:47 +02:00
Jakub 6ce67b80cf docs: expand message_screen.md — all options, context menus, placeholders
- Describe DM/Channels/Rooms mode-select
- Describe MSG_PICK flow (keyboard vs quick replies Q1-Q10)
- Complete placeholder table — remove {batt} (shows in picker only
  when a voltage sensor is connected, not from board battery)
- Separate contact and channel context menus with full item tables
- Document LEFT/RIGHT cycling for Notif/Melody/Fav
- Describe Pin to dial with slot picker detail
- Clarify short Enter (fullscreen) vs Hold Enter (context menu)
- Add fullscreen navigation and scroll description
2026-05-29 09:41:35 +02:00
Jakub 9b970bce3a Merge branch 'wio-unified' into new_docs 2026-05-29 09:11:54 +02:00
JakubandClaude Sonnet 4.6 db8c52ea9a fix(ui): standardize context menu cycling + PopupMenu height clamping
PopupMenu:
- _cap now uses max_by_height as a hard ceiling (never draw outside screen).
  Previously max(_visible, max_by_height) let callers exceed the screen on
  OLED 64px. Now: OLED→4 items, landscape e-ink→10, portrait e-ink→22.

QuickMsgScreen:
- Contact context menu: Notif and Melody cycle with LEFT/RIGHT in-place;
  ENTER closes without re-cycling.
- Channel context menu: Notif, Melody and Fav same pattern.

RingtoneEditorScreen:
- Migrated from bespoke menu to PopupMenu (same pattern as everywhere else).
- Duration (1/4…1/32) and BPM (60…180) are now single rows that cycle with
  LEFT/RIGHT; separate BPM+/BPM- rows removed.
- Fixed _menu_dur_label buffer too small for "Duration: 1/16" (14→16 bytes).

NearbyScreen:
- Removed redundant "Back" item from context menu (Cancel key navigates back).

TrailScreen:
- Grid toggle responds to LEFT/RIGHT in addition to ENTER.
- Export labels: "Export GPX (live/saved)" → "Export (live/saved)" to fit PM_BW.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:25:02 +02:00
JakubandClaude Sonnet 4.6 5caac0b693 fix(ui/trail): clarify export menu labels — live vs saved GPX
'Export GPX' and 'Export saved' were ambiguous. Renamed to
'Export GPX (live)' (RAM ring) and 'Export GPX (saved)' (flash file)
so the source of each export is immediately clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:43:45 +02:00
JakubandClaude Sonnet 4.6 95795a663c feat(ui): popup menu auto-expands on tall displays (portrait e-ink)
On a 250px-tall portrait e-ink panel the menu previously showed only 3-4
items and required scrolling. render() now computes the maximum items that
fit from display.height() and uses that instead of the hardcoded caller
hint whenever the screen is taller. handleInput() uses the same _cap value
so scroll tracking stays in sync.

OLED (64px): fits 4 items — unchanged behaviour.
Portrait e-ink (250px): fits up to 22 items (capped at PM_MAX_ITEMS=16),
no scroll needed for typical context menus (4-6 items).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:20:15 +02:00
JakubandClaude Opus 4.7 54367327f7 Merge feat/eink-screenshot — e-ink screenshot support
Adds GxEPD2 framebuffer capture to the screenshot tool, extending the
existing OLED support to the Wio Tracker L1 e-ink variant.

Key changes:
- GxEPD2-patch: patched copy of GxEPD2_BW.h exposing getBuffer/getBufferSize
- GxEPDDisplay: override getBuffer/getBufferSize/getDisplayType/
  screenshotWidth/screenshotHeight/screenshotRotation using GxEPD2's
  own reported visible dimensions (WIDTH_VISIBLE, not physical WIDTH)
  and live rotation value
- DisplayDriver: virtual screenshot interface so no C-style casts needed
- Protocol: 11-byte header with display_type, rotation, uint16 LE
  width/height/chunk_idx/total_chunks (eliminates truncation for panels >255px)
- screenshot.py: full rot 0-3 decoder for GxEPD2 buffer layout; ERR frame
  checked before length so device errors surface correctly; buffer-size
  sanity check using panel-aware expected size

Conflict resolution: kept feat/eink-screenshot protocol and decoder
(11-byte header, e-ink decode) over the OLED-only 5-byte version on
wio-unified; battery and joystick fixes from wio-unified kept intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:07:09 +02:00
JakubandClaude Opus 4.7 5dd035d309 fix(screenshot): drop C-style cast UB, fix ERR check order
handleScreenshotRequest() reinterpret-cast every DisplayDriver* first to
SH1106Display* then to SSD1306Display*; both casts always produce a
non-null pointer, so the SSD1306 fallback was unreachable and on any
SSD1306 build we called SH1106::getBuffer() on a SSD1306 object — UB
that only happened to work because both backing classes share an
Adafruit_GFX layout at offset zero.

Replace the cast hack with virtual getBuffer()/getBufferSize() on
DisplayDriver, overridden in SH1106Display and SSD1306Display. MyMesh
no longer needs to know about either concrete type. const-correct the
buffer pointer and widen bufferSize to uint16_t while passing through.

Also fix the matching Python decoder: it sanity-checked length before
dispatching on resp_code, so a 2-byte RESP_CODE_ERR frame was reported
as "Frame too short" instead of the actual device error. Move the
length check after the resp_code dispatch and log the error code byte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:58:26 +02:00
JakubandClaude Opus 4.7 6687d2b0e6 fix(ui/dashboard): Batt% uses same LiPo curve + low_batt_mv cutoff as top bar
The Clock-page Batt% field used a separate hardcoded linear mapping
(3000-4200 mV → 0-100%) that disagreed with the top-bar battery indicator,
which uses a piecewise LiPo discharge curve and the user-configurable
low_batt_mv cutoff (Settings → Low battery threshold) as the 0% anchor.
The two readings could differ by 25+ percentage points for the same
voltage, and the dashboard ignored the cutoff setting entirely.

Extract battMvToPercent(mv, low_mv) as a file-static helper so both the
top-bar indicator and the dashboard field share the same curve and the
same cutoff source. Drop the stray BATT_MIN/MAX_MILLIVOLTS #defines
that leaked from inside the dashboard code paths into the rest of the
TU.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:57:50 +02:00
JakubandClaude Opus 4.7 a84cee0f57 fix(screenshot): widen header to uint16, sanity-check buffer, fix ERR path
Protocol header grows from 7 to 11 bytes: display_type, rotation, then
uint16 LE width / height / chunk_idx / total_chunks. Removes silent
truncation on panels >255 px (e.g. future 4.2" 400x300 e-ink).

Decoder now:
- checks RESP_CODE_ERR before length so error frames don't surface as
  "Frame too short"; logs the error code byte
- validates received buffer size against expected (OLED: w*h/8; e-ink:
  ceil(min(w,h)/8) * max(w,h)) so truncated transfers fail loudly
- initializes disp_rotation in outer scope and detects missed chunk 0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:03:05 +02:00
JakubandClaude Sonnet 4.6 9dcbc74636 fix(screenshot): remove black border from output image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:12:05 +02:00
JakubandClaude Sonnet 4.6 d1a88c8277 fix(screenshot): send GxEPD2 rotation in header; full rot 0-3 decode in Python
Firmware (7-byte header):
  Byte 7 = display->screenshotRotation() — GxEPD2/GFX rotation value (0-3).
  DisplayDriver::screenshotRotation() defaults to 0 (OLED).
  GxEPDDisplay::screenshotRotation() returns display.getRotation(), the
  live GxEPD2 value (reflects runtime setDisplayRotation() calls, not just
  the compile-time DISPLAY_ROTATION macro).

Python decoder:
  - Parse 7-byte header; pass rotation to eink_buffer_to_image().
  - Implement all four GxEPD2 drawPixel coordinate transforms:
      rot 0: phys_x=lx,            phys_y=ly
      rot 1: phys_x=vis_w-1-ly,    phys_y=lx
      rot 2: phys_x=vis_w-1-lx,    phys_y=vis_h-1-ly  (180° flip)
      rot 3: phys_x=ly,            phys_y=vis_h-1-lx
  - vis_w/vis_h derived from log dimensions + rotation parity (no constants).
  - Print rot= in the status line so the user sees which rotation was used.

This fixes the 180° rotated image seen with rotation=2 (portrait inverted)
without hardcoding a flip — correct for any rotation value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:10:16 +02:00
JakubandClaude Sonnet 4.6 37432491dd fix(screenshot/eink): handle portrait and landscape rotations in decoder
The previous decoder always applied the rotation=1 (landscape) transform,
which caused three visible artefacts on a portrait (rotation=0) device:
  - Content rotated 90°     — wrong phys_x/phys_y mapping
  - Content doubled/skewed  — stride = buffer/log_width = 4000/122 = 32
                               instead of buffer/HEIGHT  = 4000/250 = 16

Fix: infer rotation from aspect ratio (portrait: log_height > log_width).
  portrait  → direct mapping: phys_x=lx, phys_y=ly
  landscape → rotation-1 map: phys_x=log_height-1-ly, phys_y=lx

Derive phys_stride from buffer_size // max(log_w, log_h) — the physical
HEIGHT is always the longer axis regardless of rotation, giving 16 bytes/row
for GxEPD2_213_B74 in both orientations.

No panel-specific constants remain; works for any GxEPD2 panel/rotation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:54:45 +02:00
JakubandClaude Sonnet 4.6 1332358ee7 fix(screenshot): use GxEPD2 visible dimensions; fix phys_x formula
Firmware: add screenshotWidth()/screenshotHeight() virtual pair to
DisplayDriver (default: width()/height()).  GxEPDDisplay overrides to
return display.width()/display.height() — the GxEPD2-reported values
that use WIDTH_VISIBLE (e.g. 122 for GxEPD2_213_B74), not the full
physical WIDTH stored in DisplayDriver (128).  MyMesh uses these
instead of display->width()/height() for the screenshot header bytes.

Result for GxEPD2_213_B74 + DISPLAY_ROTATION=1:
  header was 250×128 → now 250×122  (correct visible canvas)

Python decoder (tools/screenshot.py):
- Remove hardcoded EINK_PHYS_WIDTH=128 / EINK_VISIBLE_W=122 constants.
- Derive phys_stride from buffer_size / log_width (works for any panel).
- Fix phys_x formula: was (EINK_PHYS_WIDTH-1-ly = 127-ly),
  now (vis_w-1-ly = log_height-1-ly = 121-ly for 213_B74).
  Old formula addressed invisible columns 122-127; new formula correctly
  maps logical rows 0..121 to visible physical columns 121..0.
- vis_w = log_height (no hardcoded trim; header now carries correct value).

This also works for square panels (e.g. 128×128 where WIDTH=WIDTH_VISIBLE):
  header will carry 128×128, decoder produces a correct 128×128 image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:19:22 +02:00
JakubandClaude Sonnet 4.6 7d9d5f847d feat(screenshot): extend e-ink screenshot support via GxEPD2 patch
- Add lib/GxEPD2-patch/src/GxEPD2_BW.h: local patched copy of GxEPD2_BW.h
  that exposes getBuffer()/getBufferSize() under ENABLE_SCREENSHOT guard.
  Include guard (_GxEPD2_BW_H_) prevents double-inclusion of the installed
  library version.

- GxEPDDisplay.h: use patched header via relative include when
  ENABLE_SCREENSHOT so the patch takes precedence over the installed lib
  (PlatformIO adds library paths before -I build_flags).

- DisplayDriver.h: add virtual getBuffer()/getBufferSize()/getDisplayType()
  defaults (nullptr/0/0) under ENABLE_SCREENSHOT.

- SH1106Display.h / SSD1306Display.h / GxEPDDisplay.h: add concrete overrides;
  getDisplayType() returns 0 (OLED) or 1 (e-ink).

- MyMesh.cpp/h: replace fragile C-cast with virtual dispatch in
  handleScreenshotRequest(); extend 5-byte header to 6 bytes by appending
  display_type so the host tool can decode the correct pixel layout.

- tools/screenshot.py: parse 6-byte header; add eink_buffer_to_image()
  that decodes the row-major MSB-first GxEPD2 buffer with DISPLAY_ROTATION=1
  (phys_x = 127-ly, phys_y = lx); dispatch on display_type.

- variants/wio-tracker-l1-eink/platformio.ini: add
  [env:WioTrackerL1Eink_companion_dual_dev] with ENABLE_SCREENSHOT.
- variants/wio-tracker-l1/platformio.ini: unchanged (OLED env already exists).

Builds verified: WioTrackerL1Eink_companion_dual_dev SUCCESS,
                 WioTrackerL1Eink_companion_radio_ble SUCCESS (unaffected).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:03:47 +02:00
JakubandClaude Sonnet 4.6 c92264c21e fix(ui): OLED joystick rotation — enforce reset after prefs rd()
The previous fix set joystick_rotation=0 before reading from file, but
the subsequent rd() call overwrote it with whatever was stored (e.g. 2
from a prior e-ink firmware). Move the hardware-enforce block to after
rd() so stale values in flash are always corrected on OLED builds
(FEAT_JOYSTICK_ROTATION_SETTING=0). Remove the now-redundant pre-read
block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:49:19 +02:00
JakubandGitHub 82fa152a94 Merge pull request #11 from vanous/voltage_percentage
Dashboard Clock field: Add Battery percentage
2026-05-27 23:47:22 +02:00
JakubandGitHub f9167a01bf Merge pull request #10 from vanous/screenshot_tool
Add Screenshot tool and a build flag for conditional screenshot functionality
2026-05-27 22:45:43 +02:00
JakubandClaude Sonnet 4.6 5b40e91df3 feat(contacts): channel favourites — filter in Settings + context menu
Add per-channel favourite marking and a Settings filter (all/fav) for
the channel list, symmetric with the existing DM and Room filters.

NodePrefs:
- ch_fav_bitmask (uint64_t): bit i = channel i is marked favourite
- ch_fav_only (uint8_t): 0=show all channels, 1=favourites only
- SCHEMA_SENTINEL bumped to 0xC0DE0005

DataStore: save/load both new fields before the sentinel.

SettingsScreen: new CH_FILTER item ('Channels: all/fav') in the
Contacts section, positioned between DM_FILTER and ROOM_FILTER.

QuickMsgScreen:
- buildChannelList() skips non-favourite channels when ch_fav_only=1
- Channel context menu gains a 4th option 'Fav'/'Unfav' that toggles
  ch_fav_bitmask for the selected channel and rebuilds the list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:33:52 +02:00
JakubandClaude Sonnet 4.6 dd9c75d4f4 fix(ui/input): first melody note duration; OLED joystick reset
UITask: when a key is pressed while the buzzer is playing, defer the
next screen refresh by 300 ms instead of triggering it immediately
(via _next_refresh = 100). The blocking e-ink endFrame() was extending
the first note by the full refresh duration (~150-200 ms). Subsequent
notes were unaffected because _next_refresh was already future-dated
after the first render.

DataStore: for builds where JOYSTICK_ROTATION is not defined and
FEAT_JOYSTICK_ROTATION_SETTING is 0 (OLED), always reset
joystick_rotation to 0 on prefs load. Without this, stale e-ink prefs
(which allow changing joystick rotation in Settings) could silently
leave a non-zero value that reversed all joystick directions on OLED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:33:41 +02:00
JakubandClaude Sonnet 4.6 2e3d6381a4 fix(ui): e-ink block char position in default-font path
In GxEPDDisplay::print(), the 0xDB fallback block was drawn at
cy - 8*scale, which assumed the Lemon baseline convention
(cy = original_y + 8*scale). For the default GFX font fontAscender
returns 0, so cy = original_y (top of cell) — the block landed
16 px above the character on e-ink (scale=2).

Fix: draw at cy (top of cell) for the non-Lemon path.

Also remove the unused forceNextRefreshFull() virtual from
DisplayDriver — it was added based on a wrong hypothesis (partial
refresh ghosting) and was never wired into endFrame().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:33:31 +02:00
JakubandClaude Sonnet 4.6 64b924a9c7 feat(ui): GPS trail — scale grid on map view
Add an optional dot/intersection scale grid to the trail map view:
- renderGrid() draws labelled grid lines anchored to display edges
- Grid step auto-selected for ~3 intervals in the shorter dimension
- MIN_GRID_PX=22 prevents density on small/OLED screens; clamp ensures
  minimum 2 intervals on large trails
- Intersections inside the label bbox and north-arrow bbox are suppressed
- Toggle via Hold-Enter action menu (Grid: ON/OFF item in ActionId enum)
- Map title shows 'TRAIL MAP+' when grid is active
- README updated to mention grid toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:33:20 +02:00