Post-review cleanups, no behaviour change:
- botScanCommands() parsed the command name and its two args with three
near-identical read-token loops; extracted a single readToken() lambda.
- Fn+Esc lock branch turned the display on twice (the unlock arm repeated
what the branch head already did); dropped the redundant call.
- setGpioMode()'s comment said "Cycle" (cycling lives in GpioScreen); now
describes what it actually does — set a specific mode + persist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect
immediately during botScanCommands(), before quiet-hours/cooldown/
per-contact throttle were checked -- those gates only suppressed the
reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply()
now only records what was requested; applyPendingBotActions() runs the
deferred effects once a wrapper's throttle checks pass and the ack sent,
mirroring the existing _locfix_requested pattern. resetPendingBotActions()
clears everything on every throttled/aborted path.
- CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate
(it called into KeyboardWidget directly instead of through the
enqueueKey()/dequeue path every other key uses, so it wasn't discarded
while _locked). Now checks _locked itself.
- Since a locked device now correctly ignores CardKB entirely, Fn+Esc
(single press) is added as CardKB's own lock/unlock gesture -- otherwise
a CardKB-only setup had no way to unlock. Esc rather than the adjacent
Fn+Backspace, to avoid an accidental press.
- botScanCommands() now parses up to two arguments per command instead of
one. Used by "!gps fix [seconds]" to override the default 90s timeout
(clamped 15-300s) for a poor sky view where 90s isn't always enough to
reach isLocFixReady()'s HDOP/satellite bar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CardKB is level-triggered (repeats the held byte every poll) and its Enter
key collided with the on-screen keyboard grid's own commit action, causing
duplicate characters and accidental message sends. Debounce polling and use
the CardKB v1.1 Fn modifier (confirmed working on real hardware) instead of
tracking navigation state: plain Enter now behaves like the physical centre
button, Fn+Enter submits, Fn+Tab opens the Hold-Enter equivalent, and
Fn+<letter> opens that letter's accent popup directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auto-detected at boot on Wire1/Grove (addr 0x5F) -- no setting to flip, and a
no-op on boards without that bus or with nothing attached. This UI's key
codes (KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL) already match CardKB's own byte
protocol, so most input needs zero translation and flows through the same
key queue as physical buttons.
Two bytes get remapped in UITask::pollCardKB():
- Enter, only when the on-screen keyboard's plain grid state is active (no
placeholder/accent popup, not in cursor-mode), becomes a new KEY_KB_ENTER
sentinel meaning "submit the field" -- reusing plain KEY_ENTER there would
insert a stray character, since a CardKB typist's row/col never reflect an
intentional grid selection. Everywhere else Enter is untouched, so
selecting a placeholder or committing an accent still works normally.
- Tab (otherwise unused) becomes KEY_CONTEXT_MENU, standing in for the
"Hold-Enter" long-press gesture CardKB has no way to produce -- without it,
~30 context menus across the UI (message reply/navigate, Bot/Admin/
Repeater, ...) would be unreachable from the keyboard alone.
KeyboardWidget gains a direct-typing path: printable ASCII inserts straight
at the cursor bypassing the grid, Backspace deletes, KEY_KB_ENTER submits.
Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean; also
smoke-tested Heltec_mesh_solar_companion_radio_ble (no ENV_PIN_SDA/SCL) to
confirm zero regression on boards without the feature.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target
toggle nested under Commands (bot_actions_dm/ch/room); off by default.
- Bot Trigger fields accept comma-separated multiple phrases, matching any
one fires the reply.
- New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot
commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output;
GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only
Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the
chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR
NFCPINS bit-clear in initVariant() (adapted from Adafruit's own
nfc_to_gpio example) -- confirmed working on real hardware.
- Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong
layer, hiding the "To:" header on DM replies and leaking the raw prefix
into room messages' list view; a related mismatch had the history
scrollbar's sizing pass wrap room messages with the sender name still
attached, disagreeing with the actual rendered text.
Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean
(sizeof(NodePrefs) confirmed 2720 via real build, not guessed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
UITask::newMsg() (fired for every incoming DM/channel message) triggers a
3s "Msg: <sender>" overlay drawn on top of whatever screen is current --
including the shared KeyboardWidget when it's occupying the full screen
for text entry (message compose, room/repeater password, channel name,
device name, admin custom command, ...). A message arriving mid-typing
blanked out the letter grid for the full 3s with no way to see what was
being typed.
KeyboardWidget now tracks whether it was actually rendered this frame
(_visible, set at the top of render(), cleared by the new beginFrame()).
UITask's render loop calls _kb.beginFrame() before curr->render() and
skips the alert overlay when the keyboard turned out to be what got
drawn -- covers every screen that shares _kb, not just message compose.
The alert itself is unaffected (still fires, still expires after 3s) --
it just doesn't draw over the keyboard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renamed the vestigial Lemon/default font-switch naming (setLemonFont/
isLemonFont/drawLemonChar/lemonXAdvance/_use_lemon -> setSingleFont/
isSingleFont/drawGlyph/glyphXAdvance/_single_font) across DisplayDriver.h
and both concrete drivers -- both have been permanently single-font for
several commits, so the old names invited a future reader to think a
real switch still existed. Pure identifier rename, no logic changed.
Also extracted the byte-identical "SenderName: " prefix-splitting in
MyMeshBot.h's tryBotReplyChannel()/tryBotChannelCommand() into a shared
botChannelSenderSplit(), mirroring the existing botRoomSenderName().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string
field (free-text keyboard) into 4 independent, type-appropriate rows --
Frequency (same digit-cursor DigitEditor Settings/Repeater use locally),
Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping),
plus TX power and the 3 Routing numeric fields as number steppers and
Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per
keystroke); only Enter sends one combined `set`, Cancel sends nothing.
Full pre-release audit of all 16 commits since v1.22 (5 parallel focus
areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels,
UITask+NodePrefs schema) turned up and fixed:
- Admin: fetched FK_NUMBER values weren't clamped to the field's range,
so a value already out-of-range could get stuck unreachable; fixed
commit-time float format noise (%.6f -> %.3f); Cancel/failed-login
always returned to the Nodes picker even when Admin was opened
directly from a node's Hold-Enter action -- now returns to wherever
it was actually opened from (AdminScreen::_from_picker).
- Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap,
so cycling to the 2nd/3rd candidate always came out lowercase --
fixed by caching the cycle's caps state (t9_caps).
- Messages: history is numbered newest-first, so a message arriving
while scrolled up to an older one silently relabeled the view onto a
different message -- selection now shifts with the insert.
- Channels: onChannelRemoved() didn't clear ch_notif_override/
ch_notif_muted/ch_fav_bitmask despite its own contract comment
requiring it (now far more reachable via the on-device Delete);
an all-zero hex secret silently self-deleted the channel it was
just saved into (collides with the empty-slot sentinel) -- rejected.
- Room login: isRoomLoggedIn() indexed the login-tracking ring
directly instead of via its head offset, silently wrong once the
ring wraps (8+ rooms/session) -- the new on-device Logout depends
on this being right.
- Font/display: removed the now fully-inert Settings > Display > Font
toggle and the dead LemonFont.h (retired by the earlier misc-fixed
font unification, zero remaining includes); fixed a copy-pasted
"5x7" comment (font is 6x9), two meaningless dead ternaries, and an
OLED/e-ink inconsistency in the undefined-glyph fallback box offset.
- AdminField's `kind`/bounds fields no longer rely on default member
initializers inside aggregate-init: this toolchain's actual nRF52
build (unlike env:native) has no explicit -std= override, so it
predates C++14's aggregate-with-default-member-initializer rule.
Given an explicit constructor instead -- portable regardless of
standard, all existing field-table literals unchanged.
Docs updated to match: tools_screen.md (Diagnostics as a 3-tab
carousel, was documented as one flat screen), message_screen.md
(chat bubbles, newest-at-bottom, cursor mode, secret validation),
settings_screen.md (dropped the dead Font row), solo_ui_framework.md
(header menu_hint signatures, icon priority-drop, KeyboardWidget's
T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section
covering all of the above plus the other 15 commits since v1.22.
Build-verified on WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GxEPDDisplay now uses the same single misc-fixed 6x9 font as the OLED
driver (Lemon retired there too), with baseline math updated for the
new ascent.
- Clock Tools' Alarm screen: Repeat and Armed now respond to LEFT/RIGHT
like every other multi-value/toggle field in Settings, not Enter-only;
Hour/Minute merged into one Time row edited with a hand-rolled HH:MM
digit cursor (like the Timer's), replacing the two-row DigitEditor
popups; Repeat's "Off" label now matches the codebase-wide ON/OFF
casing.
- Top status bar: battery icon now shares the same box height as the
other status icons (Bluetooth/mute/etc.) instead of standing 2px
taller, and its charge nub is properly vertically centred instead of
drifting off-centre at non-multiple-of-4 box heights.
- Small settings-gear icon glyph tweak; wio-tracker-l1 screenshot build
variant now enables DUAL_SERIAL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A device administering itself via the Admin tool read as awkward, so the
local mode is gone and its options relocated to Settings.
- AdminScreen is remote-only: drop the CHOOSER phase, _local_mode, the
LocalField/LocalKind model and activateLocalField/commitLocalField.
Phases collapse to LOGIN/COMMAND/REPLY, entered only via startFor().
- Tools > Admin opens straight to the Nodes picker (ACT_ADMIN ->
pickAdminTarget()). Backing out of a command screen returns to that
picker; the picker's Cancel returns to Tools. gotoAdminScreen() removed.
- Settings > System gains Name (keyboard-edited node_name) and Reboot
(action row, placed last so it isn't the default cursor). Radio + TX
power were already in Settings; Send advert is the home ADVERT page.
Docs: tools_screen.md Admin section rewritten remote-only; settings_screen.md
System table gains Name + Reboot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tools > Admin now opens on a This-device / Remote-node chooser. "This
device" is a login-free 2-tab carousel (System: Name/Radio/TX power/
Lat/Lon, Actions: Send advert/Reboot) mapped onto NodePrefs/sensors and
reusing Settings' own apply chains -- no CommonCLI port. "Remote node..."
opens Tools > Nodes in a pick-mode (borrow-another-screen's-list idiom),
and Nodes gains an "Admin" Hold-Enter action; both converge on the single
canonical UITask::openAdminFor() -> AdminScreen::startFor().
Also:
- KeyboardWidget: opt-in PlaceholderRefreshFn hook for contextual {}-key
CLI command-name completion (word-replacing); default behaviour and all
other keyboard uses unchanged.
- TabBar: ellipsize() re-measures the real "text..." candidate per step
(+ width clamp) so dots can't spill into a neighbour/reserved icon.
- ChannelsView: form rows truncate instead of wrapping.
- Reboot moved off the default-selected Actions row (fires with no confirm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Messages:
- Add/edit/delete channels on-device (new ChannelsView, owned by the renamed
MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope).
Channel secret entry supports a typed passphrase (SHA-256'd, same primitive
the library already uses for the routing hash) or a raw 32-hex-char key.
- MyMesh::setChannelLocal() factors out the setChannel/saveChannels/
onChannelRemoved sequence previously duplicated across the two
CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths.
Tools > Admin (new):
- Log into a repeater/room server's admin account and send CLI commands,
the on-device equivalent of the app's repeater-admin feature.
- Commands are organised into category tabs (System/Radio/Routing/Actions)
with common get/set fields (name, radio profile, tx power, repeat, advert
intervals, ...) plus a free-text "Custom command..." fallback for anything
else. A field row fetches the current value, opens it pre-filled for
editing, and sends the change — falling back to a blank editor if the
fetch fails or times out.
- The admin password persists and self-heals exactly like room logins in
Messages: saved on a confirmed admin-level login, forgotten on a failed
one, left alone if merely under-privileged.
- New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so
a reply reaches the UI without touching the existing BLE/app CLI-terminal
path (queueMessage's should_display gate is untouched).
Shared TabBar.h extracted from NearbyScreen/BotScreen's independently
duplicated tab-carousel rendering (now a third consumer via Admin) — also
fixes neighbouring tabs vanishing outright when they didn't fully fit;
they now truncate with an ellipsis instead.
Keyboard: the combined "Ext.Latin" alphabet split into 8 separate,
linguistically complete per-language keyboards (Polish, Czech, Slovak,
German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug
where tall accented glyphs overlapped the keyboard's separator line
(SH1106's Lemon-font ascent constant was 2-3px short for them).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auto-Reply Bot gains a third target (room servers, alongside DM and
channel), new {name}/{hops} reply placeholders, and a DM all/favourites
allow-list. BotScreen is redesigned as a circular tab carousel (Direct /
Channel / Room / Other, same interaction as Nearby Nodes' filter tabs)
instead of one long scrolling list, which also exposed and fixed a
leftover coupling where Channel/Room trigger-replies and their !command
handling secretly depended on the DM tab's Enable/Commands toggles —
each target's Enable and Commands are now fully independent.
- NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope,
bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as
DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged
at 2712 (new bytes absorbed existing padding, verified via a
standalone host compile + offsetof check).
- DataStore: persists all new fields; seeds bot_commands_ch/room from
the old shared bot_commands_enabled on upgrade so existing
channel/room command behaviour isn't silently lost.
- MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's
shape but post via sendMessage (room relays to members itself);
requires an existing login session with that room, same as a manual
post would. botDmSenderAllowed() gates DM trigger-reply/commands on
the favourites bit when bot_dm_scope=Fav.
- MsgExpand: {name}/{hops} as optional trailing params (default
nullptr/-1, no existing caller affected) — deliberately not exposed
on the general compose keyboard, only on bot Reply fields.
- QuickMsgScreen/UITask: room-target picker (mirrors the channel
picker), routing through the existing room-login prompt when there's
no saved password yet.
- BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves
rows, Enter is now the only way to change a value); Enable split out
of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode;
Commands moved from a shared toggle into each tab.
- docs/tools_screen.md updated for the new tab layout and behaviour.
Not build-verified — no PlatformIO toolchain in this environment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Alarm repeat (Clock Tools):
- NodePrefs::alarm_repeat_mask (weekday bitmask, struct tm::tm_wday
convention) — new Repeat row cycles Off/Daily/Weekdays/Weekends.
computeAlarmNextFire() scans the next 7 days for a matching weekday when
set; evaluateAlarm() only clears alarm_on (one-shot) when the mask is
empty, otherwise re-arms. mask==0 is byte-for-byte the original one-shot
behaviour, so existing users see no change.
On-screen keyboard alphabets (Settings > Keyboard > Alphabet):
- KeyboardWidget reworked from single-byte ASCII cells to UTF-8 codepoints
(insertion, backspace and T9 in-place cycling all codepoint-aware now, so a
multi-byte character is never split) — see kbApplyCapsUtf8/kbUtf8Len/
kbUtf8CharAt/kbUtf8LastCharBytes.
- Cyrillic, Greek and an Extended Latin set (Polish/Czech/Slovak/German/
French/Spanish/Nordic diacritics) join the keyboard's existing #@/abc page
cycle (Latin -> alt alphabet -> Symbols -> Latin) — no new key needed.
NodePrefs::keyboard_alt_alphabet picks which one, if any, is active.
- Lemon font is forced on transiently inside KeyboardWidget::render() (saved
and restored every call) whenever the alt-alphabet page is showing or
already-typed text has non-ASCII bytes, so composing is visible even if
Font is set to Default.
- Each script's caps-shift rule is distinct and documented in
kbApplyCapsUtf8: flat -0x20 for ASCII/Cyrillic/Greek (with the ё/ς
exceptions), flat -0x20 for Latin-1 (à-þ), and an adjacent-pair -1 for
Latin Extended-A (verified against every character actually used, not a
blanket rule for that whole Unicode block).
NodePrefs schema: two tail-appended fields this session (alarm_repeat_mask,
keyboard_alt_alphabet), SCHEMA_SENTINEL 0xC0DE001B -> 0xC0DE001D,
sizeof(NodePrefs) 2496 -> 2504 (verified via a standalone host compile).
Docs: Clock Tools' Repeat row, Settings > Keyboard > Alphabet, and the
Rooms keyboard note (no longer ASCII-only once an alphabet is enabled).
Not build-verified — no PlatformIO toolchain available this session. Caps
mappings cross-checked against Python's Unicode case tables; UTF-8 literal
bytes verified at the byte level.
renderBatteryIndicator() drew the secondary icons in a fixed sequence, so a busy
bar (many background modes on) shrank the node-name area to a couple of ellipsised
characters. Rework it into a priority-ordered list laid out right->left with a
reserved minimum name width: once an icon won't fit above the reserve, every
lower-priority icon after it is dropped too.
Priority: BT > GPS fix > alarm > mute > auto-advert > trail > live-share > repeater
(battery stays rightmost). Blinking icons still reserve their slot while off, so
the name width doesn't flicker with the blink. Also -39 lines (eight if-blocks
collapse to one table + loop).
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Recent adverts carousel page was a read-only name+age list of recently-heard
nodes — now covered by the Nodes screen (which folds getRecentlyHeard() non-contacts
into its list). Remove it from the UX:
- isPageVisible() hard-hides HomePage::RECENT, so it drops out of the carousel for
everyone (default and custom page orders, existing users included).
- Delete the dead RECENT render block, the nav "Recent adverts" alert and the unused
AdvertPath recent[] member.
- Drop HOME_RECENT from Settings > Home Pages so there's no dead toggle.
HPB_RECENT bit index is left intact, so persisted page_order / home_pages_mask stay
valid (a stored RECENT entry is simply skipped) — no migration, no schema bump.
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>
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>
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>
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>
- 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>
The prefs schema-migration block runs on every SCHEMA_SENTINEL mismatch, and
the "turn Favourites on" step was ungated. Since each firmware release bumps
the sentinel, every update re-applied `home_pages_mask |= HP_FAVOURITES`,
clobbering a user who had hidden it. Gate it to the transition that added
Favourites (sentinel < 0xC0DE0002), matching the trail_units_idx migration.
The Map/Trail carousel page shipped with no visibility toggle: pageBit(MAP)
returned -1 so it was always visible with no Settings row. Add a proper "Map"
toggle (new HPB_MAP/HP_MAP bit, pageBit/bitToPage cases, HOME_MAP settings
row) plus a gated one-time migration (sentinel < 0xC0DE0018) that keeps it
visible for upgraders. Bump SCHEMA_SENTINEL to 0xC0DE0018; page_order[] stays
a literal 11 so the persisted layout is unchanged.
Also fix the fresh-install seed, which used a stale 0x01FF literal that left
Favourites hidden on new installs despite its "all pages visible" comment;
seed NodePrefs::HP_ALL so fresh installs match upgraders (Favourites + Map).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The joystick directions and Back were never begin()'d, so on the e-ink build
they never claimed a GPIOTE channel — edges landing during a blocking panel
refresh were lost and the IRQ-capture work didn't reach them. begin() them.
Even with edges captured, rapid taps of one direction replayed into a single
check() and collapsed into a double/triple-click event the navigation handler
ignores, and the loop only ever dispatched one key per render. So:
- MomentaryButton: for multiclick=false buttons emit one CLICK per completed
release (one per check() call) instead of collapsing — each tap stays a
discrete key. multiclick=true buttons (double/triple) are unchanged.
- UITask: add a key FIFO; drain each direction fully into it, apply the whole
queued burst, then redraw once. N taps captured during a refresh become N
navigation steps at the cost of a single refresh. Also fixes losing a key
when two buttons fire in the same loop iteration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Clock Tools screen (Enter on the home Clock page) with three time
utilities, plus the engine that drives them from UITask::loop() so they
work regardless of the current screen / display state:
- Alarm: a single one-shot wake alarm. Persisted in NodePrefs as a local
time-of-day; UITask schedules it as an ABSOLUTE fire instant recomputed
from that time, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump
the clock) — small corrections still fire on time, a jump over the
target still fires (late, up to 6 h). Disarms after firing. A bell
icon signals an armed alarm on the clock face and the top status bar.
- Timer: a millis-based countdown (sync-immune), big HH:MM:SS readout,
rings even when off-screen.
- Stopwatch: millis-based, keeps running in the background.
Numeric fields use the shared framework DigitEditor (digit-by-digit,
min/max enforced). The alarm/timer/ring engine lives in UITask alongside
the locator/live-share engines (no screen-cast for time-critical logic).
E-ink: live readouts refresh coarsely (and on any key); timing is exact
regardless, and the countdown's buzzer fires on time. Rings override mute
and are dismissed by any key (auto-stop after 1 min). Cannot wake from a
full Shutdown (CPU/RAM powered down).
NodePrefs: +alarm_on/alarm_hour/alarm_min (fit existing tail padding,
sizeof unchanged 2488), SCHEMA_SENTINEL 0xC0DE0016 -> 0xC0DE0017,
DataStore read/clamp/write in lockstep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the two RAM history rings (channel + DM) out of the 1988-line
QuickMsgScreen into a self-contained MessageHistory.h component, mirroring
the WaypointsView extraction from TrailScreen. The store owns the ring
buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK +
auto-resend) and the per-channel unread counters; the screen keeps all
view state (selection, scroll, fullscreen readers, the unread "viewing
session" bookkeeping) and reaches entries through accessors.
Behaviour-preserving:
- The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are
file-scope in MessageHistory.h, so the phase machine still refers to them
unqualified — only data + storage logic moved.
- addChannelMsg keeps a thin screen forwarder that computes the "viewing"
flag (a phase fact the store can't see) and returns the ring pos;
afterSend uses armChannelRelay(pos, seq) instead of poking the ring.
- The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg,
markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread,
getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as
forwarders, so no caller changes.
QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards
build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was
duplicated across 9 screens with subtle divergence: some cleared the flag
after saving, some left it set and relied on onShow() to reset. Replace all
12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves
once only if dirty and always clears the flag, so the "did we touch flash?"
answer lives in one place and the reset is consistent.
Edit sites still mark the flag manually (inherent to change tracking); only
the persist-on-exit boilerplate is centralised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding a screen touches 4 sites; 3 (member decl, gotoX decl, gotoX def) are
compile-checked, but a missed `new XScreen()` in begin() left the pointer
uninitialised and crashed at first navigation. Give every screen member an
in-class nullptr initialiser and bail early in setCurrScreen(nullptr) so the
mistake is an inert no-op instead of a null deref. Document the 4-site
registration contract on the member block.
A full registry table was considered and rejected: the named gotoXScreen()
methods are a depended-upon API (~30 call sites, menu dispatch + back-nav),
so a table would add an enum + indirection without removing them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the ad-hoc enter()/markClean() entry methods (which lived outside
the UIScreen interface and were invoked via casts from each gotoX) with a
virtual onShow() lifecycle hook called centrally by setCurrScreen().
This removes the "forgot to call enter() in a new navigator" footgun and
the unchecked cast smell: 12 navigators collapse to one-line
setCurrScreen(x) calls, and override enforces signature match. Two entries
that carry a parameter/variant keep an explicit typed call after
setCurrScreen(): RingtoneEditor::selectSlot(slot) and TrailScreen::showMapView().
Behaviour-preserving: only screens that previously had enter() get an
onShow() override; Splash/Home/QuickMsg/Diag keep no reset as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ui-new/*.h screens are header fragments compiled only as part of
UITask.cpp, in include order. Two implicit rules a contributor can trip on:
include order (a static inline helper / shared scratch is visible only to
later fragments) and single-TU-only (some fragments define external-linkage
symbols at file scope, e.g. NearbyScreen::FILTER_LABELS, so reusing one from a
second .cpp is a duplicate-symbol link error).
Spell both out in a contract comment at the inclusion block, and raise the
single-TU note to a callout in the framework guide. Comments only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The onContactRemoved/onChannelRemoved hooks already clear every NodePrefs
field keyed on a contact pubkey or channel index (verified complete), but the
convention was opt-in with no reminder — new per-entity state could silently
forget to register, the class of bug fixed earlier this session.
Add a CONTRACT comment above each hook listing what it covers and instructing
additions to land there, plus a terse [del->onContactRemoved] /
[del->onChannelRemoved] tag on each of the eight keyed fields in NodePrefs.h.
Now editing either side (struct field or hook) points at the other. Comments
only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On nRF52 the buzzer advanced notes by polling millis() >= _note_end_ms in
loop(). A blocking display refresh (e-ink endFrame) starves loop(), so a
note boundary that falls inside a refresh is serviced late — the note
plays long, or the next is skipped. The keypress-time 300 ms render delay
only masked the first note.
Advance notes from a hardware TIMER1 compare interrupt instead, scheduled
for each note's exact duration, so timing is independent of render cadence.
TIMER0 is the SoftDevice's; TIMER1 is free (tone() uses PWM2, nrfx TIMER1
driver is disabled). loop() becomes a no-op on nRF52; the UITask keypress
render-delay workaround is removed.
_disarmNoteTimer() clears the latched NVIC pending IRQ (not just the event)
on stop()/_nrfBegin(), so a note-advance latched just before a stop can't
fire spuriously — which would skip a new melody's first note or blip after
an explicit stop. Event read-backs flush the write buffer per the nRF52
event anomaly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DM, room and channel history always stamped a message with receipt time
(rtc_clock.getCurrentTime()), even though the sender's real timestamp was
already available (and, for DMs/rooms, already threaded through to
addDMMsg -- just never used for display). Live-received messages hid this
because receipt lags origination by only seconds, but a room-sync replay or
an offline-queued message held by a repeater can arrive long after it was
sent, so a burst of backlog messages all showed as "just now".
storeDMMsg() now prefers msg_ts (falling back to receipt time only when
unknown). addChannelMsg() gained a timestamp parameter, threaded from
onChannelMessageRecv() down through AbstractUITask/UITask, with the same
fallback. The DM dedup check and outgoing-message timestamps are unaffected
(they use msg_ts directly, already correct).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CMD_SET_CHANNEL clearing a slot (empty secret) left bot_channel_idx,
loc_share_channel_idx, and the per-channel melody bitmasks pointing at that
index. A new channel added later at the same slot would then silently
inherit the old one's bot target, Live Share target, or notification
melody. New onChannelRemoved() hook, mirroring onContactRemoved(), turns
the bot/Live Share channel target off (fail closed) and clears the melody
bits for that index.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deleting a waypoint left the Locator pointed at coordinates that no longer
existed (it's a coordinate snapshot, so nothing noticed). Removing a contact
was worse: nothing cleared its favourite slot, its Locator/Live Share target,
or its per-contact mute/melody entry, so all four kept referencing a pubkey
that no longer resolved to anything.
- WaypointsView's Delete now calls UITask::clearTargetIfWaypoint() first.
- New AbstractUITask::onContactRemoved() hook, called from MyMesh.cpp's
CMD_REMOVE_CONTACT handler, clears the favourite slot, the Locator target,
and dm_notif/dm_melody entries for that pubkey. Live Share's DM target
turns auto-share off instead of guessing a new recipient.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Starting trail recording with GPS switched off used to call setActive(true)
immediately -- the session timer ran while the screen sat on "Waiting for
GPS fix" forever and recorded nothing, with no hint that GPS was the
problem. Choosing "Start tracking" now opens a "GPS is off" confirmation
(Enable GPS & start / Cancel); confirming enables GPS and starts the
session. Behaviour is unchanged when GPS is already on.
Gated on a new UITask::hasGPS() so the prompt only appears on boards that
expose a toggleable GPS, not where GPS is simply absent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Log in to a room server from the device UI with no phone app: picking a
room prompts for its password (blank allowed for open rooms), and a
context-menu "Login..." allows re-login. Successful passwords are
persisted to a dedicated /room_pw file so a previously-used room logs
back in after reboot without retyping; a failed login forgets the
(now-stale) saved password so the next attempt prompts again.
The room-password file is written via a temp file + atomic rename
(new DataStore::commitFile helper), matching the crash-safety of
contacts/channels persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Squash merge of feat/location-beacon-alerts-autopause (v1.21).
Features:
- Live Location Sharing — broadcast position as movement-gated [LOC]
messages to a channel or contact; live shares show as map pins with
distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is
parsed in DMs, channel messages and room messages; DM shares name the
sender.
- Locator (geofence) — arm a geofence around a saved waypoint or a person
(live [LOC] or last-known position), alert on arrive/leave or near/far,
with an optional homing beeper (gated to arrive/both modes). Arm from
Tools > Locator, Nearby Nodes, or Waypoints; target picker lists
favourites first, clearable via a "None" entry. Active target is drawn
as a flag on the map.
- One active target shared across Locator / Navigate / Map via a single
resolver (resolvePersonPos / activeTargetPos) that prefers a live
[LOC] share over the last-advertised GPS fix.
- Follow live contacts — Navigate to a live-sharing contact follows them
as they move and adds an ETA line; quick-share your own position from
the Map.
- Map & status-bar upgrades — home mini-map gets a north marker, scale
tick, and a connected trail line (was disconnected dots); status line
shows tracked-node count, an arrow + distance to the active Locator/Nav
target (falling back to the nearest live-tracked contact); GPS fix icon
in the top status bar, shown only on GPS boards with GPS enabled.
- Trail auto-pause — recording freezes on stops (banking elapsed time,
breaking the map line across the idle gap) and resumes on movement
without ending the session.
- Streaming trail simplification — GPS points are simplified in-stream
via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight
runs collapse to their endpoints, curves stay bounded to the Min-dist
tolerance, so the 512-point buffer covers a far longer route than a
flat point budget would suggest.
- Collapsible Tools (Location / Comms / System sections, fold-in-place
like Settings) and page-indicator icons on the home carousel.
- Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon
digit by digit.
Fixes:
- Critical: low-heap hang and contact loss on RAM-tight builds. Halved
message-history scrollback rings (recovering ~14 KB free heap) and
made contacts/channels/prefs persistence atomic (temp file + rename),
so an interrupted save can no longer corrupt or wipe the store.
- Serial.write() bounded so a stalled USB host can't hang the device.
- Nearby Nodes: live [LOC] senders respect the type filter, sort by
shared position, and the list refreshes so live shares bubble up.
- Map: live contacts are labelled before waypoints.
- GPS status icon hidden when GPS is off in Settings.
- Splash screen no longer truncates a pre-release tag's own dash (e.g.
v1.21-rc1) when stripping the build's commit-hash suffix.
- Null-guarded the Locator target picker; clamped loc-share channel
index on load.
Under the hood:
- -Os size optimisation on the e-ink and GAT562 30S solo envs.
Co-Authored-By: Claude Opus 4.8 <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>
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>
- 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 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>
End-to-end delivery indicators on the device UI, drawn next to outgoing
messages and auto-scaled to the font (legible on landscape e-ink).
DM (and room servers, which share the DM path):
- Pending / delivered / failed marker driven by the real end-to-end ACK.
Pending shows a row of dots — one per send — so auto-resend progress is
visible before it resolves to ✓ / ✗.
- Auto-resend: new pref dm_resend_count (0-5, default 2), Settings ›
Messages › Resend. A pending DM whose ACK times out is re-sent (reusing
the original timestamp) until resends run out, then ✗. Driven from
UITask::loop so it completes in the background, independent of screen.
- Incoming dedup: a retry reuses the sender timestamp + text but carries a
fresh packet hash, so addDMMsg drops copies matching prefix+ts+text.
Channels (flood, no recipient ACK):
- ✓ only once a repeater echo confirms the send was relayed into the mesh;
no echo is normal, not a failure (no pending/fail shown). A small ring
tracks a burst of sends so each matches its echo. Receive-path hashing is
gated so the hot flood path is untouched when idle.
Shared:
- Markers shown in both the history list and the fullscreen message view.
- Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale);
adding a new status icon is a bitmap plus one draw call.
No changes to the upstream mesh library (src/).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull repeated UI idioms into DisplayDriver and remove duplicated logic:
- DisplayDriver: add drawScrollArrows() and drawInvertedHeader(); replaces 11
copy-pasted scroll-arrow blocks across 5 screens and 3 inverted title-bar
blocks (NearbyScreen x2, NavView). Standardises the detail-view separator.
- useImperial(): single source on UITask; NearbyScreen/TrailScreen delegate
instead of each re-reading NodePrefs.units_imperial.
- NearbyScreen: extract saveSelectedWaypoint() (two identical blocks -> one);
drop the redundant local label buffer (WaypointStore truncates).
- UITask::shutdown(): power GPS off through LocationProvider::stop() instead of
poking PIN_GPS_EN directly — centralises enable/reset-pin + active-level logic.
Net -58 lines. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo
builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>