fix(nodes): stop the Heard By empty state claiming the node is out of range (#2063)

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) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-09-22 23:56:02 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 61f565c606
commit 980c5c4515
2 changed files with 14 additions and 3 deletions
+2 -2
View File
@@ -781,7 +781,7 @@
${observers.length || relayObserverCount ? `<div class="node-full-card" id="node-observers">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
<h4 title="Observers that received this node's own transmission off the air. An observer that only saw traffic relayed through this node is counted separately below.">Heard By &mdash; direct (${observers.length} observer${observers.length === 1 ? '' : 's'})</h4>
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0">No observer is within radio range of this node.</div>'}
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0" title="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 observation proves a direct reception here, which is not the same as being out of range.</div>'}
${observers.length ? `<table class="data-table observer-sort-table" style="font-size:12px">
<thead><tr>
<th scope="col" data-sort-key="observer">Observer</th>
@@ -1756,7 +1756,7 @@
${observers.length || relayObserverCount ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<h4 title="Observers that received this node's own transmission off the air.">Heard By &mdash; direct (${observers.length} observer${observers.length === 1 ? '' : 's'})</h4>
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0">No observer is within radio range of this node.</div>'}
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0" title="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 observation proves a direct reception here, which is not the same as being out of range.</div>'}
<div class="observer-list">
${observers.map(o => {
const stats = [`${o.packetCount} pkts`];
+12 -1
View File
@@ -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 &mdash; 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');