diff --git a/cmd/server/db.go b/cmd/server/db.go index d02c096c..b53744ab 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1530,9 +1530,10 @@ type PacketPathPoint struct { } // PacketPathObserver is the station that produced a given branch's -// observation of a packet (see GetPacketPath), positioned from its -// configured IATA code the same way the Wardriving tab positions -// observers -- not a stored per-observer lat/lon column. +// observation of a packet (see GetPacketPath), positioned from its own +// self-advertised GPS (the same source /api/observers uses) when known, +// falling back to its configured IATA code otherwise -- not a stored +// per-observer lat/lon column. type PacketPathObserver struct { Name string `json:"name"` IATA string `json:"iata,omitempty"` @@ -1574,13 +1575,13 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { } var querySQL string if db.isV3 { - querySQL = `SELECT obs.rowid, obs.name, obs.iata, o.path_json, o.resolved_path, o.snr + querySQL = `SELECT obs.rowid, obs.id, obs.name, obs.iata, o.path_json, o.resolved_path, o.snr 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 = ?` } else { - querySQL = `SELECT o.observer_id, o.observer_name, NULL, o.path_json, o.resolved_path, o.snr + querySQL = `SELECT o.observer_id, o.observer_id, o.observer_name, NULL, o.path_json, o.resolved_path, o.snr FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.hash = ?` @@ -1592,18 +1593,19 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { defer rows.Close() type obsBranch struct { - hops int - resolvedPath []*string - observerName string - observerIATA sql.NullString - snr sql.NullFloat64 + hops int + resolvedPath []*string + observerName string + observerPubkey string + observerIATA sql.NullString + snr sql.NullFloat64 } best := make(map[string]*obsBranch) for rows.Next() { - var obsKey, obsName, obsIATA, pathJSON, resolvedPathJSON sql.NullString + var obsKey, obsPubkey, obsName, obsIATA, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 - if err := rows.Scan(&obsKey, &obsName, &obsIATA, &pathJSON, &resolvedPathJSON, &snr); err != nil { + if err := rows.Scan(&obsKey, &obsPubkey, &obsName, &obsIATA, &pathJSON, &resolvedPathJSON, &snr); err != nil { continue } if !pathJSON.Valid { @@ -1628,7 +1630,8 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { if existing, ok := best[key]; !ok || hops > existing.hops { best[key] = &obsBranch{ hops: hops, resolvedPath: resolvedPath, - observerName: obsName.String, observerIATA: obsIATA, snr: snr, + observerName: obsName.String, observerPubkey: strings.ToLower(strings.TrimSpace(obsPubkey.String)), + observerIATA: obsIATA, snr: snr, } } } @@ -1648,6 +1651,13 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { pubkeySet[*pk] = true } } + // An observer is itself a mesh node -- if it has ever self-advertised + // a GPS position, that's a more precise fix than its (often manually + // typed, sometimes wrong or missing) configured IATA code. Folded + // into the same batched lookup below rather than a second query. + if b.observerPubkey != "" { + pubkeySet[b.observerPubkey] = true + } } pubkeys := make([]string, 0, len(pubkeySet)) for pk := range pubkeySet { @@ -1713,6 +1723,16 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { obs := &PacketPathObserver{Name: b.observerName} if b.observerIATA.Valid { obs.IATA = strings.ToUpper(strings.TrimSpace(b.observerIATA.String)) + } + // Prefer the observer's own self-advertised GPS (same source as + // /api/observers and the Wardriving tab) over its configured + // IATA code -- the IATA table only covers a fixed list of real + // airports plus a handful of hand-added local codes, so a + // custom/regional code an operator typed in (or a typo) falls + // through it even when the node itself knows exactly where it is. + if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.lat != nil && ni.lon != nil { + obs.Lat, obs.Lon = ni.lat, ni.lon + } else if obs.IATA != "" { if coord, ok := iataCoords[obs.IATA]; ok { lat, lon := coord.Lat, coord.Lon obs.Lat, obs.Lon = &lat, &lon diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 225b953a..82185328 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -677,6 +677,45 @@ func TestGetPacketPath(t *testing.T) { } } +// TestGetPacketPath_ObserverPositionPrefersOwnGPS covers an observer whose +// configured IATA code isn't a real airport (a custom/regional code an +// operator typed in, or a typo) and so isn't in the hardcoded iataCoords +// table -- but the observer is itself a mesh node that has self-advertised +// a real GPS position. That position must be used instead of leaving the +// observer unplaced, since it's the same source /api/observers and the +// Wardriving tab already treat as authoritative. +func TestGetPacketPath_ObserverPositionPrefersOwnGPS(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('deadbeefcafe', 'Custom Coded Observer', 'QXV')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('deadbeefcafe', 'Custom Coded Observer', 'room', 56.19, 9.6)`) + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000004', '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, '[]', 1736935200)`) + + resp, err := db.GetPacketPath("pathtest00000004") + if err != nil { + t.Fatal(err) + } + if len(resp.Branches) != 1 { + t.Fatalf("Branches = %+v, want 1", resp.Branches) + } + obs := resp.Branches[0].Observer + if obs == nil { + t.Fatalf("Observer = nil, want a populated observer") + } + if obs.IATA != "QXV" { + t.Errorf("Observer.IATA = %q, want QXV (kept even though it's not in iataCoords)", obs.IATA) + } + if obs.Lat == nil || *obs.Lat != 56.19 || obs.Lon == nil || *obs.Lon != 9.6 { + t.Errorf("Observer.Lat/Lon = %v/%v, want the node's own self-advertised GPS (56.19, 9.6), not left nil just because QXV isn't a known airport", obs.Lat, obs.Lon) + } +} + // TestGetPacketPath_NoResolvedPath covers a station whose observation // never resolved: it still contributes a branch (hop count from // path_json, so reach is never silently dropped), just with no points. diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 4d6a9b6c..e87096ff 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -141,7 +141,7 @@ func routeDescriptions() map[string]routeMeta { // 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/packets/{hash}/path": {Summary: "Get a packet's full geographic flood spread", Description: "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (from its configured IATA code, like the Wardriving tab). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. 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", + "GET /api/packets/{hash}/path": {Summary: "Get a packet's full geographic flood spread", Description: "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (self-advertised GPS when known, same as /api/observers, else its configured IATA code). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. Lat/lon are null for any hop or observer that has no known 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"}, @@ -346,7 +346,7 @@ func componentSchemas() map[string]interface{} { }, "PacketPathObserver": map[string]interface{}{ "type": "object", - "description": "The station that produced a given branch's observation of a packet path, positioned from its configured IATA code.", + "description": "The station that produced a given branch's observation of a packet path, positioned from its own self-advertised GPS when known (same source as /api/observers), else its configured IATA code.", "properties": map[string]interface{}{ "name": str("Observer display name."), "iata": str("Observer's configured IATA airport code, when set."),