Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup
2026-07-24 20:14:38 +02:00
2 changed files with 191 additions and 4 deletions
+105 -4
View File
@@ -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)
}
}
}
+86
View File
@@ -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()