The composer GIF button becomes a +, opening a short picker with GIF and
My contact card. Owner decision after seeing the build: one affordance
beside the text entry rather than a button per attachable thing, because
the sendable set stays small when a channel message shares a 160-byte
payload with the Sender: prefix.
Sends the COMPACT format, not the meshcore:// URI:
<{64-hex key}:{type}:{name}>
That shape was observed on the live mesh, twice, from different senders
in #test and #hamradio four weeks apart. It costs about 75 bytes against
117 for the equivalent URI, so on a 160-byte budget the difference is
airtime rather than tidiness. The URI form stays correct for a QR, a DM,
or an out-of-band paste; this is the channel idiom.
Angle brackets are stripped from an emitted name because they are the
delimiters, and a name carrying one would truncate the payload for every
parser reading it. A colon is left alone: the name is the final field,
so a correct parser splits on the first two colons and takes the rest.
Inserts into the composer rather than sending, matching the GIF picker,
so the card can be captioned and reviewed first. It appends, so a
caption already typed is not destroyed.
Parsing a received share is #610 and is not in this change, so an
inbound card still renders as raw text for now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a calm three-state indicator beside each contact name, per the
owner steer: a green check for advert-verified, a neutral outline check
for key-confirmed, a muted key for key-only. No amber and no hazard
glyph, because nothing is wrong with a key-added contact. The scale
reads as how much we know, never as how risky.
The states are not cosmetic:
- advertVerified: a signed advert arrived, so the node itself asserted
its name, type and position, and the raw packet is stored, which is
also what makes the contact re-shareable.
- keyConfirmed: a message with this contact went through. Direct
messages are encrypted with an ECDH secret derived from the contact
key, and the ACK is computed over the decrypted plaintext
(BaseChatMesh.cpp:442,451), so this is proof the holder of the
matching private key is live. It does NOT prove the person is who the
name claims.
- keyOnly: someone supplied a key and nothing has confirmed it on air.
Costs almost nothing to compute. Contact.isAdvertVerified falls out of
the epoch last_advert_timestamp that #627 already writes, and it clears
itself when a real advert rewrites the field. The advert check runs
first so only an unconfirmed contact pays for a message scan, which
keeps a long contact list cheap.
Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran
the epoch through the relative formatter and claimed a key-added contact
was last seen tens of thousands of days ago. It now reads Not heard yet.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both halves of the visual exchange, sharing one format and one parser.
Render: showMyContactQrDialog puts this device's own identity on screen
as a QR plus the same link as selectable, copyable text. It refuses to
render when not connected, since an empty key would encode a QR nobody
can add.
Scan: ContactQrScannerScreen validates with Contact.isValidShareUri, the
same check the paste path uses, and pops the raw string. The add dialog
routes a scan through the same handler as a paste, so a QR gets no
separate code path.
The scan affordance is gated to platforms mobile_scanner supports
(Android, iOS, macOS, web). On Windows and Linux the QR half is the
render side, which is the better desktop flow anyway: put your code on
the big screen and let the other person scan it with a phone.
Fixes a real defect found by the new test, not a test artifact:
QrCodeDisplay built its QrImageView through a LayoutBuilder, which
cannot answer intrinsic dimension queries, so any intrinsic-measuring
parent threw. AlertDialog measures its content's max intrinsic height,
so the dialog crashed on open. The QR is now bounded by a tight
SizedBox, which answers the intrinsic itself. This was the widget's
first real call site, so the bug had never been exercised.
Entry point is provisional alongside Add by key; #632 reorganises.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First user-reachable piece of the identity exchange. A dialog takes a
public key, a name and a contact type, and hands the stub to
addContactByKey.
The key field also accepts a whole meshcore://contact/add link and
absorbs every field from it, because that is what someone actually
pastes, and a QR is only that link rendered visually. Whitespace is
stripped so a key copied across a line break still works.
The stub is built by round-tripping through buildShareUri and
fromShareUri rather than constructing a Contact directly, so manual
entry and a scanned QR cannot drift apart. One code path, one set of
invariants.
An informational note states plainly that the contact is not confirmed
on air and that the name is whatever the user typed until the node
adverts. Deliberately informational rather than a warning: nothing is
wrong with a key-added contact (#630).
Entry point is provisional, sitting in the contacts overflow menu so the
feature is reachable. The proper add-contact surface, split away from the
advert affordance, is #632 under epic #623.
Five widget tests pin what reaches the connector: key, typed name,
chosen type, the flood sentinel, and the epoch lastSeen that keeps the
firmware replay guard from muting the contact. Also covered: pasted-link
prefill, wrapped-key whitespace, an invalid key sending nothing, and the
missing-name fallback.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds MeshCoreConnector.addContactByKey, the device-side half of the
identity exchange. It uses the stock CMD_ADD_UPDATE_CONTACT (command 9),
whose handler creates the contact outright when the public key is
unknown. Verified byte-identical to upstream meshcore-dev/MeshCore at
0679dbef, so this needs no capability gate and works on stock firmware,
not only Offband builds.
buildUpdateContactPathFrame gains an optional lastAdvert. It defaults to
now, leaving all three existing path-update callers byte-identical, and
addContactByKey passes the epoch.
That epoch is the whole point. The firmware compares this field against
every incoming advert with 'timestamp <= last_advert_timestamp' and
silently discards the non-greater ones as replay attacks
(BaseChatMesh.cpp:142-145). Advert timestamps come from the sender's
clock, and two nodes on this mesh currently advertise with 2024 clocks,
so stamping now would leave a key-added contact permanently deaf to its
own adverts. A negative input clamps to zero rather than wrapping to a
huge value, which would be the worst case for that guard.
The frame is sent at full length with the 0xFF flood sentinel. The
firmware length guard is only 'len >= 36' but updateContactFromFrame
reads through offset 136 regardless (MyMesh.cpp:295-318), so the frame
must never be trimmed. A test pins that too.
Local state keeps lastSeen at the epoch so the contact reads as
unverified until a real advert upgrades it (#630).
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.toShareUri and the static Contact.buildShareUri, the inverse
of #625's parser. Emitting the reference-app format is what makes an
Offband card or QR importable by a stock, non-Offband user.
buildShareUri takes raw parts rather than a Contact so the local device
can share its OWN identity, which is a public key plus a node name and
never a Contact instance.
Spaces are percent-encoded rather than emitted as '+'. Both decode to a
space, and this matches Channel.toShareUri, which the codebase already
documents as round-tripping with the reference app's QR (#161).
Tests cover the emitted parameter shape, the companion default, and
round-tripping through fromShareUri for names with spaces, emoji and
reserved characters. A raw & or = in a name would otherwise truncate or
forge query parameters, so that case is pinned explicitly.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.fromShareUri / isValidShareUri for the reference-app format
documented in firmware docs/qr_codes.md:
meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4>
The payload is a bare public key, not a signed advert, so the parsed
Contact is an identity stub: no path, no position, no rawPacket. A QR is
this same URI rendered visually, so scanning will share this parser.
lastSeen is deliberately the epoch rather than DateTime.now(). It maps to
the firmware's last_advert_timestamp, which the advert handler compares
with 'timestamp <= last_advert_timestamp' and treats as a replay attack
(BaseChatMesh.cpp:142-145). Stamping now would leave the contact
permanently deaf to its own adverts, since advert timestamps come from
the sender's clock and two nodes on this mesh currently advertise with
2024 clocks. A regression test pins this.
The fork's older meshcore://<raw advert hex> form returns null here, so
callers keep routing it to the existing advert import path.
Epic #619. Not yet wired to the UI: the import path needs the send half
(#627) before there is anything to add.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unresolved state claimed "Haven't heard this node's advert yet". The
condition behind it is only that the claimed name matched nothing, which is
not the same thing: a node renamed since its last advert is known to us, just
not under the name it is posting with.
Now reads "No node known by that name" with a hint that adding needs an
advert from the node, so it states what we know and what unblocks it without
asserting something we cannot check. Tracked as issue 579.
Gemini pre-PR review finding. importDiscoveredContact no-ops when the radio
is gone, and sendFrame throws on a mid-write disconnect or an unwritable BLE
characteristic. Both reached the user as a tap that appeared to work.
Checks the connection first, catches the write failure, logs it, and shows a
persistent error snackbar in either case (SAFELANE section 6: no silent
failures).
Channel frames carry no key, so the sender name is resolved against known
and discovered contacts. Adds resolveContactsByName (returning Contacts,
which importDiscoveredContact needs) and rebuilds resolveContactKeysByName
on top of it so one matching rule serves both.
The avatar is the shortcut; the sender name stays inert because it sits too
close to the message body to hit reliably on a phone. Long-press keeps every
action it had and gains the same contact rows. A name several nodes claim
lists all of them, and an unheard name says so instead of failing silently.
Three findings reviewed, two applied, one rejected on evidence.
APPLIED. Suppression logging is de-duplicated. Device-info arrives on
every connect, so a flapping radio would have emitted the same
suppression line on each reconnect. It now logs once per distinct
reason, and the memo clears when a gate opens or when per-radio state is
cleared, so the next genuine suppression is still reported. Real, and
exactly the flooding the project forbids.
APPLIED, though not reachable today. The scope reply logged
reply.scope!, which is safe only because parseNotifyScopeReply returns
an ERROR reply when the scope code is unknown, so a non-error reply
always carries a scope. That invariant lives in another file, so it is
now checked at the boundary instead of asserted with a bang across it.
The review rated this High on the theory that an unknown sub-code could
reach the handler; it cannot, because the parser returns null for any
sub-code it does not own and the dispatcher only forwards what it
returned. Defect not reachable, guard still worth having.
REJECTED. The review claimed the matrix reply log is unbounded because
the wire count byte allows up to 255 rows, estimating 7.5 KB per line.
The log is built from ButtonMatrix.assignments, a Map keyed by
ButtonSequence, which has exactly four values, and the parser skips
sequence codes it does not recognise rather than adding them. The map
therefore holds at most four entries and the line is bounded at roughly
160 characters no matter what the device sends. The count byte cannot
inflate it.
Full suite 740 pass, analyze clean, format clean.
Agent: CalmBay (session d14220d9)
The device-UI surface logged only refusals, so a successful read or
write produced nothing at all.
That gap cost a full diagnosis on 2026-08-02. When the notification
scope appeared not to refresh, neither the app debug log nor a firmware
serial capture could distinguish "the client never asked" from "the
client asked and got an unchanged value back". Answering it needed the
firmware counterpart and a serial capture, for a question the client
should have been able to answer alone.
- Every request logs what it sent and the frame shape.
- Every reply logs what came back: the scope value, or the matrix mask
and its decoded rows.
- A SET reply is distinguishable from a GET reply, so a confirmed write
is not mistaken for a read.
- Refusals keep their existing warn-level logging, unrecognised reason
codes still surface as raw hex.
The important one is the third case, which did not exist before: a
request that is NEVER SENT now says so and says which gate closed, with
the caps2 value that closed it. Silence was the ambiguity; suppression
is now explicit.
No behaviour change. Logging only, at info for normal traffic and warn
for refusals, tagged DeviceUI to match the existing handler logs.
No flooding risk: these fire on device-info, on pane open, and on user
action. Nothing polls, and no per-frame logging was added (SAFELANE §11
rule 10).
Full suite 740 pass, analyze clean, format clean.
Agent: CalmBay (session d14220d9)
1. An unprefixed reply no longer resolves an arbitrary pending command.
#532 constrained prefixed replies only; the unprefixed fallback still
used firstWhere, which with two or more in flight is map-iteration
order, so a caller could receive another command's output. The
fallback cannot just be deleted: firmware only echoes the prefix when
the command exceeds four characters including it
(simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'),
so a very short command legitimately answers without one. It now
resolves only when exactly one command is pending, and surfaces the
ambiguity otherwise.
2. A late reply no longer overwrites unsaved edits. The settings screen
applied a late `get` payload unconditionally, so a reply arriving after
its timeout could revert a field the user had typed into while waiting.
It now applies only when nothing is dirty, and says which happened.
3. _expiredCommands is pruned on timeout as well as on receive. It was
pruned only in handleResponse, so a run of commands that all time out
with no traffic coming back accumulated records until disposal.
The reviewer described 3 as unbounded growth. It is bounded at 256 by the
prefix token space, and stale records could never be mis-attributed
because handleResponse prunes before matching, so the severity was
overstated. Fixed anyway; it is nearly free.
A fourth finding, that the new l10n strings are untranslated in the other
17 locales, is rejected. That is this project's established pipeline:
strings are authored in app_en.arb, gen-l10n emits English fallbacks, and
untranslated.json tracks the gap, which it now does for these keys. Every
string in the app arrived this way, so it is not a defect this branch
introduces.
The ambiguity test was verified to fail against the pre-fix code.
The flood arm read as though the model were consulted:
if (pathLength < 0) {
// Flood: trust ML, only enforce firmware formula as floor
if (mlTimeout < physicsMin) return physicsMin;
}
return mlTimeout.clamp(physicsMin, physicsMax);
It never did that. _physicsMinTimeout and _physicsMaxTimeout return the
identical expression for pathLength < 0, mirroring the firmware's
calcFloodTimeoutMillisFor, so a clamp between them cannot preserve a
prediction. Whichever way control went, flood returned 500 + 16 * airtime.
The comment described behaviour the code did not have, which is exactly
the kind of load-bearing false comment that re-causes a bug later.
Replaced with an explicit early return and the constraint written down.
Behaviour is unchanged.
Equivalence is tested, not asserted. A new group attaches a real
TimeoutPredictionService, trains it on 40-56 s delivery times so any
prediction is far from 1300, and checks flood is unmoved while a direct
path is still clamped to its ceiling, which proves the clamp is live
rather than the predictor being absent. That group was run against the
pre-change branch and passes there too, so this is a simplification and
not a behaviour change.
Deliberately NOT decided here: whether flood should ever trust a
prediction. Every round trip measured to date was 0-hop direct, so there
is no flood data to decide it from. The verification needed to answer it
is written up on the issue.
Part of epic #473, stacked on #528, #529, #531 and #532.
handleResponse looked up the reply's prefix, and when that matched nothing
pending it fell through to "first pending command for this repeater". So a
straggler from an expired command could complete an unrelated in-flight
one, reporting one command's output as another command's result.
The service already carries a correlation token in every command and every
reply echoes it. The fallback discarded that. Now a reply that carries a
prefix is only ever matched to the prefix's owner; if there is no owner it
goes to the unmatched handling added in #528, where an expired command is
still named. Only a reply with no prefix at all, which has no correlation
token to honour, may fall back to matching by repeater.
Not reachable from today's callers: all four pass retries: 1 and the
settings refresh awaits each command, so two are never in flight to one
repeater. It becomes live the moment anything issues concurrent commands,
which any retry work would.
The two negative tests were verified to fail against the old logic and
pass against the new, so they are a real guard rather than a restatement.
The two preservation tests pass either way by design.
_registerPending is extracted so the test seam and the real send path
cannot drift apart.
Part of epic #473, stacked on #528, #529 and #531.
The timer ran timeoutMs while the message printed
(timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced
"timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired
at 4.07 s while claiming 5, which is what made the behaviour look
arbitrary rather than deterministic: the number shown was never the
number used.
The service now throws a typed RepeaterCommandTimeout carrying the window
that was actually armed, formatted to one decimal. Every existing caller
already stringifies the error, so all of them inherit an honest figure
without being touched; the CLI screen additionally renders it through a
new localized string rather than the generic error wrapper.
Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms
budget reports 28.7 rather than 29, and that two windows inside the same
second no longer collapse to the same text, which was the defect's
signature.
Part of epic #473, stacked on #528 and #529.
The comment justified not modelling command execution time by claiming
`wifi on 30` does real work bringing up an interface. It does not. The
firmware handler sets a persistence deadline and sprintf's its reply
immediately (CommonCLI.cpp, "wifi on"), so execution is near-instant.
The owner caught it: he recalled reissuing the command several times
against on-screen errors, not one command taking 20 seconds.
The measurement supports him. On the 20.33 s case the reply carried
claimed=01:56:32 against a command sent at 01:56:26.938, and the RF frame
did not reach our radio until 01:56:47.271979. So roughly 5 s to reach the
repeater and be answered, then roughly 15 s in its transmit queue. The
tail is scheduling on both radios.
No behaviour change. The budget is unchanged and still has to tolerate a
20.33 s round trip; only the stated reason was wrong, and a wrong reason
in a load-bearing comment re-causes the bug later.
The CLI timeout used calculateTimeout, which mirrors the firmware's
calcDirectTimeoutMillisFor and estimates ONE-WAY delivery. A CLI command
is a request, an execution and a reply, so the budget was structurally
short.
Measured against rpt-01 on 910.525 MHz / SF7 / BW 62.5k / CR 4:5, where
the old window was 4074 ms: 27 commands sent, 12 replies matched, and 4
of those 12 arrived after the client had already given up, at 6.17 s,
8.28 s, 15.52 s and 20.33 s against a median of 2.75 s.
calculateCliTimeout sums four terms, each with a source rather than a
chosen value:
outbound leg calculateTimeout, which is what it actually models
cliReplyDelayMs 600, firmware CLI_REPLY_DELAY_MILLIS, unconditional
reply leg the reply is a second packet the ACK formula omits
retrieval budget replies are pull-based; the radio raises MSG_WAITING
and the app must ask, granting itself 5000 ms per
attempt across 3 retries
The retrieval term is derived from the sync constants rather than
restated, so the command timeout cannot drift below the layer it depends
on. That is the #530 invariant holding by construction, not by two
numbers being maintained in agreement.
Execution time is deliberately not modelled. The same verb, wifi on 30,
returned in both 2.31 s and 20.33 s, so it is not a per-command constant
that could be tabulated. The retrieval term carries that tail.
The reply leg uses physics only. The predictor is trained on
direct-message ACK latency, so asking it about a CLI reply leg would be
extrapolation; that is #534 and #535, not this change.
Tests cover the construction, the never-below-retrieval invariant, growth
with path length, coverage of the 20.33 s worst case actually observed,
and negatively that the direct-message ACK path is untouched.
Part of epic #473. Does not change the reported duration string (#531)
or the stale-prefix fallback (#532), and does nothing for commands that
draw no reply at all (#541).
A repeater reply that arrived after its command's window closed hit
`if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse
and was dropped with no log and no UI. Since the command timeout can be
shorter than the app's own message-retrieval budget, that made "the
command ran but the response was never reported" the normal outcome
rather than an edge case, and it affected every repeater screen, not
just the CLI one.
RepeaterCommandService now remembers a timed-out command's prefix for two
minutes, so a reply arriving afterwards can be attributed to the request
it answers. Replies that reach no waiting command are handed to a new
onUnmatchedResponse sink and logged through appLogger. No path through
handleResponse returns without either completing a command, surfacing the
payload, or logging why it could not.
The CLI screen renders these as a distinct history entry naming the
original command and how late it was. The settings screen applies the
value if it is a `get` reply and tells the user it arrived late.
repeater_status_screen already parsed responses independently of the
service, so it had no silent-loss path to fix.
Part of epic #473. Does not change the timeout window itself (#529),
the reported duration (#531), or the stale-prefix fallback (#532).
Adds three tappable rows to the About page opening in the external browser via
url_launcher: offband.org, the Google Play listing (app.offband.meshcore), and
offband.org/donate (Ko-fi + GitHub Sponsors options, live-verified). Failure
surfaces a snackbar. Completes epic #525.
Agent: QuietSnow (session 31eaba02)
About moves out of the Debug pane into a top-level 'About' category (its own
pane, not the stock dialog). Keeps the marketing version display, adds a
description + refreshed legalese (Offband, zjs81/MeshCore MIT attribution kept),
and a View licenses row (showLicensePage). Build identity stays in Device Info
(#553). #527 adds the outbound links here next. Part of epic #525.
Agent: QuietSnow (session 31eaba02)
#456 removed enabled from the profile template but the apply path still
re-enabled via wasLive, carrying over the prior enabled state on import,
against the explicit directive that import brings in NEITHER enabled nor
disabled. Firmware already force-disables a slot on any field write (#53);
apply now passes enable=false and never re-enables. The slot is left disabled
and the operator re-enables intentionally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extractScalarValue required ' = ' (space-equals-space) and trimRight()'d first,
so a blank field's reply 'key =' (no space after =) failed the match and the
whole 'mqtt.broker.N.field =' line leaked in as the value. Split on the first
'=' and trim instead: blank -> empty, values with '=' preserved. Regression
tests added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #256 channel Send Again gated on status != sent, which hid it at the
radio-ack checkmark (status=sent) -- exactly the "transmitted but no repeat-back"
case it was for. It only surfaced on the waiting clock (pending). Gate on
repeatCount == 0 instead, so it shows until a repeater repeats the message back
(repeatCount > 0), then closes off. Matches the original design intent. Follow-up
to #256; DM gate (failed-only) unchanged.
The build stamp rendered as SelectableText, which ate taps for selection, so
only the plain 'Build' label triggered the unlock. When a row is tappable it
now uses a plain Text so the whole row (label + value) is one InkWell target;
copy stays on the button. Part of #509/#553.
Agent: QuietSnow (session 31eaba02)
Hiding the section left enabled features running invisibly. The action is now
'Disable experimental features': disableExperimental() turns off every
experimental flag (CoreScope observer counts, future ones) AND re-locks the
section in one atomic write. Also fixes the subtitle to say 'Build row' (the
#553 anchor), not 'version row'. Part of the #509/#553 experimental section.
Agent: QuietSnow (session 31eaba02)
Follow-up to #509. Settings is post-connect, so About had no availability edge,
and wrapping the About version subtitle fought the About dialog's onTap. The
7-tap unlock now anchors on the Build (BuildInfo.stamp) row in Device Info,
mirroring Android's build-number gesture; About reverts to a plain row. The
countdown/unlock hint renders inline under the Build row.
Co-located on the #524 test branch so the owner tests one APK; will be split
to its own PR off dev at land time. Agent: QuietSnow (session 31eaba02)
Badge stays observers-primary; tooltip/long-press now read 'N observers . M
observations' so they reconcile with CoreScope's 'Observations (N)' feed (the
two are distinct metrics: 15 distinct observers vs 34 total sightings). Refresh
feedback moved from a bottom snackbar to an auto-dismissed top MaterialBanner,
out of the way of the composer. Part of #524.
Agent: QuietSnow (session 31eaba02)
Owner testing: the inline badge needed pixel-accurate taps, the refresh gave no
feedback, and there was no refresh in the message action sheet. Now the badge is
a padded InkWell (real tap target), the long-press panel has a 'Refresh CoreScope
observers' action, both show a snackbar with the fetched count, and the auto-poll
is more patient (adds 90s tail steps, stabilises after 3 flat checks) so it climbs
closer to the total before you need to tap. Part of #524.
Agent: QuietSnow (session 31eaba02)
Poll now runs quick-then-slow (10/20/30/60x4s, ~5min cap) and stops early once
the count is flat for 2 checks, so it climbs to the near-final observer count
instead of stopping at a fixed cutoff. The badge is tappable to re-query on
demand (counts only ever grow), covering later re-checks. Part of #524.
Agent: QuietSnow (session 31eaba02)
Observer counts accrue over time (the packet must propagate the mesh and be
reported before CoreScope has any record), so an instant query at send+0.2s
always missed it. Now polls at ~10/30/60/120s, keeping the highest count as
observers report in; bails on disconnect. Part of #524.
Agent: QuietSnow (session 31eaba02)
Info logs at each step (0xC6 reply match, hash, CoreScope GET url + result,
stored count, message-not-found) so the app debug log shows exactly where the
badge chain breaks. Part of #524.
Agent: QuietSnow (session 31eaba02)
After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND
the owner enabled the feature, the connector queries the packet hash, then
CoreScope for observer_count, and stamps it on the message (background,
best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated
switch in the #509 Experimental section (disabled until the radio supports the
capability), and a distinct cloud badge next to the radio-heard count. All
inert until the firmware PR (#611) lands. Completes the client side of #524.
Agent: QuietSnow (session 31eaba02)
Unifies the previously-duplicated send timestamps (frame builder vs outgoing
message each called now() separately) into one monotonic-per-channel value, so
every channel send has a unique (ts, channel_idx) key. That is the client-side
guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB
determinism would otherwise make two same-second messages a wrong-hash query).
Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524.
Agent: QuietSnow (session 31eaba02)
Builder/parser for the client-issued 0xC6 CMD_OFFBAND_PKT_HASH query (per
firmware #611 contract) and firmwareSupportsPktHash (cap2 0x08 + ver >= 22).
Inert until firmware advertises the capability. Part of epic #524.
Agent: QuietSnow (session 31eaba02)
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope
instance (default map.okimesh.org); returns observer_count or null. Never
throws, so the chat UI degrades silently to radio-only when offline or the
hash is unknown. Part of epic #524.
Agent: QuietSnow (session 31eaba02)
Owner feedback: per-tap snackbars appeared over the version/About row and
blocked the next tap; and a 'Hide' switch sitting ON read backwards.
- Tap progress + unlock/already-unlocked now render inline under the
version number (2s auto-clear timer), so nothing overlays the tap target
or the app bottom.
- Experimental 'Hide' is now a plain action row, not a switch.
Agent: QuietSnow (session 31eaba02)
7 taps on the About version row (rolling ~3s window) reveal a neutral
'Experimental' settings category; persisted on AppSettings, survives
restart, re-hidden from a switch inside the section. Empty of toggles
for now (Fast Sync #118, CoreScope #524 land into it later).
Countdown snackbars after tap 4; already-unlocked feedback. Version-text
tap is absorbed so it counts without opening the About dialog.
Agent: QuietSnow (session 31eaba02)
Channel messages had no resend path: unlike DMs (auto-retried by
MessageRetryService), a channel send that never gets its generic ack/repeat-back
goes to failed with no recovery. Adds a "Send Again" action to the channel
long-press sheet for outgoing messages that are not yet acked (status != sent,
i.e. pending or failed), re-sending via sendChannelMessage.
Also renames the existing direct-chat "Retry" action to "Send Again" (matching
stock MeshCore's "Send Again" for cross-client familiarity); DM gating unchanged
(offered at failed, after auto-retry is exhausted). New l10n strings
message_sendAgain + chat_sendingAgain. Build of epic #256.
Children of #471 (parent stays open for AAB hardware validation, #507).
The block list was one global unscoped list on the phone, unioned onto
every radio on connect. A stale block for one of the owner's own radios
therefore rode onto every fresh/erased radio, and clearing a radio never
stuck: any other radio still holding it re-seeded the global list on
connect, which re-pushed it back.
- #505 block_store.dart: scope keys/names by connected device key (like
the app's other stores); dropLegacyGlobal() deletes the legacy
block_keys_v1/block_names_v1 (drop-and-start-fresh, owner-approved).
No global list.
- #505 block_service.dart: load() drops the legacy global and starts
empty; loadForDevice(deviceKey) swaps the in-memory set per radio and
runs the #250 self-heal. All mutating ops serialized through a Future
chain so loadForDevice and importKeys (different connector frame
handlers, both unawaited) cannot interleave and wipe each other.
- #506 meshcore_connector.dart: the device-key hook loads the connected
radio's list, so the offload union reconciles within that one radio.
Existing radio-side firmware blocks left untouched (no auto-CLEAR on
migration, owner-approved). Tests: per-radio isolation, clear-sticks
across reconnect, legacy-drop, disconnect-clears, load/import race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A transient upstream 503 (e.g. Hugging Face) hard-failed the model download and
surfaced it as an error (#229). The download requests (HEAD, single GET, range
GET) now go through sendModelDownloadWithRetry: 5xx/429 responses and network
exceptions retry with bounded exponential backoff (1..30s, <=5 attempts, honors
Retry-After, cancellable); terminal 4xx (e.g. 404) fail immediately. The retry
logic is a pure injectable top-level function, unit-tested (503-then-200,
persistent-503, 404-no-retry, network-exception, cancel).
Manual retry after a sustained failure is the existing "Download model" button
(re-invokes the download); no new UI added. Build of epic #422.
Switching radios showed the previous radio's channel history (including
its outgoing messages) on the new radio. The in-memory caches
_channelMessages, _conversations, and _loadedConversationKeys are keyed
by channel index / contact key, not by radio, and were never cleared on
a switch; a new radio's empty store could not overwrite them
(_loadChannelMessages only writes on a non-empty read). On-disk stores
are already per-radio (device+PSK since #277), so no re-keying or
migration is needed.
Clear the three caches in _resetConnectionHandshakeState (runs at the
start of every connect). loadAllChannelMessages and _loadMessagesForContact
repopulate from the new radio's store. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner placement. Node Settings order is now node name, location, GPS
enable, Button and buzzer, public key.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
A radio without the caps bits has no button and no buzzer to configure,
so it gets nothing: no tile, no screen, no command.
I had changed the tile to appear whenever a radio was connected and
added a diagnostics pane showing its raw caps byte 2. Nobody asked for
that. I added it on my own initiative because a silently absent screen
looked like a broken client, and it broke the negative test #474
specifies verbatim:
"A device that does not advertise the bit shows no screen and the
client emits no command."
"Settings screen appears ONLY when the device advertises the
capability bit"
That negative test is the reason client code ships ahead of firmware:
cross-compatibility with radios that lack the bits can only be exercised
if the client is out in front. My change would have made it fail on
something I invented rather than on anything real.
The tile is now gated on supportsButtonMatrix || supportsNotifyScope.
The pane keeps a matching guard as belt and braces, not a reachable
path. The no-command-emitted half was already correct and is unchanged.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Three defects. One found by the review, one found while verifying it, one
the review reported at the wrong severity.
1. The device-info reconcile was in the WRONG FUNCTION. It sat inside the
`gps:1`/`gps:0` custom-var handler instead of `_handleDeviceInfo`, so
the headless-UI state was never pulled when caps byte 2 actually
landed. It worked only because the pane re-requests when opened, and
the previous commit message's claim that it re-reads on device-info
refresh was false as written. Now fires where caps2 is parsed.
2. Device-UI state was never cleared between radios. Capability bits
refresh from every device-info frame but the values behind them do
not, so a reconnect could display one radio's notification scope as
another's. Cleared explicitly before re-reading. The review did not
find this one.
3. A confirmed matrix write was silently discarded when no matrix was
held: `_buttonMatrix?.withAssignment(...)` resolves to null and drops
a change the device had already applied. Now re-reads instead.
Review rated this High on the grounds that a user could set an action
before the initial read returned. That path is not reachable: the
dropdown only renders once a matrix exists, so the user cannot fire a
write first. The defect is real, the severity was not.
Also applied the review's structural finding: the dispatcher parsed each
frame to route it and the handler parsed it again. Parsed once and passed
down, so the two copies cannot drift and drop a valid frame.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)
Client UI for the button-action matrix (#474) and the device notification
scope (#475), under Settings > Node Settings.
Speaks the canonical 0xC5 contract published by firmware in
OffbandConfigProtocol.h: one command byte, sub-code selects the surface
(0x01/0x02 scope get/set, 0x03/0x04 matrix get/set, 0x7F error). Not
folded into 0xC0, which is observer-only and would make the feature
unreachable on the headless trackers it exists for.
- Notification scope All/Self/None, labelled as this radio's buzzer and
kept distinct from the per-channel app notify mode. Re-read on open and
on every device-info refresh so a scope changed by triple-pressing the
device is never shown stale.
- Button actions per press sequence, assignable only from the action set
the DEVICE reports via its supported-actions mask, so a board with no
buzzer or no GPS never offers a choice it would refuse. Single press
defaults to unassigned.
- Long press is deliberately not assignable. Firmware owns it for CLI
rescue and power off, and remapping it could leave a screenless board
unrecoverable.
- Failures show the device's own reason via the firmware-owned reason
codes, not a generic error, and the banner persists until dismissed.
An unrecognised reason is surfaced with its raw code rather than
swallowed.
- State only ever follows the device's reply, never the request, so the
UI can never show an assignment the radio rejected and nothing is
faked or stored unacknowledged.
- A radio advertising neither capability bit gets a diagnosis, its raw
caps byte 2 and an explicit statement that no command will be sent,
rather than a blank screen that is indistinguishable from a bug.
caps2 bit assignment is firmware-confirmed: 0x01 notification scope, set
only where PIN_BUZZER is defined, 0x02 button matrix. That is the reverse
of the order the epics were filed in, so it cannot be inferred from issue
numbers.
21 protocol tests: gating, frame encoding, a lying count byte, unknown
sequence/action/scope codes, truncated and foreign frames, reason-code
mapping, and that no sequence models a long press.
NOT YET EXERCISED AGAINST A DEVICE. The firmware 0xC5 handler is written
but unmerged, so until it answers, a read shows its loading row and a
write is not confirmed. Hardware validation of the pair is the owner's
gate and has not happened.
Full suite 740 pass, analyze clean, format clean.
Epic: #474, #475
Agent: CalmBay (session d14220d9)