fix: View Path positions observers from their own GPS, not just the IATA table

GetPacketPath only ever tried the hardcoded iataCoords table to place
an observer. Any station whose configured IATA code isn't a real
airport (a custom/regional code, or a typo) fell out of the map
entirely -- even when the station is itself a mesh node that has
self-advertised a real GPS position, the same source /api/observers
and the Wardriving tab already treat as authoritative.

Now checks the observer's own node-table position first (folded into
the existing bulk pubkey lookup, no extra query) and only falls back
to the IATA table when it has none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-24 11:35:28 +02:00
co-authored by Claude Sonnet 5
parent 2f686d40c6
commit 94be933ed5
3 changed files with 74 additions and 15 deletions
+33 -13
View File
@@ -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
+39
View File
@@ -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.
+2 -2
View File
@@ -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."),