Repository navigation
feat(app): sweep the repeaters you can hear when no target is picked - #484
Conversation
efiten
left a comment
There was a problem hiding this comment.
The firmware table is right where it matters — discover_limiter(4, 120) and anon_limiter(4, 180) are at simple_repeater/MyMesh.cpp:860-861 (your citation says 867/868, a few lines off but the values are the values), and I verified separately on #483 that the directed-TRACE path really is unmetered: every throttle in allowPacketForward sits behind isRouteFlood(). So "the cheapest probe, and the only unmetered one, was the one we were not sending" holds.
The two named decisions are both good. Bounding the batch by the send queue rather than the duty cycle is the honest constraint — 16 slots that drop silently on overflow is a much harder edge than a percentage. And recording the ask inside the send callback rather than at plan time is the right instinct: a BLE drop between the tick and the staggered timer should not cost a node its place.
Resetting the backoff on any traffic from a node, not only its reply, is the better rule for the reason given — in range is a property of where the hunter is now.
One thing I want to put numbers on, because the PR bounds our transmission and the thing it provokes is someone else's.
Your 2.3% figure is the hunter's own deaf time, and it is fine. But each ping makes a repeater transmit, on a path with no limiter, and that side is not in the table:
| 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% |
So in the sparse case — two repeaters audible, which is a normal rural hunt — we ask each of them for six times what a Discover-limited exchange would, and the firmware has nothing to refuse with. Two hunters sweeping the same pair doubles it again. That is not obviously wrong, and 0.9% is not obviously over anything, but it is a cost we impose on hardware we do not own, and it goes up as the area gets emptier, which is the opposite of the intuition the batch size was chosen against.
Worth either a sentence in the PR that says so plainly, or a floor — scale the batch to the set rather than fixing it at four, so a two-repeater area gets the same per-node rate as a ten-repeater one. I lean towards the latter, but it is a judgement about being a good neighbour on a shared channel rather than a defect, and it is your call and @efiten's.
Approving — the mechanism is right and well-tested, and this is a question about the constant, not the design.
|
Approved. Stacked on #483, so it rebases after that one lands. The per-node airtime question from my review still stands as a judgement call, not a blocker. |
dbf3c16 to
1a49fb7
Compare
da966eb to
6256956
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>
Auto-ping 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, which it answers at most 4 times per 120 s (discover_limiter). A trace-ping is answered by a retransmission we hear zero-hop, and the firmware rate- limits that path not at all -- so the cheapest probe was the one we were not sending. With a selection the behaviour is unchanged: those targets and nothing else. With no selection the cycle now rotates a batch of four over every repeater heard, bounded by the send queue and the stagger that drains it rather than by a duty budget. A node that does not answer is demoted and backed off; hearing it again resets that, because in range is a property of where the hunter is now, not of the clock. Bookkeeping follows the frame rather than the plan: a ping that never left the phone does not count as an unanswered ask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6256956 to
74ea531
Compare
# Conflicts: # app/changelog.json # app/src/__tests__/feed.test.js # app/src/app.js # web/changelog.json
🤖 I have created a release *beep* *boop* --- <details><summary>app: 1.22.0</summary> ## [1.22.0](app-v1.21.0...app-v1.22.0) (2026-08-29) ### Features * **app:** remove Locate, rename its maths to geometry.js ([#540](#540)) ([6dd94ae](6dd94ae)) * **app:** sweep the repeaters you can hear when no target is picked ([#484](#484)) ([7f2eafb](7f2eafb)) </details> <details><summary>web: 1.18.0</summary> ## [1.18.0](web-v1.17.0...web-v1.18.0) (2026-08-29) ### Features * **app:** remove Locate, rename its maths to geometry.js ([#540](#540)) ([6dd94ae](6dd94ae)) * **app:** sweep the repeaters you can hear when no target is picked ([#484](#484)) ([7f2eafb](7f2eafb)) </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>
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 #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.
Mesh::onRecvPacket(src/Mesh.cpp:41) forwards a DIRECT TRACE on a hash match, and a repeater'sallowPacketForwardonly testsdisable_fwdfor a direct packetdiscover_limiter(4, 120)(examples/simple_repeater/MyMesh.cpp:867)anon_limiter(4, 180)(MyMesh.cpp:868)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
Two decisions worth naming
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.sendTracePingreports 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.jsalready 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:
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:
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.heardRepeaterIds(3), the per-frame collapse removed (2).heardRepeaterIdsshares its body withselectedRepeaterIdsrather 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".