Settings > Contacts gains "Expire" (Off/7d/30d/90d) and a "Prune now" action
that counts first and asks before removing anything. A contact with no
advert/update within the threshold is eligible; favourites are always kept.
Thresholds and their labels live in one NodePrefs table so the age shown and
the age enforced can't drift. SCHEMA_SENTINEL -> 0xC0DE002C (sizeof unchanged
at 2824, confirmed on native and a real WioTrackerL1 build).
Also fixes four bugs in the scope list from 89c02eea:
- removeScope() saved /scopes1 but never savePrefs(), so the ch_scope_idx[]
and repeat_extra_scope_mask fix-ups it makes were lost on reboot, leaving
shifted entries against unshifted indices.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote the legacy fields directly instead of
going through setPrimaryScope(), so the app's default-scope setting had
nothing reading it once sends resolved through the list.
- The reverse direction was stale too: an on-device "Set as default" never
refreshed default_scope_name/key, so CMD_GET_DEFAULT_FLOOD_SCOPE reported
a scope the device had stopped using. New syncLegacyDefaultScope().
- Upgrading from the old single Scope field set the default (so DMs kept it)
but left every channel unscoped, since "*" means unscoped, not "inherit".
loadScopeList() now reports when it migrated and begin() seeds the channels
that already exist, leaving empty slots alone.
"Set default" -> "Set as default", and setting it now says what it governs
("Default: DMs + relay") rather than leaving the [default] marker to imply
more than it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the single device-wide Scope text field with a small named list
(wildcard "*" fixed first, plus a movable default): each channel picks one
scope of its own (matching the app's per-channel region picker, surfaced in
the channel-history title), and the repeater's Extra scopes multi-selects
from the same list instead of comma-typed names. Both pickers are popups
(PopupMenu gains addCheckItem()/a fillable-square checkbox glyph) over the
existing screen rather than a full-screen takeover.
Also fixes a stray-bits bug where a pre-existing prefs file's own sentinel
tail could read into the new repeat_extra_scope_mask/ch_scope_idx fields and
silently reappear as a real pick later once the scope list grew into that
range.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Unselected message bubbles no longer carry a stray light-filled header
strip behind the sender name -- the one place with a filled patch on an
otherwise plain row. Selected fill is unchanged.
- Received DMs and channel posts now show their hop count as the same tiny
digit icon a sent message already uses for its repeater/echo count, using
the hop path the mesh already records for incoming messages.
- DM/channel history list: UP at the oldest message now wraps straight to
the newest (not the compose row), closing the loop the same way every
other list in the UI does at its ends.
- A received message's age no longer goes blank when its timestamp reads
slightly ahead of the device's own clock (sender/receiver skew, or an
unsynced receiver) or is genuinely unknown -- both now show the receipt
time instead of leaving the row with just a name and hop count.
- release-notes.md: add the v1.28 section covering the above plus this
cycle's already-committed dev fixes (GPS altitude field, units
compliance, noise floor, Msg wake location, bot relay tracking, PR #34).
- Add .github/workflows/build-solo-sim.yml: builds the companion+repeater
WASM sim via Emscripten and attaches it as a fixed-name asset to the tag's
draft release, independent of the hardware firmware build job.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every other list in the UI (contacts, channels, message-pick, the
mode tabs) wraps at its ends; the DM and channel history lists were the
one exception -- UP at the oldest message (top of the list) just
stopped instead of wrapping to the compose row (the list's own
bottom-most stop), and DOWN from the compose row didn't wrap back to
the oldest either. Both directions now close the loop, same as
everywhere else.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add a separate "Altitude (GPS)" dashboard field (label AltG) alongside
the existing barometric one (now labelled "Altitude (Baro)") -- the
original single Altitude field only ever read a barometric sensor's
CayenneLPP telemetry, showing "--" on any board without one even with
a perfectly good GPS fix.
- Every altitude reading (baro + GPS, Clock and Lock screen) and the GPS
home page's "alt" row now go through a shared fmtAlt() helper and
respect Settings > System > Units, same as every other distance in the
UI -- none of them checked units_imperial before. Same fix for
Locator's Radius row, which always showed metric regardless.
- Radio home page's noise floor showed "n/a" whenever Pwr save (RX
duty-cycle) was on, on the assumption duty-cycle RX can't sample the
floor. That's stale: RadioLibWrapper already periodically recalibrates
it (noiseFloorCalibCheck(), every 60s) even under duty-cycle, which is
exactly why Diagnostics' unconditional read of the same value was
already showing something real. Radio home page now matches.
- Moved "Msg wake" from Settings > Sound to Settings > Display -- more
logical home for a display-wake toggle than notifications. Menu-order
only; NodePrefs storage is unaffected by this enum's ordering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remote Bot's channel trigger reply, channel command reply, and !gps fix
result all mirrored their send into the on-device history with
addOwnChannelMsg() but never called armChannelRelay() afterward, unlike
the app's CMD_SEND_CHANNEL_TXT_MSG send which already did both -- so a
bot's own channel posts never showed which repeaters echoed them back,
while everything else did.
Factor the always-together pair (mirror into history, then arm relay
tracking from the send sendGroupMessage's sendFloodScoped already
tracked) into MyMesh::mirrorOwnChannelMsg(), and route all four call
sites through it instead of pairing the two calls by hand -- the bot
bug was exactly a dropped second call, so a new call site can no longer
add the first half without the second.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: Fix message channel list not updating instantly
Creating a channel didn't rebuild the Messages screen's cached channel
list until you backed all the way out and reopened it. Tracks whether
the Add/Edit channel form was active on the previous render and rebuilds
the list once it closes.
Same staleness bug as the build-date fallback: bypassing build.sh (a
direct `pio run`, an IDE Build button, the sim) fell back to a hardcoded
"v1.27-dev" literal that would silently keep claiming to BE v1.27 forever,
even once development has moved well past it. build.sh/CI's real
git-tag-derived FIRMWARE_VERSION is untouched -- only the bypass fallback
changes, to a version-agnostic "dev-<build date>".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Settings > Reboot, the phone app's CMD_REBOOT, and the serial CLI's
"reboot" command each hand-rolled their own flush-then-board.reboot()
sequence, so each could drift independently (Settings > Reboot and the
CLI command were both missing the contacts flush added in the previous
commit). Add a pure-virtual AbstractUITask::shutdown(bool restart) --
every UI variant (ui-new/ui-tiny/ui-orig) already implements a method
with this exact signature -- and route all three call sites through it
instead. CMD_FACTORY_RESET intentionally keeps calling board.reboot()
directly: flushing stale in-RAM prefs back out would defeat the format
it just did.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contact updates (advert path cache, new/removed contacts, favourites) are
lazily debounced (dirty_contacts_expiry, 5s) to avoid wearing flash on
every mesh packet. CMD_REBOOT already flushed this before rebooting, but
UITask::shutdown() -- the low-battery auto-shutdown and long-press
power-off path -- didn't, so under continuous mesh traffic (which keeps
re-arming the debounce timer) a pending write could sit unflushed for a
whole session and be lost when one of those paths fired. Add
MyMesh::flushDirtyContacts() and call it from shutdown() alongside the
existing savePrefs()/saveRTCTime() flush.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two distinct bugs behind "Relayed by" scrolling looking broken:
1. The DM/channel history row underneath a context menu (Path/Relayed by,
Options, etc.) keeps rendering every frame with sel=true regardless of
the popup on top of it, and both it and the popup's own selected item
share DisplayDriver's single marquee slot. Whichever drew last each
frame kept is_new-resetting the other's animation -- looked like the
scroll "remembered" a stale, wrong position. Now suppressed via
`sel && !_ctx_menu.active` at all four call sites that had it (DM/
channel history bodies, contact/channel pick names).
2. PopupMenu::render() returns 50 (it wants a fast, smooth redraw), but
all 7 `_ctx_menu.render(display)` call sites discarded that return
value, so the enclosing phase's own return statement (500-2000ms)
governed the actual redraw cadence instead -- the popup's marquee only
ever advanced whenever the much slower underlying screen happened to
redraw. Now folded into the existing mq_delay accumulator, same as
every other marquee source in this file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New DASH_SATS clock/lock-screen dashboard field, reading
LocationProvider::satellitesCount() (already used by the GPS Tools
panel, just not previously exposed as a dashboard option). Follows the
existing DASH_GPS pattern: always selectable, "--" when built without
GPS support. Added to both dashboard-render paths (HomeScreen's CLOCK
page inline loop and formatDashVal(), shared by the LOCK page).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #32 added a title bar (battery + status icons) to the LOCK page but
its drawClockTime() call still started at top_y=0, same row the title
bar draws into -- renderBatteryIndicator() runs unconditionally for any
_page != CLOCK (LOCK included), only the node-name text is skipped for
LOCK specifically. Landscape panels could overlap the icon row whenever
enough status badges (BT/GPS/alarm/mute/advert/trail/repeater) are
active; portrait e-ink's huge size-4 HH/MM block (the only board shape
that hits drawClockTime()'s "tall" branch) is worst hit, since it's
designed to fill most of the narrow width. Starts at top_y=lh now,
clearing the title bar row, matching how every other non-CLOCK page
already reserves that space via content_y.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feat: Make lock screen a page type and display title bar on it
Moves lock-screen rendering out of a dedicated branch in UITask::loop()
into a proper HomePage::LOCK page in HomeScreen (appended after Count,
so no existing HomePage/home_pages_mask bit shifts), adds a title bar to
the lock screen (node name omitted, intentionally, for at-a-glance
anonymity), and makes drawClockTime()'s center-alignment opt-in so the
lock screen can left-align its clock. Reviewed: the two existing
drawClockTime() call sites are both updated to keep their prior
alignment, and this doesn't interact with our home_pages_mask=0 default,
msg_wake_screen_off, or the shutdown()-flush fix landed this cycle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause of "settings don't survive a reboot": every screen that edits
NodePrefs (Settings, Bot, Trail, Locator, GPS sharing, etc.) only persisted
via its own savePrefsIfDirty(_dirty) call on its own Cancel/exit path.
UITask::shutdown() -- the one function behind every real power-off/reboot,
including the low-battery auto-shutdown that fires directly from
UITask::loop() regardless of which screen is on-screen -- never called
the_mesh.savePrefs() at all. A user who changed a setting and then hit any
of those paths without first explicitly backing out of the screen (e.g.
display auto-off mid-edit, then a low-battery auto-shutdown) silently lost
the change.
Also closes two direct board.reboot() bypasses that skipped shutdown()
entirely: the phone-app CMD_REBOOT command and the serial CLI "reboot"
command. The factory-reset reboot path is deliberately left alone -- it's
supposed to wipe everything.
Verified with a real regression test (not just "it compiles"): set a pref
in memory without flushing it, cross the real low_batt_mv threshold to
trigger the actual production low-battery-shutdown code path, then a real
page.reload(), then confirm behaviourally (does an incoming message wake
the screen or not) whether the setting survived. Temporarily reverted the
fix and re-ran to confirm the test actually fails without it (screen woke,
setting lost) before restoring it and confirming it passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent changes, requested together:
1. Settings > Sound > "Msg wake" (NodePrefs::msg_wake_screen_off,
0xC0DE002A) lets a user disable UITask::newMsg()'s existing behaviour
of turning the display on for an incoming message when it was off and
no companion app is connected. Stored inverted so both a fresh device
and an existing saved-prefs file default to "on" (today's behaviour).
New sim_test_set_msg_wake_disabled() hook verifies it without scripting
Settings navigation -- confirmed end to end via a real A<->R<->B mesh
in Playwright: message delivered while B's screen is off either wakes
it (enabled) or doesn't (disabled), checked via real canvas pixels.
2. A brand-new or factory-reset device now shows ALL home pages by
default instead of a curated 5-page carousel (MyMesh.cpp used to seed
home_pages_mask = HP_DEFAULT; now seeds 0, which every other read site
already treated as "all visible" -- HP_DEFAULT is now unused, removed).
Existing users' saved masks are untouched. New sim_test_get_home_pages_mask()
getter confirms a fresh instance reads back 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit aligns the clock to the left side of the screen and shows the title bar, excluding the node name (intentionally - I think it's a good idea to make a node harder to identify when locked ;) ).
TODO: I'm not sure if this clock alignment works on all display types, I only have a Wio Tracker L1 Pro OLED to test right now.
This commit
- adds LOCK to the HomePage enum
- moves the lock page code from loop() to the selected page handler
- adds a forward declaration for formatDashVal to make it callable from within the selected page handler
- adds a syncLockToHome function that triggers a (un)lock page switch
sim_buzzer_toggle_quiet()/sim_buzzer_get_quiet() (UITask.cpp) call the
literal UITask::toggleBuzzer() the real on-device Settings > Buzzer mute
toggle already calls -- persists into NodePrefs.buzzer_quiet, clears
buzzer_auto, saves prefs, shows the real "Buzzer: ON/OFF" alert. Not a
re-implementation, not a host-side Web Audio mute layered on top.
Every simple_repeater instance advertised the literal ADVERT_NAME
("repeater") -- across meshcore-solo-site's cross-visitor relay bridge
every hop in every path/relay list was an indistinguishable wall of
"repeater"s. A SIM_PLATFORM/__EMSCRIPTEN__-only default now picks a
random "<adjective> <geographic feature>" pair from
sim_instance_entropy() (real crypto.getRandomValues()-sourced entropy,
already used for RNG seeding) at first boot -- matches the real,
common ham-radio convention of naming a repeater after the hill it
sits on. Persists normally across reboots via the existing prefs
load/save path, same as a real operator-set name would.
sim_repeater_get_name() added alongside the existing
sim_repeater_get_relay_count() for host-page/test access to the
result.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
advert_interval defaulted to 1 (the field is minutes/2, so 2 minutes) --
too chatty for a repeater sitting on a desk being demoed/deployed. Field
only stores even minute counts, so 6 (value 3) is the closest step down
from "beeps too often" without landing under-target at 4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
BaseChatMesh::bootstrapRTCfromContacts() called setCurrentTime(latest+1)
unconditionally. The newest contact's lastmod proves the clock must be at
*least* that late -- it says nothing about it being any earlier -- so on
any node that already has a better time source (a board with a real RTC,
or the Emscripten sim whose SimRTCClock is backed by the host's wall
clock) this dragged a correct clock backwards to whatever timestamp
happened to be persisted alongside the contact list. Measured in the
sim: ~9s behind real time after every reboot, growing with how long the
previous session ran.
Also adds sim_test_sync_time(epoch_secs) next to the other
SIM_PLATFORM/__EMSCRIPTEN__ test hooks, so a host page can re-anchor the
RTC on every (re)boot. The clock is live state the real mesh writes to at
runtime -- a peer whose own clock runs ahead pushes ours forward, which
is right on a node with no better source and, with meshcore-solo-site's
cross-visitor relay, means one skewed visitor can drag everyone. On a
demo running on someone's computer, "reset the device" should also mean
"the clock is correct again".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
meshcore-solo-site's new cross-visitor relay bridge (Phase 3) needs a way
to read a sim instance's live freq/bw/sf/cr so a WebSocket relay server
can fan traffic out only to other visitors tuned to the same params,
mirroring real LoRa channel isolation. Added sim_radio_get_params()
(examples/companion_radio/main.cpp), reading NodePrefs directly -- the
real live source of truth, since SimRadio::setParams() has always been a
complete no-op (accepts and discards its arguments). Out-params via
pointer, same buffer-pointer idiom sim_radio_poll_tx() already uses;
build_wasm.sh needed HEAPF32/HEAP32 added to EXPORTED_RUNTIME_METHODS so
JS can read them back.
While testing the relay bridge with two independent browser contexts, hit
a real bug: sim_instance_salt() (SimInstance.h) is a pure function of the
simInstanceTag STRING ('hero'/'B'/'R'), so it's only useful for telling
apart same-tab instances -- it's identical for two genuinely different
visitors who both boot a 'hero' instance. Combined with SimRNG::begin()'s
other seed ingredients -- time(NULL) (1-second resolution) and a WASM
heap pointer (no ASLR inside the sandbox, so it's fully deterministic
across independent boots of the same binary) -- two different visitors
landing on the same wall-clock second reliably generated byte-identical
Ed25519 identities (confirmed: two fresh browser contexts launched
together produced provably identical advert packets end to end).
Fixed by mixing in real crypto.getRandomValues()-sourced entropy from the
host page (new sim_instance_entropy(), read the same way simInstanceTag
already is) into both SimRNG::begin() and the twin seeding pattern in
SimRadio::getRngSeed(). Applies to every sim instance uniformly (hero/B/R
all get proper per-session entropy now), not just the new relay path --
strictly an improvement with no compatibility concern, since identity
generation only ever runs once per fresh/empty IDBFS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
- Turn on ENV_INCLUDE_GPS/UI_SENSORS_PAGE for the sim build (both were
compiled out entirely, so the GPS and Sensors home-page carousel
entries didn't exist regardless of home_pages_mask) -- SimSensorManager
already fed a real JS-settable GPS fix and a temperature/battery
channel with nothing to display them.
- Add sim_test_advert_flood() to the repeater build too, mirroring
companion_radio's hook: MyMesh::updateAdvertTimer() otherwise leaves
the repeater's first self-advert 2 minutes out, so a host page
couldn't make it discoverable as a contact (needed for admin login)
right away.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
NodePrefs::auto_off_secs defaults to 15 -- real power-saving behaviour a
battery-powered board needs (screen off, optionally locked, 15s after
the last input). UITask::autoOffMillis() treats 0 as "never" and skips
that whole branch in UITask::loop() entirely, so there's no separate
flag to touch. A demo running on a visitor's screen has no battery to
save and no reason to go dark while they're reading it.
sim_test_disable_screen_timeout() is a new sim-only hook, same shape as
the other sim_test_* boot-time hooks: meshcore-solo-site calls it once
after boot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
NodePrefs::tz_offset_hours defaults to 0 (UTC) -- correct for real
hardware, which has no other way to know the visitor's timezone besides
Settings > System > Timezone. SimRTCClock (see 718dbdbe) is already
correct live UTC (time(NULL)), but without a timezone applied, a demo
instance's Clock screen displayed correct-but-UTC time, which just reads
as "wrong" to a visitor who never opened Settings -- e.g. 20:40 shown
while it was really 22:40 CEST. This is the other half of the site's
"would be nice if the time was synced with the computer" ask; 718dbdbe
only fixed the stale-IDBFS-restore half.
sim_test_set_timezone_hours(int) is a new sim-only hook, same shape as
sim_test_show_all_home_pages(): meshcore-solo-site calls it once after
boot with the browser's own timezone offset. Whole hours only
(tz_offset_hours is an int8_t) -- same limit a real device's own
Settings field has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
A Reset control on a host page (meshcore-solo-site's RESET button,
mesh.html's own Reset buttons) re-invokes the MODULARIZE factory for the
same simInstanceTag to simulate a device restart, since board.reboot()
just exit()s the wasm process (inert / freezes the tab under
-sEXIT_RUNTIME=0). Nothing ever tore down the OLD instance's own
emscripten_set_main_loop() callback, so it keeps ticking forever after
being "replaced" -- and keeps drawing onto the same simInstanceTag-keyed
<canvas> the new instance draws onto too. Two visible symptoms this
caused on meshcore-solo-site: flicker/reversion after resetting the same
device more than once (multiple orphaned instances competing for one
canvas), and -- together with the HomeScreen::poll() fix in 5fbfd7c5 --
would have still left a residual single-old-instance repaint race even
after that fix alone.
sim_stop_main_loop() wraps emscripten_cancel_main_loop() so a host page
can explicitly stop the OLD Module reference right before discarding it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iubftDmKNWmkNnhJRz8UH
_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.