Turn Nearby Nodes into the single node hub and make its navigation legible.
Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
now reuses deleteContactByKey() (one delete path: contact + blob + room password
+ UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.
Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.
Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.
Rename Tools > "Nearby Nodes" to "Nodes".
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-polish trio from CODE_REVIEW (biggest "feels finished" gain per line):
- Pill unread badges: new DisplayDriver::drawUnreadBadge()/unreadBadgeWidth()
draw a filled capsule with the count knocked out (corners knocked back for a
rounded look; inverts on a selected row). Replaces the bare right-aligned
digits in MODE_SELECT, the contact/channel pickers and favourites tiles.
No <stdio.h> in the header — fmtBadgeCount formats manually, clamps to 99+.
- Unified DM/CH history headers: DM_HIST and CHANNEL_HIST drew their titles by
hand (drawTextCentered + fillRect at lh+1), a different height/separator than
every other screen. Both now route through drawCenteredHeader().
- Trimmed default home carousel: new NodePrefs::HP_DEFAULT (Clock, Tools,
Shutdown, Favourites, Map; Messages + Settings always visible = 7 pages).
applyDefaults() seeds it instead of HP_ALL. Recent/Radio/BT/Advert/GPS/Sensors
are opt-in via Settings > Home Pages. Existing users keep their saved mask —
factory default only; no migration, no schema bump.
Both solo envs build green (OLED RAM 69.9%/Flash 62.8%; e-ink SUCCESS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full (non-partial) refresh on every screen change (638eea7b) turned out to be
far too aggressive on real e-ink hardware: every navigation black-flashes,
which is worse than the ghosting it was clearing. Remove the whole mechanism —
DisplayDriver::forceFullRefresh() virtual, GxEPDDisplay's _force_full flag and
override, the endFrame() branch, and the setCurrScreen() call. E-ink is back to
interval-only full refreshes (Settings > Full refresh interval).
The favourites "(gone)"-tile prune that shipped in the same commit is kept.
Builds green: WioTrackerL1Eink_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logging in to a room server (first-time prompt or a remembered password) used
to leave the user on the room list, needing a second Enter to open the chat.
onRoomLoginResult() now opens the room history on success via a shared
openDmHistory() helper (extracted from the Enter-on-contact path).
The login result is async, so the auto-enter is gated on the user still being
on that room in the picker: phase CONTACT_PICK, room mode, no context menu /
share / pick-target sub-flow active, and the result pubkey matching the
selected contact. Otherwise it stays put (no yanking the user into a screen
they navigated away from).
Resolves the "auto-enter after login" code-review item. Builds green:
WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared keyboard seeds {loc}/{time} on begin() for message composition, but
a room/repeater login password is not a message — a placeholder token there is
nonsensical and a footgun (picking one inserts literal "{time}" into the
password). Clear them right after opening the keyboard in both room-login paths
(context-menu Login… and the Enter-on-room prompt), matching the same fix
already applied to preset names and waypoint labels.
Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two duplications surfaced by a framework-consistency pass:
- KeyboardWidget applied the a-z shift-uppercase at five draw/commit sites
with an inline `if (caps && ch >= 'a' && ch <= 'z')`. Fold them into one
`kbApplyCaps(ch, caps)` helper.
- The favourites grid drew the empty "+" tile from two branches (the gone-slot
prune and the always-empty slot). Route both through a single `has_contact`
flag so the "+" and the trailing selection-colour reset each live at one site.
No behaviour change. Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
T9 keys now read like a phone keypad — each cell is labelled <digit><group>
(e.g. 2abc), with the digit matching what the multi-tap cycle lands on after
the letters. No separator space: the widest group (".,!?'-") plus the digit
already fills a narrow 128px OLED cell at 3 columns.
Also fills in the v1.22 release notes (T9 keyboard, trail auto-save, Clock
Tools in Tools, Map/Shutdown reorderable, battery-read fix, the UI-audit fix
batch, discover-dedup, favourites self-heal, preset-name placeholder, OLED
frame-skip, e-ink full-refresh) and syncs the solo-feature docs: new Keyboard
settings section, Trail Auto-save row, and Clock Tools reachable from Tools.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The favourites render already continues on !found (gone-slot prune), so the
badge block only runs when found is true. The if(found) guard around the
unread-badge lookup was always true — remove it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two UI quick wins from the 2026-07-05 review:
- E-ink: force a full (non-partial) refresh on the first frame of a new
screen. Inter-screen ghosting was the most visible cheap win — the
N-partials interval alone doesn't catch navigation. New
DisplayDriver::forceFullRefresh() (no-op on OLED), set in setCurrScreen()
and consumed by GxEPDDisplay::endFrame().
- Favourites: clear a stale "(gone)" tile at render time so it reverts to an
empty "+" slot. Happens when prefs outlive the contact list (e.g. a wiped
/contacts3); onContactRemoved only catches a live delete. Pruned slots are
persisted once per pass (self-healing — an emptied slot can't re-fire).
Builds green: WioTrackerL1_companion_solo_dual (OLED),
WioTrackerL1Eink_companion_solo_dual (e-ink).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SH1106 path always pushed the full 1 KB buffer over I2C every endFrame,
even on static screens (clock, home) that don't change between updates. Hash
the GFX buffer (FNV-1a, no external dep — the CRC32 lib is only wired into
e-ink builds) and skip display.display() when it matches the last frame
pushed. _force_redraw forces a flush on the first frame and after
turnOn()/clear() so a post-wake or post-clear frame can't be wrongly skipped.
Mirrors the existing e-ink CRC-skip. Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Losing the whole route on a low-battery auto-shutdown was the worst solo-mode
failure. New NodePrefs::trail_autosave_lowbatt toggle (Tools > Trail >
Settings > Auto-save, default OFF): UITask::shutdown() writes the live trail
to /trail when the toggle is on and _trail.count() > 0. The count guard stops
an empty trail from wiping a previously saved one; it overwrites the same
/trail file the manual Trail > Save uses.
Schema bumped 0xC0DE001A -> 0xC0DE001B: append-only tail field with a load
clamp, so pre-0x1B saves default to off. sizeof(NodePrefs) is unchanged (the
byte fits existing padding after keyboard_type), so the tripwire assert stays
2496.
Builds green: WioTrackerL1_companion_solo_dual (RAM 69.9%, Flash 62.8%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "(N)" origin readout printed the raw wire path_len. That byte packs the
path hash-size mode in its top 2 bits and the hop count in the low 6, so with
path_hash_mode >= 1 (2-byte hashes) it over-reported — e.g. "(66)" for a
2-hop message. Mask & 63 (getPathHashCount) so it shows the real hop count.
Affects the ui-orig variants only (ui-new/ui-tiny ignore the arg).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A discover response often arrives twice — the zero-hop direct copy and a
re-flooded copy relayed by another repeater carry different packet hashes,
so the mesh duplicate filter passes both. The standalone scan appended the
same node twice and app-driven discover forwarded both copies, so one
repeater showed up as two.
Track (tag, responder pubkey) for a short window in isDupDiscoverResp() and
drop the second copy in both the standalone and app-forward paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- locked device: a ringing alarm/timer was invisible — wakeForAlarm() now
holds the lock wake window for the whole ring, the lock screen draws the
alert overlay, and a key-dismissed ring falls back to a 5 s glance
- status bar: A / live-share / trail / repeater icons no longer vanish when
Bluetooth is off (moved outside the isSerialEnabled() gate)
- alert overlay: long text wraps to up to 3 lines inside the box instead of
overflowing the border (new UITask::renderAlertOverlay, shared with lock)
- MyMesh: force NUL on contact.name copied from an app frame (unterminated
name could overrun the AdvertPath strcpy)
- clock tools: ring/timer deadline compares now wrap-safe (signed diff),
matching the trail/loc-share timers
- messages: channel context menu freezes its target channel at open; fav
toggle no longer retargets the menu when the fav-only filter drops the row
All three solo envs build green (WioTrackerL1 OLED/Eink, GAT562-30S).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces the Alarm/Timer/Stopwatch screen (previously reachable only
from the home Clock page) as a System tool, via the existing
UITask::gotoClockTools() and ICON_ALARM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared on-screen keyboard seeds {loc}/{time} placeholders on begin()
for message composition, but they make no sense in a radio-preset name.
Clear them right after opening the keyboard from the "+ Save current..."
flow in both Settings > Radio and Tools > Repeater; the message-slot
editor still keeps them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-read VBAT_ENABLE gating from 4d46abb2 (energy optimizations)
powered the battery voltage divider HIGH for only 10ms before each ADC
read, then LOW. 10ms is too short for the high-impedance divider node
to settle before the nRF52 SAADC samples it, so readings came out high
and inconsistent (e.g. 4.6-5.0V on a LiPo that maxes ~4.2V).
Hold VBAT_ENABLE HIGH continuously again (as before 4d46abb2) so the
node is always settled at sample time, and drop the toggle + delay(10)
from getBattMilliVolts(). Standby cost is ~2uA on the 2x1M divider,
negligible next to the device's mA-level draw.
The CPU-sleep and LED-off energy optimizations from 4d46abb2 are left
untouched — those are the real savings; only the VBAT gating is reverted.
The on-screen keyboard grid has been a-b-c...z alphabetical order since
it was first introduced, never an actual QWERTY row layout. The
"QWERTY" name only appeared with the T9 option (needed something to
contrast it against) and was inaccurate from the start.
Merges PR #21 (T9 on-screen keyboard option, marczykm) plus a follow-up
fix bumping SCHEMA_SENTINEL to 0xC0DE001A — the PR added a new persisted
field (keyboard_type) without bumping the schema version, which would
have silently misaligned every NodePrefs field after alarm_min for
devices already on the previous schema.
keyboard_type was inserted into the append-only NodePrefs layout without
bumping SCHEMA_SENTINEL, leaving it at 0xC0DE0019 — the same value plain
main already uses without this field. A device already on that schema
would silently misalign every field read after alarm_min once updated
to a build containing this change. Bump to 0xC0DE001A and fix the
migration comment's stale version label (it said 0xC0DE0018, which was
already taken by the MAP home-page migration).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Repeater screen's preset picker only let you pick an existing
preset, not save the current profile as a new one or remove a saved
one — the only way to manage user presets was from Settings > Radio,
even though the repeater profile is a separate set of freq/sf/bw/cr
values.
Ports Settings' "+ Save current..." / "- Delete preset..." popup flow
onto the repeater profile fields, reusing the same shared on-screen
keyboard (UITask::keyboard()) and the same 4 NodePrefs::user_radio_presets
slots — a preset saved from either screen shows up in both, since it's
the same underlying array.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Settings > Radio gains a repeater toggle, 16 community-suggested radio
presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor),
4 persisted user preset slots, and the packet pool bumped 16->32 so
queued retransmits stop starving incoming-packet allocation while
relaying.
Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR),
all flood-only and off by default, plus overhear suppression that
cancels a queued retransmit if a peer relays the same packet first.
Adaptive Power Control is suppressed while relaying (pins TX power to
the ceiling) and duty-cycle RX is forced off, since a repeater needs
to hear and relay at consistent power.
A dedicated Tools > Repeater screen consolidates the toggle, the
politeness knobs, and live forwarding stats, plus an optional "Custom"
radio profile — a dedicated frequency/SF/BW/CR for relaying, separate
from the companion's own network, band-matched to the companion
frequency by default and used everywhere a radio change can happen
(boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings
radio edits) so the device never silently falls back to the wrong
params mid-relay.
Diagnostics gains the actually-forwarded packet count, real heap-free
via mallinfo(), a reset-counters popup, and hardened loop-detect
bounds. Also: a frequency-floor fix and a float-equality preset-match
fix in the repeater profile logic, deduped BW-table/valCol/profile-
seeding helpers shared between Settings and the Repeater screen, and
a build.sh fix tolerating control characters in `pio project config`'s
JSON dump.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows packet counts by category (RX/TX), radio noise floor/RSSI/SNR,
packet-pool free count and outbound queue length, uptime, and heap/stack
headroom on a single scrollable screen.
Adds generic per-payload-type RX/TX counters to Dispatcher (mesh layer),
plus pool-free/queue-length getters, and a new DeviceDiag helper for
nRF52 heap (linker-symbol + sbrk) and stack (FreeRTOS high-water-mark)
stats — the only platform needed for the 3 in-scope boards (Wio Tracker
L1 OLED/Eink, GAT562 30S), all nRF52840.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matches every other list (drawList's row width - reserve): the highlight
shouldn't paint over the indicator's column, just frame it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tick marks, the scale baseline and the travel-direction pointer triangle
were hardcoded pixel constants, so they shrank to near-invisible slivers
next to the 2x cardinal labels and numeric readout on landscape e-ink.
Same fix pattern as the trail map / keyboard separator: derive sizes from
miniIconScale()/sepH() instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PopupMenu drew its own unscaled ^/v text markers instead of the
track+thumb indicator the rest of the UI uses, so it didn't show position/
proportion and looked inconsistent on e-ink. drawScrollIndicator(Px) assumed
a full-width list (anchored to d.width()), which doesn't hold for a centred,
narrower popup box — added a right_x-aware overload so it can anchor to the
box's own edge instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PopupMenu is compact and stands alone (no full-screen header to separate
from), so the gap meant for listStart() just wasted vertical space and let
the selection bar's edges miss the box border by a few px.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trail map dots/diamonds/cross/start markers and the north indicator were
drawn as raw unscaled pixels, so they shrank to near-invisible specks on
landscape e-ink's larger font scale; they now route through the mini-icon
framework (icons.h) like the rest of the UI, and the grid intersection dots
scale with it too. The keyboard's preview/grid separator had the same bug
(hardcoded 1px) and now uses display.sepH().
Also makes UP/DOWN (and the keyboard's LEFT/RIGHT/UP/DOWN) wrap top<->bottom
across every list, menu and form selector, matching the behavior PopupMenu
already had.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content drawn under the title separator touched the line on every standard
screen, because listStart() == headerH() (the row right after the separator).
Graphical screens worked around it with a hand-rolled hdr+2.
Bake a 2px breathing gap into listStart() so every list gains it at once, and
switch the screens that hand-rolled the offset (Compass, Nav, Nearby detail) to
listStart() so the content top is computed the same way everywhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The joystick rotation setting never survived a reboot: loadPrefsInt cleared it
on every load. The post-read override guarded on FEAT_JOYSTICK_ROTATION_SETTING
(and JOYSTICK_ROTATION), but DataStore.cpp included neither Features.h nor the
macro's header, so both were undefined — `#if !FEAT_JOYSTICK_ROTATION_SETTING`
was always true and the stored value was forced back to 0 every time.
Include Features.h so the flag resolves per build, and only force the default
when the setting isn't user-editable (OLED). On e-ink the stored value is now
kept; stale values migrated from another build are still corrected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The solo build already copies firmware.zip (the adafruit-nrfutil DFU package
used for OTA / BLE-DFU updates) into out/, so it was present in the Actions
artifact but never in the tagged GitHub Release. Give it a friendly name
(solo-<ver>-<device>-ota.zip) and include out/*.zip in the release files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Portrait e-ink message history had two scrollbar glitches:
- Track length tracked the last visible box's bottom, so a long message at
the bottom that left empty space shrank the whole bar. Pin the track to the
full list area (hist_start_y..cby); only the thumb sizes/moves.
- Thumb size was derived from the per-frame visible-box count, which fluctuates
with variable-height boxes. Drive it off pixel sums with a constant viewport
(view_px = track_h) so the thumb stays stable while scrolling one list.
- Gutter reserve used last frame's _hist_visible, so an incoming message briefly
toggled the gutter and reflowed box widths. Decide it from a whole-list fit
test at the widest layout instead — stable, no flicker.
Unify both fixes in computeHistScroll() (shared by DM + channel views) and
split drawScrollIndicator into a pixel core (drawScrollIndicatorPx) + an
item-count wrapper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes from a full ui-new audit (OLED + e-ink both build green):
- remove dead DisplayDriver::drawScrollArrows (all lists use drawScrollIndicator)
- SettingsScreen selection bar -> lineStep()-1, matching the drawList screens
- HomeScreen: don't force the 1s blink refresh on the CLOCK page (status icons
are hidden there anyway)
- FullscreenMsgView: clamp KEY_DOWN scroll to a cached _max_scroll instead of
over-incrementing and leaning on the next render to clamp
- DataStore: clamp use_lemon_font (>1 -> 0) on load, like the other enum fields
- move the ~1.5KB wrap scratch (trans/lines) off the render stack into shared
file-scope statics in FullscreenMsgView (single-threaded UI; the fullscreen
view and history list never lay out in the same frame)
Cleanup:
- drop dead members RingtoneEditorScreen::DUR_VALS and HomeScreen::sensors_scroll
- delete redundant manual scroll-clamps in handleInput across the drawList
screens (drawList already reclamps each render); remove the now-unused _visible
from QuickMsgScreen/NearbyScreen/BotScreen and BotScreen::scrollToSel()
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DM/channel history views size the scroll-column reserve from the previous
frame's visible count. A new message bumps the count so the reserve briefly
appears until the next frame settles it — normally invisible, but the alert
overlay froze the screen (_next_refresh = _alert_expiry) for the alert's whole
duration, leaving the content shifted as if a scrollbar were needed. Keep the
underlying screen refreshing at its own cadence (capped at the alert expiry);
the display CRC skips unchanged frames so e-ink isn't thrashed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- drawList(): scrollable list skeleton (visible window, keep-sel-in-view,
scroll-column reserve, per-row callback, indicator). Migrate Tools, Bot,
Nearby, Waypoints list and the three QuickMsg lists onto it; centralises the
scroll-clamp/reserve/indicator that each had open-coded (and inconsistently).
- drawCenteredHeader(): centred title + separator, replacing the duplicated
two-liner across ~10 screens (incl. QuickMsg, which used width()/2 spacing and
was missed before); unifies the separator at headerH()-sepH().
- keyIsPrev()/keyIsNext() in UIScreen.h for the LEFT||PREV / RIGHT||NEXT idiom;
applied to 6 screens, behaviour-preserving. Cancel/context-menu stay
per-screen (they mean different things on different screens).
- rotLabel() dedups the 0/90/180/270 array in Settings.
- Unify selection-row height to lineStep()-1 (Tools/Bot matched the rest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract drawBoxedIcon()/drawSlotIcon() in icons.h; collapse the four
near-identical mute/BT/advert/trail blocks in renderBatteryIndicator.
- Centre the glyph on the actual indicator box (box_h) instead of the text
line, fixing the 1px vertical offset in Lemon mode.
- Extract blinkOn() for the shared advert/trail blink cadence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the old ^/v scroll arrows with the proportional scroll indicator
(thumb + triangle caps) used by the list screens. Reserve its right-edge
column and re-wrap the message text to the narrower width so nothing renders
under it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace single-letter M/B/A/G top-bar indicators with scalable mini-icons:
ICON_MUTE (speaker+cross), ICON_BLUETOOTH (rune), ICON_ADVERT (broadcast
mast+waves), ICON_TRAIL (map pin). Centred in the cw+2 indicator box;
disconnected BT shows the plain glyph instead of lowercase b.
- fix(settings): right-side values used display.valCol() without the scrollbar
reserve, so after expanding a section they rendered under the indicator.
Route all value cursors through valCol(display) = display.valCol() - _reserve.
- fix(settings): font toggle used ^=1, which on a stale value of 2 (older Hybrid
build) flips 2<->3 — both nonzero, locking applyFont() on Lemon with no way
back. Normalise: use_lemon_font = use_lemon_font ? 0 : 1.
- docs(settings): document Messages > Resend setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent, default-off toggles under Settings › Radio.
Pwr save: hardware RX duty-cycle (SX126x SetRxDutyCycle via
startReceiveDutyCycleAuto). The chip cycles RX↔sleep and wakes on a preamble —
no MCU state machine; recvRaw reads the packet exactly as in continuous RX.
Falls back to continuous RX on non-SX126x. (Replaces an earlier software-CAD
state machine that fought the hardware: polling a warm-sleeping chip gave a
phantom-busy channel that stalled TX ~4 s and dropped ACKs in the scan gaps.)
Auto pwr: Adaptive Power Control. tx_power_dbm becomes a ceiling; actual TX
power tracks the reverse-link SNR margin (measured above the per-SF demod floor,
EWMA-smoothed, proportional step with a deadband). Feedback comes from direct /
room-server ACKs and, for channels (no ACK), from hearing a repeater rebroadcast
our own flood; a lost confirmation ramps power back up so channel sends can't get
stranded below what the repeaters can hear.
Prefs schema 0xC0DE0009 (rx_powersave, tx_apc). Radio page / name bar show the
live TX power; noise floor reads n/a while duty-cycling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore commented-out EXTERNAL_FLASH_DEVICES/USE_QSPI for reference;
board has no QSPI flash chip — defines were RAK4631 leftovers causing
framework to lock P0.28/P0.30 (joystick UP/LEFT) as QSPI outputs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Board has no QSPI flash chip — EXTERNAL_FLASH_DEVICES/USE_QSPI were
copied from RAK4631. The framework was initialising the QSPI peripheral
on startup, locking P0.28/P0.30 (joystick UP/LEFT) as outputs. Removing
these defines prevents QSPI init entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QSPI IO0=P0.30 and IO2=P0.28 are wired to joystick LEFT and UP on this
board. QSPI init configures those pins as OUTPUT, blocking button reads.
Disable QSPIFLASH so the joystick works; firmware falls back to internal
flash storage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contacts with lastmod=0 or lastmod>now (RTC not synced after reboot but
contacts have timestamps from a prior session) were sorted to the top
because their raw lastmod > 0. Now both cases are normalised to 0 before
the comparison, so unknown-time entries always appear at the bottom.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TIME mode: call refresh() every 3 s so newly-heard contacts bubble up
to the correct position; previously the list froze at the order captured
when the filter was selected.
- All filters: right-align the distance/age value within the right column
(width - textWidth - 2 px) instead of pinning it to a fixed left edge,
so short strings like "5s" or "3m" sit flush with the longer ones.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Trail.h gpxWaypoints/gpxPoint: snprintf truncation clamp was `> sizeof`
(writing the NUL terminator into the XML stream); corrected to
`>= sizeof - 1` at all 3 sites (<wpt>, <time>, <trkpt>).
- UITask.cpp battMvToPercent: raise 100% ceiling from 4200 → 4170 mV —
the board never charges to 4200, so the indicator was stuck at ~97%.
- Trail.h header comment: corrected RAM cost from "256×12 B=3 KB" to
actual CAPACITY(512)×16 B=8 KB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NearbyScreen:
- Hold Enter on a list row now shows Navigate and Save waypoint directly,
without entering the detail view first
- TIME added as the last filter slot (LEFT/RIGHT cycles Fav→ALL→…→Snsr→TIME);
time-sort shows age in right column and sorts by last-heard descending
- Save waypoint also available from detail view Options menu
UITask:
- Extract addWaypoint() helper (two overloads: with/without explicit timestamp)
consolidating the full()+add()+saveWaypoints()+alert pattern; TrailScreen
and QuickMsgScreen updated to use it
Fixes:
- SoundNotifier.h: correct NodePrefs.h include path (../NodePrefs.h)
- kbAddSensorPlaceholders: dereference _kb pointer in SettingsScreen,
QuickMsgScreen and BotScreen (signature changed to KeyboardWidget&)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-file Web Serial app for GitHub Pages. Connects to the device
over USB CDC, auto-detects <?xml…</gpx> frame from the Export GPX
action, and offers the file as a browser download with an editable
filename (defaults to trail_YYYY-MM-DD.gpx). Resets automatically
after download so back-to-back exports work without reconnecting.
Requires Chrome/Edge (Web Serial API). Enable GitHub Pages from the
repo settings pointing at the branch root or docs/ as needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deduplicate the snprintf+showAlert pattern used in three mark-as-read
sites into a single private method.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MODE_SELECT already showed the count; CONTACT_PICK ("Mark as read") and
CHANNEL_PICK ("Mark all read") were silently clearing unread without
any feedback. Count is read before zeroing so the alert is accurate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move all buzzer/melody dispatch (DM, channel, advert) into a new
SoundNotifier class in SoundNotifier.h, isolating fork-specific sound
code so upstream changes to notify() no longer cause merge conflicts.
The shared playSlot() helper also removes the custom_played
duplication that existed across the three cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The enum value and its case label were never emitted after PR #16 —
both dispatch sites in MyMesh.cpp now use advertReceivedFlood or
advertReceivedZeroHop exclusively.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves KeyboardWidget from a per-screen value member to a single instance
owned by UITask, passed to SettingsScreen, QuickMsgScreen, and BotScreen
as a pointer. Only one screen is ever active at a time, so the shared
state is safe. Saves ~1.5 KB of always-resident heap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reverts onDiscoveredContact to the upstream 4-param signature, adding a
separate virtual onDiscoveredAdvert(bool was_flood) with a default no-op
implementation. Only MyMesh overrides the new virtual; simple_secure_chat
and any future implementors need no changes on upstream merges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After PR #16 added None (slot=3) to melody options, only notif_melody_ad
had a bounds check. Add matching clamps for dm and ch to prevent stray
bytes from an older prefs file triggering buildMelodyFromPrefs with an
out-of-range slot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream's native NRF52 companion power-saving (PR #1238) and the preamble
16->32 bump for SF<9 both landed in the v1.16 merge. Update the battery/power
roadmap entry: the MCU now sleeps via board.sleep(0) when !hasPendingWork()
(no toggle — not sleeping only wastes power; it doesn't touch the radio), and
the preamble fix unblocks the CAD RX-windowing experiment on feat/power-saving.
Note the radio_set_*() globals were removed upstream (replace with radio_driver.*)
so that branch will need a rebase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The splash shows the upstream base version from the hardcoded MESHCORE_VERSION
build flag (distinct from FIRMWARE_VERSION, which is our solo version). The
upstream merge bumped FIRMWARE_VERSION to v1.16-solo.0 but left this flag at
1.15. Update all three occurrences (wio + eink) to 1.16.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-review caught a regression I'd just introduced: feeding hasConnection()
from isClientConnected() (BLE *or* USB) broke the GPX-export collision warning
in TrailScreen, which relies on hasConnection() meaning "BLE app connected"
(BLE → USB dump is safe; otherwise the app may be on USB → warn).
Correct split:
- hasConnection() ← isBLEConnected() — BLE-specific consumers: BT status
indicator, pairing PIN, GPX-export warning.
- isClientConnected() (BLE bonded OR open USB-CDC port) used directly only by
the Auto buzzer mute and message-wake, which should react to either transport.
Builds clean on dual (OLED + e-ink) and pure-BLE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
USB client presence IS detectable on nRF52 after all: (bool)Serial ==
tud_cdc_n_connected() (DTR — a host has the CDC port open). Add
isClientConnected() = BLE-bonded OR USB-CDC-open; DualSerialInterface
overrides it, base defaults to isConnected() (single-transport unchanged).
Wire it so each consumer gets the right signal:
- hasConnection() ← isClientConnected(): Auto buzzer mute + message-wake now
trigger on BLE or an open USB port (PR #14's intent, done correctly), but
not on charging-only (no host → DTR low).
- BT status indicator + pairing PIN stay on isBLEConnected() (BLE-specific).
Caveat: a plain serial monitor also asserts DTR, so it counts as connected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR #14 (advert sound) changed setHasConnection() from isBLEConnected() to
isConnected() to also mute on USB — but DualSerialInterface::isConnected() is
hardcoded true (USB is always a send fallback) and ArduinoSerialInterface's is
too ("no way of knowing"), so USB-client presence is undetectable. The result
on every dual build: hasConnection() always true, which broke everything that
means "a companion app is connected":
- Auto buzzer mute → permanently muted (Auto behaved like Off)
- BT status indicator → always shown connected
- message-wake → display never woke for new messages
Revert to isBLEConnected() (the only detectable client signal). Mute-on-USB
isn't achievable; Buzzer = Off covers that case manually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Bluetooth-page PIN prompt was gated on hasConnection(), which is fed by
the serial interface's isConnected(). DualSerialInterface::isConnected()
always returns true (USB always "connected"), so waiting_for_pair was always
false and the PIN was never drawn — on every dual build, i.e. the published
OLED + e-ink firmware. Pure-BLE builds were unaffected (there isConnected()
reflects bond state).
Gate the PIN on actual BLE-bonded state instead: add
AbstractUITask::isBLEConnected() (forwards to BaseSerialInterface::
isBLEConnected(), which DualSerialInterface overrides with the real BLE state
and pure-BLE inherits as its bond-aware isConnected()). The PIN now shows on
the Bluetooth page while BLE is on and not yet bonded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings › Sound gains an "AD sound" row, and Auto-Advert notes the audible
"in range" heartbeat: the device chirps on every received advert (any node),
melody chosen by AD sound; there's no off, so silence it via the Buzzer
setting. built-in still chirps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>