From 980c5c451539df5fa8ff4247e90b4ce72ff1adba Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 22 Sep 2026 23:56:02 +0200 Subject: [PATCH] fix(nodes): stop the Heard By empty state claiming the node is out of range (#2063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2057, which is correct in what it does and overclaims in one sentence. ## The sentence When no observer heard the node directly, the card said: > No observer is within radio range of this node. #2057's own rule cannot establish that: - every **direct route** is discarded, because the firmware removes the sender from the path before retransmitting (`Mesh.cpp`, `removeSelfFromPath`), so the packet cannot say who transmitted it. #2057 measured these at **38% of transmissions over 7 days**. - **28.8% of flood observations with a path** are dropped because the last hop resolves to more than one candidate, and the rule under-attributes rather than guesses. So an empty direct list is missing evidence, not evidence of missing coverage. ## Why it matters in practice Sampled 40 repeaters on a production instance after #2057 shipped: | | | |---|---| | at least one direct observer | 24 | | empty card, "no observer is within radio range" | **16** | | of those 16, with a non-zero `relayObserverCount` | **16** | Every node showing "nobody is in radio range" also showed "Seen via relay by N observers" two lines below. An operator reading that about a working repeater concludes they have a coverage problem they do not have. ## The change Wording only, in both copies of the card (full page and side pane): > No observation proves a direct reception here, which is not the same as being out of range. with the reason in a `title`, so the card stays one line: > Only flood-routed transmissions identify who was heard: a direct route removes the sender from the path before retransmitting (firmware `Mesh.cpp`, `removeSelfFromPath`), and an ambiguous relay hop is left unattributed rather than guessed. So an empty list is missing evidence, not proof of missing coverage. No API change. #2057's rule, shape and performance work are untouched — I verified its firmware derivation against the clone at `0679dbef` before writing this: `Packet.h:83`, the forwarder appending with `packet->getPathHashSize()` at `Mesh.cpp:349`, and `removeSelfFromPath` on the direct path at `Mesh.cpp:89-105` all read as described. ## Test `tests/unit/test-direct-rf-heard-by.js` slices this template out of `public/nodes.js`, so it pins the shipped markup. It now asserts the old sentence is gone and the qualifier is present. Worth recording how that assertion was reached: my first version banned the phrase "out of range" from the card, and it failed — on the new line, which contains that phrase precisely in order to deny it. A word ban was the wrong instrument. Matching the old sentence and requiring the new qualifier is the assertion that actually distinguishes the two states. 8 of 8 in that suite, eslint clean. ## Not in this PR The **Regions** line and **Region** column on the same card read `o.iata`, which `HealthObserverRow` does not emit, so both have always been dead. #2057 named this and left it; it is now **#2062** with the file and line references, rather than a remark inside a merged description. Co-authored-by: Claude Opus 5 (1M context) --- public/nodes.js | 4 ++-- tests/unit/test-direct-rf-heard-by.js | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/public/nodes.js b/public/nodes.js index d1322b31..05f98a72 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -781,7 +781,7 @@ ${observers.length || relayObserverCount ? `
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.map(r => '' + escapeHtml(r) + '').join(' ')}
` : ''; })()}

Heard By — direct (${observers.length} observer${observers.length === 1 ? '' : 's'})

- ${observers.length ? '' : '
No observer is within radio range of this node.
'} + ${observers.length ? '' : '
No observation proves a direct reception here, which is not the same as being out of range.
'} ${observers.length ? ` @@ -1756,7 +1756,7 @@ ${observers.length || relayObserverCount ? `
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.join(', ')}
` : ''; })()}

Heard By — direct (${observers.length} observer${observers.length === 1 ? '' : 's'})

- ${observers.length ? '' : '
No observer is within radio range of this node.
'} + ${observers.length ? '' : '
No observation proves a direct reception here, which is not the same as being out of range.
'}
${observers.map(o => { const stats = [`${o.packetCount} pkts`]; diff --git a/tests/unit/test-direct-rf-heard-by.js b/tests/unit/test-direct-rf-heard-by.js index 28a219b4..c308f71b 100644 --- a/tests/unit/test-direct-rf-heard-by.js +++ b/tests/unit/test-direct-rf-heard-by.js @@ -66,8 +66,19 @@ test('relayed observers are reported as a count, without signal numbers', () => test('a node nobody hears directly still renders the card, with an empty state', () => { const html = renderCard([], 35, esc); assert.ok(/Heard By — direct \(0 observers\)/.test(html), 'expected a zero direct heading'); - assert.ok(/No observer is within radio range of this node\./.test(html), + assert.ok(/No observation proves a direct reception here/.test(html), 'expected the empty state line'); + // The empty state must not assert that nobody is in range. It cannot know + // that: direct-routed traffic carries no sender (firmware Mesh.cpp calls + // removeSelfFromPath before retransmitting) and ambiguous relay hops are + // left unattributed, so a node can be heard by several observers and still + // have no attributable direct reception. Measured on a production instance + // when this card shipped: 16 of 40 sampled repeaters showed the empty state + // while the same card counted relay observers two lines below. + assert.ok(!/No observer is within radio range/i.test(html), + 'the empty state must not assert that nobody is in range'); + assert.ok(/not the same as being out of range/i.test(html), + 'the empty state must say what it cannot conclude, not only what it found'); assert.ok(/Seen via relay by 35 observers/.test(html), 'expected the relay count'); assert.ok(!/observer-sort-table/.test(html), 'an empty direct list must not render a table header with no rows');
Observer