mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-29 14:57:58 +00:00
feat: persist ADVERT Feat1/Feat2 capability bytes per node
MeshCore firmware sends two capability bytes on ADVERT packets (wire bits
per AdvertDataHelpers.h) whenever the HasFeat1/HasFeat2 flags are set.
CoreScope already decoded them per-packet but discarded them rather than
storing a per-node value.
- New feat1/feat2 columns on nodes/inactive_nodes (internal/dbschema,
idempotent ALTER like the existing multibyte_sup/multibyte_evidence
columns).
- Ingestor's UpdateNodeTelemetry now writes feat1/feat2 alongside
battery_mv/temperature_c in the same COALESCE-based UPDATE, from the
same ADVERT payload.
- Server exposes them on /api/nodes and /api/nodes/{pubkey} (nullable,
same shape as battery_mv/temperature_c).
- Node detail page shows them as raw hex (0x....) in the Overview panel
when present -- undecoded, since CoreScope doesn't know the individual
bit meanings, but no longer silently discarded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1acaabe10a
commit
10f9f22148
+20
-5
@@ -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)
|
||||
}
|
||||
|
||||
+28
-5
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
+19
-2
@@ -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)
|
||||
}
|
||||
|
||||
+27
-5
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -1664,6 +1664,8 @@
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${Number(stats.avgSnr).toFixed(1)} dB</dd>` : ''}
|
||||
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
|
||||
${hasLoc ? `<dt>Location</dt><dd>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</dd>` : ''}
|
||||
${n.feat1 != null ? `<dt title="Raw ADVERT capability byte (MeshCore firmware AdvertDataHelpers.h). Undecoded -- shown as-is.">Feat1</dt><dd class="mono">0x${Number(n.feat1).toString(16).padStart(4, '0')}</dd>` : ''}
|
||||
${n.feat2 != null ? `<dt title="Raw ADVERT capability byte (MeshCore firmware AdvertDataHelpers.h). Undecoded -- shown as-is.">Feat2</dt><dd class="mono">0x${Number(n.feat2).toString(16).padStart(4, '0')}</dd>` : ''}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user