Commit Graph

3557 Commits

Author SHA1 Message Date
Jakub
05609019b4 fix(ui): Admin password change didn't update the saved login copy
Tools > Admin > System > "Admin password" (set-only, sends "password
<new>") changes the remote node's own admin credential, but nothing
updated this device's saved copy of it -- so the very next login
attempt to that node retried the password just replaced, landing
straight in the "stuck on Logging in..." case fixed in the previous
commit. Likely the actual trigger behind that report.

CommonCLI::handleCommand() always echoes a successful password change
back as "password now: <value>" (truncation and all), so parsing that
reply gives the exact value now required to log back in, rather than
trusting what we sent (which the remote may have truncated further).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:11:30 +02:00
Jakub
23f43cac59 fix(ui): Admin login could hang forever on "Logging in..."
AdminScreen's LOGIN phase had no timeout, unlike its COMMAND phase
(_cmd_deadline_ms). If a login reply never arrived -- most commonly a
saved password gone stale after the remote node's password changed,
silently dropped instead of nacked -- the screen stayed stuck with only
a manual Cancel to escape.

sendRoomLogin() now returns the same est_timeout sendAdminCommand()
already exposes; AdminScreen uses it to arm a deadline (poll(),
mirroring the COMMAND-phase pattern) that forgets the stale password
and returns to the picker on expiry, same as an explicit login
rejection already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:50:56 +02:00
Jakub
7113d34ea1 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	.github/actions/setup-build-environment/action.yml
2026-07-18 10:13:56 +02:00
Jakub
5908ba1256 feat(ui): channel Add gets a Type picker (Public/Hashtag/Private)
"+ Add channel" jumped straight into a generic Name+Secret form, so the
"hashtag channel" convention documented in docs/companion_protocol.md
(secret = first 16 bytes of sha256("#topic")) was only reachable by
already knowing to type a literal "#topic" into the passphrase field --
nothing in the UI surfaced it. The phone app instead shows an explicit
channel-type picker; this adds the same on-device.

"+ Add channel" now asks Public / Hashtag / Private first:
- Public commits immediately with the well-known default channel's name
  and secret (8b3387e9c5cdea6ac9e5edbaa115cd72, confirmed to match
  MyMesh.cpp's PUBLIC_GROUP_PSK and the docs' published key) -- useful
  to restore it if deleted.
- Hashtag shows a single Topic field; Save synthesizes name="#topic" and
  derives the secret via the existing SHA-256 passphrase path -- same
  underlying mechanism Private already had, just discoverable without
  knowing the "#" convention.
- Private is today's manual Name+Secret form, unchanged.

Editing an existing channel skips the picker (no ambiguity to resolve
there). Extracted hexToSecret() out of deriveSecret()'s hex-mode branch
so Public's fixed key parses through the same code instead of a second
hand-rolled loop. No other file needed changes -- openAdd()/openEdit()/
active()/render()/handleInput() keep their existing signatures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v1.23
2026-07-17 22:04:40 +02:00
Jakub
94181831dd fix(ui): don't draw the new-message alert over a full-screen keyboard
UITask::newMsg() (fired for every incoming DM/channel message) triggers a
3s "Msg: <sender>" overlay drawn on top of whatever screen is current --
including the shared KeyboardWidget when it's occupying the full screen
for text entry (message compose, room/repeater password, channel name,
device name, admin custom command, ...). A message arriving mid-typing
blanked out the letter grid for the full 3s with no way to see what was
being typed.

KeyboardWidget now tracks whether it was actually rendered this frame
(_visible, set at the top of render(), cleared by the new beginFrame()).
UITask's render loop calls _kb.beginFrame() before curr->render() and
skips the alert overlay when the keyboard turned out to be what got
drawn -- covers every screen that shares _kb, not just message compose.
The alert itself is unaffected (still fires, still expires after 3s) --
it just doesn't draw over the keyboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:04:29 +02:00
Jakub
273fbfde7a chore: pre-release cleanup pass (Lemon-era naming, bot sender parsing dedup)
Renamed the vestigial Lemon/default font-switch naming (setLemonFont/
isLemonFont/drawLemonChar/lemonXAdvance/_use_lemon -> setSingleFont/
isSingleFont/drawGlyph/glyphXAdvance/_single_font) across DisplayDriver.h
and both concrete drivers -- both have been permanently single-font for
several commits, so the old names invited a future reader to think a
real switch still existed. Pure identifier rename, no logic changed.

Also extracted the byte-identical "SenderName: " prefix-splitting in
MyMeshBot.h's tryBotReplyChannel()/tryBotChannelCommand() into a shared
botChannelSenderSplit(), mirroring the existing botRoomSenderName().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 21:02:33 +02:00
Jakub
fe1e0d29ce fix(display): undefined-glyph box overlapped the line above on OLED
drawLemonChar()'s "y" is the top of the current text row on SH1106 (real
glyphs render at y + 7 + yo + row), unlike GxEPDDisplay's version, where
y IS the baseline. The undefined-glyph fallback box copied GxEPD's
y - 7*sz formula verbatim during the font-unification pass, sending it
7px above the row's top edge -- into the previous line's space. Drawing
it at plain y (already baseline minus the font's 7px ascent) lands it in
the same relative position GxEPD's version occupies, within its own row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:53:56 +02:00
Jakub
afdfca6f9b feat(keyboard): selectable main/additional script for the on-screen keyboard
Page 0 was hardcoded to Latin -- Cyrillic/Greek could only ever be the
second, cycled-to page. Settings > Keyboard's Alphabet row splits into
Main (which script the keyboard opens on by default) and Additional
(the second one reached via #@/abc), so a Cyrillic/Greek typist can make
their own script the default instead of always landing on Latin first.
Setting Additional equal to Main collapses back to a 2-page cycle (that
script + Symbols), same rule the old Latin-hardcoded design already used
implicitly.

KeyboardWidget.h: cellStr()/t9GroupStr() now dispatch through
scriptCellStr()/scriptT9GroupStr(), treating Latin as an ordinary peer of
Cyrillic/Greek instead of a special case; scriptHint() replaces
altAlphabetHint() so the #@/abc key's "next page" hint is correct
regardless of which script that lands on; the accent popup's gating
checks the current page's actual script instead of assuming page 0 is
always Latin. Removed the now-dead pageIsAltAlphabet().

NodePrefs gains keyboard_main_alphabet (schema sentinel 0xC0DE001F ->
0xC0DE0020, same append-at-tail/clamp-on-load pattern as every prior
schema growth this file uses). Verified via a real build that the new
field lands in existing tail padding -- sizeof(NodePrefs) is unchanged
at 2712.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:50:41 +02:00
Jakub
694bbcd68b feat(keyboard): hold-Enter accent popup; relocate cursor-mode trigger
Replaces the 8 separate Latin-diacritic alt-alphabet pages (Polish, Czech,
Slovak, German, French, Spanish, Portuguese, Nordic) with one popup: Hold
Enter on a Latin letter that has accented variants (a c d e i l n o r s
t u y z) opens a floating horizontal strip of that letter's accents,
anchored over its own row so the grid stays visible underneath (LEFT/
RIGHT picks, Enter inserts via a new shared insertGlyph() helper, Cancel
dismisses). Holding a letter with no variants is a no-op. Cyrillic/Greek
remain full alt-alphabet pages; NodePrefs::keyboard_alt_alphabet shrinks
from 11 to 3 values accordingly -- an old saved Polish..Nordic value just
clamps to Latin via DataStore.cpp's existing range check, no migration
code needed.

Freeing Hold-Enter on letter cells required moving cursor-mode's own
trigger: UP from the top letter row now enters it instead of wrapping to
the special row. To keep that wrap reachable, cursor mode's UP/DOWN
(Home/End) continue the wrap once already at that boundary -- UP again
lands on the special row, DOWN again back on the letter grid -- reusing
the same proportional column mapping the old direct wrap used.

Diagnostics' font-coverage sample swaps its 8 per-language lines for one
line sampling the new accent table. Docs (message_screen, settings_screen,
solo_ui_framework) and release-notes updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:28:11 +02:00
Jakub
ce1fde4fdb fix(admin): field label/value overlap and scrollbar-clipped freq digit
Long field labels ("Flood advert interval (h)", "Frequency (MHz)") were
ellipsized to nearly the full row width with no reserve for the value
column, so a label could run under/through the value or digit editor on
the row currently being edited. Only rows showing an inline value now
reserve room before valCol(); other rows keep the full width as before.

Separately, the Frequency digit editor (4 int + 3 dec digits, needed for
Admin's wider 150-2500 MHz range vs Repeater's 3-digit range) drew all 8
digits flush to the screen edge, landing the thousandths digit exactly
under a visible scrollbar's reserve column. Shifted left by that reserve,
matching the plain-value branch's existing scrollbar-aware positioning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:27:55 +02:00
Jakub
453dc5e570 feat(admin): typed Radio/Routing field editors; pre-release audit fixes
Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string
field (free-text keyboard) into 4 independent, type-appropriate rows --
Frequency (same digit-cursor DigitEditor Settings/Repeater use locally),
Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping),
plus TX power and the 3 Routing numeric fields as number steppers and
Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per
keystroke); only Enter sends one combined `set`, Cancel sends nothing.

