Commit Graph
100 Commits
Author SHA1 Message Date
Jakub 90bf5e5a11 fix(bot): channel auto-replies now render as outgoing in history
tryBotReplyChannel() mirrored its own reply into the on-device history
prefixed with the node's own name instead of the "Me: " convention the
history view uses to tell an outgoing bubble from an incoming one, so
the bot's own reply rendered left-anchored like a message from someone
else labelled with the device's own name.
2026-08-31 22:04:55 +02:00
Jakub 1025d88045 docs: trim redundant text in tools/settings docs and v1.27 release notes
Cut meta-commentary asides, a restated default, and a stale changelog
footnote that didn't help someone using the feature -- plus tightened
a few release-notes bullets that repeated the same rationale two or
three times over.
2026-08-31 20:42:24 +02:00
Jakub 8573543dd5 fix(ui): Admin's remote Reboot now confirms like Start OTA
Reboot fired on a single Enter with no way back, unlike Start OTA on
the same tab -- inconsistent given both take an unattended remote node
out of action for a while. Reuses the same beginConfirm() idiom.
2026-08-31 20:31:42 +02:00
Jakub 0b34eac41e fix(ui): Tools > Repeater shows its settings before the switch is on
Network mode, the dedicated profile and the flood filters were only
built into the row list once client_repeat was already ON, so setting
up a repeater meant enabling it blind first. Every other on/off tool
in ui-new (Bot, Live Share, Locator) already keeps its settings
visible while off; Repeater now matches.
2026-08-31 20:28:32 +02:00
Jakub d012ad7ee9 fix(ui): Diagnostics' Reset counters now confirms like every other destructive action
The Hold-Enter popup was a single "Reset counters" item with no Cancel
row, so one Enter zeroed all stats immediately -- the same shape the
Trail/channel/preset resets had before beginConfirm() fixed them.
2026-08-31 20:28:16 +02:00
Jakub 050633b6fc fix(bot): normalize GPS reply casing, dedupe channel sender-split
Reuse the existing botChannelSenderSplit() helper for the channel
[LOC]-share sender name instead of re-implementing the same "Name: "
split inline, and fix a stray lowercase "gps:" reply that didn't
match the rest of the !gps command's replies.
2026-08-31 20:20:41 +02:00
Jakub 8ceb420b43 fix(ui): marquee-scroll never advances past 63 characters
The animated marquee that lets a selected row scroll into view instead of
truncating behind an ellipsis uses a small "is this the same text as last
frame" buffer, _marquee_text[64], compared against the current frame's
temp_str[256] via strcmp(). For any text at or past that 64-byte cutoff,
temp_str simply has more bytes than _marquee_text can hold, so strcmp
always finds a difference at the truncation point -- is_new evaluates true
on every single call, which resets _marquee_skip_cp and _marquee_phase back
to the start every frame. Short labels and names (what this was written
for) stayed under the cutoff and animated fine; a longer message-body
preview -- letting a message be read from the list without opening
fullscreen, an emergent use of the same feature -- got stuck holding at its
first frame forever.

Bumped _marquee_text to 256 to match temp_str/window's own cap in the same
function, so the comparison is exact instead of silently truncated. Static
+192 bytes on the one global DisplayDriver instance; verified against RAM
usage on all four build targets (all comfortably under headroom).

This lives in src/helpers/ui/, shared with upstream, unlike the rest of
this session's ui-new/-only work -- kept as its own commit.
2026-08-31 20:10:09 +02:00
Jakub d25fda5f32 refactor(ui): extract PopupMenu::beginConfirm() for destructive-action popups
Found while re-reviewing this session's own commits: five screens each
hand-built the same 2-row Action/Cancel confirm popup, defaulting the
highlight to Cancel -- NearbyScreen's contact-delete, AdminScreen's
OTA-start, and the three just added (Trail's reset, Messages' channel
delete, RadioPresetPicker's preset delete). The plan that added those
three had already flagged this exact duplication without acting on it, so
it just tripled instead of getting fixed.

One PopupMenu::beginConfirm(title, action_label, cancel_label="Cancel")
replaces all five call sites, and makes "defaults to Cancel" a property of
the popup itself rather than something each new confirm has to remember.
Also drops two small redundancies spotted along the way: NearbyScreen's
_confirm.active = true, dead since begin() already sets it, and
RadioPresetPicker's deleting = false being set twice in a row (once inside
openConfirm(), once again by its only caller).

No behavior change; verified against the actual PopupMenu/menu-level state
machines in each of the five call sites before touching them.
2026-08-31 20:09:59 +02:00
Jakub 691e2ab506 fix(ui): confirm destructive actions, retire last Hold-Enter-cancel, de-dup labels
Continuing the consistency review: sweep for the same three defect shapes
elsewhere in ui-new/ (own read pass plus two parallel research agents),
verified against source before acting.

Three destructive actions fired on a single Enter with no way back, unlike
contact-delete's existing confirm-defaulted-to-Cancel popup: Trail's "Reset
trail" (wipes the whole recorded route, no undo short of a prior manual
Save -- reuses Trail's own multi-level menu machinery, alongside its
GPS-off confirm), Messages' channel Delete, and RadioPresetPicker's saved-
preset delete (shared by Settings > Radio and Tools > Repeater, so one fix
covers both). All three now confirm the same way, defaulting to Cancel.

KeyboardWidget was the one place Hold-Enter still doubled as Cancel: Shift,
Backspace and a Latin letter's accent popup already have real, kept
meanings under a hold, but every other special-row cell (Space, OK/Done,
the {} placeholder) fell through to a bare CANCELLED, closing the keyboard
exactly like the real Cancel key. Now a no-op there too, matching the "only
Back closes it" rule already applied to popups and screens.

MessagesScreen defined the same two label arrays (Notif states, melody
slots) four times over, once per context-menu handler. Hoisted to one
pair of static class members -- constexpr wasn't enough to get the linker
to emit them on this toolchain, so they follow the same declare-in-class/
define-out-of-class shape NearbyScreen::FILTER_LABELS already uses.

Alert text: "Advert sent!"/"Advert failed.." and "Sent!" were the only
toasts anywhere with trailing punctuation; normalized to the plain style
every other confirmation uses. Unpinning from the Favourites Dial reported
the freed slot number from the Messages screens but not from Nodes or the
dial's own tile menu; now consistent everywhere pinning already was.

DiagnosticsScreen's Live/System/Font tab renderers hand-rolled the same
scroll-clamp/loop/indicator skeleton drawList() already bundles; switched
both to drawList (passing the screen's own _scroll as its `sel` too, since
neither tab has a row cursor -- makes drawList's internal clamp a no-op and
leaves clampScroll() as the only thing bounding it, unchanged). Pure
internal tidy, no behavior change.
2026-08-31 19:52:43 +02:00
Jakub f40748ba61 fix(ui): unify the four navigate views, add Set as target everywhere
Continuing the consistency pass: the four screens that show the
distance/bearing "navigate to a point" view (Nodes, Waypoints, Trail's
Track back, and navigating to a location shared in a message) had drifted
apart in three ways.

Only two of the four passed an EtaTracker to navview::draw(), so only
Nodes and Track back showed the closing-speed/ETA line -- navigating to a
waypoint or a shared location left it off for no reason. All four get one
now. They also left the view on three different key sets (Back alone,
Back+LEFT/RIGHT, Back+Enter); Back is now the only way out of any of them,
so a stray sideways nudge can't drop you out of a running track-back.
Messages' renderNav() also switched from reading node_prefs directly to
the shared useImperial() helper the other three already used.

Set as target -- the row Nodes and Waypoints both offer for a coordinate
-- was missing from the message-location Options menu; added alongside
Navigate and Save waypoint.

