Field report: a user on the stock SF8 "EU/UK (Narrow)" preset saw reception
drop from ~1-5 msg/min to ~1/3h with Pwr save on, unaffected by a better
antenna. Root cause, confirmed against the SX1262 datasheet and RadioLib's
own maintainers (jgromes/RadioLib#1597, closed as inherent chip behaviour):
the SX126x's duty-cycle preamble detection needs the sender's actual
preamble to closely match what we've configured our receiver to expect.
A mismatch — e.g. a repeater still on pre-v1.16 firmware sending 16 symbols
against our 32 — doesn't cost a little sensitivity, it silently drops every
packet from that sender regardless of signal strength. There's no local
parameter fix: shortening minSymbols to tolerate shorter preambles directly
shortens the wake-window's correlator dwell time, trading the preamble
mismatch failure mode for a marginal-signal one instead. Checked whether
IoTThinks' MeshCore fork solved this differently — it doesn't hit the
problem at all, because its "power saving" only light-sleeps the MCU on the
radio's own GPIO interrupt and never touches RX duty-cycle.
examples/companion_radio/MyMesh.h now defines FEAT_RX_POWERSAVE 0, gating
out the Settings row, the Diagnostics RXPS watchdog row, and every call site
that would apply rx_powersave to the radio or CAD auto-enable — including
forcing setPowerSaving(false) unconditionally so a stale rx_powersave=1 byte
from before this change can't do anything either. The real duty-cycle
implementation in RadioLibWrapper/CustomSX1262Wrapper is left in place,
unneutered, for if a network-wide compatibility mechanism (e.g. the
still-unused ADV_FEAT1/FEAT2 advert bits) is ever built to make it safe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The marker was placed after the sender's full width while the name itself
was only ellipsized against the age, so a long name pushed the hop count
(worst with two digits) onto the age. The name now yields to the marker and
the age, the marker follows the width the name actually got, and a 2px gap
separates marker from age.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Live Share gets a "Scope" row (Target / * / any scope from the shared
list). It applies to [LOC] sends only, beats the app's send_scope and a
channel's own pick, and never touches normal messages. Target keeps the
previous behaviour. removeScope() fixes the stored index up.
- Auto share now always ends: "Stop after" 1/2/4/8/12 h (default 1 h, no
unlimited option). On expiry it switches itself off and shows "Live share
ended". The session clock is RAM-only, so a reboot starts a new session.
- NodePrefs: loc_share_scope + loc_share_duration_idx appended, sentinel
0xC0DE002C -> 0xC0DE002E, sizeof unchanged (2824).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build_wasm.sh now takes FIRMWARE_VERSION (tag name for a release,
dev-<commit> otherwise) exported by build-solo-sim.yml, so the live demo
no longer falls back to "dev-<compile date>" on tagged releases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A DM/channel post sent from the phone app was mirrored into the device history
with the app's timestamp. A phone clock ahead of the device's (or on local
time) made "now - timestamp" negative, which fmtAgeShort clamps to 0, so the
age sat at "0s" until the device clock caught up.
MessageHistory::displayTimestamp(): outgoing entries use the device's own
clock (they were sent just now); incoming entries with an unknown or future
sender timestamp use receipt time. Sender timestamps still drive incoming
dedupe and resends (msg_ts is untouched); an on-air timestamp is unchanged.
Release notes: add this fix, and say "older than v1.27" for the prefs cleanup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
eed6d31d dropped two one-time migrations as "old cruft", but both are needed
by exactly the upgrade path this release ships: v1.27 has sentinel 0xC0DE002A
and the scope list is new in this cycle.
- loadScopeList() again turns an existing single default_scope_name/key into
list entry 1 (default) and MyMesh::begin() seeds the channels that already
exist with it. Without it an upgrader's DMs and channels silently go out
unscoped, contradicting the release note.
- loadPrefsInt() again zeroes repeat_extra_scope_mask/ch_scope_idx when the
file predates 0xC0DE002B, so the old sentinel tail can't read back as real
scope picks.
The older-than-v1.27 backfills stay removed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95e3cf26 raised LORA_TX_POWER to 30 for the GAT562 30S, but CustomSX1262 passed
it straight to RadioLib's begin(), whose setOutputPower() rejects anything over
22 dBm (ERR_INVALID_OUTPUT_POWER) -- so radio init would fail on that board.
With NUM_PA_POINTS/TX_GAIN_LORA defined, LORA_TX_POWER is a radiated power, not
a chip register value: init at register 0 and let RadioLibWrapper::setTxPower()
apply the real power through the curve right after (companion applyApc(),
repeater setTxPower at begin). Other boards are unchanged.
Verified by build (GAT562 30S companion + repeater, Heltec V3 repeater) and by
reading RadioLib's SX1262::checkOutputPower(); not tested on hardware.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The on-device "+ Add channel" flow (Public quick-add, Hashtag, Private) wrote
to the first free slot with no check, so a second Public -- or any channel
already present -- could be added, giving the same on-air channel two list
entries with split history/unread state. A channel's identity is its secret,
not its name, so ChannelsView now rejects a save whose 16-byte secret is held
by a different slot. Edit skips its own slot, so renaming still works. Public
quick-add says "Already added"; the forms say "Channel already exists" and stay
open. App-side CMD_SET_CHANNEL and already-saved duplicates are untouched.
Verified: sim_companion_radio builds; in the WASM sim, adding Public with
Public present shows the alert and leaves one entry.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Message bubbles are capped at the row width minus a gutter (2 char widths,
min 8px) so a long message no longer fills the whole row and loses the
left/right anchoring that shows who sent it. Body wrapping uses the capped
width. DM and channel history.
- Docs: shared scope list (Settings, Repeater Extra scopes, channel Scope row),
contact Expire / Prune now, history hop count / wrap / scope tag, external
PA TX curve build flags.
- release-notes.md: v1.28 gains GAT562 30S TX power, Heltec V3/V4 joystick
Enter fix, prefs-migration removal note, and the bubble gutter fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
With UI_HAS_JOYSTICK, user_btn (Enter) was left with pulldownup=false and
multiclick=true (the plain-PRG-button defaults), instead of matching the
other wired joystick contacts. Two effects on a wired Enter button:
- No internal pull-up on the Enter pin, unlike the direction/back contacts.
- multiclick=true buffers a click for ~280ms waiting for a possible double/
triple click, but UITask.cpp's UI_HAS_JOYSTICK loop only ever checks
user_btn for CLICK/LONG_PRESS — so a quick double-tap collapses into a
DOUBLE_CLICK event that's silently dropped, making Enter feel laggy and
unreliable.
wio-tracker-l1 (factory joystick) already builds user_btn with
pulldownup/multiclick matching its other contacts; apply the same pattern
here, conditional on UI_HAS_JOYSTICK so the stock external-pull-up PRG
button path (no joystick) is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
loadPrefsInt()'s "sentinel mismatch" block had accumulated ~62 lines of
one-shot backfills tied to specific old NodePrefs::SCHEMA_SENTINEL values
(favourites/map home-page bits, split bot-command toggles, trail_units_idx,
bot_trigger_ch seed, scope mask/index zeroing). Reduce it to just the
sentinel read + mismatch log line -- an upgrade from a version old enough
to hit any of these now lands on today's plain defaults for that field
instead of a silently-seeded value, same outcome a fresh device already
gets. The ongoing per-field range clamps (defensive against any corrupt or
out-of-range byte, not version-specific) are untouched.
Same treatment for DataStore::loadScopeList()'s one-time synthesis of a
scope-list entry from the old single default_scope_name/key field: removed,
along with the scope_migrated_legacy plumbing in MyMesh::begin() that only
ran once that migration had just fired. loadScopeList() drops its now-
unused NodePrefs parameter and bool return.
Also trimmed ~25 per-field comments that over-specified which exact old
sentinel produced which stray byte, down to what the clamp actually
defends against -- except where the hex value explained a genuinely
non-obvious byte-aliasing interaction, which stays. One comment (scope
mask/index) was flagged and rewritten because it described the very
zeroing logic this change removes.
Drive-by: two comments still said Settings > Sound > "Msg wake" -- it's
been under Settings > Display for a while, per the enum's section grouping
in SettingsScreen.h.
Verified: sim_companion_radio and WioTrackerL1_companion_solo_dual build
clean, RAM/Flash usage unchanged (pure logic removal, no schema/layout
change).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gat562_30s_mesh_kit has an always-on external PA rated at +30dBm, but the
firmware wrote tx_power_dbm straight to the SX1262 register (capped at 22),
so the app/CLI/UI never matched the real radiated power and couldn't ask
for more than 22 even though the hardware supports it.
Add a generic, macro-gated PA gain-curve resolution in
RadioLibWrapper::setTxPower() (the single choke point every TX-power path
already funnels through), reusable by any future board with a similar PA.
For this board, reuse the vendor-measured 869MHz curve from the open
upstream Meshtastic PR that added it for the same physical module
(meshtastic/firmware#11212): pick the lowest SX1262 setting whose measured
output reaches the requested dBm, clamped at the PA's saturation knee.
Bump LORA_TX_POWER/MAX_LORA_TX_POWER to 30 for this variant only -- the
other three GAT562 boards use the SX1262's own PA_BOOST (22dBm, no
external PA) and are unaffected.
Since a request below the PA's floor gain (14dBm) still radiates at that
floor, also make CommonCLICallbacks::setTxPower() return what was actually
applied, and have every setter (BLE CMD_SET_RADIO_TX_POWER, CLI "tx",
simple_repeater/room_server/sensor) persist and report that value instead
of the raw request -- so tx_power_dbm never silently disagrees with the
physical output in either direction.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Three v1.27 changes this cycle weren't reflected yet: the low-battery/
reboot contacts-persistence fix (matching the settings one already
documented), the Clock page's new Sats field, and the Path/Relayed by
popup's marquee glitch + slowness fix.
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>
- settings_screen.md: document Settings > Sound > Msg wake
- clock_screen.md: document the new Sats dashboard field
- screen_lock.md: mention the lock screen's title bar (PR #32)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
User report: a repeater path popup's short "?A1B2C3D4"-style hop tags
(often just a couple pixels over the box width) felt stuck rather than
scrolling, while longer names (Nodes list) already felt right after the
recent marqueeHoldMs() speedup.
Root cause: the swing marquee's hold applies uniformly regardless of how
much text is actually hidden. When only 1-2 codepoints overflow, the full
cycle is hold + a single, barely-there step + hold + step back -- nearly
all hold, for almost no new information revealed each side. New
marqueeHoldForSkip(skip_cp) scales the hold down (to 1/3 or 1/2) when the
computed reveal distance is small; longer overflows keep the full hold,
since there's real text worth pausing to read.
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>
release-notes.md was missing three shipped changes: the Msg-wake toggle +
all-home-pages default, the repeater advert-interval default (2min->6min),
and three Fixes (settings-persist-on-reboot, RTC bootstrap floor-not-
rollback, ESP32 diagnostics heap/stack).
marqueeHoldMs() reverted 1500/2500 -> 700/1200 (OLED/e-ink) -- the longer
hold made the swing-marquee's full reveal cycle too slow for long message
previews.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
Real SSD1306/SH1106 OLEDs this sim mirrors are monochrome white-on-black,
not amber -- swap the canvas fillStyle/strokeStyle from #ffb000 to #fff
everywhere the sim display driver blits lit pixels.
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