diff --git a/cmd/server/channel_message_area_test.go b/cmd/server/channel_message_area_test.go index e4612d49..1841ddcd 100644 --- a/cmd/server/channel_message_area_test.go +++ b/cmd/server/channel_message_area_test.go @@ -155,3 +155,161 @@ func TestHandleChannelMessages_EntryPointArea_Unresolved(t *testing.T) { t.Error("entryPrefix must never reach the client") } } + +// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback covers a +// 0-hop (direct) reception: there's no relay path at all, so path[0] +// resolution has nothing to work with, but the hearing station's own GPS +// fix is a reasonable stand-in for "where this happened" at 0 hops. The +// observer here has role "client" (not repeater), deliberately proving the +// fallback bypasses the path-hop role filter that buildPrefixMap applies -- +// a listening station doesn't need to be relay-eligible to have its own +// position count here. +func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + if !srv.db.hasScopeName { + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "DK_DJURS": {Label: "Djursland", LatMin: f(56.10), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)}, + } + + // The hearing station itself: a plain client, not a repeater, sitting + // in Djursland. + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "pkebeltoftobserver", "DK_8400_Ebeltoft Observer", 56.1959, 10.6801, "client"); err != nil { + t.Fatalf("insert node: %v", err) + } + srv.store.InvalidateNodeCache() + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`, + "aa", "chmsgdirect1", now, `{"sender":"HSVI","text":"HSVI: Tak tak"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, + "pkebeltoftobserver", "DK_8400_Ebeltoft Observer", "EBT") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + // path_json '[]' -- direct reception, no relay hops at all. + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 12.8, -70.0, `[]`, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + + if msg["hops"] != float64(0) { + t.Errorf("hops = %v, want 0 (direct)", msg["hops"]) + } + if msg["area"] != "Djursland" { + t.Errorf("area = %v, want \"Djursland\" (resolved from the hearing station's own position, no relay path needed)", msg["area"]) + } + if _, present := msg["entryObserverPubkey"]; present { + t.Error("entryObserverPubkey must never reach the client -- it's an internal-only intermediate field") + } +} + +// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS +// confirms the fallback stays silent (never guesses) when the hearing +// station of a 0-hop message has no GPS fix of its own on file -- the +// common case, since most observers aren't also positioned mesh nodes. +func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + if !srv.db.hasScopeName { + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "DK_DJURS": {Label: "Djursland", LatMin: f(56.20), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)}, + } + // Deliberately no nodes row for the observer -- no GPS fix on file. + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`, + "aa", "chmsgdirect2", now, `{"sender":"Someone","text":"Someone: hi"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, + "pknogps", "No GPS Observer", "XXX") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 5.0, -90.0, `[]`, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + if _, present := msg["area"]; present { + t.Errorf("area = %v, want absent (observer has no GPS fix on file)", msg["area"]) + } + if _, present := msg["entryObserverPubkey"]; present { + t.Error("entryObserverPubkey must never reach the client") + } +} diff --git a/cmd/server/channel_message_bot_reply_touched_areas_test.go b/cmd/server/channel_message_bot_reply_touched_areas_test.go new file mode 100644 index 00000000..817c03fd --- /dev/null +++ b/cmd/server/channel_message_bot_reply_touched_areas_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestHandleChannelMessages_BotReplyTouchedAreas covers dborup's follow-up +// request on the ping-bot reply: beyond the numeric "spread up to Nkm", show +// which named areas the packet actually touched, deduped across whichever +// hearing stations have their own GPS fix on file. +func TestHandleChannelMessages_BotReplyTouchedAreas(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)}, + "ODE": {Label: "Odense by", LatMin: f(55.30), LatMax: f(55.50), LonMin: f(10.25), LonMax: f(10.45)}, + } + + // Two observers inside Aarhus by (must dedupe to one label), one inside + // Odense by. + for _, n := range []struct { + pk string + lat, lon float64 + }{ + {"pkaarhusobs1", 56.15, 10.20}, + {"pkaarhusobs2", 56.16, 10.21}, + {"pkodenseobs1", 55.40, 10.38}, + } { + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + n.pk, n.pk, n.lat, n.lon, "client"); err != nil { + t.Fatalf("insert node %s: %v", n.pk, err) + } + } + srv.store.InvalidateNodeCache() + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,0,5,'#ping',?)`, + "aa", "chmsgtouch1", now, `{"sender":"Alice","text":"Alice: ping"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + + obsPKs := []string{"pkaarhusobs1", "pkaarhusobs2", "pkodenseobs1"} + for i, pk := range obsPKs { + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, pk, pk, "") + if err != nil { + t.Fatalf("insert observer %s: %v", pk, err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 10.0, -80.0, `[]`, time.Now().Unix()+int64(i), + ); err != nil { + t.Fatalf("insert observation %s: %v", pk, err) + } + } + + req := httptest.NewRequest("GET", "/api/channels/%23ping/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + br, _ := msg["botReply"].(map[string]interface{}) + if br == nil { + t.Fatal("expected a botReply on the ping message") + } + text, _ := br["text"].(string) + if !strings.Contains(text, "touched Aarhus by, Odense by") { + t.Errorf("botReply text = %q, want \"touched Aarhus by, Odense by\" (deduped, alphabetical)", text) + } + if _, present := br["touchedObserverPubkeys"]; present { + t.Error("touchedObserverPubkeys must never reach the client -- it's an internal-only intermediate field") + } +} + +// TestHandleChannelMessages_BotReplyTouchedAreas_Capped covers the display +// cap: a packet touching more than botReplyMaxAreasShown distinct areas +// shows only the first few (alphabetically) plus a "+N more" count, rather +// than growing the chat bubble unboundedly. +func TestHandleChannelMessages_BotReplyTouchedAreas_Capped(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "A1": {Label: "Area One", LatMin: f(56.00), LatMax: f(56.10), LonMin: f(10.00), LonMax: f(10.10)}, + "A2": {Label: "Area Two", LatMin: f(56.20), LatMax: f(56.30), LonMin: f(10.20), LonMax: f(10.30)}, + "A3": {Label: "Area Three", LatMin: f(56.40), LatMax: f(56.50), LonMin: f(10.40), LonMax: f(10.50)}, + "A4": {Label: "Area Four", LatMin: f(56.60), LatMax: f(56.70), LonMin: f(10.60), LonMax: f(10.70)}, + } + + for _, n := range []struct { + pk string + lat, lon float64 + }{ + {"pkarea1", 56.05, 10.05}, + {"pkarea2", 56.25, 10.25}, + {"pkarea3", 56.45, 10.45}, + {"pkarea4", 56.65, 10.65}, + } { + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + n.pk, n.pk, n.lat, n.lon, "client"); err != nil { + t.Fatalf("insert node %s: %v", n.pk, err) + } + } + srv.store.InvalidateNodeCache() + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,0,5,'#ping',?)`, + "aa", "chmsgtouch2", now, `{"sender":"Bob","text":"Bob: ping"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + + obsPKs := []string{"pkarea1", "pkarea2", "pkarea3", "pkarea4"} + for i, pk := range obsPKs { + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, pk, pk, "") + if err != nil { + t.Fatalf("insert observer %s: %v", pk, err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 10.0, -80.0, `[]`, time.Now().Unix()+int64(i), + ); err != nil { + t.Fatalf("insert observation %s: %v", pk, err) + } + } + + req := httptest.NewRequest("GET", "/api/channels/%23ping/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + br, _ := msg["botReply"].(map[string]interface{}) + if br == nil { + t.Fatal("expected a botReply on the ping message") + } + text, _ := br["text"].(string) + want := "touched Area Four, Area One, Area Three +1 more" + if !strings.Contains(text, want) { + t.Errorf("botReply text = %q, want it to contain %q (capped to 3, alphabetical, remainder counted)", text, want) + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 1398a881..221241fc 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1602,6 +1602,13 @@ type PacketPathResponse struct { Hash string `json:"hash"` Branches []PacketPathBranch `json:"branches"` First *PacketPathBranch `json:"first,omitempty"` + // TouchedAreas is every configured area any point or observer on this + // path resolved to (deduped, alphabetized, uncapped -- unlike the + // ping-bot reply's capped list, the map has room to show all of them). + // Populated by routes.go's annotatePacketPathTouchedAreas, not here: + // area resolution needs config.Areas, not available at this SQL-only + // DB layer. + TouchedAreas []string `json:"touchedAreas,omitempty"` } // GetPacketPath resolves every distinct station that observed a packet to @@ -2643,22 +2650,34 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } } senderTs := decoded["sender_timestamp"] + // entryObserverPubkey: a 0-hop (direct) reception has no relay path, + // so entryPrefix is empty and annotateMessageAreas has nothing to + // resolve -- even though the hearing station's own position is a + // reasonable stand-in for "where this happened" at 0 hops. Only + // captured for the direct case; a message with an unresolvable + // multi-hop path deliberately still gets no fallback (an estimate + // from the wrong end of a relay chain isn't worth showing). + var entryObserverPubkey string + if hops == 0 && obsID.Valid && obsID.String != "" { + entryObserverPubkey = strings.ToLower(strings.TrimSpace(obsID.String)) + } m := &msg{ Data: map[string]interface{}{ - "sender": displaySender, - "text": displayText, - "timestamp": nullStr(fs), - "first_seen": nullStr(fs), - "sender_timestamp": senderTs, - "packetId": pktID, - "packetHash": nullStr(pktHash), - "repeats": 1, - "observers": []string{}, - "hops": hops, - "snr": nullFloat(snr), - "scope": nullStr(scopeName), - "routeType": nullInt(routeType), - "entryPrefix": entryPrefix, + "sender": displaySender, + "text": displayText, + "timestamp": nullStr(fs), + "first_seen": nullStr(fs), + "sender_timestamp": senderTs, + "packetId": pktID, + "packetHash": nullStr(pktHash), + "repeats": 1, + "observers": []string{}, + "hops": hops, + "snr": nullFloat(snr), + "scope": nullStr(scopeName), + "routeType": nullInt(routeType), + "entryPrefix": entryPrefix, + "entryObserverPubkey": entryObserverPubkey, }, Repeats: 1, } @@ -2803,7 +2822,19 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } } if m, ok := msgMap[txID]; ok { - m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm) + br := pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm) + // touchedObserverPubkeys never reaches the client -- routes.go's + // annotateBotReplyTouchedAreas resolves it to area labels + // (needs s.cfg.Areas, not available at this SQL-only DB + // layer) and deletes it before the response is written. + if len(p.observerPubkeys) > 0 { + pks := make([]string, 0, len(p.observerPubkeys)) + for pk := range p.observerPubkeys { + pks = append(pks, pk) + } + br["touchedObserverPubkeys"] = pks + } + m.Data["botReply"] = br } } } @@ -4255,6 +4286,55 @@ func (db *DB) namesAndRolesForPubkeys(pubkeys []string) (names, roles map[string return names, roles } +// gpsByPubkeysExact bulk-resolves GPS positions for a set of FULL pubkeys +// via an exact match -- unlike the path-hop prefix machinery (buildPrefixMap +// /resolveEntryPointArea), which only indexes repeater/room-server roles as +// path-hop candidates, this has no role filter: any node type qualifies, +// since a hearing station's own position doesn't depend on whether it could +// ever appear as a relay hop in someone else's path. +func (db *DB) gpsByPubkeysExact(pubkeys []string) map[string][2]float64 { + result := make(map[string][2]float64, len(pubkeys)) + if len(pubkeys) == 0 { + return result + } + const chunkSize = 499 + for start := 0; start < len(pubkeys); start += chunkSize { + end := start + chunkSize + if end > len(pubkeys) { + end = len(pubkeys) + } + chunk := pubkeys[start:end] + placeholders := make([]byte, 0, len(chunk)*2) + args := make([]interface{}, len(chunk)) + for i, pk := range chunk { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = pk + } + query := "SELECT public_key, lat, lon FROM nodes WHERE public_key IN (" + string(placeholders) + ")" + rows, err := db.conn.Query(query, args...) + if err != nil { + continue + } + for rows.Next() { + var pk string + var lat, lon sql.NullFloat64 + if rows.Scan(&pk, &lat, &lon) != nil { + continue + } + // (0,0) is the ocean off Ghana, not a real fix -- same + // exclusion GetPacketPath/packetSpreadStats apply. + if lat.Valid && lon.Valid && !(lat.Float64 == 0 && lon.Float64 == 0) { + result[pk] = [2]float64{lat.Float64, lon.Float64} + } + } + rows.Close() + } + return result +} + // GetChannelMessageScopeStats narrows the scoped/unscoped/unknown question // to channel chat specifically (payload_type=5), for the given window. // Unlike GetScopeStats' TransportTotal (route_type 0/3 only), TotalMessages diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 44dfab65..5d9844ea 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -377,9 +377,10 @@ func componentSchemas() map[string]interface{} { "PacketPathResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "hash": str("The packet hash this path was resolved for."), - "branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."}, - "first": schemaRef("PacketPathBranch"), + "hash": str("The packet hash this path was resolved for."), + "branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."}, + "first": schemaRef("PacketPathBranch"), + "touchedAreas": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Every configured area any point or observer on the path falls in, deduped and alphabetized. Omitted when no areas are configured or none resolved."}, }, }, } diff --git a/cmd/server/packet_path_touched_areas_test.go b/cmd/server/packet_path_touched_areas_test.go new file mode 100644 index 00000000..44bb5f22 --- /dev/null +++ b/cmd/server/packet_path_touched_areas_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// TestHandlePacketPath_TouchedAreas covers dborup's follow-up to the +// ping-bot reply's capped "touched" list: View Path has room to show every +// area the packet's points and observers fall in, not just the first few. +// Deduped and alphabetized, but uncapped -- unlike +// annotateBotReplyTouchedAreas's pong-reply version. +func TestHandlePacketPath_TouchedAreas(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)}, + "ODE": {Label: "Odense by", LatMin: f(55.30), LatMax: f(55.50), LonMin: f(10.25), LonMax: f(10.45)}, + } + + // A relay hop positioned in Aarhus, an observer positioned in Odense -- + // touchedAreas must cover both, from a single 1-hop branch. + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "pkaarhusrepeater", "AarhusRepeater", 56.15, 10.20, "repeater"); err != nil { + t.Fatalf("insert repeater node: %v", err) + } + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "pkodenseobserver", "OdenseObserver", 55.40, 10.38, "client"); err != nil { + t.Fatalf("insert observer node: %v", err) + } + srv.store.InvalidateNodeCache() + + txRes, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'touchedpath00001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := txRes.LastInsertId() + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, + "pkodenseobserver", "OdenseObserver", "") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (?,?,?,?,?,?,?)`, + txID, obsIdx, 8.0, -85.0, `["aa"]`, `["pkaarhusrepeater"]`, 1736935200, + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/packets/touchedpath00001/path", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp PacketPathResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.TouchedAreas) != 2 || resp.TouchedAreas[0] != "Aarhus by" || resp.TouchedAreas[1] != "Odense by" { + t.Errorf("TouchedAreas = %v, want [\"Aarhus by\" \"Odense by\"] (alphabetical, one from the relay hop, one from the observer)", resp.TouchedAreas) + } +} + +// TestHandlePacketPath_TouchedAreas_NoAreasConfigured confirms the field is +// simply omitted (never guessed) when no areas are configured. +func TestHandlePacketPath_TouchedAreas_NoAreasConfigured(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + srv.cfg.Areas = nil + + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "pkaarhusrepeater", "AarhusRepeater", 56.15, 10.20, "repeater"); err != nil { + t.Fatalf("insert repeater node: %v", err) + } + srv.store.InvalidateNodeCache() + + txRes, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'touchedpath00002', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := txRes.LastInsertId() + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsX", "ObsX", "") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (?,?,?,?,?,?,?)`, + txID, obsIdx, 8.0, -85.0, `["aa"]`, `["pkaarhusrepeater"]`, 1736935200, + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/packets/touchedpath00002/path", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if _, present := body["touchedAreas"]; present { + t.Errorf("touchedAreas = %v, want absent (no areas configured)", body["touchedAreas"]) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index d88fca6c..5f8f4c76 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2876,20 +2876,123 @@ func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool // sitting in Aarhus but sending with the broad #dk scope should still show // "Aarhus by" here. The raw entryPrefix never reaches the client, whether // or not it resolved. +// +// A 0-hop (direct) message has no relay path at all, so there's no +// entryPrefix to resolve -- even though the hearing station's own GPS fix +// (captured as "entryObserverPubkey", direct-reception case only) is a +// reasonable stand-in for "where this happened". That fallback is resolved +// in a second bulk pass below, deliberately not extended to messages whose +// multi-hop path just failed to resolve (an estimate from the wrong end of +// a relay chain isn't worth showing). entryObserverPubkey never reaches the +// client either way. func (s *Server) annotateMessageAreas(messages []map[string]interface{}) { hasAreas := s.cfg != nil && len(s.cfg.Areas) > 0 + needsFallback := make([]map[string]interface{}, 0) + fallbackPubkeys := make([]string, 0) for _, m := range messages { prefix, _ := m["entryPrefix"].(string) delete(m, "entryPrefix") - if !hasAreas || prefix == "" { + observerPK, _ := m["entryObserverPubkey"].(string) + delete(m, "entryObserverPubkey") + if !hasAreas { continue } - if label, ok := s.resolveEntryPointArea([]string{prefix}); ok { + if prefix != "" { + if label, ok := s.resolveEntryPointArea([]string{prefix}); ok { + m["area"] = label + continue + } + } + if observerPK != "" { + needsFallback = append(needsFallback, m) + fallbackPubkeys = append(fallbackPubkeys, observerPK) + } + } + if len(needsFallback) == 0 || s.db == nil { + return + } + gpsByPK := s.db.gpsByPubkeysExact(fallbackPubkeys) + for i, m := range needsFallback { + pos, ok := gpsByPK[fallbackPubkeys[i]] + if !ok { + continue + } + if label, ok := AreaForPoint(pos[0], pos[1], s.cfg.Areas); ok { m["area"] = label } } } +// annotateBotReplyTouchedAreas extends a ping-bot reply with the distinct +// configured areas any hearing station (with its own GPS fix on file) was +// in -- "how wide" the spread was in named-place terms, alongside the +// numeric "spread up to Nkm" pingBotReply already reports. Capped to keep +// the chat bubble readable, since a broadly-flooded packet can easily touch +// a dozen+ areas. touchedObserverPubkeys never reaches the client either +// way. +const botReplyMaxAreasShown = 3 + +func (s *Server) annotateBotReplyTouchedAreas(messages []map[string]interface{}) { + hasAreas := s.cfg != nil && len(s.cfg.Areas) > 0 + type pending struct { + br map[string]interface{} + pubkeys []string + } + var work []pending + pubkeySet := map[string]bool{} + for _, m := range messages { + br, ok := m["botReply"].(map[string]interface{}) + if !ok { + continue + } + pks, _ := br["touchedObserverPubkeys"].([]string) + delete(br, "touchedObserverPubkeys") + if !hasAreas || len(pks) == 0 { + continue + } + for _, pk := range pks { + pubkeySet[pk] = true + } + work = append(work, pending{br: br, pubkeys: pks}) + } + if len(work) == 0 || s.db == nil { + return + } + allPubkeys := make([]string, 0, len(pubkeySet)) + for pk := range pubkeySet { + allPubkeys = append(allPubkeys, pk) + } + gpsByPK := s.db.gpsByPubkeysExact(allPubkeys) + for _, w := range work { + seen := map[string]bool{} + var labels []string + for _, pk := range w.pubkeys { + pos, ok := gpsByPK[pk] + if !ok { + continue + } + label, ok := AreaForPoint(pos[0], pos[1], s.cfg.Areas) + if !ok || seen[label] { + continue + } + seen[label] = true + labels = append(labels, label) + } + if len(labels) == 0 { + continue + } + sort.Strings(labels) + shown := labels + suffix := "" + if len(labels) > botReplyMaxAreasShown { + shown = labels[:botReplyMaxAreasShown] + suffix = fmt.Sprintf(" +%d more", len(labels)-botReplyMaxAreasShown) + } + text, _ := w.br["text"].(string) + w.br["text"] = text + " · touched " + strings.Join(shown, ", ") + suffix + } +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -2935,12 +3038,14 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { return } s.annotateMessageAreas(messages) + s.annotateBotReplyTouchedAreas(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) + s.annotateBotReplyTouchedAreas(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } @@ -3164,9 +3269,49 @@ func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + s.annotatePacketPathTouchedAreas(resp) writeJSON(w, resp) } +// annotatePacketPathTouchedAreas resolves resp.TouchedAreas: every +// configured area any point or observer on the path falls in, deduped and +// alphabetized, uncapped (unlike annotateBotReplyTouchedAreas's capped +// pong-reply list -- the map view has room to show the full set). Unlike +// that function, no DB round-trip is needed: GetPacketPath already +// resolved every position (including the neighbor-centroid approximation +// fallback), so this just reads the lat/lon already on the response. +func (s *Server) annotatePacketPathTouchedAreas(resp *PacketPathResponse) { + if resp == nil || s.cfg == nil || len(s.cfg.Areas) == 0 { + return + } + seen := map[string]bool{} + var labels []string + add := func(lat, lon *float64) { + if lat == nil || lon == nil { + return + } + label, ok := AreaForPoint(*lat, *lon, s.cfg.Areas) + if !ok || seen[label] { + return + } + seen[label] = true + labels = append(labels, label) + } + for _, b := range resp.Branches { + for _, pt := range b.Points { + add(pt.Lat, pt.Lon) + } + if b.Observer != nil { + add(b.Observer.Lat, b.Observer.Lon) + } + } + if len(labels) == 0 { + return + } + sort.Strings(labels) + resp.TouchedAreas = labels +} + var iataCoords = map[string]IataCoord{ "SJC": {Lat: 37.3626, Lon: -121.929}, "SFO": {Lat: 37.6213, Lon: -122.379}, diff --git a/cmd/server/store.go b/cmd/server/store.go index cc7ffd9f..bb7e1d87 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2971,6 +2971,18 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac if label, ok := s.resolveEntryPointArea([]string{entryPrefix}); ok { pkt["area"] = label } + } else if obs.ObserverID != "" && s.db != nil && s.config != nil && len(s.config.Areas) > 0 { + // 0-hop (direct) reception: no relay path to resolve, but + // the hearing station's own position is a reasonable + // stand-in for "where this happened" -- same fallback as + // annotateMessageAreas (routes.go), not extended to a + // multi-hop path that just failed to resolve. + pk := strings.ToLower(strings.TrimSpace(obs.ObserverID)) + if gps, ok := s.db.gpsByPubkeysExact([]string{pk})[pk]; ok { + if label, ok := AreaForPoint(gps[0], gps[1], s.config.Areas); ok { + pkt["area"] = label + } + } } // Use decode-window resolved path for broadcast (never from struct) if broadcastRP != nil { diff --git a/public/packet-path-map.js b/public/packet-path-map.js index a6fca65f..7b2fca55 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -192,6 +192,12 @@ var bounds = []; var missingTotal = 0; var approxTotal = 0; + // The same physical node (e.g. a shared entry-point repeater near the + // sender) commonly appears in many branches' chains -- dedupe by + // identity so "N approximate" counts distinct stations, not chain + // appearances (#1... a packet heard by 12 stations through one shared + // repeater was showing "11 approximate" for what was really 1 node). + var approxSeen = {}; // Draw secondary branches first so the primary (deepest) one ends up on top. var ordered = plotted.slice().sort(function (a, b) { return (a.primary ? 1 : 0) - (b.primary ? 1 : 0); }); ordered.forEach(function (p) { @@ -199,7 +205,13 @@ var lineColor = p.primary ? accent : muted; var line = []; p.chain.forEach(function (pt) { - if (pt.approx) approxTotal++; + if (pt.approx) { + var approxKey = pt.publicKey || pt.name; + if (!approxSeen[approxKey]) { + approxSeen[approxKey] = true; + approxTotal++; + } + } bounds.push([pt.lat, pt.lon]); line.push([pt.lat, pt.lon]); var color = pt.isObserver ? observerColor : lineColor; @@ -269,6 +281,7 @@ if (firstPoint) statusParts.push('entered near ' + firstPoint.name); if (approxTotal > 0) statusParts.push(approxTotal + ' approximate (estimated from neighbors)'); if (missingTotal > 0) statusParts.push(missingTotal + ' hop' + (missingTotal === 1 ? '' : 's') + ' without a known position (not shown)'); + if (data.touchedAreas && data.touchedAreas.length > 0) statusParts.push('touched ' + data.touchedAreas.join(', ')); if (statusEl) statusEl.textContent = statusParts.join(' · '); } diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 8d4aac5b..ea73cdd6 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -320,6 +320,50 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ approximate (neighbor-borrowed) positions render hollow/dashed and are called out in status: ' + e.message); } })(); + await (async () => { + try { + // A shared entry-point node with an approximate position commonly + // appears in MANY branches' chains (e.g. one repeater near the + // sender that a dozen stations all relayed through). The status + // count must dedupe by identity -- 1 distinct approximate node + // showing up in 3 branches is "1 approximate", not "3". + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 2, + points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }], + observer: { name: 'ObserverA', lat: 56.1, lon: 10.1 }, + }, + { + hops: 1, + points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }], + observer: { name: 'ObserverB', lat: 56.2, lon: 10.2 }, + }, + { + hops: 1, + points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }], + observer: { name: 'ObserverC', lat: 56.3, lon: 10.3 }, + }, + ], + })); + + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('1 approximate'), 'status should count the shared node once (1 approximate), not once per branch it appears in, got: ' + status.textContent); + assert.ok(!status.textContent.includes('3 approximate'), 'status should NOT count 3 -- that would be counting chain appearances, not distinct nodes, got: ' + status.textContent); + passed++; + console.log(' ✅ "N approximate" dedupes a shared node across branches instead of counting each chain appearance'); + } catch (e) { failed++; console.log(' ❌ "N approximate" dedupes a shared node across branches instead of counting each chain appearance: ' + e.message); } + })(); + await (async () => { try { // branch.secondsAfterFirst (0 for the earliest arrival, positive @@ -498,6 +542,55 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ markers with a publicKey are clickable and navigate to node detail, closing the modal: ' + e.message); } })(); + await (async () => { + try { + // touchedAreas is the server-resolved, uncapped list of every + // configured area any point/observer on the path fell in -- View + // Path has room to show all of them (unlike the pong reply's + // capped "+N more" version). + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [{ hops: 0, points: [], observer: { name: 'Obs', lat: 56.0, lon: 10.0 } }], + touchedAreas: ['Aarhus by', 'Djursland', 'Odense by'], + })); + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('touched Aarhus by, Djursland, Odense by'), 'status should list every touched area uncapped, got: ' + status.textContent); + passed++; + console.log(' ✅ touchedAreas renders as an uncapped, comma-joined list in the status line'); + } catch (e) { failed++; console.log(' ❌ touchedAreas renders as an uncapped, comma-joined list in the status line: ' + e.message); } + })(); + + await (async () => { + try { + // No touchedAreas field at all (no areas configured server-side, or + // none resolved) -- must not add a stray "touched" fragment or throw. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [{ hops: 0, points: [], observer: { name: 'Obs', lat: 56.0, lon: 10.0 } }], + })); + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(!status.textContent.includes('touched'), 'status should have no "touched" fragment when touchedAreas is absent, got: ' + status.textContent); + passed++; + console.log(' ✅ omits the "touched" fragment when touchedAreas is absent'); + } catch (e) { failed++; console.log(' ❌ omits the "touched" fragment when touchedAreas is absent: ' + e.message); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');