Nodes' own Set as target required a full 32-byte public key, which a
name-only live-track entry (someone sharing position on a channel who
isn't a saved contact -- the group-outing case this exists for) never
has. One flag was doing two jobs: "can be pinged" (needs the full key)
and "can be identified" (needs only the 6-byte prefix a person target
actually uses). Split into has_key/has_prefix; Set as target now only
needs a position, resolving to a person target (follows them) when a
prefix is available and a place target (pinned where they were) when it
isn't -- the same distinction Locator's own picker already draws.

Locator's target picker separately still listed the people pinned to the
Favourites Dial as its privileged top tier, which stopped making sense
once pinning and favouriting became separate concepts. It now leads with
favourites instead, matching every other list in the firmware.
2026-08-31 19:25:08 +02:00
Jakub 6d1e71cd3f fix(ui): one rule for value rows, one meaning for Hold Enter
Three interaction inconsistencies found while auditing the favourites work,
all of the same shape: the same gesture meaning different things depending
on which screen you were on.

Value rows in popup menus. Rows like "Notif: ON" or "Sort: Dist" show a
value the user steps through with LEFT/RIGHT, but Enter treated them as
ordinary menu picks and closed the popup, so changing two of them meant
reopening the menu in between. Trail's settings submenu was the lone
exception, working around it by rebuilding and re-selecting after each
Enter. PopupMenu now knows the difference: addValueItem() marks a row, and
Enter on it returns the new VALUE_NEXT instead of SELECTED, leaving the
menu open. Only Back closes a menu now. Applied to the Messages
contact/room/channel menus, Nodes, the Ringtone editor and Trail, which
drops its reopenSettingsAt() workaround. The LEFT/RIGHT cycling bodies
moved into one helper per menu, since Enter and RIGHT now share them.

Nodes' Fav row was the worst case: LEFT/RIGHT did nothing there at all, so
the only way to toggle a favourite was an Enter that dismissed the menu on
every flip. Its label moved to a member buffer (as the Pin row already had)
so it can be retitled in place.

Settings rows Auto-off, Low battery, GPS pwr and Battery ignored Enter,
though their options wrap exactly like the melody/keyboard/clock rows
beside them, where Enter has always stepped forward. They accept it now.
Rows that ramp between fixed ends (Brightness, Volume, TX Pwr, Timezone,
SF/BW/CR) stay LEFT/RIGHT-only -- there is nothing to wrap to.

Hold Enter no longer doubles as Back. It quietly meant "go back" on Tools,
Locator, Live Share, Repeater, Bot, Auto-Advert, GPIO, Compass, the
Dashboard config and the Messages navigate view, while elsewhere the same
long press opens a context menu. It now only ever opens a menu, or does
nothing where there is none. Same for dismissing an open popup, which it
used to do. Checked that this strands nobody: every board that can reach
these screens has a real Back key (back_btn on joystick boards, Esc on
CardKB/TCA8418/T-Deck). Single-button boards produce no KEY_ENTER at all,
so they never leave the home pages in the first place.
2026-08-31 15:43:10 +02:00
JakubandClaude Opus 5 57851627cf feat(ui): make favourites mean one thing across every list
Three names had grown around one idea. "Favourite" was a filter in
Settings, an invisible app-only flag on a contact, a device-settable bit on
a channel, and — on the Nodes screen — a menu row that actually pinned to
the Favourites dial. Nothing marked a favourite on screen, and the dial
only took chat contacts.

A favourite is now the starred flag (ContactInfo::flags bit 0 for contacts
and rooms, ch_fav_bitmask for channels), settable on the device everywhere
via a Fav: ON/OFF row, marked with a star on its row, and sorted to the top
of the list — in Messages, Tools > Nodes and the Locator target picker.
Settings > Contacts > "Favs top" turns the sorting off; it defaults on, and
is stored inverted so an upgraded prefs file reads back as on rather than
off.

MyMesh::setContactFavourite() writes the same bit the app sets and bumps
lastmod, so the two stay in sync. The DMs/Rooms = Fav list filters no
longer depend on having starred someone in the app first.

Pinning is now separate and explicit, and the dial takes contacts, room
servers and channels. Slots carry a kind (NodePrefs::favourite_kinds,
schema 0xC0DE0029) — a channel slot holds an index, so emptiness is decided
by the kind first, since channel 0's payload is all zeroes. Choosing what
to pin reuses the Messages screen's own Direct/Channels/Rooms browse
instead of a second picker built on the dial, which drops that picker, its
key/label tables and the now-unused getRecentDMContacts(). A filled tile
gained Unpin/Replace under Hold Enter.

Fixes a room server being pinnable and then unremovable: the picker's
recent-conversations tier didn't filter by contact type and room posts
share the DM history, opening one from the dial skipped the login
handshake, and Unpin only existed in the chat contact list that rooms never
appear in.

Also: PopupMenu::_visible was written and never read (render recomputes the
cap from display height), which is why menus with more items than the
"visible" argument always worked; Settings' all/fav values and the
dm_show_all comment said things that were not true.

Built for Heltec V3, Wio Tracker L1, Wio Tracker L1 e-ink and T-Echo Card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:02:01 +02:00
JakubandClaude Sonnet 5 355816eafd polish(ui): text/icon consistency pass across Tools, Settings, and menus
- RepeaterScreen: drop the redundant "Rpt " prefix on the profile fields
  (Preset/Freq/SF/BW/CR), matching Settings > Radio's own terminology --
  the screen is already dedicated to the repeater's own profile, so the
  prefix disambiguated nothing.
- Channel context menu: "Fav: yes/no" -> "Fav: ON/OFF", matching every
  other toggle in the app.
- Settings (System tab): six labels left as raw concatenated identifiers
  (AutoOff/AutoLock/TimeZone/LowBat/BattDisp/BzrVol) now read like their
  space-separated neighbours (Auto pwr/Pwr save/DM sound/GPS pwr), and
  BzrVol no longer clashes with "Buzzer" one row above it for the same
  feature. Three value-label arrays also had one mismatched-case entry
  fixed to match its siblings: Auto-off's "never" -> "OFF", Batt display's
  "icon" -> "Icon", Sound's "built-in" -> "Built-in".
- ToolsScreen: re-enabled the mini-icons next to each tool, which had
  been commented out ("don't fit visually"). Root cause: the screen's own
  drawIcon() centred against lineStep() (line height + inter-row gap)
  instead of getLineHeight() alone, 2px too generous for a near-full-
  height icon -- now delegates to the already-correct miniIconDraw() used
  elsewhere (e.g. the message-list ack checkmark). Also gave Admin and
  GPIO their own icons (padlock, 3-pin header) instead of both sharing
  System's cog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:37:50 +02:00
JakubandClaude Sonnet 5 b6dce2b7f0 feat(ui): show repeater echo count instead of a checkmark on channel sends
The plain ✓ on your own channel post only said "at least one repeater
heard it" -- now it shows how many distinct repeaters echoed it back,
drawn as tiny 3x5 digit icons (icons.h) rather than the normal font,
since the slot next to the sender name is icon-sized, not text-row-
sized. DM delivery ticks are untouched (no repeater-count concept
there, so they keep the plain checkmark).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:45:53 +02:00
JakubandClaude Sonnet 5 ba3c3cc91f fix(mesh): don't mark our own channel post as unread
An app-originated channel send (mirrored into the on-device history)
bumped that channel's unread badge whenever the device's own UI
wasn't already showing that exact channel -- unlike an on-device
compose, which sidesteps this by forcing itself into that channel's
view right before sending. Adds an explicit own_message flag through
addChannelMsg (MessageHistory -> AbstractUITask -> UITask ->
MessagesScreen) so an own post is never counted unread regardless of
what's on screen when it's sent.

Found the same bug in MyMeshBot.h's three auto-reply-into-channel call
sites (Remote Bot's own reply showing as unread on itself) and fixed
those with the same mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:26:57 +02:00
JakubandClaude Sonnet 5 bdd2ed379d feat(mesh): show which repeaters relayed/confirmed a message's path
Extends the existing single-boolean channel relay-echo marker into a
full count + list of distinct confirming repeaters, since each
repeater retransmit already appends its own identity hash to the
packet's path and the echo-matching hash deliberately ignores that
mutable path -- so every distinct repeater's echo of one send now
matches the same tracking slot instead of only the first.

Symmetrically captures the hop path a received DM/channel message
actually took, so a new "Path"/"Relayed by" row in the existing
Hold-Enter Options popup can show the resolved sequence of repeaters
(by contact name, or a hex fallback for an unknown one).

Also fixes a real bug caught during testing: the popup row's own
label ("Path (N hops)"/"Relayed by (N)") was built into a stack-local
buffer handed to PopupMenu, which only stores the pointer -- it
rendered as garbage once the building function returned. Moved to a
persistent member buffer.

Bumps the dev-build fallback version and adds release notes/docs
for this plus the two other 1.27 features already on this branch
(BLE retry backoff, marquee-scroll for selected long text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:04:06 +02:00
JakubandClaude Sonnet 5 389f3f7a36 feat(mesh): mirror app-originated DM/channel sends into on-device history
CMD_SEND_TXT_MSG and CMD_SEND_CHANNEL_TXT_MSG (the phone app's send path)
transmitted over the mesh but never touched the device's own MessagesScreen
history, unlike a message composed on-device (MessagesScreen::afterSend) --
so a DM/channel post sent from the app was invisible if that same
conversation was later opened on the device's own screen. Both handlers now
also call into the same history-store entry points incoming messages use.

Also wires up delivery-status parity with an on-device send, not just the
raw text:
- Channels: arms the existing "relayed into mesh" repeater-echo tracker
  (trackRelaySend()/armChannelRelay()) on the new entry -- sendGroupMessage
  already runs that tracker regardless of who originated the send, this
  just attaches it to the right history entry. Required threading a ring
  position back out through AbstractUITask::addChannelMsg (now returns int)
  and a new armChannelRelay() passthrough.
- DMs: addDMMsg gained ack_tag/ack_deadline_ms/resends params (threaded
  through MessageHistory -> MessagesScreen -> AbstractUITask/UITask) so an
  app-sent DM gets the same pending -> \xe2\x9c\x93/\xe2\x9c\x97 status the on-device compose
  path shows. resends stays 0 deliberately: the app owns its own retry
  decision, so this only drives the on-screen status, never a second,
  independent auto-resend from the device itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:35:13 +02:00
JakubandClaude Sonnet 5 1bb28296c2 feat(ui): marquee-scroll selected long names/labels instead of static ellipsis
Selecting a row whose ellipsized text overflows now animates a "swing"
marquee: holds at the start, scrolls to reveal the full tail, holds
there, then scrolls back and repeats. Unselected/non-overflowing text
is unchanged (still a static "..."). E-ink gets slower, coarser steps
(fewer, cheaper partial refreshes) than OLED; unchanged frames are
already skipped by the display's CRC diff, so idle holds are free.

Wired into every screen with a selectable row: home favourites, DM/
channel lists and message bodies, Settings, popup menus, Bot, Admin,
Nearby, Waypoints, Locator, Live Share, and the alarm screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:17:55 +02:00
JakubandClaude Sonnet 5 12cf81b13f fix(ble): back off BLE send retries instead of hammering a marginal link
At the edge of BLE range the phone repeatedly drops and reconnects, and
each queued outgoing frame's write can block the main loop up to 100ms
(Bluefruit's notify() waits on a free HVN packet slot). The fixed 250ms
retry throttle re-triggered that blocking wait constantly during a bad
link, making the device feel almost unresponsive. Retries now back off
exponentially (250ms -> 2s cap) on repeated failure and reset on the
next successful send.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:39:57 +02:00
JakubandClaude Sonnet 5 fe0093d07b merge: sync with upstream/main (companion-v1.17.1 + FAQ wipe/reset fix)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:31:49 +02:00
JakubandClaude Sonnet 5 7151af044a docs: add release-notes bullet for the 1.17.1 splash-screen version fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:30:08 +02:00
JakubandClaude Sonnet 5 5933ff3178 fix(version): splash screen showed 1.17, not the actual 1.17.1 upstream base
release-notes.md's v1.25 section already documents "Updated upstream
base to companion-v1.17.1", and that merge (68527e7b) is confirmed in
main's history -- but every MESHCORE_VERSION string, including
UITask.cpp's fallback default for boards that don't set it explicitly
(Heltec v3/v4, ThinkNode, Mesh Pocket, T-Echo), was still hardcoded to
the pre-bump "1.17". Bumped every occurrence to "1.17.1" to match what
actually shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:23:08 +02:00
JakubandClaude Sonnet 5 5323cd9e6b merge: 1.26 into main
Scope/repeater-scope filtering, magnetic Hall-sensor screen lock,
solo build configs restructured into their own solo/<board>/ folder
(one _solo_dual build per board), ProMicro + CardKB support (PR #31,
tchellow), new Build Flags reference doc, book-order fullscreen message
paging, and a confirmed "Start OTA" admin action -- plus a full
pre-merge documentation audit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:06:58 +02:00
JakubandClaude Sonnet 5 9e90758900 docs: fix pre-merge audit gaps (missing boards, Ext. KB row)
Full documentation audit before merging 1.26 into main turned up:
- README's Supported Devices table was missing GAT562 Mesh Watch13
  (a CI-built, shipping firmware with zero end-user docs -- pre-existing
  gap, not introduced by this branch) and ProMicro (added to the build
  table by an earlier commit but never to the device list up top).
  Building-from-source's env table was also missing Watch13.
- settings_screen.md's Keyboard table never documented the Ext. KB
  (Full/Compact) setting, despite external_keyboard.md already
  describing it in detail.
- nrf52_power_management.md's board-support table only listed 5 of the
  13 boards that actually have a PowerMgtConfig implementation wired up
  (grep-verified against every variants/*/*.cpp defining `power_config`)
  -- added the other 8, and fixed one board's listed name (RAK 3401,
  not "RAK WisMesh Tag") to match its actual getManufacturerName().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:02:22 +02:00
JakubandClaude Sonnet 5 7d09210b9c feat(admin): add confirmed "Start OTA" action for remote nodes
start ota was already sendable via Admin's Custom-command row (and
CLI-reachable directly), but had no dedicated menu entry. Adds a row to
the Actions tab that confirms first (Start/Cancel, defaulting to
Cancel) before sending -- unlike Reboot, OTA parks the remote in BLE
DFU mode for the duration of the update, disruptive enough to warrant
the extra step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:52:32 +02:00
JakubandClaude Sonnet 5 77f4ac48d6 docs: credit tchellow in README Contributors
PR #31 (ProMicro CardKB support) landed a few commits back but the
Contributors list was never updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:44:12 +02:00
JakubandClaude Sonnet 5 433548dfee docs: list ProMicro solo build, document CARDKB_I2C, update release notes
- README's env table and external_keyboard.md's device-support table both
  get a ProMicro row -- missed when the PR #31 merge landed since neither
  was in scope for that commit.
- external_keyboard.md and build_flags.md now cover CARDKB_I2C alongside
  ENV_PIN_SDA/ENV_PIN_SCL: the PR generalised CardKB bus selection to a
  single macro after build_flags.md was first written, so the doc only had
  the older (still valid, but no longer the only) form.
- release-notes.md: added the ProMicro solo build and the new Build Flags
  reference page to v1.26's What's New.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 10:24:44 +02:00
JakubandClaude Sonnet 5 f952bf237f chore(solo): move PR #31's new ProMicro solo env into solo/promicro/
Merged after the fact, so it followed the old convention (solo env inside
variants/promicro/platformio.ini alongside the shared board configs). Moved
to match every other board post-restructuring -- extends still points at
Promicro, defined in variants/promicro/platformio.ini.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 10:22:03 +02:00
Jakub ca1192c491 merge: PR #31 (CardKB via shared I2C bus for ProMicro) into 1.26 2026-08-29 10:14:15 +02:00
JakubandClaude Sonnet 5 b71b46fd84 fix(ui): debounce the Hall sensor poll against reed-switch contact bounce
pollHallSensor() acted on the raw pin reading immediately, unlike every other
physical-input path in this file (MomentaryButton, pollCardKB()'s own
last-raw edge check). A cheap mechanical reed switch -- one of the two
sensor types the docs explicitly recommend wiring here, alongside a
solid-state Hall IC -- can chatter for a few ms while the magnet crosses the
trigger distance, so a poll every loop() tick during that window could flip
_locked and fire _display->turnOff()/turnOn() repeatedly in that short span:
wasted work on any panel, and a real cost on e-ink where each is a slow
full-panel operation.

A raw reading now has to hold steady for HALL_DEBOUNCE_MS (25ms, same
threshold as MomentaryButton's ISR_DEBOUNCE_MS) before it replaces
_hall_magnet_present and triggers the lock/unlock actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:22:50 +02:00
JakubandClaude Sonnet 5 a389a733d7 refactor(solo): move solo build configs into their own solo/ folder; one build per board; document all build flags
Solo's `_solo_dual` env for each board lived inside variants/<board>/, mixed
in with the shared upstream board configs (repeater, room server, plain
companion) that every MeshCore fork carries. Split them out into their own
top-level solo/<board>/ folder -- each still `extends` the board base defined
in its original variants/<board>/platformio.ini, just no longer interleaved
with it file-wise.

- platformio.ini: extra_configs picks up solo/*/platformio.ini alongside
  variants/*/platformio.ini.
- build-solo-firmwares.yml: env-discovery grep now scans solo/ instead of
  variants/ (the release matrix is unchanged -- same 8 envs, same names).
- pr-build-check.yml: solo/** added to the trigger paths.
- GAT562 30S Mesh Kit: dropped the separate solo_ble env -- solo_dual is a
  strict superset (BLE still works, plus USB), and every other board only
  ever had one solo build to begin with.
- GAT562 Mesh Watch13: renamed solo_ble -> solo_dual (added DUAL_SERIAL=1) to
  match. No comment anywhere recorded whether this board's USB data lines
  are actually broken out to a connector -- DUAL_SERIAL compiles and works
  over BLE regardless, so worst case the USB half goes unused.
- Removed a dead AUTO_SHUTDOWN_MILLIVOLTS from the three solo envs that set
  it (Heltec V3/V4, T-Echo Lite+KeyShield): that macro is only ever read in
  the old ui-tiny UITask, never ui-new, which every solo build (these
  included) uses -- it did nothing on any of them. Left alone everywhere
  else it's set (non-solo companion envs elsewhere use ui-tiny/ui-orig, or
  the flag is legitimately read); out of scope here.
- Added docs/solo_features/build_flags.md: every optional -D flag a solo
  build understands (GPIO, CardKB/joystick, Hall-sensor cover lock, buzzer/
  vibration, GPS switch, display/battery tuning), verified against the code
  rather than the existing per-board comments, with what's already baked
  into every solo build kept separate from what's opt-in.
- README: doc index + Building from source section link to the new page;
  path references to the moved solo files updated to solo/<board>/.

All 8 solo_dual envs rebuilt clean after the move; native test suite (40
cases) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:49:37 +02:00
JakubandClaude Sonnet 5 58e6bfcec5 feat(ui): optional magnetic flip-cover screen lock via user-wired Hall sensor
No board in this repo has one built in, and no default pin is assumed
anywhere -- whoever wires a Hall-effect or reed sensor to a free GPIO sets
PIN_HALL_SENSOR (and HALL_ACTIVE_HIGH, for a sensor that pulls the pin high
rather than low on presence) as a build_flag on their own env. Entirely
opt-in and a no-op elsewhere, same pattern as PIN_GPIO1..4/ADC_MULTIPLIER/
CARDKB_ENABLE.

Level-triggered polling (like pollCardKB()) rather than an edge interrupt --
a magnet held near the sensor reads the same way every tick, so the new
pollHallSensor() only acts on the two transitions. Closing locks and blanks
the display with no wake grace (the cover is physically over the screen, so
there's nothing to show); opening unlocks and wakes it, with no key combo
either way. Both are independent of the Auto-lock setting, which is a
timeout, not a physical event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:48:33 +02:00
JakubandClaude Opus 5 13fb4a189f feat(ui): page the fullscreen message view in reading order
LEFT went to the newer message and RIGHT to the older one, which reads
backwards against the page metaphor the "<" / ">" markers set up. Swap it:
LEFT turns back to the older message, RIGHT forward to the newer one, and
the markers follow (they were keyed to the opposite flags).

PREV/NEXT are named in message order, not screen order -- MessagesScreen's
_hist_sel counts newest-first, so PREV is the older message -- so only the
key mapping and the two marker conditions change; the caller side is
untouched. Applies to both the DM and channel fullscreen views, which share
handleInput(). AdminScreen's reply view treats every non-NONE result as
"close", so it is unaffected.

Docs and release notes updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:13:41 +02:00
JakubandClaude Opus 5 51c06b78c1 fix(companion): prefs schema corruption, broken multi-scope, stale relay filter, cursor row
Follow-up review of f589b9b2 -- five defects in that commit's own changes.

- repeat_scope_only + repeat_extra_scopes were read/written in the MIDDLE of
  the prefs stream, beside their repeat_* siblings. loadPrefsInt()'s rd() is a
  plain sequential reader gated only on file.available(), with no per-field
  versioning, so on any pre-existing file those 25 bytes were taken from the
  fields that follow, shifting EVERY later field: repeater profile (incl. a
  float freq), track_shared_loc, all of loc_share_*, trail, bot, GPIO modes.
  Moved to the struct/file tail, sentinel bumped to 0xC0DE0027 with 0xC0DE0026
  marked burned. sizeof stays 2752 (confirmed by build); the tripwire procedure
  now spells out the append-only rule that "in struct order" left implicit.
- rebuildRepeatScopes() called getAutoKeyFor() with id 0 for every entry, but
  that cache is keyed on the id alone and ignores the name on a hit -- so every
  extra scope after the first silently got the first one's key, making the
  comma-separated list do nothing. Distinct id per scope now.
- interference_threshold had no load clamp, so an upgrader read 0x23 (35) out
  of the old file's sentinel tail instead of 0.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote default_scope_key without rebuilding the
  relay filter, so setting or clearing the scope from the app left the repeater
  filtering on the previous key until reboot. The on-device path already did.
- The keyboard preview derived the cursor's row a second time from byte
  offsets, disagreeing with the cursor_line the scroll window already computes:
  it pinned the cursor to the end of a full line (drawing '_' one character
  past the display width) at every wrap boundary. Use cursor_line directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:02:05 +02:00
JakubandClaude Sonnet 5 f589b9b2d1 feat(companion): on-device scope + repeater scope filtering; fix CAD, UTF-8 truncation, Public channel, Nodes list, keyboard cursor
- Settings > Radio > Scope: type a community/region name on-device (derives
  the shared key the same "#name" -> SHA256 way as DEFAULT_FLOOD_SCOPE_NAME),
  previously only settable from a connected app.
- Tools > Repeater > Scope only + Extra scopes: only relay flood traffic
  matching the device's own scope or a comma-separated list of additional
  scopes, without changing what scope the device's own messages send under.
  No-op while unconfigured.
- getCADEnabled()/getInterferenceThreshold() were hardcoded off on
  companion_radio; CAD now auto-enables whenever RX power-save (duty-cycle)
  is active, since the noise floor isn't kept fresh during duty-cycle sleep.
- Message truncation to fit the send frame could split a multi-byte UTF-8
  character in half; now stops at the last complete character.
- The default "Public" channel was unconditionally re-added at every boot
  before the saved channel list was loaded, so deleting it never stuck.
  Only seeded now on a genuinely fresh device (no channel file yet).
- Tools > Nodes read contacts from the wrong starting offset, landing on
  internally-reserved bookkeeping slots instead of real contacts -- showed
  as blank "Unknown" rows and silently dropped that many real contacts off
  the end of the list.
- resetContacts() only cleared the first few reserved slots, not the whole
  contact table, contrary to its own comment; only reachable today via
  private-key import, fixed to match stated intent regardless.
- Keyboard's multi-line text preview could render the cursor on an empty
  line below short typed text instead of right after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:40:50 +02:00
JakubandClaude Sonnet 5 76ea401dd5 Merge branch 'merge-upstream-v1.17' into power-saving
Brings in upstream companion-v1.17.0 (CAD, MCU temp telemetry, misc
fixes) alongside our RX duty-cycle power-save; see merge-upstream-v1.17
history for the full conflict-resolution rationale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:25:50 +02:00
JakubandClaude Sonnet 5 7699d8ffad Merge upstream companion-v1.17.0 (CAD) into power-saving
Adopts hardware Channel Activity Detection (wired into
RadioLibWrapper::isChannelActive() alongside our RSSI-threshold check
and RX duty-cycle power-save), MCU temperature telemetry, LR2021
standby workaround, DISPLAY_SCALE/FLIP overrides, NRF52Board
shutdownPeripherals() refactor, and misc upstream fixes.

Declines upstream's ConfigSerializer-based NodePrefs rewrite,
MultiSerialInterface/interface_manager, and UIColor palette system —
each would have broken large parts of the Solo-specific feature set
(NodePrefs fields, per-variant single serial_interface, enum-based
DisplayDriver::Color). Flagged as candidate follow-up migrations, not
permanent no's.

Also fixes several pre-existing bugs surfaced while chasing silent
merge breaks (stale newMsg() override signature in ui-tiny/ui-orig,
dead UIEventType::newContactMessage case, missing ContactsIterator
init), bumps FIRMWARE_VERSION/MESHCORE_VERSION to 1.17, and fixes a
missing <cstdlib> include that broke the native ConfigSerializer unit
tests.

Verified via 13+ pio run builds across ESP32/nRF52, all 3 companion UI
variants, and 7 display drivers, plus the full native unit test suite
(33/33 passing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:25:35 +02:00
JakubandClaude Opus 5 05e57357d3 feat(boards): add M5Stack Cardputer ADV and LilyGO T-Echo Lite KeyShield
New M5Stack Cardputer ADV variant (ESP32-S3, ST7789 TFT, built-in TCA8418
QWERTY keyboard, PI4IOE5V6408 LoRa-cap IO-expander autodetect), and a
KeyShield accessory variant for the existing LilyGO T-Echo Lite (external
TCA8418 T9 keypad + AW21009 backlight driver). Both keyboards share one
ENV_USE_TCA8418 polling block in UITask.cpp::loop(), coexisting with the
unrelated CardKB support (different chip/address/flag).

Fixes carried in from the contributed T-Echo Lite code: swapped GPS RX/TX
pins, TX-LED hooks, TCXO voltage, missing GxEPD2_122_T61 panel include.
Fixed during integration: I2C bus was probed for an RTC before Wire.begin()
configured its pins on Cardputer ADV (silent RTC autodetect failure).

Added dedicated *_solo_dual release envs for both boards (auto-picked up by
the solo-firmware release workflow). Gave the T-Echo Lite KeyShield solo
build -Os/-Ofast-unflag like every other nRF52 solo build (was missing,
cut flash usage from 90.7% to 61.4%).

Ported the shared misc-fixed 6x9 font (full Latin/Greek/Cyrillic, opt-in via
OLED_MISC_FIXED_FONT) to ST7789Display for the Cardputer's on-screen
keyboard. ST7789Spi isn't Adafruit_GFX-based like the other single-font
drivers, and this panel's logical->physical scale is non-integer, so glyphs
are re-packed to XBM and blitted through the existing drawXbm(), which
already does correct fractional-scale boundary math, rather than
duplicating that logic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:06:24 +02:00
JakubandClaude Opus 5 6fd1c4bae2 docs: document GPS duty-cycling and RXPS watchdog
Settings screen doc gets the new "GPS pwr" row and a watchdog note on
"Pwr save"; Diagnostics doc gets the new "RXPS wd s/h" row and updates
what "Reset counters" actually clears; release-notes.md gets Unreleased
entries for all three additions from the prior commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:14:36 +02:00
JakubandClaude Opus 5 73744175b0 feat(power): RX duty-cycle watchdog, noise-floor recal, GPS duty-cycling
Three power-saving additions, prompted by comparing this fork's existing
RX duty-cycle support against IoTThinks/EasySkyMesh:

- RX duty-cycle watchdog: the SX126x's hardware RX<->sleep sequencer runs
  with no MCU polling, so a desync (a known failure mode) previously had
  nothing watching for it. A new watchdog samples the BUSY pin every tick;
  no transition for too long triggers a soft re-arm, then a full chip
  reset (with cached radio params reapplied, since std_init() resets to
  compiled firmware defaults) if that doesn't clear it. Soft/hard recovery
  counts surface on Tools > Diagnostics > Live as "RXPS wd s/h".

- Noise-floor recalibration during power-save: sampling was previously
  skipped entirely while duty-cycling, freezing int.thresh interference
  detection at whatever the floor was when power-save turned on. Now
  borrows a brief continuous-RX window once a minute to take a fresh
  reading before re-arming duty-cycle.

- GPS duty-cycling (Settings > System > "GPS pwr"): cycles GPS off between
  fixes instead of running it continuously. Each wake waits for a fix
  (capped at 60s) before sleeping again for the configured interval.
  Repurposes the long-dead NodePrefs::gps_interval byte rather than adding
  a new persisted field. A "is anything live using GPS right now" hold in
  UITask keeps GPS continuously on whenever trail recording, live-share,
  an armed Locator, or the Compass/Nearby-navigate view actually need a
  live fix, so none of those features degrade. Locator crossing-state is
  reset on each wake so a still-settling first fix can't read as a false
  geofence crossing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:14:19 +02:00
JakubandClaude Opus 5 fe02fda897 docs: restructure README, document external input, tidy docs tree
README had drifted from the firmware in several places:

- Supported Devices listed only the three nRF52 boards; Heltec V3/V4 were
  missing entirely, as was any mention that ESP32-S3 flashes differently.
  Flashing is now split per MCU, with the merged-vs-app-only .bin trap
  spelled out — that one costs an afternoon to diagnose from a dark screen.
- It advertised a Lemon/Default font switch that was retired in v1.23; there
  is one unified misc-fixed 6x9 font now and no font setting at all.
- tools/README claimed ENABLE_SCREENSHOT had to be added by hand, directly
  contradicting README's "no special build flags required". The envs have
  carried the flag for a while; rewrote the file to cover all four tools.
- "S key for screenshot" described screenshot.py's menu, not the device —
  screenshots are driven entirely from the host via CMD_GET_SCREENSHOT.

Structurally, general notes (factory reset on migration, BLE-over-USB
priority) sat at the tail of the ESP32 subsection and read as ESP32-specific;
they're now placed where they apply. Heltec wiring moved out of Supported
Devices into its own section so the device table stays scannable, and the
Solo Tools heading is no longer a link (it was generating a garbage anchor).

New docs/solo_features/external_keyboard.md covers CardKB and the wired
joystick: shortcut table, Full vs Compact, and the pin assignment. The pins
are marked as verified on real V4 hardware only — V3 inherits them from
Heltec's documented pin-compatibility and hasn't been checked on a board.

FEATURES.md (roadmap + code audit, developer-only) moves to
docs/development/roadmap.md; nothing referenced it by path.

Adds Building from source / Releasing / Repository layout, since the release
flow was only discoverable by reading the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:21:39 +02:00
JakubandClaude Opus 5 6d667d8bca fix(release): publish ESP32 solo binaries, add screenshot to Heltec envs
The solo release job only attached *.uf2 and *.zip, which covered every solo
board back when they were all nRF52. The Heltec V3/V4 .bin images were built
and then silently dropped, so tagging a release produced nothing downloadable
for those two boards.

Releases now carry a single -merged.bin per ESP32 board (bootloader +
partition table + app at 0x0). build.sh also writes an app-only .bin, but
that one needs offset 0x10000 and a bootloader already on the chip, so it's
deleted before upload rather than shipped next to the merged image — flashing
it at 0x0 looks exactly like a bricked device.

Also adds -D ENABLE_SCREENSHOT to both Heltec solo envs. Every other solo env
already had it, so Solo Tools couldn't capture these two boards' displays and
the README's "no special build flags required" was untrue for them. Both envs
rebuilt clean (V3: RAM 58.8%, Flash 43.2%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:21:12 +02:00
JakubandClaude Sonnet 5 60811a4f5d fix(heltec_v3): carry V4's confirmed CardKB/joystick pins over to V3
V3 and V4 are pin-compatible per Heltec's docs, and V3's own PIN_GPS_RX/TX/EN
defines are dead code here (ENV_INCLUDE_GPS is never set to 1 in this file),
so there's no reason to keep the blind-guess pins from before. Reuses the
exact CardKB (SDA 3/SCL 4) and joystick (UP 23/DOWN 6/LEFT 47/RIGHT 48/BACK 33)
assignment already confirmed working on real V4 hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 20:12:32 +02:00
JakubandClaude Sonnet 5 2bde84a573 feat(heltec): solo dual-transport builds for Heltec V3/V4
Port the Wio Tracker L1 solo firmware (full on-device UI, dual BLE/USB
companion transport) to Heltec V3 and V4 OLED boards. Neither board has
a joystick or CardKB on-board, so each new env wires up both as optional
peripherals with default pins from what the board leaves free, gated
behind the existing UI_HAS_JOYSTICK/ENV_PIN_SDA+SCL flags.

DUAL_SERIAL was nRF52-only; added an ESP32 helpers/esp32/DualSerialInterface.h
counterpart so the flag isn't silently ignored on these boards. On V4's
native USB CDC, isClientConnected() also honours (bool)Serial (real DTR),
same as the nRF52 version; V3 has no native CDC so it stays BLE-only there.

Screen (SDA 17/SCL 18) and CardKB (SDA 3/SCL 4) confirmed working on real
V4 hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 20:08:31 +02:00
JakubandClaude Opus 5 f8b9e1acb8 feat(oled): misc-fixed 6x9 font on SSD1306 too, for GAT562 30S solo
The misc-fixed font (full Latin/Greek/Cyrillic) had only ever reached
SH1106Display and the e-ink driver. GAT562 30S -- like 24 other variants
-- uses SSD1306Display, which stayed on the built-in 5x7 font, so every
keyboard alphabet beyond ASCII and every accented contact name was
transliterated (Łódź -> Lodz) or drawn as a filled block.

Extract the renderer into MiscFixedRenderer.h rather than copying it:
Adafruit_SH110X and Adafruit_SSD1306 both derive from Adafruit_GFX and
glyph drawing is pure pixel plotting, so one implementation serves both
drivers (-79 lines from SH1106Display.cpp, same flash usage as before).
The header is .cpp-only by contract -- the font tables are static const,
so including it from a driver header would land a copy in every
translation unit.

On SSD1306 the path is opt-in behind OLED_MISC_FIXED_FONT, set only in
the two GAT562 30S solo envs: the font costs ~14 KB of flash and the
other 24 variants' repeater/companion builds have no keyboard to type
those alphabets on. Without the flag that driver is unchanged, verified
by building GAT562_30S_Mesh_Kit_repeater.

Drive-by consistency fix from the extraction: a newline in print() now
advances the cursor by yAdvance * text_size instead of a single yAdvance,
which was half a row short at setTextSize(2). The e-ink driver always
scaled it. No effect at size 1, which is everywhere the UI prints '\n'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:48:06 +02:00
JakubandClaude Opus 5 de16dbe32a fix(eink): drive the whole panel on every update, not just the changes
The screen faded as the device was used: text went grey a few updates
after it was drawn, while whatever had just changed stayed crisp. Only a
full refresh brought it back, and the next few updates ate it again.

A partial update is differential -- the controller drives only the pixels
that differ between its "current" (0x24) and "previous" (0x26) RAM and
leaves the rest to hold their own charge, which this panel doesn't do
well. Priming the previous-image RAM with the inverse of the incoming
frame makes every pixel a difference, so all of them get driven to their
target, in both directions.

The inverse matters: priming with flat white makes only white->black a
difference, so ink is re-driven but never erased and every screen ever
shown accumulates as a ghost. Confirmed on hardware, both ways round.

Costs one extra full-screen RAM write (a few ms of SPI). The refresh
itself is unchanged -- the waveform clocks the whole panel regardless of
how many pixels it drives -- so nothing got slower. Clearing ghosts is
still the periodic full refresh's job (Settings > Full rfsh), which can
now stay off.

The helper needs GxEPD2_BW's private _buffer/_page_height, so it goes in
the patched copy of the header, which every e-ink build now includes
unconditionally rather than only screenshot-enabled ones. All e-ink
variants pin GxEPD2 1.6.2, the version that copy tracks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:02:28 +02:00
JakubandClaude Opus 5 b36cc7730b fix(ui): honour newlines in message text instead of overdrawing lines
A message containing a line break drew two words on top of each other in
the fullscreen reader. wrapLines() treated '\n' as an ordinary character:
it measured it via getCodepointWidth() -- which reports a full 6px cell
for it, since 0x0A sits below the font's first glyph -- and copied it into
the wrapped line. Both display drivers' print() then acts on '\n' by
resetting the cursor to x=0 and stepping down one row, so the tail of that
line was drawn straight over the following one.

wrapLines() now ends the line at '\n'/'\r' (CRLF counts as one break) and
consumes the byte rather than emitting it, preserving blank lines the
sender typed while still skipping degenerate empty wrap segments so the
loop can't stall. This covers the fullscreen view and the history list's
portrait bubbles, which share the function.

drawTextEllipsized() folds newlines into spaces for the same reason: it
draws one line clipped to max_width, and the compact one-line message
previews in the landscape list feed it raw message bodies. A space keeps
the words apart and measures the same, so the ellipsis maths is unchanged;
for names and labels it's a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:54:34 +02:00
JakubandClaude Opus 5 07c80cd548 fix(keyboard): count preview line breaks in codepoints, mark caps-lock
The text preview was the last part of the keyboard still working in bytes
rather than codepoints. cpl is how many characters physically fit on a
line, so dividing byte offsets by it counted every 2-byte Cyrillic/Greek/
accented character as two: lines held half the text they had room for, and
a break could land inside a codepoint. Both display drivers are
permanently single-font, so translateUTF8ToBlocks() passes UTF-8 straight
through -- the truncated sequence reached print() and drew as garbage on
both sides of the break. Line boundaries now walk the buffer with the same
kbUtf8*() helpers insertion/backspace/T9 already use, and the per-line
buffers are sized for a full line of 2-byte characters.

Caps-lock also gets an underline on the shift key: it sets caps too, so
the highlight alone made a one-shot Shift and a held lock indistinguishable
despite capitalising one letter vs. every following one.

Drops UITask::applyFont() -- setSingleFont() is a no-op on both drivers
since they were pinned to misc-fixed, so it did nothing, and use_lemon_font
has had no Settings row for a while. The pref itself stays: it's part of
the on-disk layout. Retires the matching stale rationale on scriptHint().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:24:42 +02:00
JakubandClaude Opus 5 e558ea0b86 fix(keyboard): joystick-free Compact mode for external keyboards
Compact mode (Settings > Keyboard's "Ext. KB" row) is meant to guarantee
operation with no joystick at all, but it was still half-tied to the
on-screen grid it hides:

- arrows now move the text cursor directly instead of a grid selection
  nobody can see, and Tab opens the placeholder picker directly instead of
  the row/col-dependent Hold-Enter dispatch
- plain Enter submits the field (there's no grid cell to have deliberately
  landed on), same as Fn+Enter
- Fn+letter's accent popup no longer gates on the grid's script/T9
  settings -- CardKB always types plain Latin regardless of them, so the
  gate only made the gesture silently stop working
- the whole status line is gone: nothing it showed (script, T9-vs-ABC,
  caps) is actionable from an external keyboard. The freed height goes to
  message-preview lines, floored at the smallest grid's footprint so
  cursor mode's own hint block still fits
- the accent popup gets a fixed slot instead of anchoring on a `row` that
  is never deliberately navigated to in this mode

Also fixes a text-corrupting invariant break: moveCursorDirect() and
openPlaceholders() move the cursor without finalizing a pending T9
multi-tap cycle, so a later tap on the same cell within the timeout
overwrote an unrelated character. Every other cursor-moving path already
cleared it.

Fn+Tab is dropped as a separate shortcut -- plain Tab already covered
every case it did. Fn+Enter no longer reads as a dead key in cursor mode.

Direct typing moves into insertTyped(), one translation point documenting
what a future relabelled-keycap layout (Cyrillic/Greek) would need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:14:57 +02:00
JakubandClaude Sonnet 5 8e5f083e2e fix(ui,bot): resolve remaining findings from the 2026-07-26 screen review
- DM/room unread badges could claim messages the ring no longer held
  (same class as the channel fix in 6470afaf, not covered by it):
  getDMUnread()/getDMUnreadTotal() now clamp to dmHistCountForContact(),
  and a new reconcileDMUnread() (called once per loop()) frees any
  _dm_unread_table slot whose ring occupancy has dropped to 0, so a
  17th sender isn't starved by stale entries. onContactRemoved() now
  also clears _dm_unread_table -- the one per-contact table it was
  missing.
- Shift didn't capitalise ł/ń/ź/ż (+ĺ/ľ/ň/ž): the Latin Extended-A
  case-pairing rule assumed a single parity for the whole block, but it
  flips around the unpaired codepoints ĸ/ʼn/Ÿ. Fixed with four
  sub-ranges, verified exhaustively over U+0100-U+017F.
- Triple-click could still toggle the buzzer while locked on
  PIN_USER_BTN/PIN_USER_BTN_ANA boards (joystick path already guarded
  this).
- millis() wraparound: 4 absolute comparisons in UITask.cpp (battery
  poll, auto-off, lock-wake, backlight) converted to the existing
  (int32_t)(millis()-deadline)>=0 idiom; MyMeshBot.h's DM-throttle
  eviction now picks the oldest slot by elapsed time instead of raw
  t_ms, which picked the wrong slot right after a rollover.
- Long-press bypassed checkDisplayOn() on all 5 call sites -- neither
  woke the display nor extended auto-off, and could deliver
  KEY_CONTEXT_MENU to the invisible screen. Moved the gate inside
  handleLongPress() itself instead of patching each site.
- CardKB's backspace/printable-insert branches didn't reset t9_cell,
  so typing right after a T9 cycle tap could get silently overwritten
  by a same-cell re-tap within the T9 timeout.
- buildContactList()'s counts[MAX_CONTACTS] was a 1400 B int array on
  the 4 KB loop() stack; values are bounded by DM_HIST_MAX (32), so
  now uint8_t.
- ACK table treated ack==0 as a wildcard: isAckPending(0) matched any
  free slot, and processAck() with an all-zero ACK matched the first
  free slot and returned its stale contact pointer. Both now skip/reject
  ack==0, and the matched slot's contact pointer is cleared alongside
  its ack hash.
- ensurePageOrderInit() could write one byte past page_order[13] when
  migrating a saved order with all 13 slots full and CLOCK last --
  guarded on insert_at < PAGE_ORDER_LEN.

Two findings from the same review were resolved as no-op decisions,
not code changes: !buzz over DM ignoring quiet hours is intentional
(the pull exemption is meant to cover the buzzer), and the offline
queue's full-queue drop-newest behaviour is upstream code, left alone.

Build-verified green on WioTrackerL1_companion_solo_dual (RAM 71.1%,
Flash 66.6%) and WioTrackerL1Eink_companion_solo_dual (RAM 73.0%,
Flash 67.9%).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:05:52 +02:00
JakubandClaude Opus 5 6470afaf56 fix(ui): cap channel unread badge to what the message ring still holds
The per-channel unread counter was independent of the ring's actual
contents, so the two drifted apart:

- Opening a channel whose entries had been evicted left the badge
  claiming messages the list could no longer show. The viewing-session
  bookkeeping computes the count from an _unread_at_entry snapshot, and
  with an empty list _hist_visible is 0, so entering only knocked the
  count down by one instead of clearing it (badge "7", empty list, then
  "6").
- Eviction from a full ring decremented the counter for any dropped
  entry, including already-read ones, undercounting the newer unread
  messages the counter actually refers to.

chUnread()/getTotalChannelUnread() now clamp to the channel's ring
occupancy, so the badge can never promise more than the history holds
whatever the raw counter says, and eviction only decrements when the
entry being dropped was itself unread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:05:47 +02:00
Jakub 096e4580b5 Merge branch 'bot-actions-gpio'
# Conflicts:
#	release-notes.md
2026-07-25 18:46:34 +02:00
JakubandClaude Opus 4.8 5844fa2390 refactor(bot,ui): dedupe bot token parsing, drop redundant turnOn, fix comment
Post-review cleanups, no behaviour change:
- botScanCommands() parsed the command name and its two args with three
  near-identical read-token loops; extracted a single readToken() lambda.
- Fn+Esc lock branch turned the display on twice (the unlock arm repeated
  what the branch head already did); dropped the redundant call.
- setGpioMode()'s comment said "Cycle" (cycling lives in GpioScreen); now
  describes what it actually does — set a specific mode + persist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:00:07 +02:00
JakubandClaude Sonnet 5 0a61b7ef15 fix(bot,ui): defer bot actions past throttle, fix CardKB lock bypass, add !gps fix timeout arg
- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect
  immediately during botScanCommands(), before quiet-hours/cooldown/
  per-contact throttle were checked -- those gates only suppressed the
  reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply()
  now only records what was requested; applyPendingBotActions() runs the
  deferred effects once a wrapper's throttle checks pass and the ack sent,
  mirroring the existing _locfix_requested pattern. resetPendingBotActions()
  clears everything on every throttled/aborted path.

- CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate
  (it called into KeyboardWidget directly instead of through the
  enqueueKey()/dequeue path every other key uses, so it wasn't discarded
  while _locked). Now checks _locked itself.

- Since a locked device now correctly ignores CardKB entirely, Fn+Esc
  (single press) is added as CardKB's own lock/unlock gesture -- otherwise
  a CardKB-only setup had no way to unlock. Esc rather than the adjacent
  Fn+Backspace, to avoid an accidental press.

- botScanCommands() now parses up to two arguments per command instead of
  one. Used by "!gps fix [seconds]" to override the default 90s timeout
  (clamped 15-300s) for a poor sky view where 90s isn't always enough to
  reach isLocFixReady()'s HDOP/satellite bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 23:51:31 +02:00
JakubandClaude Sonnet 5 7b6ae8e2cb feat(gps): use HDOP for !gps fix readiness, satellite count as fallback
Satellite count alone is a poor proxy for fix quality -- few satellites
in good geometry can beat many in poor geometry. LocationProvider now
exposes getHDOP() (default -1 = unsupported); MicroNMEA implements it.
isLocFixReady() prefers HDOP <= 2.0 when available, falling back to the
old >=8 satellite threshold for providers that don't report it (e.g.
RAK12500/u-blox).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:49:17 +02:00
JakubandClaude Sonnet 5 5e3c958114 docs: label the Admin login fixes as v1.23.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:35:19 +02:00
JakubandClaude Sonnet 5 2c656f5af7 fix(ui): a stale login reply for a different node could be accepted
Same fix as hotfix/admin-login-timeout (96b44460). AdminScreen's only
guard was "_phase == LOGIN" (true for any node sat at the login
screen), not that the reply actually named _target. Now also checks
pub_key against _target.id.pub_key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:27:02 +02:00
JakubandClaude Sonnet 5 96b444601f fix(ui): a stale login reply for a different node could be accepted
AdminScreen::onRoomLoginResult()'s only guard was "_phase == LOGIN" --
true for *any* node currently sat at the login screen, not specifically
_target. Combined with UITask::onRoomLoginResult()'s current-screen
dispatch (not requester-based), a slow reply for an earlier login
attempt (this screen's own previous target, or even MessagesScreen's)
arriving while the user has since opened Admin on a different,
password-less node -- still parked at the blank LOGIN keyboard, so
_phase == LOGIN here too -- was accepted as that new node's own login
result, flipping _admin_ok/_phase to COMMAND without ever actually
authenticating with it.

Root-caused by cancelUiPendingLogin() (previous commit): that fix
covers the "gave up, then it resolved late" path, but not "a reply for
a genuinely different pubkey arrives while merely _phase == LOGIN".
Checking pub_key against _target.id.pub_key closes that regardless of
which path let the reply through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:24:59 +02:00
JakubandClaude Sonnet 5 9932fb01df fix(ui): late Admin login reply could be misrouted after giving up
Same fix as hotfix/admin-login-timeout (5a5ebe9f). UITask::onRoomLoginResult()
dispatches by whichever screen is currently shown, not by who sent the
request, so a reply arriving after AdminScreen gave up (Cancel or the
timeout fix) could land on MessagesScreen instead and persist its own
unrelated _login_pw as the "confirmed" password for that pubkey.
MyMesh::cancelUiPendingLogin(pub_key) stops tracking the request on
give-up so a late reply matches nothing instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:21:31 +02:00
JakubandClaude Sonnet 5 5a5ebe9ff1 fix(ui): late Admin login reply could be misrouted after giving up
UITask::onRoomLoginResult() dispatches a login reply to whichever
screen is *currently* shown (curr == admin_screen ? AdminScreen :
MessagesScreen), not to whoever actually sent the request. Neither
giving up path (manual Cancel, or the timeout added in 23f43cac) told
MyMesh to stop tracking the request, so a reply that still arrived
after the user had navigated away landed on whatever screen they'd
moved to instead -- most likely MessagesScreen, which then persisted
its own unrelated _login_pw as the "confirmed" password for that
pubkey, silently corrupting the saved password even on a genuine
success.

Adds MyMesh::cancelUiPendingLogin(pub_key), pubkey-guarded so it's a
no-op if a newer request has since overwritten ui_pending_login, called
from both of AdminScreen's give-up paths. A late reply now simply
matches nothing and is dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:20:08 +02:00
JakubandClaude Sonnet 5 0ab74bdd41 fix(ui): Admin password change didn't update the saved login copy
Same fix as hotfix/admin-login-timeout (05609019). Tools > Admin >
System > "Admin password" changes the remote's admin credential but
never updated this device's saved copy, so the next login retried the
password just replaced -- likely the actual trigger behind the
"stuck on Logging in..." report. Parses CommonCLI's "password now: <v>"
success echo and saves that as the new on-device password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:13:33 +02:00
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 220d46de8e feat(bot): !gps fix -- single-shot GPS location
Turns GPS on (if it wasn't already), waits for a stabilised fix
(isValid() + >=8 satellites, then averages 10s of readings), sends the
position, and restores GPS to whatever state it was in before -- up to
a 90s timeout, after which it reports a partial fix (if it got any
samples) or plain failure.

Replies in two parts since a fix takes seconds-to-minutes, unlike every
other bot command here: an immediate "acquiring fix..." ack (through
the existing synchronous command path), then the actual position as a
separate follow-up message once ready, delivered to whichever
destination (DM/room/channel) the request came from. Only one fix can
be in flight at a time -- a second request while one is pending gets an
immediate "already pending" instead of silently replacing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:57:05 +02:00
JakubandClaude Sonnet 5 176094d32e 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.

Same fix as hotfix/admin-login-timeout (23f43cac), split out of this
branch's other in-progress work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:56:46 +02:00
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 a0b1c80c85 feat(ui): compact on-screen keyboard toggle for external keyboards
Add Settings > Keyboard "Ext. KB" row (boards with a CardKB-capable I2C
bus only): switching it to Compact hides the letter grid and special-row
icons in favour of a one-line status (script/page, caps) plus a Fn-shortcut
reminder, since an external-keyboard typist never looks at the on-screen
grid. Accent/placeholder popups still render as before. Off by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:51:51 +02:00
JakubandClaude Sonnet 5 ad4668242b fix(ui): CardKB key handling - debounce, Fn modifier for submit/accents
CardKB is level-triggered (repeats the held byte every poll) and its Enter
key collided with the on-screen keyboard grid's own commit action, causing
duplicate characters and accidental message sends. Debounce polling and use
the CardKB v1.1 Fn modifier (confirmed working on real hardware) instead of
tracking navigation state: plain Enter now behaves like the physical centre
button, Fn+Enter submits, Fn+Tab opens the Hold-Enter equivalent, and
Fn+<letter> opens that letter's accent popup directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 17:49:04 +02:00
JakubandClaude Sonnet 5 7cae6470bf feat(ui): CardKB (I2C keyboard) support with full keyboard-only navigation
Auto-detected at boot on Wire1/Grove (addr 0x5F) -- no setting to flip, and a
no-op on boards without that bus or with nothing attached. This UI's key
codes (KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL) already match CardKB's own byte
protocol, so most input needs zero translation and flows through the same
key queue as physical buttons.

Two bytes get remapped in UITask::pollCardKB():
- Enter, only when the on-screen keyboard's plain grid state is active (no
  placeholder/accent popup, not in cursor-mode), becomes a new KEY_KB_ENTER
  sentinel meaning "submit the field" -- reusing plain KEY_ENTER there would
  insert a stray character, since a CardKB typist's row/col never reflect an
  intentional grid selection. Everywhere else Enter is untouched, so
  selecting a placeholder or committing an accent still works normally.
- Tab (otherwise unused) becomes KEY_CONTEXT_MENU, standing in for the
  "Hold-Enter" long-press gesture CardKB has no way to produce -- without it,
  ~30 context menus across the UI (message reply/navigate, Bot/Admin/
  Repeater, ...) would be unreachable from the keyboard alone.

KeyboardWidget gains a direct-typing path: printable ASCII inserts straight
at the cursor bypassing the grid, Backspace deletes, KEY_KB_ENTER submits.

Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean; also
smoke-tested Heltec_mesh_solar_companion_radio_ble (no ENV_PIN_SDA/SCL) to
confirm zero regression on boards without the feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 23:43:33 +02:00
JakubandClaude Sonnet 5 a57d5d67bb rename(ui): Auto-Reply Bot -> Remote Bot
The bot outgrew "auto-reply" once it gained Actions (!buzz/!gps/!advert/
!gpio1-4) that control device hardware remotely, not just answer messages.
Renames the Tools screen entry and all doc cross-references; already-shipped
release notes (v1.23) are left as-is to match what actually shipped under
that name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 23:15:41 +02:00
JakubandClaude Sonnet 5 5bfebc6559 feat(bot): Actions commands, multi-trigger, and user GPIO pins
- Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target
  toggle nested under Commands (bot_actions_dm/ch/room); off by default.
- Bot Trigger fields accept comma-separated multiple phrases, matching any
  one fires the reply.
- New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot
  commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output;
  GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only
  Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the
  chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR
  NFCPINS bit-clear in initVariant() (adapted from Adafruit's own
  nfc_to_gpio example) -- confirmed working on real hardware.
- Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong
  layer, hiding the "To:" header on DM replies and leaking the raw prefix
  into room messages' list view; a related mismatch had the history
  scrollbar's sizing pass wrap room messages with the sender name still
  attached, disagreeing with the actual rendered text.

Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean
(sizeof(NodePrefs) confirmed 2720 via real build, not guessed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 20:30:03 +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
JakubandClaude Sonnet 5 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>
2026-07-17 22:04:40 +02:00
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Sonnet 5 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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>
2026-07-06 21:07:31 +02:00
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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
JakubandClaude Opus 4.8 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