From 10f9f22148f85eea5c2e510138bb01b64f3dff03 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 11:26:06 +0200 Subject: [PATCH 01/10] 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 --- cmd/ingestor/db.go | 25 ++++++++++--- cmd/ingestor/db_test.go | 33 ++++++++++++++--- cmd/ingestor/main.go | 6 +-- cmd/server/bridge_handle_nodes_test.go | 10 ++++- cmd/server/coverage_test.go | 3 +- cmd/server/db.go | 21 ++++++++++- cmd/server/db_test.go | 32 +++++++++++++--- cmd/server/first_seen_1166_test.go | 6 +++ cmd/server/openapi.go | 2 + cmd/server/traffic_share_score_test.go | 12 ++++++ .../usefulness_axes_handle_nodes_test.go | 6 +++ internal/dbschema/dbschema.go | 37 +++++++++++++++++++ public/nodes.js | 2 + 13 files changed, 172 insertions(+), 23 deletions(-) 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 ? `
Avg SNR
${Number(stats.avgSnr).toFixed(1)} dB
` : ''} ${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''} + ${n.feat1 != null ? `
Feat1
0x${Number(n.feat1).toString(16).padStart(4, '0')}
` : ''} + ${n.feat2 != null ? `
Feat2
0x${Number(n.feat2).toString(16).padStart(4, '0')}
` : ''} From f8ec91319f8c94f326d6f39c88bd8ccebf237537 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 11:35:55 +0200 Subject: [PATCH 02/10] Revert "feat: persist ADVERT Feat1/Feat2 capability bytes per node" This reverts commit 10f9f22148f85eea5c2e510138bb01b64f3dff03. --- cmd/ingestor/db.go | 25 +++---------- cmd/ingestor/db_test.go | 33 +++-------------- cmd/ingestor/main.go | 6 +-- cmd/server/bridge_handle_nodes_test.go | 10 +---- cmd/server/coverage_test.go | 3 +- cmd/server/db.go | 21 +---------- cmd/server/db_test.go | 32 +++------------- cmd/server/first_seen_1166_test.go | 6 --- cmd/server/openapi.go | 2 - cmd/server/traffic_share_score_test.go | 12 ------ .../usefulness_axes_handle_nodes_test.go | 6 --- internal/dbschema/dbschema.go | 37 ------------------- public/nodes.js | 2 - 13 files changed, 23 insertions(+), 172 deletions(-) diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index b5075550..f70e7104 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -861,9 +861,7 @@ func (s *Store) prepareStatements() error { s.stmtUpdateNodeTelemetry, err = s.db.Prepare(` UPDATE nodes SET battery_mv = COALESCE(?, battery_mv), - temperature_c = COALESCE(?, temperature_c), - feat1 = COALESCE(?, feat1), - feat2 = COALESCE(?, feat2) + temperature_c = COALESCE(?, temperature_c) WHERE public_key = ? `) if err != nil { @@ -1047,29 +1045,16 @@ func (s *Store) MarkNodeForeign(pubKey string) error { return err } -// 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{} +// UpdateNodeTelemetry updates battery and temperature for a node. +func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error { + var bv, tc interface{} if batteryMv != nil { bv = *batteryMv } if temperatureC != nil { tc = *temperatureC } - if feat1 != nil { - f1 = *feat1 - } - if feat2 != nil { - f2 = *feat2 - } - _, err := s.stmtUpdateNodeTelemetry.Exec(bv, tc, f1, f2, pubKey) + _, err := s.stmtUpdateNodeTelemetry.Exec(bv, tc, pubKey) if err != nil { s.Stats.WriteErrors.Add(1) } diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index cfbacd80..64b66831 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -1386,15 +1386,13 @@ func TestUpdateNodeTelemetry(t *testing.T) { battery := 3700 temp := 28.5 - feat1 := 0x12 - feat2 := 0x34 - if err := s.UpdateNodeTelemetry("telem1", &battery, &temp, &feat1, &feat2); err != nil { + if err := s.UpdateNodeTelemetry("telem1", &battery, &temp); err != nil { t.Fatal(err) } - var bv, f1, f2 int + var bv int var tc float64 - err = s.db.QueryRow("SELECT battery_mv, temperature_c, feat1, feat2 FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc, &f1, &f2) + err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc) if err != nil { t.Fatal(err) } @@ -1404,18 +1402,12 @@ 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, nil, nil); err != nil { + if err := s.UpdateNodeTelemetry("telem1", nil, &newTemp); err != nil { t.Fatal(err) } - err = s.db.QueryRow("SELECT battery_mv, temperature_c, feat1, feat2 FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc, &f1, &f2) + err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc) if err != nil { t.Fatal(err) } @@ -1425,12 +1417,6 @@ 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) { @@ -1445,15 +1431,6 @@ 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 5d8fbde0..c415051d 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 + 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 { + // 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 { 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 8b96544c..b88b7b1b 100644 --- a/cmd/server/bridge_handle_nodes_test.go +++ b/cmd/server/bridge_handle_nodes_test.go @@ -17,17 +17,11 @@ import ( func TestBridgeScore_HandleNodesSurface(t *testing.T) { db := setupCapabilityTestDB(t) defer db.conn.Close() - // handleNodes/db.GetNodes selects foreign_advert/feat1/feat2 columns - // not in the minimal capability-test schema. + // handleNodes/db.GetNodes selects a foreign_advert column 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 e7a0efdd..8dc83153 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -30,8 +30,7 @@ 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, - feat1 INTEGER, feat2 INTEGER + battery_mv INTEGER, temperature_c REAL, foreign_advert INTEGER DEFAULT 0 ); 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 cc0449d6..91f01542 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, feat1, feat2" + cols := "public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert" if db.hasDefaultScope { cols += ", default_scope" } @@ -243,12 +243,6 @@ 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. @@ -2502,10 +2496,9 @@ 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, &feat1, &feat2} + scanArgs := []interface{}{&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign} if db.hasDefaultScope { scanArgs = append(scanArgs, &defaultScope) } @@ -2536,16 +2529,6 @@ 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 1f2e2760..20af417d 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -33,9 +33,7 @@ func setupTestDB(t *testing.T) *DB { advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL, - foreign_advert INTEGER DEFAULT 0, - feat1 INTEGER, - feat2 INTEGER + foreign_advert INTEGER DEFAULT 0 ); CREATE TABLE observers ( @@ -1214,9 +1212,7 @@ func setupTestDBV2(t *testing.T) *DB { advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL, - foreign_advert INTEGER DEFAULT 0, - feat1 INTEGER, - feat2 INTEGER + foreign_advert INTEGER DEFAULT 0 ); CREATE TABLE observers ( @@ -1765,9 +1761,9 @@ func TestNodeTelemetryFields(t *testing.T) { db := setupTestDB(t) defer db.Close() - // 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)`) + // 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)`) // Test via GetNodeByPubkey node, err := db.GetNodeByPubkey("pk_telem1") @@ -1783,12 +1779,6 @@ 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", "", "", "", "", "") @@ -1801,12 +1791,6 @@ 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) @@ -1818,12 +1802,6 @@ 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 75c24cbd..f056c127 100644 --- a/cmd/server/first_seen_1166_test.go +++ b/cmd/server/first_seen_1166_test.go @@ -18,12 +18,6 @@ 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 832f55a2..b2650d36 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -195,8 +195,6 @@ 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 d48dbe34..7610336d 100644 --- a/cmd/server/traffic_share_score_test.go +++ b/cmd/server/traffic_share_score_test.go @@ -24,12 +24,6 @@ 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. @@ -127,12 +121,6 @@ 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 ce9854ce..9628525c 100644 --- a/cmd/server/usefulness_axes_handle_nodes_test.go +++ b/cmd/server/usefulness_axes_handle_nodes_test.go @@ -20,12 +20,6 @@ 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 a03a15f4..cf28557f 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -79,9 +79,6 @@ 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) } @@ -153,12 +150,6 @@ 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 @@ -533,34 +524,6 @@ 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 0ea0133e..a22a8d43 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -1664,8 +1664,6 @@ ${stats.avgSnr != null ? `
Avg SNR
${Number(stats.avgSnr).toFixed(1)} dB
` : ''} ${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''} - ${n.feat1 != null ? `
Feat1
0x${Number(n.feat1).toString(16).padStart(4, '0')}
` : ''} - ${n.feat2 != null ? `
Feat2
0x${Number(n.feat2).toString(16).padStart(4, '0')}
` : ''} From 8144b461367ec9c361b6a38d3dfc34b053a5fe2d Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 13:47:28 +0200 Subject: [PATCH 03/10] feat: CoreScope-only "ping" bot reply in Channels view A channel message whose text is exactly "ping" (mention-prefix like "@MeshviewBot ping" stripped first) now gets a synthesized "pong" reply showing hop count, SNR, and hearing observer -- computed at read time from that message's own already-stored data, no new table. Deliberately NOT transmitted back onto the mesh: CoreScope has no publish path to a MeshCore broker/radio (confirmed: it only ever subscribes to MQTT, never publishes). The reply is visible only in CoreScope's own Channels view, rendered as a visually distinct dashed-border bubble with an explicit "Not sent to the mesh" caveat so it's never mistaken for a real bot reply the sender's own radio received. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 48 +++++++++++ cmd/server/db_test.go | 83 ++++++++++++++++++ public/channels.js | 20 ++++- public/style.css | 5 ++ test-all.sh | 1 + test-channels-ping-bot-reply.js | 143 ++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 test-channels-ping-bot-reply.js diff --git a/cmd/server/db.go b/cmd/server/db.go index 91f01542..8999f165 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1749,6 +1749,48 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // This avoids loading every observation row for a channel into Go memory // before paginating (issue #1225: 5703 tx × ~50 obs ≈ 275K rows → ~30s // for limit=50). +// channelMentionPrefixRe strips a leading "@target " reply-address the +// same way the frontend does (public/channels.js replyMatch) before +// matching the ping trigger, so "@MeshviewBot ping" triggers the same as +// a bare "ping". +var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) + +// pingBotReply synthesizes a "pong" reply for a channel message whose +// (mention-stripped) text is exactly "ping" — CoreScope-side only, never +// transmitted back onto the mesh (CoreScope has no publish path to a +// MeshCore broker/radio). Purely a read-time annotation over data this +// message's own row already carries (hop count, SNR, hearing observer), +// not a persisted message: nil when text isn't a ping trigger. +func pingBotReply(displayText string, hops int, snr sql.NullFloat64, observer string) map[string]interface{} { + trigger := strings.TrimSpace(displayText) + trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") + if !strings.EqualFold(strings.TrimSpace(trigger), "ping") { + return nil + } + parts := make([]string, 0, 3) + if hops > 0 { + s := "s" + if hops == 1 { + s = "" + } + parts = append(parts, fmt.Sprintf("%d hop%s", hops, s)) + } else { + parts = append(parts, "0 hops (direct)") + } + if snr.Valid { + parts = append(parts, fmt.Sprintf("SNR %.1fdB", snr.Float64)) + } + if observer != "" { + parts = append(parts, "heard by "+observer) + } + return map[string]interface{}{ + "sender": "MeshviewBot", + "text": "🏓 pong! " + strings.Join(parts, " · "), + "hops": hops, + "snr": nullFloat(snr), + } +} + func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int, error) { if limit <= 0 { limit = 100 @@ -1978,11 +2020,17 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if obsTs.Valid { m.LatestEpoch = obsTs.Int64 } + observerName := "" if obsName.Valid { + observerName = obsName.String m.Data["observers"] = []string{obsName.String} } else if obsID.Valid { + observerName = obsID.String m.Data["observers"] = []string{obsID.String} } + if reply := pingBotReply(displayText, hops, snr, observerName); reply != nil { + m.Data["botReply"] = reply + } msgMap[txID] = m } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 20af417d..28470608 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "os" "path/filepath" + "strings" "testing" "time" @@ -1400,6 +1401,88 @@ func TestGetChannelMessagesNoSender(t *testing.T) { } } +// TestGetChannelMessages_PingBotReply covers the CoreScope-only "ping" +// bot: a channel message whose text is exactly "ping" gets a synthetic +// botReply attached (never transmitted back onto the mesh -- see +// pingBotReply's doc comment), while ordinary messages don't. +func TestGetChannelMessages_PingBotReply(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + + // tx1: a plain chat message -- must NOT get a botReply. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"just chatting","sender":"Alice"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 9.0, -88, '["aa","bb"]', 1736935200)`) + + // tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Bob"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 8.2, -90, '["aa","bb"]', 1736935260)`) + + // tx3: "@MeshviewBot ping" -- the mention-prefix must be stripped before matching. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"@MeshviewBot ping","sender":"Carol"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 5.0, -95, '[]', 1736935320)`) + + // tx4: "pinging" -- must NOT match (not an exact "ping"). + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('DD', 'chanmsg00000004', '2026-01-15T10:03:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"pinging around","sender":"Dave"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (4, 1, 3.0, -99, '[]', 1736935380)`) + + messages, total, err := db.GetChannelMessages("#ping", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 4 { + t.Fatalf("expected 4 messages, got %d", total) + } + + byText := map[string]map[string]interface{}{} + for _, m := range messages { + byText[m["text"].(string)] = m + } + + if r := byText["just chatting"]["botReply"]; r != nil { + t.Errorf("plain chat message should not get a botReply, got %+v", r) + } + if r := byText["pinging around"]["botReply"]; r != nil { + t.Errorf("\"pinging\" should not match the exact \"ping\" trigger, got %+v", r) + } + + pingReply, _ := byText["ping"]["botReply"].(map[string]interface{}) + if pingReply == nil { + t.Fatal("bare \"ping\" message should get a botReply") + } + if pingReply["sender"] != "MeshviewBot" { + t.Errorf("botReply sender = %v, want MeshviewBot", pingReply["sender"]) + } + if pingReply["hops"] != 2 { + t.Errorf("botReply hops = %v, want 2", pingReply["hops"]) + } + replyText, _ := pingReply["text"].(string) + if !strings.Contains(replyText, "2 hops") || !strings.Contains(replyText, "8.2dB") || !strings.Contains(replyText, "Observer One") { + t.Errorf("botReply text = %q, want hops/SNR/observer mentioned", replyText) + } + + mentionReply, _ := byText["@MeshviewBot ping"]["botReply"].(map[string]interface{}) + if mentionReply == nil { + t.Fatal("\"@MeshviewBot ping\" should get a botReply (mention prefix stripped before matching)") + } + if mentionReply["hops"] != 0 { + t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"]) + } +} + func TestGetNetworkStatusDateFormats(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/public/channels.js b/public/channels.js index 2dea7662..18ca4a57 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2290,6 +2290,23 @@ if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`); const safeId = btoa(encodeURIComponent(sender)); + + // Ping-bot reply (server-synthesized in GetChannelMessages when this + // message's text is exactly "ping" -- see pingBotReply in db.go). + // CoreScope-only: never transmitted back onto the mesh, since + // CoreScope has no publish path to a MeshCore broker/radio. The + // "Not sent to the mesh" caveat is load-bearing, not decoration -- + // without it this could be misread as a real bot reply the sender's + // own radio received. + const botReplyHtml = msg.botReply ? `
+ +
+
${escapeHtml(msg.botReply.sender || 'MeshviewBot')}
+
${escapeHtml(msg.botReply.text || '')}
+
Not sent to the mesh — CoreScope-only reply
+
+
` : ''; + // #1367: emit BOTH the new chat-app class names (.ch-message / // .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg* // names so existing tests/themes don't regress. @@ -2300,7 +2317,7 @@
${displayText}
${meta.join(' · ')}${msg.packetHash ? ` · View packet →` : ''}
- `; + ${botReplyHtml}`; }).join(''); } @@ -2309,6 +2326,7 @@ if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); } } + window._channelsRenderMessagesForTest = renderMessages; window._channelsSetStateForTest = function (state) { if (!state) return; if (Array.isArray(state.channels)) channels = state.channels; diff --git a/public/style.css b/public/style.css index 3d7c3d3b..59bcbd20 100644 --- a/public/style.css +++ b/public/style.css @@ -1902,6 +1902,11 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } border: 1px solid var(--border); } .ch-mention { color: var(--link-color); font-weight: 600; } +/* Ping-bot reply (public/channels.js botReplyHtml) -- dashed border marks + * it as a synthesized, CoreScope-only reply, never actually transmitted + * onto the mesh, distinct from a real observed message. */ +.ch-bot-message { margin-top: -8px; } +.ch-bot-message .ch-msg-bubble { border-style: dashed; font-style: italic; } .ch-encrypted-text { font-size: 11px; color: var(--text-muted); } .ch-msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; } .ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; } diff --git a/test-all.sh b/test-all.sh index bb19215c..c48116ea 100755 --- a/test-all.sh +++ b/test-all.sh @@ -72,6 +72,7 @@ node test-issue-1770-mobile-row-clamp.js node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js node test-analytics-hop-depth-ui.js +node test-channels-ping-bot-reply.js echo "" echo "═══════════════════════════════════════" diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js new file mode 100644 index 00000000..76274777 --- /dev/null +++ b/test-channels-ping-bot-reply.js @@ -0,0 +1,143 @@ +/** + * Unit tests for the CoreScope-only "ping" bot reply bubble + * (public/channels.js renderMessages' botReplyHtml block). + * + * The backend (cmd/server/db.go pingBotReply) attaches a synthetic + * `botReply` field to a channel message whose text is exactly "ping" -- + * this file covers that the frontend renders it distinctly, includes the + * "not sent to the mesh" caveat, and escapes attacker-controlled fields + * (observer names flow into botReply.text server-side, so it must not be + * trusted blindly). + * + * Sandbox pattern borrowed from test-channels-merge-1498-unit.js: load + * channels.js in a tolerant vm context, grab the test-only export. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function makeSandbox() { + const noop = () => {}; + const fakeEl = () => ({ + addEventListener: noop, querySelector: () => null, querySelectorAll: () => [], + classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null, + textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0, + }); + const chMessagesEl = fakeEl(); + const doc = { + readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(), + documentElement: fakeEl(), + getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null), + querySelector: () => null, querySelectorAll: () => [], + addEventListener: noop, + }; + const win = { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) }; + const ctx = { + window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error, + setTimeout, clearTimeout, setInterval, clearInterval, + history: { replaceState: noop, pushState: noop }, + location: { hash: '', href: '', pathname: '/' }, + navigator: { userAgent: 'node' }, + RegionFilter: { getRegionParam: () => '' }, + api: () => Promise.resolve({ messages: [] }), + CLIENT_TTL: {}, + ChannelDecrypt: undefined, + truncate: (s) => s, + formatHashHex: (h) => String(h), + channelDisplayName: (c) => c && c.name, + escapeHtml, + getSenderColor: () => '#123456', + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'), + }; + vm.createContext(ctx); + try { + vm.runInContext(fs.readFileSync('public/channels.js', 'utf8'), ctx); + } catch (e) { + // Tolerant: only the render path under test needs to have been + // exported before any unrelated init code throws. + } + return { ctx, chMessagesEl }; +} + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +console.log('\n=== channels.js: ping-bot reply rendering ==='); + +test('a message without botReply renders no bot bubble', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Alice', text: 'just chatting', timestamp: '2026-01-15T10:00:00Z' }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('ch-bot-message'), 'no botReply field should mean no bot bubble'); +}); + +test('a message with botReply renders a distinct bot bubble with the reply text', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: 'MeshviewBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(html.includes('ch-bot-message'), 'should render the distinct bot-message class'); + assert.ok(html.includes('MeshviewBot'), 'should show the bot sender name'); + assert.ok(html.includes('2 hops'), 'should include the hop count from the reply text'); + assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text'); +}); + +test('the "not sent to the mesh" caveat is always present on a bot bubble', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'MeshviewBot', text: 'pong', hops: 0 } }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(chMessagesEl.innerHTML.includes('Not sent to the mesh'), 'the caveat must be visible so this is never mistaken for a real mesh reply'); +}); + +test('botReply.text and .sender are HTML-escaped (observer names are operator-controlled)', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: '', text: 'heard by ', hops: 0 }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(!html.includes('alert(2)'), 'botReply.text must be escaped'); +}); + +test('the bot bubble renders immediately after its triggering message, not before other messages', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'MeshviewBot', text: 'pong', hops: 0 } }, + { sender: 'Carol', text: 'after', timestamp: '2026-01-15T10:02:00Z' }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + const pingIdx = html.indexOf('>ping<'); + const botIdx = html.indexOf('ch-bot-message'); + const afterIdx = html.indexOf('>after<'); + assert.ok(pingIdx > -1 && botIdx > -1 && afterIdx > -1, 'all three pieces should be present'); + assert.ok(pingIdx < botIdx && botIdx < afterIdx, 'order should be: ping message, bot reply, next message'); +}); + +console.log('\n════════════════════════════════════════'); +console.log(` Channels ping-bot reply: ${passed} passed, ${failed} failed`); +console.log('════════════════════════════════════════'); +if (failed > 0) process.exit(1); From 79c734a26e336f9a2633559186cd34d944471bd4 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 13:59:44 +0200 Subject: [PATCH 04/10] fix: ping-bot reply missing on live/WS-pushed and PSK-decrypted messages The ping->pong synthesis only ran in GetChannelMessages (server-side, REST-loaded history). Messages that arrive while a channel is already open -- via the WebSocket live-push path, or the client-side decrypt path for user-added PSK channels -- are built directly from the broadcast payload and never round-trip through that endpoint, so typing "ping" in an already-open channel showed nothing until a manual reload. Adds a client-side pingBotReply() mirroring the Go version's trigger rule and reply format, wired into both paths. Co-Authored-By: Claude Sonnet 5 --- public/channels.js | 28 +++++++++++++++++--- test-channels-ping-bot-reply.js | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/public/channels.js b/public/channels.js index 18ca4a57..b9bed4b6 100644 --- a/public/channels.js +++ b/public/channels.js @@ -323,6 +323,21 @@ return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash; } function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; } + // Mirrors pingBotReply in cmd/server/db.go -- kept in sync by hand since + // this is the client-side equivalent for messages that arrive live over + // the WebSocket (handleWSMessage below), which never round-trips through + // GetChannelMessages and so never gets the server-computed botReply. + // Same trigger rule, same reply format. CoreScope-only: see the doc + // comment on botReplyHtml in renderMessages for why this never reaches + // the real mesh. + function pingBotReply(text, hops, snr, observer) { + var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); + if (trigger.toLowerCase() !== 'ping') return null; + var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)']; + if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB'); + if (observer) parts.push('heard by ' + observer); + return { sender: 'MeshviewBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr }; + } function getSenderColor(name) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); @@ -656,6 +671,7 @@ if (ci > 0 && ci < 50 && text.substring(0, ci) === sender) { text = text.substring(ci + 2); } + var alreadyDecObserver = c.packet.observer_name || null; decrypted.push({ sender: sender, text: text, timestamp: c.packet.first_seen || c.packet.timestamp, @@ -665,7 +681,8 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, - repeats: 1 + repeats: 1, + botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver) }); continue; } @@ -674,6 +691,7 @@ var result = await ChannelDecrypt.decryptPacket(keyBytes, c.decoded.mac, c.decoded.encryptedData); if (result) { macFailCount = 0; + var decObserver = c.packet.observer_name || null; decrypted.push({ sender: result.sender, text: result.message, timestamp: c.packet.first_seen || c.packet.timestamp, @@ -683,7 +701,8 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, - repeats: 1 + repeats: 1, + botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver) }); } else { macFailCount++; @@ -1467,6 +1486,7 @@ existing._fromWS = true; existing._wsAt = Date.now(); } else { + var wsHops = payload.path_len || 0; messages.push({ sender: sender, text: displayText, @@ -1476,11 +1496,12 @@ packetHash: pktHash, repeats: 1, observers: observer ? [observer] : [], - hops: payload.path_len || 0, + hops: wsHops, snr: snr, scope: scope, routeType: routeType, area: area, + botReply: pingBotReply(displayText, wsHops, snr, observer), // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of // stomp. Without this flag the REST response wipes any @@ -2327,6 +2348,7 @@ } window._channelsRenderMessagesForTest = renderMessages; + window._channelsPingBotReplyForTest = pingBotReply; window._channelsSetStateForTest = function (state) { if (!state) return; if (Array.isArray(state.channels)) channels = state.channels; diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 76274777..1d7d3601 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -72,6 +72,53 @@ function test(name, fn) { catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } } +console.log('\n=== channels.js: pingBotReply (shared trigger/format logic) ==='); +// This is the client-side twin of pingBotReply in cmd/server/db.go, used +// by the WebSocket live-push path and the client-side PSK-channel decrypt +// path (neither of which round-trips through GetChannelMessages, so +// neither gets the server-computed botReply without this). + +test('exact "ping" (any case) triggers a reply', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('ping', 1, 5, 'Obs') !== null); + assert.ok(fn('PING', 1, 5, 'Obs') !== null); + assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed'); +}); + +test('a mention prefix like "@MeshviewBot ping" is stripped before matching', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('@MeshviewBot ping', 0, null, null) !== null); +}); + +test('"pinging" or other substrings do not match (exact trigger only)', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.strictEqual(fn('pinging around', 1, 5, 'Obs'), null); + assert.strictEqual(fn('not ping', 1, 5, 'Obs'), null); + assert.strictEqual(fn('', 1, 5, 'Obs'), null); +}); + +test('reply text includes hops, SNR, and observer when present', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const r = fn('ping', 3, 8.25, 'Observer One'); + assert.strictEqual(r.sender, 'MeshviewBot'); + assert.ok(r.text.includes('3 hops'), r.text); + assert.ok(r.text.includes('SNR 8.3dB') || r.text.includes('SNR 8.2dB'), r.text); + assert.ok(r.text.includes('heard by Observer One'), r.text); +}); + +test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const r = fn('ping', 0, null, null); + assert.ok(r.text.includes('0 hops (direct)'), r.text); + assert.ok(!r.text.includes('SNR'), r.text); + assert.ok(!r.text.includes('heard by'), r.text); +}); + console.log('\n=== channels.js: ping-bot reply rendering ==='); test('a message without botReply renders no bot bubble', () => { From b3d7efb5115d3e60f631193238f41b0d1c9cbc5f Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 14:39:13 +0200 Subject: [PATCH 05/10] feat: ping-bot reply shows relay path, scope, and area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST-loaded channel history (GetChannelMessages) now resolves the ping message's relay path to node names ("via RepeaterA → RepeaterB"), bulk- resolving every referenced pubkey across the page in one query rather than per-message. Falls back to the raw pubkey when a hop's node isn't known. Also includes the message's region scope and (via a handler-level pass, since area resolution needs server config db.go doesn't have) its resolved area. The client-side pingBotReply (WebSocket live-push + PSK-channel decrypt paths) gains scope/area too, since both are already present in that data. Relay-path names are REST-only for now: the live WS broadcast doesn't carry a resolved_path, only historical/REST-loaded messages do. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 119 +++++++++++++++++++++++++++----- cmd/server/db_test.go | 45 +++++++++++- cmd/server/routes.go | 22 ++++++ public/channels.js | 15 ++-- test-channels-ping-bot-reply.js | 12 ++++ 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 8999f165..50a05715 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1755,25 +1755,38 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) -// pingBotReply synthesizes a "pong" reply for a channel message whose -// (mention-stripped) text is exactly "ping" — CoreScope-side only, never -// transmitted back onto the mesh (CoreScope has no publish path to a -// MeshCore broker/radio). Purely a read-time annotation over data this -// message's own row already carries (hop count, SNR, hearing observer), -// not a persisted message: nil when text isn't a ping trigger. -func pingBotReply(displayText string, hops int, snr sql.NullFloat64, observer string) map[string]interface{} { +// isPingTrigger reports whether displayText, after stripping a leading +// "@target " mention the same way the frontend does (public/channels.js +// replyMatch), is exactly "ping". +func isPingTrigger(displayText string) bool { trigger := strings.TrimSpace(displayText) trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") - if !strings.EqualFold(strings.TrimSpace(trigger), "ping") { - return nil - } - parts := make([]string, 0, 3) + return strings.EqualFold(strings.TrimSpace(trigger), "ping") +} + +// pingBotReply synthesizes a "pong" reply for a channel message whose +// text matched isPingTrigger — CoreScope-side only, never transmitted +// back onto the mesh (CoreScope has no publish path to a MeshCore +// broker/radio). Purely a read-time annotation over data this message's +// own row already carries (hop count + relay path, SNR, hearing +// observer, region scope), not a persisted message. +// +// repeaterNames is the resolved relay path in hop order (element i is +// hop i's node name, falling back to its pubkey/hash-prefix when a name +// couldn't be resolved); nil/empty when hops == 0 or resolution wasn't +// available -- the hop count itself is unaffected either way. +func pingBotReply(hops int, snr sql.NullFloat64, observer, scope string, repeaterNames []string) map[string]interface{} { + parts := make([]string, 0, 4) if hops > 0 { s := "s" if hops == 1 { s = "" } - parts = append(parts, fmt.Sprintf("%d hop%s", hops, s)) + hopDesc := fmt.Sprintf("%d hop%s", hops, s) + if len(repeaterNames) > 0 { + hopDesc += " (via " + strings.Join(repeaterNames, " → ") + ")" + } + parts = append(parts, hopDesc) } else { parts = append(parts, "0 hops (direct)") } @@ -1783,6 +1796,9 @@ func pingBotReply(displayText string, hops int, snr sql.NullFloat64, observer st if observer != "" { parts = append(parts, "heard by "+observer) } + if scope != "" { + parts = append(parts, "scope "+scope) + } return map[string]interface{}{ "sender": "MeshviewBot", "text": "🏓 pong! " + strings.Join(parts, " · "), @@ -1912,10 +1928,17 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if db.hasScopeName { scopeCol = ", t.scope_name" } + // resolvedPathCol feeds the ping-bot reply's "via RepeaterA → RepeaterB" + // hop names (see the bulk-resolve pass below) -- optional like + // scopeCol since not every DB/test fixture has this column. + resolvedPathCol := "" + if db.hasResolvedPath { + resolvedPathCol = ", o.resolved_path" + } var obsSQL string if db.isV3 { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, - obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + ` + obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx @@ -1923,7 +1946,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . ORDER BY o.id ASC` } else { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, - o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + ` + o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `) @@ -1943,9 +1966,24 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } msgMap := make(map[int]*msg, len(pageIDs)) + // pendingPing collects a ping-triggering message's ingredients for its + // botReply. Reply text isn't built inline in the scan loop below + // because the repeater-name lookup needs ONE bulk query across every + // ping on the page (see the pass after the loop) rather than a + // per-message round trip. + type pendingPing struct { + txID int + hops int + snr sql.NullFloat64 + observer string + scope string + resolvedPath []*string + } + var pendingPings []pendingPing + for rows.Next() { var pktID, txID int - var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString + var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 var obsTs sql.NullInt64 var routeType sql.NullInt64 @@ -1954,6 +1992,9 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } + if db.hasResolvedPath { + scanArgs = append(scanArgs, &resolvedPathJSON) + } if err := rows.Scan(scanArgs...); err != nil { return nil, 0, err } @@ -2028,12 +2069,56 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . observerName = obsID.String m.Data["observers"] = []string{obsID.String} } - if reply := pingBotReply(displayText, hops, snr, observerName); reply != nil { - m.Data["botReply"] = reply + if isPingTrigger(displayText) { + var resolvedPath []*string + if resolvedPathJSON.Valid { + resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + } + pendingPings = append(pendingPings, pendingPing{ + txID: txID, hops: hops, snr: snr, observer: observerName, + scope: scopeName.String, resolvedPath: resolvedPath, + }) } msgMap[txID] = m } + // Bulk-resolve every pubkey referenced by any ping's relay path in ONE + // query, then build each pending reply's "via RepeaterA → RepeaterB" + // text. Names default to the raw pubkey/prefix when unresolved rather + // than being dropped, so the hop count and reply still make sense. + if len(pendingPings) > 0 { + pubkeySet := map[string]bool{} + for _, p := range pendingPings { + for _, pk := range p.resolvedPath { + if pk != nil && *pk != "" { + pubkeySet[*pk] = true + } + } + } + pubkeys := make([]string, 0, len(pubkeySet)) + for pk := range pubkeySet { + pubkeys = append(pubkeys, pk) + } + names, _ := db.namesAndRolesForPubkeys(pubkeys) + + for _, p := range pendingPings { + var repeaterNames []string + for _, pk := range p.resolvedPath { + if pk == nil || *pk == "" { + continue + } + if name := names[*pk]; name != "" { + repeaterNames = append(repeaterNames, name) + } else { + repeaterNames = append(repeaterNames, *pk) + } + } + if m, ok := msgMap[p.txID]; ok { + m.Data["botReply"] = pingBotReply(p.hops, p.snr, p.observer, p.scope, repeaterNames) + } + } + } + // Issue #1366 follow-up: emit batch sorted by LatestSeen ascending // (newest LAST) — matches the in-memory path's tail-of-msgOrder // convention and the frontend's scrollToBottom() behavior. pageIDs diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 28470608..d47a0233 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1410,6 +1410,9 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { defer db.Close() db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`) + // pkBravoRepeater deliberately has NO nodes row -- exercises the + // unresolved-pubkey fallback (raw pubkey shown instead of a name). // tx1: a plain chat message -- must NOT get a botReply. db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) @@ -1418,12 +1421,14 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (1, 1, 9.0, -88, '["aa","bb"]', 1736935200)`) - // tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer. + // tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer, + // and the relay path resolved to "RepeaterAlpha → pkBravoRepeater" + // (second hop has no nodes row, so its raw pubkey is shown instead). db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5, '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Bob"}', '#ping')`) - db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) - VALUES (2, 1, 8.2, -90, '["aa","bb"]', 1736935260)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (2, 1, 8.2, -90, '["aa","bb"]', '["pkAlphaRepeater","pkBravoRepeater"]', 1736935260)`) // tx3: "@MeshviewBot ping" -- the mention-prefix must be stripped before matching. db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) @@ -1473,6 +1478,9 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { if !strings.Contains(replyText, "2 hops") || !strings.Contains(replyText, "8.2dB") || !strings.Contains(replyText, "Observer One") { t.Errorf("botReply text = %q, want hops/SNR/observer mentioned", replyText) } + if !strings.Contains(replyText, "via RepeaterAlpha → pkBravoRepeater") { + t.Errorf("botReply text = %q, want the resolved relay path (RepeaterAlpha for the known node, raw pubkey fallback for the unresolved one)", replyText) + } mentionReply, _ := byText["@MeshviewBot ping"]["botReply"].(map[string]interface{}) if mentionReply == nil { @@ -1483,6 +1491,37 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { } } +// TestAppendAreaToBotReply covers appendAreaToBotReply (routes.go): the +// handler-level pass that folds a ping message's resolved "area" (set by +// annotateMessageAreas, which needs server config unavailable to db.go) +// into its already-built botReply text. +func TestAppendAreaToBotReply(t *testing.T) { + withArea := map[string]interface{}{ + "area": "Aarhus", + "botReply": map[string]interface{}{"sender": "MeshviewBot", "text": "🏓 pong! 2 hops"}, + } + noArea := map[string]interface{}{ + "botReply": map[string]interface{}{"sender": "MeshviewBot", "text": "🏓 pong! 0 hops (direct)"}, + } + noBotReply := map[string]interface{}{"area": "Aarhus", "text": "just chatting"} + + appendAreaToBotReply([]map[string]interface{}{withArea, noArea, noBotReply}) + + gotText := withArea["botReply"].(map[string]interface{})["text"].(string) + if !strings.Contains(gotText, "area Aarhus") { + t.Errorf("botReply text = %q, want area appended", gotText) + } + + gotNoAreaText := noArea["botReply"].(map[string]interface{})["text"].(string) + if strings.Contains(gotNoAreaText, "area") { + t.Errorf("botReply text = %q, want unchanged when message has no area", gotNoAreaText) + } + + if _, ok := noBotReply["botReply"]; ok { + t.Error("a message with no botReply must not gain one") + } +} + func TestGetNetworkStatusDateFormats(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 1879d914..5f4c2560 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2889,6 +2889,26 @@ func (s *Server) annotateMessageAreas(messages []map[string]interface{}) { } } +// appendAreaToBotReply folds a ping message's own resolved area (set by +// annotateMessageAreas just above, which MUST run first) into its +// botReply text. Area resolution needs server-level config (s.cfg.Areas) +// that db.go's GetChannelMessages/pingBotReply don't have access to, so +// this runs as a handler-level second pass instead. +func appendAreaToBotReply(messages []map[string]interface{}) { + for _, m := range messages { + area, _ := m["area"].(string) + if area == "" { + continue + } + reply, ok := m["botReply"].(map[string]interface{}) + if !ok { + continue + } + text, _ := reply["text"].(string) + reply["text"] = text + " · area " + area + } +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -2934,12 +2954,14 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { return } s.annotateMessageAreas(messages) + appendAreaToBotReply(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } if s.store != nil { messages, total := s.store.GetChannelMessages(hash, limit, offset, region) s.annotateMessageAreas(messages) + appendAreaToBotReply(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } diff --git a/public/channels.js b/public/channels.js index b9bed4b6..5eaddb44 100644 --- a/public/channels.js +++ b/public/channels.js @@ -330,12 +330,19 @@ // Same trigger rule, same reply format. CoreScope-only: see the doc // comment on botReplyHtml in renderMessages for why this never reaches // the real mesh. - function pingBotReply(text, hops, snr, observer) { + // + // Unlike the server version, this one can't show the resolved relay + // path (repeater names) -- the live WS broadcast doesn't carry a + // per-packet resolved_path, only REST-loaded history does (via + // GetChannelMessages). scope/area ARE available live and are included. + function pingBotReply(text, hops, snr, observer, scope, area) { var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); if (trigger.toLowerCase() !== 'ping') return null; var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)']; if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB'); if (observer) parts.push('heard by ' + observer); + if (scope) parts.push('scope ' + scope); + if (area) parts.push('area ' + area); return { sender: 'MeshviewBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr }; } function getSenderColor(name) { @@ -682,7 +689,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, repeats: 1, - botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver) + botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver, c.packet.scope_name || null) }); continue; } @@ -702,7 +709,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, repeats: 1, - botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver) + botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver, c.packet.scope_name || null) }); } else { macFailCount++; @@ -1501,7 +1508,7 @@ scope: scope, routeType: routeType, area: area, - botReply: pingBotReply(displayText, wsHops, snr, observer), + botReply: pingBotReply(displayText, wsHops, snr, observer, scope, area), // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of // stomp. Without this flag the REST response wipes any diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 1d7d3601..1aae9ec4 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -119,6 +119,18 @@ test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly assert.ok(!r.text.includes('heard by'), r.text); }); +test('scope and area are included when present, omitted when not', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const withBoth = fn('ping', 1, 5, 'Obs', '#dk', 'Aarhus'); + assert.ok(withBoth.text.includes('scope #dk'), withBoth.text); + assert.ok(withBoth.text.includes('area Aarhus'), withBoth.text); + + const withNeither = fn('ping', 1, 5, 'Obs', null, null); + assert.ok(!withNeither.text.includes('scope'), withNeither.text); + assert.ok(!withNeither.text.includes('area'), withNeither.text); +}); + console.log('\n=== channels.js: ping-bot reply rendering ==='); test('a message without botReply renders no bot bubble', () => { From 3c091900e260dff67417b023a8f9176b530bba0e Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 14:55:17 +0200 Subject: [PATCH 06/10] rename: ping-bot sender MeshviewBot -> CoreScopeBot Matches the product name (CoreScope) rather than the meshview.dk domain. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 4 ++-- cmd/server/db_test.go | 16 ++++++++-------- public/channels.js | 4 ++-- test-channels-ping-bot-reply.js | 14 +++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 50a05715..097e6e57 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1751,7 +1751,7 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // for limit=50). // channelMentionPrefixRe strips a leading "@target " reply-address the // same way the frontend does (public/channels.js replyMatch) before -// matching the ping trigger, so "@MeshviewBot ping" triggers the same as +// matching the ping trigger, so "@CoreScopeBot ping" triggers the same as // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) @@ -1800,7 +1800,7 @@ func pingBotReply(hops int, snr sql.NullFloat64, observer, scope string, repeate parts = append(parts, "scope "+scope) } return map[string]interface{}{ - "sender": "MeshviewBot", + "sender": "CoreScopeBot", "text": "🏓 pong! " + strings.Join(parts, " · "), "hops": hops, "snr": nullFloat(snr), diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index d47a0233..7ee2c163 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1430,10 +1430,10 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (2, 1, 8.2, -90, '["aa","bb"]', '["pkAlphaRepeater","pkBravoRepeater"]', 1736935260)`) - // tx3: "@MeshviewBot ping" -- the mention-prefix must be stripped before matching. + // tx3: "@CoreScopeBot ping" -- the mention-prefix must be stripped before matching. db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5, - '{"type":"CHAN","channel":"#ping","text":"@MeshviewBot ping","sender":"Carol"}', '#ping')`) + '{"type":"CHAN","channel":"#ping","text":"@CoreScopeBot ping","sender":"Carol"}', '#ping')`) db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (3, 1, 5.0, -95, '[]', 1736935320)`) @@ -1468,8 +1468,8 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { if pingReply == nil { t.Fatal("bare \"ping\" message should get a botReply") } - if pingReply["sender"] != "MeshviewBot" { - t.Errorf("botReply sender = %v, want MeshviewBot", pingReply["sender"]) + if pingReply["sender"] != "CoreScopeBot" { + t.Errorf("botReply sender = %v, want CoreScopeBot", pingReply["sender"]) } if pingReply["hops"] != 2 { t.Errorf("botReply hops = %v, want 2", pingReply["hops"]) @@ -1482,9 +1482,9 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { t.Errorf("botReply text = %q, want the resolved relay path (RepeaterAlpha for the known node, raw pubkey fallback for the unresolved one)", replyText) } - mentionReply, _ := byText["@MeshviewBot ping"]["botReply"].(map[string]interface{}) + mentionReply, _ := byText["@CoreScopeBot ping"]["botReply"].(map[string]interface{}) if mentionReply == nil { - t.Fatal("\"@MeshviewBot ping\" should get a botReply (mention prefix stripped before matching)") + t.Fatal("\"@CoreScopeBot ping\" should get a botReply (mention prefix stripped before matching)") } if mentionReply["hops"] != 0 { t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"]) @@ -1498,10 +1498,10 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { func TestAppendAreaToBotReply(t *testing.T) { withArea := map[string]interface{}{ "area": "Aarhus", - "botReply": map[string]interface{}{"sender": "MeshviewBot", "text": "🏓 pong! 2 hops"}, + "botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 2 hops"}, } noArea := map[string]interface{}{ - "botReply": map[string]interface{}{"sender": "MeshviewBot", "text": "🏓 pong! 0 hops (direct)"}, + "botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 0 hops (direct)"}, } noBotReply := map[string]interface{}{"area": "Aarhus", "text": "just chatting"} diff --git a/public/channels.js b/public/channels.js index 5eaddb44..71ef6ff5 100644 --- a/public/channels.js +++ b/public/channels.js @@ -343,7 +343,7 @@ if (observer) parts.push('heard by ' + observer); if (scope) parts.push('scope ' + scope); if (area) parts.push('area ' + area); - return { sender: 'MeshviewBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr }; + return { sender: 'CoreScopeBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr }; } function getSenderColor(name) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || @@ -2329,7 +2329,7 @@ const botReplyHtml = msg.botReply ? `
-
${escapeHtml(msg.botReply.sender || 'MeshviewBot')}
+
${escapeHtml(msg.botReply.sender || 'CoreScopeBot')}
${escapeHtml(msg.botReply.text || '')}
Not sent to the mesh — CoreScope-only reply
diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 1aae9ec4..a7d5724e 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -86,10 +86,10 @@ test('exact "ping" (any case) triggers a reply', () => { assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed'); }); -test('a mention prefix like "@MeshviewBot ping" is stripped before matching', () => { +test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => { const { ctx } = makeSandbox(); const fn = ctx.window._channelsPingBotReplyForTest; - assert.ok(fn('@MeshviewBot ping', 0, null, null) !== null); + assert.ok(fn('@CoreScopeBot ping', 0, null, null) !== null); }); test('"pinging" or other substrings do not match (exact trigger only)', () => { @@ -104,7 +104,7 @@ test('reply text includes hops, SNR, and observer when present', () => { const { ctx } = makeSandbox(); const fn = ctx.window._channelsPingBotReplyForTest; const r = fn('ping', 3, 8.25, 'Observer One'); - assert.strictEqual(r.sender, 'MeshviewBot'); + assert.strictEqual(r.sender, 'CoreScopeBot'); assert.ok(r.text.includes('3 hops'), r.text); assert.ok(r.text.includes('SNR 8.3dB') || r.text.includes('SNR 8.2dB'), r.text); assert.ok(r.text.includes('heard by Observer One'), r.text); @@ -147,13 +147,13 @@ test('a message with botReply renders a distinct bot bubble with the reply text' ctx.window._channelsSetStateForTest({ messages: [ { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', - botReply: { sender: 'MeshviewBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 }, + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 }, }, ] }); ctx.window._channelsRenderMessagesForTest(); const html = chMessagesEl.innerHTML; assert.ok(html.includes('ch-bot-message'), 'should render the distinct bot-message class'); - assert.ok(html.includes('MeshviewBot'), 'should show the bot sender name'); + assert.ok(html.includes('CoreScopeBot'), 'should show the bot sender name'); assert.ok(html.includes('2 hops'), 'should include the hop count from the reply text'); assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text'); }); @@ -161,7 +161,7 @@ test('a message with botReply renders a distinct bot bubble with the reply text' test('the "not sent to the mesh" caveat is always present on a bot bubble', () => { const { ctx, chMessagesEl } = makeSandbox(); ctx.window._channelsSetStateForTest({ messages: [ - { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'MeshviewBot', text: 'pong', hops: 0 } }, + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } }, ] }); ctx.window._channelsRenderMessagesForTest(); assert.ok(chMessagesEl.innerHTML.includes('Not sent to the mesh'), 'the caveat must be visible so this is never mistaken for a real mesh reply'); @@ -184,7 +184,7 @@ test('botReply.text and .sender are HTML-escaped (observer names are operator-co test('the bot bubble renders immediately after its triggering message, not before other messages', () => { const { ctx, chMessagesEl } = makeSandbox(); ctx.window._channelsSetStateForTest({ messages: [ - { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'MeshviewBot', text: 'pong', hops: 0 } }, + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } }, { sender: 'Carol', text: 'after', timestamp: '2026-01-15T10:02:00Z' }, ] }); ctx.window._channelsRenderMessagesForTest(); From 3b5bc171807f556e8176beabd785d2b7ed0a6cc0 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 15:09:19 +0200 Subject: [PATCH 07/10] feat: ping-bot reply reports full reach (deepest hop + breadth), not just first observation GetChannelMessages previously used "first observation wins" for a ping's botReply -- if the same flood was heard by multiple stations, only whichever one happened to be scanned first shaped the hop count, SNR, and relay path. That understates reach: different stations legitimately hear the same flood at different hop depths depending on which relay leg reached them. Now tracks, across every observation of the ping: the DEEPEST (max-hop) observation's hops/SNR/relay path, and every DISTINCT station that heard it. The reply shows the deepest leg's path and reports "heard by N stations" once more than one did (falling back to the single station's name when there's exactly one, same as before). Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 115 +++++++++++++++++++++++++++--------------- cmd/server/db_test.go | 56 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 41 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 097e6e57..e1868e48 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1966,20 +1966,22 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } msgMap := make(map[int]*msg, len(pageIDs)) - // pendingPing collects a ping-triggering message's ingredients for its - // botReply. Reply text isn't built inline in the scan loop below - // because the repeater-name lookup needs ONE bulk query across every - // ping on the page (see the pass after the loop) rather than a - // per-message round trip. + // pendingPing collects a ping-triggering message's REACH across every + // observation of it, not just the first: hops/snr/resolvedPath track + // the DEEPEST (max-hop) observation seen so far -- how far the packet + // had propagated before the farthest-along station heard it -- and + // observers is every distinct station that heard it at all (breadth). + // A single arbitrary "first observation wins" data point understates + // both: two stations can hear the same flood at very different hop + // depths depending on which relay leg reached them. type pendingPing struct { - txID int hops int snr sql.NullFloat64 - observer string - scope string resolvedPath []*string + observers map[string]bool + scope string } - var pendingPings []pendingPing + pendingPings := make(map[int]*pendingPing) for rows.Next() { var pktID, txID int @@ -2001,11 +2003,45 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if !dj.Valid { continue } + + // Hop count, relay path, and hearing station for THIS observation + // row -- computed for every row (not just the first) so a ping's + // reach can be tracked across every station that heard it. + var hops int + var entryPrefix string + if pathJSON.Valid { + var h []string + if json.Unmarshal([]byte(pathJSON.String), &h) == nil { + hops = len(h) + if len(h) > 0 { + entryPrefix = h[0] + } + } + } + var resolvedPath []*string + if resolvedPathJSON.Valid { + resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + } + observerName := "" + if obsName.Valid { + observerName = obsName.String + } else if obsID.Valid { + observerName = obsID.String + } + if existing, ok := msgMap[txID]; ok { existing.Repeats++ if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch { existing.LatestEpoch = obsTs.Int64 } + if agg, ok := pendingPings[txID]; ok { + if observerName != "" { + agg.observers[observerName] = true + } + if hops > agg.hops { + agg.hops, agg.snr, agg.resolvedPath = hops, snr, resolvedPath + } + } continue } var decoded map[string]interface{} @@ -2027,17 +2063,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . displayText = text[idx+2:] } } - var hops int - var entryPrefix string - if pathJSON.Valid { - var h []string - if json.Unmarshal([]byte(pathJSON.String), &h) == nil { - hops = len(h) - if len(h) > 0 { - entryPrefix = h[0] - } - } - } senderTs := decoded["sender_timestamp"] m := &msg{ Data: map[string]interface{}{ @@ -2061,31 +2086,24 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if obsTs.Valid { m.LatestEpoch = obsTs.Int64 } - observerName := "" - if obsName.Valid { - observerName = obsName.String - m.Data["observers"] = []string{obsName.String} - } else if obsID.Valid { - observerName = obsID.String - m.Data["observers"] = []string{obsID.String} + if observerName != "" { + m.Data["observers"] = []string{observerName} } if isPingTrigger(displayText) { - var resolvedPath []*string - if resolvedPathJSON.Valid { - resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + agg := &pendingPing{hops: hops, snr: snr, resolvedPath: resolvedPath, scope: scopeName.String, observers: map[string]bool{}} + if observerName != "" { + agg.observers[observerName] = true } - pendingPings = append(pendingPings, pendingPing{ - txID: txID, hops: hops, snr: snr, observer: observerName, - scope: scopeName.String, resolvedPath: resolvedPath, - }) + pendingPings[txID] = agg } msgMap[txID] = m } - // Bulk-resolve every pubkey referenced by any ping's relay path in ONE - // query, then build each pending reply's "via RepeaterA → RepeaterB" - // text. Names default to the raw pubkey/prefix when unresolved rather - // than being dropped, so the hop count and reply still make sense. + // Bulk-resolve every pubkey referenced by any ping's DEEPEST relay path + // in ONE query, then build each pending reply's "via RepeaterA → + // RepeaterB" text plus its observer-breadth label. Names default to + // the raw pubkey/prefix when unresolved rather than being dropped, so + // the hop count and reply still make sense. if len(pendingPings) > 0 { pubkeySet := map[string]bool{} for _, p := range pendingPings { @@ -2101,7 +2119,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } names, _ := db.namesAndRolesForPubkeys(pubkeys) - for _, p := range pendingPings { + for txID, p := range pendingPings { var repeaterNames []string for _, pk := range p.resolvedPath { if pk == nil || *pk == "" { @@ -2113,8 +2131,23 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . repeaterNames = append(repeaterNames, *pk) } } - if m, ok := msgMap[p.txID]; ok { - m.Data["botReply"] = pingBotReply(p.hops, p.snr, p.observer, p.scope, repeaterNames) + // Breadth: name the single station when there's only one (as + // specific as before), otherwise report the count -- "heard by + // 4 stations" says more about actual reach than an arbitrarily + // picked single name once more than one station heard it. + observerLabel := "" + switch len(p.observers) { + case 0: + // leave empty + case 1: + for name := range p.observers { + observerLabel = name + } + default: + observerLabel = fmt.Sprintf("%d stations", len(p.observers)) + } + if m, ok := msgMap[txID]; ok { + m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, p.scope, repeaterNames) } } } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 7ee2c163..d6970169 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1491,6 +1491,62 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { } } +// TestGetChannelMessages_PingBotReply_MultiObservation covers a single +// ping transmission heard by TWO different observer stations at +// DIFFERENT hop depths (normal in a mesh: one station may hear an early +// relay leg, another a later one). The botReply must report the DEEPEST +// (max-hop) observation's path/SNR -- not whichever observation happened +// to be scanned first -- and the breadth ("N stations") once more than +// one distinct station heard it, per pingBotReply's doc comment. +func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkCharlieRepeater', 'RepeaterCharlie', 'repeater')`) + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + // obs1 (scanned first, o.id=1): shallow leg, 1 hop. transmission_id=1 + // since this is the first (only) transmission inserted in this fresh DB. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlphaRepeater"]', 1736935440)`) + // obs2 (scanned second, o.id=2): deeper leg, 3 hops -- must win despite + // being neither first nor having the highest SNR. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 2, 4.5, -99, '["aa","bb","cc"]', '["pkAlphaRepeater","pkBravoRepeater","pkCharlieRepeater"]', 1736935445)`) + + messages, _, err := db.GetChannelMessages("#ping", 100, 0) + if err != nil { + t.Fatal(err) + } + var reply map[string]interface{} + for _, m := range messages { + if m["text"] == "ping" { + reply, _ = m["botReply"].(map[string]interface{}) + } + } + if reply == nil { + t.Fatal("expected a botReply on the ping message") + } + if reply["hops"] != 3 { + t.Errorf("botReply hops = %v, want 3 (the deeper of the two observations)", reply["hops"]) + } + text, _ := reply["text"].(string) + if !strings.Contains(text, "SNR 4.5dB") { + t.Errorf("botReply text = %q, want the SNR paired with the deeper (3-hop) observation, not the shallower one's 9.0dB", text) + } + if !strings.Contains(text, "via RepeaterAlpha → pkBravoRepeater → RepeaterCharlie") { + t.Errorf("botReply text = %q, want the deeper observation's resolved relay path", text) + } + if !strings.Contains(text, "heard by 2 stations") { + t.Errorf("botReply text = %q, want breadth reported as \"2 stations\" now that more than one station heard it", text) + } +} + // TestAppendAreaToBotReply covers appendAreaToBotReply (routes.go): the // handler-level pass that folds a ping message's resolved "area" (set by // annotateMessageAreas, which needs server config unavailable to db.go) From f0f28b5c32dd086b14fc01ac33ae9e4b3b7df0fc Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 15:17:10 +0200 Subject: [PATCH 08/10] fix: ping-bot reply says "observers" not "stations" Matches the term used everywhere else in CoreScope (/api/observers, observer names, etc.) instead of an inconsistent synonym. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 14 +++++++------- cmd/server/db_test.go | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index e1868e48..9ee30108 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1969,10 +1969,10 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . // pendingPing collects a ping-triggering message's REACH across every // observation of it, not just the first: hops/snr/resolvedPath track // the DEEPEST (max-hop) observation seen so far -- how far the packet - // had propagated before the farthest-along station heard it -- and - // observers is every distinct station that heard it at all (breadth). + // had propagated before the farthest-along observer heard it -- and + // observers is every distinct observer that heard it at all (breadth). // A single arbitrary "first observation wins" data point understates - // both: two stations can hear the same flood at very different hop + // both: two observers can hear the same flood at very different hop // depths depending on which relay leg reached them. type pendingPing struct { hops int @@ -2131,10 +2131,10 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . repeaterNames = append(repeaterNames, *pk) } } - // Breadth: name the single station when there's only one (as + // Breadth: name the single observer when there's only one (as // specific as before), otherwise report the count -- "heard by - // 4 stations" says more about actual reach than an arbitrarily - // picked single name once more than one station heard it. + // 4 observers" says more about actual reach than an arbitrarily + // picked single name once more than one observer heard it. observerLabel := "" switch len(p.observers) { case 0: @@ -2144,7 +2144,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . observerLabel = name } default: - observerLabel = fmt.Sprintf("%d stations", len(p.observers)) + observerLabel = fmt.Sprintf("%d observers", len(p.observers)) } if m, ok := msgMap[txID]; ok { m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, p.scope, repeaterNames) diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index d6970169..5b06292b 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1492,11 +1492,11 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { } // TestGetChannelMessages_PingBotReply_MultiObservation covers a single -// ping transmission heard by TWO different observer stations at +// ping transmission heard by TWO different observers at // DIFFERENT hop depths (normal in a mesh: one station may hear an early // relay leg, another a later one). The botReply must report the DEEPEST // (max-hop) observation's path/SNR -- not whichever observation happened -// to be scanned first -- and the breadth ("N stations") once more than +// to be scanned first -- and the breadth ("N observers") once more than // one distinct station heard it, per pingBotReply's doc comment. func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) { db := setupTestDB(t) @@ -1542,8 +1542,8 @@ func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) { if !strings.Contains(text, "via RepeaterAlpha → pkBravoRepeater → RepeaterCharlie") { t.Errorf("botReply text = %q, want the deeper observation's resolved relay path", text) } - if !strings.Contains(text, "heard by 2 stations") { - t.Errorf("botReply text = %q, want breadth reported as \"2 stations\" now that more than one station heard it", text) + if !strings.Contains(text, "heard by 2 observers") { + t.Errorf("botReply text = %q, want breadth reported as \"2 observers\" now that more than one observer heard it", text) } } From 5842d85abd990947bac5600c702511873400741a Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 15:38:52 +0200 Subject: [PATCH 09/10] feat: "View path" map for the ping-bot reply New GET /api/packets/{hash}/path resolves a packet's DEEPEST observation (same "farthest leg is more informative" reasoning as the ping-bot reply itself) to a geographic point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Hops that have never advertised a GPS position come back with null lat/lon rather than being dropped, so the frontend can draw a gap instead of guessing. Frontend: public/packet-path-map.js is a small on-demand Leaflet modal (reuses node-reach-map.js's tile/marker conventions, but draws an ordered chain instead of a star) opened via a new "View path" link on the ping-bot reply -- shown only when there's an actual multi-hop route and packet hash to look up. Kept general (keyed by packet hash, not ping-specific) since any packet with a resolved path could use it later. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 164 +++++++++++++++++++++++++++++ cmd/server/db_test.go | 83 +++++++++++++++ cmd/server/openapi.go | 36 ++++++- cmd/server/routes.go | 15 +++ public/channels.js | 12 ++- public/index.html | 1 + public/packet-path-map.js | 131 +++++++++++++++++++++++ test-all.sh | 1 + test-channels-ping-bot-reply.js | 38 +++++++ test-packet-path-map.js | 179 ++++++++++++++++++++++++++++++++ 10 files changed, 657 insertions(+), 3 deletions(-) create mode 100644 public/packet-path-map.js create mode 100644 test-packet-path-map.js diff --git a/cmd/server/db.go b/cmd/server/db.go index 9ee30108..edbae522 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1517,6 +1517,170 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) { return traces, nil } +// PacketPathPoint is one hop's position along a packet's resolved relay +// path, for map visualization (public/packet-path-map.js). Lat/Lon are +// nil when that node has never advertised a GPS position -- the caller +// draws a gap rather than guessing. +type PacketPathPoint struct { + PublicKey string `json:"publicKey"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` +} + +// PacketPathObserver is the station that produced the deepest observation +// of a packet path (see GetPacketPath), positioned from its configured +// IATA code the same way the Wardriving tab positions observers -- not a +// stored per-observer lat/lon column. +type PacketPathObserver struct { + Name string `json:"name"` + IATA string `json:"iata,omitempty"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` +} + +// PacketPathResponse is the geographic relay path for one packet hash, +// used to draw it on a map (the ping-bot reply's "View path" link). +type PacketPathResponse struct { + Hash string `json:"hash"` + Hops int `json:"hops"` + Points []PacketPathPoint `json:"points"` + Observer *PacketPathObserver `json:"observer,omitempty"` +} + +// GetPacketPath resolves a packet's DEEPEST observation (the one with the +// most hops -- same "farthest leg" reasoning as the ping-bot reply, see +// pingBotReply's doc comment) to a geographic point sequence: each +// relay's name/role/lat/lon in path order, plus the hearing observer's +// position. A packet can have several observations (heard by more than +// one station, possibly at different hop depths); this always picks the +// one that traveled farthest, since that's the more informative path to +// show on a map. +func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { + if !db.hasResolvedPath { + return nil, fmt.Errorf("resolved_path not available on this server") + } + var querySQL string + if db.isV3 { + querySQL = `SELECT obs.name, obs.iata, o.resolved_path + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx + WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + } else { + querySQL = `SELECT o.observer_name, NULL, o.resolved_path + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + } + rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) + if err != nil { + return nil, fmt.Errorf("packet path query: %w", err) + } + defer rows.Close() + + var bestPath []*string + var bestObserverName, bestObserverIATA sql.NullString + for rows.Next() { + var obsName, obsIATA, rpJSON sql.NullString + if err := rows.Scan(&obsName, &obsIATA, &rpJSON); err != nil { + continue + } + if !rpJSON.Valid { + continue + } + rp := unmarshalResolvedPath(rpJSON.String) + if len(rp) > len(bestPath) { + bestPath = rp + bestObserverName, bestObserverIATA = obsName, obsIATA + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("packet path iteration: %w", err) + } + + resp := &PacketPathResponse{Hash: hash, Hops: len(bestPath), Points: []PacketPathPoint{}} + if len(bestPath) == 0 { + return resp, nil + } + + pubkeys := make([]string, 0, len(bestPath)) + for _, pk := range bestPath { + if pk != nil && *pk != "" { + pubkeys = append(pubkeys, *pk) + } + } + type nodeInfo struct { + name string + role string + lat *float64 + lon *float64 + } + nodeByPK := make(map[string]nodeInfo, len(pubkeys)) + if len(pubkeys) > 0 { + placeholders := make([]byte, 0, len(pubkeys)*2) + args := make([]interface{}, len(pubkeys)) + for i, pk := range pubkeys { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = pk + } + nodeRows, err := db.conn.Query( + "SELECT public_key, name, role, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...) + if err == nil { + for nodeRows.Next() { + var pk string + var name, role sql.NullString + var lat, lon sql.NullFloat64 + if nodeRows.Scan(&pk, &name, &role, &lat, &lon) == nil { + ni := nodeInfo{name: name.String, role: role.String} + if lat.Valid { + v := lat.Float64 + ni.lat = &v + } + if lon.Valid { + v := lon.Float64 + ni.lon = &v + } + nodeByPK[pk] = ni + } + } + nodeRows.Close() + } + } + + for _, pk := range bestPath { + if pk == nil || *pk == "" { + continue + } + ni := nodeByPK[*pk] + name := ni.name + if name == "" { + name = *pk + } + resp.Points = append(resp.Points, PacketPathPoint{ + PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon, + }) + } + + if bestObserverName.Valid && bestObserverName.String != "" { + obs := &PacketPathObserver{Name: bestObserverName.String} + if bestObserverIATA.Valid { + obs.IATA = strings.ToUpper(strings.TrimSpace(bestObserverIATA.String)) + if coord, ok := iataCoords[obs.IATA]; ok { + lat, lon := coord.Lat, coord.Lon + obs.Lat, obs.Lon = &lat, &lon + } + } + resp.Observer = obs + } + + return resp, nil +} + // GetChannels returns channel list from GRP_TXT packets. // Queries transmissions directly (not a VIEW) to avoid observation-level // duplicates that could cause stale lastMessage when an older message has diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 5b06292b..caec75a4 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -621,6 +621,89 @@ func TestGetTraces(t *testing.T) { } } +// TestGetPacketPath covers the "View path" map data source: given a +// packet hash, resolve its DEEPEST observation's relay path to +// name/role/lat/lon per hop, plus the hearing observer's IATA-derived +// position. Deliberately independent of seedTestData's fixtures. +func TestGetPacketPath(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`) + // pkBravo deliberately has NO nodes row -- exercises the raw-pubkey/no-position fallback. + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + // Shallow observation (obs1): 1 hop. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`) + // Deeper observation (obs2): 2 hops -- must win even though it's not first. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`) + + resp, err := db.GetPacketPath("pathtest00000001") + if err != nil { + t.Fatal(err) + } + if resp.Hops != 2 { + t.Fatalf("Hops = %d, want 2 (the deeper observation)", resp.Hops) + } + if len(resp.Points) != 2 { + t.Fatalf("Points = %+v, want 2 entries", resp.Points) + } + if resp.Points[0].Name != "RepeaterAlpha" || resp.Points[0].Lat == nil || *resp.Points[0].Lat != 56.1 { + t.Errorf("Points[0] = %+v, want RepeaterAlpha at lat 56.1", resp.Points[0]) + } + if resp.Points[1].PublicKey != "pkBravo" || resp.Points[1].Name != "pkBravo" || resp.Points[1].Lat != nil { + t.Errorf("Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", resp.Points[1]) + } + if resp.Observer == nil || resp.Observer.Name != "Observer Two" { + t.Fatalf("Observer = %+v, want Observer Two (heard the deeper observation)", resp.Observer) + } + if resp.Observer.Lat == nil || *resp.Observer.Lat != 37.6213 { + t.Errorf("Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", resp.Observer.Lat) + } +} + +func TestGetPacketPath_NoResolvedPath(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000002', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', 1736935200)`) + + resp, err := db.GetPacketPath("pathtest00000002") + if err != nil { + t.Fatal(err) + } + if len(resp.Points) != 0 { + t.Errorf("Points = %+v, want empty when no observation has a resolved_path", resp.Points) + } + if resp.Observer != nil { + t.Errorf("Observer = %+v, want nil when there's no resolved path", resp.Observer) + } +} + +func TestGetPacketPath_UnknownHash(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + resp, err := db.GetPacketPath("doesnotexist0000") + if err != nil { + t.Fatal(err) + } + if resp.Hops != 0 || len(resp.Points) != 0 { + t.Errorf("expected an empty response for an unknown hash, got %+v", resp) + } +} + func TestGetChannels(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b2650d36..39e832b4 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -139,8 +139,10 @@ func routeDescriptions() map[string]routeMeta { "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"}, // Misc - "GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}}, - "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, + "GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}}, + "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, + "GET /api/packets/{hash}/path": {Summary: "Get a packet's geographic relay path", Description: "Resolves a packet's DEEPEST observation (the one with the most hops -- same reasoning as the ping-bot reply, issue tracker: when the same flood is heard by more than one station, the farthest-along leg is the more informative one to show) to a point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets", + Response: schemaRef("PacketPathResponse")}, "GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"}, "GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"}, } @@ -331,6 +333,36 @@ func componentSchemas() map[string]interface{} { "timeSeries": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthTimePoint"), "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse."}, }, }, + "PacketPathPoint": map[string]interface{}{ + "type": "object", + "description": "One hop's position along a packet's resolved relay path.", + "properties": map[string]interface{}{ + "publicKey": str("Node public key (hex)."), + "name": str("Node display name, or its public key if unnamed."), + "role": str("Node role (e.g. repeater, room), when known."), + "lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position."}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + }, + }, + "PacketPathObserver": map[string]interface{}{ + "type": "object", + "description": "The station that produced the deepest observation of a packet path, positioned from its configured IATA code.", + "properties": map[string]interface{}{ + "name": str("Observer display name."), + "iata": str("Observer's configured IATA airport code, when set."), + "lat": map[string]interface{}{"type": "number", "nullable": true}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + }, + }, + "PacketPathResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "hash": str("The packet hash this path was resolved for."), + "hops": map[string]interface{}{"type": "integer", "description": "Length of the deepest observed relay path."}, + "points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The relay path in hop order."}, + "observer": schemaRef("PacketPathObserver"), + }, + }, } } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 5f4c2560..a77e9808 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -346,6 +346,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET") r.HandleFunc("/api/observers", s.handleObservers).Methods("GET") r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET") + r.HandleFunc("/api/packets/{hash}/path", s.handlePacketPath).Methods("GET") r.HandleFunc("/api/paths/inspect", s.handlePathInspect).Methods("POST") r.HandleFunc("/api/iata-coords", s.handleIATACoords).Methods("GET") r.HandleFunc("/api/audio-lab/buckets", s.handleAudioLabBuckets).Methods("GET") @@ -3174,6 +3175,20 @@ func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) { writeJSON(w, TraceResponse{Traces: traces}) } +func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { + hash := mux.Vars(r)["hash"] + if s.db == nil { + writeJSON(w, PacketPathResponse{Hash: hash, Points: []PacketPathPoint{}}) + return + } + resp, err := s.db.GetPacketPath(hash) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, resp) +} + var iataCoords = map[string]IataCoord{ "SJC": {Lat: 37.3626, Lon: -121.929}, "SFO": {Lat: 37.6213, Lon: -122.379}, diff --git a/public/channels.js b/public/channels.js index 71ef6ff5..fe8c19c0 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1349,6 +1349,10 @@ }); msgEl.addEventListener('click', handleNodeTap); + msgEl.addEventListener('click', function (e) { + const el = e.target.closest('[data-view-path]'); + if (el && window.PacketPathMap) window.PacketPathMap.open(el.dataset.viewPath); + }); // touchend fires more reliably on mobile for non-button elements let touchMoved = false; msgEl.addEventListener('touchstart', () => { touchMoved = false; }, { passive: true }); @@ -2326,12 +2330,18 @@ // "Not sent to the mesh" caveat is load-bearing, not decoration -- // without it this could be misread as a real bot reply the sender's // own radio received. + // "View path" only makes sense when there's an actual multi-hop + // route to draw (hops > 0) and we have a packet hash to look it up + // by -- a direct (0-hop) reply has no relay path to show on a map. + const viewPathHtml = (msg.botReply && msg.botReply.hops > 0 && msg.packetHash) + ? ` · ` + : ''; const botReplyHtml = msg.botReply ? `
${escapeHtml(msg.botReply.sender || 'CoreScopeBot')}
${escapeHtml(msg.botReply.text || '')}
-
Not sent to the mesh — CoreScope-only reply
+
Not sent to the mesh — CoreScope-only reply${viewPathHtml}
` : ''; diff --git a/public/index.html b/public/index.html index aea7556b..8b337235 100644 --- a/public/index.html +++ b/public/index.html @@ -220,6 +220,7 @@ + diff --git a/public/packet-path-map.js b/public/packet-path-map.js new file mode 100644 index 00000000..f035e154 --- /dev/null +++ b/public/packet-path-map.js @@ -0,0 +1,131 @@ +/* window.PacketPathMap.open(hash) — on-demand modal showing a packet's + resolved relay path (see GET /api/packets/{hash}/path, cmd/server/db.go + GetPacketPath) as a sequential Leaflet map: each hop plotted in path + order and connected by a line, ending at the observer that produced + the deepest observation. Reuses node-reach-map.js's Leaflet setup + conventions (tile helper, circleMarker points, theme-aware colors) but + draws an ORDERED CHAIN instead of a star, since a relay path is a + sequence, not a hub-and-spoke. + + Entry point today: the ping-bot reply's "View path" link + (public/channels.js botReplyHtml) -- kept general (keyed by packet + hash, not ping-specific) since any packet with a resolved path could + use the same view later. */ +(function () { + 'use strict'; + + function cssVar(name) { + var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || '#888'; + } + + var activeMap = null; + + function onKeydown(e) { + if (e.key === 'Escape') close(); + } + + function close() { + var overlay = document.getElementById('packetPathModal'); + if (overlay) overlay.remove(); + if (activeMap) { + try { activeMap.remove(); } catch (e) { /* already gone */ } + activeMap = null; + } + document.removeEventListener('keydown', onKeydown); + } + + async function open(hash) { + close(); // in case one's already open + + var overlay = document.createElement('div'); + overlay.id = 'packetPathModal'; + overlay.className = 'modal-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + var closeBtn = document.getElementById('packetPathClose'); + if (closeBtn) closeBtn.addEventListener('click', close); + document.addEventListener('keydown', onKeydown); + + var statusEl = document.getElementById('packetPathStatus'); + + var data; + try { + data = await api('/packets/' + encodeURIComponent(hash) + '/path'); + } catch (e) { + if (statusEl) statusEl.textContent = 'Failed to load path: ' + e.message; + return; + } + + var allHops = data.points || []; + var located = allHops.filter(function (p) { return p.lat != null && p.lon != null; }); + var missing = allHops.length - located.length; + var hasObserver = !!(data.observer && data.observer.lat != null && data.observer.lon != null); + + if (located.length === 0 && !hasObserver) { + if (statusEl) { + statusEl.textContent = data.hops > 0 + ? 'None of the ' + data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' in this path have a known position yet.' + : 'This packet has no resolved relay path yet.'; + } + return; + } + + if (typeof L === 'undefined') { + if (statusEl) statusEl.textContent = 'Map library unavailable.'; + return; + } + + var chain = located.map(function (p, i) { + return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (i + 1) + ' of ' + data.hops }; + }); + if (hasObserver) { + chain.push({ lat: data.observer.lat, lon: data.observer.lon, name: data.observer.name, label: 'observer', isObserver: true }); + } + + var center = chain[Math.floor(chain.length / 2)]; + var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false }) + .setView([center.lat, center.lon], 10); + if (typeof window._applyTilesToNodeMap === 'function') { + window._applyTilesToNodeMap(map); + } else { + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); + } + + var outline = cssVar('--surface-0'); + var accent = cssVar('--accent'); + var observerColor = cssVar('--status-yellow'); + + var bounds = []; + var line = []; + chain.forEach(function (p) { + bounds.push([p.lat, p.lon]); + line.push([p.lat, p.lon]); + var color = p.isObserver ? observerColor : accent; + L.circleMarker([p.lat, p.lon], { radius: p.isObserver ? 7 : 6, color: outline, weight: 2, fillColor: color, fillOpacity: 1 }) + .addTo(map) + .bindTooltip(escapeHtml(p.name) + ' (' + p.label + ')'); + }); + if (line.length > 1) { + L.polyline(line, { color: accent, weight: 2.5, opacity: 0.85 }).addTo(map); + } + try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ } + setTimeout(function () { map.invalidateSize(); }, 120); + activeMap = map; + + var statusParts = [data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' total']; + if (missing > 0) statusParts.push(missing + ' without a known position (not shown)'); + if (statusEl) statusEl.textContent = statusParts.join(' · '); + } + + window.PacketPathMap = { open: open, close: close }; +})(); diff --git a/test-all.sh b/test-all.sh index c48116ea..cf16694e 100755 --- a/test-all.sh +++ b/test-all.sh @@ -73,6 +73,7 @@ node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js node test-analytics-hop-depth-ui.js node test-channels-ping-bot-reply.js +node test-packet-path-map.js echo "" echo "═══════════════════════════════════════" diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index a7d5724e..565d6b2d 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -158,6 +158,44 @@ test('a message with botReply renders a distinct bot bubble with the reply text' assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text'); }); +test('"View path" link appears when hops > 0 and a packetHash is available', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(html.includes('View path'), 'should show the View path link'); + assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up'); +}); + +test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 0 hops (direct)', hops: 0, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'a direct reply has no relay path to visualize'); +}); + +test('"View path" link is absent when there is no packetHash to look it up by', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'without a packetHash there is nothing to fetch the path for'); +}); + test('the "not sent to the mesh" caveat is always present on a bot bubble', () => { const { ctx, chMessagesEl } = makeSandbox(); ctx.window._channelsSetStateForTest({ messages: [ diff --git a/test-packet-path-map.js b/test-packet-path-map.js new file mode 100644 index 00000000..1f63ef3a --- /dev/null +++ b/test-packet-path-map.js @@ -0,0 +1,179 @@ +/** + * Tests for public/packet-path-map.js — the on-demand "View path" modal + * that draws a packet's resolved relay path on a Leaflet map (backed by + * GET /api/packets/{hash}/path, cmd/server/db.go GetPacketPath). + * + * Two layers, matching this repo's established pattern for modal/DOM + * code (see test-channel-modal-ux.js): string-contract checks over the + * raw source for structural/safety properties, plus a functional smoke + * test using a minimal-but-real DOM mock (createElement/appendChild/ + * getElementById/remove all actually work, unlike the channels.js test + * sandbox's inert stubs) to exercise open()/close() end-to-end on the + * two code paths that don't need Leaflet: a failed fetch, and a + * fetch that resolves with nothing plottable. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +const src = fs.readFileSync('public/packet-path-map.js', 'utf8'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +console.log('\n=== packet-path-map.js: string-contract checks ==='); + +test('exports window.PacketPathMap.{open,close}', () => { + assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close\s*\}/.test(src)); +}); + +test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-URL handling)', () => { + assert.ok(/api\(\s*'\/packets\/'\s*\+\s*encodeURIComponent\(hash\)\s*\+\s*'\/path'\s*\)/.test(src)); +}); + +test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => { + assert.ok(/escapeHtml\(p\.name\)/.test(src), 'point tooltips must escape the name'); +}); + +test('handles Escape key and click-outside to close, matching other CoreScope modals', () => { + assert.ok(/e\.key === 'Escape'/.test(src)); + assert.ok(/e\.target === overlay/.test(src)); +}); + +test('degrades gracefully when the Leaflet global is unavailable, rather than throwing', () => { + assert.ok(/typeof L === 'undefined'/.test(src)); +}); + +test('close() tears down the Leaflet map instance, not just the DOM overlay (avoids a leaked map on repeat opens)', () => { + assert.ok(/activeMap\.remove\(\)/.test(src)); +}); + +console.log('\n=== packet-path-map.js: functional smoke test (no-Leaflet code paths) ==='); + +function makeSandbox(apiImpl) { + // A minimal but REAL DOM: elements track their own children/attributes + // so createElement -> appendChild -> getElementById -> remove() all + // actually work, unlike the inert stubs used for pure string-render + // testing elsewhere. Deliberately small: only what open()/close() touch. + function makeElement(tag) { + const el = { + tagName: tag, children: [], attributes: {}, style: {}, dataset: {}, + _listeners: {}, + get id() { return this.attributes.id || ''; }, + set id(v) { this.attributes.id = v; }, + // Real innerHTML would parse into a live child tree; this mock only + // needs id-addressable children with a settable textContent (all + // open()/close() read back), so it scans for id="..." occurrences + // and registers one lightweight child per id found. + set innerHTML(html) { + this._innerHTML = html; + this.children = []; + const re = /id="([^"]+)"/g; + let m; + while ((m = re.exec(html))) { + const child = makeElement('div'); + child.id = m[1]; + this.appendChild(child); + } + }, + get innerHTML() { return this._innerHTML || ''; }, + set textContent(t) { this._text = t; }, + get textContent() { return this._text || ''; }, + appendChild(child) { this.children.push(child); child._parent = this; return child; }, + remove() { if (this._parent) this._parent.children = this._parent.children.filter(c => c !== this); }, + addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }, + removeEventListener(type, fn) { if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(f => f !== fn); }, + querySelector() { return null; }, + }; + return el; + } + + const body = makeElement('body'); + const docListeners = {}; + const doc = { + createElement: makeElement, + body, + documentElement: { style: {} }, + getElementById(id) { + const search = (el) => { + if (el.id === id) return el; + for (const c of el.children) { const found = search(c); if (found) return found; } + return null; + }; + return search(body); + }, + addEventListener(type, fn) { (docListeners[type] = docListeners[type] || []).push(fn); }, + removeEventListener(type, fn) { if (docListeners[type]) docListeners[type] = docListeners[type].filter(f => f !== fn); }, + }; + + const ctx = { + window: {}, document: doc, console, Math, String, JSON, Promise, Error, + setTimeout, clearTimeout, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'), + api: apiImpl, + L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths. + }; + vm.createContext(ctx); + vm.runInContext(src, ctx); + return ctx; +} + +(async () => { + await (async () => { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('network down'))); + await ctx.window.PacketPathMap.open('deadbeef'); + const overlay = ctx.document.getElementById('packetPathModal'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(overlay, 'modal overlay should be created'); + assert.ok(status.textContent.includes('Failed to load path'), 'should surface the fetch error, got: ' + status.textContent); + passed++; + console.log(' ✅ a failed fetch shows an error status without throwing'); + } catch (e) { failed++; console.log(' ❌ a failed fetch shows an error status without throwing: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 0, points: [] })); + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('no resolved relay path'), 'should explain there is nothing to show yet, got: ' + status.textContent); + passed++; + console.log(' ✅ an empty path (hops=0, no points) shows a clear "nothing to show" status'); + } catch (e) { failed++; console.log(' ❌ an empty path (hops=0, no points) shows a clear "nothing to show" status: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }] })); + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no hop has a known position, got: ' + status.textContent); + passed++; + console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank'); + } catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('boom'))); + await ctx.window.PacketPathMap.open('deadbeef'); + assert.ok(ctx.document.getElementById('packetPathModal'), 'modal should be open'); + ctx.window.PacketPathMap.close(); + assert.ok(!ctx.document.getElementById('packetPathModal'), 'modal should be removed after close()'); + passed++; + console.log(' ✅ close() removes the modal overlay from the DOM'); + } catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); } + })(); + + console.log('\n════════════════════════════════════════'); + console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + if (failed > 0) process.exit(1); +})(); From 653949479a0307a8d597cd8211bc89c552f1bb5a Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 15:52:08 +0200 Subject: [PATCH 10/10] feat: ping-bot also triggers on "/ping", not just bare "ping" Trigger check moved from a single string comparison to a small pingTriggerWords set (mirrored by hand in db.go and channels.js), so adding more trigger words later is a one-line change in each. Still an exact match after the existing @mention-stripping -- "/pingx" etc. don't match. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 12 ++++++++++-- cmd/server/db_test.go | 19 +++++++++++++++++-- public/channels.js | 8 ++++++-- test-channels-ping-bot-reply.js | 8 ++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index edbae522..35fac20d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1919,13 +1919,21 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) +// pingTriggerWords are the exact (case-insensitive) message bodies that +// trigger a pong reply. Mirrored by pingTriggerWords in +// public/channels.js -- keep both lists in sync by hand. +var pingTriggerWords = map[string]bool{ + "ping": true, + "/ping": true, +} + // isPingTrigger reports whether displayText, after stripping a leading // "@target " mention the same way the frontend does (public/channels.js -// replyMatch), is exactly "ping". +// replyMatch), exactly matches one of pingTriggerWords. func isPingTrigger(displayText string) bool { trigger := strings.TrimSpace(displayText) trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") - return strings.EqualFold(strings.TrimSpace(trigger), "ping") + return pingTriggerWords[strings.ToLower(strings.TrimSpace(trigger))] } // pingBotReply synthesizes a "pong" reply for a channel message whose diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index caec75a4..0ef405b9 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1527,12 +1527,19 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (4, 1, 3.0, -99, '[]', 1736935380)`) + // tx5: "/ping" -- the slash-command form must trigger too. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"/ping","sender":"Frank"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (5, 1, 6.0, -91, '["aa"]', 1736935440)`) + messages, total, err := db.GetChannelMessages("#ping", 100, 0) if err != nil { t.Fatal(err) } - if total != 4 { - t.Fatalf("expected 4 messages, got %d", total) + if total != 5 { + t.Fatalf("expected 5 messages, got %d", total) } byText := map[string]map[string]interface{}{} @@ -1572,6 +1579,14 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { if mentionReply["hops"] != 0 { t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"]) } + + slashReply, _ := byText["/ping"]["botReply"].(map[string]interface{}) + if slashReply == nil { + t.Fatal("\"/ping\" should get a botReply -- it's in pingTriggerWords alongside bare \"ping\"") + } + if slashReply["sender"] != "CoreScopeBot" { + t.Errorf("\"/ping\" botReply sender = %v, want CoreScopeBot", slashReply["sender"]) + } } // TestGetChannelMessages_PingBotReply_MultiObservation covers a single diff --git a/public/channels.js b/public/channels.js index fe8c19c0..9b62d70a 100644 --- a/public/channels.js +++ b/public/channels.js @@ -335,9 +335,12 @@ // path (repeater names) -- the live WS broadcast doesn't carry a // per-packet resolved_path, only REST-loaded history does (via // GetChannelMessages). scope/area ARE available live and are included. + // pingTriggerWords mirrors pingTriggerWords in cmd/server/db.go -- keep + // both lists in sync by hand. + var pingTriggerWords = { 'ping': true, '/ping': true }; function pingBotReply(text, hops, snr, observer, scope, area) { var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); - if (trigger.toLowerCase() !== 'ping') return null; + if (!pingTriggerWords[trigger.toLowerCase()]) return null; var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)']; if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB'); if (observer) parts.push('heard by ' + observer); @@ -2324,7 +2327,8 @@ const safeId = btoa(encodeURIComponent(sender)); // Ping-bot reply (server-synthesized in GetChannelMessages when this - // message's text is exactly "ping" -- see pingBotReply in db.go). + // message's text matches a trigger word (pingTriggerWords) -- see + // pingBotReply in db.go). // CoreScope-only: never transmitted back onto the mesh, since // CoreScope has no publish path to a MeshCore broker/radio. The // "Not sent to the mesh" caveat is load-bearing, not decoration -- diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 565d6b2d..30206d26 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -86,6 +86,14 @@ test('exact "ping" (any case) triggers a reply', () => { assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed'); }); +test('"/ping" (the slash-command form) also triggers, alongside bare "ping"', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('/ping', 1, 5, 'Obs') !== null); + assert.ok(fn('/PING', 1, 5, 'Obs') !== null, 'case-insensitive like the bare form'); + assert.strictEqual(fn('/pingx', 1, 5, 'Obs'), null, 'still an exact match, not a prefix match'); +}); + test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => { const { ctx } = makeSandbox(); const fn = ctx.window._channelsPingBotReplyForTest;