Files
MeshCore-Solo/FEATURES.md
Jakub 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

20 KiB
Raw Blame History

Feature roadmap

Joystick-only UX constraints: 4 directions + Enter + Back. No text entry except inside KeyboardWidget. Everything else navigable with cursor + press.

Status legend: 📋 planned · 🚧 in progress · done · rejected/deferred


Priority queue

Mark-all-read at type level

Hold Enter on the MESSAGE mode-select screen (DM / Channels / Rooms) opens a 1-item context menu "Mark all read". Acts on the currently highlighted mode and shows a brief confirmation alert.

Implementation:

  • New UITask::clearAllDMUnread()memset over _dm_unread_table
  • QuickMsgScreen::clearAllChannelUnread() already existed
  • UITask::clearRoomUnread() already existed
  • Title is a static const char* table (PopupMenu stores the title pointer verbatim — locals would dangle)
  • Zero schema impact, all counters live in RAM

Favourites dial

Phase 1 (storage + read-only render + grid nav) Phase 2 (pin from Contact options menu in QuickMsg + slot picker submenu)

  • Enter on a filled tile opens that contact's DM directly
  • Cancel from a Favourites-opened DM returns to the home screen Phase 3 (in-place pin picker on empty tile: upstream-favourited contacts first, then recent DM contacts deduped; selecting a contact that's already pinned elsewhere moves it to the new slot)

Follow-up done: OLED unread-badge overlap fixed — badge and name share the same baseline, drawTextEllipsized's max width subtracts badge width + a 3 px gap so names shorten to "Nam…" before the digit.

A 2×3 grid (six slots) of pinned contacts on its own home page, between Clock and Messages. Joystick picks a tile, Enter opens the existing DM conversation or sends a pre-set quick reply.

Data model:

  • New field in NodePrefs: uint8_t favourite_contacts[6][6] — first 6 bytes of each contact's pub_key (enough to disambiguate locally)
  • Lookup at render time: walk contacts, match prefix, render name + unread badge
  • Empty slot renders as "+" placeholder; Enter on empty opens a contact picker (existing UI)

Pinning UX:

  • In QuickMsg DM list, long-press on a contact → context menu → "Pin to dial" → asks which of the 6 slots
  • Unpin via the same menu (only shown when contact is already pinned)

Schema bump: add favourite_contacts to NodePrefs, bump SCHEMA_SENTINEL low byte.

Render layout (250×122 landscape e-ink):

╔══════════════════════════════╗
║ Favourites                   ║
╠══════════════════════════════╣
║ ┌──────┐ ┌──────┐ ┌──────┐   ║
║ │Alice │ │Bob 3 │ │  +   │   ║
║ └──────┘ └──────┘ └──────┘   ║
║ ┌──────┐ ┌──────┐ ┌──────┐   ║
║ │Carol │ │  +   │ │  +   │   ║
║ └──────┘ └──────┘ └──────┘   ║
╚══════════════════════════════╝

Joystick navigation is natural with 6 tiles (UP/DOWN between rows, LEFT/RIGHT within row).

🚧 GPS trail (renamed from breadcrumb)

Phase 1 (storage + sampling + Summary view + G indicator in status bar) Phase 2 (auto-fit Map view with cos(lat) aspect compensation; LEFT/RIGHT cycles views)

  • Summary scrolls on short panels (OLED) so hint stops overlapping
  • Status-bar G blinks at the same cadence as A (forces 1 s home refresh)
  • Default sampling 30 s + 5 m min-delta (was 60 s + 25 m — too sparse on foot)
  • "Avg speed" replaces "Speed"; Time uses RTC so it ticks every render
  • Stop → start creates a new segment; the map doesn't bridge dead time, and total distance skips segment boundaries Phase 3 (per-point list view with HH:MM local time + delta-from-previous; segment-start rows show "start" instead of a delta) Phase 4 (Hold-Enter popup grows Save / Load / Reset / Export GPX / Export saved entries. Single flash slot at /trail (binary header with magic+version+count+accumulated_ms then raw TrailPoint records). GPX 1.1 dump goes over USB Serial; "Export GPX" streams the live RAM ring, "Export saved" streams the flash file straight to USB without touching the live ring. Segments respect SEG_START boundaries. Alert reflects BLE-app-collision state) Phase 5 (Settings + actions consolidated into a single Hold-Enter popup — Min dist + Units cycled with LEFT/RIGHT (popup stays open), plus Start/Stop tracking and Reset action items. Short Enter never toggles — both start and stop go through the popup, so a stray tap can never change tracking state. View counter (N/3) lives in the title bar; the bottom hint row is gone, content fills the freed space. Sampling cadence fixed at 1 s, GPS upd setting also removed; both rely on the sensor manager's defaults) Polish (map view: filled/open dot markers around segment breaks; "Waiting for GPS fix" status when started without a lock; capacity bumped to 512 points; elapsed/avg-speed run on millis() instead of RTC so they tick even before GPS time is synced)

