Commit Graph
4064 Commits
Author SHA1 Message Date
JakubandClaude Sonnet 5 d49acc4e55 fix(ui): age messages from the device clock, not the phone's timestamp
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>
v1.28
2026-09-19 13:19:46 +02:00
JakubandClaude Sonnet 5 dff56a3c53 fix(companion): restore the v1.27 -> v1.28 scope migrations
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>
2026-09-19 13:19:46 +02:00
JakubandClaude Sonnet 5 745aefcee1 fix(radio): init SX1262 at a safe power on boards with an external-PA curve
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>
2026-09-19 13:19:46 +02:00
JakubandClaude Sonnet 5 b3e3bae21b fix(ui): refuse adding a channel whose secret already exists
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>
2026-09-19 13:01:41 +02:00
JakubandClaude Sonnet 5 461107e31e fix(ui): keep a gutter beside chat bubbles; docs + release notes catch-up for v1.28
- 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>
2026-09-19 12:58:37 +02:00
JakubandClaude Sonnet 5 1981826a85 fix(heltec_v3/v4): user_btn needs pull-up + multiclick=false in joystick mode
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>
2026-09-18 09:57:29 +02:00
JakubandClaude Sonnet 5 eed6d31d42 refactor(companion): drop one-time prefs-migration cruft, fix stale Msg wake comments
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>
2026-09-17 15:41:26 +02:00
JakubandClaude Sonnet 5 95e3cf2618 feat(radio): honest TX power for GAT562 30S external PA
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>
2026-09-16 12:54:48 +02:00
JakubandClaude Opus 5 1d5dbe2f9a feat(companion): contact expiry + prune, and scope-list follow-up fixes
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>
2026-09-12 21:55:46 +02:00
JakubandClaude Sonnet 5 89c02eea27 feat(companion): shared named-scope list, per-channel/repeater popup pickers
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>
2026-09-12 00:05:40 +02:00
JakubandClaude Sonnet 5 7533c70ee2 fix(ui): message bubble color, received-message hop count, list wrap-around, age fallback; add sim WASM build workflow
- 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>
2026-09-11 12:30:42 +02:00
JakubandClaude Sonnet 5 64b79d00eb fix(ui): wrap-around at both ends of the DM/channel history list
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>
2026-09-10 21:31:31 +02:00
JakubandClaude Sonnet 5 aa73400414 fix(ui): community v1.27 feedback — GPS altitude field, noise floor, Msg wake location, units
- 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>
2026-09-10 20:12:29 +02:00
JakubandClaude Sonnet 5 6e0e981048 fix(companion): arm the "Relayed by" tracker for bot-originated channel posts
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>
2026-09-09 16:22:04 +02:00
Jakub 42afd180f9 Merge pull request #34 from 3urobeat/fix/message-channel-create-refresh
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.
2026-09-09 14:19:32 +02:00
Jakub 48cfdff1f3 Merge main into dev (README live-demo link) 2026-09-08 23:50:48 +02:00
JakubandClaude Sonnet 5 de2fceca7c fix(companion): stop the FIRMWARE_VERSION fallback claiming a fixed version
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>
2026-09-08 23:48:27 +02:00
JakubandClaude Sonnet 5 d72fed66c1 docs(readme): link the live browser demo at the top
Fills in the "Try it out live" placeholder that's sat empty since the
website went up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 23:48:20 +02:00
3urobeat 39f2d7edc9 fix: Fix creating message channels not rebuilding channel list immediately 2026-09-08 19:18:51 +02:00
JakubandClaude Sonnet 5 6426ef9319 docs(readme): add 3urobeat to contributors
PR #32 (lock screen title bar), merged into v1.27.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v1.27
2026-09-08 16:04:54 +02:00
JakubandClaude Sonnet 5 e9f590ad40 docs(release-notes): add contacts-flush fix, Sats field, popup marquee fix
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>
2026-09-08 16:03:37 +02:00
JakubandClaude Sonnet 5 e3279646f9 refactor(companion): unify every reboot/power-off through UITask::shutdown()
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>
2026-09-08 16:03:37 +02:00
JakubandClaude Sonnet 5 a868a60935 fix(companion): flush pending lazy contacts write on shutdown/power-off
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>
2026-09-08 13:27:45 +02:00
JakubandClaude Sonnet 5 23ccc06d1d fix(ui): stop the Path/Relayed by popup fighting its own row for marquee state
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>
2026-09-08 13:04:36 +02:00
Jakub 333d739103 Revert "fix(ui): marquee's hold no longer dominates a barely-overflowing label"
This reverts commit e300db7908.
2026-09-08 11:38:31 +02:00
JakubandClaude Sonnet 5 34c2792099 docs: catch up solo_features docs with this cycle's UI changes
- 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>
2026-09-08 11:35:19 +02:00
JakubandClaude Sonnet 5 e300db7908 fix(ui): marquee's hold no longer dominates a barely-overflowing label
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>
2026-09-08 11:12:23 +02:00
JakubandClaude Sonnet 5 57e029ccc4 feat(ui): add Sats dashboard field
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>
2026-09-08 11:04:14 +02:00
JakubandClaude Sonnet 5 dd87265d29 fix(ui): lock screen clock no longer overlaps its own title bar
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>
2026-09-08 10:49:40 +02:00
JakubandClaude Sonnet 5 5fd887d781 docs(release-notes): add PR #32 lock-screen title bar to v1.27
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:42:00 +02:00
JakubandClaude Sonnet 5 dd170874d2 Merge pull request #32 from 3urobeat/feat/lock-screen-page-type
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>
2026-09-08 10:41:18 +02:00
JakubandClaude Sonnet 5 1f41b3a1b8 docs(release-notes): catch up v1.27 with missing entries; tune(ui): faster marquee hold
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>
2026-09-08 10:12:58 +02:00
JakubandClaude Sonnet 5 531eec2cd3 fix(prefs): flush settings on every reboot/shutdown path, not just per-screen exit
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>
2026-09-08 08:33:46 +02:00
JakubandClaude Sonnet 5 00c3279fa6 feat(ui): message-wake toggle + all home pages visible by default
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>
2026-09-06 22:35:12 +02:00
3urobeat 4746a9a494 feat: Show title bar on lock screen
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.
2026-09-05 22:05:15 +02:00
3urobeat ec143f577a feat: Make align center in drawClockTime optional 2026-09-05 22:01:14 +02:00
3urobeat 376fcd6bdd refactor: Make screen lock a dedicated page
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
2026-09-05 20:40:13 +02:00
JakubandClaude Sonnet 5 a18a821326 feat(sim): host-page buzzer mute hook + randomized repeater names
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
2026-09-05 08:36:02 +02:00
JakubandClaude Sonnet 5 a54922e3d6 tune(repeater): default local advert interval 2min -> 6min
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
2026-09-04 20:53:48 +02:00
JakubandClaude Opus 5 c7d0cd23b6 fix(rtc): bootstrap from contacts is a floor, never a rollback
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
2026-09-04 20:34:36 +02:00
JakubandClaude Sonnet 5 cc01583d53 feat(sim): sim_radio_get_params export + fix identity-collision RNG bug
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
2026-09-04 19:12:36 +02:00
JakubandClaude Sonnet 5 80c375427a feat(sim): GPS/Sensors home pages + repeater instant self-advert
- 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
2026-09-03 23:19:36 +02:00
JakubandClaude Sonnet 5 f8ab4c3a98 fix(sim): white-on-black display palette instead of amber
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
2026-09-03 23:05:04 +02:00
JakubandClaude Sonnet 5 1eb088a0db fix(sim): add sim_test_disable_screen_timeout() -- no auto-off in the demo
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
2026-09-03 22:59:05 +02:00
JakubandClaude Sonnet 5 f10a1e7b4f fix(sim): add sim_test_set_timezone_hours() -- clock was showing UTC
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
2026-09-03 22:42:44 +02:00
JakubandClaude Sonnet 5 c3ce3f1606 fix(sim): add sim_stop_main_loop() -- host pages must stop old instances
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
2026-09-03 22:34:47 +02:00
JakubandClaude Sonnet 5 5fbfd7c5f9 fix(ui): HomeScreen::poll() re-fired shutdown() every tick after the first
_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
2026-09-03 22:20:55 +02:00
JakubandClaude Sonnet 5 718dbdbe74 fix(sim): live RTC restore + add sim_test_show_all_home_pages() hook
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
2026-09-03 22:17:08 +02:00
JakubandClaude Sonnet 5 3e7e9e349a fix(sim): skip blocking pre-shutdown buzzer wait in SIM_PLATFORM builds
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
2026-09-03 21:50:45 +02:00
JakubandClaude Sonnet 5 b2cf459460 fix(sim): text-width/render bugs, splash version, wasm-fetch error, battery lag
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>
2026-09-03 19:32:23 +02:00