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.
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.
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.
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>
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>
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>
- 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>
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>
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>
- 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>
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).
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
"+ 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>
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>
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>
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>