Tools Breadcrumb. Periodically samples (lat, lon, ts) into a RAM ring buffer; user explicitly saves snapshots to flash.

Logging is a runtime state, not a settings value. User starts/stops from the Tools Breadcrumb screen. Once active, sampling continues in the background regardless of which screen is shown, and a G indicator appears in the status bar (analogous to A for auto-advert). A reboot resets the active state to off; the RAM trail is also lost on reboot unless saved to a flash slot first.

Settings only control sampling cadence and the min-distance gate — they don't enable/disable the feature.

Storage model — RAM ring with explicit save

Rationale: auto-off only blanks the display, the firmware keeps running, so the RAM trail survives every idle scenario. Typical use is a single trip start→stop while wearing the device; persisting across reboots is rarely wanted. RAM-only avoids ~1400 flash writes/day and the LittleFS wear that comes with continuous logging.

  • Live ring: BreadcrumbEntry[BC_RAM_CAP] in UITask (or a dedicated component). Each entry int32_t lat_1e6, int32_t lon_1e6, uint32_t ts = 12 B. Cap = 256 → 3 KB RAM. nRF52840 (256 KB RAM) has plenty of headroom.
  • Wrap-on-write: oldest entry replaced when buffer full.
  • Reboot wipes the live trail (intentional; matches the "this trip" model).

Snapshot slots on flash (user-initiated only):

  • /breadcrumb.0, /breadcrumb.1, /breadcrumb.2 — three named slots
  • Each file: small header (count, start_ts, end_ts, total_distance_m) + entry array
  • Written only on explicit "Save trail" action — zero background writes, zero wear concern
  • Optional: auto-save to slot 0 on detected low-battery shutdown (single write before going dark)

UI screens (LEFT/RIGHT cycles):

  1. Summary — total distance (km), elapsed time (h:mm), point count, current speed (from last 2 samples), GPS fix indicator
  2. Trail map — ASCII bounding-box plot. Auto-fit the polygon, current position marked X, start marked *. UP/DOWN zoom, LEFT/RIGHT pan when zoomed.
  3. Last N entries list — scroll through recent points with timestamp + delta from previous.

Joystick actions:

  • Enter → toggle live logging on/off (status bar shows * when active, like auto-advert A)
  • Back → exit
  • Hold Enter → context menu:
    • "Reset trail" — clear RAM ring
    • "Save trail → slot N" — snapshot RAM into chosen flash slot
    • "Load trail ← slot N" — restore from chosen slot into RAM ring
    • "Export over USB" — dump live RAM trail as KML/GPX over serial

Statistics computed on the fly walking the ring:

  • Total distance: sum of Haversine(p[i], p[i-1])
  • Elapsed time: ts[last] - ts[first]
  • Current speed: dist(last, prev) / (ts[last] - ts[prev])

Settings:

  • Settings GPS Breadcrumb interval: 30 s / 1 min / 5 min / 15 min (default 1 min) — only the cadence; logging on/off is a Tools toggle
  • Settings GPS Breadcrumb min delta: 5 m / 25 m / 100 m (skip near-stationary samples to keep the ring densely populated with real movement)
  • Export format: GPX (standard for GPS tracks; OSMAnd / Garmin compatible)

Schema impact: new prefs fields uint8_t breadcrumb_interval_idx, uint8_t breadcrumb_min_delta_idx. Sentinel bump. The slot files are separate from prefs.

Edge cases:

  • No GPS fix: skip sampling, status indicator dims
  • Low-batt shutdown: optional auto-save to slot 0 (one write) before powerdown
  • Memory: 3 KB RAM is negligible on this MCU; if RAM ever tightens, drop to 128 entries

Backlog (not yet prioritised)

Compass to contact

Select contact in Nearby → Enter → fullscreen compass. Arrow points to last-known position of contact relative to my GPS heading (need accel/mag or compute heading from movement). Distance below. Updates every 1 s.

Joystick: Enter from Nearby toggles, Back exits. Heading-from-GPS requires moving; for stationary use, would need magnetometer that the L1 board does or doesn't have — check.

SOS broadcast

Configurable in Settings System SOS:

  • Target: channel index or DM contact
  • Message template (uses placeholders)

Trigger: Hold Back + Hold Enter for 3 s on any screen → confirmation popup ("Send SOS?") → Enter to send. Sends with {loc} and {batt} filled. 30 s cooldown.

Range test

Tools Range Test:

  • Pick a node from contacts/nearby
  • Enter starts pinging every 5 s, logs RTT + RSSI + SNR (ring ~30)
  • Display shows current values + 30-sample sparkline (block characters)
  • Enter stops; Hold Enter for context menu (reset, change target)

