Commit Graph

1265 Commits

Author SHA1 Message Date
Jakub
9bfccf4cf4 feat(admin): local-device mode + reuse Nodes screen for target picking
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>
2026-07-12 20:32:04 +02:00
Jakub
f399298fa6 feat(ui): on-device channel management, remote admin tool, per-language keyboards
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>
2026-07-12 19:40:53 +02:00
Jakub
62e82e8740 fix(ui): bot screen tab order starts on Channel
Reordered BotScreen's carousel to Channel / Room / Direct / Other and
made Channel the default opening tab, per feedback that the screen
should start from the first tab shown. Docs updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 21:19:07 +02:00
Jakub
afd7c0ee78 feat(bot): room-server target, tab-carousel UI, per-target independence
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>
2026-07-10 20:16:59 +02:00
Jakub
dfb993de53 feat(clock,keyboard): alarm repeat + non-Latin keyboard alphabets
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.
2026-07-10 16:01:42 +02:00
Jakub
cf3c2c0353 fix(ui): revert corner-anchored context menus, centre unread badge, add room logout
- 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.
2026-07-10 16:01:14 +02:00
Jakub
a1ee67ae94 feat(ui): discoverable context-menu hint — header glyph, highlight, corner dropdown
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>
2026-07-07 10:31:01 +02:00
Jakub
7f56af1663 feat(ui): status-bar icons drop by priority instead of crushing the node name
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>
2026-07-07 10:30:48 +02:00
Jakub
a56d7079dd feat(ui): retire Recent adverts home page, folded into Nodes
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>
2026-07-07 01:53:02 +02:00
Jakub
97493b490e feat(nodes): on-device add/delete/favourite + circular filter tabs, rename to "Nodes"
Turn Nearby Nodes into the single node hub and make its navigation legible.

Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
  now reuses deleteContactByKey() (one delete path: contact + blob + room password
  + UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
  Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
  defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.

Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.

Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.

Rename Tools > "Nearby Nodes" to "Nodes".

Both solo envs build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:52:51 +02:00
Jakub
8e5b02f0a6 feat(ui): pill unread badges, unified DM/CH headers, trimmed home carousel
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>
2026-07-07 01:12:51 +02:00
Jakub
8d8eace99c revert(eink): drop full refresh on screen change — too much black-flash
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>
2026-07-06 20:58:51 +02:00
Jakub
51a0c46828 feat(ui): auto-enter room chat after a successful login
Logging in to a room server (first-time prompt or a remembered password) used
to leave the user on the room list, needing a second Enter to open the chat.
onRoomLoginResult() now opens the room history on success via a shared
openDmHistory() helper (extracted from the Enter-on-contact path).

The login result is async, so the auto-enter is gated on the user still being
on that room in the picker: phase CONTACT_PICK, room mode, no context menu /
share / pick-target sub-flow active, and the result pubkey matching the
selected contact. Otherwise it stays put (no yanking the user into a screen
they navigated away from).

Resolves the "auto-enter after login" code-review item. Builds green:
WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:42:05 +02:00
Jakub
8a9fb33a6a fix(ui): drop {loc}/{time} placeholders in the room password keyboard
The shared keyboard seeds {loc}/{time} on begin() for message composition, but
a room/repeater login password is not a message — a placeholder token there is
nonsensical and a footgun (picking one inserts literal "{time}" into the
password). Clear them right after opening the keyboard in both room-login paths
(context-menu Login… and the Enter-on-room prompt), matching the same fix
already applied to preset names and waypoint labels.

Builds green: WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:39:11 +02:00
Jakub
21a34451d9 refactor(ui): dedupe caps-shift and favourites "+" tile
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>
2026-07-06 20:18:36 +02:00
Jakub
866e44c4e2 feat(keyboard): show digit on each T9 key; complete v1.22 docs
T9 keys now read like a phone keypad — each cell is labelled <digit><group>
(e.g. 2abc), with the digit matching what the multi-tap cycle lands on after
the letters. No separator space: the widest group (".,!?'-") plus the digit
already fills a narrow 128px OLED cell at 3 columns.

Also fills in the v1.22 release notes (T9 keyboard, trail auto-save, Clock
Tools in Tools, Map/Shutdown reorderable, battery-read fix, the UI-audit fix
batch, discover-dedup, favourites self-heal, preset-name placeholder, OLED
frame-skip, e-ink full-refresh) and syncs the solo-feature docs: new Keyboard
settings section, Trail Auto-save row, and Clock Tools reachable from Tools.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:08:29 +02:00
Jakub
fbcc67f275 style(ui): drop dead if(found) guard in favourites badge
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>
2026-07-05 22:51:56 +02:00
Jakub
638eea7bb0 feat(ui): full e-ink refresh on screen change + prune gone favourites
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>
2026-07-05 22:40:14 +02:00
Jakub
9ee199e641 feat(trail): auto-save GPS trail on shutdown (Trail settings toggle)
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>
2026-07-05 22:21:42 +02:00
Jakub
046c2f258e fix(ui-orig): show hop count, not raw path_len, in message origin
The "(N)" origin readout printed the raw wire path_len. That byte packs the
path hash-size mode in its top 2 bits and the hop count in the low 6, so with
path_hash_mode >= 1 (2-byte hashes) it over-reported — e.g. "(66)" for a
2-hop message. Mask & 63 (getPathHashCount) so it shows the real hop count.

Affects the ui-orig variants only (ui-new/ui-tiny ignore the arg).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:21:31 +02:00
Jakub
399b61e2bb fix(companion): dedup node-discover responses heard more than once
A discover response often arrives twice — the zero-hop direct copy and a
re-flooded copy relayed by another repeater carry different packet hashes,
so the mesh duplicate filter passes both. The standalone scan appended the
same node twice and app-driven discover forwarded both copies, so one
repeater showed up as two.

Track (tag, responder pubkey) for a short window in isDupDiscoverResp() and
drop the second copy in both the standalone and app-forward paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:00:52 +02:00
Jakub
9a71ee14b6 fix(solo): six fixes from the 2026-07-05 UI audit
- 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>
2026-07-05 16:08:44 +02:00
Jakub
04978d8ae9 feat(tools): add Clock Tools entry to Tools > System
Surfaces the Alarm/Timer/Stopwatch screen (previously reachable only
from the home Clock page) as a System tool, via the existing
UITask::gotoClockTools() and ICON_ALARM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:03:40 +02:00
Jakub
e63e260eab fix(ui): drop {loc}/{time} placeholders when naming a radio preset
The shared on-screen keyboard seeds {loc}/{time} placeholders on begin()
for message composition, but they make no sense in a radio-preset name.
Clear them right after opening the keyboard from the "+ Save current..."
flow in both Settings > Radio and Tools > Repeater; the message-slot
editor still keeps them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:02:43 +02:00
Jakub
3419e590fe fix(keyboard): rename QWERTY label to ABC (layout is alphabetical, not QWERTY)
The on-screen keyboard grid has been a-b-c...z alphabetical order since
it was first introduced, never an actual QWERTY row layout. The
"QWERTY" name only appeared with the T9 option (needed something to
contrast it against) and was inaccurate from the start.
2026-07-02 18:33:40 +02:00
Jakub
b576de75b6 fix(prefs): bump schema sentinel for keyboard_type field (PR #21)
keyboard_type was inserted into the append-only NodePrefs layout without
bumping SCHEMA_SENTINEL, leaving it at 0xC0DE0019 — the same value plain
main already uses without this field. A device already on that schema
would silently misalign every field read after alarm_min once updated
to a build containing this change. Bump to 0xC0DE001A and fix the
migration comment's stale version label (it said 0xC0DE0018, which was
already taken by the MAP home-page migration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:02:48 +02:00
Marcin Marczyk
5ed0a05265 Merge branch 'main' into feat/t9-onscreen-keyboard 2026-07-02 13:49:46 +02:00
marcin
3363867d98 Added T9 on-screen keyboard as an option 2026-07-02 12:21:52 +02:00
MarekZegare4
890fc89431 fix(prefs): update NodePrefs size tripwire after page_order growth
Growing page_order 11 → 13 bumped sizeof(NodePrefs) 2488 → 2496 (the 2 added
bytes plus alignment padding to the next 8-byte boundary). Save/load/clamp and
SCHEMA_SENTINEL were already updated; this syncs the static_assert. Verified
with a host compile (fixed-width members → layout matches the target).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:06:08 +02:00
MarekZegare4
948076560b feat(home-pages): make Map and Shutdown reorderable; v1.22 notes
The Map page had no Settings entry and Shutdown was pinned to the end of the
carousel because page_order held only 11 slots — full in the common
GPS+SENSORS config, so both pages fell back to being appended at nav time and
couldn't be moved. Grow page_order to 13 (== HPB_COUNT) so every page has a
reorderable slot, and add Map + Shutdown to the default order and the
required-append list.

To keep the persisted format backward-compatible (page_order sits mid-record,
not at the tail), the on-disk head stays the original 11 bytes at its existing
offset and the 2 new slots are appended at the file tail, matching the
append-only schema design. Bump SCHEMA_SENTINEL to 0xC0DE0019; on a pre-0x19
save the tail bytes are the old sentinel/EOF, so they're clamped to 0 and
ensurePageOrderInit re-appends Map/Shutdown into the freed slots on first use.

Drop the now-dead Shutdown-eviction path (13 slots fit all pages).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:01:40 +02:00
MarekZegare4
088bbe61ba fix(home-pages): stop Favourites re-enabling on update; add Map visibility toggle
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>
2026-07-02 10:34:38 +02:00
MarekZegare4
78dba5e807 Merge branch 'main' into fix/eink-button-irq-edge-capture
# Conflicts:
#	examples/companion_radio/ui-new/UITask.cpp
2026-06-30 22:13:18 +02:00
MarekZegare4
8c70acc3ef fix(eink): queue input so a burst of taps survives a blocking refresh
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>
2026-06-30 22:10:18 +02:00
MarekZegare4
9ce04835da feat(companion): clock tools — alarm, countdown timer, stopwatch
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>
2026-06-30 09:01:03 +02:00
MarekZegare4
116ee3e767 refactor(companion): share history row-frame + compose button across DM/channel
The DM/room and channel history lists drew each message box's selection frame
and the bottom-left [+ send] button with byte-identical inline code. Extract
two small static helpers (drawHistRowFrame, drawComposeButton) and route both
lists through them.

Only the parts that were already identical are unified — the divergent
per-type logic (sender parsing, ACK semantics, body handling) stays inline, so
this is behaviour-preserving. The DM compose button now also gets the channel
list's border (and its always-reset-to-LIGHT), folding in the earlier
[+ send] visibility fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:05:11 +02:00
MarekZegare4
efb6a8b629 fix(companion): keep [+ send] visible in DM/room history when not selected
In the DM/room message history, the message loop leaves the ink DARK when the
last drawn box is the selected one, so the unselected [+ send] row printed
dark-on-dark and vanished. Most visible in room servers, where there's always
a selected message. Reset to LIGHT before drawing the row (matching the
channel-history version). Pre-existing bug, independent of the history-store
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:59:59 +02:00
MarekZegare4
17cc51e84d refactor(companion): dedupe notif/melody overrides via shared primitives
The eight per-contact/channel notification + melody accessors were four
near-identical copies of two storage shapes. Collapse them onto two
primitives:

- maskPairGet/Set — a 3-state packed in a (presence, variant) channel-index
  bitmask pair. The notif and melody uses disagree on which state the variant
  bit means, so the caller passes the state value for variant-set (v_set).
- prefTableGet/Set<Entry> — the {prefix[4], value} DM table find/update/clear/
  insert/overwrite-slot-0 logic, templated over the entry struct + a member
  pointer to its value field (DmNotifEntry::state / DmMelodyEntry::slot).

The eight accessors are now thin wrappers; behaviour identical (verified
state↔bit mappings). QuickMsgScreen 1738 -> 1715 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:48:10 +02:00
MarekZegare4
f6baa5c38c refactor(companion): extract MessageHistory store from QuickMsgScreen
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>
2026-06-29 22:41:01 +02:00
MarekZegare4
4d795ade51 feat(companion): track-back — retrace the recorded trail to its start
Add Tools › Trail → Hold Enter → Track back (shown when the trail has ≥2
points). It reuses NavView but, instead of a single fixed target, snaps onto
the route at the nearest recorded breadcrumb and walks the points in reverse,
auto-advancing the target as each is reached (within 20 m). The header shows
points remaining ("Back: 12 pt" → "Trail start"); reaching the start toasts
"Back at start" and exits. Cancel leaves at any time.

Lives in WaypointsView as a new sub-mode; advancement runs in poll()
(forwarded from TrailScreen) so it tracks GPS independently of the render
cadence. An EtaTracker drives the approach/ETA line and is reset on each
breadcrumb advance to avoid a bogus closing-speed spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:27:54 +02:00
MarekZegare4
c60a7a7f64 feat(companion): GPS averaging for waypoint marking (Mark avg)
Add a "Mark avg" trail setting (Off/5/10/30 s). When set, Tools › Trail ›
Mark here samples the GPS fix once a second for the chosen window and stores
the mean position instead of one instantaneous fix — a steadier, more
accurate mark for a precise spot. A progress screen shows time left + sample
count; Cancel aborts; the window then opens the label keyboard as usual.
Off (default) keeps marking instant.

Sampling runs in WaypointsView::poll() (forwarded from TrailScreen::poll())
so it ticks on the main loop, independent of the slow e-ink render cadence;
int64 accumulators avoid overflow over 30 samples.

Persisted as NodePrefs::gps_avg_idx (schema 0xC0DE0016): struct field +
option table, DataStore rd/write/clamp in lockstep. sizeof unchanged (the
uint8_t fits existing tail padding) so the 2488 tripwire still holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:19:43 +02:00
MarekZegare4
cc88e89a64 refactor(companion): extract drawRowSelection() for the canonical list-row bar
Every drawList/AccordionList row opened with the same hand-written
display.drawSelectionRow(0, y-1, width-reserve, lineStep-1, sel) line —
14 copies of the same geometry and magic offsets. Add a drawRowSelection(d,
y, sel, reserve) helper in icons.h next to drawList and route the canonical
sites through it (Bot/LiveShare/Locator/Repeater/Settings/Tools/Nearby/
QuickMsg/Waypoints).

Rows that intentionally differ (full-width DashboardConfig/QuickMsg, the
keyboard/ringtone grids) keep their explicit drawSelectionRow() call — the
helper is opt-in rather than baked into drawList, so legitimate variants
aren't forced into one geometry. Framework doc updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:53:18 +02:00
MarekZegare4
0e6bd743a6 refactor(companion): unify screen save-on-exit via UITask::savePrefsIfDirty()
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>
2026-06-29 18:47:51 +02:00
MarekZegare4
15716d2b03 fix(companion): nullptr-init screen pointers so a forgotten new() fails safe
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>
2026-06-29 18:42:59 +02:00
MarekZegare4
eedd47d1e1 refactor(companion): hoist screen entry into virtual UIScreen::onShow()
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>
2026-06-29 18:37:04 +02:00
MarekZegare4
bcca97a848 docs(companion): document the screen-fragment single-TU contract
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>
2026-06-29 18:20:47 +02:00
MarekZegare4
b624d03e96 docs(companion): make the entity-reference cleanup contract explicit
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>
2026-06-29 18:17:38 +02:00
MarekZegare4
325b200d07 fix(companion): compile-time tripwire for NodePrefs serialization drift
NodePrefs is written/read field-by-field in DataStore::savePrefs/loadPrefsInt;
the on-disk format is the struct's field layout, with nothing checking that the
two hand-written sequences still match the struct. A forgotten read/write
silently misaligns every later field — the highest-blast-radius convention in
the codebase.

Add a static_assert on sizeof(NodePrefs): changing a data member trips it with
a checklist (sync save/load, clamp on load, bump SCHEMA_SENTINEL, update the
size). A manual checkpoint, not a proof, but it turns silent drift into a build
break. Size is variant-stable (all arrays sized by NodePrefs' own constants),
verified on both nRF52 boards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:13:05 +02:00
MarekZegare4
089048a036 refactor(ui): align screens to the documented UI conventions
Audit pass against docs/design/solo_ui_framework.md, converging deviations:

- prev/next: replace hand-rolled (c == KEY_LEFT || c == KEY_PREV) with
  keyIsPrev()/keyIsNext() in QuickMsgScreen, RingtoneEditor, TrailScreen,
  FullscreenMsgView. Two were real rotary-encoder gaps, not just style:
  NearbyScreen's filter and WaypointsView's hemisphere toggle used raw
  KEY_LEFT/RIGHT with no encoder twin, so the encoder couldn't drive them.
- NearbyScreen's list right column used a manual setCursor(width -
  getTextWidth(...)) instead of the existing drawTextRightAlign() helper
  (which also UTF-8-translates) — now uses it, like Diagnostics/Repeater.
- RepeaterScreen hand-rolled the whole drawList skeleton (scroll clamp +
  reserve + row loop + indicator); collapsed onto drawList().

No D-pad behaviour change; more uniform encoder support. Net -19 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:01:29 +02:00
MarekZegare4
fe19d1b0a7 refactor(companion): dedup age formatting + reply-prefix parsing; drop dead code
Consolidation pass over the message/nearby UI, no functional change beyond
one intentional display tweak:

- Age tags: NearbyScreen::fmtAge, the nearby list's inline column, and
  QuickMsgScreen::fmtMsgAge each reimplemented the same s/m/h bucket ladder
  on top of geo::fmtAgeShort. All now delegate to it (fmtMsgAge removed).
  Visible effect: ages over 24h render as "Nd" instead of capped hours,
  matching the Locator target picker which already used fmtAgeShort.
- Reply prefix: the "@[nick] " parse was duplicated in skipReplyPrefix() and
  FullscreenMsgView::render(). Extracted to one msgReplyBody() helper (body,
  plus optional addressee nick) — one place to handle its edge cases.
- LiveTrack: the expiry predicate was duplicated in expire()/isActive();
  extracted to a private expired() helper.
- Removed a dead M_PI define (and unused <math.h>) in NearbyScreen.h, and a
  comment pointing at a CODE_REVIEW.md that doesn't exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:43:17 +02:00
MarekZegare4
31d602c88a fix(companion): reply to the right author in rooms (and keep nicks UTF-8)
A room is viewed through DM history, where each post is stored "Author:
text" and shown attributed to that author. The reply path, though, built
the @[nick] prefix from _sel_contact.name — the room server's own name —
so replying to someone's post in a room addressed the room, not the person.

New buildDmReplyPrefix() derives the addressee from the same author the
history shows (via dmDisplayParts): the post's author in a room, the
contact name in a 1:1 DM. It also stores the nick raw (UTF-8), like the
channel reply path, instead of running it through the lossy display
transliterator — the prefix is sent over the air verbatim, so a non-ASCII
nick was previously corrupted on the wire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:27:16 +02:00