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>
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>
drawLemonChar()'s "y" is the top of the current text row on SH1106 (real
glyphs render at y + 7 + yo + row), unlike GxEPDDisplay's version, where
y IS the baseline. The undefined-glyph fallback box copied GxEPD's
y - 7*sz formula verbatim during the font-unification pass, sending it
7px above the row's top edge -- into the previous line's space. Drawing
it at plain y (already baseline minus the font's 7px ascent) lands it in
the same relative position GxEPD's version occupies, within its own row.
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>
Replace the Lemon/default font-switch with a single misc-fixed 6x9 font
(full Latin/Greek/Cyrillic coverage), generated via a new tools/bdf2gfx.py
BDF-to-GFX converter. Removes the keyboard's per-render font-switch
workarounds now that one font fits its cell cleanly.
DiagnosticsScreen becomes a circular tab carousel (Live / System / Font),
adding a firmware+device+radio info tab and a per-alphabet rendering test
card covering every keyboard language.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Context menus (PopupMenu, Nearby/QuickMsg call sites) go back to centring on
screen; the header's discoverable-menu glyph stays, but dropping the popup
out of its corner looked bad on some screens.
- Unread pill badge digit wasn't centred: Adafruit_GFX's classic built-in font
(SH1106/SSD1306) always pads a measured string by one trailing advance
column regardless of the glyph drawn, so centring on the raw width left 1px
more slack on the right than the left. New DisplayDriver::
textWidthTrailingGap() (0 by default) corrects for it on those two backends.
- On-device room Logout: mirrors the app's CMD_LOGOUT (drops keep-alive
tracking, forgets the saved password) so a room can be deliberately signed
out of from the Room options menu, not just re-logged-in.
Not build-verified — no PlatformIO toolchain available this session.
Hold-Enter context menus were invisible. Add a menu affordance to every screen
that has one:
- A small ≡ glyph in the header's top-right, via new DisplayDriver::
drawContextMenuHint() and an optional menu_hint arg on drawCenteredHeader() /
drawInvertedHeader() (reserves the corner so the title never runs under it).
- The glyph highlights (corner cell filled, bars knocked out) while the menu is
open, tying the hint to the popup it spawned — driven by a menu_open flag the
screens pass from their live popup state.
- The context menu now drops out of that corner: PopupMenu gains an opt-in
top-right anchor (begin(..., anchor_top_right)); it right-aligns under the
header instead of centring, so the menu reads as emerging from the ≡. Default
stays centred, so non-context popups (Tools/Settings/etc.) are untouched.
Enabled on Nodes (list/scan/detail) and Messages (mode select, contact/room and
channel pickers, DM and channel history).
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 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>
A single button press could surface as two CLICKs on the e-ink build, most
visibly as start+stop on the stopwatch. Two contact-bounce paths fed the
IRQ edge-capture machinery a phantom press/release pair:
- a bounce edge accepted just after a clean release was replayed as a real
press — ISR_DEBOUNCE_MS (5 ms) was too short for the joystick switch; raised
to 25 ms so the settling burst is swallowed.
- the live-pin self-heal reconciled prev against a single raw digitalRead,
which can sample a bouncing contact mid-flap and synthesise a transition.
It now acts only once the divergence has been stable for ISR_DEBOUNCE_MS, so
a momentary read can't inject a click; a genuinely lost edge still heals
(~25 ms later) so the button can't stick.
Both thresholds stay far below any human tap cadence (>100 ms), so rapid
multi-tap navigation still registers every tap.
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>
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>
begin() claimed the trampoline slot and set _isr_slot before calling
attachInterrupt(), so a button that lost the GPIOTE race (only 8 channels,
shared with the radio's DIO1) would take the ISR branch forever while its
interrupt never fired. attachInterrupt() returns 0 when channels are
exhausted — only commit the slot on success, else leave _isr_slot = -1 so
check() uses the polling path. Combined with the live-pin reconcile, an
exhausted button degrades cleanly to plain polling instead of misbehaving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interrupt path trusted the replayed edge stream alone (btn = prev) and
never sampled the live pin. Any lost edge — buffer overflow, a debounce-
dropped settling edge, or a missed GPIOTE event — left prev diverged from
the hardware until the next captured edge, i.e. a stuck button. After
draining the queue, reconcile against digitalRead(): a no-op when the edge
stream already matches, a one-call self-heal when it doesn't.
Also remove dead code in applyTransition() (a local `event` that was always
BUTTON_EVENT_NONE, making its cancel-on-click branch unreachable) carried
over verbatim from the original inline logic. 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>
E-ink panel refreshes block the main loop for 500ms-3.6s (GxEPD2's
display.display() waits on the BUSY pin), and MomentaryButton::check()
only ever samples the live pin level once per loop iteration. A full
press+release that lands entirely inside a refresh is never sampled,
not just delayed - users on WioTracker L1 Eink were losing taps.
Add an opt-in ISR path (BUTTON_USE_INTERRUPTS) that latches each edge
with its own timestamp into a small ring buffer from attachInterrupt(),
independent of what the main loop is doing. check() replays buffered
edges through the existing transition/multi-click logic afterwards, so
click-duration and multi-click-window math still uses the edge's actual
capture time rather than "now".
Scoped to wio-tracker-l1-eink only for now: it's the one board this was
reported against and the only GxEPDDisplay-class variant that currently
builds clean on main. OLED boards refresh in ~20-30ms so polling already
catches every press; analog-button boards (rak4631/rak3401) can't use
attachInterrupt(CHANGE) at all.
Co-Authored-By: Claude Sonnet 4.6 <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>
Address the open items from the FEATURES.md audit backlog:
- saveChannels() now skips unused slots (all-zero secret) instead of
writing all 40 every time, so /channels2 holds only configured channels
(was always ~2.7 KB) and wears the flash less. loadChannels() already
compacted empties on read, so the loaded result is unchanged.
- findChannelIdx() returns -1 for an all-zero secret, so a corrupted/empty
channel can't match an unused all-zero slot and misroute messages.
- TrailStore gains a static_assert that CAPACITY fits the uint16_t save
header count, failing the build instead of silently truncating.
Also re-classify two audit items verified to be non-issues in current
code: the bot strstr "199-char" truncation (BOT_SCRATCH=200 >= MAX_TEXT_LEN
=160, unreachable) and the MSG_PICK reply title (rlen clamped to 20, so
title[24] never overflows).
Verified: WioTrackerL1_companion_solo_dual builds clean.
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>
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>
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>
- 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>
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>
- UITask::shutdown(): power GPS off using !PIN_GPS_EN_ACTIVE instead of hardcoded
LOW, matching MicroNMEALocationProvider::stop() (still LOW on current active-high
devices, but correct for a future active-low GPS).
- buzzer _noteFreq(): clamp octave to 8 instead of 7 so the parser's accepted range
(4-8) is fully consumed; previously an octave-8 digit was clamped away and could
leak into the stream as the next note's duration.
- Waypoint::readFrom(): check read() return for version/reserved/count header bytes
so a truncated file is rejected instead of using garbage count.
Co-Authored-By: Claude Sonnet 4.6 <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>
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>
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>
On a tall portrait e-ink panel the clock and lock screens wasted most of
the vertical space on a small inline "HH:MM". Render HH and MM on two
lines in a new size-4 font (built-in GFX scaled 7×, ~42×56 px) so the
digits roughly double in height and fill the narrow width. Wide panels
(OLED, landscape e-ink) keep the classic single-line size-2 layout.
- New shared drawClockTime() helper used by both the Clock home page and
the lock screen; returns the y below the time block so the date and
dashboard rows flow beneath it.
- GxEPDDisplay: size 4 = built-in font × BIG_TEXT_SCALE (7); getCharWidth /
getLineHeight / setTextSize handle it; fontAscender stays 0 (built-in is
top-left origin).
- Centre the big digits on their visible glyph width — the built-in font
advances 6 px per char but the glyph is 5 px wide, so getTextWidth over-
reports by one trailing column and the digits would sit ~half a column
left of centre. AM/PM rendered one size larger (size 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before this commit, there was no way to set a different max hop count
for unscoped messages.
Now with this change, by defaul it tracks the flood.max setting, until
a user provides a flood.max.unscoped value, which tax precidence for
packets if ROUTE_TYPE_FLOOD is true.