Quiet hours

Settings Sound Quiet Hours:

  • Enable on/off
  • Start HH (LEFT/RIGHT to change, 24 h)
  • End HH

When within window: buzzer set to "off" (overrides setting), display brightness → 0. Restores prefs values when window ends. Time source: rtc_clock.

Channel scanner home page

Toggleable in Settings Home Pages. Lists channels with: name, unread count, last message age. Enter opens the channel. Sort by recency by default; LEFT/RIGHT toggles to alphabetical.

Contact distance sort

QuickMsg DM list: a 4-th sort mode (currently sorted by message count). LEFT/RIGHT on the list header cycles: name | message-count | recency | distance. Distance uses GPS pos from contact's last advert.

Signal stats screen

Tools Stats:

  • Battery voltage 60-min sparkline
  • RSSI of last 30 received packets
  • Noise floor current
  • Free heap (if available)

Read-only. UP/DOWN switches between metrics. Bottom shows current value as text.

Auto-reply trigger words with sensor placeholders

Bot already has a single trigger word. Extend to a small table of (trigger, reply-with-placeholder) pairs:

  • "temp?" → replies with {temp}
  • "loc?" → replies with {loc}
  • "batt?" → replies with {batt}

Stored in NodePrefs as fixed-size table (say 4 pairs × 16 B). UI: edit pairs in Tools Auto-Reply Bot.

Lock-screen unread count

Show 12 new or two-line 5 DM / 7 ch on lock screen below the time. Reuses existing _hist/_dm_hist unread counters. Cosmetic only, no schema change.

Power profile presets

Settings Profile: Indoor / Outdoor / Expedition. Each pre-fills:

  • Auto-off seconds
  • GPS interval
  • Auto-advert interval
  • Brightness

Single Enter applies. Stored as uint8_t profile_idx with hardcoded value tables.

Battery curve calibration

Settings System Batt Calibration: edit 5 voltage breakpoints used to convert mV → %. UP/DOWN selects breakpoint, LEFT/RIGHT changes voltage in 50 mV steps. Helps users with non-standard LiPos report accurate %.

Mark-read at type level — already covered above as priority

Display test pattern

Tools Display Test: full-screen grid + bars + Lemon glyph dump. Useful for verifying driver/font changes after flashing.

Group "who's online" ping

From channel view, Hold Enter → "Who's online?". Sends 0-hop discovery to channel members, collects responses for 10 s, shows a list with RSSI. Similar to existing Nearby active discovery but scoped to a channel.


Deferred

Morse keyboard

Cute but niche. Skip unless explicitly requested.

Buzzer EVENTS_STOPPED IRQ chaining

Marginal real-world gain (2 ms between notes during melody playback only), high risk on the PWM peripheral.

Vibration feedback

Wio Tracker L1 doesn't have a haptic motor. N/A.


Implementation order suggestion

  1. Mark-all-read — smallest patch, biggest day-to-day comfort gain
  2. Favourites dial — new home page + new prefs field; touches familiar areas (QuickMsg context menu, NodePrefs, schema sentinel bump)
  3. GPS breadcrumb — largest of the three; introduces a new flash file and a Tools sub-screen with multiple views

After #3, re-prioritise the backlog with the user.


Code audit — known bugs / hardening backlog

Pass through wio-unified after commit 321d769e. Grouped by severity. Listed but not yet fixed.

Critical

onChannelMessageRecv / onChannelDataRecv — guard for findChannelIdx == -1

MyMesh.cpp:561-602, MyMesh.cpp:619-625

Both group-channel receive paths now check the result of findChannelIdx() before continuing:

int idx = findChannelIdx(channel);
if (idx < 0) {
  MESH_DEBUG_PRINTLN("...: unknown channel secret — dropping message");
  return;
}
uint8_t channel_idx = (uint8_t)idx;

Unknown-secret packets no longer pollute the offline queue, UI history or trigger the bot with a bogus idx=255.

addChannelMsg guards against bogus index

QuickMsgScreen.h:414-433

Defensive if (ch_idx >= MAX_GROUP_CHANNELS) return; at function entry — prevents ring-buffer pollution in case any future caller forgets the upstream guard. With C1 fixed this should never trigger, but the cost is zero.

High

📋 findChannelIdx scans all-zero secret in uninitialised slots

BaseChatMesh.cpp:892-897

for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {  // full range, not num_channels
  if (memcmp(ch.secret, channels[i].channel.secret, 32) == 0) return i;
}

If ch.secret is all-zero (uninitialised or corrupted) and an unused slot is also all-zero, the function returns that unused slot as a "match". Upstream code — needs upstream fix or local override.

📋 saveChannels writes all 40 slots to /channels2

