Commit Graph

106 Commits

Author SHA1 Message Date
Jakub
0a61b7ef15 fix(bot,ui): defer bot actions past throttle, fix CardKB lock bypass, add !gps fix timeout arg
- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect
  immediately during botScanCommands(), before quiet-hours/cooldown/
  per-contact throttle were checked -- those gates only suppressed the
  reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply()
  now only records what was requested; applyPendingBotActions() runs the
  deferred effects once a wrapper's throttle checks pass and the ack sent,
  mirroring the existing _locfix_requested pattern. resetPendingBotActions()
  clears everything on every throttled/aborted path.

- CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate
  (it called into KeyboardWidget directly instead of through the
  enqueueKey()/dequeue path every other key uses, so it wasn't discarded
  while _locked). Now checks _locked itself.

- Since a locked device now correctly ignores CardKB entirely, Fn+Esc
  (single press) is added as CardKB's own lock/unlock gesture -- otherwise
  a CardKB-only setup had no way to unlock. Esc rather than the adjacent
  Fn+Backspace, to avoid an accidental press.

- botScanCommands() now parses up to two arguments per command instead of
  one. Used by "!gps fix [seconds]" to override the default 90s timeout
  (clamped 15-300s) for a poor sky view where 90s isn't always enough to
  reach isLocFixReady()'s HDOP/satellite bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 23:51:31 +02:00
Jakub
7b6ae8e2cb feat(gps): use HDOP for !gps fix readiness, satellite count as fallback
Satellite count alone is a poor proxy for fix quality -- few satellites
in good geometry can beat many in poor geometry. LocationProvider now
exposes getHDOP() (default -1 = unsupported); MicroNMEA implements it.
isLocFixReady() prefers HDOP <= 2.0 when available, falling back to the
old >=8 satellite threshold for providers that don't report it (e.g.
RAK12500/u-blox).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:49:17 +02:00
Jakub
9932fb01df fix(ui): late Admin login reply could be misrouted after giving up
Same fix as hotfix/admin-login-timeout (5a5ebe9f). UITask::onRoomLoginResult()
dispatches by whichever screen is currently shown, not by who sent the
request, so a reply arriving after AdminScreen gave up (Cancel or the
timeout fix) could land on MessagesScreen instead and persist its own
unrelated _login_pw as the "confirmed" password for that pubkey.
MyMesh::cancelUiPendingLogin(pub_key) stops tracking the request on
give-up so a late reply matches nothing instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:21:31 +02:00
Jakub
220d46de8e feat(bot): !gps fix -- single-shot GPS location
Turns GPS on (if it wasn't already), waits for a stabilised fix
(isValid() + >=8 satellites, then averages 10s of readings), sends the
position, and restores GPS to whatever state it was in before -- up to
a 90s timeout, after which it reports a partial fix (if it got any
samples) or plain failure.

Replies in two parts since a fix takes seconds-to-minutes, unlike every
other bot command here: an immediate "acquiring fix..." ack (through
the existing synchronous command path), then the actual position as a
separate follow-up message once ready, delivered to whichever
destination (DM/room/channel) the request came from. Only one fix can
be in flight at a time -- a second request while one is pending gets an
immediate "already pending" instead of silently replacing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:57:05 +02:00
Jakub
176094d32e fix(ui): Admin login could hang forever on "Logging in..."
AdminScreen's LOGIN phase had no timeout, unlike its COMMAND phase
(_cmd_deadline_ms). If a login reply never arrived -- most commonly a
saved password gone stale after the remote node's password changed,
silently dropped instead of nacked -- the screen stayed stuck with only
a manual Cancel to escape.

sendRoomLogin() now returns the same est_timeout sendAdminCommand()
already exposes; AdminScreen uses it to arm a deadline (poll(),
mirroring the COMMAND-phase pattern) that forgets the stale password
and returns to the picker on expiry, same as an explicit login
rejection already does.

Same fix as hotfix/admin-login-timeout (23f43cac), split out of this
branch's other in-progress work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:56:46 +02:00
Jakub
5bfebc6559 feat(bot): Actions commands, multi-trigger, and user GPIO pins
- Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target
  toggle nested under Commands (bot_actions_dm/ch/room); off by default.
- Bot Trigger fields accept comma-separated multiple phrases, matching any
  one fires the reply.
- New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot
  commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output;
  GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only
  Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the
  chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR
  NFCPINS bit-clear in initVariant() (adapted from Adafruit's own
  nfc_to_gpio example) -- confirmed working on real hardware.
- Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong
  layer, hiding the "To:" header on DM replies and leaking the raw prefix
  into room messages' list view; a related mismatch had the history
  scrollbar's sizing pass wrap room messages with the sender name still
  attached, disagreeing with the actual rendered text.

Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean
(sizeof(NodePrefs) confirmed 2720 via real build, not guessed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 20:30:03 +02:00
Jakub
273fbfde7a chore: pre-release cleanup pass (Lemon-era naming, bot sender parsing dedup)
Renamed the vestigial Lemon/default font-switch naming (setLemonFont/
isLemonFont/drawLemonChar/lemonXAdvance/_use_lemon -> setSingleFont/
isSingleFont/drawGlyph/glyphXAdvance/_single_font) across DisplayDriver.h
and both concrete drivers -- both have been permanently single-font for
several commits, so the old names invited a future reader to think a
real switch still existed. Pure identifier rename, no logic changed.

Also extracted the byte-identical "SenderName: " prefix-splitting in
MyMeshBot.h's tryBotReplyChannel()/tryBotChannelCommand() into a shared
botChannelSenderSplit(), mirroring the existing botRoomSenderName().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 21:02:33 +02:00
Jakub
453dc5e570 feat(admin): typed Radio/Routing field editors; pre-release audit fixes
Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string
field (free-text keyboard) into 4 independent, type-appropriate rows --
Frequency (same digit-cursor DigitEditor Settings/Repeater use locally),
Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping),
plus TX power and the 3 Routing numeric fields as number steppers and
Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per
keystroke); only Enter sends one combined `set`, Cancel sends nothing.

