_shutdown_init (set true once KEY_ENTER is pressed on the Shutdown home
page) was never cleared, so this branch kept calling _task->shutdown()
on every single poll() tick indefinitely once triggered.
Invisible on real hardware: _board->powerOff() halts the MCU in the
non-restart path, so there's no next tick to matter. But
SimMainBoard::powerOff() is a deliberate no-op (no real hardware to
power off), so a sim instance keeps running after "shutdown" -- and each
repeated shutdown() call re-fires _display->turnOff(), which blacks out
its <canvas> (keyed by simInstanceTag) again on every frame.
This is what made meshcore-solo-site's RESET button unusable after a
device had been shut down: the still-running old instance kept
re-blacking the very canvas a freshly reset instance (same tag, same
canvas element) was trying to render its own boot splash onto -- visible
as a black screen, with the new instance's splash winning a single frame
every so often before being painted over again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
UITask::shutdown() busy-waits on buzzer.isPlaying() for up to 2.5s before
powering off. On real hardware that's a real (if crude) wait; in the
Emscripten sim it's a synchronous block on the browser's single JS/wasm
thread, which freezes the whole page for the duration -- reported as the
site "zacinanie się" (stuttering) whenever hibernate/shutdown triggers.
Guards it with #ifdef SIM_PLATFORM, mirroring the identical pattern
already used a few lines below for the low-battery pre-shutdown pause.
Verified empirically (not just by reading the diff): measured real
requestAnimationFrame throughput on meshcore-solo-site while triggering
hibernate for real (Home -> Shutdown page -> Enter). Before: 135 frames
in 3.5s (607ms max stall). After: 633 frames in the same window (110ms
max) -- confirmed with a real stash/rebuild before-after control.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
Code-review pass over the buzzer/sim commits turned up several real bugs,
plus two issues found afterward from manual browser testing:
Rendering (SimDisplayDriverCanvas, variants/sim/SimDisplayDriver.h + target.cpp):
- getTextWidth() measured UTF-8 BYTES (strlen()*6), not codepoints. Since
b067e95b stopped stripping accents, any accented string now measures
double its real width -- mis-centred titles, premature ellipsis/marquee,
badges pushed off-screen. Now uses the real MiscFixedRenderer measurement
(miscFixedTextWidth()), same as SH1106Display/SSD1306Display.
- Added the matching getCodepointWidth() override (O(1) single-glyph
advance), same pattern as SSD1306Display.
- isSingleFont() was left at the base class's `false`, though this backend
only ever renders MiscFixed -- UITask.cpp's status-bar indicator height
keys off this (`lh-2` vs `lh`), so the sim drew it 2px taller than a real
board.
- print() blitted the full 128x64 canvas on every call (dozens per frame,
60fps) -- now tracks a dirty bounding box and only clears/blits the
region actually touched.
Web Audio (buzzer bridge, index.html + mesh.html):
- No AudioContext.resume() -- a context created (or later suspended) in the
'suspended' state (Safari/Firefox, or any browser backgrounding the tab)
stayed silent forever. Now resumed on every gesture.
- linearRampToValueAtTime with no anchoring setValueAtTime interpolates
from the LAST scheduled event, not "now" -- so the anti-click ramps could
effectively snap instead of fading. Fixed with cancelScheduledValues +
setValueAtTime(current) before each ramp.
- mesh.html: a gesture only armed the clicked instance's audio. Click A,
send A->B, and B (the one actually meant to beep on receipt) stayed
silent. Now any gesture arms both A and B.
- RTTTL rests (freq=0, still "playing") now explicitly hold pitch and drop
gain instead of it happening to work by coincidence.
Misc: sim_test_get_num_contacts() was missing the g_sim_ready gate every
other sim_test_* hook has, so it could return a bogus negative count before
setup() finishes seeding num_contacts.
Splash screen missing "Solo <version>" bar: variants/sim never defined
FIRMWARE_SOLO_BUILD (every real Solo board does), so SplashScreen silently
skipped that whole line -- the sim looked like a plain non-Solo companion
build. Added -D FIRMWARE_SOLO_BUILD=1 to platformio.ini and build_wasm.sh.
Verified on a real canvas screenshot: "MESHCORE 1.17.1 / 19 Aug 2026 /
Solo v1.27".
Wasm-fetch error message: "failed to start: RuntimeError: Aborted(both
async and sync fetching of the wasm failed)" is Emscripten's own opaque
message for the single most common real cause -- the page opened via
file://...index.html instead of served over http(s) (fetch() on a local
file is blocked by CORS in both Chrome and Safari, confirmed by reproducing
the exact same error/stack via file://). Both harnesses now detect
location.protocol === 'file:' and show an actionable message with the
one-line fix instead of the raw stack trace.
Battery-set latency: SimMainBoard's battery value is an exact, instantaneous
JS-set integer (see sim_battery_set_mv()), but UITask's battery-check code
polls it every 8s and runs it through an EMA (alpha=0.2) meant to smooth a
REAL board's noisy ADC -- so a value typed into the demo UI could take tens
of seconds to visibly settle. SIM_PLATFORM now checks every 250ms and skips
the EMA (nothing to smooth), since the reading is already clean. Measured
on real canvas pixels: indicator update now lands within one screen-refresh
cycle instead of up to 8s+.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
genericBuzzer (src/helpers/ui/buzzer.h/.cpp) gets a third platform branch,
#elif defined(SIM_PLATFORM), alongside the existing NRF52 (direct PWM) and
NonBlockingRtttl paths -- purely additive, no changes to either real-hardware
branch. It reuses the NRF52 branch's already hardware-free RTTTL parser
(_parseHeader/_parseNext/_noteFreq, now shared via a widened guard) but
tracks (current frequency, note-end-time) instead of touching real PWM/timer
registers, advancing on plain millis() polling from loop() -- same
non-blocking shape UITask already drives every tick.
Wired into the sim build the same way every real board sets its buzzer pin
(-D PIN_BUZZER=<n> in build_flags/DEFINES; here it's a dummy sentinel since
there's no real pin, just something to activate the existing #ifdef
PIN_BUZZER guards in UITask.h/.cpp/SoundNotifier.h unchanged), plus two new
small UITask accessors (isBuzzerPlaying/buzzerFreqHz/buzzerVolume) and three
EMSCRIPTEN_KEEPALIVE exports so a host page can poll the buzzer's state.
Browser side: one Web Audio oscillator+gain per companion instance (index.html
single-instance; mesh.html per A/B, not R which is headless), created lazily
on the first real user gesture (AudioContext autoplay policy), polled every
20ms and mapped to the oscillator frequency/gain -- so every notification
sound, ringtone, alarm, and volume-blip that already worked on real hardware
now actually produces audio in the browser, unchanged at the call-site level.
Verified end-to-end with real RTTTL playback traces (not just "no errors"):
the startup jingle's exact note frequencies (C6/E6/G6) and a real DM-received
notification triggering the receiving instance's Web Audio gain node from 0
to its mapped volume and back, matching the actual "MsgRcv3" melody's notes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rounds out the browser sim harness with the rest of the physical board's
interactions: a reset button (JS-driven, since board.reboot() is inert
under -sEXIT_RUNTIME=0), GPS input wired into a real LocationProvider via
new SimSensorManager, JS-settable battery/environment telemetry, an
admin/repeater-login test hook (sendRoomLogin against the default
"password"), and full physical-keyboard text entry (printable ASCII
passthrough into the existing KeyboardWidget, Tab->KEY_KB_ENTER submit).
Also fixes three real bugs found while exercising all of this in a real
browser:
- UITask.cpp's native-only stdin poll branch had no __EMSCRIPTEN__
exclusion, so it also compiled into the wasm build and called a real,
blocking window.prompt() on nearly every frame -- the actual cause of
the reported time/controls jumping. Now gated to native only.
- 'n'/'p' were mapped as Next/Prev keyboard shortcuts, colliding with
typing those literal letters. Removed the shortcuts; added explicit
Next/Prev buttons to mesh.html (previously relied solely on them).
- Buttons grabbed native browser keyboard focus on click, so a later
stray Enter/Space could silently re-trigger a previously-clicked button
(e.g. Reset). mousedown now calls preventDefault() on all buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two more real-vs-sim mismatches, found after seeing the rendered UI:
1. The Clock screen's big time display (setTextSize(2)) rendered at size 1
-- SimDisplayDriverCanvas ignored setTextSize() entirely (a leftover
no-op from the old system-font renderer) and never overrode
getCharWidth()/getLineHeight(), so the big-digit layout math in
UITask.cpp's drawBig() came out wrong even once print() itself gained
real font support. Track _text_sz, scale both metrics by it (matching
SSD1306Display's own getCharWidth()==6*_text_sz pattern), and pass it
through to miscFixedPrint() in target.cpp instead of a hardcoded 1.
2. The Home carousel's "<PRESS_LABEL> to open" hint said "long press to
open" -- true only for touchscreen-only boards with no dedicated Enter
button (PRESS_LABEL's #if UI_HAS_JOYSTICK / #else split in
examples/companion_radio/ui-new/UITask.cpp). The sim's D-pad + OK key
behaves like a joystick board (a SHORT Enter press opens each page;
holding it separately reaches the real context menu via
handleLongPress()), so showing the touchscreen wording was both
inaccurate and different from what a real joystick board like Heltec V3
displays. Added SIM_PLATFORM to that #if alongside UI_HAS_JOYSTICK --
UI_HAS_JOYSTICK itself stays unset, since its other two gates
(begin()-ing/polling real joystick MomentaryButton objects) need
hardware the sim's target.cpp doesn't declare.
Verified in real Chromium: Clock screen shows "08:11:10" at real double
size above the normal-size date line; Home carousel now says "press Enter
to open". Full regression clean: 3 native envs, wasm companion_radio, the
2-instance+repeater mesh demo, and the long-press context-menu test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two prototype polish fixes ahead of the website embed work:
1. Holding Enter (the sim's main way into "the rest of the options") did
nothing -- the JS bridge's sim_enqueue_key() went straight to
enqueueKey(), bypassing UITask::handleLongPress() entirely, so
KEY_CONTEXT_MENU could never be reached. Add injectSimKeyLongPress()/
sim_enqueue_key_longpress(), which does route through the real
handleLongPress() (same code a real MomentaryButton(pin, 1000, ...)
reaches), and wire up press-and-hold (buttons + Enter/Space key) in both
web harnesses with the same 1000ms threshold real hardware uses.
2. SimDisplayDriverCanvas::print() drew text with the browser's own system
font (ctx.fillText, '8px monospace') instead of the real bitmap font a
MeshCore-Solo board renders with OLED_MISC_FIXED_FONT=1 (see
solo/heltec_v3/platformio.ini). Vendor the real Adafruit_GFX (unmodified,
from the same PlatformIO registry package a real board build pulls) into
variants/sim/thirdparty/gfx/, and render print() through the real,
shared src/helpers/ui/MiscFixedRenderer.h + MiscFixedFont.h -- byte-
identical glyphs to real hardware, not a look-alike.
Verified in a real Chromium (Playwright): short Enter -> "CLOCK TOOLS",
held Enter -> "CLOCK FIELDS" (gotoDashboardConfig(), proving
KEY_CONTEXT_MENU is really reached); font renders as hard square pixels,
inverse/selected-row text still punches correctly through a filled bar.
Full regression re-run clean: all 3 native envs, wasm companion_radio, and
the two-instance + repeater mesh demo (relay routing, DM delivery).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New board variant compiling the unmodified MyMesh/UITask/DataStore app
logic against real mesh::Radio/MainBoard/RTCClock/RNG interfaces, for
running the actual firmware outside embedded hardware:
- Native (plain g++, platform = native): ASCII-art display over stdout,
stdin-driven input, local-disk-backed DataStore/IdentityStore.
- Emscripten/WASM (variants/sim/build_wasm.sh, since PlatformIO's native
platform force-overrides any CC/CXX toolchain override back to system
clang++): canvas-backed display, IDBFS-backed persistence across page
reloads, JS-callable input via sim_enqueue_key(), emscripten_set_main_loop.
Real rweather/Crypto (AES128/SHA256/Ed25519) vendored unmodified and
proven working on both targets. variants/sim/web/index.html is a bare
verification harness, not the polished website embed.
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.
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>
An app-originated channel send (mirrored into the on-device history)
bumped that channel's unread badge whenever the device's own UI
wasn't already showing that exact channel -- unlike an on-device
compose, which sidesteps this by forcing itself into that channel's
view right before sending. Adds an explicit own_message flag through
addChannelMsg (MessageHistory -> AbstractUITask -> UITask ->
MessagesScreen) so an own post is never counted unread regardless of
what's on screen when it's sent.
Found the same bug in MyMeshBot.h's three auto-reply-into-channel call
sites (Remote Bot's own reply showing as unread on itself) and fixed
those with the same mechanism.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
CMD_SEND_TXT_MSG and CMD_SEND_CHANNEL_TXT_MSG (the phone app's send path)
transmitted over the mesh but never touched the device's own MessagesScreen
history, unlike a message composed on-device (MessagesScreen::afterSend) --
so a DM/channel post sent from the app was invisible if that same
conversation was later opened on the device's own screen. Both handlers now
also call into the same history-store entry points incoming messages use.
Also wires up delivery-status parity with an on-device send, not just the
raw text:
- Channels: arms the existing "relayed into mesh" repeater-echo tracker
(trackRelaySend()/armChannelRelay()) on the new entry -- sendGroupMessage
already runs that tracker regardless of who originated the send, this
just attaches it to the right history entry. Required threading a ring
position back out through AbstractUITask::addChannelMsg (now returns int)
and a new armChannelRelay() passthrough.
- DMs: addDMMsg gained ack_tag/ack_deadline_ms/resends params (threaded
through MessageHistory -> MessagesScreen -> AbstractUITask/UITask) so an
app-sent DM gets the same pending -> \xe2\x9c\x93/\xe2\x9c\x97 status the on-device compose
path shows. resends stays 0 deliberately: the app owns its own retry
decision, so this only drives the on-screen status, never a second,
independent auto-resend from the device itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Selecting a row whose ellipsized text overflows now animates a "swing"
marquee: holds at the start, scrolls to reveal the full tail, holds
there, then scrolls back and repeats. Unselected/non-overflowing text
is unchanged (still a static "..."). E-ink gets slower, coarser steps
(fewer, cheaper partial refreshes) than OLED; unchanged frames are
already skipped by the display's CRC diff, so idle holds are free.
Wired into every screen with a selectable row: home favourites, DM/
channel lists and message bodies, Settings, popup menus, Bot, Admin,
Nearby, Waypoints, Locator, Live Share, and the alarm screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release-notes.md's v1.25 section already documents "Updated upstream
base to companion-v1.17.1", and that merge (68527e7b) is confirmed in
main's history -- but every MESHCORE_VERSION string, including
UITask.cpp's fallback default for boards that don't set it explicitly
(Heltec v3/v4, ThinkNode, Mesh Pocket, T-Echo), was still hardcoded to
the pre-bump "1.17". Bumped every occurrence to "1.17.1" to match what
actually shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pollHallSensor() acted on the raw pin reading immediately, unlike every other
physical-input path in this file (MomentaryButton, pollCardKB()'s own
last-raw edge check). A cheap mechanical reed switch -- one of the two
sensor types the docs explicitly recommend wiring here, alongside a
solid-state Hall IC -- can chatter for a few ms while the magnet crosses the
trigger distance, so a poll every loop() tick during that window could flip
_locked and fire _display->turnOff()/turnOn() repeatedly in that short span:
wasted work on any panel, and a real cost on e-ink where each is a slow
full-panel operation.
A raw reading now has to hold steady for HALL_DEBOUNCE_MS (25ms, same
threshold as MomentaryButton's ISR_DEBOUNCE_MS) before it replaces
_hall_magnet_present and triggers the lock/unlock actions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Home key toggles the keyboard backlight but wasn't going through
checkDisplayOn() like every other TCA8418 key, so it couldn't wake a
sleeping display or extend the auto-off timer.
Also: removed a no-op #elif branch in ST7789Display.cpp (same values as
the #else it duplicated), and ENABLE_SCREENSHOT on the Cardputer ADV
solo env, which does nothing since ST7789Display has no getBuffer().
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).
Two races between the new GPS duty-cycle scheduler and code that changes
GPS state independently of it:
- gpsDutyCycleLoop() capped every "GPS on" phase at a fixed 60s and would
stop_gps() as soon as a fix went valid, with no awareness of an
in-flight "!gps fix" bot request -- so a fix's own up-to-300s acquire
window (and its 10s averaging phase) could get cut short by the
scheduler shutting GPS off mid-request. MyMesh::isGpsFixPending() now
feeds into UITask's existing "is anything live using GPS right now"
hold, alongside trail/live-share/locator/nearby.
- setSettingValue("gps", ...) (Settings toggle, bot !gps on/off, CLI)
starts/stops GPS directly without resetting the scheduler's own phase
timer, so a manual toggle could land on a stale, already-expired
deadline left over from before -- immediately re-stopping GPS a tick
after turning it on. The phase timer now resets on every external
change, so the next duty-cycle tick re-arms fresh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
formatDashVal(), the locked-screen counterpart to the unlocked clock's
dashboard rendering, never learned about DASH_MSGS and fell through to
an empty string for it, so Messages was the one CLOCK FIELDS choice
that showed nothing at all once the screen locked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adopts hardware Channel Activity Detection (wired into
RadioLibWrapper::isChannelActive() alongside our RSSI-threshold check
and RX duty-cycle power-save), MCU temperature telemetry, LR2021
standby workaround, DISPLAY_SCALE/FLIP overrides, NRF52Board
shutdownPeripherals() refactor, and misc upstream fixes.
Declines upstream's ConfigSerializer-based NodePrefs rewrite,
MultiSerialInterface/interface_manager, and UIColor palette system —
each would have broken large parts of the Solo-specific feature set
(NodePrefs fields, per-variant single serial_interface, enum-based
DisplayDriver::Color). Flagged as candidate follow-up migrations, not
permanent no's.
Also fixes several pre-existing bugs surfaced while chasing silent
merge breaks (stale newMsg() override signature in ui-tiny/ui-orig,
dead UIEventType::newContactMessage case, missing ContactsIterator
init), bumps FIRMWARE_VERSION/MESHCORE_VERSION to 1.17, and fixes a
missing <cstdlib> include that broke the native ConfigSerializer unit
tests.
Verified via 13+ pio run builds across ESP32/nRF52, all 3 companion UI
variants, and 7 display drivers, plus the full native unit test suite
(33/33 passing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New M5Stack Cardputer ADV variant (ESP32-S3, ST7789 TFT, built-in TCA8418
QWERTY keyboard, PI4IOE5V6408 LoRa-cap IO-expander autodetect), and a
KeyShield accessory variant for the existing LilyGO T-Echo Lite (external
TCA8418 T9 keypad + AW21009 backlight driver). Both keyboards share one
ENV_USE_TCA8418 polling block in UITask.cpp::loop(), coexisting with the
unrelated CardKB support (different chip/address/flag).
Fixes carried in from the contributed T-Echo Lite code: swapped GPS RX/TX
pins, TX-LED hooks, TCXO voltage, missing GxEPD2_122_T61 panel include.
Fixed during integration: I2C bus was probed for an RTC before Wire.begin()
configured its pins on Cardputer ADV (silent RTC autodetect failure).
Added dedicated *_solo_dual release envs for both boards (auto-picked up by
the solo-firmware release workflow). Gave the T-Echo Lite KeyShield solo
build -Os/-Ofast-unflag like every other nRF52 solo build (was missing,
cut flash usage from 90.7% to 61.4%).
Ported the shared misc-fixed 6x9 font (full Latin/Greek/Cyrillic, opt-in via
OLED_MISC_FIXED_FONT) to ST7789Display for the Cardputer's on-screen
keyboard. ST7789Spi isn't Adafruit_GFX-based like the other single-font
drivers, and this panel's logical->physical scale is non-integer, so glyphs
are re-packed to XBM and blitted through the existing drawXbm(), which
already does correct fractional-scale boundary math, rather than
duplicating that logic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three power-saving additions, prompted by comparing this fork's existing
RX duty-cycle support against IoTThinks/EasySkyMesh:
- RX duty-cycle watchdog: the SX126x's hardware RX<->sleep sequencer runs
with no MCU polling, so a desync (a known failure mode) previously had
nothing watching for it. A new watchdog samples the BUSY pin every tick;
no transition for too long triggers a soft re-arm, then a full chip
reset (with cached radio params reapplied, since std_init() resets to
compiled firmware defaults) if that doesn't clear it. Soft/hard recovery
counts surface on Tools > Diagnostics > Live as "RXPS wd s/h".
- Noise-floor recalibration during power-save: sampling was previously
skipped entirely while duty-cycling, freezing int.thresh interference
detection at whatever the floor was when power-save turned on. Now
borrows a brief continuous-RX window once a minute to take a fresh
reading before re-arming duty-cycle.
- GPS duty-cycling (Settings > System > "GPS pwr"): cycles GPS off between
fixes instead of running it continuously. Each wake waits for a fix
(capped at 60s) before sleeping again for the configured interval.
Repurposes the long-dead NodePrefs::gps_interval byte rather than adding
a new persisted field. A "is anything live using GPS right now" hold in
UITask keeps GPS continuously on whenever trail recording, live-share,
an armed Locator, or the Compass/Nearby-navigate view actually need a
live fix, so none of those features degrade. Locator crossing-state is
reset on each wake so a still-settling first fix can't read as a false
geofence crossing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 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>
Post-review cleanups, no behaviour change:
- botScanCommands() parsed the command name and its two args with three
near-identical read-token loops; extracted a single readToken() lambda.
- Fn+Esc lock branch turned the display on twice (the unlock arm repeated
what the branch head already did); dropped the redundant call.
- setGpioMode()'s comment said "Cycle" (cycling lives in GpioScreen); now
describes what it actually does — set a specific mode + persist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
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>
- 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>
UITask::newMsg() (fired for every incoming DM/channel message) triggers a
3s "Msg: <sender>" overlay drawn on top of whatever screen is current --
including the shared KeyboardWidget when it's occupying the full screen
for text entry (message compose, room/repeater password, channel name,
device name, admin custom command, ...). A message arriving mid-typing
blanked out the letter grid for the full 3s with no way to see what was
being typed.
KeyboardWidget now tracks whether it was actually rendered this frame
(_visible, set at the top of render(), cleared by the new beginFrame()).
UITask's render loop calls _kb.beginFrame() before curr->render() and
skips the alert overlay when the keyboard turned out to be what got
drawn -- covers every screen that shares _kb, not just message compose.
The alert itself is unaffected (still fires, still expires after 3s) --
it just doesn't draw over the keyboard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renamed the vestigial Lemon/default font-switch naming (setLemonFont/
isLemonFont/drawLemonChar/lemonXAdvance/_use_lemon -> setSingleFont/
isSingleFont/drawGlyph/glyphXAdvance/_single_font) across DisplayDriver.h
and both concrete drivers -- both have been permanently single-font for
several commits, so the old names invited a future reader to think a
real switch still existed. Pure identifier rename, no logic changed.
Also extracted the byte-identical "SenderName: " prefix-splitting in
MyMeshBot.h's tryBotReplyChannel()/tryBotChannelCommand() into a shared
botChannelSenderSplit(), mirroring the existing botRoomSenderName().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- GxEPDDisplay now uses the same single misc-fixed 6x9 font as the OLED
driver (Lemon retired there too), with baseline math updated for the
new ascent.
- Clock Tools' Alarm screen: Repeat and Armed now respond to LEFT/RIGHT
like every other multi-value/toggle field in Settings, not Enter-only;
Hour/Minute merged into one Time row edited with a hand-rolled HH:MM
digit cursor (like the Timer's), replacing the two-row DigitEditor
popups; Repeat's "Off" label now matches the codebase-wide ON/OFF
casing.
- Top status bar: battery icon now shares the same box height as the
other status icons (Bluetooth/mute/etc.) instead of standing 2px
taller, and its charge nub is properly vertically centred instead of
drifting off-centre at non-multiple-of-4 box heights.
- Small settings-gear icon glyph tweak; wio-tracker-l1 screenshot build
variant now enables DUAL_SERIAL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A device administering itself via the Admin tool read as awkward, so the
local mode is gone and its options relocated to Settings.
- AdminScreen is remote-only: drop the CHOOSER phase, _local_mode, the
LocalField/LocalKind model and activateLocalField/commitLocalField.
Phases collapse to LOGIN/COMMAND/REPLY, entered only via startFor().
- Tools > Admin opens straight to the Nodes picker (ACT_ADMIN ->
pickAdminTarget()). Backing out of a command screen returns to that
picker; the picker's Cancel returns to Tools. gotoAdminScreen() removed.
- Settings > System gains Name (keyboard-edited node_name) and Reboot
(action row, placed last so it isn't the default cursor). Radio + TX
power were already in Settings; Send advert is the home ADVERT page.
Docs: tools_screen.md Admin section rewritten remote-only; settings_screen.md
System table gains Name + Reboot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tools > Admin now opens on a This-device / Remote-node chooser. "This
device" is a login-free 2-tab carousel (System: Name/Radio/TX power/
Lat/Lon, Actions: Send advert/Reboot) mapped onto NodePrefs/sensors and
reusing Settings' own apply chains -- no CommonCLI port. "Remote node..."
opens Tools > Nodes in a pick-mode (borrow-another-screen's-list idiom),
and Nodes gains an "Admin" Hold-Enter action; both converge on the single
canonical UITask::openAdminFor() -> AdminScreen::startFor().
Also:
- KeyboardWidget: opt-in PlaceholderRefreshFn hook for contextual {}-key
CLI command-name completion (word-replacing); default behaviour and all
other keyboard uses unchanged.
- TabBar: ellipsize() re-measures the real "text..." candidate per step
(+ width clamp) so dots can't spill into a neighbour/reserved icon.
- ChannelsView: form rows truncate instead of wrapping.
- Reboot moved off the default-selected Actions row (fires with no confirm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Messages:
- Add/edit/delete channels on-device (new ChannelsView, owned by the renamed
MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope).
Channel secret entry supports a typed passphrase (SHA-256'd, same primitive
the library already uses for the routing hash) or a raw 32-hex-char key.
- MyMesh::setChannelLocal() factors out the setChannel/saveChannels/
onChannelRemoved sequence previously duplicated across the two
CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths.
Tools > Admin (new):
- Log into a repeater/room server's admin account and send CLI commands,
the on-device equivalent of the app's repeater-admin feature.
- Commands are organised into category tabs (System/Radio/Routing/Actions)
with common get/set fields (name, radio profile, tx power, repeat, advert
intervals, ...) plus a free-text "Custom command..." fallback for anything
else. A field row fetches the current value, opens it pre-filled for
editing, and sends the change — falling back to a blank editor if the
fetch fails or times out.
- The admin password persists and self-heals exactly like room logins in
Messages: saved on a confirmed admin-level login, forgotten on a failed
one, left alone if merely under-privileged.
- New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so
a reply reaches the UI without touching the existing BLE/app CLI-terminal
path (queueMessage's should_display gate is untouched).
Shared TabBar.h extracted from NearbyScreen/BotScreen's independently
duplicated tab-carousel rendering (now a third consumer via Admin) — also
fixes neighbouring tabs vanishing outright when they didn't fully fit;
they now truncate with an ellipsis instead.
Keyboard: the combined "Ext.Latin" alphabet split into 8 separate,
linguistically complete per-language keyboards (Polish, Czech, Slovak,
German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug
where tall accented glyphs overlapped the keyboard's separator line
(SH1106's Lemon-font ascent constant was 2-3px short for them).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>