mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
Refs #667 (M1, the first part). ## Problem Observations with `direction = 'tx'` are packets the observer's own radio sent, published by the MQTT bridge. Nothing in `cmd/server` or the ingestor's neighbor builder filtered on `direction`, so these rows counted as receptions. Two shapes, both seen in a production DB: - **Forwarded flood.** A repeater-observer appends its own hash before retransmitting (firmware `Mesh.cpp`), so the tx copy's path ends on the observer itself, for example tx `["2979","2029"]` next to rx `["2979"]` for the same transmission. - **Own advert.** The observer's own advert goes out with an empty path and no rx copy. Effects: - **The node detail "Heard By" card listed the observer as having heard itself.** On one repeater-observer that was the only row in the card (2,107 packets, no SNR). - **The neighbor graph counted a forwarded advert twice,** once from the rx copy and once from the tx copy (originator ↔ path[0]). This happened in both the in-memory build and the ingestor's `neighbor_edges` builder. ## Scale Last 2,000,000 observations on that DB, about 7.5 days: - 1,385 tx rows against 1,805,378 rx rows; - all tx rows have SNR and RSSI NULL; - every tx row with a non-empty path has an rx row from the same observer for the same transmission. ## Change - `StoreObs.isOwnTransmission()` (`store.go`). - These now skip tx rows: - `buildDirectHeardIndex` (`direct_heard.go`); - phase 1 of the neighbor graph build (`neighbor_graph.go`); - the ingestor's `buildAndPersistNeighborEdges` SQL, via `o.direction IS NOT 'tx'` (NULL rows from older bridges still count). ## Deliberately not changed - **Reach** (`node_reach.go`). A tx row with path `[A, X]` from observer X is the only row that shows X received A and forwarded it, because X is absent from the rx copy `[A]`. There it is valid evidence. - **Home regions** (`computeNodeHomeRegions`). A tx row of the observer's own zero-hop advert counts the observer's own IATA, which is where it is. - **Counters and region filters.** These include observation counts, `byObserver`, `packet_count` and the SQL region joins. There the effect is double counting of about 0.08% of rows, not a wrong fact. That is left for a follow-up if wanted. - **Existing `neighbor_edges` rows.** The builder is incremental from its watermark, so edges persisted before this change keep their counts. The Heard By card is rebuilt from the store, so it is correct after a restart. ## Tests - **Red then green:** - `TestBuildDirectHeardIndex_SkipsOwnTransmissions`: before the fix the observer was credited for itself; - `TestBuildNeighborGraph_SkipsOwnTransmissions`: X↔R1 count 2 instead of 1, plus a zero-hop edge from an empty-path tx row; - `TestNeighborEdgesBuilderSkipsOwnTransmissions`: 2 `neighbor_edges` rows from a single tx observation. - `go vet` and `go test .` pass in `cmd/server` and `cmd/ingestor`. No frontend change. I have not yet checked the "Heard By" card in a browser; that will be on a staging deploy. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
11 KiB
Go
308 lines
11 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 == "" || obs.isOwnTransmission() {
|
|
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.
|
|
//
|
|
// It counts as a read of the direct-heard recomputer, so with pauseWhenIdle
|
|
// on the card keeps it refreshing (the snapshot is read here, not through
|
|
// the recomputer's Load).
|
|
func (s *PacketStore) loadDirectHeard() directHeardIndex {
|
|
s.analyticsRecomputerMu.RLock()
|
|
rc := s.recompDirectHeard
|
|
s.analyticsRecomputerMu.RUnlock()
|
|
if rc != nil {
|
|
rc.markRead()
|
|
}
|
|
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
|
|
}
|