DataStore.cpp:563-580 + BaseChatMesh.cpp:871-877

saveChannels calls getChannelForSave(idx, ch) in a loop; BaseChatMesh::getChannel(idx) returns true for any idx < MAX_GROUP_CHANNELS (including uninitialised slots). Result: file is always ~2.7 KB (40 × 68 B). The loadChannels sanity check (skip empty secret) papers over the symptom but the root cause is in save.

📋 msgRead(0) wipes the whole DM unread table

UITask.cpp:1403-1410

if (msgcount == 0) {
    memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
    ((QuickMsgScreen*)quick_msg)->clearAllChannelUnread();
}

When the companion app reads the last message from the offline queue, all on-device badges disappear. Previously discussed and a fix was reverted as "intended sync behaviour" — keep as known limitation; document or restrict to "Favourites Dial badges only".

loadPrefsInt scopes trail_units_idx reset to the 0xC0DE0003 jump

DataStore.cpp:326-343

The reset is now gated on sentinel == 0xC0DE0003 so newer mismatches (e.g. 0xC0DE0004 → 0xC0DE0005, which both saved the field correctly) no longer clobber the user's choice.

Medium

CMD_SET_DEFAULT_FLOOD_SCOPE off-by-one — not a bug

MyMesh.cpp:2143

Re-checked: default_scope_name is declared char[31] (not 32), so n < 31 correctly admits the maximum 30-character string + NUL. The audit entry was a misread.

strlen on cmd_frame without null-termination — replaced with strnlen

MyMesh.cpp:2140-2147

The 31-byte name slot in CMD_SET_DEFAULT_FLOOD_SCOPE doesn't have to be NUL-terminated by the sender. Switched to strnlen(…, 31) so the search can't run past the field into the 16-byte key (or beyond the frame).

📋 PopupMenu._cap updated only in render()

PopupMenu.h:37-44, 77-90

Left as-is: the framework always renders before forwarding input, so the fragile invariant doesn't fire in practice. Worth a refactor only if the call order ever changes.

renderDiscoverDetail guarded against narrow displays

NearbyScreen.h:325-348

Pub-key line is now skipped entirely when max_chars < 4 instead of feeding a negative length to strncpy.

📋 expandMsg GPS validity test treats (0, 0) as invalid

MyMeshBot.h:23, 60

sensors.node_lat != 0.0 || sensors.node_lon != 0.0  // proxy for "valid GPS"

Point (0, 0) is a legitimate location (Gulf of Guinea). Corner case but a logic error. Left for a future pass with a proper GPS-validity bool.

Low

SNR division by 4 — now uses %.1f

NearbyScreen.h:355-357, 487

Detail view (SNR: %.1f dB, Rem: %.1f dB) and discover list cards (SNR:%.1f) both keep the 0.25 dB resolution. Stays consistent with the ping popup, which already used %.1f.

📋 Trail _count cast to uint16_t

Trail.h:176

Safe today (CAPACITY=512 fits), fragile if CAPACITY grows past 65535.

📋 Bot strstr on truncated 199-char buffer

MyMeshBot.h:14-17

Trigger word near the end of a >199-character message will not match.

strncpy("?", buf, sizeof(buf)) replaced with strcpy

QuickMsgScreen.h:785, 858

Two fallback "?" sender names now use a plain strcpy so we don't memset 21 unused bytes for a one-character string.

📋 Title truncation in MSG_PICK reply mode

QuickMsgScreen.h:937

char title[24] for "RE:" + nick[32] truncates long nicks silently.

Priority for merge

Fix status after this pass:

  • C1 + C2 — findChannelIdx == -1 guarded at both channel-recv paths; addChannelMsg defends against bogus index
  • H4 — trail_units_idx reset scoped to the 0xC0DE0003 jump
  • M2 — strnlen instead of strlen on default scope name
  • M4 — renderDiscoverDetail skips pub-key line on very narrow displays
  • L1 — SNR shown with 0.25 dB precision everywhere
  • L4 — fallback "?" sender no longer memsets through strncpy
  • Trail map grid silent loss — when a very elongated trail makes lat_n or lon_n exceed 40, the renderer now bumps the step up instead of dropping the grid entirely. Comment fixed to match the / 3.0f divisor ("~3 intervals", not 4). TrailScreen.h:505-565
  • M1 — re-checked, not a bug (default_scope_name[31])
  • 📋 H1 + H2 — still open; need coordinated fix in upstream BaseChatMesh (findChannelIdx should iterate num_channels, not MAX_GROUP_CHANNELS; saveChannels should stop at the first uninitialised slot) or a local override
  • 📋 H3 — left as known limitation pending UX call
  • 📋 M3, M5, L2, L3, L6 — minor or stylistic; left in backlog