- 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>
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>
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>
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>
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>
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>
UITask::currentLocation() becomes the one place that reads the
LocationProvider fix. TrailScreen::ownPos, CompassScreen::gpsValid and
NearbyScreen::refresh now delegate to it instead of each duplicating the
getLocationProvider/isValid dance (and the ENV_INCLUDE_GPS guard).
pushCogFix now restarts the course-over-ground ring when the gap since the
previous fix exceeds 15 s: after losing GPS for a while, a window spanning
the stale old fixes would compute a bogus "teleport" heading. The
last-good heading is kept for display continuity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tools › Compass shows the device's course over ground (from the GPS COG
ring — no magnetometer) as a rotating arrow in a ring with a north tick,
plus a large degrees + cardinal readout. "No GPS fix" without a fix;
"move to set heading" when stationary (course undefined). Reuses
UITask::currentCourse, so it works without recording a trail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3. Fixed 16-entry table of (lat, lon, ts, label[12]) persisted to
/waypoints with a magic+version header (mirrors TrailStore's format).
Unlike the RAM-only trail, waypoints are loaded in UITask::begin() and
rewritten on change via UITask::saveWaypoints(), so they survive reboots.
add/remove/rename/clear operate on the in-RAM table; the screen layer
(phase 5) drives persistence after each edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2. A 5-fix GPS ring sampled every ~1 s in UITask::loop, filled
whenever there's a valid fix — independent of trail recording, so the
heading is available to navigation at all times, not only while logging
a trail.
- pushCogFix() rejects gross outliers (a fix implying > 50 m/s since the
previous one) so a single bad GPS reading can't swing the heading.
- currentCourse(deg) returns the bearing across the window (oldest→newest)
once the window spans ≥ 6 m of real movement; below that it holds the
last good heading, and returns false only until the first valid heading
exists. This is the "two absolute bearings (To / Hdg)" heading source
for the upcoming nav view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
README:
- Fix typo "offical" → "official"
- File names in Firmware Variants table now match workflow output
(solo-<version>-oled.uf2 / solo-<version>-eink.uf2)
- Un-comment the E-ink Display section (now fully supported)
- Clock Screen: "sensor values" → "data fields" (covers Batt%, Nodes, Msgs)
- Tools Screen: mention nearby nodes and auto-advert in the highlight
- Promote Screenshot Tool to its own top-level section (was buried under
Contributing); fix `uv run tools/screenshot.py` invocation path
Docs:
- favourites_dial: document the 3rd pin-picker tier (all chat contacts
fallback) added in 0e0e5b93
- clock_screen: Altitude source clarified — onboard sensor (GPS or
barometric), not always GPS
- message_screen: blank lines around image tables that were inlined
Code:
- UITask.cpp: rename leftover plus_y/plus_label locals to solo_y/solo_label
in the splash-screen Solo banner
Workflow:
- workflow_dispatch builds previously labelled firmware with the branch
name (GITHUB_REF_NAME). Resolve BUILD_VERSION to the tag when run on a
tag push, else dev-<short-sha> so manual runs produce
solo-dev-abc1234-oled.uf2 etc.
Cleanup:
- remove stray docs/solo_features/settings_screen/set_scr_1_eink copy.png
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AUTO_OFF_MILLIS is a power-save feature aimed at battery use. When the
board reports isExternalPowered() == true (USB or other DC source),
blanking the screen serves no purpose — there's nothing to conserve.
But OLEDs are vulnerable to burn-in with static content, so this
behaviour is gated behind a new build flag KEEP_DISPLAY_ON_USB. Default
is unchanged from upstream — the display blanks after AUTO_OFF_MILLIS
on USB or battery. Variants that ship with an LCD instead of an OLED
(e.g. heltec_t096) can opt in by adding -D KEEP_DISPLAY_ON_USB to
their env, gaining always-on-while-powered without exposing OLED users
to burn-in risk.
When the flag is enabled, the implementation refreshes _auto_off every
loop iteration while externally powered, so the timer naturally counts
a fresh AUTO_OFF_MILLIS window from the moment power is removed —
no instantaneous-blank-on-unplug.
Applied to all three companion_radio UI flavours (ui-new, ui-tiny,
ui-orig). Boards without an isExternalPowered() override use the
base-class default in MeshCore.h (returns false), so battery-powered
behaviour is unchanged everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
msgRead(0) was called when the companion app read the last message from
the offline queue, and it erased the entire _dm_unread_table. This wiped
Favourites Dial badges the moment the app synced — leaving a 3-second
window (the alert duration) where the badge was faintly visible behind
the incoming-message popup, then gone.
_dm_unread_table tracks per-contact UI state (Favourites Dial badges,
contact list counters). It should only be cleared by user action:
clearDMUnread (opens a DM) or clearAllDMUnread (mark-all-read). It has
no relationship to the offline queue drain state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user presses + on an empty Favourites Dial tile, the picker
previously showed 'No fav contacts' if there were no starred contacts
(flags & 0x01) AND no recent DMs in _dm_hist (empty after reboot).
Add a third fallback tier: when the first two tiers yield nothing, list
all ADV_TYPE_CHAT contacts so the user can always pin anyone. Alert
text updated to 'No contacts' for the remaining edge case (no contacts
at all in the mesh).
Known limitation: per-contact unread badges on the dial reset on reboot
(in-RAM only). Persisted unread is a separate future improvement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Clock-page Batt% field used a separate hardcoded linear mapping
(3000-4200 mV → 0-100%) that disagreed with the top-bar battery indicator,
which uses a piecewise LiPo discharge curve and the user-configurable
low_batt_mv cutoff (Settings → Low battery threshold) as the 0% anchor.
The two readings could differ by 25+ percentage points for the same
voltage, and the dashboard ignored the cutoff setting entirely.
Extract battMvToPercent(mv, low_mv) as a file-static helper so both the
top-bar indicator and the dashboard field share the same curve and the
same cutoff source. Drop the stray BATT_MIN/MAX_MILLIVOLTS #defines
that leaked from inside the dashboard code paths into the rest of the
TU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UITask: when a key is pressed while the buzzer is playing, defer the
next screen refresh by 300 ms instead of triggering it immediately
(via _next_refresh = 100). The blocking e-ink endFrame() was extending
the first note by the full refresh duration (~150-200 ms). Subsequent
notes were unaffected because _next_refresh was already future-dated
after the first render.
DataStore: for builds where JOYSTICK_ROTATION is not defined and
FEAT_JOYSTICK_ROTATION_SETTING is 0 (OLED), always reset
joystick_rotation to 0 on prefs load. Without this, stale e-ink prefs
(which allow changing joystick rotation in Settings) could silently
leave a non-zero value that reversed all joystick directions on OLED.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four findings from a code review pass on the Trail feature:
- showAlert overlay was gated to `curr == home`, so feedback like
"Tracking started", "Trail saved", "GPX N B (USB)" never rendered on
the Trail screen (or QuickMsg / Settings / Auto-Advert, for that
matter). Drop the gate — the overlay shows on whichever screen the
caller invoked from.
- TrailStore::writeTo only verified the first write; subsequent point
writes could partially fail (full filesystem, mid-write power loss)
and the method would still return true. Check every write return
value so handleSave's "Trail saved" only fires on actual success.
- gpxPoint: clamp snprintf's intended-length return against the buffer
size and skip the point if gmtime returns null. Avoids any chance of
out.write reading past buf[120].
- TrailScreen::_act_map sized exactly to today's 8 actions — bump to 12
so adding another popup item later doesn't cause an OOB write into
the surrounding members.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The badge was rendered at cy + 1 while the name centered around cy + ~8;
on OLED with line_h = 8 the two ended up touching, and the name's
drawTextEllipsized used the full cell width so any "..." would land
under the digit anyway.
Compute the badge width up-front, subtract it (plus a 3 px gap) from
the name's max width, and render both on the same baseline (name_y).
The name now shortens to "Nam…" before the badge instead of running
through it. E-ink with its wider tiles already had room, so this is a
strict improvement there too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both call sites of buildVisibleOrder wrote into a fixed-size 11-int
stack buffer. After FAVOURITES landed, HomePage::Count became 12; in
the common case where all pages are visible, buildVisibleOrder writes
12 entries — one past the end. That OOB write trashed whatever lived
next on the stack (typically the loop variable in navPage), so LEFT
wrap-around behaved randomly: sometimes locked at the first page,
sometimes jumped to a wrong one. User-visible as "menu doesn't wrap
to the left".
Size the buffer to `(int)Count` so it tracks the enum and grows with
any future home page additions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GPS NMEA polling is cheap, and a slower cadence just hides motion from
the trail; the min-delta gate already filters jitter without throttling
the GPS. Both knobs that exposed cadence-style controls to the user are
removed:
- Trail sampling is fixed at TrailStore::SAMPLING_SECS = 1 s, matching
the sensor manager's built-in GPS update default. The
intervalSecs / intervalLabel / INTERVAL_COUNT helpers go with it.
- GPS update rate ("GPS upd" entry) leaves the sensor manager on its
built-in 1 s default. applyGPSInterval and the GPS_INTERVAL_OPTS /
LABELS tables / gpsIntervalIndex helper are deleted.
Both NodePrefs fields (gps_interval, trail_interval_idx) are retained
as reserved so the schema sentinel doesn't have to bump — older saves
load cleanly, the bytes are just ignored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The status-bar G/A indicators both flip every 2 s (millis() % 4000 <
2000), but with only auto-advert forcing the 1 s refresh, a screen with
G alone repainted every 5 s — the blink looked sparse vs A. Treat any
blinking indicator (auto_adv OR trail active) as needing the 1 s
cadence on OLED.
E-ink builds keep the 30 s home refresh — blink is gated to "always on"
there via Features::BLINK_INDICATORS = false.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Tools › Breadcrumb. Enter on the screen toggles tracking; while
active, UITask::loop samples GPS at the cadence chosen in settings
(default 1 min) and drops the point into a 256-entry RAM ring guarded
by a min-distance gate (default 25 m). A "G" indicator joins "A" in
the status bar with the same blink convention.
New storage:
- examples/companion_radio/Breadcrumb.h — BreadcrumbStore class
- 256 × 12 B = 3 KB RAM (survives auto-off, lost on reboot — explicit
flash-slot save comes in phase 4)
- Haversine min-delta gate, total distance, elapsed seconds, current
km/h, bounding-box helper
New screen:
- examples/companion_radio/ui-new/BreadcrumbScreen.h — Summary view
with Status, Points, Dist (m or km), Time (h:mm), Speed; Enter
toggles tracking, Esc returns to Tools
Schema bump 0xC0DE0002 → 0xC0DE0003 for two new NodePrefs bytes:
breadcrumb_interval_idx (1min default) and breadcrumb_min_delta_idx
(25m default). Older saves leave both at zero, which matches the new
defaults. DataStore::loadPrefsInt/savePrefs read/write the pair.
Logging on/off is a runtime flag inside BreadcrumbStore — not a
preference — so reboot returns to the off state cleanly; the user
will be able to persist a snapshot to a slot in phase 4.
Phase 2 (map view), 3 (point list), 4 (slot save/load, GPX export over
USB), and 5 (settings UI) follow incrementally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.
Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.
Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
(12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
Contact-options menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on a populated slot of the Favourites grid now jumps straight into
that contact's DM history — resolves the pinned 6-byte pub_key prefix
against the contact list, then routes through new
UITask::openContactDM(ci) which resets QuickMsgScreen and calls
enterDM() to land directly in DM_HIST.
Empty slots still show the "pin from contact options" hint until phase 3
wires the in-grid mini-picker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Render issues:
- Drop the "FAVOURITES" title — it overlapped the node-name/battery top
bar; the page-dots indicator already identifies the page
- Use the locally-computed content_y (matches other home pages) so tiles
sit below the dots row instead of starting at headerH() + sepH()
- Add 2 px top/bottom margin inside the grid so cells don't hug the dots
or the panel edge
Upgrade migration (visibility + position):
- Newly-loaded prefs with an older sentinel OR a non-default mask had
HP_FAVOURITES off and could not be enabled — DataStore::loadPrefsInt
now ORs HP_FAVOURITES into home_pages_mask whenever the sentinel
doesn't match the current schema
- Existing page_order_set users were stuck because FAVOURITES wasn't in
page_order, so movePageInOrder bailed — ensurePageOrderInit now
detects the missing FAVOURITES entry and inserts it right after CLOCK,
shifting the tail; if the array is already full the page still
appears via the buildVisibleOrder fallback
Bug fix in ensurePageOrderInit verification loop: iterated to HPB_COUNT
(12) over a PAGE_ORDER_LEN (11) array → one OOB read per call. Now
bounded to PAGE_ORDER_LEN.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New home page rendering 6 pinned-contact slots. Grid transposes to
orientation: landscape → 3×2, portrait → 2×3. Joystick claims UP/DOWN
and inner LEFT/RIGHT for grid nav; edge LEFT/RIGHT fall through to
page navigation. Selected tile inverts via drawSelectionRow, filled
tile shows contact name + unread badge, empty tile shows "+".
Storage:
- NodePrefs::favourite_contacts[6][6] (6-byte pub_key prefix per slot,
all-zero = empty; collision probability with a real key is 2^-48)
- HomePageBit gains HPB_FAVOURITES = 11 (HPB_COUNT = 12), with a new
HP_FAVOURITES mask bit so the page is toggleable in Settings
- New PAGE_ORDER_LEN constant decouples the persisted array length (11)
from the page-type count; loops over page_order now use it, value-range
checks still use HPB_COUNT
- homePageBit returns 0 for HPB_SETTINGS / HPB_QUICK_MSG explicitly
rather than by bit-index range, so HPB_FAVOURITES (bit 11) is correctly
treated as toggleable
- Default order inserts FAVOURITES after CLOCK; SHUTDOWN drops to the
fallback "missing pages" append (still reachable, lands at end)
Schema sentinel bumped to 0xC0DE0002. DataStore::loadPrefsInt and
savePrefs read/write the new field. Older saves leave the field
zero-initialised (all slots empty), which is the natural starting state.
Phase 2 (Pin from Contact options) and Phase 3 (Pin from empty-slot
mini picker) wire interactions; this commit handles render + nav only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pageBit/bitToPage in HomeScreen and homePageBitIndex in SettingsScreen
each spelled out the bit assignment with magic numbers 0..10. Lift the
numbering into a NodePrefs::HomePageBit enum (HPB_CLOCK … HPB_QUICK_MSG,
HPB_COUNT) and route every reference through it: mapping functions, the
HP_* bitmasks, the homePageLabel table sizing, loop bounds over
page_order, the bounds check on page_order[0], the "always-visible"
discriminator for SETTINGS/QUICK_MSG.
Per-class enums (HomeScreen::HomePage, SettingsScreen::SettingItem)
stay — they index different concerns (navigation page vs. settings
row). The mapping functions still exist, but now reference a single
source of truth for the bit values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five sites in UITask.cpp + MyMesh.cpp branched on EINK_DISPLAY_MODEL
purely for runtime-shape decisions (blink rate, default pref values,
refresh intervals). Replace with a constexpr Features namespace:
IS_EINK, BLINK_INDICATORS, CLOCK_HIDE_SECONDS_DEFAULT, HOME_REFRESH_MS,
LOCKSCREEN_REFRESH_MS. Compiler dead-branch-eliminates so cost is
identical to the preprocessor branch, but the code stays reachable to
tooling and the policy lives in one file.
SettingsScreen and driver headers still need real #ifdef — they
condition enum members and class field layouts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mechanical sweep across UITask.cpp and SettingsScreen.h. All current
format strings already fit their buffers, but a future format change
would silently overflow with sprintf. snprintf with sizeof makes the
buffer bound explicit and traceable to the array declaration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>