Repository navigation
feat(app): name a trace reply after the node we pinged - #483
Conversation
efiten
left a comment
There was a problem hiding this comment.
Read the firmware rather than the citation, and the load-bearing claim is exactly right. simple_repeater/MyMesh.cpp:426:
bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
if (_prefs.disable_fwd) return false;
if (packet->isRouteFlood()) { ...flood_max, region, loop detect... }
return true;
}Every throttle — flood_max, flood_max_unscoped, flood_max_advert, the region check, loop detection — sits behind isRouteFlood(). A directed TRACE is isRouteDirect(), so it passes on disable_fwd alone. And Mesh::onRecvPacket's trace branch has no rate limit in it either. So "the app's most effective probe" is a claim about the firmware and it holds.
The regression you caught is the better half of this PR. selectedRepeaterIds reading only the newest record per id means the trace reply becomes that newest record, its sender_kind stops being relay, and the node drops out of the ping set — a node stops being pinged because it answered. Autoping would have pinged each target exactly once and the symptom would have looked like autoping being broken, not like an attribution change. Counting trace_reply as forwarding behaviour is right: only a node that forwards retransmits a directed trace.
Refusing the match when two live pings address the same first byte is the right rule and the right precedent (§7: ambiguity is evidence against, never for). Being explicit that the tag narrows the claim to "this is the reply to our probe" and not to "this id is proven" is the honest framing of a one-byte address.
The 30 s expiry argument is sound in the direction it argues — a late match costs nothing because the reception is a real measurement wherever we were standing. Worth noting the asymmetry runs the other way too: with two orders of magnitude of slack, the ambiguity refusal is doing more work than the expiry is, which is the right way round.
One thing to carry into #484, not a change here. This establishes that our probe is unthrottled at every hop of the reply path. That is a good property for a hunt and a less good one for the network if the probe rate goes up — and #484 sweeps every audible repeater when no target is picked, which multiplies exactly this. Raising it there rather than here, since this PR only names one target at a time.
Approving.
|
Approved. Needs a rebase; #478 is ahead of it in the app queue. |
381597a to
836130b
Compare
891d1be to
397351a
Compare
A trace-ping is the app's most effective probe -- the firmware forwards a directed TRACE on a hash match with no rate limit anywhere in the path -- and since efiten#455 the retransmission it provokes is captured. It was stored with no sender, because classifyReception refuses to read a TRACE packet's path bytes as ids: they are per-hop SNR values. That is right for traffic we overheard and wrong for a packet we asked for. We generate the tag, the firmware echoes it back, so the reply is tied to the ping and the ping to a target. The measurement of the node being hunted stopped being an anonymous point that Locate never sees. The claim is bounded: the trace path names its first hop with ONE byte, so a 1-in-256 collision answers exactly as the target would. Where the ambiguity is visible -- two live pings addressing the same byte -- the match is refused rather than guessed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
397351a to
ade2bdd
Compare
🤖 I have created a release *beep* *boop* --- <details><summary>app: 1.21.0</summary> ## [1.21.0](app-v1.20.0...app-v1.21.0) (2026-08-28) ### Features * **app:** name a trace reply after the node we pinged ([#483](#483)) ([04eded1](04eded1)) * **web:** name the picked target on the picker button ([#499](#499)) ([cabe596](cabe596)) </details> <details><summary>web: 1.17.0</summary> ## [1.17.0](web-v1.16.0...web-v1.17.0) (2026-08-28) ### Features * **app:** name a trace reply after the node we pinged ([#483](#483)) ([04eded1](04eded1)) * **web:** name the picked target on the picker button ([#499](#499)) ([cabe596](cabe596)) ### Bug Fixes * **web:** drop the hop notice from the map ([#508](#508)) ([ac925df](ac925df)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…484) Closes #479 **Stacked on #483** (`feat/481-trace-tag`): the backoff needs to know whether a node answered, and that signal is the trace-tag attribution. Until #483 merges its commit shows in this diff; the change here is the second commit. ## What the firmware will answer, and what we were asking for Autoping sent one kind of probe to one part of what the radio hears: a Discover broadcast plus a trace-ping per **selected** target. A repeater we heard but had not selected got nothing beyond the broadcast. | probe | gate in firmware | answers per minute | |---|---|---| | trace-ping | none — `Mesh::onRecvPacket` (`src/Mesh.cpp:41`) forwards a DIRECT TRACE on a hash match, and a repeater's `allowPacketForward` only tests `disable_fwd` for a direct packet | unbounded | | Discover | `discover_limiter(4, 120)` (`examples/simple_repeater/MyMesh.cpp:867`) | 2.0 | | anon request (regions/owner/clock share one bucket) | `anon_limiter(4, 180)` (`MyMesh.cpp:868`) | 1.33 | The cheapest probe, and the only unmetered one, was the one we were not sending. Since #455 the retransmission it provokes is captured, and since #481 it is attributed — so a sweep is now measurements rather than anonymous dots. ## What changes - **With a selection: nothing.** Those targets and nothing else — airtime spent elsewhere is airtime not spent on the hunt. - **With no selection:** the cycle rotates a batch of four over every repeater heard directly, taking turns so the whole set gets asked rather than the head of the list. - **The Discover broadcast keeps its cadence.** It overruns any one repeater's bucket (12 per two minutes against four answers) and that is fine: a moving hunter keeps meeting nodes whose bucket is untouched, and one broadcast reaches all of them at once. - A node that does not answer is demoted and backed off (0 / 30 s / 2 min / 10 min). **Hearing it again resets that** — in range is a property of where the hunter is now, not of the clock, and a repeater we drove back towards should not sit out an old backoff. Any traffic from it counts, not only its reply, which is the cheaper signal and just as good. ## Two decisions worth naming - **Batch size four, and the ceiling is the send queue, not the duty cycle.** The companion's queue is 16 slots and drops silently on overflow; `STAGGER_MS` (1.5 s) exists to drain it one packet per slot. Four pings is 6 s of a 10 s cycle, so the broadcast, the pings and their replies all fit and the queue is empty before the next tick. The issue said one per cycle; I raised it and updated the issue. - **Bookkeeping follows the frame, not the plan.** The ask is recorded inside the send callback, only when `sendTracePing` reports the frame went out. Recording it at plan time meant a BLE drop between the tick and the staggered timer cost a node its place in the rotation for something that never left the phone. ## What bounds this, since the duty cycle does not Half-duplex: the radio cannot hear while it transmits. Four pings plus a broadcast is ~230 ms of transmission per 10 s cycle at SF7 (~46 ms per ~13-byte frame, the figure `autoping.js` already carries), so ~2.3% of listening time. That is the number to watch in the field, and it is the reason the batch is bounded at all. ## What a sweep costs the node it asks The 2.3% above is our own deaf time. The other side of it is not ours to spend: every ping makes a repeater transmit, on a path the firmware rate-limits not at all, and the cost per node rises as the area empties. @efiten's figures from the review: | repeaters heard | replies/min each | that node's airtime | |---|---|---| | 2 | 12.0 | 0.92% | | 4 | 6.0 | 0.46% | | 6 | 4.0 | 0.31% | | 10 | 2.4 | 0.18% | | Discover, for comparison | 2.0 | 0.15% | In a two-repeater rural hunt each node is asked for six times what a Discover-limited exchange would cost it, and nothing in the firmware refuses. The batch stays at four, and this is the sentence saying so on purpose rather than by omission: - The sweep runs only with no target selected. Picking one stops it, so this is what the mapping mode costs, not the hunt. - Four is what the companion's 16-slot send queue drains in a cycle. Scaling the batch to the set puts that ceiling and the per-node rate in tension on every tick, and the gain is a guess until a sweep is measured against a real repeater's own duty accounting. - 0.92% is inside the 1% the band allows, but not by much, and it is a cost imposed on hardware we do not own. What would change it: a repeater reporting its duty near the ceiling during a sweep, or two hunters sweeping the same pair, which doubles every figure above. Both are field observations rather than something this PR can settle. ## Verification - `app/`: 42 files, 737 tests green. `web/`: 378 vitest plus all 147 Playwright specs with `--fail-on-flaky-tests`. Build and eslint clean. - Mutation-checked, each red for its own reason: the in-range signal removed (2 red), the backoff boundary moved (1), the promotion of answering nodes removed (1), the clamp on the last backoff step removed (1), the forwarding filter removed from `heardRepeaterIds` (3), the per-frame collapse removed (2). - `heardRepeaterIds` shares its body with `selectedRepeaterIds` rather than restating it, so the sweep and the targeted path cannot disagree about what counts as a repeater or about collapsing ids that produce the byte-identical frame. Changelog entry added to both copies, `where: "app"`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #482 **Stacked on #483** (`feat/481-trace-tag`): the reading is only meaningful on a reply we provoked, and knowing that a reply is ours is what #483 added. Until it merges its commit shows in this diff; the change here is the second commit. ## What we were throwing away A trace reply carries, in its path bytes, the SNR at which each hop heard the transmission it forwarded — the firmware writes `(int8_t)(pkt->getSNR() * 4)` before retransmitting (`src/Mesh.cpp`). For a trace we sent, the first of those is the node we addressed reading **our** ping. That is the reciprocal of every measurement this app makes. The RX log says how well we hear a node; this says how well that node hears us, from the same position at the same moment. An asymmetry between the two is what an obstruction or a bad antenna looks like — and with #484 sweeping, every repeater in earshot produces one of these per cycle. We were decoding the packet and dropping the number. ## The issue asked for a 0x89 handler; it is not needed #482 originally proposed parsing `PUSH_CODE_TRACE_DATA` (0x89), the frame the companion emits when a trace completes. That turned out to be redundant: `Dispatcher::checkRecv` calls `logRxRaw` for **every** raw reception before parsing (`src/Dispatcher.cpp:199`), so the completing packet is already in the 0x88 log the app processes, and the decoder exposes its path SNRs. No new frame handler, no new companion surface — only reading what we already have. The issue is rewritten to match. ## Where it is kept, and where it is not - Its own field, `heard_us_snr`. Not folded into `snr`, which means our own radio's reading on every other row. - Only on a reply we provoked, attached where #483 recognises our tag. On an overheard trace those same bytes describe other nodes hearing each other, which would be a number with no owner. - Not published. `buildPayload` is an explicit shape and the ingestor has no column for it; app-local until something reads it. - Nothing renders it, so there is no changelog entry — a user cannot see this yet (AGENTS §6). ## Verification - `app/`: 42 files, 741 tests green. Build and `npx eslint src` clean. - Mutation-checked: reading the second hop instead of the first (3 red), and feeding the field from our own `snr` instead of the packet's (2 red). - The firmware encoding is pinned through the real decoder rather than a hand-built fixture: a path byte of `0xf0` is −16 as int8, ÷4 = −4 dB, asserted end to end from hex. - A composition test runs the sequence `processFrame` runs and asserts both readings land side by side and mean different things: `heard_us_snr` −4 from the path byte, `snr` −4.25 from the 0x88 header. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #481
A trace-ping is the app's most effective probe. The firmware forwards a directed TRACE on a hash match with no rate limit anywhere in that path —
Mesh::onRecvPacket(src/Mesh.cpp:41), and a repeater'sallowPacketForwardonly testsdisable_fwdfor a direct packet — where a Discover answer is capped at 4 per 120 s and an anonymous request at 4 per 180 s. Since #455 the retransmission it provokes is captured instead of dropped.It was stored with no sender.
classifyReceptionrefuses to read a TRACE packet's path bytes as node ids, correctly: they are per-hop SNR values. So the measurement of the node being hunted landed on the map as an anonymous point, and Locate never saw it.What names it
We generate the tag, the firmware echoes it back in the retransmission, and the decoder already exposes it as
payload.decoded.traceTag. A packet carrying a tag we sent is the reply to our own probe.app/src/tracetag.jsremembers(tag → target)when the ping goes out and answers "whose reply is this" when one comes back.Only where
classifyReceptionfound no sender, and only on our own tag: overheard trace traffic stays anonymous exactly as before.The claim, and its edge
buildTracePathFrameaddresses the first hop with one byte of the target's id, so any node whose id starts with that byte answers a trace addressed that way. The tag narrows the claim to "this is the reply to our probe" — it does not widen it to "this id is proven", and the code says so where it does it.Where the ambiguity is visible the match is refused rather than guessed: two live pings addressing the same first byte produce no attribution at all. That is the same rule AGENTS §7 applies to prefix merging — ambiguity is evidence against, never for.
A ping expires after 30 s. That is generous by two orders of magnitude against a zero-hop reply, deliberately: a late match costs nothing (the reception is a real measurement of that node at wherever we were standing when it arrived) while a premature expiry throws the attribution away.
A regression this would have shipped with
selectedRepeaterIdsreads only the newest record per id. Once the trace reply became that newest record, itssender_kindwas no longerrelayand its role was notRepeater, so the target dropped out of the ping set — a node stopped being pinged because it answered. Autoping would have pinged each target exactly once.trace_replynow counts as forwarding behaviour, which is what it is evidence of: only a node that forwards packets retransmits a directed trace; a companion does not. Pinned by two tests that were red before the fix.Verification
app/: 41 files, 717 tests green.web/: 378 vitest plus all 147 Playwright specs with--fail-on-flaky-tests.npm run buildandnpx eslint srcclean.selectedRepeaterIdstests were red before their fix.processFrameruns — decode, classify, read the tag, match,buildRecord— over a TRACE packet built to the firmware's own layout, and asserts both halves: the row carries the target's id, and it carries no name (an id is an id).Note for whoever merges second
#478 edits the same two lines of
processFrame. Whichever lands first, the other rebases — the resolution is mechanical (both turnconst clsintolet clsand add one branch).Changelog entry added to both copies,
where: "app".