mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-06 22:38:08 +00:00
Reported by @dborup on #2057. The mechanism is real; the suspected scale is not. Both parts measured below. ## The defect `directHeardNode` rejects every non-flood route type before it looks at the path, so the `hop == ""` branch that credits an advert's originator can never run for a direct route. Zero-hop adverts — the clearest direct-RF evidence the network produces — are discarded and the node is listed under "Seen via relay" instead. ## Firmware Read at `0679dbef` rather than taken on trust: - `Mesh::sendZeroHop` sets `ROUTE_TYPE_DIRECT` and `path_len = 0`, commented there as "path_len of zero means Zero Hop". The transport overload does the same with `ROUTE_TYPE_TRANSPORT_DIRECT`. - `examples/simple_repeater/MyMesh.cpp` sends the periodic **local advert** through it (the `next_local_advert` branch), as does `sendSelfAdvertisement` when `flood` is false. - `examples/companion_radio/MyMesh.cpp` does the same for a companion's own advert. An ADVERT arriving on a direct route with an empty path therefore cannot have been forwarded: the observer received the advertiser's own transmission, and the advert carries its pubkey in the clear. Every other direct case keeps #2057's rule. A non-empty path on a direct route is the **remaining** route, because the forwarder ran `removeSelfFromPath` before retransmitting, and `advertOriginPubkey` already returns `""` for any payload type other than ADVERT — so the payload guard costs nothing. ## Measured, 7-day window on a production instance | | | |---|---| | zero-hop advert observations currently dropped | 8,166 | | distinct nodes they evidence | 139 | | node-observer pairs they evidence | 185 | | pairs **not** already credited via an empty-path flood advert | **52** | | pairs currently credited from flood adverts | 217 | So the fix restores 52 node-observer pairs of direct evidence that are invisible today, roughly a quarter more advert-based direct evidence. ## What it does not explain The report suspected this accounts for #2057's low headline numbers ("NL-BXE-RP01 | 433 → 0", 234 of 1,860 nodes with any direct observer). The measurement does not support that: - Of 30 sampled nodes with zero-hop advert evidence, **29 already show at least one direct observer**, because they also send flood adverts which #2057 credits. - **NL-BXE-RP01 | 433 has zero zero-hop adverts** in the window. Its empty list is not caused by this rule. So this mostly enriches lists that are already non-empty, and flips few cards from empty to populated. Worth doing on correctness grounds, not as a fix for the counts. ## Tests Four cases added to the `TestDirectHeardNode` table, which previously covered direct routes only with `PayloadTXT_MSG`: - direct + ADVERT + empty path credits the advertiser - transport-direct + ADVERT + empty path credits the advertiser - direct + empty path + **not** an advert credits nobody - direct + ADVERT + **non-empty** path credits nobody The last two matter as much as the first two: they pin the exception to exactly the shape the firmware guarantees. `gofmt` clean. Go tests not run locally (no cgo toolchain on this machine since #1992, and per AGENTS.md `CGO_ENABLED=0` builds a stub that proves nothing), so CI is their first run. ## Related #2063 fixes the empty state's wording on the same card, which asserts the node is out of range when the data cannot establish that. The two are independent: this one adds evidence, that one stops overclaiming when there is none. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
10 KiB
Go
298 lines
10 KiB
Go
// Package main: direct-RF attribution for the node-health "Heard By" card.
|
|
//
|
|
// An observer "heard" a node when it received that node's own transmission
|
|
// off the air. That is a narrower relation than "saw a packet this node was
|
|
// involved in", which is what the node-health card reported before this
|
|
// file existed, and it is the only one for which the SNR and RSSI printed
|
|
// next to an observer belong to the node the row names.
|
|
//
|
|
// The rule follows the firmware:
|
|
//
|
|
// - Flood routes build the path up as they travel: a forwarding repeater
|
|
// appends its own hash before retransmitting (Mesh.cpp:349). The last
|
|
// hop is therefore the node whose transmission the observer received.
|
|
// - Direct routes carry the REMAINING route, not the travelled one. A
|
|
// forwarder matches itself against the head of the path and calls
|
|
// removeSelfFromPath (Mesh.cpp:89,103) before retransmitting, so the
|
|
// node the observer heard is not in the path at all. Direct routes
|
|
// therefore never attribute.
|
|
// - An empty flood path means the observer received the originator's own
|
|
// transmission. Only ADVERTs carry the originator's pubkey in the
|
|
// clear, so other payload types with an empty path attribute to nobody.
|
|
// - A hop prefix that matches more than one node attributes to nobody.
|
|
// Path hop sizes are chosen by the originator (Packet.h:83,
|
|
// Mesh.cpp:649) and default to one byte, so a hop often matches many
|
|
// candidates. resolveWithContext guesses in that case; this file does
|
|
// not. The gate matches resolvePathForObsColdLoad: under-attribute
|
|
// rather than credit the wrong node.
|
|
package main
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// directHeardAgg accumulates one observer's direct receptions of one node.
|
|
type directHeardAgg struct {
|
|
ObserverName string
|
|
Count int
|
|
SNRSum float64
|
|
SNRCount int
|
|
RSSISum float64
|
|
RSSICount int
|
|
}
|
|
|
|
// directHeardIndex maps node pubkey (lowercase) to observer id to aggregate.
|
|
type directHeardIndex map[string]map[string]*directHeardAgg
|
|
|
|
// HealthObserverRow is one row of the node-health "Heard By" table.
|
|
//
|
|
// Field names and null semantics are unchanged from the map-based rows this
|
|
// replaced, so the frontend needs no migration: avgSnr and avgRssi are null
|
|
// when the observer contributed no sample, and can_relay is tri-state (null
|
|
// = no repeat field ever reported, see PR #1624).
|
|
type HealthObserverRow struct {
|
|
ObserverID string `json:"observer_id"`
|
|
ObserverName string `json:"observer_name"`
|
|
AvgSNR *float64 `json:"avgSnr"`
|
|
AvgRSSI *float64 `json:"avgRssi"`
|
|
PacketCount int `json:"packetCount"`
|
|
CanRelay *bool `json:"can_relay"`
|
|
}
|
|
|
|
// lastPathHop returns the last quoted token of a path_json array, or "" when
|
|
// there is none. It scans backwards rather than unmarshalling: the compute
|
|
// pass runs over every observation in the store (2.9M on the reference
|
|
// deployment) and only ever needs the final element.
|
|
func lastPathHop(pathJSON string) string {
|
|
end := -1
|
|
for i := len(pathJSON) - 1; i >= 0; i-- {
|
|
if pathJSON[i] != '"' {
|
|
continue
|
|
}
|
|
if end < 0 {
|
|
end = i
|
|
continue
|
|
}
|
|
return pathJSON[i+1 : end]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// advertOriginPubkey returns the pubkey an ADVERT announces, or "" when the
|
|
// transmission is not an ADVERT or carries no decodable pubkey. Mirrors the
|
|
// field probing in trackAdvertPubkey.
|
|
func advertOriginPubkey(tx *StoreTx) string {
|
|
if tx.PayloadType == nil || *tx.PayloadType != PayloadADVERT || tx.DecodedJSON == "" {
|
|
return ""
|
|
}
|
|
d := tx.ParsedDecoded()
|
|
if d == nil {
|
|
return ""
|
|
}
|
|
if v, ok := d["pubKey"].(string); ok && v != "" {
|
|
return strings.ToLower(v)
|
|
}
|
|
if v, ok := d["public_key"].(string); ok && v != "" {
|
|
return strings.ToLower(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// directHeardNode returns the lowercase pubkey of the node whose
|
|
// transmission this observation received off the air, or "" when that cannot
|
|
// be established. See the package comment for the rule and its firmware
|
|
// grounding.
|
|
func directHeardNode(tx *StoreTx, obs *StoreObs, pm *prefixMap) string {
|
|
if tx == nil || obs == nil || tx.RouteType == nil {
|
|
return ""
|
|
}
|
|
hop := lastPathHop(obs.PathJSON)
|
|
switch *tx.RouteType {
|
|
case RouteFlood, RouteTransportFlood:
|
|
// Handled below: empty path means the originator, otherwise the last
|
|
// hop is whoever was heard.
|
|
case RouteDirect, RouteTransportDirect:
|
|
// A direct route normally says nothing about the transmitter, because
|
|
// the forwarder calls removeSelfFromPath before retransmitting
|
|
// (firmware Mesh.cpp) and the path left behind is the REMAINING route.
|
|
//
|
|
// The exception is a zero hop. Mesh::sendZeroHop sets ROUTE_TYPE_DIRECT
|
|
// (its transport overload ROUTE_TYPE_TRANSPORT_DIRECT) and path_len = 0,
|
|
// commented there as "path_len of zero means Zero Hop". Repeaters send
|
|
// their periodic local advert that way (simple_repeater/MyMesh.cpp, the
|
|
// next_local_advert branch and sendSelfAdvertisement with flood=false),
|
|
// and so do companions. An ADVERT arriving on a direct route with an
|
|
// empty path therefore cannot have been forwarded: the observer heard
|
|
// the advertiser's own transmission, and the advert carries its pubkey
|
|
// in the clear. That is the strongest direct-RF evidence there is.
|
|
//
|
|
// Anything else direct still says nothing: a non-empty path is the
|
|
// remaining route, and advertOriginPubkey returns "" for every payload
|
|
// type other than ADVERT.
|
|
if hop == "" {
|
|
return advertOriginPubkey(tx)
|
|
}
|
|
return ""
|
|
default:
|
|
return ""
|
|
}
|
|
if hop == "" {
|
|
return advertOriginPubkey(tx)
|
|
}
|
|
if pm == nil {
|
|
return ""
|
|
}
|
|
candidates := pm.relayCandidates(hop)
|
|
if len(candidates) != 1 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(candidates[0].PublicKey)
|
|
}
|
|
|
|
// buildDirectHeardIndex folds every observation of every transmission into
|
|
// the node-to-observer aggregate. Pure over its arguments so it can be
|
|
// tested and benchmarked without a store.
|
|
func buildDirectHeardIndex(packets []*StoreTx, pm *prefixMap) directHeardIndex {
|
|
idx := make(directHeardIndex, 256)
|
|
for _, tx := range packets {
|
|
for _, obs := range tx.Observations {
|
|
if obs.ObserverID == "" {
|
|
continue
|
|
}
|
|
pk := directHeardNode(tx, obs, pm)
|
|
if pk == "" {
|
|
continue
|
|
}
|
|
byObs := idx[pk]
|
|
if byObs == nil {
|
|
byObs = make(map[string]*directHeardAgg, 4)
|
|
idx[pk] = byObs
|
|
}
|
|
agg := byObs[obs.ObserverID]
|
|
if agg == nil {
|
|
agg = &directHeardAgg{ObserverName: obs.ObserverName}
|
|
byObs[obs.ObserverID] = agg
|
|
}
|
|
agg.Count++
|
|
if obs.SNR != nil {
|
|
agg.SNRSum += *obs.SNR
|
|
agg.SNRCount++
|
|
}
|
|
if obs.RSSI != nil {
|
|
agg.RSSISum += *obs.RSSI
|
|
agg.RSSICount++
|
|
}
|
|
}
|
|
}
|
|
return idx
|
|
}
|
|
|
|
// computeDirectHeard rebuilds the whole index from the current store. Run by
|
|
// a background recomputer rather than per request: the reference deployment
|
|
// holds 232,928 transmissions and 2,887,861 observations, and one node's
|
|
// byNode slice alone can hold 1.45M observations.
|
|
//
|
|
// Rebuilding wholesale also means eviction needs no bookkeeping — a pass
|
|
// simply does not see transmissions that are gone.
|
|
func (s *PacketStore) computeDirectHeard() directHeardIndex {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
_, pm := s.getCachedNodesAndPM()
|
|
return buildDirectHeardIndex(s.packets, pm)
|
|
}
|
|
|
|
// publishDirectHeard installs a snapshot for readers. Called by the
|
|
// recomputer after each pass; a nil-safe no-op for callers that have nothing
|
|
// to publish.
|
|
func (s *PacketStore) publishDirectHeard(idx directHeardIndex) {
|
|
if idx == nil {
|
|
idx = directHeardIndex{}
|
|
}
|
|
s.directHeardSnap.Store(idx)
|
|
}
|
|
|
|
// loadDirectHeard returns the latest snapshot, or nil before the first
|
|
// compute has published one. A nil index simply yields empty direct rows:
|
|
// the card degrades to "nobody hears this node" until the first pass lands,
|
|
// never to a wrong attribution.
|
|
func (s *PacketStore) loadDirectHeard() directHeardIndex {
|
|
idx, _ := s.directHeardSnap.Load().(directHeardIndex)
|
|
return idx
|
|
}
|
|
|
|
// canRelaySets fetches the two inputs behind the can_relay tri-state badge
|
|
// (#1290, PR #1624): the observers that reported repeat:off, and the
|
|
// observers we have any repeat field for at all. Both are lowercase to match
|
|
// pm.nonRelay and GetNonRelayObserverPubkeys; two case conventions on the
|
|
// same upstream string would be a latent regression. A read failure degrades
|
|
// to "no badge" rather than a wrong badge.
|
|
func (s *PacketStore) canRelaySets() (nonRelay, seen map[string]struct{}) {
|
|
nonRelay = map[string]struct{}{}
|
|
seen = map[string]struct{}{}
|
|
if s.db == nil || s.db.conn == nil {
|
|
return nonRelay, seen
|
|
}
|
|
if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil {
|
|
for _, pk := range pks {
|
|
nonRelay[strings.ToLower(pk)] = struct{}{}
|
|
}
|
|
}
|
|
if pks, err := s.db.GetCanRelaySeenObserverPubkeys(); err == nil {
|
|
for _, pk := range pks {
|
|
seen[strings.ToLower(pk)] = struct{}{}
|
|
}
|
|
}
|
|
return nonRelay, seen
|
|
}
|
|
|
|
// relayOnlyObserverCount counts observers that saw traffic involving the node
|
|
// without hearing it on air. seenObservers is the set the health builders
|
|
// already collect from each transmission's representative observation.
|
|
func relayOnlyObserverCount(seenObservers map[string]struct{}, direct map[string]*directHeardAgg) int {
|
|
n := 0
|
|
for id := range seenObservers {
|
|
if _, isDirect := direct[id]; !isDirect {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// buildDirectObserverRows renders one node's aggregate as sorted API rows.
|
|
// nonRelay and seen carry the can_relay tri-state; both may be nil.
|
|
func buildDirectObserverRows(byObs map[string]*directHeardAgg, nonRelay, seen map[string]struct{}) []HealthObserverRow {
|
|
rows := make([]HealthObserverRow, 0, len(byObs))
|
|
for id, agg := range byObs {
|
|
row := HealthObserverRow{
|
|
ObserverID: id,
|
|
ObserverName: agg.ObserverName,
|
|
PacketCount: agg.Count,
|
|
}
|
|
if agg.SNRCount > 0 {
|
|
v := agg.SNRSum / float64(agg.SNRCount)
|
|
row.AvgSNR = &v
|
|
}
|
|
if agg.RSSICount > 0 {
|
|
v := agg.RSSISum / float64(agg.RSSICount)
|
|
row.AvgRSSI = &v
|
|
}
|
|
idLower := strings.ToLower(id)
|
|
if _, ok := seen[idLower]; ok {
|
|
_, isListener := nonRelay[idLower]
|
|
canRelay := !isListener
|
|
row.CanRelay = &canRelay
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
// Packet count descending, observer id ascending as a deterministic
|
|
// tiebreak so repeated requests return a stable order.
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].PacketCount != rows[j].PacketCount {
|
|
return rows[i].PacketCount > rows[j].PacketCount
|
|
}
|
|
return rows[i].ObserverID < rows[j].ObserverID
|
|
})
|
|
return rows
|
|
}
|