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 picker emitted `g:<id>`, a convention that exists only in this Flutter
client lineage's parseGif (inherited from upstream, not a MeshCore protocol
feature). A recipient on stock or any non-lineage client has no way to expand
it, so a GIF arrived as opaque text (#223).
encodeGif now emits `https://giphy.com/gifs/<id>`, a form parseGif already
matches, so:
- stock clients receive a tappable URL they can actually open
- Offband keeps rendering inline with no render-side change
- legacy `g:<id>` payloads still decode, back-compat unchanged
Cost is ~41B vs ~20B against a ~160B payload. A full message carrying sender
prefix + reply mention + URL measures ~79B, roughly half the cap.
Checked, and did NOT change, the outbound structured-payload guard in
prepareContactOutboundText/prepareChannelOutboundText: Smaz.encodeIfSmaller
declines to compress a URL (base64 overhead exceeds the dictionary gain) and
Cyr2Lat passes unmapped ASCII through, so a GIF URL survives intact without
widening the guard. Both pinned as regression tests instead.
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>
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>
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>
11 tests for ObserverConfigClient / BrokerListDecoder. 9 green (builders,
response parsing, happy-path decode, malformed-NUL, garbage-numeric, stray-KV).
2 RED BY DESIGN, documenting the Gemini findings to be fixed by C1 (#72):
- DoS: BrokerListDecoder retains 256 slots from a hostile BROKER_KV flood
(must bound to brokerSlotCount=10).
- out-of-range slot 200 is stored (must be dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DirectRepeater.ranking added recencyScore (0..1.8M) to the SNR term (1000/dB), so recency dominated (~1 dB == 1 second), contradicting the comment that recency only breaks ties. Scale recency to a 0-999 bonus so SNR (1000/dB) is primary and recency only decides sub-dB ties.
Offset SNR by its floor (+32; SX126x SNR spans -32..+31.75 dB) so every live ranking stays >= 0, above the -1 stale sentinel; otherwise the scaled-down recency leaves live rankings negative and a stale repeater would outrank a live one.
Adds DirectRepeater.ranking tests: SNR-first, recency tie-break, stale=-1, live-always-outranks-stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The function emitted two consecutive 8-byte position blocks instead of
one, producing a frame 8 bytes longer than the documented layout. When
a caller passed lastModified, the firmware parsed the duplicated second
lat as the timestamp, giving wildly wrong "last seen" values on
imported contacts.
Delete the unconditional first block; keep the conditional block that
correctly skips the optional tail when neither location nor
lastModified is set, zero-fills position slots when only lastModified
is present, and appends the optional timestamp.
Adds test/connector/build_update_contact_path_frame_test.dart with
five cases covering all four optional-tail combinations plus the
fixed-point lat/lon encoding.
Fixes#427