Skip to content

feat(app): sweep the repeaters you can hear when no target is picked - #484

Merged
efiten merged 3 commits into
efiten:masterfrom
khagele:feat/479-probe-plan
Aug 29, 2026
Merged

efiten merged 3 commits into
efiten:masterfrom
khagele:feat/479-probe-plan

Conversation

@khagele

@khagele khagele commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

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".

@efiten efiten left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@efiten

efiten commented Aug 24, 2026

Copy link
Copy Markdown
Owner

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.

khagele and others added 2 commits August 26, 2026 20:34
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>
@khagele
khagele force-pushed the feat/479-probe-plan branch from 6256956 to 74ea531 Compare August 26, 2026 18:35
# Conflicts:
#	app/changelog.json
#	app/src/__tests__/feed.test.js
#	app/src/app.js
#	web/changelog.json
@efiten
efiten merged commit 7f2eafb into efiten:master Aug 29, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 29, 2026
efiten pushed a commit that referenced this pull request Aug 29, 2026
🤖 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>
efiten pushed a commit that referenced this pull request Aug 30, 2026
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>
@khagele
khagele deleted the feat/479-probe-plan branch September 12, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app: autoping sends one kind of probe, so most of what a repeater will answer is never asked for

2 participants