diff --git a/cmd/server/db.go b/cmd/server/db.go index 2862722c..1398a881 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2331,8 +2331,13 @@ func isPingTrigger(displayText string) bool { // 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 string, repeaterNames []string) map[string]interface{} { - parts := make([]string, 0, 3) +// +// farthestKm is the same "how wide did this spread geographically" signal +// View Path's map shows (farthest any hearing station was from whoever +// heard it first), computed from stations' own GPS fixes only -- nil when +// fewer than two distinct stations have a position on file. +func pingBotReply(hops int, snr sql.NullFloat64, observer string, repeaterNames []string, farthestKm *float64) map[string]interface{} { + parts := make([]string, 0, 4) if hops > 0 { s := "s" if hops == 1 { @@ -2349,6 +2354,9 @@ func pingBotReply(hops int, snr sql.NullFloat64, observer string, repeaterNames if snr.Valid { parts = append(parts, fmt.Sprintf("SNR %.1fdB", snr.Float64)) } + if farthestKm != nil { + parts = append(parts, fmt.Sprintf("spread up to %.1fkm", *farthestKm)) + } if observer != "" { parts = append(parts, "heard by "+observer) } @@ -2535,6 +2543,14 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . snr sql.NullFloat64 resolvedPath []*string observers map[string]bool + // observerPubkeys/firstPubkey/firstTS feed the "spread up to Nkm" + // reply text -- the same farthest-from-first-hearer distance View + // Path shows on its map, computed here as a cheap position-only + // pass (no neighbor-centroid approximation) rather than reusing + // GetPacketPath's heavier per-branch query for every ping. + observerPubkeys map[string]bool + firstPubkey string + firstTS int64 } pendingPings := make(map[int]*pendingPing) @@ -2593,6 +2609,14 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if observerName != "" { agg.observers[observerName] = true } + if obsID.Valid && obsID.String != "" { + pk := strings.ToLower(strings.TrimSpace(obsID.String)) + agg.observerPubkeys[pk] = true + if obsTs.Valid && (agg.firstPubkey == "" || obsTs.Int64 < agg.firstTS) { + agg.firstPubkey = pk + agg.firstTS = obsTs.Int64 + } + } if hops > agg.hops { agg.hops, agg.snr, agg.resolvedPath = hops, snr, resolvedPath } @@ -2645,10 +2669,21 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . m.Data["observers"] = []string{observerName} } if isPingTrigger(displayText) { - agg := &pendingPing{hops: hops, snr: snr, resolvedPath: resolvedPath, observers: map[string]bool{}} + agg := &pendingPing{ + hops: hops, snr: snr, resolvedPath: resolvedPath, + observers: map[string]bool{}, observerPubkeys: map[string]bool{}, + } if observerName != "" { agg.observers[observerName] = true } + if obsID.Valid && obsID.String != "" { + pk := strings.ToLower(strings.TrimSpace(obsID.String)) + agg.observerPubkeys[pk] = true + if obsTs.Valid { + agg.firstPubkey = pk + agg.firstTS = obsTs.Int64 + } + } pendingPings[txID] = agg } msgMap[txID] = m @@ -2674,6 +2709,53 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } names, _ := db.namesAndRolesForPubkeys(pubkeys) + // Bulk-resolve observer positions too, for the "spread up to Nkm" + // part of the reply -- only for pings that could possibly show one + // (a first-hearer plus at least one other distinct station), and + // deliberately WITHOUT GetPacketPath's neighbor-centroid fallback + // for unpositioned stations: that's a per-node query each, too + // expensive to run for every ping on a page of channel messages. + // A station missing its own GPS fix just doesn't contribute here. + posPubkeySet := map[string]bool{} + for _, p := range pendingPings { + if p.firstPubkey == "" || len(p.observerPubkeys) < 2 { + continue + } + for pk := range p.observerPubkeys { + posPubkeySet[pk] = true + } + } + posByPK := map[string][2]float64{} + if len(posPubkeySet) > 0 { + posPubkeys := make([]string, 0, len(posPubkeySet)) + for pk := range posPubkeySet { + posPubkeys = append(posPubkeys, pk) + } + placeholders := make([]byte, 0, len(posPubkeys)*2) + args := make([]interface{}, len(posPubkeys)) + for i, pk := range posPubkeys { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = pk + } + posRows, err := db.conn.Query( + "SELECT public_key, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...) + if err == nil { + for posRows.Next() { + var pk string + var lat, lon sql.NullFloat64 + // (0,0) is the ocean off Ghana, not a real fix -- same + // exclusion GetPacketPath applies. + if posRows.Scan(&pk, &lat, &lon) == nil && lat.Valid && lon.Valid && !(lat.Float64 == 0 && lon.Float64 == 0) { + posByPK[pk] = [2]float64{lat.Float64, lon.Float64} + } + } + posRows.Close() + } + } + for txID, p := range pendingPings { var repeaterNames []string for _, pk := range p.resolvedPath { @@ -2701,8 +2783,27 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . default: observerLabel = fmt.Sprintf("%d observers", len(p.observers)) } + var farthestKm *float64 + if firstPos, ok := posByPK[p.firstPubkey]; ok { + var maxKm float64 + sawOther := false + for pk := range p.observerPubkeys { + if pk == p.firstPubkey { + continue + } + if pos, ok2 := posByPK[pk]; ok2 { + d := haversineKm(firstPos[0], firstPos[1], pos[0], pos[1]) + if !sawOther || d > maxKm { + maxKm, sawOther = d, true + } + } + } + if sawOther { + farthestKm = &maxKm + } + } if m, ok := msgMap[txID]; ok { - m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, repeaterNames) + m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm) } } } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index c11473dd..b638bcd6 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "fmt" "os" "path/filepath" "strings" @@ -2127,6 +2128,91 @@ func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) { } } +// TestGetChannelMessages_PingBotReply_SpreadDistance covers the "spread up +// to Nkm" part of the reply: when the first-hearing station and at least +// one other distinct station both have their own GPS fix on file (as +// mesh nodes, keyed by the observer's own pubkey), the farthest of those +// distances from the first hearer is reported -- the same "how wide did +// this spread" signal View Path's map shows. +func TestGetChannelMessages_PingBotReply_SpreadDistance(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('pkobsnear', 'Near Observer', 'CPH')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('pkobsfar', 'Far Observer', 'AAR')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkobsnear', 'Near Observer', 'repeater', 55.6761, 12.5683)`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkobsfar', 'Far Observer', 'repeater', 56.1629, 10.2039)`) + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('FF', 'chanmsg00000006', '2026-01-15T10:05:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Grace"}', '#ping')`) + // pkObsNear (observer_idx=1) heard it first -- lower timestamp. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -85, '[]', 1736935500)`) + // pkObsFar (observer_idx=2) heard it a moment later. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 2, 6.0, -95, '["aa"]', 1736935505)`) + + 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") + } + text, _ := reply["text"].(string) + wantKm := haversineKm(55.6761, 12.5683, 56.1629, 10.2039) + wantFrag := fmt.Sprintf("spread up to %.1fkm", wantKm) + if !strings.Contains(text, wantFrag) { + t.Errorf("botReply text = %q, want %q (farthest distance from the first hearer)", text, wantFrag) + } +} + +// TestGetChannelMessages_PingBotReply_NoSpreadWithoutPositions covers the +// omission side: when hearing stations don't have their own GPS fix on +// file (the common case -- most observers aren't also positioned mesh +// nodes), no "spread" claim should be fabricated. +func TestGetChannelMessages_PingBotReply_NoSpreadWithoutPositions(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')`) + // Deliberately no nodes rows for obs1/obs2 -- neither has a GPS fix. + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('GG', 'chanmsg00000007', '2026-01-15T10:06:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Heidi"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -85, '[]', 1736935600)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 2, 6.0, -95, '["aa"]', 1736935605)`) + + 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") + } + text, _ := reply["text"].(string) + if strings.Contains(text, "spread up to") { + t.Errorf("botReply text = %q, want no \"spread up to\" claim when neither observer has a GPS fix", text) + } +} + func TestGetNetworkStatusDateFormats(t *testing.T) { db := setupTestDB(t) defer db.Close()