diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 48aa4e7d..d24f305e 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -197,7 +197,9 @@ func applySchema(db *sql.DB) error { last_packet_at TEXT DEFAULT NULL, clock_skew_seconds INTEGER DEFAULT NULL, clock_skew_count_24h INTEGER DEFAULT 0, - clock_last_naive_at TEXT DEFAULT NULL + clock_last_naive_at TEXT DEFAULT NULL, + can_relay INTEGER DEFAULT 1, + can_relay_seen INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen); @@ -727,8 +729,8 @@ func (s *Store) prepareStatements() error { } s.stmtUpsertObserver, err = s.db.Prepare(` - INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor) - VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, can_relay, can_relay_seen) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 1), CASE WHEN ? IS NULL THEN 0 ELSE 1 END) ON CONFLICT(id) DO UPDATE SET name = COALESCE(?, name), iata = COALESCE(?, iata), @@ -740,7 +742,9 @@ func (s *Store) prepareStatements() error { radio = COALESCE(?, radio), battery_mv = COALESCE(?, battery_mv), uptime_secs = COALESCE(?, uptime_secs), - noise_floor = COALESCE(?, noise_floor) + noise_floor = COALESCE(?, noise_floor), + can_relay = COALESCE(?, can_relay), + can_relay_seen = CASE WHEN ? IS NULL THEN can_relay_seen ELSE 1 END `) if err != nil { return err @@ -973,6 +977,13 @@ type ObserverMeta struct { RecvErrors *int // cumulative CRC/decode failures since boot PacketsSent *int // cumulative packets sent since boot PacketsRecv *int // cumulative packets received since boot + // CanRelay reflects the firmware 1.16 /status `repeat` flag (#1290). + // nil means the firmware did not send the field — caller must + // preserve the existing observers.can_relay value (default 1). + // true → relay-capable (`repeat:on`); false → listener-only + // (`repeat:off`), which causes the server-side disambiguator to + // exclude this observer's pubkey from path-hop candidate sets. + CanRelay *bool } // UpsertObserver inserts or updates an observer using the current wall-clock @@ -995,7 +1006,7 @@ func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, last normalizedIATA := strings.TrimSpace(strings.ToUpper(iata)) var model, firmware, clientVersion, radio interface{} - var batteryMv, uptimeSecs, noiseFloor interface{} + var batteryMv, uptimeSecs, noiseFloor, canRelay interface{} if meta != nil { if meta.Model != nil { model = *meta.Model @@ -1018,11 +1029,22 @@ func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, last if meta.NoiseFloor != nil { noiseFloor = *meta.NoiseFloor } + // Issue #1290: nil → leave DB column unchanged (COALESCE in + // the prepared stmt); 0/1 written when firmware provided + // the `repeat` field. INSERT branch defaults to 1 via the + // COALESCE in the VALUES clause. + if meta.CanRelay != nil { + if *meta.CanRelay { + canRelay = 1 + } else { + canRelay = 0 + } + } } _, err := s.stmtUpsertObserver.Exec( - id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, - name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, + id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, canRelay, canRelay, + name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, canRelay, canRelay, ) if err != nil { s.Stats.WriteErrors.Add(1) diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index f7515343..37806bd2 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -1124,6 +1124,37 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta { } } + // Issue #1290: firmware 1.16 publishes a `repeat` flag at the top + // level of the /status JSON (MQTTMessageBuilder.cpp:58 — see + // agessaman/MeshCore mqtt-bridge-implementation-flex). Accept + // either a boolean or a case-insensitive `on|off|true|false|1|0` + // string. Missing field → leave CanRelay nil; the writer preserves + // the prior column value (default 1, back-compat). + if v, ok := msg["repeat"]; ok && v != nil { + switch t := v.(type) { + case bool: + b := t + meta.CanRelay = &b + hasData = true + case string: + s := strings.ToLower(strings.TrimSpace(t)) + switch s { + case "on", "true", "1", "yes": + b := true + meta.CanRelay = &b + hasData = true + case "off", "false", "0", "no": + b := false + meta.CanRelay = &b + hasData = true + } + case float64: + b := t != 0 + meta.CanRelay = &b + hasData = true + } + } + if !hasData { return nil } diff --git a/cmd/server/db.go b/cmd/server/db.go index dc8a5b93..dc5a1471 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/meshcore-analyzer/dbschema" "github.com/meshcore-analyzer/geofilter" _ "modernc.org/sqlite" ) @@ -251,6 +252,13 @@ type Observer struct { ClockSkewSeconds *int64 `json:"clock_skew_seconds"` ClockSkewCount24h int `json:"clock_skew_count_24h"` ClockLastNaiveAt *string `json:"clock_last_naive_at"` + // Issue #1290: firmware 1.16 `repeat: on|off` flag persisted by the + // ingestor. true = relay-capable, false = listener-only, nil = + // unknown (legacy observer that never sent the field — drives the + // tri-state UI badge so legacy rows don't masquerade as confirmed + // repeaters). The ingestor sets can_relay_seen=1 only when it has + // an explicit value; the read layer returns nil when seen=0. + CanRelay *bool `json:"can_relay,omitempty"` } // Transmission represents a row from the transmissions table. @@ -1148,9 +1156,24 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string] // GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC. func (db *DB) GetObservers() ([]Observer, error) { + // Issue #1290: can_relay is read via COALESCE(can_relay, 1). The + // column is added by internal/dbschema; older test fixtures and + // pre-migration DBs may lack it, so we probe and fall back. + // PR #1624 MAJOR-2: can_relay_seen is the tri-state sentinel — 1 + // means the ingestor explicitly wrote a value, 0 means "unknown" + // and the server returns CanRelay=nil so the UI shows no badge. + canRelayClause := "COALESCE(can_relay, 1)" + canRelaySeenClause := "0" + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { + canRelayClause = "1" + } + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); hasCol { + canRelaySeenClause = "COALESCE(can_relay_seen, 0)" + } rows, err := db.conn.Query(`SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at, - clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at + clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at, + ` + canRelayClause + `, ` + canRelaySeenClause + ` FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC`) if err != nil { return nil, err @@ -1163,11 +1186,16 @@ func (db *DB) GetObservers() ([]Observer, error) { var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64 var clockSkewCount sql.NullInt64 var noiseFloor sql.NullFloat64 + var canRelay, canRelaySeen int if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt, - &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt); err != nil { + &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen); err != nil { continue } + if canRelaySeen != 0 { + b := canRelay != 0 + o.CanRelay = &b + } if batteryMv.Valid { v := int(batteryMv.Int64) o.BatteryMv = &v @@ -1190,22 +1218,91 @@ func (db *DB) GetObservers() ([]Observer, error) { return observers, nil } +// GetNonRelayObserverPubkeys returns the lowercase observer.id pubkeys +// for observers that have advertised `repeat:off` (#1290). The server's +// path-hop disambiguator consumes this to exclude listener-only nodes +// from the candidate set. Inactive observers are excluded for +// consistency with GetObservers; reactivation flips can_relay only on +// the next status message. +func (db *DB) GetNonRelayObserverPubkeys() ([]string, error) { + // Graceful no-op when can_relay column is absent (legacy DB / older + // test fixture). Avoids noisy schema-degradation log spam. + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { + return nil, nil + } + rows, err := db.conn.Query(`SELECT LOWER(id) FROM observers + WHERE COALESCE(can_relay, 1) = 0 + AND (inactive IS NULL OR inactive = 0)`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var pk string + if err := rows.Scan(&pk); err == nil && pk != "" { + out = append(out, pk) + } + } + return out, rows.Err() +} + +// GetCanRelaySeenObserverPubkeys returns the lowercase observer.id +// pubkeys for which the ingestor has explicitly written a repeat-field +// value (can_relay_seen=1). PR #1624 MAJOR-2: the badge surface uses +// this to render tri-state — observers NOT in this set are "unknown" +// and the UI shows no badge. +func (db *DB) GetCanRelaySeenObserverPubkeys() ([]string, error) { + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); !hasCol { + return nil, nil + } + rows, err := db.conn.Query(`SELECT LOWER(id) FROM observers + WHERE COALESCE(can_relay_seen, 0) = 1 + AND (inactive IS NULL OR inactive = 0)`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var pk string + if err := rows.Scan(&pk); err == nil && pk != "" { + out = append(out, pk) + } + } + return out, rows.Err() +} + // GetObserverByID returns a single observer. func (db *DB) GetObserverByID(id string) (*Observer, error) { var o Observer var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64 var clockSkewCount sql.NullInt64 var noiseFloor sql.NullFloat64 + var canRelay, canRelaySeen int + canRelayClause := "COALESCE(can_relay, 1)" + canRelaySeenClause := "0" + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { + canRelayClause = "1" + } + if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); hasCol { + canRelaySeenClause = "COALESCE(can_relay_seen, 0)" + } err := db.conn.QueryRow(`SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at, - clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at + clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at, + `+canRelayClause+`, `+canRelaySeenClause+` FROM observers WHERE id = ?`, id). Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt, - &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt) + &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen) if err != nil { return nil, err } + if canRelaySeen != 0 { + b := canRelay != 0 + o.CanRelay = &b + } if batteryMv.Valid { v := int(batteryMv.Int64) o.BatteryMv = &v diff --git a/cmd/server/node_health_can_relay_case_1290_test.go b/cmd/server/node_health_can_relay_case_1290_test.go new file mode 100644 index 00000000..d2785ba5 --- /dev/null +++ b/cmd/server/node_health_can_relay_case_1290_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Issue #1290 (MAJOR-1, adversarial review of PR #1624) — regression guard. +// GetNonRelayObserverPubkeys() returns LOWER(id); the disambiguator +// (pm.nonRelay) also uses lowercase. GetNodeHealth previously used +// UPPERCASE for both insert and lookup which happens to work by symmetry, +// but any refactor that changes how pkt.ObserverID is normalized would +// silently break the badge. This test pins lowercase as the convention by +// seeding an observer.id with mixed-case packet ObserverID and asserting +// the listener badge is rendered for the matching observer in HeardBy. +func TestNodeHealth_CanRelayCaseInsensitive_Issue1290(t *testing.T) { + srv, router := setupTestServer(t) + + // DB row: observer id is the canonical LOWERCASE pubkey with can_relay=0. + const obsIDLower = "deadbeefcafe1290" + const obsIDMixed = "DeadBeefCafe1290" // packet observer-id w/ mixed case + const nodePubkey = "aabbccdd11223344" // seeded by seedTestData + now := time.Now().UTC().Format(time.RFC3339) + // The test fixture's observers table predates the can_relay migration; + // add both columns (matches dbschema migrations). + for _, ddl := range []string{ + `ALTER TABLE observers ADD COLUMN can_relay INTEGER DEFAULT 1`, + `ALTER TABLE observers ADD COLUMN can_relay_seen INTEGER DEFAULT 0`, + } { + if _, err := srv.store.db.conn.Exec(ddl); err != nil { + t.Fatalf("alter: %v", err) + } + } + if _, err := srv.store.db.conn.Exec( + `INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, can_relay, can_relay_seen) + VALUES (?, 'ListenerOnly', 'SJC', ?, '2026-01-01T00:00:00Z', 1, 0, 1)`, + obsIDLower, now); err != nil { + t.Fatalf("seed observer: %v", err) + } + + // In-memory packet with the MIXED-case observer id so the badge resolver + // must lower-case both sides to match against the lower-cased pubkey set. + snr := 7.0 + srv.store.mu.Lock() + if srv.store.byNode == nil { + srv.store.byNode = make(map[string][]*StoreTx) + } + srv.store.byNode[nodePubkey] = append(srv.store.byNode[nodePubkey], &StoreTx{ + Hash: "1290casebadge00", + FirstSeen: now, + SNR: &snr, + ObservationCount: 1, + ObserverID: obsIDMixed, + ObserverName: "ListenerOnly", + }) + srv.store.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "/api/nodes/"+nodePubkey+"/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("json: %v", err) + } + obs, ok := body["observers"].([]interface{}) + if !ok { + t.Fatalf("expected observers array, got %T", body["observers"]) + } + var found bool + for _, raw := range obs { + row, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if row["observer_id"] != obsIDMixed { + continue + } + found = true + if row["can_relay"] != false { + t.Errorf("listener observer with can_relay=0 + mixed-case ObserverID: expected can_relay=false, got %v", row["can_relay"]) + } + } + if !found { + t.Fatalf("did not find observer %q in HeardBy rows; got %v", obsIDMixed, obs) + } +} diff --git a/cmd/server/observers_canrelay_tristate_1290_test.go b/cmd/server/observers_canrelay_tristate_1290_test.go new file mode 100644 index 00000000..aa40da97 --- /dev/null +++ b/cmd/server/observers_canrelay_tristate_1290_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Issue #1290 (MAJOR-2, adversarial review of PR #1624) — tri-state badge. +// +// The badge surface needs to distinguish three states: +// 1. legacy observer (never sent `repeat` field) → unknown → no badge +// 2. firmware confirmed `repeat:on` → "Repeater" +// 3. firmware confirmed `repeat:off` → "Listener" +// +// Previously `CanRelay bool` defaulted to false in Go even when the row +// was the legacy DEFAULT 1, conflating "confirmed repeater" with +// "unknown". This pins the API surface to *bool + JSON omitempty so the +// frontend tri-state render works. +func TestObservers_CanRelayTriState_Issue1290(t *testing.T) { + srv, router := setupTestServer(t) + + // Add the can_relay column (matches dbschema migration) PLUS the + // can_relay_seen tracking column so the read layer can distinguish + // "ingestor explicitly wrote a value" from "default sentinel". + for _, ddl := range []string{ + `ALTER TABLE observers ADD COLUMN can_relay INTEGER DEFAULT 1`, + `ALTER TABLE observers ADD COLUMN can_relay_seen INTEGER DEFAULT 0`, + } { + if _, err := srv.store.db.conn.Exec(ddl); err != nil { + t.Fatalf("alter: %v", err) + } + } + + now := time.Now().UTC().Format(time.RFC3339) + // Legacy: never received repeat field. can_relay=DEFAULT 1, seen=0. + if _, err := srv.store.db.conn.Exec( + `INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('legacy-obs', 'Legacy', 'SJC', ?, '2026-01-01T00:00:00Z', 1)`, now); err != nil { + t.Fatalf("seed legacy: %v", err) + } + // Repeater: ingestor wrote can_relay=1, seen=1. + if _, err := srv.store.db.conn.Exec( + `INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, can_relay, can_relay_seen) + VALUES ('rep-obs', 'Repeater', 'SFO', ?, '2026-01-01T00:00:00Z', 1, 1, 1)`, now); err != nil { + t.Fatalf("seed repeater: %v", err) + } + // Listener: ingestor wrote can_relay=0, seen=1. + if _, err := srv.store.db.conn.Exec( + `INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, can_relay, can_relay_seen) + VALUES ('lst-obs', 'Listener', 'OAK', ?, '2026-01-01T00:00:00Z', 1, 0, 1)`, now); err != nil { + t.Fatalf("seed listener: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/observers?nocache=1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + + var body struct { + Observers []map[string]interface{} `json:"observers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("json: %v", err) + } + + rows := map[string]map[string]interface{}{} + for _, o := range body.Observers { + if id, _ := o["id"].(string); id != "" { + rows[id] = o + } + } + + // Legacy: can_relay key must be absent (JSON omitempty for nil *bool). + legacy, ok := rows["legacy-obs"] + if !ok { + ids := make([]string, 0, len(rows)) + for k := range rows { + ids = append(ids, k) + } + t.Fatalf("legacy-obs missing from response; got ids: %v", ids) + } + if _, has := legacy["can_relay"]; has { + t.Errorf("legacy observer (never sent repeat) should have can_relay omitted (unknown); got can_relay=%v", legacy["can_relay"]) + } + + // Repeater: can_relay must be true. + if v := rows["rep-obs"]["can_relay"]; v != true { + t.Errorf("repeater observer: expected can_relay=true, got %v", v) + } + // Listener: can_relay must be false. + if v, has := rows["lst-obs"]["can_relay"]; !has || v != false { + t.Errorf("listener observer: expected can_relay=false, got %v (present=%v)", v, has) + } + + // And the raw JSON must not contain the legacy observer's can_relay key + // (defense against a future ObserverResp change that hardcodes false). + raw := w.Body.String() + if idx := strings.Index(raw, `"id":"legacy-obs"`); idx >= 0 { + // scan its row only — observers are JSON-array-ordered objects. + end := strings.Index(raw[idx:], "}") + if end > 0 { + rowStr := raw[idx : idx+end] + if strings.Contains(rowStr, `"can_relay"`) { + t.Errorf("legacy observer raw JSON unexpectedly contains can_relay key: %s", rowStr) + } + } + } +} diff --git a/cmd/server/resolve_non_relay_1290_test.go b/cmd/server/resolve_non_relay_1290_test.go new file mode 100644 index 00000000..d7d8aac2 --- /dev/null +++ b/cmd/server/resolve_non_relay_1290_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +// Issue #1290 — exclude observers that advertised `repeat:off` (listener-only) +// from the path-hop disambiguator candidate set. Three cases: +// 1. repeat:off pubkey → NOT a candidate +// 2. repeat:on pubkey → IS a candidate (regression guard) +// 3. legacy / no field → IS a candidate (back-compat preserve current behavior) +func TestResolveWithContext_ExcludesNonRelayObservers_Issue1290(t *testing.T) { + nodes := []nodeInfo{ + {Role: "repeater", PublicKey: "a1aaaaaa", Name: "RealRepeater"}, + {Role: "repeater", PublicKey: "a1bbbbbb", Name: "ListenerOnly"}, + } + + // Case 1: marked non-relay → excluded from candidate set. + pm := buildPrefixMap(nodes) + pm.markNonRelay([]string{"a1bbbbbb"}) + ni, conf, _ := pm.resolveWithContext("a1bbbbbb", nil, nil) + if ni != nil { + t.Fatalf("case repeat:off — expected nil (listener-only excluded), got name=%q confidence=%q", ni.Name, conf) + } + if conf != "no_match" { + t.Fatalf("case repeat:off — expected no_match confidence after exclusion, got %q", conf) + } + + // Case 2: repeat:on (i.e. not in nonRelay set) → still resolves. + pm2 := buildPrefixMap(nodes) + pm2.markNonRelay([]string{"a1bbbbbb"}) + ni2, _, _ := pm2.resolveWithContext("a1aaaaaa", nil, nil) + if ni2 == nil || ni2.Name != "RealRepeater" { + t.Fatalf("case repeat:on — expected RealRepeater, got %+v", ni2) + } + + // Case 3: legacy back-compat — no markNonRelay call → behavior unchanged. + pm3 := buildPrefixMap(nodes) + ni3, _, _ := pm3.resolveWithContext("a1bbbbbb", nil, nil) + if ni3 == nil || ni3.Name != "ListenerOnly" { + t.Fatalf("case legacy — expected ListenerOnly (back-compat), got %+v", ni3) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 687b5191..135b663e 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2534,6 +2534,7 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) { LastPacketAt: o.LastPacketAt, PacketsLastHour: plh, Lat: lat, Lon: lon, NodeRole: nodeRole, + CanRelay: o.CanRelay, } applyObserverNaiveClock(&resp, o, nowTime) result = append(result, resp) @@ -2578,6 +2579,7 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { NoiseFloor: obs.NoiseFloor, LastPacketAt: obs.LastPacketAt, PacketsLastHour: plh, + CanRelay: obs.CanRelay, } applyObserverNaiveClock(&resp, obs, time.Now().UTC()) return resp diff --git a/cmd/server/store.go b/cmd/server/store.go index 8233191d..bfd71872 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -6121,6 +6121,28 @@ func (s *PacketStore) getAllNodes() []nodeInfo { type prefixMap struct { m map[string][]nodeInfo + // nonRelay holds lowercase pubkeys of observer-known nodes that have + // advertised `repeat:off` in their MQTT /status message (issue #1290). + // Such nodes are pure listeners and must never be selected as a + // path-hop candidate by resolveWithContext, since by firmware + // contract they do not forward packets. nil/empty preserves the + // pre-#1290 behavior (every prefix-matching node is a candidate). + nonRelay map[string]struct{} +} + +// markNonRelay registers a set of lowercase pubkeys as listener-only. +// Called by the server when wiring the prefix map after reading the +// observers table's can_relay column. Issue #1290. +func (pm *prefixMap) markNonRelay(pubkeys []string) { + if pm == nil { + return + } + if pm.nonRelay == nil { + pm.nonRelay = make(map[string]struct{}, len(pubkeys)) + } + for _, pk := range pubkeys { + pm.nonRelay[strings.ToLower(pk)] = struct{}{} + } } // maxPrefixLen caps prefix map entries. MeshCore path hops use 2–6 char @@ -6172,6 +6194,17 @@ func (s *PacketStore) getCachedNodesAndPM() ([]nodeInfo, *prefixMap) { nodes := s.getAllNodes() pm := buildPrefixMap(nodes) + // Issue #1290: exclude observers that advertised `repeat:off` from + // the path-hop candidate set. Failure is non-fatal — we log via the + // schema-degradation channel and proceed with an empty filter (i.e. + // pre-#1290 behavior). + if s.db != nil && s.db.conn != nil { + if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil { + pm.markNonRelay(pks) + } else { + s.logSchemaDegradationOnce("observers.can_relay read failed; path-hop disambiguator will not filter listener-only observers: " + err.Error()) + } + } s.cacheMu.Lock() s.nodeCache = nodes @@ -6235,6 +6268,25 @@ func (pm *prefixMap) resolve(hop string) *nodeInfo { func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) { h := strings.ToLower(hop) candidates := pm.m[h] + // Issue #1290: drop observer-known listener-only nodes from the + // candidate set. By firmware contract a node that advertises + // `repeat:off` in its MQTT /status will never relay a packet, so it + // cannot legitimately be a hop in someone else's path. Filtering + // here shrinks ambiguous candidate sets without affecting any + // upstream caller (the returned shape and confidence labels are + // preserved; only no_match becomes more likely when the only + // matching prefix belonged to a listener). Empty pm.nonRelay + // preserves the pre-#1290 behavior exactly (back-compat). + if len(pm.nonRelay) > 0 && len(candidates) > 0 { + filtered := candidates[:0:0] + for i := range candidates { + if _, isListener := pm.nonRelay[strings.ToLower(candidates[i].PublicKey)]; isListener { + continue + } + filtered = append(filtered, candidates[i]) + } + candidates = filtered + } if len(candidates) == 0 { return nil, "no_match", 0 } @@ -8848,6 +8900,34 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro } observerRows := make([]map[string]interface{}, 0) + // Issue #1290: surface listener/repeater hint on node detail by + // looking up can_relay for each observer that heard this node. + // One-shot fetch of the non-relay set keeps this O(observers) on + // rare events; nil on error degrades to "neither badge" client-side. + // Issue #1290: keep this set lowercase to match the convention used + // by the resolver (cmd/server/store.go pm.nonRelay) and by + // GetNonRelayObserverPubkeys (which already returns LOWER(id)). + // Two case conventions on the same upstream string would be a + // latent regression waiting for any refactor that touches the + // observer-id normalization layer. + nonRelaySet := map[string]struct{}{} + // PR #1624 MAJOR-2: tri-state badge needs to distinguish "confirmed + // repeater" (seen=1, can_relay=1) from "unknown" (seen=0). Build + // the set of observers we have NO repeat-field record for so the + // badge is nil/omitted for them — matches nodes.js:679 tri-state. + seenSet := map[string]struct{}{} + if s.db != nil && s.db.conn != nil { + if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil { + for _, pk := range pks { + nonRelaySet[strings.ToLower(pk)] = struct{}{} + } + } + if pks, err := s.db.GetCanRelaySeenObserverPubkeys(); err == nil { + for _, pk := range pks { + seenSet[strings.ToLower(pk)] = struct{}{} + } + } + } for id, o := range observerStats { var avgSnr, avgRssi interface{} if o.snrCount > 0 { @@ -8856,9 +8936,19 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro if o.rssiCount > 0 { avgRssi = o.rssiSum / float64(o.rssiCount) } + idLower := strings.ToLower(id) + var canRelay interface{} // nil = unknown (no repeat field ever) + if _, seen := seenSet[idLower]; seen { + if _, isListener := nonRelaySet[idLower]; isListener { + canRelay = false + } else { + canRelay = true + } + } observerRows = append(observerRows, map[string]interface{}{ "observer_id": id, "observer_name": o.name, "avgSnr": avgSnr, "avgRssi": avgRssi, "packetCount": o.count, + "can_relay": canRelay, }) } sort.Slice(observerRows, func(i, j int) bool { diff --git a/cmd/server/types.go b/cmd/server/types.go index 2a6ff9de..ff17d332 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -909,6 +909,11 @@ type ObserverResp struct { ClockSkewSeconds interface{} `json:"clock_skew_seconds"` ClockSkewCount24h int `json:"clock_skew_count_24h"` ClockLastNaiveAt interface{} `json:"clock_last_naive_at"` + // Issue #1290: firmware 1.16 `repeat` flag — true=repeater, + // false=listener-only, nil=unknown (legacy observer never sent the + // field). UI tri-state badge renders nothing when nil so legacy + // rows don't masquerade as confirmed repeaters (PR #1624 MAJOR-2). + CanRelay *bool `json:"can_relay,omitempty"` } type ObserverListResponse struct { diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go index 832d1716..cb450b89 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -82,6 +82,12 @@ func Apply(rw *sql.DB, logf Logger) error { if err := ensureObserverNaiveClockColumns(rw, logf); err != nil { return fmt.Errorf("ensure observers naive-clock columns: %w", err) } + if err := ensureObserverCanRelayColumn(rw, logf); err != nil { + return fmt.Errorf("ensure observers.can_relay: %w", err) + } + if err := ensureObserverCanRelaySeenColumn(rw, logf); err != nil { + return fmt.Errorf("ensure observers.can_relay_seen: %w", err) + } return nil } @@ -138,6 +144,19 @@ func AssertReady(ro *sql.DB) error { mustCol("observers", "clock_skew_seconds") mustCol("observers", "clock_skew_count_24h") mustCol("observers", "clock_last_naive_at") + // Issue #1290: firmware 1.16 publishes a `repeat: on|off` flag in + // the MQTT /status JSON. Ingestor persists it as can_relay; server + // reads it to filter listener-only observers out of the path-hop + // disambiguator candidate set. Default 1 preserves prior behavior + // for legacy observers that never sent the field. + mustCol("observers", "can_relay") + // Issue #1290 follow-up (PR #1624 MAJOR-2): tri-state badge. The + // can_relay column defaults to 1 at INSERT, so we cannot distinguish + // "firmware confirmed repeater" from "legacy observer that never + // sent a repeat field". can_relay_seen=1 means the ingestor wrote + // an explicit value; can_relay_seen=0 means leave the UI badge + // unset (unknown state). + mustCol("observers", "can_relay_seen") if len(missing) > 0 { return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s", @@ -522,3 +541,48 @@ func ensureObserverNaiveClockColumns(rw *sql.DB, logf Logger) error { } return nil } + +// ensureObserverCanRelayColumn adds the can_relay column to observers. +// Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT /status +// JSON (#1290); the ingestor parses it and writes 0/1 here. The server's +// path-hop disambiguator (cmd/server/store.go pm.resolveWithContext) +// excludes observers with can_relay=0 from the candidate set. Default 1 +// preserves prior behavior for legacy observers (no repeat field). +func ensureObserverCanRelayColumn(rw *sql.DB, logf Logger) error { + has, err := TableHasColumn(rw, "observers", "can_relay") + if err != nil { + return err + } + if has { + return nil + } + // PREFLIGHT: async=true reason="single-column ALTER on observers (low-cardinality, ~1k rows in prod); DEFAULT 1 is a constant so SQLite does the rewrite as a metadata-only schema update, no row scan" + if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN can_relay INTEGER DEFAULT 1"); err != nil { + return err + } + logf("[dbschema] added can_relay column to observers") + return nil +} + +// ensureObserverCanRelaySeenColumn adds the can_relay_seen tracking column. +// Issue #1290 follow-up (PR #1624 MAJOR-2): can_relay defaults to 1 at +// INSERT, which conflates "confirmed repeater" with "legacy observer +// that never sent the repeat field". can_relay_seen=1 is written by +// the ingestor whenever the firmware actually provided the field; the +// server's read layer returns CanRelay=nil whenever seen=0 so the UI +// can render the tri-state badge (no badge for unknown). +func ensureObserverCanRelaySeenColumn(rw *sql.DB, logf Logger) error { + has, err := TableHasColumn(rw, "observers", "can_relay_seen") + if err != nil { + return err + } + if has { + return nil + } + // PREFLIGHT: async=true reason="single-column ALTER on observers (low-cardinality, ~1k rows in prod); DEFAULT 0 is a constant so SQLite does the rewrite as a metadata-only schema update, no row scan" + if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN can_relay_seen INTEGER DEFAULT 0"); err != nil { + return err + } + logf("[dbschema] added can_relay_seen column to observers") + return nil +} diff --git a/public/nodes.js b/public/nodes.js index 548184f1..ce17d8fa 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -676,7 +676,7 @@
${observers.map(o => `