Full pre-release audit of all 16 commits since v1.22 (5 parallel focus
areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels,
UITask+NodePrefs schema) turned up and fixed:

- Admin: fetched FK_NUMBER values weren't clamped to the field's range,
  so a value already out-of-range could get stuck unreachable; fixed
  commit-time float format noise (%.6f -> %.3f); Cancel/failed-login
  always returned to the Nodes picker even when Admin was opened
  directly from a node's Hold-Enter action -- now returns to wherever
  it was actually opened from (AdminScreen::_from_picker).
- Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap,
  so cycling to the 2nd/3rd candidate always came out lowercase --
  fixed by caching the cycle's caps state (t9_caps).
- Messages: history is numbered newest-first, so a message arriving
  while scrolled up to an older one silently relabeled the view onto a
  different message -- selection now shifts with the insert.
- Channels: onChannelRemoved() didn't clear ch_notif_override/
  ch_notif_muted/ch_fav_bitmask despite its own contract comment
  requiring it (now far more reachable via the on-device Delete);
  an all-zero hex secret silently self-deleted the channel it was
  just saved into (collides with the empty-slot sentinel) -- rejected.
- Room login: isRoomLoggedIn() indexed the login-tracking ring
  directly instead of via its head offset, silently wrong once the
  ring wraps (8+ rooms/session) -- the new on-device Logout depends
  on this being right.
- Font/display: removed the now fully-inert Settings > Display > Font
  toggle and the dead LemonFont.h (retired by the earlier misc-fixed
  font unification, zero remaining includes); fixed a copy-pasted
  "5x7" comment (font is 6x9), two meaningless dead ternaries, and an
  OLED/e-ink inconsistency in the undefined-glyph fallback box offset.
- AdminField's `kind`/bounds fields no longer rely on default member
  initializers inside aggregate-init: this toolchain's actual nRF52
  build (unlike env:native) has no explicit -std= override, so it
  predates C++14's aggregate-with-default-member-initializer rule.
  Given an explicit constructor instead -- portable regardless of
  standard, all existing field-table literals unchanged.

