diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index f70e7104..b5075550 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -861,7 +861,9 @@ func (s *Store) prepareStatements() error { s.stmtUpdateNodeTelemetry, err = s.db.Prepare(` UPDATE nodes SET battery_mv = COALESCE(?, battery_mv), - temperature_c = COALESCE(?, temperature_c) + temperature_c = COALESCE(?, temperature_c), + feat1 = COALESCE(?, feat1), + feat2 = COALESCE(?, feat2) WHERE public_key = ? `) if err != nil { @@ -1045,16 +1047,29 @@ func (s *Store) MarkNodeForeign(pubKey string) error { return err } -// UpdateNodeTelemetry updates battery and temperature for a node. -func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error { - var bv, tc interface{} +// UpdateNodeTelemetry updates battery, temperature, and the raw ADVERT +// Feat1/Feat2 capability bytes for a node. feat1/feat2 are the wire +// capability bits MeshCore firmware sends per AdvertDataHelpers.h when the +// ADVERT's HasFeat1/HasFeat2 flags are set -- previously decoded per-packet +// but never persisted per-node (see decoder.go's Payload.Feat1/Feat2 doc). +// COALESCE-based like battery/temperature: a nil here leaves the existing +// stored value untouched rather than clobbering it with NULL, since not +// every ADVERT carries every field. +func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64, feat1 *int, feat2 *int) error { + var bv, tc, f1, f2 interface{} if batteryMv != nil { bv = *batteryMv } if temperatureC != nil { tc = *temperatureC } - _, err := s.stmtUpdateNodeTelemetry.Exec(bv, tc, pubKey) + if feat1 != nil { + f1 = *feat1 + } + if feat2 != nil { + f2 = *feat2 + } + _, err := s.stmtUpdateNodeTelemetry.Exec(bv, tc, f1, f2, pubKey) if err != nil { s.Stats.WriteErrors.Add(1) } diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index 64b66831..cfbacd80 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -1386,13 +1386,15 @@ func TestUpdateNodeTelemetry(t *testing.T) { battery := 3700 temp := 28.5 - if err := s.UpdateNodeTelemetry("telem1", &battery, &temp); err != nil { + feat1 := 0x12 + feat2 := 0x34 + if err := s.UpdateNodeTelemetry("telem1", &battery, &temp, &feat1, &feat2); err != nil { t.Fatal(err) } - var bv int + var bv, f1, f2 int var tc float64 - err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc) + err = s.db.QueryRow("SELECT battery_mv, temperature_c, feat1, feat2 FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc, &f1, &f2) if err != nil { t.Fatal(err) } @@ -1402,12 +1404,18 @@ func TestUpdateNodeTelemetry(t *testing.T) { if tc != 28.5 { t.Errorf("temperature_c=%f, want 28.5", tc) } + if f1 != 0x12 { + t.Errorf("feat1=%d, want %d", f1, 0x12) + } + if f2 != 0x34 { + t.Errorf("feat2=%d, want %d", f2, 0x34) + } newTemp := -5.0 - if err := s.UpdateNodeTelemetry("telem1", nil, &newTemp); err != nil { + if err := s.UpdateNodeTelemetry("telem1", nil, &newTemp, nil, nil); err != nil { t.Fatal(err) } - err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc) + err = s.db.QueryRow("SELECT battery_mv, temperature_c, feat1, feat2 FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc, &f1, &f2) if err != nil { t.Fatal(err) } @@ -1417,6 +1425,12 @@ func TestUpdateNodeTelemetry(t *testing.T) { if tc != -5.0 { t.Errorf("temperature_c after update=%f, want -5.0", tc) } + if f1 != 0x12 { + t.Errorf("feat1 after nil update=%d, want %d (preserved)", f1, 0x12) + } + if f2 != 0x34 { + t.Errorf("feat2 after nil update=%d, want %d (preserved)", f2, 0x34) + } } func TestTelemetryMigrationAddsColumns(t *testing.T) { @@ -1431,6 +1445,15 @@ func TestTelemetryMigrationAddsColumns(t *testing.T) { t.Errorf("nodes table should have battery_mv and temperature_c columns: %v", err) } + _, err = s.db.Exec("SELECT feat1, feat2 FROM nodes LIMIT 1") + if err != nil { + t.Errorf("nodes table should have feat1 and feat2 columns: %v", err) + } + _, err = s.db.Exec("SELECT feat1, feat2 FROM inactive_nodes LIMIT 1") + if err != nil { + t.Errorf("inactive_nodes table should have feat1 and feat2 columns: %v", err) + } + _, err = s.db.Exec("SELECT battery_mv, temperature_c FROM inactive_nodes LIMIT 1") if err != nil { t.Errorf("inactive_nodes table should have battery_mv and temperature_c columns: %v", err) diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index c415051d..5d8fbde0 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -816,9 +816,9 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, log.Printf("MQTT [%s] advert count error: %v", tag, err) } } - // Update telemetry if present in advert - if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil { - if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil { + // Update telemetry + raw capability bytes if present in advert + if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil || decoded.Payload.Feat1 != nil || decoded.Payload.Feat2 != nil { + if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC, decoded.Payload.Feat1, decoded.Payload.Feat2); err != nil { log.Printf("MQTT [%s] node telemetry update error: %v", tag, err) } } diff --git a/cmd/server/bridge_handle_nodes_test.go b/cmd/server/bridge_handle_nodes_test.go index b88b7b1b..8b96544c 100644 --- a/cmd/server/bridge_handle_nodes_test.go +++ b/cmd/server/bridge_handle_nodes_test.go @@ -17,11 +17,17 @@ import ( func TestBridgeScore_HandleNodesSurface(t *testing.T) { db := setupCapabilityTestDB(t) defer db.conn.Close() - // handleNodes/db.GetNodes selects a foreign_advert column not in - // the minimal capability-test schema. + // handleNodes/db.GetNodes selects foreign_advert/feat1/feat2 columns + // not in the minimal capability-test schema. if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { t.Fatal(err) } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat1 INTEGER`); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat2 INTEGER`); err != nil { + t.Fatal(err) + } // Four repeater nodes in a line. pks := []string{ diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 8dc83153..e7a0efdd 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -30,7 +30,8 @@ func setupTestDBv2(t *testing.T) *DB { CREATE TABLE nodes ( public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0, - battery_mv INTEGER, temperature_c REAL, foreign_advert INTEGER DEFAULT 0 + battery_mv INTEGER, temperature_c REAL, foreign_advert INTEGER DEFAULT 0, + feat1 INTEGER, feat2 INTEGER ); CREATE TABLE observers ( id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT, first_seen TEXT, diff --git a/cmd/server/db.go b/cmd/server/db.go index 91f01542..cc0449d6 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -153,7 +153,7 @@ func (db *DB) detectSchema() { // nodeSelectCols returns the SELECT column list for nodes queries. // When hasDefaultScope is true, default_scope is appended as the last column. func (db *DB) nodeSelectCols() string { - cols := "public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert" + cols := "public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert, feat1, feat2" if db.hasDefaultScope { cols += ", default_scope" } @@ -243,6 +243,12 @@ type Node struct { AdvertCount int `json:"advert_count"` BatteryMv *int `json:"battery_mv"` TemperatureC *float64 `json:"temperature_c"` + // Feat1/Feat2 are the raw ADVERT capability bytes (wire bits per + // MeshCore firmware's AdvertDataHelpers.h), present only when the + // advert's HasFeat1/HasFeat2 flags were set. CoreScope does not + // decode individual bits — these are the raw uint16 values as sent. + Feat1 *int `json:"feat1"` + Feat2 *int `json:"feat2"` } // Observer represents a row from the observers table. @@ -2496,9 +2502,10 @@ func (db *DB) scanNodeRow(rows *sql.Rows) map[string]interface{} { var batteryMv sql.NullInt64 var temperatureC sql.NullFloat64 var foreign sql.NullInt64 + var feat1, feat2 sql.NullInt64 var defaultScope sql.NullString - scanArgs := []interface{}{&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign} + scanArgs := []interface{}{&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign, &feat1, &feat2} if db.hasDefaultScope { scanArgs = append(scanArgs, &defaultScope) } @@ -2529,6 +2536,16 @@ func (db *DB) scanNodeRow(rows *sql.Rows) map[string]interface{} { } else { m["temperature_c"] = nil } + if feat1.Valid { + m["feat1"] = int(feat1.Int64) + } else { + m["feat1"] = nil + } + if feat2.Valid { + m["feat2"] = int(feat2.Int64) + } else { + m["feat2"] = nil + } if db.hasDefaultScope { m["default_scope"] = nullStr(defaultScope) } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 20af417d..1f2e2760 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -33,7 +33,9 @@ func setupTestDB(t *testing.T) *DB { advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL, - foreign_advert INTEGER DEFAULT 0 + foreign_advert INTEGER DEFAULT 0, + feat1 INTEGER, + feat2 INTEGER ); CREATE TABLE observers ( @@ -1212,7 +1214,9 @@ func setupTestDBV2(t *testing.T) *DB { advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL, - foreign_advert INTEGER DEFAULT 0 + foreign_advert INTEGER DEFAULT 0, + feat1 INTEGER, + feat2 INTEGER ); CREATE TABLE observers ( @@ -1761,9 +1765,9 @@ func TestNodeTelemetryFields(t *testing.T) { db := setupTestDB(t) defer db.Close() - // Insert node with telemetry data - db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c) - VALUES ('pk_telem1', 'SensorNode', 'sensor', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5, 3700, 28.5)`) + // Insert node with telemetry data + raw ADVERT capability bytes + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, feat1, feat2) + VALUES ('pk_telem1', 'SensorNode', 'sensor', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5, 3700, 28.5, 18, 52)`) // Test via GetNodeByPubkey node, err := db.GetNodeByPubkey("pk_telem1") @@ -1779,6 +1783,12 @@ func TestNodeTelemetryFields(t *testing.T) { if node["temperature_c"] != 28.5 { t.Errorf("temperature_c=%v, want 28.5", node["temperature_c"]) } + if node["feat1"] != 18 { + t.Errorf("feat1=%v, want 18", node["feat1"]) + } + if node["feat2"] != 52 { + t.Errorf("feat2=%v, want 52", node["feat2"]) + } // Test via GetNodes nodes, _, _, err := db.GetNodes(50, 0, "sensor", "", "", "", "", "") @@ -1791,6 +1801,12 @@ func TestNodeTelemetryFields(t *testing.T) { if nodes[0]["battery_mv"] != 3700 { t.Errorf("GetNodes battery_mv=%v, want 3700", nodes[0]["battery_mv"]) } + if nodes[0]["feat1"] != 18 { + t.Errorf("GetNodes feat1=%v, want 18", nodes[0]["feat1"]) + } + if nodes[0]["feat2"] != 52 { + t.Errorf("GetNodes feat2=%v, want 52", nodes[0]["feat2"]) + } // Test node without telemetry — fields should be nil db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) @@ -1802,6 +1818,12 @@ func TestNodeTelemetryFields(t *testing.T) { if node2["temperature_c"] != nil { t.Errorf("expected nil temperature_c for node without telemetry, got %v", node2["temperature_c"]) } + if node2["feat1"] != nil { + t.Errorf("expected nil feat1 for node without a Feat1-carrying advert, got %v", node2["feat1"]) + } + if node2["feat2"] != nil { + t.Errorf("expected nil feat2 for node without a Feat2-carrying advert, got %v", node2["feat2"]) + } } func TestMain(m *testing.M) { diff --git a/cmd/server/first_seen_1166_test.go b/cmd/server/first_seen_1166_test.go index f056c127..75c24cbd 100644 --- a/cmd/server/first_seen_1166_test.go +++ b/cmd/server/first_seen_1166_test.go @@ -18,6 +18,12 @@ func TestFirstSeen_1166_HandleNodesSurface(t *testing.T) { if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { t.Fatal(err) } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat1 INTEGER`); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat2 INTEGER`); err != nil { + t.Fatal(err) + } pk := "cccc000000000000000000000000000000000000000000000000000000000000" first := time.Now().Add(-72 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z") diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b2650d36..832f55a2 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -195,6 +195,8 @@ func componentSchemas() map[string]interface{} { "flood_advert_count_7d": map[string]interface{}{"type": "integer", "description": "Distinct FLOOD adverts originated in the last 7 days (zero-hop adverts excluded). Present on the node detail endpoint."}, "battery_mv": map[string]interface{}{"type": "integer", "nullable": true}, "temperature_c": map[string]interface{}{"type": "number", "nullable": true}, + "feat1": map[string]interface{}{"type": "integer", "nullable": true, "description": "Raw ADVERT Feat1 capability byte (wire bits per MeshCore firmware's AdvertDataHelpers.h), present only when the node's most recent advert carrying it had the HasFeat1 flag set. CoreScope does not decode individual bits -- this is the raw uint16 value as sent."}, + "feat2": map[string]interface{}{"type": "integer", "nullable": true, "description": "Raw ADVERT Feat2 capability byte, same caveats as feat1."}, "relay_active": map[string]interface{}{"type": "boolean", "description": "Repeater/room only: relayed traffic within the active window."}, "relay_count_1h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last hour."}, "relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last 24 hours."}, diff --git a/cmd/server/traffic_share_score_test.go b/cmd/server/traffic_share_score_test.go index 7610336d..d48dbe34 100644 --- a/cmd/server/traffic_share_score_test.go +++ b/cmd/server/traffic_share_score_test.go @@ -24,6 +24,12 @@ func TestTrafficShareScore_HandleNodesSurface(t *testing.T) { if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { t.Fatal(err) } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat1 INTEGER`); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat2 INTEGER`); err != nil { + t.Fatal(err) + } // Three repeaters on a line L-pk-R so the middle node `pk` is a cut // vertex: bridge/coverage/redundancy all > 0 while it relays no traffic. @@ -121,6 +127,12 @@ func TestTrafficShareScore_NodeDetail(t *testing.T) { if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { t.Fatal(err) } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat1 INTEGER`); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat2 INTEGER`); err != nil { + t.Fatal(err) + } pk := "bbbb000000000000000000000000000000000000000000000000000000000000" recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") diff --git a/cmd/server/usefulness_axes_handle_nodes_test.go b/cmd/server/usefulness_axes_handle_nodes_test.go index 9628525c..ce9854ce 100644 --- a/cmd/server/usefulness_axes_handle_nodes_test.go +++ b/cmd/server/usefulness_axes_handle_nodes_test.go @@ -20,6 +20,12 @@ func TestUsefulnessAxes_HandleNodesSurface(t *testing.T) { if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { t.Fatal(err) } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat1 INTEGER`); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN feat2 INTEGER`); err != nil { + t.Fatal(err) + } pks := []string{ "aaaa000000000000000000000000000000000000000000000000000000000000", diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go index cf28557f..a03a15f4 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -79,6 +79,9 @@ func Apply(rw *sql.DB, logf Logger) error { if err := ensureMultibyteCapColumns(rw, logf); err != nil { return fmt.Errorf("ensure multibyte_cap columns: %w", err) } + if err := ensureFeat1Feat2Columns(rw, logf); err != nil { + return fmt.Errorf("ensure feat1/feat2 columns: %w", err) + } if err := ensureObserverNaiveClockColumns(rw, logf); err != nil { return fmt.Errorf("ensure observers naive-clock columns: %w", err) } @@ -150,6 +153,12 @@ func AssertReady(ro *sql.DB) error { // enrichment, ingestor's RunMultibyteCapPersist is the only writer. mustCol("nodes", "multibyte_sup") mustCol("nodes", "multibyte_evidence") + // Raw ADVERT capability bytes (Feat1/Feat2) -- owned by ingestor, see + // ensureFeat1Feat2Columns. + mustCol("nodes", "feat1") + mustCol("nodes", "feat2") + mustCol("inactive_nodes", "feat1") + mustCol("inactive_nodes", "feat2") mustCol("inactive_nodes", "multibyte_sup") mustCol("inactive_nodes", "multibyte_evidence") // Issue #1478: per-observer naive-clock skew tracking. Server reads @@ -524,6 +533,34 @@ func ensureMultibyteCapColumns(rw *sql.DB, logf Logger) error { return nil } +// ensureFeat1Feat2Columns adds the raw ADVERT Feat1/Feat2 capability-byte +// columns to nodes / inactive_nodes. MeshCore firmware sends these as +// wire capability bits (per AdvertDataHelpers.h) on every ADVERT that has +// HasFeat1/HasFeat2 set, but CoreScope only ever decoded them into the +// per-packet Payload struct and discarded them rather than persisting a +// per-node value — this closes that gap. Nullable: absent until the next +// ADVERT with the corresponding flag set arrives for that node (a repeat +// of the battery_mv/temperature_c nullability, which are also only +// present on sensor-role ADVERTs). +func ensureFeat1Feat2Columns(rw *sql.DB, logf Logger) error { + for _, table := range []string{"nodes", "inactive_nodes"} { + for _, col := range []string{"feat1", "feat2"} { + has, err := TableHasColumn(rw, table, col) + if err != nil { + return fmt.Errorf("inspect %s.%s: %w", table, col, err) + } + if !has { + if _, err := rw.Exec(fmt.Sprintf( + "ALTER TABLE %s ADD COLUMN %s INTEGER", table, col)); err != nil { + return fmt.Errorf("add %s.%s: %w", table, col, err) + } + logf("[dbschema] added %s column to %s", col, table) + } + } + } + return nil +} + // ensureObserverNaiveClockColumns adds the three per-observer naive-clock // skew tracking columns (#1478). Server reads them to populate the // clock_naive / clock_skew_seconds / clock_skew_count_24h / diff --git a/public/nodes.js b/public/nodes.js index a22a8d43..0ea0133e 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -1664,6 +1664,8 @@ ${stats.avgSnr != null ? `