Full pre-release audit of all 16 commits since v1.22 (5 parallel focus
areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels,
UITask+NodePrefs schema) turned up and fixed:

- Admin: fetched FK_NUMBER values weren't clamped to the field's range,
  so a value already out-of-range could get stuck unreachable; fixed
  commit-time float format noise (%.6f -> %.3f); Cancel/failed-login
  always returned to the Nodes picker even when Admin was opened
  directly from a node's Hold-Enter action -- now returns to wherever
  it was actually opened from (AdminScreen::_from_picker).
- Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap,
  so cycling to the 2nd/3rd candidate always came out lowercase --
  fixed by caching the cycle's caps state (t9_caps).
- Messages: history is numbered newest-first, so a message arriving
  while scrolled up to an older one silently relabeled the view onto a
  different message -- selection now shifts with the insert.
- Channels: onChannelRemoved() didn't clear ch_notif_override/
  ch_notif_muted/ch_fav_bitmask despite its own contract comment
  requiring it (now far more reachable via the on-device Delete);
  an all-zero hex secret silently self-deleted the channel it was
  just saved into (collides with the empty-slot sentinel) -- rejected.
- Room login: isRoomLoggedIn() indexed the login-tracking ring
  directly instead of via its head offset, silently wrong once the
  ring wraps (8+ rooms/session) -- the new on-device Logout depends
  on this being right.
