Commit Graph
66 Commits
Author SHA1 Message Date
JakubandClaude Sonnet 5 5fd887d781 docs(release-notes): add PR #32 lock-screen title bar to v1.27
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:42:00 +02:00
JakubandClaude Sonnet 5 1f41b3a1b8 docs(release-notes): catch up v1.27 with missing entries; tune(ui): faster marquee hold
release-notes.md was missing three shipped changes: the Msg-wake toggle +
all-home-pages default, the repeater advert-interval default (2min->6min),
and three Fixes (settings-persist-on-reboot, RTC bootstrap floor-not-
rollback, ESP32 diagnostics heap/stack).

marqueeHoldMs() reverted 1500/2500 -> 700/1200 (OLED/e-ink) -- the longer
hold made the swing-marquee's full reveal cycle too slow for long message
previews.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:12:58 +02:00
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 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 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 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 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 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 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 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
MarekZegare4 7e10e0359c docs: bump release notes to companion-v1.17.1 upstream base 2026-08-14 16:20:53 +02:00
MarekZegare4 55cb5d25d3 fix(gps): hold GPS awake during waypoint nav/avg/track-back and message-location navigate
These views run the same live bearing/distance readout as Compass/Nearby's
navigate mode, which already held GPS awake -- these three didn't, so
duty-cycling could leave them stuck on a stale fix until the next scheduled
wake (up to the configured sleep interval).
2026-08-14 00:40:50 +02:00
MarekZegare4andClaude Sonnet 5 df8bdb05e8 docs: note the upstream base bump to companion-v1.17.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:09:13 +02:00
MarekZegare4andClaude Sonnet 5 f333fdb26c docs: trim v1.25 release notes to short bullets
The prior entries carried full root-cause narrative for each change,
matching this file's older sections -- too dense for what's meant to
be a quick skim. Cut down to what changed and where.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:04:59 +02:00
MarekZegare4andClaude Sonnet 5 1f60c2437f docs: flag Heltec V3/V4 solo builds experimental too
Same reasoning as the Cardputer ADV / T-Echo Lite KeyShield note --
new board support that hasn't had much real-world use yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:49:07 +02:00
MarekZegare4andClaude Sonnet 5 5b4d4fca88 docs: version this cycle's release notes as v1.25, flag new boards experimental
Names the upcoming release and marks the Cardputer ADV / T-Echo Lite
KeyShield board support as experimental, since it's new enough not to
have had much real-world use yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:45:31 +02:00
MarekZegare4andClaude Sonnet 5 71c39bab4f docs: update release notes for v1.24 recap and the power-saving/UI fixes
Restructures already-shipped v1.24 content out of Unreleased into its
own version section, adds the missing v1.22 heading and a v1.21
separator, documents the M5Stack Cardputer ADV / LilyGO T-Echo Lite
KeyShield board support, and folds in the locked-screen Messages fix,
the GPS duty-cycle races, and the OFF-casing cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:08:27 +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 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 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 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