Commit Graph

3537 Commits

Author SHA1 Message Date
Jakub
9d44921d34 feat(ui): migrate e-ink to misc-fixed font; fix Alarm UX and status-bar icon alignment
- 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>
2026-07-15 15:36:39 +02:00
Jakub
82caa3e292 feat(ui): unify OLED on misc-fixed 6x9 font; add Diagnostics tab carousel
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>
2026-07-15 11:16:25 +02:00
Jakub
56aaff1f76 refactor(admin): make Admin remote-only, move device settings to Settings
A device administering itself via the Admin tool read as awkward, so the
local mode is gone and its options relocated to Settings.

- AdminScreen is remote-only: drop the CHOOSER phase, _local_mode, the
  LocalField/LocalKind model and activateLocalField/commitLocalField.
  Phases collapse to LOGIN/COMMAND/REPLY, entered only via startFor().
- Tools > Admin opens straight to the Nodes picker (ACT_ADMIN ->
  pickAdminTarget()). Backing out of a command screen returns to that
  picker; the picker's Cancel returns to Tools. gotoAdminScreen() removed.
- Settings > System gains Name (keyboard-edited node_name) and Reboot
  (action row, placed last so it isn't the default cursor). Radio + TX
  power were already in Settings; Send advert is the home ADVERT page.

Docs: tools_screen.md Admin section rewritten remote-only; settings_screen.md
System table gains Name + Reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:10:24 +02:00
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
58e07900db docs(readme): add Contributors section
Big thanks to vanous and marczykm; plus a nod to upstream MeshCore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.22
2026-07-06 21:07:31 +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
4ae3d22654 perf(oled): skip I2C flush when the frame is unchanged
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>
2026-07-05 22:40:01 +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
7ff1b7c86b fix(wio-tracker-l1): battery voltage reads high/jittery — divider always on
The per-read VBAT_ENABLE gating from 4d46abb2 (energy optimizations)
powered the battery voltage divider HIGH for only 10ms before each ADC
read, then LOW. 10ms is too short for the high-impedance divider node
to settle before the nRF52 SAADC samples it, so readings came out high
and inconsistent (e.g. 4.6-5.0V on a LiPo that maxes ~4.2V).

Hold VBAT_ENABLE HIGH continuously again (as before 4d46abb2) so the
node is always settled at sample time, and drop the toggle + delay(10)
from getBattMilliVolts(). Standby cost is ~2uA on the 2x1M divider,
negligible next to the device's mA-level draw.

The CPU-sleep and LED-off energy optimizations from 4d46abb2 are left
untouched — those are the real savings; only the VBAT gating is reverted.
2026-07-03 09:51:32 +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
5fe9240fc5 Merge branch 'integrate/pr21-t9-keyboard'
Merges PR #21 (T9 on-screen keyboard option, marczykm) plus a follow-up
fix bumping SCHEMA_SENTINEL to 0xC0DE001A — the PR added a new persisted
field (keyboard_type) without bumping the schema version, which would
have silently misaligned every NodePrefs field after alarm_min for
devices already on the previous schema.
2026-07-02 18:05:18 +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
7e0dbe4afc docs: clock tools + e-ink input handling; v1.21 release notes
- release-notes v1.21: add Clock tools (alarm/timer/stopwatch) to What's new,
  e-ink button responsiveness + double-tap debounce to Fixes, and the input
  edge-capture/key-queue and hardware-timer ringtone notes to Under the hood.
- README: note clock tools on the Clock Screen entries.
- solo_ui_framework: document the hardware->handleInput path (mandatory
  begin() per button, IRQ edge capture, key queue + coalesced redraw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.21
2026-06-30 22:40:44 +02:00
MarekZegare4
f31596da1a fix(eink): debounce edge capture + self-heal so one press isn't a double-tap
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>
2026-06-30 22:40:44 +02:00
MarekZegare4
65e5b0af99 Merge branch 'fix/eink-button-irq-edge-capture' 2026-06-30 22:16:16 +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
697af2d435 Merge branch 'feat/clock-alarm-timer-stopwatch'
Clock Tools: one-shot alarm, countdown timer and stopwatch on the Clock
page, with the alarm/timer/ring engine in UITask (sync-robust absolute
alarm scheduling; millis-based timer/stopwatch).
2026-06-30 09:01:11 +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
59d1722207 Merge branch 'refactor/quickmsg-history-store'
Extract the DM + channel message-history rings from QuickMsgScreen into a
self-contained MessageHistory store, dedupe the notif/melody override
accessors, share the history row-frame / [+ send] button between the two
history lists, and fix the [+ send] row vanishing in room/DM history.
Hardware-validated before merge.
2026-06-29 23:10:54 +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