Long-press a channel post -> Block sender: resolve the claimed name to known
pubkey(s) and block each (full block across DM+adverts+all channels, multi-device
covered); if unresolved, block the name globally across ALL channels (promotes to
pubkey later, #174). Adds block_* l10n strings. A5 channel surface;
contacts/chat/discovery + unblock still to come.
Epic A #165 / A5 #172. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Connector: resolveContactKeysByName(name) maps an anonymous channel sender
(name-only) to matching contact/discovered pubkeys (self-heals via adverts
keeping contact names current).
- channel_chat_screen: hide a post only when the name resolves and EVERY
matching pubkey is blocked (ambiguous namesake with any unblocked match still
shown), or the name is in blockedNames. View-layer filter only.
Epic A #165 / A3 #170. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Connector: BlockService injected via initialize(); incoming DM from a blocked
key dropped (no thread/unread/notify), advert notifications gated at all 3
sites (advert still processed for name self-heal). App-push-layer only.
- Contacts + Discovery: blocked entries stay VISIBLE with a red block icon
(BlockedBadge) + strikethrough/muted name (not hidden).
- New lib/widgets/blocked_badge.dart.
Epic A #165 / A2 #169. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App-global (NOT per-device) block list, source of truth for the client:
- BlockStore persists blockedKeys (pubkey hex) + blockedNames (name->first-seen
epoch, for A7 promote-and-prune) via PrefsManager, global keys.
- BlockService (ChangeNotifier): isBlocked/isNameBlocked/block/unblock/
blockName/unblockName + unmodifiable accessors.
- Wired into main.dart MultiProvider.
Epic A #165 / A1 #168. Design: docs/architecture/block-contract-as-built.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror every queue-drain decision -- requested, deferred (which guard),
asking radio, message received, no-more, timeout/give-up, post-channel
proactive fire -- into the App Debug Log via _logQueueSync, so a missed or
stuck drain on reconnect is diagnosable from the device.
Diagnostic only; the targeted fix follows once the log shows the failure.
Refs #51
Reply-shape [0xC2][sub][ok] (result byte, not ack/err frame); generic error
frame [0x01][0x06] is NOT 0xC2-prefixed. Overflow at MAX_BLOCKED_KEYS=32 returns
ok=0 (not error) + already-stored returns ok=1 (dedup short-circuit). BLOCK_LIST
truncation detect via START count vs key-frames -> re-request on APP_START
early-END; never derive removals from LIST. DM-drop is silent (no notif/frame) ->
no firmware block counts for UX. No unsolicited pushes. Design confirmed by
PearlMeadow; app half locked to shipped firmware, no open discrepancies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Align to firmware PR #247 (merged): cap OFFBAND_CAP_BLOCK=0x02, FIRMWARE_VER_CODE
15, 0xC2 CMD_OFFBAND_BLOCK (ADD/REMOVE/LIST/CLEAR), MAX_BLOCKED_KEYS=32, BLOCK_LIST
streamed-dump framing (0xFF count / index / 0xFE end) + APP_START early-END.
Switch sync model to union / always-retain-local (app-local never wiped; portable
across Offband clients, local-only on non-Offband). Global (by-pubkey) app store.
Fold in channel all-matching-pubkeys rule, dual name-key index sourcing (0x8A vs
0x80), promote-and-prune age-out, gaps G1/L1/L2/L3, interop invariant.
Feature #164 / Epic A #165 / Epic B #166; firmware doc #244.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per meshcore-firmware/docs/trace-path-format.md: a TRACE route must exclude
the origin (implicit at both ends). If route[0] is our own node's prefix it
matches no forwarding node (we are the sender) -> the packet never advances
-> RESP_CODE_SENT then a silent timeout (the doc's #1, most-common cause).
buildPath now drops a leading self hop before assembling the round trip.
Bumps build to +51 for the b51 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Debug Log showed every failing trace sending an empty path ->
"Firmware responded with error code: 1". Cause: repeaters with a flood
route (path stored as all-zeros) were treated as routed; buildPath
collapsed the all-zero path to empty; the firmware rejects an empty trace.
- buildPath: an empty OR all-zero path now traces the target's own width
prefix (never returns an empty payload).
- _doPathTrace: guard - never send an empty path; surface "not available".
- Repeater menu: constant "Path Trace" label (no more Ping/Path-Trace
flip-flop); a flood path (all-zero) counts as no route -> direct ping.
Bumps build to +50 for the b50 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The map "Current path" panel joined _pathTrace byte-by-byte, so once the
builder started capturing the full configured-width prefix per tap (#150),
a single 2-byte node read as "84,f4" — looking like two hops. Format via
PathHelper.formatPathHex at the configured width so it reads "84F4".
Bumps build to +49 for the b49 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repeater contact menu hardcoded "Ping" and always sent a target-only
(direct) trace, so a multi-hop repeater offered a "Ping" that could never
return. Mirror the room behavior: a repeater with a known route is
labelled "Path Trace" and traced along that route (round-trip); only a
direct neighbour (no path) shows "Ping" and pings directly.
Bumps build to +48 for the b48 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trace was width-1 end to end (flags=0, a 1-byte path, and a
1-byte-per-hop parse + render), so on a 2-/3-byte path-hash mesh it
returned 1-byte hops (`6A`, not `6Axx`) and failed to route where a
1-byte prefix is ambiguous ("Path trace not available").
- Send: path_sz = floor(log2(width)); build width-byte hops — the
repeater/room "Ping" prefixes (contacts_screen), the round-trip
buildPath (now mirrors by hop, not byte), and the map trace builder.
- Parse: read the flag byte for the hop width, group the hash bytes into
packed width-byte hops, key contacts by the full prefix, 1 SNR per hop.
- Render: list panel + markers + map points iterate packed hops, labelled
at the configured width.
PathHelper gains tracePathSz / traceHopBytes / packPrefix / tracePathHops
/ buildTraceRoundTrip, all unit-tested (incl. width-2 hop mirroring).
Bumps build to +47 for the b47 test binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review flagged setPublicKeyHex using `value.length > 10`, which would
store an empty scope for an exactly-10-char device key (unreachable today —
MeshCore public keys are 64 hex — but inconsistent with ChannelStore, which
uses >=10). Align to >=10 for consistency and to remove the latent off-by-one.
Channel message history was persisted keyed by the reusable slot index, so a
channel added to a slot that once held another channel inherited its messages.
Storage now keys by the channel's PSK identity via a resolver on the store
(index -> channel PSK), leaving all call sites unchanged. A one-time migration
adopts pre-existing slot-index history into the PSK key on first load (only when
the PSK is known); clearChannelMessages now wipes both the PSK key and the raw
slot-index key so Build-B's reuse-clear can't be re-migrated onto a new occupant.
Build-A (durable) of epic #190; supersedes Build-B's (#193) guard. Also removes
the dead device-wide fallback read in loadChannelMessages.
Channel message history is keyed by the reusable slot index, not by the
channel's PSK. Adding a channel to a slot that previously held a different
channel surfaced the prior occupant's messages (e.g. #echo history appearing
in a newly QR-added channel).
setChannel now clears the slot's stored history unless the slot already holds
the same channel (same PSK, e.g. a rename). Only deleteChannel cleared before.
Build-B (bleed-stop) of epic #190. Build-A (PSK-keyed history + migration) to
follow. Accepted edge: re-adding the exact same channel that was orphaned
device-side loses its old history (messages carry no PSK to match on).
The Add Channel "Scan QR Code" scanner now accepts channel share URIs
(meshcore://channel/add) alongside community QR JSON: it parses name +
secret, confirms, and adds the channel to the next free slot via
setChannel. Completes the channel-QR round-trip in-app, and interops with
the reference app's channel QRs.
Bumps build to +52 for the b52 combined test binary (path-hash + trace +
channel QR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.
Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three routing-match sites keyed/compared nodes by only the first
public-key byte, which collides when two nodes share a first byte on a
2-/3-byte path-hash mesh:
- DirectRepeater.matchesPathStart() replaces the 1-byte
`pubkeyFirstByte == pathBytes.first` direct-repeater check in
chat_screen and path_management_dialog (x3 each).
- map _computeGuessedLocations packs the full configured-width prefix as
the repeaterByHash key and looks up the contact-side hop at the same
width — was keying publicKey[0] but reading pathBytes.last, a silent
miss at width>1 that stopped location-guessing on multi-byte meshes.
- map _filterContactsBySettings overlap detection compares the
configured-width prefix via _samePubkeyPrefix (was publicKey.first).
Adds DirectRepeater.matchesPathStart unit tests. Bumps build to +46 for
the combined path-hash sweep test binary (b46: #151+#154+#155+#156).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Set Path" dialog parsed, built, and displayed hop prefixes as a fixed
1 byte (substring(0,2)), so on a 2-/3-byte path-hash mesh manual entry produced
wrong-width routing paths and the helper text claimed "1 byte". Make it width-
aware from connector.pathHashByteWidth.
- parsePathPrefixes(text, width, invalid): static + testable; each entry must be
EXACTLY width*2 hex chars (wrong-length / malformed go to invalid, never
silently truncated); width clamped >= 1.
- _updateTextFromContacts / display / maxLength group by the configured width;
the display substring is guarded against short keys.
- show() takes pathHashByteWidth; the chat + path-management callers pass
connector.pathHashByteWidth.
- l10n: reworded the path-entry helper / example / instructions to be width-
agnostic (dropped the "2 hex / 1 byte / first byte" claims). The 17 non-English
locales keep their prior strings, flagged for re-translation (English fixed).
analyze clean; 6 parsePathPrefixes tests (incl. exact-length + clamp). Gemini v1
BLOCKER (unguarded substring) + MAJOR (silent truncation) fixed; v2 MAJORs were
the pre-existing refresh button (out of scope), v2 MINOR (1-byte example) fixed.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PathHelper.formatPathHex / resolvePathNames assumed 1 byte per hop, so the
direct-chat "full path" and path-management dialogs rendered routing paths
per-byte (84,AB instead of 84AB) and resolved hop names by a single byte on
2-/3-byte path-hash meshes. Group bytes into pathHashByteWidth-sized hops and
match hop names by the full-width prefix.
- formatPathHex(bytes, width) / resolvePathNames(bytes, contacts, width) group
into width-byte hops (trailing partial hop kept); name match uses the full
hop prefix.
- chat_screen + path_management_dialog pass connector.pathHashByteWidth.
analyze clean; 7 PathHelper tests (incl. width-2 collision-avoidance); Gemini: ship.
Closes#154
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DirectRepeater stored only pubkeyFirstByte (1 byte of the last hop), so the SNR
indicator + Nearby Repeaters dialog showed a 1-byte prefix and matched the wrong
repeater on 2-/3-byte path-hash meshes. Capture the last hop's full configured-
width prefix and use it for display, contact-match, and dedup.
- DirectRepeater.pubkeyPrefix (Uint8List) + prefixHex getter; pubkeyFirstByte
retained for the path-management / chat matching sites (those are #156).
- _updateDirectRepeater captures path[len-width..] (or the pubkey prefix when no
path); dedup by listEquals(pubkeyPrefix).
- snr_indicator: status-bar + dialog render prefixHex; matcher keys on the full
width prefix so the correct repeater name resolves.
Bumps build to +45. analyze clean; 6 DirectRepeater tests (2 new); Gemini: ship.
Closes#151
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New workflow .github/workflows/triage-user-issues.yml. On issues:opened, if
the author is external (author_association not OWNER/MEMBER/COLLABORATOR),
apply user-submitted + priority:P2 + board:backlog using PROJECT_PAT so the
existing sync-labels-to-board.yml fires and sets Status=Backlog /
Priority=Medium on board #2. Maintainer-opened issues are untouched. type:
is left for manual triage.
workflow_dispatch entrypoint added for manual re-triage and the post-merge
live test: both triggers require the workflow to be on the default branch
first (issues:-triggered workflows only run from default; a new
workflow_dispatch is only dispatchable once on default), so validation
happens after the merge to dev.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps pubspec 1.1.2-beta.4+43 -> 1.1.2-rc.1+44, adds the 1.1.2-rc.1
CHANGELOG section (reply-UX #131/#106, device-info #134, GPS query #135,
BLE flood fix#140, path-hash #115, 0xC1 gate #144; known issues #86/#91),
and updates the README banner from public beta to release candidate.
Folds the #86 known-issue note into the rc.1 changelog, superseding #99.
Closes#146
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fork-only 0xC1 GPS command was sent to any connected radio — both the
60s self-location poll (on gps=1) and the Settings GPS status query. Stock /
non-Offband MeshCore firmware can't answer it, so gate both behind a
capability check.
- firmwareSupportsOffbandGps(offbandCaps): presence of the Offband-fork
offband_caps byte (device-info v14+), which stock MeshCore never emits.
- requestOffbandGps() hard-returns when unsupported — 0xC1 never goes out.
- _reconcileGpsPolling() centralizes the poll decision (support AND gps=1)
across all three triggers + the device-info reply, so frame order is moot.
- Settings hides the GPS status section on unsupported firmware.
Interim presence-gate; a dedicated OFFBAND_CAP_GPS bit can follow once
firmware defines one. Unit-tested (3 new); Gemini review: ship.
Bumps build number to +43 (test build b43).
Closes#144
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offband 0xC1 GPS query: 1-byte request -> RESP 0xC1 ASCII reply
(enabled/detected/active/fix/baud/lat/lon/alt_cm/sats/time), parsed into
OffbandGpsStatus (lat/lon /1e6). New GPS-status section in the Location dialog
(live-fix vs stored banner + coords/sats/alt/time). Validated against RedCreek's
feat/gps-state-query test firmware.
#140: the per-minute GPS poll now uses the lightweight 0xC1 query instead of
CMD_APP_START (which re-walked channels + re-drained messages = the BLE re-init
flood); a live fix updates self-lat/lon. Validated on b42 (APP_START ~1/min ->
~0, 0xC1 polls clean every 60s, channel re-walk gone).
Gemini (fix-then-ship): atomic completer-claim in _handleOffbandGps to shrink
the stale-reply race; full elimination needs a protocol sequence id, but the
poll shares in-flight via the re-entrancy guard and the query button is disabled
during a request, so concurrent requests don't occur in practice. The hardcoded
snackbars Gemini flagged in _editLocation are pre-existing l10n debt, not
introduced here; all new strings are localized.
Closes#135Closes#140
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parse the device-info reply's readable strings (build date / model / firmware
version, NUL-terminated after the 8-byte header) and show firmwareVersion +
deviceModel in Settings → Device Info. Raw strings are logged on connect.
End-anchored field mapping (deviceInfoFields) keeps the version correct on
partial frames.
Gemini (fix-then-ship): removed the unused deviceBuildDate field/getter (dead
state) — the build date stays in the connect log via the raw strings, and
deviceInfoFields still parses + tests it.
Closes#134
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reply long-press drops the cursor into the composer (post-frame focus). (#131)
- "Reply with route" sends `@[sender] ↩ N hops · via <path>`, byte-truncated with `…`, Cyr2Lat-aware. (#106)
Gemini (fix-then-ship): the unawaited `sendChannelMessage` in `_sendRouteReply` mirrors the established `_sendMessage` convention — failure surfaces via the visible message's delivery status, not the returned Future; holistic channel-send error handling is tracked in #133 per maintainer decision. O(n^2) path truncation is negligible on the <=160-byte payload; the `w<1` guard is a defensive div-by-zero (pathHashByteWidth is 1-3).
Closes#131Closes#106
Part of #132
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy.yml (inherited from upstream zjs81) fired on every tag push and failed on a missing CLOUDFLARE_API_TOKEN — secrets do not fork. Switch to workflow_dispatch-only to stop the per-tag failures; preserve the job for the scoped Offband web-app launch (Feature #121 / Epic #122).
Packet Path mis-identified repeaters: a 2-byte path hash like 6A3D was
sliced into two 1-byte hops (6A + 3D), so the first byte collided with an
unrelated repeater (Freemasons 6A..) and the second matched nothing
("Unknown Repeater").
- meshcore_protocol: realHopCount() converts the firmware path BYTE length
to true hops via the device hash width.
- channel_message_path_screen: slice/count/match at the device
pathHashByteWidth (not the unreliable per-message high-bits); bucket and
match on the full w-byte hash (mirrors _pathMatchesContact), not hash[0].
- channel_chat_screen: same width fix on the per-message via-line + hop badge.
- settings: show the build number alongside the version name.
- bump 1.1.2-beta.4+36.
- test: realHopCount regression coverage.
Gemini review (llm-consult): fix-then-ship; 2 findings clarification-only
(_formatHash is canonical hex used identically both sides; pathHashByteWidth
is static per-connection).
Part of epic #112.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getBrokers logs request-sent, header count, and completed-with-N-brokers-in-Xms or failed-after-Xms (frames + expected count) via FileLogService. A tester Share-logs capture then shows in plain text whether the broker pool load succeeds, stalls, or times out - which the raw BLE frames cannot reveal. Behaviour unchanged (parse-once refactor; all 13 service tests green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The broker-pool GET put a single fixed 6s deadline on the entire paginated dump. A large pool (6+ brokers) streams field-by-field over ~8s and blew the deadline, so the client falsely reported the pool as not finished even though the device sent it all (the END terminator arrives after the client gave up). Re-arm an inactivity Timer on each received frame: a steadily-streaming dump never times out; only a genuine stall (no frame for the timeout window) fails. Adds a TDD test - a slow-but-steady dump, frames under the timeout but total far over it, must complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CHANGELOG.md seeded with the 1.1.2-beta.1 entry (Added / Fixed / Known issues).
- README gains a "Download & Install" section for end users (Releases link,
extract + run on Windows with the SmartScreen note, sideload the APK) — the repo
previously had only developer build-from-source steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repeater "Path hash mode" dropdown showed raw mode numbers (0/1/2). Match
the companion device's selector (settings_screen.dart _PathHashSizeTile) so the
options are self-describing: "1-byte · max 64 hops" / "2-byte · max 32 hops" /
"3-byte · max 21 hops".
Underlying value (mode 0/1/2 = 1/2/3-byte) and the `set path.hash.mode N` wire
command are unchanged — labels only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- pubspec 1.1.2+30 -> 1.1.2-beta.1+31: the app self-identifies as the public beta
heading toward 1.1.2 final.
- scripts/package-windows.ps1 (#96): builds the distributable zip and BUNDLES the
VC++ runtime DLLs (msvcp140 / vcruntime140*) that Flutter's Windows build omits,
so the zip launches on a clean machine without the redistributable installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Platform-adaptive export on the BLE/frame log screen: the system share sheet on
mobile (the priority path), or open the logs folder in the file manager on
desktop. Web-safe (defaultTargetPlatform, no dart:io); flushes the file first so
it is current. Completes #97.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Always-on file logging so users/support can retrieve a log without enabling
anything in the UI. No-op on web (no filesystem).
- FileLogWriter: rotating file writer (size cap + max files), single + batch
writes, unit-tested against a real temp dir.
- FileLogService: singleton initialised in main(); resolves
getApplicationSupportDirectory()/logs, queues lines, periodic 2s batched
flush, bounded queue, never crashes the app on an IO error.
- AppDebugLogService.log + BleDebugLogService.logFrame write every entry to the
file (app logs always, independent of the in-app viewer's enable gate).
Location per the #97 decision: standard support dir on all platforms (Windows
%APPDATA%\app.offband.meshcore\logs). Still TODO before recompile: access UI
("Open logs folder" on desktop / "Share logs" on mobile).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Was 1.1.1+18. Tags already referenced 1.1.2 (v1.1.2-ble-test/-queuediag) and the
rc8/rc9 APKs shipped +28/+29 via --build-number, so the pubspec build number had
drifted. +30 is the next build after the +29 that shipped. Tag v1.1.2 at release.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the additive OCFG_BROKERS dump fields from firmware #172/#173
(backward-compatible — absent fields read as today's enabled/disabled):
- BrokerRuntimeState (`state`) + BrokerLastError (`last_error`) + a pure
BrokerConfig.status mapping -> Connected / Connecting / Failed (reason) /
Held (no clock|low heap) / Enabled / Disabled; held shown neutral, not error.
- Brokers list shows the real status line + colour instead of just "enabled".
- Editor shows jwt_owner / iata_override resolved defaults (`jwt_owner_resolved`,
`iata_resolved`) as greyed placeholders when the raw key is blank; only writes
the raw key when the user enters a value (blank stays the source of truth).
Sourced from the pool dump (getBrokers); getBroker's per-field GET list is
unchanged pending the firmware GET-ability answer on #173. Unit-tested mapping,
parsing, and backward-compat (9 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware build can ACK a broker enable/disable as success without applying it
(HV4; meshcore-firmware#179). The quick toggle and the editor save previously
trusted the ACK and showed a success snackbar, so an ack-but-no-op looked like
it worked.
Both paths now SET, wait a short settle, re-read the slot, and report what the
device ACTUALLY did:
- applied -> confirm
- notApplied -> warn "possible firmware issue"; the editor stays and re-seeds to
the device's true state instead of popping a false success
- unverified -> re-read failed (e.g. an enable that rebooted the device)
Every device is treated identically: the UI reflects the re-read, never assuming
a change took that the device didn't confirm.
- BrokerConfig.classifyApply pure classifier + BrokerApplyOutcome (unit-tested)
- ObserverConfigService.applySettleDelay
- wired into mqtt_brokers_screen quick toggle + broker_editor_screen save
- widget test for the ACK-but-not-applied path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The BLE/USB self-info retry was an unbounded 3.5s hammer -- the Web path caps at
3 and skips while syncing; BLE had neither. On a slow device handshake it churned
the connect and starved the contact sync, hanging the contact-load bar. Inherited
from upstream zjs81/meshcore-open (git blame; zero Offband commits).
- Replace the unbounded 3.5s Timer.periodic with a self-rescheduling backoff:
3.5s -> 7 -> 14 -> 28 -> 56s, then a steady ~60s keep-alive. Never hard-stops --
a slow/recovering device still gets prompted.
- Add the Web path's skip-while-syncing guard on BLE (per-tick skip, never
permanent: the timer keeps rescheduling, so a settled sync lets the next tick
send).
- Decisions extracted as pure, tested helpers: nextAppStartRetryDelay(attempt)
and shouldSendAppStartRetry({connected, awaitingSelfInfo, syncing}).
- Disconnect teardown (Gemini's amendment) was already present (:2501 / :2374).
Gemini plan-review: Proceed, no blockers. 6 new helper tests; full suite 299 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware feedback: JWT owner defaults to the device pubkey and IATA override
is optional by definition, so gating a save on them is wrong -- they have
under-the-hood firmware defaults.
BrokerConfig.enableError now checks only the structural fields with no default:
URL present, port in range. The JWT field checks are gone; the firmware enforces
JWT completeness with its defaults, and a genuinely-bad enable surfaces the
firmware's reason via the failure messaging added earlier. The editor's pre-save
check and the list's quick Enable both follow.
Tests updated to the new contract (a JWT broker with blank owner/audience now
enables; a structurally-complete quick Enable confirms). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware test feedback:
- Long-press Enable/Disable was silent on success AND failure. It now confirms
on success and, on failure, surfaces the device's reason (lastError) instead
of nothing.
- The client now refuses to quick-Enable a broker that can't work (incomplete
required fields, e.g. JWT without audience/owner) and says why -- so it never
reports "enabled" on a broker that won't run. (Firmware should reject too.)
- The editor blocked a DISABLE on blank/invalid fields. Validation now gates
ENABLING only -- disabling a slot with partial values is fine; it won't be
active.
Validation rules shared via BrokerConfig.enableError (editor pre-save + list
quick-Enable). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>