- Font/display: removed the now fully-inert Settings > Display > Font
  toggle and the dead LemonFont.h (retired by the earlier misc-fixed
  font unification, zero remaining includes); fixed a copy-pasted
  "5x7" comment (font is 6x9), two meaningless dead ternaries, and an
  OLED/e-ink inconsistency in the undefined-glyph fallback box offset.
- AdminField's `kind`/bounds fields no longer rely on default member
  initializers inside aggregate-init: this toolchain's actual nRF52
  build (unlike env:native) has no explicit -std= override, so it
  predates C++14's aggregate-with-default-member-initializer rule.
  Given an explicit constructor instead -- portable regardless of
  standard, all existing field-table literals unchanged.

Docs updated to match: tools_screen.md (Diagnostics as a 3-tab
carousel, was documented as one flat screen), message_screen.md
(chat bubbles, newest-at-bottom, cursor mode, secret validation),
settings_screen.md (dropped the dead Font row), solo_ui_framework.md
(header menu_hint signatures, icon priority-drop, KeyboardWidget's
T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section
covering all of the above plus the other 15 commits since v1.22.

Build-verified on WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
Jakub
e439dd0fbe feat(ui): chat bubbles, right-aligned compose button, keyboard cursor editing
- MessagesScreen: shrink message boxes to fit content and anchor them
  right (outgoing) / left (incoming), like typical messengers; move
  the [+ send] compose button to the right edge to match; fix its
  frame margins (no descenders in the label made the symmetric padding
  look uneven) and reclaim the freed 2px for the message list.
- KeyboardWidget: add Hold-Enter cursor-positioning mode (LEFT/RIGHT
  move, UP/DOWN jump to start/end, Enter/Cancel exit) with a visual
  "CURSOR MODE" indicator, so edits and inserts can target any point
  in the typed text instead of always the end.
2026-07-17 14:40:59 +02:00
Jakub
af0cd2116e feat(ui): message history newest-at-bottom; keyboard one-shot Shift + hold-clear
Message history (DM + channel) now stacks bubbles upward from the compose
row instead of the header, so the newest message sits at the bottom like
a typical messenger, with older messages progressively above. UP/DOWN and
the scrollbar direction are swapped to match; the newest bubble now keeps
the same breathing-room gap against the compose row as it does against
another bubble, instead of touching it.

Keyboard: Shift is one-shot by default (capitalises just the next
letter, then reverts), with Hold-Enter on Shift toggling a persistent
caps-lock for the old sticky behavior. Hold-Enter on Backspace clears
the whole field in one action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 10:02:00 +02:00
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
ripplebiz
219812b9f1 Merge pull request #2943 from meshcore-dev/revert-2915-main
Revert "Add StreamSensor allocation for GroupData"
2026-07-13 19:31:05 +10:00
ripplebiz
d9cd3ea03a Revert "Add StreamSensor allocation for GroupData" 2026-07-13 19:30:11 +10:00
ripplebiz
a517f9c24f Merge pull request #2915 from hpux735/main
Add StreamSensor allocation for GroupData
2026-07-13 18:42:54 +10: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
Will Dillon
754fcb1fae Update number_allocations.md
Adds a small allocation for groupdata packets for the Meshcore firmware for the StreamSensor product.  It was previously a LoRaWAN platform, and we've moved to Meshcore.
2026-07-09 11:43:20 -07: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
Liam Cottle
bbb58cceb8 Merge pull request #2903 from liamcottle/ci/build-matrix
Refactor builds via GitHub Actions to be super fast
2026-07-07 14:31:28 +12:00
liamcottle
54234b5837 implement matrix builds for faster firmware releases 2026-07-07 13:23:18 +12: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
liamcottle
57563ab8f9 update qr code docs 2026-07-06 02:04:56 +12: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