The persisted store was being overwritten with the windowed in-memory list, so
any channel or DM with more than _messageWindowSize (200) messages lost
everything older than the newest 200 on the first save after load. Observed
live: Public went 232 -> 201 in one session on a build that already had the
#333 fix, so this was not the race - it was windowing truncating the store.
This is the slow-erosion cause behind the whole 566 -> 231 -> 206 -> 201
history.
saveChannelMessages / saveMessages now MERGE into the persisted set instead of
overwriting: upsert by message identity so older persisted messages are kept,
new ones added, and the in-memory copy wins for edits/reactions/status. If the
existing history fails to decode, the save aborts loudly rather than merging
into an empty base and truncating (SAFELANE 6).
Deletion is now an explicit path - removeChannelMessage / removeMessage - since
the app deletes individual messages. Routing delete through the merging save
would resurrect them; the connector's deleteChannelMessage / deleteMessage now
call the explicit remove.
Windowing stays for display and memory; it no longer dictates what is stored.
Tests: 250-message history survives a 200-window save; new message appends;
edit is captured not duplicated; delete does not resurrect. 508 pass, analyze
and format clean.
Cost: a save now re-reads and re-encodes the channel/contact history. Cheap
with drift's per-key writes (#335); a future append-only schema removes even
that.
Measured, not guessed. Cumulative per-code frame timing named the owner:
frame handler cumulative (2002ms total sync): code=3 2002ms/105calls
code=3 is RESP_CODE_CONTACT, and it accounted for ALL of the synchronous
frame time at ~19ms per contact. That is why a per-call 100ms threshold never
fired: no single call is slow, the volume is.
The 19ms is _handleContact calling notifyListeners() for every contact. Each
notification is a synchronous full-tree rebuild, and each rebuild walks the
contact list, so the cost grows with the address book. The channel handler
directly alongside already guards its notify with _isLoadingChannels; the
contact handler had no equivalent.
Notifications are now throttled to one per 250ms while _isLoadingContacts is
set, and remain immediate outside a pull so single adverts still update live.
Throttled rather than suppressed so the sync progress bar keeps advancing. The
existing notifyListeners() when the pull completes still fires, so the final
state cannot be stale.
Scope of what this accounts for, stated honestly: measured synchronous frame
work was ~2s per report window and the pull showed ~4s total, against a ~30s
observed freeze. This removes the largest measured contributor. If the stall
persists, the remainder is outside the frame handlers and the cumulative
counters will show frame time has dropped, which narrows it further.
flutter analyze clean, dart format clean, 494 tests pass.
The conversation-load metric was misread, and nearly caused a fix to the wrong
thing. It reported store=30138ms across 300 contacts, which looked like
loadMessages costing ~100ms each. It is not:
- The device-wide fallback key `messages_<dev>` does not exist on the
reporting machine, and only 11 per-contact message keys exist in total, so
loadMessages does three in-memory map lookups and returns empty.
- storeWatch spans an `await`, so it measures WALL time including event-loop
queueing, not work. merge, which is synchronous and has no await, measures
0ms - consistent with the isolate being busy elsewhere while those awaits
wait their turn.
So the 30s is ~300 awaits each waiting for a busy isolate, not 30s of storage
work. The metric label now says so, rather than inviting the same misreading.
The per-call 100ms threshold cannot see the actual shape here: a handler that
takes 40ms and runs 234 times owns ~9s of isolate time and never trips it.
Frame handlers now accumulate synchronous time per frame code and report the
top codes by total, which names the owner regardless of per-call cost.
Diagnostics only, no behaviour change.
flutter analyze clean, dart format clean, 494 tests pass.
The startup loads are now clean (all steps ~0ms, down from 37792ms), but the
contact pull still stalls in multi-second bursts: 8.0s and 7.7s at +25s..+52s
on the last run, matching the reported freeze from ~15s to ~49s.
No frame handler exceeds 100ms, because the work is not in one.
_loadMessagesForContact is fired unawaited from the contact handler, so it runs
after the handler returns and escapes that timing entirely.
This times it directly and reports cumulative store time versus merge+notify
time every 25 contacts, so the next run says which half is responsible rather
than requiring another guess. Two candidates it will discriminate:
- store: prefs read + jsonDecode per contact
- merge+notify: the per-contact notifyListeners, i.e. one full widget tree
rebuild per contact, each of which walks the contact list
Diagnostics only, no behaviour change.
flutter analyze clean, dart format clean.
Two reported freezes: one shortly after connect, one during channel sync,
with the sync progress bar never repainting and then completing all at once.
That is the UI isolate being blocked, and the log already shows the
signature - a long silence followed by many frames sharing one timestamp,
which is queued input flushing after the loop frees up.
The previous fix (batching 600 per-contact notifications) moved the stall from
43.6s to 40.1s, which is noise. It was the wrong culprit, so this replaces
guessing with measurement:
- The post-SELF_INFO cache load is split into named steps, each timed and
logged as `startup-load <step> took Nms`. The step order is unchanged, and
cachedChannels still runs before channelMessages so PSK-keyed history
resolves.
- Every RX frame handler is timed, and any that occupies the isolate for more
than 100ms logs `slow frame handler: code=N blocked UI for Nms`. This names
the handler wherever it is, rather than requiring a guess about which one.
No behaviour change. Diagnostics only, kept as Perf-tagged logging because
this class of stall is worth catching again.
flutter analyze clean, dart format clean, 469 tests pass.
Diagnosed from the app's own log. After SELF_INFO the client went completely
silent for 43.7s, then flushed a burst of radio frames all sharing the
identical timestamp - queued while the event loop was blocked, not a slow
radio.
Cause: loadContactCache() looped over every cached contact calling
_ensureContactSmazSettingLoaded and _ensureContactCyr2LatSettingLoaded, and
each of those notified on completion. With 300 contacts that is 600
notifyListeners() calls in a burst, each rebuilding the whole widget tree, and
each rebuild itself walks the contact list. O(contacts^2) on the UI isolate.
Both loaders are now awaitable and take notify: false for bulk use.
loadContactCache awaits them all and notifies once.
Measured on the reporting device (radio 4f6585264e, TCP): 300 contacts, 1090
discovered contacts, 12 channels holding 1841 messages, 6.9 MB prefs file.
Log evidence: 43.7s stall between "Pulled battery" and the first channel
response, followed by same-millisecond frame delivery.
This is the Windows-visible freeze in #306. The platform asymmetry is
consistent with contact-list size rather than anything desktop-specific, so
whether Android is genuinely faster or simply has a smaller address book on
its paired radio is still open - #306 stays open pending a like-for-like
re-measure.
flutter analyze clean, dart format clean, 469 tests pass.
Channel history is keyed by the channel's PSK (#194), and the resolver set on
the store reads that PSK out of _channels. After SELF_INFO, loadCachedChannels()
and loadAllChannelMessages() were both fired without awaiting, so the messages
could load while _channels was still empty. The resolver then returned null,
_storageKey() silently fell back to the legacy slot-index key, and channels
read as empty even though their history was intact under the PSK key.
Silent because the fallback is a legitimate code path: there is no error, no
parse failure and no log line, just an empty list. It is indistinguishable
from data loss to the user.
Observed on a live radio: Public held 566 messages under
channel_messages_<dev>psk_8b3387e9..., and the app displayed nothing.
loadAllChannelMessages() now runs only after loadCachedChannels() completes.
The startup call in main.dart already awaits in the right order; it runs before
any radio is connected, which is the source of the "Public key hex is not set"
warnings at launch, and is harmless once this post-connect load is correct.
Was previously committed against #291 on the nav-redesign branch; split out
here under its own issue.
A capability-gated control that doesn't appear is indistinguishable from
a broken one. Diagnosing "the FEM LNA toggle is missing" on real hardware
required a rebuild, because the caps byte was never surfaced and the app
debug log records nothing in a release build unless logging is enabled.
Adds an "Offband capabilities" row to Device Info showing the raw caps
byte, firmware version code, and which gated features it grants, e.g.
"0x02 (v16) - block". That turned an unexplained missing toggle into a
one-look answer: firmware v16 running, FEM LNA bit clear, client correct.
Also logs the same values at device-info parse for anyone who does have
logging on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the client half against the firmware as-built (#298):
- Device-info offset 83 carries the FEM LNA state on v16+, appended
unconditionally (reads 0 on non-capable boards). parseFemLnaState is
length-guarded and returns null pre-v16. Byte presence signals firmware
version, not capability — the cap BIT gates the UI.
- 0xC3 reply handler adopts the reported value verbatim: firmware returns
post-apply hardware state, not an echo, so a refused write surfaces as
truth rather than a lie.
- setFemLna / requestFemLnaState no-op unless the capability bit is set,
so a non-capable radio never sees 0xC3 traffic.
- Radio Settings toggle rendered only when the bit is set, driven by
connector state via ListenableBuilder rather than local optimistic
state, so it always shows what the radio reports.
- errCodeUnsupportedCmd documented: a mis-gated request draws [0x01][0x01],
not [0x01][0x06]. Neither is 0xC3-prefixed, so no new error path.
Gating is on the bit alone, never model or version: firmware derives it
from a runtime FEM probe, so two Heltec V4s can legitimately disagree and
rak3401 reports false by design (its SKY66122 gates LNA and PA together).
13 tests. Not hardware-validated — firmware 0xC3 is still on a branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the client half of the Heltec V4 FEM LNA control surface at the
protocol level: 0xC3 CMD_OFFBAND_FEM_LNA (0x01 SET [value], 0x02 GET),
a 3-byte [0xC3][sub][value] reply parser, and the
OFFBAND_CAP_FEM_LNA = 0x04 capability gate.
Deliberately a fork-private command rather than a sixth byte on the
stock CMD_SET_OTHER_PARAMS (38): that frame is shared with upstream
MeshCore and is sent to every radio regardless of fork, so widening it
would perturb stock firmware. Nothing is emitted unless the capability
bit is set.
The gate checks the bit only, never model or version — firmware derives
it at runtime from the auto-detected FEM chip, so it is a per-unit
answer and two Heltec V4s can legitimately disagree.
PROVISIONAL: firmware owns the caps byte and has not yet confirmed 0x04
is free, and the 0xC3 setter is not built yet. Spec sent to
OffbandMesh/meshcore-firmware#298. Do not merge before firmware
confirms the shape as built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini adversarial review finding (minor, accepted). _pathDiag derived the
expected byte count from the connector's global _pathHashByteWidth, which is
the CONNECTED device's width, while each Contact now carries the width its
path was actually captured at.
Failure case: a contact discovered on a 1-byte net (3 hops, 3 bytes) viewed
while connected to a 2-byte device computes expected = 3 * 2 = 6 and logs
"bytes=3/6 TRUNCATED" for a complete, correct path. A false TRUNCATED in the
very diagnostic added to investigate truncation would actively mislead.
_pathDiag now takes the per-path width; all seven call sites pass
contact.pathHashWidth.
Refs #298, #309
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware path-len byte is packed and self-describing (src/Packet.h:79-84):
high 2 bits = hash size - 1, low 6 bits = hash COUNT (hops), byte length =
count * size. The app treated the low 6 bits as a byte count and ignored the
width, on the strength of a comment asserting the high bits were "not reliably
populated". Both halves of that comment were wrong.
Receive: Contact.fromFrame read `count` bytes where firmware meant `count`
hops, so at 2-byte width it kept HALF of every path and discarded the rest.
DAYTON VA's 2-hop route became a 2-byte fragment, which is why every repeater
login in Bandit's capture timed out.
Send: buildUpdateContactPathFrame wrote the count raw, leaving mode bits 00.
That tells the radio "1-byte hashes" while handing it 2-byte hash data, so it
routed to nodes never on the route. This is the likelier cause of the observed
"routes via c6 and 5c separately" behaviour.
Also fixes a third unit inconsistency: setContactPath wrote customPath.length
(bytes) into pathLength (hops) after every path set.
Contact now carries pathHashWidth per path, so a stored path can never be
re-sliced at a width it was not captured at. realHopCount() is removed rather
than adjusted: it divided an already-correct hop count, halving it at 2-byte
width, and every call site was compensating for a bug that no longer exists.
Migration (Ben's call, option A): records written before this change have no
pathHashWidth and hold truncated bytes that cannot be recovered by
reinterpretation, so their path is dropped and the contact reverts to flood
until the radio re-supplies it. Logged, not silent.
Tests asserted the old semantics (expect(realHopCount(2, 2), 1)) and were
rewritten against firmware truth rather than adjusted to keep passing. Added
send-side encoding coverage, which did not exist. 456 tests pass.
Refs #240, #279, #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The original diagnostic derived hops via realHopCount(), which divides a
value that firmware already defines as a hop count (src/Packet.h:79-84:
getPathByteLen() == getPathHashCount() * getPathHashSize()). It would have
reported hops=1 for a real 2-hop path, misleading the exact investigation
this logging exists to serve.
_pathDiag now reports the hop count directly and prints held-vs-implied
byte counts, flagging TRUNCATED when short. That makes the #309 decode
truncation self-evident in any capture without the device present.
The login dialogs no longer assert a hop count at all: selection.hopCount
is ambiguous by construction (device paths carry hops, user overrides carry
bytes, per #279), so they log the raw field, width and actual byte length,
which discriminates the two cases.
Refs #240, #279, #309
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Style rule, comment only. No behaviour change; the handed-over test builds
were produced from 060a808 and differ from this commit by one character in
a doc comment.
Captures previously logged only a path byte length, making it impossible
to tell one 2-byte hop from two 1-byte hops - the exact question #240 and
#279 turn on. Bandit's 2026-07-18 log hit this wall.
Adds a shared _pathDiag() rendering (len, applied width, derived hops,
hop-grouped bytes) to the existing contact path log sites, and the same
detail to the repeater/room "Login routing" lines.
Also logs the path-hash width transition on every device-info frame, with
the frame length, so a short-frame downgrade to width 1 (#240) is visible
in any capture without needing the device. Warn on change, info otherwise.
Existing log sites only - no new per-frame logging (SAFELANE 11.10).
Display labels (#279) and the calculateTimeout unit bug (#299) are out of
scope here.
Blocking yourself silently hid your own traffic with no visible cause,
and because blocks are offloaded to the radio it was pushed there and
could be pulled back by the connect-time union.
BlockService gains a selfKeyHex, injected by the connector when the self
key is learned and cleared on disconnect. Every add path refuses it
(block, importKeys, maybePromote) — importKeys specifically, so a
self-block already on the radio cannot be re-imported. setSelfKey also
heals an existing self-block and pushes REMOVE, covering the case where
the union pull lands before self-info arrives. Contacts and discovery
sheets hide the Block option for your own node.
Per Gemini review: heal on an unchanged key too (an early return would
strand a self-block introduced after the key was known), and order
setSelfKey so all in-memory state settles synchronously, keeping callers
consistent even though the connector fires it unawaited.
Adds test/services/block_service_test.dart — the first coverage for
BlockService (10 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every received DM/channel message now logs arrival wall-clock vs the
sender-claimed payload timestamp (delta + TIME ANOMALY marker beyond
24h), routed directly to AppDebugLogService so the rotating on-disk
trail is unconditional. Models persist a nullable rxTime set at every
live ingest construction site; legacy stored records stay null.
Diagnostic child of #280.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_maybeNotifyChannelMessage now switches on ChannelNotifyMode instead of the
binary isChannelMuted check. mentionsOnly notifies only when the message
carries a canonical @[selfName] mention.
The mode is keyed by the channel's PSK identity, reusing the same
_findChannelByIndex(index)?.pskHex resolver the message store already uses
for history (#194), so the setting survives a rename and does not follow a
reused slot.
The mention is matched against resolvedText (post-translation) so a
translated mention still fires, and case-insensitively since a hand-typed
mention need not match the advert casing. Bare @Name is deliberately not
matched: it false-positives on ordinary text and cannot be delimited for
names containing spaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard _sendMessageDirect too (automatic retries route there without
passing sendMessage, so a pre-block message kept retrying post-block),
log both connector drops instead of returning silently, and surface a
blocked-contact snackbar on the manual retry and reaction paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review flagged that the composer swap is UX-only: retry-tap
and reaction sends call connector.sendMessage directly, bypassing the
hidden composer. Add a block guard at sendMessage (covers normal DMs,
reactions, and retries), mirroring the incoming DM-drop so a block is
symmetric. The composer notice bar remains the visible affordance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Race (data loss): exclude keys the user block/unblocked WHILE a BLOCK_LIST dump
was in flight from the union (they were already pushed + local is authoritative)
-> no re-import of an unblock, no redundant re-push.
- _pubKeyHexToBytes returns null on malformed hex (try/catch + length) so a corrupt
persisted key can't crash the unawaited push.
- Cancel _blockListRetryTimer + reset dump state on disconnect (timer leak).
Justified/not-fixed: generic [0x01][0x06] error is unreachable (we only build
well-formed 32-byte frames); Completer-correlation is out of scope for v1.
Epic B #166 / #179#180. Gemini audit: docs/llm-consultations/2026-07-14-epicAB-*.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connector parses ADD/REMOVE/CLEAR replies; ADD ok=0 (node store full at 32) sets
blockOffloadStoreFull (local stays authoritative), cleared on reconcile. BlockedView
shows a firmware-offload status row when supportsOffbandBlock — 'active', or a
'radio block list full' warning. Adds block_offload* l10n.
Epic B #166 / B5 #180.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockService gains a firmwareSync sink (fired on real block/unblock/promote) and
importKeys (pull side, no echo). Connector sets the sink in initialize, pushes
ADD/REMOVE via 0xC2 when supportsOffbandBlock, and on device-info (caps landed)
pulls BLOCK_LIST. On dump completion: UNION — import node keys missing locally
(no echo), push local keys the node lacks; never remove (only explicit unblock
sends REMOVE). Non-Offband radios skip all of it (app-local only).
Epic B #166 / B4 #179.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route 0xC2 frames in _handleFrame; accumulate the LIST dump (START 0xFF count ->
KEY index+key:32 -> END 0xFE). Detect truncation (count vs received; APP_START
early-END uses the same 0xFE) and re-request on a 2s settle. Never derive
removals from a partial pull. Union reconcile wired in B4.
Epic B #166 / B3 #178.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build frames for ADD/REMOVE (pubkey:32) / LIST / CLEAR; parse the 3-byte
[0xC2][sub][ok] reply (OffbandBlockReply). Generic error frame is [1][6]
(respCodeErr/errCodeIllegalArg), NOT 0xC2-prefixed. LIST streamed dump parsed
in B3.
Epic B #166 / B2 #177.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockService.maybePromote(name,key): when a name-only block links to a pubkey,
add the key to blockedKeys and drop the name entry. Connector calls it on every
contact advert AND incoming DM (the two ways we learn a name<->key link).
_pruneExpiredNames on load drops name-only blocks older than 30 days.
Epic A #165 / A7 #174. 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>
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
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).
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>
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>
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>
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>
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>
The firmware answers RESP_CODE_ERR for an empty/invalid channel index, but
_handleErrorFrame never advanced the in-flight channel sync. So every empty slot
waited out the 2s timeout + 3 retries (~8s each): a device with a few populated
channels out of a large capacity took minutes to sync (~40 slots requested, ~29
empty -> ~4 min) before the real channels (which answer CHANNEL_INFO) finally
synced in the last couple seconds.
Now an ERR that lands while a channel GET is in flight (and no generic-ack
command is waiting -- that one is order-correlated to the ERR first) advances to
the next slot immediately, mirroring the CHANNEL_INFO success path minus adding
a channel. The decision is pinned as a pure static predicate and tested,
matching the connector's existing decision-helper test convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini MAJOR #4 (offset unverified / short-frame robustness):
- Offset VERIFIED against firmware MyMesh.cpp: the device-info tail is three
consecutive out_frame[i++] writes -- client_repeat@80, path_hash_mode@81,
offband_caps@82. Bytes 80/81 are shipping (path-hash), so caps@82 is correct
by adjacency, not a guess.
- Extracted MeshCoreConnector.parseOffbandCaps (pure, bounds-checked) so the
guard + offset are unit-testable; _handleDeviceInfo delegates to it.
- 4 tests pin: pre-v14 short frame -> null, len-83 reads byte 82 verbatim,
observer bit round-trips supportsConfig, hostile truncated frames never OOB.
Live observer node confirms end-to-end at G2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- screens/settings/observer_settings_view.dart: staged-save pane for WiFi
(ssid/pwd/enabled), MQTT region (iata/status_interval), display
(always_on/rotation 0|180), with Save (changed keys only) + Refresh +
stale/error banners; the broker pool renders read-only (enable/clear await
the firmware F-task).
- settings_screen.dart: register the Observer category, gated on
ObserverConfigService.supported (re-evaluated as the connector's offband_caps
flips on connect/disconnect).
- connector/meshcore_connector.dart: parse the offband_caps byte (device-info
byte 82, after path_hash_mode) + expose offbandCaps.
- services/observer_config_service.dart: supported computed live from the
connector (version gate + cap bit).
- main.dart: provide ObserverConfigService.
Strings are English-only pending l10n ARB keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflow the systemDevices.map(...) chain after the .where() removal so
`dart format --set-exit-if-changed .` is clean. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scanner filtered advertisements by device-name keyword
(withKeywords: deviceNamePrefixes), so any MeshCore/Offband device
whose advertised name fell outside the hardcoded prefix list was
invisible to the scanner — even though the official MeshCore app,
which scans by the Nordic UART service UUID, found them. Switch
startScan to withServices: [Guid(service)] and drop the now-redundant
name filter on the already-service-scoped systemDevices fallback.
Device-tested on a Samsung S25: a previously-invisible node now
appears in the scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's `dart format --set-exit-if-changed .` step is repo-wide. My A/B/C
commit hand-wrote the .catchError blocks without running the formatter,
and 2 settings views carried pre-existing format drift from #28. dart
format reflows all three (whitespace only, identical logic):
- lib/connector/meshcore_connector.dart (this PR's blocks)
- lib/screens/settings/app_settings_view.dart (pre-existing, #28)
- lib/screens/settings/message_settings_view.dart (pre-existing, #28)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>