_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
DataStore::restoreRTCTime() runs on every boot and, if a prior save
exists, overwrites RTCClock with that stale timestamp. Correct on real
hardware (no other way to know the time before a GPS fix or a phone/CLI
sync), but SimRTCClock is already backed by the real host wall clock from
construction -- overwriting it with an old IDBFS-persisted save made a
returning meshcore-solo-site visitor's on-screen clock drift away from
their own real time instead of just showing it. Guarded behind #ifndef
SIM_PLATFORM.
sim_test_show_all_home_pages() is a new sim-only test hook, same shape as
the existing sim_test_* hooks in this file: real hardware ships with a
curated 5-page Home carousel (NodePrefs::HP_DEFAULT) so a first-time user
isn't handed 13 pages to joystick through, with the rest opt-in via
Settings > Home Pages. meshcore-solo-site calls this once after boot to
show the whole feature set instead, without touching the real-hardware
default.
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>
getContactByIdx() indexes the raw contacts[] table directly, whose first
MAX_ANON_CONTACTS (8) slots are reserved for anon requests -- getNumContacts()
already excludes them from its count, so real contacts start at index
MAX_ANON_CONTACTS, not 0 (NearbyScreen.h's own contact scan already applies
this offset; its comment documents why).
MessagesScreen.h's buildContactList() didn't, so its loop only ever read
the reserved anon slots (empty name, type 0) for any total at or under
MAX_ANON_CONTACTS -- e.g. a device with exactly one known contact would
show "SELECT CONTACT" / "No favourites" with an empty list forever,
regardless of the dm_show_all/fav_only setting or that contact's own
favourite flag. Reproduced live in the browser sim: a fresh companion_radio
instance with one real ADV_TYPE_CHAT contact showed nothing until this fix.
Also fixed _sorted[]'s stored index (was the bare loop counter, needed to
be the raw table index every other call site in the file already assumes)
and the same missing-offset bug in BotScreen.h's room-contact counter and
MessageHistory.h's pub_key-prefix contact lookup.
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>
Mirrors the simple_repeater sim port exactly: headless (no DISPLAY_CLASS,
UITask.cpp excluded from the build), new sim_simple_room_server native env
plus build_wasm_room_server.sh, own SimFS root ./sim_data_room so its
identity storage can't collide with the companion or repeater instances on
the same page/cwd.
Verified beyond "it compiles": ran the native binary and confirmed a real
_main.id identity file gets persisted through the actual SimFS/IdentityStore
path, same as the other two sim targets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports examples/simple_repeater to variants/sim/ (new sim_simple_repeater
native env + build_wasm_repeater.sh) and adds a JS "ether"
(variants/sim/web/mesh.html) that bridges two real companion_radio WASM
instances through a real simple_repeater instance in a strict A<->R<->B
topology (no direct A-B link), proving genuine relay routing rather than
a shortcut.
Also fixes multi-instance issues Phase 2's single-instance design never
surfaced: SimDisplayDriver's canvas context/id caching was keyed on a
single global instead of per-instance, and both wasm builds were missing
_malloc/_free/HEAPU8 runtime exports needed for the ether to poke bytes
into an instance's memory.
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.
hop_count was clamped to MAX_HIST_PATH_BYTES regardless of hash_size, but
path[] is only MAX_HIST_PATH_BYTES bytes total -- with hash_size>1 the old
clamp let i*hash_size run past the buffer in resolveHopName(). Not reachable
today since capturePath()/markChannelRelayed() already bound hop_count to
MAX_HIST_PATH_BYTES/hash_size on write, but the reader shouldn't rely on
writer discipline alone. Now clamps to the same MAX_HIST_PATH_BYTES/hash_size
bound.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NodePrefs.h's field declaration order used to just be historical append
order (on-disk format is defined solely by DataStore's explicit rd()/wr()
sequence, not struct layout), making the file hard to navigate. Reordered
fields into thematic groups (radio, repeater, bot, GPS/trail/location,
display/keyboard, etc.) with no on-disk/schema change; fixed two comments
that had gone stale (favourite_contacts/_kinds' [del→...] tags only named
one of the two handlers that actually clear them; dashboard_fields was
miscategorized under favourites). sizeof(NodePrefs) shifted twice as a
side effect of packing (2760→2752→2760) — verified via real builds on all
four canonical envs and re-checked against the serialization tripwire.
Also replaced test_companion_node_prefs.cpp's dead body (a disabled test
against a saveSerial/loadSerial API this struct never got) with real
coverage of the pure helper functions NodePrefs.h already carries -- band
bucketing, repeater-profile bounds, alarm-repeat round-trip, and every
option-lookup table, including their inconsistent out-of-range fallback
behaviour.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four call sites (three bot reply paths, one app-originated-send mirror)
each hand-built the same "Me: <text>" + own_message=true framing that
MessagesScreen relies on to render an outgoing bubble -- one of them
(now fixed) had already drifted to the wrong prefix once. Centralizing
it in AbstractUITask::addOwnChannelMsg() means a caller can no longer
get the framing wrong.
tryBotReplyChannel() mirrored its own reply into the on-device history
prefixed with the node's own name instead of the "Me: " convention the
history view uses to tell an outgoing bubble from an incoming one, so
the bot's own reply rendered left-anchored like a message from someone
else labelled with the device's own name.
Reboot fired on a single Enter with no way back, unlike Start OTA on
the same tab -- inconsistent given both take an unattended remote node
out of action for a while. Reuses the same beginConfirm() idiom.
Network mode, the dedicated profile and the flood filters were only
built into the row list once client_repeat was already ON, so setting
up a repeater meant enabling it blind first. Every other on/off tool
in ui-new (Bot, Live Share, Locator) already keeps its settings
visible while off; Repeater now matches.
The Hold-Enter popup was a single "Reset counters" item with no Cancel
row, so one Enter zeroed all stats immediately -- the same shape the
Trail/channel/preset resets had before beginConfirm() fixed them.
Reuse the existing botChannelSenderSplit() helper for the channel
[LOC]-share sender name instead of re-implementing the same "Name: "
split inline, and fix a stray lowercase "gps:" reply that didn't
match the rest of the !gps command's replies.
Found while re-reviewing this session's own commits: five screens each
hand-built the same 2-row Action/Cancel confirm popup, defaulting the
highlight to Cancel -- NearbyScreen's contact-delete, AdminScreen's
OTA-start, and the three just added (Trail's reset, Messages' channel
delete, RadioPresetPicker's preset delete). The plan that added those
three had already flagged this exact duplication without acting on it, so
it just tripled instead of getting fixed.
One PopupMenu::beginConfirm(title, action_label, cancel_label="Cancel")
replaces all five call sites, and makes "defaults to Cancel" a property of
the popup itself rather than something each new confirm has to remember.
Also drops two small redundancies spotted along the way: NearbyScreen's
_confirm.active = true, dead since begin() already sets it, and
RadioPresetPicker's deleting = false being set twice in a row (once inside
openConfirm(), once again by its only caller).
No behavior change; verified against the actual PopupMenu/menu-level state
machines in each of the five call sites before touching them.
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>
- RepeaterScreen: drop the redundant "Rpt " prefix on the profile fields
(Preset/Freq/SF/BW/CR), matching Settings > Radio's own terminology --
the screen is already dedicated to the repeater's own profile, so the
prefix disambiguated nothing.
- Channel context menu: "Fav: yes/no" -> "Fav: ON/OFF", matching every
other toggle in the app.
- Settings (System tab): six labels left as raw concatenated identifiers
(AutoOff/AutoLock/TimeZone/LowBat/BattDisp/BzrVol) now read like their
space-separated neighbours (Auto pwr/Pwr save/DM sound/GPS pwr), and
BzrVol no longer clashes with "Buzzer" one row above it for the same
feature. Three value-label arrays also had one mismatched-case entry
fixed to match its siblings: Auto-off's "never" -> "OFF", Batt display's
"icon" -> "Icon", Sound's "built-in" -> "Built-in".
- ToolsScreen: re-enabled the mini-icons next to each tool, which had
been commented out ("don't fit visually"). Root cause: the screen's own
drawIcon() centred against lineStep() (line height + inter-row gap)
instead of getLineHeight() alone, 2px too generous for a near-full-
height icon -- now delegates to the already-correct miniIconDraw() used
elsewhere (e.g. the message-list ack checkmark). Also gave Admin and
GPIO their own icons (padlock, 3-pin header) instead of both sharing
System's cog.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plain ✓ on your own channel post only said "at least one repeater
heard it" -- now it shows how many distinct repeaters echoed it back,
drawn as tiny 3x5 digit icons (icons.h) rather than the normal font,
since the slot next to the sender name is icon-sized, not text-row-
sized. DM delivery ticks are untouched (no repeater-count concept
there, so they keep the plain checkmark).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
Follow-up review of f589b9b2 -- five defects in that commit's own changes.
- repeat_scope_only + repeat_extra_scopes were read/written in the MIDDLE of
the prefs stream, beside their repeat_* siblings. loadPrefsInt()'s rd() is a
plain sequential reader gated only on file.available(), with no per-field
versioning, so on any pre-existing file those 25 bytes were taken from the
fields that follow, shifting EVERY later field: repeater profile (incl. a
float freq), track_shared_loc, all of loc_share_*, trail, bot, GPIO modes.
Moved to the struct/file tail, sentinel bumped to 0xC0DE0027 with 0xC0DE0026
marked burned. sizeof stays 2752 (confirmed by build); the tripwire procedure
now spells out the append-only rule that "in struct order" left implicit.
- rebuildRepeatScopes() called getAutoKeyFor() with id 0 for every entry, but
that cache is keyed on the id alone and ignores the name on a hit -- so every
extra scope after the first silently got the first one's key, making the
comma-separated list do nothing. Distinct id per scope now.
- interference_threshold had no load clamp, so an upgrader read 0x23 (35) out
of the old file's sentinel tail instead of 0.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote default_scope_key without rebuilding the
relay filter, so setting or clearing the scope from the app left the repeater
filtering on the previous key until reboot. The on-device path already did.
- The keyboard preview derived the cursor's row a second time from byte
offsets, disagreeing with the cursor_line the scroll window already computes:
it pinned the cursor to the end of a full line (drawing '_' one character
past the display width) at every wrap boundary. Use cursor_line directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 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>
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).
The pre-v1.13 "GPS Interval" setting (hidden from Settings ever since,
but its byte kept "for backwards compatibility") used a different option
set than today's duty-cycle presets -- its old 30s choice isn't one of
them. A device that had it set to 30 would load that value straight into
the new duty-cycle scheduler while "GPS pwr" in Settings showed OFF
(gpsDutyIndex() found no matching preset), silently cycling GPS on a
setting nobody could see or change. Unrecognised values now reset to OFF
on load, same as the existing out-of-range clamp this replaces.
Also refreshes MyMesh.h's FIRMWARE_VERSION/FIRMWARE_BUILD_DATE fallback
(only ever used by a `pio run` that bypasses build.sh entirely) -- it
was still "v1.17-solo.0" from 12 tags ago.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every genuine on/off toggle already agreed on ON/OFF, but the disabled
point of several value pickers didn't: Settings' LowBat/GPS pwr/e-ink
full-refresh options and the auto-advert interval showed lowercase
"off", GPIO's mode row showed "Off" right above its own State row's
"OFF", and the GPS-averaging/trail-autopause pickers showed "Off" where
the alarm-repeat picker already said "OFF". All display-only label
arrays, no behaviour change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>