Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup
2026-07-26 08:24:56 +02:00
9 changed files with 835 additions and 21 deletions
+158
View File
@@ -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")
}
}
@@ -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)
}
}
+95 -15
View File
@@ -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
+4 -3
View File
@@ -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."},
},
},
}
@@ -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"])
}
}
+147 -2
View File
@@ -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},
+12
View File
@@ -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 {
+14 -1
View File
@@ -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(' · ');
}
+93
View File
@@ -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('════════════════════════════════════════');