mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 17:03:36 +00:00
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:
+2
-2
@@ -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 — 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 — 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`];
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user