Docs updated to match: tools_screen.md (Diagnostics as a 3-tab
carousel, was documented as one flat screen), message_screen.md
(chat bubbles, newest-at-bottom, cursor mode, secret validation),
settings_screen.md (dropped the dead Font row), solo_ui_framework.md
(header menu_hint signatures, icon priority-drop, KeyboardWidget's
T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section
covering all of the above plus the other 15 commits since v1.22.

Build-verified on WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
Jakub
f399298fa6 feat(ui): on-device channel management, remote admin tool, per-language keyboards
Messages:
- Add/edit/delete channels on-device (new ChannelsView, owned by the renamed
  MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope).
  Channel secret entry supports a typed passphrase (SHA-256'd, same primitive
  the library already uses for the routing hash) or a raw 32-hex-char key.
- MyMesh::setChannelLocal() factors out the setChannel/saveChannels/
  onChannelRemoved sequence previously duplicated across the two
  CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths.

Tools > Admin (new):
- Log into a repeater/room server's admin account and send CLI commands,
  the on-device equivalent of the app's repeater-admin feature.
- Commands are organised into category tabs (System/Radio/Routing/Actions)
  with common get/set fields (name, radio profile, tx power, repeat, advert
  intervals, ...) plus a free-text "Custom command..." fallback for anything
  else. A field row fetches the current value, opens it pre-filled for
  editing, and sends the change — falling back to a blank editor if the
  fetch fails or times out.
- The admin password persists and self-heals exactly like room logins in
  Messages: saved on a confirmed admin-level login, forgotten on a failed
  one, left alone if merely under-privileged.
- New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so
  a reply reaches the UI without touching the existing BLE/app CLI-terminal
  path (queueMessage's should_display gate is untouched).

Shared TabBar.h extracted from NearbyScreen/BotScreen's independently
duplicated tab-carousel rendering (now a third consumer via Admin) — also
fixes neighbouring tabs vanishing outright when they didn't fully fit;
they now truncate with an ellipsis instead.

Keyboard: the combined "Ext.Latin" alphabet split into 8 separate,
linguistically complete per-language keyboards (Polish, Czech, Slovak,
German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug
where tall accented glyphs overlapped the keyboard's separator line
(SH1106's Lemon-font ascent constant was 2-3px short for them).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
Jakub
afd7c0ee78 feat(bot): room-server target, tab-carousel UI, per-target independence
Auto-Reply Bot gains a third target (room servers, alongside DM and
channel), new {name}/{hops} reply placeholders, and a DM all/favourites
allow-list. BotScreen is redesigned as a circular tab carousel (Direct /
Channel / Room / Other, same interaction as Nearby Nodes' filter tabs)
instead of one long scrolling list, which also exposed and fixed a
leftover coupling where Channel/Room trigger-replies and their !command
handling secretly depended on the DM tab's Enable/Commands toggles —
each target's Enable and Commands are now fully independent.

- NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope,
  bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as
  DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged
  at 2712 (new bytes absorbed existing padding, verified via a
  standalone host compile + offsetof check).
- DataStore: persists all new fields; seeds bot_commands_ch/room from
  the old shared bot_commands_enabled on upgrade so existing
  channel/room command behaviour isn't silently lost.
- MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's
  shape but post via sendMessage (room relays to members itself);
  requires an existing login session with that room, same as a manual
  post would. botDmSenderAllowed() gates DM trigger-reply/commands on
  the favourites bit when bot_dm_scope=Fav.
- MsgExpand: {name}/{hops} as optional trailing params (default
  nullptr/-1, no existing caller affected) — deliberately not exposed
  on the general compose keyboard, only on bot Reply fields.
- QuickMsgScreen/UITask: room-target picker (mirrors the channel
  picker), routing through the existing room-login prompt when there's
  no saved password yet.
- BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves
  rows, Enter is now the only way to change a value); Enable split out
  of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode;
  Commands moved from a shared toggle into each tab.
- docs/tools_screen.md updated for the new tab layout and behaviour.

Not build-verified — no PlatformIO toolchain in this environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
Jakub
cf3c2c0353 fix(ui): revert corner-anchored context menus, centre unread badge, add room logout
- Context menus (PopupMenu, Nearby/QuickMsg call sites) go back to centring on
  screen; the header's discoverable-menu glyph stays, but dropping the popup
  out of its corner looked bad on some screens.
- Unread pill badge digit wasn't centred: Adafruit_GFX's classic built-in font
  (SH1106/SSD1306) always pads a measured string by one trailing advance
  column regardless of the glyph drawn, so centring on the raw width left 1px
  more slack on the right than the left. New DisplayDriver::
  textWidthTrailingGap() (0 by default) corrects for it on those two backends.
- On-device room Logout: mirrors the app's CMD_LOGOUT (drops keep-alive
  tracking, forgets the saved password) so a room can be deliberately signed
  out of from the Room options menu, not just re-logged-in.

Not build-verified — no PlatformIO toolchain available this session.
2026-07-10 16:01:14 +02:00
Jakub
97493b490e feat(nodes): on-device add/delete/favourite + circular filter tabs, rename to "Nodes"
Turn Nearby Nodes into the single node hub and make its navigation legible.

Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
  now reuses deleteContactByKey() (one delete path: contact + blob + room password
  + UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
  Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
  defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.

Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.

Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.

Rename Tools > "Nearby Nodes" to "Nodes".

Both solo envs build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:52:51 +02:00
Jakub
399b61e2bb fix(companion): dedup node-discover responses heard more than once
A discover response often arrives twice — the zero-hop direct copy and a
re-flooded copy relayed by another repeater carry different packet hashes,
so the mesh duplicate filter passes both. The standalone scan appended the
same node twice and app-driven discover forwarded both copies, so one
repeater showed up as two.

Track (tag, responder pubkey) for a short window in isDupDiscoverResp() and
drop the second copy in both the standalone and app-forward paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:00:52 +02:00
MarekZegare4
c81b95b8b9 feat(companion): persist app/USB-entered room passwords on device
The on-device login path already saved room passwords to /room_pw; do the
same for logins issued by the phone/USB app (CMD_SEND_LOGIN). The password
is stashed when the login is sent and persisted once the server confirms,
gated to ADV_TYPE_ROOM contacts; a failed login forgets any stale saved
password, mirroring the on-device path. This lets the device post to a
room standalone after a reboot without re-prompting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:34:08 +02:00
MarekZegare4
be9f3db8c6 feat(companion): on-device room login with saved passwords
Log in to a room server from the device UI with no phone app: picking a
room prompts for its password (blank allowed for open rooms), and a
context-menu "Login..." allows re-login. Successful passwords are
persisted to a dedicated /room_pw file so a previously-used room logs
back in after reboot without retyping; a failed login forgets the
(now-stale) saved password so the next attempt prompts again.

The room-password file is written via a temp file + atomic rename
(new DataStore::commitFile helper), matching the crash-safety of
contacts/channels persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:55:24 +02:00
MarekZegare4
4369a0cc74 fix(repeater): validate profile against chip freq bounds; fix digit-editor padding
isValidRepeaterProfile() hard-coded the 150-960 MHz SX1262 range, which
would wrongly reject legal frequencies on any other chip. Take the freq
bounds as parameters and pass radio_driver.getFreqBounds() at both call
sites (repeaterProfileValid() and the load-time migration), so the chip's
own validated range is the single source of truth. DataStore.cpp gains a
target.h include for radio_driver (declared extern there).

DigitEditor::render() now zero-pads the integer part to int_digits: the
cursor addresses place values (100/10/1/0.1…), so a value with fewer
integer digits than int_digits would shift every glyph and misplace the
highlight. No visual change for the frequency field (always 3 digits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:09:27 +02:00
MarekZegare4
52e65b960f docs(repeater): rename "politeness" -> "forwarding filters", fix stale refs
Rename the repeater filter knobs from "politeness" to "forwarding
filters" across code comments, FEATURES.md and the docs, and correct
references that hadn't followed the controls when they moved from
Settings › Radio to the dedicated Tools › Repeater screen.

Comment/doc accuracy fixes:
- NodePrefs: user_radio_presets are now written by both Settings and
  Repeater (shared picker), not Settings only.
- DiagnosticsScreen: class header listed only some rows; spell out the
  full set (forwarded, signal, pool/queue, error flags, reset gesture).
- RepeaterScreen header no longer claims to show live forwarding stats
  (they live on Diagnostics).
- tools_screen.md: drop the stale paragraph claiming the Repeater screen
  shows live stats at the bottom — it is config-only.

Comments/docs only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:32:59 +02:00
Jakub
32c50d1cfe feat(repeater): politeness controls, dedicated radio profile, and diagnostics
Settings > Radio gains a repeater toggle, 16 community-suggested radio
presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor),
4 persisted user preset slots, and the packet pool bumped 16->32 so
queued retransmits stop starving incoming-packet allocation while
relaying.

Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR),
all flood-only and off by default, plus overhear suppression that
cancels a queued retransmit if a peer relays the same packet first.
Adaptive Power Control is suppressed while relaying (pins TX power to
the ceiling) and duty-cycle RX is forced off, since a repeater needs
to hear and relay at consistent power.

A dedicated Tools > Repeater screen consolidates the toggle, the
politeness knobs, and live forwarding stats, plus an optional "Custom"
radio profile — a dedicated frequency/SF/BW/CR for relaying, separate
from the companion's own network, band-matched to the companion
frequency by default and used everywhere a radio change can happen
(boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings
radio edits) so the device never silently falls back to the wrong
params mid-relay.

Diagnostics gains the actually-forwarded packet count, real heap-free
via mallinfo(), a reset-counters popup, and hardened loop-detect
bounds. Also: a frequency-floor fix and a float-equality preset-match
fix in the repeater profile logic, deduped BW-table/valCol/profile-
seeding helpers shared between Settings and the Repeater screen, and
a build.sh fix tolerating control characters in `pio project config`'s
JSON dump.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
MarekZegare4
8bd6fbf1cb feat(bot): hardening + auto-responder, query commands, quiet hours
Reply-bot overhaul on top of the trigger/reply auto-reply:

Robustness
- single botTriggerMatches() shared by DM/channel paths; named constants
  (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers.
- per-contact DM throttle (8-entry ring) so a second sender isn't starved
  while one contact is on cooldown.
- channel anti-loop: skip only when an incoming message equals our own reply
  (was: any reply containing the trigger word — which silently killed channel
  replies for the common "trigger word in reply" setup).

Features
- away / reply-to-all: a lone "*" trigger matches every message.
- independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works
  on the channel too, bounded by cooldown + echo guard.
- query commands: a DM or monitored-channel message is scanned for "!word"
  tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one
  combined reply. DM = per-contact throttle, ignores quiet hours; channel =
  broadcast, per-channel cooldown, respects quiet hours. !hops uses
  getPathHashCount() (0 = direct).
- quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies.
- reply counter shown in the BotScreen header.

UI / storage
- BotScreen: 9 rows, scrolling list (no more cramming), field I/O via
  fieldBuf()/fieldCap().
- prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch
  persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration
  (bot_trigger_ch seeded from bot_trigger on upgrade).
- docs: tools_screen bot section rewritten; FEATURES.md refreshed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
MarekZegare4
bcf8774962 feat(ui): message delivery status (DM ACK + channel relay)
End-to-end delivery indicators on the device UI, drawn next to outgoing
messages and auto-scaled to the font (legible on landscape e-ink).

DM (and room servers, which share the DM path):
- Pending / delivered / failed marker driven by the real end-to-end ACK.
  Pending shows a row of dots — one per send — so auto-resend progress is
  visible before it resolves to ✓ / ✗.
- Auto-resend: new pref dm_resend_count (0-5, default 2), Settings ›
  Messages › Resend. A pending DM whose ACK times out is re-sent (reusing
  the original timestamp) until resends run out, then ✗. Driven from
  UITask::loop so it completes in the background, independent of screen.
- Incoming dedup: a retry reuses the sender timestamp + text but carries a
  fresh packet hash, so addDMMsg drops copies matching prefix+ts+text.

Channels (flood, no recipient ACK):
- ✓ only once a repeater echo confirms the send was relayed into the mesh;
  no echo is normal, not a failure (no pending/fail shown). A small ring
  tracks a burst of sends so each matches its echo. Receive-path hashing is
  gated so the hot flood path is untouched when idle.

Shared:
- Markers shown in both the history list and the fullscreen message view.
- Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale);
  adding a new status icon is a bitmap plus one draw call.

No changes to the upstream mesh library (src/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:33:16 +02:00
Jakub
5b58049139 feat(power): battery saving — hardware duty-cycle RX + adaptive TX power
Two independent, default-off toggles under Settings › Radio.

Pwr save: hardware RX duty-cycle (SX126x SetRxDutyCycle via
startReceiveDutyCycleAuto). The chip cycles RX↔sleep and wakes on a preamble —
no MCU state machine; recvRaw reads the packet exactly as in continuous RX.
Falls back to continuous RX on non-SX126x. (Replaces an earlier software-CAD
state machine that fought the hardware: polling a warm-sleeping chip gave a
phantom-busy channel that stalled TX ~4 s and dropped ACKs in the scan gaps.)

Auto pwr: Adaptive Power Control. tx_power_dbm becomes a ceiling; actual TX
power tracks the reverse-link SNR margin (measured above the per-SF demod floor,
EWMA-smoothed, proportional step with a deadband). Feedback comes from direct /
room-server ACKs and, for channels (no ACK), from hearing a repeater rebroadcast
our own flood; a lost confirmation ramps power back up so channel sends can't get
stranded below what the repeaters can hear.

Prefs schema 0xC0DE0009 (rx_powersave, tx_apc). Radio page / name bar show the
live TX power; noise floor reads n/a while duty-cycling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:15:42 +02:00
Jakub
2d6d35192b refactor(BaseChatMesh): extract onDiscoveredAdvert to keep upstream signature
Reverts onDiscoveredContact to the upstream 4-param signature, adding a
separate virtual onDiscoveredAdvert(bool was_flood) with a default no-op
implementation. Only MyMesh overrides the new virtual; simple_secure_chat
and any future implementors need no changes on upstream merges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:37:22 +02:00
vanous
8e1352b24a Settings - Sound: Add Advert scope 2026-06-07 10:53:17 +02:00
Jakub
f5b3213f80 Merge upstream/main (v1.16.0) into wio-unified
Merge 254 upstream commits. Highlights relevant to the Wio fork: native
NRF52 companion power-saving (sleep when !hasPendingWork), preamble 16->32
for SF<9, generic sensor-registration model, CMD_SEND_RAW_PACKET, isEink()
display abstraction, KEEP_DISPLAY_ON_USB, contacts-sync/transient fixes.

Conflicts resolved (11 files):
- buzzer.h/.cpp: keep NRF52 include guard + our begin()->startup(); fold in
  upstream doc comment.
- platformio (wio + eink): keep our slim env layout, add upstream kiss_modem
  env (+ debug_tool=stlink on eink ble).
- GxEPDDisplay.h: take upstream isEink() override.
- EnvironmentSensorManager.h: take upstream's wider #if guard.
- DataStore.cpp: take upstream saveContacts(filter) signature, keep our
  ::openWrite (member openWrite would shadow the global 2-arg overload).
- main.cpp: keep our watchdog init + add board.onBootComplete(); adopt
  upstream's `if(!hasPendingWork()) sleep` loop.
- MyMesh.h: version -> v1.16-solo.0, build date 6 Jun 2026.
- MyMesh.cpp: keep both CMD codes (SCREENSHOT 66 + RAW_PACKET 65), both field
  inits, both handlers, our screenshot fns + upstream saveContacts/save_filter,
  upstream hasPendingWork() + our MyMeshBot include.
- UITask.cpp: keep our solo splash/clock page/snprintf/buzzer init; merge
  auto-off (+ opt-in KEEP_DISPLAY_ON_USB); merge low-batt shutdown (our EMA +
  prefs threshold + upstream's !isExternalPowered() guard and eink-aware delay).

Post-merge drift fixes (upstream API/architecture changes, not conflicts):
- applyTxPower(): radio_set_tx_power() removed upstream -> radio_driver.setTxPower().
- Sensor refactor dropped the per-sensor *_initialized flags our
  getAvailableLPPTypes() relied on; removed that override and the SENSORS page
  now derives available LPP types from the populated sensors_lpp buffer.

README kept ours via .gitattributes merge=ours. Builds verified on all 5 Wio
envs (dual, eink dual, radio_ble, dual_dev, eink_dual_dev). Not yet
hardware-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:21:41 +02:00
Scott Powell
8c0d5c5b24 * version 1.16.0 2026-06-06 21:09:38 +10:00
Scott Powell
d9df8307ca * first draft 2026-06-01 16:49:31 +10:00
vanous
8c3622b879 Nearby Nodes: Add Ping 2026-05-30 16:32:46 +02:00
Jakub
c7009140ce refactor: rename firmware from Plus to Solo
- FIRMWARE_VERSION: v1.15-plus.1 → v1.15-solo.1
- FIRMWARE_PLUS_BUILD → FIRMWARE_SOLO_BUILD in all platformio.ini and UITask.cpp
- docs/plus_features/ → docs/solo_features/ (all sub-docs moved)
- README: update all doc links plus new Documentation section
- GitHub Actions: extract GIT_TAG_VERSION in each build job (was missing,
  causing empty FIRMWARE_VERSION in builds); rename output files to
  solo-VERSION-oled.uf2 / solo-VERSION-eink.uf2; release title → "Solo VERSION"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:25:34 +02:00
Jakub
5dd035d309 fix(screenshot): drop C-style cast UB, fix ERR check order
handleScreenshotRequest() reinterpret-cast every DisplayDriver* first to
SH1106Display* then to SSD1306Display*; both casts always produce a
non-null pointer, so the SSD1306 fallback was unreachable and on any
SSD1306 build we called SH1106::getBuffer() on a SSD1306 object — UB
that only happened to work because both backing classes share an
Adafruit_GFX layout at offset zero.

Replace the cast hack with virtual getBuffer()/getBufferSize() on
DisplayDriver, overridden in SH1106Display and SSD1306Display. MyMesh
no longer needs to know about either concrete type. const-correct the
buffer pointer and widen bufferSize to uint16_t while passing through.

Also fix the matching Python decoder: it sanity-checked length before
dispatching on resp_code, so a 2-byte RESP_CODE_ERR frame was reported
as "Frame too short" instead of the actual device error. Move the
length check after the resp_code dispatch and log the error code byte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:58:26 +02:00
vanous
7fc8b4b5a5 Screenshot tool and a build flag for conditional screenshot functionality:
- Adds the possibility to capture the device screen and save it as a PNG
  image
- Wrap the code behind ENABLE_SCREENSHOT build flag, as per instructions
  in README
2026-05-27 22:08:28 +02:00
Jakub
2671e3f764 feat(ui): Trail phase 4 — Save/Load to flash + GPX export over USB
Single-slot persistence as agreed. Action popup grows three entries
(conditional on what makes sense for the current state):

- "Save trail"  — writes the current RAM ring to /trail in LittleFS.
- "Load trail"  — only shown when /trail exists; replaces the live ring
                  with the snapshot (any active session ends first).
- "Export GPX"  — dumps the trail as a minimal GPX 1.1 document over
                  Serial so the user can capture it from a USB host.

Plumbing:
- TrailStore gains writeTo / readFrom (template on the file handle, so
  the platform-specific File flavour stays out of Trail.h) plus
  exportGpx(Stream&) for the GPX serializer. Header carries magic
  "TRAL", a version byte, point count, and the accumulated active time
  so elapsed survives the round trip.
- DataStore::openWrite is exposed publicly so callers outside DataStore
  don't have to duplicate the platform open-mode dance.
- MyMesh gains a public getDataStore() accessor.

After Load, _pending_seg_break is set so any subsequent Start opens a
new segment instead of joining the loaded last point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:49:17 +02:00
Kevin Le
280213d41f Fixed to put hasPendingWork out of ENV_INCLUDE_GPS 2026-05-22 10:58:21 +07:00
Kevin Le
6b1099161c Added Power Saving for NRF52 companions 2026-05-22 10:58:17 +07:00
Jakub
52d97ed314 feat: show pub_key as base64 in discover detail screen
Replaces "Type: Repeater" line with the node's full public key encoded
as base64 (44 chars). drawTextEllipsized clips it with ... to fit the
128px wide display. Type is already visible in the inverted header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:11:32 +02:00
Jakub
2da37b3af5 feat: 2-line boxed discover entries with RSSI, SNR, remote SNR
Each discovered node now renders as a boxed 2-line card (same style as
message history):
  ┌──────────────────────────────┐
  │ NodeName (or [Sensor])  Type │  ← inverted header
  │ RSSI:-87 SNR:7 Rem:5         │  ← signal stats
  └──────────────────────────────┘

Add snr_x4 and remote_snr_x4 to DiscoverResult:
  snr_x4       = _radio->getLastSNR()*4  (how well we heard the response)
  remote_snr_x4 = payload[1]             (how well responder heard our request)

2 items visible at a time; UP/DOWN scroll through results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:56:54 +02:00
Jakub
2a9874d05d feat: show RSSI in discover results list
Add rssi field to DiscoverResult, captured from _radio->getLastRSSI()
when the discover response is received.

Display format in the right column:
  known node  → "Rpt-87" / "Snsr-92" (type + dBm)
  new node    → "*-87" (asterisk + dBm; type already visible in "[Rpt]" label)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:51:43 +02:00
Jakub
d851043059 feat: add dedicated discover sub-screen to NearbyScreen
Replaces the inline advert scan in the nearby list with a proper
CTL_TYPE_NODE_DISCOVER_RESP-based sub-screen accessible via the context menu.

- DiscoveredEntry → DiscoverResult: adds name[32] and is_known flag so
  both known contacts and unknown nodes are shown with useful labels
- getDiscoveredNodes → getDiscoverResults, capacity 8 → 16
- onControlDataRecv populates name from contacts for known nodes and
  records all respondents (not just unknowns)
- NearbyScreen gains _discover_mode sub-screen: 8-second scan window,
  live result polling each render cycle, UP/DOWN scroll, CANCEL to
  return, ENTER/M to re-scan; known nodes show actual name + "known",
  unknown nodes show "[Type]" + "NEW"
- List view is clean: no discovered nodes mixed in, no scanning banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 07:57:21 +02:00
Jakub
9cabcd5ac7 feat: replace advert scan with active node discovery in NearbyScreen
NearbyScreen now sends CTL_TYPE_NODE_DISCOVER_REQ (same protocol as the
companion app) instead of a plain self-advert. Nearby repeaters, sensors
and room servers respond with their pub_key and type. Known contacts get
their lastmod refreshed; unknown nodes appear as temporary entries (e.g.
"Repeater") in the list until their advert is received.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:16:53 +02:00
Jakub
5d41812784 fix: separate DM and channel bot reply cooldown timers
Single shared _bot_last_reply_ms caused a DM reply to block the
channel bot for 10s and vice versa. Split into _bot_last_dm_reply_ms
and _bot_last_ch_reply_ms so each cooldown is independent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:26:31 +02:00
Jakub
083ddac85c refactor: remove ping from NearbyScreen, rely on auto-advert for presence
Ping sent empty messages appearing in target's chat history, and RTT
measurement never worked for direct sendMessage() calls. Removed all
ping state machinery and registerExpectedAck(); detail view now shows
node info only (lat/lon/dist/bearing/type).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:17:52 +02:00
Jakub
09ac455806 feat: add periodic auto-advert with GPS position (Auto-Advert tool)
Adds configurable periodic 0-hop self-advert that includes GPS coordinates,
making the device's position visible in other nodes' Nearby Nodes screen.
Options: off / 1min / 2min / 5min / 10min / 30min / 1h.

Accessible via Tools → Auto-Advert. Timer runs in MyMesh::loop().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:08:33 +02:00
Jakub
d59687647f fix: wire NearbyScreen ping through expected_ack_table so RTT is measurable
Direct sendMessage() calls bypass the BT serial handler that populates
expected_ack_table; add registerExpectedAck() to MyMesh and call it from
NearbyScreen so isAckPending() can actually track the outstanding ping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:57:00 +02:00
Jakub
9e5c0e79e7 fix: move EXPECTED_ACK_TABLE_SIZE define before class for isAckPending
The #define was inside the private section, after isAckPending() which
references it — preprocessor hadn't seen the symbol yet at that point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:30:43 +02:00
Jakub
24474f67fa feat: NearbyScreen, configurable clock dashboard, memory optimizations
- Add NearbyScreen: GPS contact list with haversine distance sort,
  type filter (ALL/Chat/Rpt/Room/Snsr), detail view with ping (RTT via
  ACK poll), node scan via advert(), context menu
- Add DashboardConfigScreen: configure up to 3 data fields on the clock
  page (Battery, Temp, Humidity, Pressure, GPS, Altitude, Lux, CO2,
  Contacts); entered via long-press ENTER on clock page
- Rework HP_CLOCK layout: full-screen clock (size 2 at y=0), date at
  y=19, separator at y=28, three data field rows at y=31/41/51;
  hide node name/battery indicator on clock page
- Persist dashboard_fields[3] in NodePrefs and DataStore
- Add isAckPending() to MyMesh for NearbyScreen ping detection
- Wire NearbyScreen into ToolsScreen and UITask
- Reduce NearbyScreen entry buffer from MAX_CONTACTS(100) to 32,
  saving ~3.3 KB RAM
- Switch haversine/bearing math from double to float (sinf/cosf/atan2f),
  eliminating double libm and saving ~5-10 KB flash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:28:36 +02:00
Jakub
f6e251cfe3 chore: adopt vX.Y-plus.N versioning scheme
vX.Y tracks upstream major.minor; plus.N is fork-specific revision.
Bump to v1.15-plus.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00
Jakub
dd2c09174f Extract bot logic from MyMesh into MyMeshBot.h
Move tryBotReplyDM() and tryBotReplyChannel() implementations to
MyMeshBot.h (included at end of MyMesh.cpp). onMessageRecv and
onChannelMessageRecv now contain only a single-line call each,
minimising the diff against upstream in those methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00
Jakub
dcccc9733b Bot: global keyboard in BotScreen, placeholder expansion via MsgExpand.h
- Replace BotScreen's custom embedded keyboard with the shared global
  keyboard layout (KB_* constants): char rows at KB_CHARS_Y, special row
  at KB_SPECIAL_Y=48 with 5 buttons ([^][Sp][Del][{}][OK]), fixing the
  previous layout that rendered below the 64 px display boundary.
  Proportional column scaling between char rows (10 cols) and special row
  (5 cols). [{}] opens the placeholder picker overlay.

- Add MsgExpand.h: standalone inline expandMsg() with no framework
  dependencies, expanding {loc} and {time} placeholders. Used by both
  MyMesh (bot channel/DM replies) and QuickMsgScreen, replacing the
  duplicate expandBotMsg() static and the inline expandMsg() method.

- Bump FIRMWARE_VERSION to v1.15.3.

- ToolsScreen: remove key-hint footer line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00
Jakub
58afc23df3 Add Auto-Reply Bot and Tools screen to home carousel
- New 'Tools' page on the home screen carousel with Ringtone Editor and Auto-Reply Bot items
- Bot settings: enable/disable toggle, channel picker, trigger phrase, reply text (keyboard with pre-fill)
- Bot logic in MyMesh: case-insensitive substring match, 10 s cooldown to prevent reply loops
- Settings: Home Pages visibility control (which carousel pages are shown; Settings and Messages always visible)
- DataStore: persist home_pages_mask and bot fields with backward-compat guards on read

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00
Jakub
aa99b77de4 WioTrackerL1 UI v1.15.1: bugfixes and UX polish
- Fix placeholder picker colors: dark box + white border, light fill
  for selected item (consistent with rest of UI)
- Fix placeholders not expanded when sending from keyboard (expandMsg
  was only called in MSG_PICK path, not KEYBOARD path)
- Fix sent messages incrementing unread counter: set _phase=CHANNEL_HIST
  before addChannelMsg so viewing=true
- Fix keyboard column inverse mapping on UP from special row
- Remove old_row dead code from QuickMsgScreen keyboard handler
- Alert banner shown only on home screen, suppressed in settings/messages
- Bump FIRMWARE_VERSION to v1.15.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00
Jakub
1fe5922c79 Add RTC time persistence to flash
Save last known unix timestamp to /rtc_save on shutdown and restore it
on startup, so the clock shows correct time immediately after restart
without waiting for GPS fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:47 +02:00