From 822c9660f540a0c5051cf07b28d6c99ce066f444 Mon Sep 17 00:00:00 2001 From: Openclaw Date: Wed, 23 Sep 2026 16:28:14 +0200 Subject: [PATCH 1/7] Split advert relay airtime by route class --- cmd/server/relay_airtime_share.go | 73 ++++++++++++++++++++----- cmd/server/relay_airtime_share_test.go | 75 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 4127e4559..08447a86c 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -25,7 +25,8 @@ import ( // preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the // SF-dependent preamble pulled from internal/lora.PreambleForSF. // -// Aggregated by payload_type. Originator TX is deliberately excluded — a +// Aggregated by payload_type, except ADVERT packets which are split into flood +// and zero-hop route classes. Originator TX is deliberately excluded — a // never-relayed direct message scores 0, which is the correct framing for a // "relay amplification" metric. In-memory only; no SQL, no new index. @@ -197,7 +198,57 @@ func (s *PacketStore) AirtimeAndRelayCountForTransmission(txID int64) (total tim return total, relays, true } -// computeRelayAirtimeShare aggregates relay-airtime-share per payload_type. +type relayAirtimeAdvertRoute uint8 + +const ( + relayAirtimeAdvertUnknown relayAirtimeAdvertRoute = iota + relayAirtimeAdvertFlood + relayAirtimeAdvertZeroHop +) + +type relayAirtimeBucketKey struct { + payloadType int + advertRoute relayAirtimeAdvertRoute +} + +// relayAirtimeKey keeps non-ADVERT aggregation unchanged while separating the +// two ADVERT behaviours defined by MeshCore's route bits. Unknown/legacy route +// values deliberately keep the historical unsuffixed ADVERT bucket rather than +// being silently assigned to either behaviour. +func relayAirtimeKey(tx *StoreTx) relayAirtimeBucketKey { + key := relayAirtimeBucketKey{payloadType: *tx.PayloadType} + if key.payloadType != PayloadADVERT || tx.RouteType == nil { + return key + } + switch *tx.RouteType { + case RouteTransportFlood, RouteFlood: + key.advertRoute = relayAirtimeAdvertFlood + case RouteDirect, RouteTransportDirect: + key.advertRoute = relayAirtimeAdvertZeroHop + } + return key +} + +func relayAirtimeBucketName(key relayAirtimeBucketKey) string { + name := payloadTypeNames[key.payloadType] + if name == "" { + name = "UNK" + } + if key.payloadType != PayloadADVERT { + return name + } + switch key.advertRoute { + case relayAirtimeAdvertFlood: + return name + " (flood)" + case relayAirtimeAdvertZeroHop: + return name + " (zero-hop)" + default: + return name + } +} + +// computeRelayAirtimeShare aggregates relay-airtime-share per payload_type, +// separating ADVERT rows by their flood and zero-hop route classes. // // Returns: // @@ -212,14 +263,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int s.mu.RLock() defer s.mu.RUnlock() - ptNames := payloadTypeNames preset := s.resolveLoRaPreset() type bucket struct { count int score int64 // sum of ToA(payload) × relays, in nanoseconds } - buckets := make(map[int]*bucket) + buckets := make(map[relayAirtimeBucketKey]*bucket) seenHash := make(map[string]bool, len(s.packets)) totalCount := 0 var totalScore int64 @@ -240,11 +290,11 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } seenHash[tx.Hash] = true } - pt := *tx.PayloadType - b := buckets[pt] + key := relayAirtimeKey(tx) + b := buckets[key] if b == nil { b = &bucket{} - buckets[pt] = b + buckets[key] = b } b.count++ totalCount++ @@ -261,11 +311,8 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } rows := make([]map[string]interface{}, 0, len(buckets)) - for pt, b := range buckets { - name := ptNames[pt] - if name == "" { - name = "UNK" - } + for key, b := range buckets { + name := relayAirtimeBucketName(key) var countPct, airtimePct float64 if totalCount > 0 { countPct = float64(b.count) / float64(totalCount) * 100.0 @@ -275,7 +322,7 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } rows = append(rows, map[string]interface{}{ "payload_type": name, - "type": pt, + "type": key.payloadType, "count": b.count, "count_pct": countPct, "score": b.score, diff --git a/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index 62ffb2bf3..a1b41d4eb 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "math" "strings" "testing" @@ -156,6 +157,80 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) { } } +// TestRelayAirtimeShare_SplitsAdvertRouteClasses is the regression gate for +// Kpa-clawbot/CoreScope#2041. MeshCore firmware defines route types 0/1 as +// flood and 2/3 as direct; adverts using the direct route are zero-hop adverts. +// Legacy rows without a route type retain the historical ADVERT label, while +// non-advert payloads must remain aggregated solely by payload type. +func TestRelayAirtimeShare_SplitsAdvertRouteClasses(t *testing.T) { + makeTx := func(id, payloadType, routeType int, hash string) *StoreTx { + tx := makeRelayAirtimeTx(id, payloadType, 20, 1, hash) + tx.RouteType = &routeType + return tx + } + + transportFloodAdvert := makeTx(1, PayloadADVERT, 0, "advert-tf") + floodAdvert := makeTx(2, PayloadADVERT, 1, "advert-f") + directAdvert := makeTx(3, PayloadADVERT, 2, "advert-d") + transportDirectAdvert := makeTx(4, PayloadADVERT, 3, "advert-td") + legacyAdvert := makeRelayAirtimeTx(5, PayloadADVERT, 20, 1, "advert-legacy") + unknownRouteAdvert := makeTx(6, PayloadADVERT, 99, "advert-unknown") + floodACK := makeTx(7, PayloadACK, 0, "ack-f") + directACK := makeTx(8, PayloadACK, 2, "ack-d") + + packets := []*StoreTx{ + transportFloodAdvert, floodAdvert, directAdvert, transportDirectAdvert, + legacyAdvert, unknownRouteAdvert, floodACK, directACK, + } + store := newRelayAirtimeShareTestStore(packets) + for _, tx := range packets { + store.addToResolvedPubkeyIndex(tx.ID, []string{"relay"}) + } + + encoded, err := json.Marshal(store.computeRelayAirtimeShare(TimeWindow{})) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + var response struct { + Rows []struct { + PayloadType string `json:"payload_type"` + Type int `json:"type"` + Count int `json:"count"` + } `json:"rows"` + } + if err := json.Unmarshal(encoded, &response); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + counts := make(map[string]int, len(response.Rows)) + types := make(map[string]int, len(response.Rows)) + for _, row := range response.Rows { + counts[row.PayloadType] = row.Count + types[row.PayloadType] = row.Type + } + + wantCounts := map[string]int{ + "ADVERT (flood)": 2, + "ADVERT (zero-hop)": 2, + "ADVERT": 2, + "ACK": 2, + } + if len(counts) != len(wantCounts) { + t.Fatalf("bucket count = %d (%v), want %d (%v)", len(counts), counts, len(wantCounts), wantCounts) + } + for label, want := range wantCounts { + if got := counts[label]; got != want { + t.Errorf("%s count = %d, want %d (all rows: %v)", label, got, want, counts) + } + } + if types["ADVERT (flood)"] != PayloadADVERT || types["ADVERT (zero-hop)"] != PayloadADVERT || types["ADVERT"] != PayloadADVERT { + t.Errorf("advert bucket numeric types = %v, want all %d", types, PayloadADVERT) + } + if types["ACK"] != PayloadACK { + t.Errorf("ACK numeric type = %d, want %d", types["ACK"], PayloadACK) + } +} + // TestRelayAirtimeShare_ToAReplacesByteProxy is the issue #1768 acceptance // gate: airtime_pct must follow true LoRa Time-on-Air, NOT bytes. // From 0260c4c8a346bee3a01fb70d1930cadad99a06ee Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:06:51 +0200 Subject: [PATCH 2/7] test(analytics): harden relay airtime ADVERT split contract Pin the route-class contract for the Relay Airtime Share ADVERT split: - table tests for routes 0/1 (flood), 2/3 (zero-hop), NULL and out-of-range values (legacy ADVERT), and all non-ADVERT payload types staying unsplit on every route - totals, percentage denominators, hash dedup and deterministic ordering with all three ADVERT buckets present - single-class and empty datasets - the served API: ADVERT rows share numeric type 4, stay unique by payload_type, and the row shape is unchanged - Payload Type Mix keeps one combined ADVERT entry - frontend: renderRelayAirtimeDumbbell renders one row per API row when rows repeat numeric type (test-only export), wired into test-all.sh and the CI frontend step Document on computeRelayAirtimeShare that type can repeat across the ADVERT rows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + cmd/server/relay_airtime_share.go | 2 + .../relay_airtime_share_advert_split_test.go | 383 ++++++++++++++++++ public/analytics.js | 1 + test-all.sh | 1 + test-analytics-relay-airtime-dumbbell.js | 139 +++++++ 6 files changed, 527 insertions(+) create mode 100644 cmd/server/relay_airtime_share_advert_split_test.go create mode 100644 test-analytics-relay-airtime-dumbbell.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 108d2b970..7de7c14a5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,6 +170,7 @@ jobs: node test-a11y-1716-rf-range-btn-active.js node test-issue-1705-subpath-contrast.js node test-analytics-table-ids-unique.js + node test-analytics-relay-airtime-dumbbell.js node test-live-vcr-mode-contrast.js node test-issue-1770-mobile-row-clamp.js node test-a11y-axe-routes-coverage.js diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 08447a86c..57780811f 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -254,6 +254,8 @@ func relayAirtimeBucketName(key relayAirtimeBucketKey) string { // // { // "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc, +// where type is the numeric payload type and payload_type the row label; +// up to three ADVERT rows share type 4 and differ only by label, // "total_count": int, // "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets), // "window": window label, diff --git a/cmd/server/relay_airtime_share_advert_split_test.go b/cmd/server/relay_airtime_share_advert_split_test.go new file mode 100644 index 000000000..1c51e0392 --- /dev/null +++ b/cmd/server/relay_airtime_share_advert_split_test.go @@ -0,0 +1,383 @@ +package main + +import ( + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "testing" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/lora" +) + +// Route-class contract for the ADVERT split in Relay Airtime Share +// (Kpa-clawbot/CoreScope#2041). MeshCore firmware (src/Packet.h) defines the +// two low header bits as the route type: 0 transport-flood, 1 flood, 2 direct, +// 3 transport-direct. Every firmware advert sent over a direct route goes +// through Mesh::sendZeroHop (path_len 0), so direct adverts are zero-hop. +// NULL and any value outside 0..3 must fall back to the historical +// unsuffixed ADVERT bucket, and no other payload type may be split. + +func TestRelayAirtimeKey_RouteClassification(t *testing.T) { + cases := []struct { + name string + payloadType int + routeType *int + wantRoute relayAirtimeAdvertRoute + wantLabel string + }{ + {"advert transport flood", PayloadADVERT, intPtr(RouteTransportFlood), relayAirtimeAdvertFlood, "ADVERT (flood)"}, + {"advert flood", PayloadADVERT, intPtr(RouteFlood), relayAirtimeAdvertFlood, "ADVERT (flood)"}, + {"advert direct", PayloadADVERT, intPtr(RouteDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)"}, + {"advert transport direct", PayloadADVERT, intPtr(RouteTransportDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)"}, + {"advert NULL route", PayloadADVERT, nil, relayAirtimeAdvertUnknown, "ADVERT"}, + {"advert route 4", PayloadADVERT, intPtr(4), relayAirtimeAdvertUnknown, "ADVERT"}, + {"advert route 99", PayloadADVERT, intPtr(99), relayAirtimeAdvertUnknown, "ADVERT"}, + {"advert route -1", PayloadADVERT, intPtr(-1), relayAirtimeAdvertUnknown, "ADVERT"}, + {"advert route -2", PayloadADVERT, intPtr(-2), relayAirtimeAdvertUnknown, "ADVERT"}, + {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tx := &StoreTx{PayloadType: intPtr(tc.payloadType), RouteType: tc.routeType} + key := relayAirtimeKey(tx) + want := relayAirtimeBucketKey{payloadType: tc.payloadType, advertRoute: tc.wantRoute} + if key != want { + t.Fatalf("relayAirtimeKey = %+v, want %+v", key, want) + } + if got := relayAirtimeBucketName(key); got != tc.wantLabel { + t.Fatalf("relayAirtimeBucketName = %q, want %q", got, tc.wantLabel) + } + }) + } +} + +// Every non-ADVERT payload type, including unnamed ones, must map to exactly +// one bucket regardless of route, keeping the pre-split grouping. +func TestRelayAirtimeKey_NonAdvertPayloadsIgnoreRoute(t *testing.T) { + routes := []*int{nil, intPtr(0), intPtr(1), intPtr(2), intPtr(3), intPtr(7), intPtr(-1)} + for pt := 0; pt <= 15; pt++ { + if pt == PayloadADVERT { + continue + } + want := relayAirtimeBucketKey{payloadType: pt} + wantName := payloadTypeNames[pt] + if wantName == "" { + wantName = "UNK" + } + for _, rt := range routes { + key := relayAirtimeKey(&StoreTx{PayloadType: intPtr(pt), RouteType: rt}) + if key != want { + t.Fatalf("payload %d route %v: key = %+v, want %+v", pt, rt, key, want) + } + if got := relayAirtimeBucketName(key); got != wantName { + t.Fatalf("payload %d route %v: name = %q, want %q", pt, rt, got, wantName) + } + } + } +} + +type relayAirtimeSplitRow struct { + PayloadType string `json:"payload_type"` + Type int `json:"type"` + Count int `json:"count"` + CountPct float64 `json:"count_pct"` + Score int64 `json:"score"` + AirtimePct float64 `json:"airtime_pct"` +} + +type relayAirtimeSplitResponse struct { + Rows []relayAirtimeSplitRow `json:"rows"` + TotalCount int `json:"total_count"` + TotalScore int64 `json:"total_score"` +} + +type relayAirtimeSplitFixture struct { + payloadType int + route *int + bytes int + relays int + hash string +} + +// relayAirtimeSplitStore builds a store whose transmissions each have the +// fixture's route type and number of distinct relays. +func relayAirtimeSplitStore(fixtures []relayAirtimeSplitFixture) *PacketStore { + packets := make([]*StoreTx, 0, len(fixtures)) + for i, f := range fixtures { + tx := makeRelayAirtimeTx(i+1, f.payloadType, f.bytes, f.relays, f.hash) + tx.RouteType = f.route + packets = append(packets, tx) + } + store := newRelayAirtimeShareTestStore(packets) + for i, f := range fixtures { + if f.relays == 0 { + continue + } + pks := make([]string, f.relays) + for r := range pks { + pks[r] = "relay-" + zeroPad(r, 3) + } + store.addToResolvedPubkeyIndex(packets[i].ID, pks) + } + return store +} + +func relayAirtimeSplitScore(bytes, relays int) int64 { + return int64(lora.TimeOnAir(bytes, defaultLoRaPreset())) * int64(relays) +} + +// mixedAdvertFixtures holds all three ADVERT buckets, ACKs on both route +// classes, a GRP_TXT, an unnamed payload type, a zero-score ADVERT and a +// re-observed hash that must only be counted once. +func mixedAdvertFixtures() []relayAirtimeSplitFixture { + return []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(0), 120, 3, "adv-tf"}, + {PayloadADVERT, intPtr(1), 110, 4, "adv-f"}, + {PayloadADVERT, intPtr(1), 110, 4, "adv-f"}, // re-observation: same hash + {PayloadADVERT, intPtr(2), 100, 0, "adv-d"}, // zero-hop, never relayed + {PayloadADVERT, intPtr(3), 100, 1, "adv-td"}, + {PayloadADVERT, nil, 90, 2, "adv-null"}, + {PayloadADVERT, intPtr(7), 90, 1, "adv-unk"}, + {PayloadACK, intPtr(0), 10, 2, "ack-tf"}, + {PayloadACK, intPtr(2), 10, 1, "ack-d"}, + {PayloadGRP_TXT, intPtr(1), 40, 2, "grp"}, + {13, intPtr(1), 30, 1, "pt13"}, + } +} + +func TestRelayAirtimeShare_AdvertSplitTotalsAndDedup(t *testing.T) { + store := relayAirtimeSplitStore(mixedAdvertFixtures()) + result := store.computeRelayAirtimeShare(TimeWindow{}) + rows, ok := result["rows"].([]map[string]interface{}) + if !ok { + t.Fatalf("rows has type %T", result["rows"]) + } + + type want struct { + typ int + count int + score int64 + } + wants := map[string]want{ + "ADVERT (flood)": {PayloadADVERT, 2, relayAirtimeSplitScore(120, 3) + relayAirtimeSplitScore(110, 4)}, + "ADVERT (zero-hop)": {PayloadADVERT, 2, relayAirtimeSplitScore(100, 1)}, + "ADVERT": {PayloadADVERT, 2, relayAirtimeSplitScore(90, 2) + relayAirtimeSplitScore(90, 1)}, + "ACK": {PayloadACK, 2, relayAirtimeSplitScore(10, 2) + relayAirtimeSplitScore(10, 1)}, + "GRP_TXT": {PayloadGRP_TXT, 1, relayAirtimeSplitScore(40, 2)}, + "UNK": {13, 1, relayAirtimeSplitScore(30, 1)}, + } + var wantTotalCount int + var wantTotalScore int64 + for _, w := range wants { + wantTotalCount += w.count + wantTotalScore += w.score + } + if wantTotalCount != 10 { + t.Fatalf("fixture sanity: expected 10 distinct hashes, got %d", wantTotalCount) + } + + if got := result["total_count"].(int); got != wantTotalCount { + t.Errorf("total_count = %d, want %d (re-observed hash must count once)", got, wantTotalCount) + } + if got := result["total_score"].(int64); got != wantTotalScore { + t.Errorf("total_score = %d, want %d", got, wantTotalScore) + } + if len(rows) != len(wants) { + t.Fatalf("got %d rows, want %d: %+v", len(rows), len(wants), rows) + } + + seen := make(map[string]bool, len(rows)) + var sumCount int + var sumScore int64 + var sumCountPct, sumAirtimePct float64 + for _, r := range rows { + label := r["payload_type"].(string) + if seen[label] { + t.Fatalf("duplicate payload_type label %q", label) + } + seen[label] = true + w, ok := wants[label] + if !ok { + t.Fatalf("unexpected row %q", label) + } + count, score := r["count"].(int), r["score"].(int64) + countPct, airtimePct := r["count_pct"].(float64), r["airtime_pct"].(float64) + if r["type"].(int) != w.typ || count != w.count || score != w.score { + t.Errorf("%s: type=%v count=%d score=%d, want type=%d count=%d score=%d", + label, r["type"], count, score, w.typ, w.count, w.score) + } + if want := float64(w.count) / float64(wantTotalCount) * 100; math.Abs(countPct-want) > 1e-9 { + t.Errorf("%s: count_pct = %v, want %v", label, countPct, want) + } + if want := float64(w.score) / float64(wantTotalScore) * 100; math.Abs(airtimePct-want) > 1e-9 { + t.Errorf("%s: airtime_pct = %v, want %v", label, airtimePct, want) + } + sumCount += count + sumScore += score + sumCountPct += countPct + sumAirtimePct += airtimePct + } + if sumCount != wantTotalCount || sumScore != wantTotalScore { + t.Errorf("row sums count=%d score=%d do not match totals %d/%d", sumCount, sumScore, wantTotalCount, wantTotalScore) + } + if math.Abs(sumCountPct-100) > 1e-9 || math.Abs(sumAirtimePct-100) > 1e-9 { + t.Errorf("percentages sum to count=%v airtime=%v, want 100", sumCountPct, sumAirtimePct) + } + + // Rows are sorted airtime desc, count desc, label asc, so repeated + // computations over the same data must return identical ordering. + firstOrder := relayAirtimeRowLabels(rows) + for i := 0; i < 20; i++ { + again := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + if got := relayAirtimeRowLabels(again); !reflect.DeepEqual(got, firstOrder) { + t.Fatalf("row order not deterministic: %v vs %v", got, firstOrder) + } + } + if !sort.SliceIsSorted(rows, func(i, j int) bool { + return rows[i]["airtime_pct"].(float64) > rows[j]["airtime_pct"].(float64) + }) { + t.Errorf("rows not sorted by airtime_pct desc: %v", firstOrder) + } +} + +func relayAirtimeRowLabels(rows []map[string]interface{}) []string { + labels := make([]string, len(rows)) + for i, r := range rows { + labels[i] = r["payload_type"].(string) + } + return labels +} + +func TestRelayAirtimeShare_AdvertSplitSingleClassAndEmpty(t *testing.T) { + cases := []struct { + name string + fixtures []relayAirtimeSplitFixture + want []string + }{ + {"empty", nil, []string{}}, + {"flood only", []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(0), 100, 1, "a"}, + {PayloadADVERT, intPtr(1), 100, 1, "b"}, + }, []string{"ADVERT (flood)"}}, + {"zero-hop only", []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(2), 100, 1, "a"}, + {PayloadADVERT, intPtr(3), 100, 1, "b"}, + }, []string{"ADVERT (zero-hop)"}}, + {"legacy only", []relayAirtimeSplitFixture{ + {PayloadADVERT, nil, 100, 1, "a"}, + {PayloadADVERT, intPtr(42), 100, 1, "b"}, + }, []string{"ADVERT"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := relayAirtimeSplitStore(tc.fixtures).computeRelayAirtimeShare(TimeWindow{}) + rows := result["rows"].([]map[string]interface{}) + if got := relayAirtimeRowLabels(rows); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("labels = %v, want %v", got, tc.want) + } + if result["total_count"].(int) != len(tc.fixtures) { + t.Errorf("total_count = %v, want %d", result["total_count"], len(tc.fixtures)) + } + if len(rows) == 1 { + if rows[0]["count_pct"].(float64) != 100 || rows[0]["airtime_pct"].(float64) != 100 { + t.Errorf("single bucket should hold 100%%: %+v", rows[0]) + } + } + }) + } +} + +// The serialized API response is the contract the frontend and external +// clients see: the three ADVERT rows share numeric type 4 and are told apart +// by their payload_type label, and the row shape is unchanged. +func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + srv := NewServer(db, &Config{Port: 3000}, NewHub()) + srv.store = relayAirtimeSplitStore(mixedAdvertFixtures()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/analytics/relay-airtime-share", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var raw struct { + Rows []map[string]json.RawMessage `json:"rows"` + } + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode rows: %v", err) + } + wantFields := []string{"airtime_pct", "count", "count_pct", "payload_type", "score", "type"} + for _, r := range raw.Rows { + fields := make([]string, 0, len(r)) + for k := range r { + fields = append(fields, k) + } + sort.Strings(fields) + if !reflect.DeepEqual(fields, wantFields) { + t.Fatalf("row fields = %v, want %v", fields, wantFields) + } + } + + var resp relayAirtimeSplitResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + advertLabels := []string{} + labels := make(map[string]bool, len(resp.Rows)) + for _, r := range resp.Rows { + if labels[r.PayloadType] { + t.Fatalf("payload_type %q repeated; rows must be distinguishable by label", r.PayloadType) + } + labels[r.PayloadType] = true + if r.Type == PayloadADVERT { + advertLabels = append(advertLabels, r.PayloadType) + } + } + sort.Strings(advertLabels) + if want := []string{"ADVERT", "ADVERT (flood)", "ADVERT (zero-hop)"}; !reflect.DeepEqual(advertLabels, want) { + t.Fatalf("rows with type %d = %v, want %v", PayloadADVERT, advertLabels, want) + } + if resp.TotalCount != 10 { + t.Errorf("total_count = %d, want 10", resp.TotalCount) + } +} + +// Payload Type Mix (RF analytics) must keep a single combined ADVERT entry; +// the route split is specific to Relay Airtime Share. +func TestPayloadTypeMix_AdvertStaysCombined(t *testing.T) { + store := relayAirtimeSplitStore(mixedAdvertFixtures()) + result := store.computeAnalyticsRF("", "", TimeWindow{}) + encoded, err := json.Marshal(result["payloadTypes"]) + if err != nil { + t.Fatalf("marshal payloadTypes: %v", err) + } + var entries []struct { + Type int `json:"type"` + Name string `json:"name"` + Count int `json:"count"` + } + if err := json.Unmarshal(encoded, &entries); err != nil { + t.Fatalf("decode payloadTypes: %v", err) + } + var adverts []string + for _, e := range entries { + if e.Type == PayloadADVERT { + adverts = append(adverts, e.Name) + if e.Name != "ADVERT" || e.Count != 6 { + t.Errorf("ADVERT entry = %+v, want name ADVERT count 6", e) + } + } + } + if len(adverts) != 1 { + t.Fatalf("Payload Type Mix has %d ADVERT entries %v, want exactly 1", len(adverts), adverts) + } +} diff --git a/public/analytics.js b/public/analytics.js index 7f6bfad8b..f5333450a 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -3065,6 +3065,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsChannelTbodyHtml = channelTbodyHtml; window._analyticsChannelTheadHtml = channelTheadHtml; window._analyticsRfNFColumnChart = rfNFColumnChart; + window._analyticsRenderRelayAirtimeDumbbell = renderRelayAirtimeDumbbell; window._analyticsRenderMultiByteCapability = renderMultiByteCapability; window._analyticsRenderMultiByteAdopters = renderMultiByteAdopters; window._analyticsHashStatCardsHtml = hashStatCardsHtml; diff --git a/test-all.sh b/test-all.sh index 0e63d460f..bd1abe219 100755 --- a/test-all.sh +++ b/test-all.sh @@ -45,6 +45,7 @@ node test-analytics-channels-integration.js node test-analytics-foreign-traffic-tab.js node test-analytics-nodes-without-scope.js node test-analytics-table-ids-unique.js +node test-analytics-relay-airtime-dumbbell.js node test-live-vcr-mode-contrast.js node test-observers-headings.js node test-issue-1789-observer-firmware-cols.js diff --git a/test-analytics-relay-airtime-dumbbell.js b/test-analytics-relay-airtime-dumbbell.js new file mode 100644 index 000000000..4e6de43dd --- /dev/null +++ b/test-analytics-relay-airtime-dumbbell.js @@ -0,0 +1,139 @@ +/** + * Relay Airtime Share dumbbell: one rendered row per API row. + * + * /api/analytics/relay-airtime-share splits ADVERT into "ADVERT (flood)", + * "ADVERT (zero-hop)" and the legacy "ADVERT" bucket, and all three rows + * carry the same numeric `type` (4). The renderer must therefore treat each + * row independently: keying rows by `type` would collapse the split. + * + * Drives the real renderRelayAirtimeDumbbell from public/analytics.js. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeCtx() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: () => {}, + RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' }, + onWS: () => {}, offWS: () => {}, connectWS: () => {}, + invalidateApiCache: () => {}, initTabBar: () => {}, + IATA_COORDS_GEO: {}, + }; + vm.createContext(ctx); + const load = (file) => { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + }; + load('public/payload-labels.js'); + load('public/roles.js'); + load('public/app.js'); + try { load('public/analytics.js'); } catch (_) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +// Shape returned by the API for a window containing all three ADVERT buckets. +const splitResponse = { + rows: [ + { payload_type: 'ADVERT (flood)', type: 4, count: 30, count_pct: 30, score: 6e9, airtime_pct: 60 }, + { payload_type: 'ACK', type: 3, count: 50, count_pct: 50, score: 2e9, airtime_pct: 20 }, + { payload_type: 'ADVERT (zero-hop)', type: 4, count: 15, count_pct: 15, score: 1.5e9, airtime_pct: 15 }, + { payload_type: 'ADVERT', type: 4, count: 5, count_pct: 5, score: 5e8, airtime_pct: 5 }, + ], + total_count: 100, + total_score: 1e10, + preset: { freq_hz: 869.6e6, bw_khz: 62.5, sf: 8, cr: 5, preamble: 16 }, +}; + +// Split rendered HTML into per-row chunks (each starts at a dumbbell-row div). +function rowsOf(html) { + return html.split('
(row.match(/class="dumbbell-label"[^>]*>([^<]*) (row.match(/^ title="([^"]*)"/) || [])[1] || ''; +const airtimeLeftOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*left:([0-9.]+)%/) || [])[1]; + +console.log('\n=== analytics.js: renderRelayAirtimeDumbbell ==='); +const ctx = makeCtx(); +const render = ctx._analyticsRenderRelayAirtimeDumbbell; + +test('renderRelayAirtimeDumbbell is exposed for tests', () => { + assert.strictEqual(typeof render, 'function', 'window._analyticsRenderRelayAirtimeDumbbell must be a function'); +}); + +if (typeof render === 'function') { + test('ADVERT rows sharing numeric type 4 each render as their own row, in API order', () => { + const rows = rowsOf(render(splitResponse)); + assert.deepStrictEqual(rows.map(labelOf), ['ADVERT (flood)', 'ACK', 'ADVERT (zero-hop)', 'ADVERT']); + }); + + test('each row carries its own tooltip, count and airtime position', () => { + const rows = rowsOf(render(splitResponse)); + splitResponse.rows.forEach((r, i) => { + const tip = titleOf(rows[i]); + assert.ok(tip.startsWith(r.payload_type + '\n'), `row ${i} tooltip starts with ${JSON.stringify(tip.slice(0, 30))}`); + assert.ok(tip.includes('Count: ' + r.count.toLocaleString() + ' (' + r.count_pct.toFixed(2) + '%)'), `row ${i} tooltip count`); + assert.ok(tip.includes('Airtime: ' + r.airtime_pct.toFixed(2) + '%'), `row ${i} tooltip airtime`); + assert.strictEqual(airtimeLeftOf(rows[i]), r.airtime_pct.toFixed(3), `row ${i} airtime dot position`); + }); + }); + + test('rendered chart introduces no element ids', () => { + const html = render(splitResponse); + assert.ok(!/\sid="/.test(html), 'dumbbell markup must not contain id attributes'); + }); + + test('a single ADVERT class renders one row', () => { + const only = { rows: [{ payload_type: 'ADVERT (zero-hop)', type: 4, count: 3, count_pct: 100, score: 1e9, airtime_pct: 100 }], total_count: 3, total_score: 1e9 }; + assert.deepStrictEqual(rowsOf(render(only)).map(labelOf), ['ADVERT (zero-hop)']); + }); + + test('empty rows render the empty-state message', () => { + const html = render({ rows: [], total_count: 0, total_score: 0 }); + assert.ok(html.includes('No relay-airtime data in this window.')); + assert.strictEqual(rowsOf(html).length, 0); + }); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); From a2e726cafad8e75db3582b53322ee5728bbb9c69 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:15:07 +0200 Subject: [PATCH 3/7] fix(analytics): stable relay airtime order for tied UNK rows Independent review follow-ups for the relay airtime ADVERT split: - break sort ties on the numeric payload type after the label, since unnamed payload types all share the "UNK" label and previously kept Go map iteration order when airtime and count tied - note that relayAirtimeKey expects a non-nil payload type - assert that ADVERT rows sharing numeric type 4 get distinct airtime colours in the dumbbell renderer Co-Authored-By: Claude Opus 5.5 --- cmd/server/relay_airtime_share.go | 14 +++++++++---- .../relay_airtime_share_advert_split_test.go | 20 +++++++++++++++++++ test-analytics-relay-airtime-dumbbell.js | 8 ++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 57780811f..fc62c75cf 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -214,7 +214,7 @@ type relayAirtimeBucketKey struct { // relayAirtimeKey keeps non-ADVERT aggregation unchanged while separating the // two ADVERT behaviours defined by MeshCore's route bits. Unknown/legacy route // values deliberately keep the historical unsuffixed ADVERT bucket rather than -// being silently assigned to either behaviour. +// being silently assigned to either behaviour. tx.PayloadType must be non-nil. func relayAirtimeKey(tx *StoreTx) relayAirtimeBucketKey { key := relayAirtimeBucketKey{payloadType: *tx.PayloadType} if key.payloadType != PayloadADVERT || tx.RouteType == nil { @@ -332,8 +332,8 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int }) } - // Sort descending by airtime_pct; tiebreak count desc, then name asc - // for deterministic ordering. + // Sort descending by airtime_pct; tiebreak count desc, then name asc, + // then numeric type asc for deterministic ordering. sort.SliceStable(rows, func(i, j int) bool { ai, _ := rows[i]["airtime_pct"].(float64) aj, _ := rows[j]["airtime_pct"].(float64) @@ -347,7 +347,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } ni, _ := rows[i]["payload_type"].(string) nj, _ := rows[j]["payload_type"].(string) - return ni < nj + if ni != nj { + return ni < nj + } + // Unnamed payload types all share the "UNK" label. + ti, _ := rows[i]["type"].(int) + tj, _ := rows[j]["type"].(int) + return ti < tj }) label := "" diff --git a/cmd/server/relay_airtime_share_advert_split_test.go b/cmd/server/relay_airtime_share_advert_split_test.go index 1c51e0392..81d890096 100644 --- a/cmd/server/relay_airtime_share_advert_split_test.go +++ b/cmd/server/relay_airtime_share_advert_split_test.go @@ -381,3 +381,23 @@ func TestPayloadTypeMix_AdvertStaysCombined(t *testing.T) { t.Fatalf("Payload Type Mix has %d ADVERT entries %v, want exactly 1", len(adverts), adverts) } } + +// Unnamed payload types share the "UNK" label, so equal-airtime UNK rows +// need the numeric type as the final tiebreak to keep a stable order. +func TestRelayAirtimeShare_UnknownPayloadTieOrderIsStable(t *testing.T) { + store := relayAirtimeSplitStore([]relayAirtimeSplitFixture{ + {14, intPtr(1), 30, 1, "pt14"}, + {12, intPtr(1), 30, 1, "pt12"}, + {13, intPtr(1), 30, 1, "pt13"}, + }) + for i := 0; i < 50; i++ { + rows := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + got := make([]int, len(rows)) + for j, r := range rows { + got[j] = r["type"].(int) + } + if !reflect.DeepEqual(got, []int{12, 13, 14}) { + t.Fatalf("run %d: UNK row types = %v, want [12 13 14]", i, got) + } + } +} diff --git a/test-analytics-relay-airtime-dumbbell.js b/test-analytics-relay-airtime-dumbbell.js index 4e6de43dd..d788b7553 100644 --- a/test-analytics-relay-airtime-dumbbell.js +++ b/test-analytics-relay-airtime-dumbbell.js @@ -91,6 +91,7 @@ function rowsOf(html) { } const labelOf = (row) => (row.match(/class="dumbbell-label"[^>]*>([^<]*) (row.match(/^ title="([^"]*)"/) || [])[1] || ''; +const airtimeColourOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*background:([^;"]+)/) || [])[1]; const airtimeLeftOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*left:([0-9.]+)%/) || [])[1]; console.log('\n=== analytics.js: renderRelayAirtimeDumbbell ==='); @@ -118,6 +119,13 @@ if (typeof render === 'function') { }); }); + test('ADVERT rows sharing numeric type get distinct airtime colours', () => { + const rows = rowsOf(render(splitResponse)); + const colours = rows.filter(r => /ADVERT/.test(labelOf(r))).map(airtimeColourOf); + assert.strictEqual(colours.length, 3); + assert.strictEqual(new Set(colours).size, 3, `colours: ${colours.join(', ')}`); + }); + test('rendered chart introduces no element ids', () => { const html = render(splitResponse); assert.ok(!/\sid="/.test(html), 'dumbbell markup must not contain id attributes'); From daeec639558592ad43230bbf3e9a9db87086b8ad Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:44:40 +0200 Subject: [PATCH 4/7] feat(analytics): add machine-readable route_class to relay airtime rows Relay Airtime Share rows gain an additive route_class field so clients no longer have to parse the payload_type display label to tell the three ADVERT rows (all type 4) apart: - "flood" for ADVERT route_type 0/1 - "zero_hop" for ADVERT route_type 2/3 - "legacy" for ADVERT with NULL, negative or out-of-range route_type - null on every non-ADVERT row (type, route_class) identifies each row. Existing fields and labels are unchanged. The values are constants independent of the display labels. Document the endpoint in openapi.go (fields, route_class values, row key, window/from/to) and drop it from openapi_known_gaps.json. The route class is the route_type stored on the transmission, i.e. that of the first observation the ingestor inserted for the content hash; a test pins how a hash seen on both route classes is reported. Co-Authored-By: Claude Opus 5.5 --- cmd/server/openapi.go | 6 ++ cmd/server/openapi_known_gaps.json | 1 - cmd/server/relay_airtime_share.go | 41 ++++++- .../relay_airtime_share_advert_split_test.go | 101 ++++++++++++++++-- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b454f3724..1e8314d9c 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -122,6 +122,12 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"}, "GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"}, "GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"}, + "GET /api/analytics/relay-airtime-share": {Summary: "Relay airtime share per payload type", Description: "Overview tab's Relay Airtime Share. Each distinct packet (deduplicated by content hash) scores LoRa Time-on-Air of its raw bytes × distinct repeaters that relayed it, under the assumed preset returned in preset (config analytics.loraPreset); originator TX is excluded. rows[] = {payload_type, type, route_class, count, count_pct, score, airtime_pct}, sorted by airtime_pct desc. type is the numeric payload type; payload_type is a display label and may change. ADVERT is split into up to three rows that all have type 4: route_class \"flood\" (route_type 0/1, label \"ADVERT (flood)\"), \"zero_hop\" (route_type 2/3, label \"ADVERT (zero-hop)\") and \"legacy\" (NULL or out-of-range route_type, label \"ADVERT\"). route_class is null on every other row, so key rows by (type, route_class), never by type alone. The route is the one stored on the transmission: the first observation the ingestor inserted for that content hash, even if later observations of the same hash arrive on the other route class (e.g. a contact re-shared as a zero-hop advert). count_pct and airtime_pct are shares of total_count and total_score (nanoseconds). Payload Type Mix (/api/analytics/rf payloadTypes) is not split. Cached in the RF analytics cache (60s default).", Tag: "analytics", + QueryParams: []paramMeta{ + {Name: "window", Description: "Relative window: 1h, 24h/1d, 3d, 7d/1w or 30d (default: all loaded packets)", Type: "string"}, + {Name: "from", Description: "RFC3339 start; overrides window", Type: "string"}, + {Name: "to", Description: "RFC3339 end; overrides window", Type: "string"}, + }}, "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages normally carry an anonymous session token, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap, each with an AirtimeMs field — LoRa Time-on-Air × distinct relaying repeaters, same formula as the Overview tab's Relay Airtime Share, omitted in DB-only mode), and any senders who explicitly shared their own position (some clients append plaintext \",\" after the token — a deliberate choice by that sender, confirmed empirically, not something CoreScope infers). Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, diff --git a/cmd/server/openapi_known_gaps.json b/cmd/server/openapi_known_gaps.json index aa60ec929..128fec18c 100644 --- a/cmd/server/openapi_known_gaps.json +++ b/cmd/server/openapi_known_gaps.json @@ -4,7 +4,6 @@ "routes": [ "/api/admin/prune-geo-filter", "/api/admin/prune-geo-filter/status", - "/api/analytics/relay-airtime-share", "/api/analytics/roles", "/api/config/areas", "/api/config/areas/polygons", diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index fc62c75cf..d7cc90326 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -247,15 +247,49 @@ func relayAirtimeBucketName(key relayAirtimeBucketKey) string { } } +// Machine-readable ADVERT route classes, returned as route_class. They are +// part of the API contract and deliberately independent of the display +// labels built by relayAirtimeBucketName. +const ( + relayAirtimeRouteClassFlood = "flood" + relayAirtimeRouteClassZeroHop = "zero_hop" + relayAirtimeRouteClassLegacy = "legacy" +) + +// relayAirtimeRouteClass returns the route_class value for a row: one of the +// constants above for ADVERT rows and nil (JSON null) for every other payload +// type, so (type, route_class) identifies each row. +func relayAirtimeRouteClass(key relayAirtimeBucketKey) interface{} { + if key.payloadType != PayloadADVERT { + return nil + } + switch key.advertRoute { + case relayAirtimeAdvertFlood: + return relayAirtimeRouteClassFlood + case relayAirtimeAdvertZeroHop: + return relayAirtimeRouteClassZeroHop + default: + return relayAirtimeRouteClassLegacy + } +} + // computeRelayAirtimeShare aggregates relay-airtime-share per payload_type, // separating ADVERT rows by their flood and zero-hop route classes. // +// The route class is the route_type stored on the transmission, which is the +// route of the first observation the ingestor inserted for that content hash. +// The content hash ignores route bits, so a contact re-shared as a zero-hop +// advert has the same hash as the original flood advert; later observations +// never change the stored route (see cmd/ingestor InsertTransmission). +// // Returns: // // { -// "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc, -// where type is the numeric payload type and payload_type the row label; -// up to three ADVERT rows share type 4 and differ only by label, +// "rows": [{payload_type, type, route_class, count, count_pct, score, airtime_pct}, ...] +// sorted by airtime_pct desc, where type is the numeric payload type, +// payload_type the display label and route_class "flood" / "zero_hop" / +// "legacy" on ADVERT rows and null otherwise; up to three ADVERT rows +// share type 4, and (type, route_class) identifies each row, // "total_count": int, // "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets), // "window": window label, @@ -325,6 +359,7 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int rows = append(rows, map[string]interface{}{ "payload_type": name, "type": key.payloadType, + "route_class": relayAirtimeRouteClass(key), "count": b.count, "count_pct": countPct, "score": b.score, diff --git a/cmd/server/relay_airtime_share_advert_split_test.go b/cmd/server/relay_airtime_share_advert_split_test.go index 81d890096..f7e9272c5 100644 --- a/cmd/server/relay_airtime_share_advert_split_test.go +++ b/cmd/server/relay_airtime_share_advert_split_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "math" "net/http" "net/http/httptest" @@ -28,17 +29,18 @@ func TestRelayAirtimeKey_RouteClassification(t *testing.T) { routeType *int wantRoute relayAirtimeAdvertRoute wantLabel string + wantClass interface{} // route_class: string for ADVERT rows, nil otherwise }{ - {"advert transport flood", PayloadADVERT, intPtr(RouteTransportFlood), relayAirtimeAdvertFlood, "ADVERT (flood)"}, - {"advert flood", PayloadADVERT, intPtr(RouteFlood), relayAirtimeAdvertFlood, "ADVERT (flood)"}, - {"advert direct", PayloadADVERT, intPtr(RouteDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)"}, - {"advert transport direct", PayloadADVERT, intPtr(RouteTransportDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)"}, - {"advert NULL route", PayloadADVERT, nil, relayAirtimeAdvertUnknown, "ADVERT"}, - {"advert route 4", PayloadADVERT, intPtr(4), relayAirtimeAdvertUnknown, "ADVERT"}, - {"advert route 99", PayloadADVERT, intPtr(99), relayAirtimeAdvertUnknown, "ADVERT"}, - {"advert route -1", PayloadADVERT, intPtr(-1), relayAirtimeAdvertUnknown, "ADVERT"}, - {"advert route -2", PayloadADVERT, intPtr(-2), relayAirtimeAdvertUnknown, "ADVERT"}, - {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK"}, + {"advert transport flood", PayloadADVERT, intPtr(RouteTransportFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, + {"advert flood", PayloadADVERT, intPtr(RouteFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, + {"advert direct", PayloadADVERT, intPtr(RouteDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)", "zero_hop"}, + {"advert transport direct", PayloadADVERT, intPtr(RouteTransportDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)", "zero_hop"}, + {"advert NULL route", PayloadADVERT, nil, relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route 4", PayloadADVERT, intPtr(4), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route 99", PayloadADVERT, intPtr(99), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route -1", PayloadADVERT, intPtr(-1), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route -2", PayloadADVERT, intPtr(-2), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK", nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -51,6 +53,9 @@ func TestRelayAirtimeKey_RouteClassification(t *testing.T) { if got := relayAirtimeBucketName(key); got != tc.wantLabel { t.Fatalf("relayAirtimeBucketName = %q, want %q", got, tc.wantLabel) } + if got := relayAirtimeRouteClass(key); got != tc.wantClass { + t.Fatalf("relayAirtimeRouteClass = %#v, want %#v", got, tc.wantClass) + } }) } } @@ -76,6 +81,9 @@ func TestRelayAirtimeKey_NonAdvertPayloadsIgnoreRoute(t *testing.T) { if got := relayAirtimeBucketName(key); got != wantName { t.Fatalf("payload %d route %v: name = %q, want %q", pt, rt, got, wantName) } + if got := relayAirtimeRouteClass(key); got != nil { + t.Fatalf("payload %d route %v: route_class = %#v, want nil", pt, rt, got) + } } } } @@ -87,6 +95,7 @@ type relayAirtimeSplitRow struct { CountPct float64 `json:"count_pct"` Score int64 `json:"score"` AirtimePct float64 `json:"airtime_pct"` + RouteClass *string `json:"route_class"` } type relayAirtimeSplitResponse struct { @@ -315,7 +324,7 @@ func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("decode rows: %v", err) } - wantFields := []string{"airtime_pct", "count", "count_pct", "payload_type", "score", "type"} + wantFields := []string{"airtime_pct", "count", "count_pct", "payload_type", "route_class", "score", "type"} for _, r := range raw.Rows { fields := make([]string, 0, len(r)) for k := range r { @@ -327,13 +336,43 @@ func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { } } + // route_class is the machine-readable ADVERT identity; it is JSON null on + // every other row, never absent. + wantClass := map[string]string{ + "ADVERT (flood)": `"flood"`, + "ADVERT (zero-hop)": `"zero_hop"`, + "ADVERT": `"legacy"`, + "ACK": `null`, + "GRP_TXT": `null`, + "UNK": `null`, + } + for _, r := range raw.Rows { + var label string + if err := json.Unmarshal(r["payload_type"], &label); err != nil { + t.Fatalf("decode payload_type: %v", err) + } + if got := string(r["route_class"]); got != wantClass[label] { + t.Errorf("%s: route_class = %s, want %s", label, got, wantClass[label]) + } + } + var resp relayAirtimeSplitResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } advertLabels := []string{} labels := make(map[string]bool, len(resp.Rows)) + identities := make(map[string]bool, len(resp.Rows)) for _, r := range resp.Rows { + class := "" + if r.RouteClass != nil { + class = *r.RouteClass + } + id := fmt.Sprintf("%d/%s", r.Type, class) + if identities[id] { + t.Fatalf("(type, route_class) %s repeated; it must identify each row", id) + } + identities[id] = true if labels[r.PayloadType] { t.Fatalf("payload_type %q repeated; rows must be distinguishable by label", r.PayloadType) } @@ -401,3 +440,43 @@ func TestRelayAirtimeShare_UnknownPayloadTieOrderIsStable(t *testing.T) { } } } + +// A contact re-shared as a zero-hop advert (shareContactZeroHop) has the same +// content hash as the original flood advert, so both land on one transmission +// whose route_type is that of the first observation the ingestor inserted +// (pinned in cmd/ingestor TestInsertTransmission_AdvertRouteIsFirstIngested). +// Relay Airtime Share classifies by that stored route and scores relays from +// every observation. When the zero-hop re-share was inserted first, the flood +// observations' relays are therefore reported on the zero_hop row. This test +// pins that documented first-ingested contract so changing it is deliberate. +func TestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute(t *testing.T) { + cases := []struct { + name string + stored int + wantLabel string + wantClass string + }{ + {"flood observation inserted first", RouteFlood, "ADVERT (flood)", "flood"}, + {"zero-hop re-share inserted first", RouteTransportDirect, "ADVERT (zero-hop)", "zero_hop"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Relays come from the flood observations' resolved paths; the + // zero-hop observation contributes none. + store := relayAirtimeSplitStore([]relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(tc.stored), 110, 2, "mixed"}, + }) + rows := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1: %+v", len(rows), rows) + } + r := rows[0] + if r["payload_type"] != tc.wantLabel || r["route_class"] != tc.wantClass { + t.Fatalf("row = %v/%v, want %s/%s", r["payload_type"], r["route_class"], tc.wantLabel, tc.wantClass) + } + if r["count"].(int) != 1 || r["score"].(int64) != relayAirtimeSplitScore(110, 2) { + t.Errorf("count=%v score=%v, want 1 and the flood relays' score", r["count"], r["score"]) + } + }) + } +} From 4e11bd398ccb7b373f18cd54911bd590c1fd022f Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:44:40 +0200 Subject: [PATCH 5/7] feat(analytics): expose relay airtime row identity in the dumbbell Each dumbbell row carries data-payload-type and, for ADVERT rows, data-route-class from the API's route_class. Rows stay rendered and coloured by position; the attributes give tooling and tests a stable (type, route_class) identity that does not depend on the display label or on type being unique. Responses without route_class (older servers) render unchanged, just without data-route-class. Co-Authored-By: Claude Opus 5.5 --- public/analytics.js | 6 ++++- test-analytics-relay-airtime-dumbbell.js | 33 +++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index f5333450a..c482a8ffc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -567,6 +567,10 @@ var apct = Number(r.airtime_pct || 0); var score = Number(r.score || 0); var color = palette[i % palette.length]; + // Rows are rendered positionally; (type, route_class) is the stable row + // identity. The three ADVERT rows share type 4, so type alone is not. + var identityAttrs = ' data-payload-type="' + esc(String(Number(r.type))) + '"' + + (typeof r.route_class === 'string' ? ' data-route-class="' + esc(r.route_class) + '"' : ''); var loPct = Math.min(cpct, apct); var hiPct = Math.max(cpct, apct); // Tooltip per row — payload_type, count %, count N, airtime %, raw score, caveat. @@ -582,7 +586,7 @@ 'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' + 'Airtime: ' + apct.toFixed(2) + '% (score ' + scoreStr + ' · airtime × repeaters)\n' + 'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'; - html += '
' + + html += '
' + '
' + esc(name) + '
' + '
' + '
' + diff --git a/test-analytics-relay-airtime-dumbbell.js b/test-analytics-relay-airtime-dumbbell.js index d788b7553..262af5cce 100644 --- a/test-analytics-relay-airtime-dumbbell.js +++ b/test-analytics-relay-airtime-dumbbell.js @@ -75,10 +75,10 @@ function makeCtx() { // Shape returned by the API for a window containing all three ADVERT buckets. const splitResponse = { rows: [ - { payload_type: 'ADVERT (flood)', type: 4, count: 30, count_pct: 30, score: 6e9, airtime_pct: 60 }, - { payload_type: 'ACK', type: 3, count: 50, count_pct: 50, score: 2e9, airtime_pct: 20 }, - { payload_type: 'ADVERT (zero-hop)', type: 4, count: 15, count_pct: 15, score: 1.5e9, airtime_pct: 15 }, - { payload_type: 'ADVERT', type: 4, count: 5, count_pct: 5, score: 5e8, airtime_pct: 5 }, + { payload_type: 'ADVERT (flood)', type: 4, route_class: 'flood', count: 30, count_pct: 30, score: 6e9, airtime_pct: 60 }, + { payload_type: 'ACK', type: 3, route_class: null, count: 50, count_pct: 50, score: 2e9, airtime_pct: 20 }, + { payload_type: 'ADVERT (zero-hop)', type: 4, route_class: 'zero_hop', count: 15, count_pct: 15, score: 1.5e9, airtime_pct: 15 }, + { payload_type: 'ADVERT', type: 4, route_class: 'legacy', count: 5, count_pct: 5, score: 5e8, airtime_pct: 5 }, ], total_count: 100, total_score: 1e10, @@ -92,6 +92,8 @@ function rowsOf(html) { const labelOf = (row) => (row.match(/class="dumbbell-label"[^>]*>([^<]*) (row.match(/^ title="([^"]*)"/) || [])[1] || ''; const airtimeColourOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*background:([^;"]+)/) || [])[1]; +const attrOf = (row, name) => { const m = row.match(new RegExp('^[^>]*\\s' + name + '="([^"]*)"')); return m ? m[1] : null; }; +const identityOf = (row) => attrOf(row, 'data-payload-type') + '/' + attrOf(row, 'data-route-class'); const airtimeLeftOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*left:([0-9.]+)%/) || [])[1]; console.log('\n=== analytics.js: renderRelayAirtimeDumbbell ==='); @@ -126,6 +128,29 @@ if (typeof render === 'function') { assert.strictEqual(new Set(colours).size, 3, `colours: ${colours.join(', ')}`); }); + test('each row exposes its stable (type, route_class) identity', () => { + const rows = rowsOf(render(splitResponse)); + assert.deepStrictEqual(rows.map(identityOf), ['4/flood', '3/null', '4/zero_hop', '4/legacy']); + assert.strictEqual(new Set(rows.map(identityOf)).size, rows.length, 'row identities must be unique'); + }); + + test('identity comes from route_class, not from the display label', () => { + const relabelled = Object.assign({}, splitResponse, { + rows: splitResponse.rows.map(r => Object.assign({}, r, { payload_type: 'Advert #' + r.count })), + }); + const rows = rowsOf(render(relabelled)); + assert.deepStrictEqual(rows.map(identityOf), ['4/flood', '3/null', '4/zero_hop', '4/legacy']); + }); + + test('responses without route_class (older servers) still render every row', () => { + const legacy = Object.assign({}, splitResponse, { + rows: splitResponse.rows.map(r => { const c = Object.assign({}, r); delete c.route_class; return c; }), + }); + const rows = rowsOf(render(legacy)); + assert.deepStrictEqual(rows.map(labelOf), ['ADVERT (flood)', 'ACK', 'ADVERT (zero-hop)', 'ADVERT']); + assert.deepStrictEqual(rows.map(r => attrOf(r, 'data-route-class')), [null, null, null, null]); + }); + test('rendered chart introduces no element ids', () => { const html = render(splitResponse); assert.ok(!/\sid="/.test(html), 'dumbbell markup must not contain id attributes'); From 9fefb14238c54427a54c7504e5fe8d0696642721 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:44:40 +0200 Subject: [PATCH 6/7] test(ingestor): pin first-ingested route for adverts seen on both routes A contact re-shared with shareContactZeroHop is the same advert payload sent as TRANSPORT_DIRECT, so ComputeContentHash gives it the same hash as the original flood advert. Drive the real DecodePacket, BuildPacketData and InsertTransmission with both variants in both orders and pin the current contract: one transmission, two observations, route_type and raw_hex from the first inserted observation, first_seen moved back to the earliest receive time. Co-Authored-By: Claude Opus 5.5 --- .../advert_route_first_ingested_test.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 cmd/ingestor/advert_route_first_ingested_test.go diff --git a/cmd/ingestor/advert_route_first_ingested_test.go b/cmd/ingestor/advert_route_first_ingested_test.go new file mode 100644 index 000000000..de9532572 --- /dev/null +++ b/cmd/ingestor/advert_route_first_ingested_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "strings" + "testing" +) + +// The same ADVERT can reach the ingestor on both route classes: MeshCore's +// BaseChatMesh::shareContactZeroHop re-sends a stored advert unchanged as a +// TRANSPORT_DIRECT zero-hop packet. ComputeContentHash ignores the route bits, +// transport codes and path, so both variants share one transmissions row, and +// InsertTransmission never rewrites route_type or raw_hex for an existing hash. +// The stored route is therefore that of the first observation *inserted*, not +// the earliest received. Relay Airtime Share's ADVERT route classes are built +// on this contract; these tests pin it so any change to it is deliberate. + +var firstIngestedAdvertPayload = "" + + strings.Repeat("86", 32) + // public key + "00e1f565" + // timestamp + strings.Repeat("00", 64) + // signature (not validated here) + "81" + "50523836" // app data: flags + name "PR86" + +var ( + // ROUTE_TYPE_FLOOD (header 0x11 = ADVERT<<2 | 1), two 1-byte relay hops. + firstIngestedFloodRaw = "11" + "02" + "a1b2" + firstIngestedAdvertPayload + // ROUTE_TYPE_TRANSPORT_DIRECT (0x13), transport codes {0,0}, path_len 0: + // exactly what shareContactZeroHop sends. + firstIngestedZeroHopRaw = "13" + "00000000" + "00" + firstIngestedAdvertPayload +) + +type advertObservation struct { + raw, observer, rxTime string +} + +func advertPacketData(t *testing.T, o advertObservation) *PacketData { + t.Helper() + decoded, err := DecodePacket(o.raw, nil, false) + if err != nil { + t.Fatalf("decode %s: %v", o.raw[:10], err) + } + pd := BuildPacketData(&MQTTPacketMessage{Raw: o.raw}, decoded, o.observer, "", nil) + pd.Timestamp = o.rxTime + return pd +} + +type storedAdvert struct { + rows int + routeType int + rawHex string + firstSeen string + observations int +} + +func ingestAdverts(t *testing.T, obs ...advertObservation) (storedAdvert, string) { + t.Helper() + s := newTestStore(t) + var hash string + for i, o := range obs { + pd := advertPacketData(t, o) + if pd.PayloadType != PayloadADVERT { + t.Fatalf("observation %d decoded as payload %d, want ADVERT", i, pd.PayloadType) + } + if i == 0 { + hash = pd.Hash + } else if pd.Hash != hash { + t.Fatalf("observation %d hash %s differs from %s; route variants must share a content hash", i, pd.Hash, hash) + } + if _, err := s.InsertTransmission(pd); err != nil { + t.Fatalf("insert observation %d: %v", i, err) + } + } + var got storedAdvert + if err := s.db.QueryRow(`SELECT COUNT(*), MAX(route_type), MAX(raw_hex), MAX(first_seen) FROM transmissions WHERE hash = ?`, hash). + Scan(&got.rows, &got.routeType, &got.rawHex, &got.firstSeen); err != nil { + t.Fatalf("query transmissions: %v", err) + } + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.hash = ?`, hash). + Scan(&got.observations); err != nil { + t.Fatalf("query observations: %v", err) + } + return got, hash +} + +func TestAdvertRouteVariantsShareContentHash(t *testing.T) { + flood := advertPacketData(t, advertObservation{firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}) + zeroHop := advertPacketData(t, advertObservation{firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}) + if flood.RouteType != RouteFlood || zeroHop.RouteType != RouteTransportDirect { + t.Fatalf("route types = %d/%d, want %d/%d", flood.RouteType, zeroHop.RouteType, RouteFlood, RouteTransportDirect) + } + if flood.Hash != zeroHop.Hash { + t.Fatalf("hashes differ: flood %s, zero-hop %s", flood.Hash, zeroHop.Hash) + } +} + +func TestInsertTransmission_AdvertRouteIsFirstIngested(t *testing.T) { + cases := []struct { + name string + obs []advertObservation + wantRoute int + wantRaw string + wantFirstSeen string + }{ + { + name: "flood then zero-hop", + obs: []advertObservation{ + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}, + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}, + }, + wantRoute: RouteFlood, wantRaw: firstIngestedFloodRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + { + name: "zero-hop then flood", + obs: []advertObservation{ + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:00:00Z"}, + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:05:00Z"}, + }, + wantRoute: RouteTransportDirect, wantRaw: firstIngestedZeroHopRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + { + // A delayed zero-hop observation inserted first keeps its route even + // though the flood observation was received earlier: first_seen moves + // back, route_type and raw_hex do not. + name: "zero-hop inserted first but received later", + obs: []advertObservation{ + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}, + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}, + }, + wantRoute: RouteTransportDirect, wantRaw: firstIngestedZeroHopRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := ingestAdverts(t, tc.obs...) + if got.rows != 1 || got.observations != 2 { + t.Fatalf("rows=%d observations=%d, want one transmission with two observations", got.rows, got.observations) + } + if got.routeType != tc.wantRoute { + t.Errorf("route_type = %d, want %d (first inserted observation)", got.routeType, tc.wantRoute) + } + if got.rawHex != tc.wantRaw { + t.Errorf("raw_hex header %s, want %s: raw_hex must stay consistent with route_type", got.rawHex[:2], tc.wantRaw[:2]) + } + if got.firstSeen != tc.wantFirstSeen { + t.Errorf("first_seen = %s, want %s (earliest rx time)", got.firstSeen, tc.wantFirstSeen) + } + }) + } +} From f5daed38ccaf664e147ce9ff0dad2a8be4f08f6c Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Wed, 23 Sep 2026 17:54:23 +0200 Subject: [PATCH 7/7] fix(analytics): type route_class and stop keying relay airtime rows by label Independent review follow-ups for route_class: - relayAirtimeRouteClass returns *string instead of interface{}; nil still serialises as JSON null - the served-API test no longer requires unique payload_type labels, which contradicts the (type, route_class) row key; it now includes two unnamed payload types (both "UNK") and asserts uniqueness of the key instead - reword the mixed-route test comment to say what it models: the stored first-inserted route with relays from flood observations - OpenAPI: from/to replace window whenever either is given, and an unparseable value leaves that bound open Co-Authored-By: Claude Opus 5.5 --- cmd/server/openapi.go | 4 +- cmd/server/relay_airtime_share.go | 10 ++-- .../relay_airtime_share_advert_split_test.go | 50 +++++++++++-------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 1e8314d9c..b1af86407 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -125,8 +125,8 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/relay-airtime-share": {Summary: "Relay airtime share per payload type", Description: "Overview tab's Relay Airtime Share. Each distinct packet (deduplicated by content hash) scores LoRa Time-on-Air of its raw bytes × distinct repeaters that relayed it, under the assumed preset returned in preset (config analytics.loraPreset); originator TX is excluded. rows[] = {payload_type, type, route_class, count, count_pct, score, airtime_pct}, sorted by airtime_pct desc. type is the numeric payload type; payload_type is a display label and may change. ADVERT is split into up to three rows that all have type 4: route_class \"flood\" (route_type 0/1, label \"ADVERT (flood)\"), \"zero_hop\" (route_type 2/3, label \"ADVERT (zero-hop)\") and \"legacy\" (NULL or out-of-range route_type, label \"ADVERT\"). route_class is null on every other row, so key rows by (type, route_class), never by type alone. The route is the one stored on the transmission: the first observation the ingestor inserted for that content hash, even if later observations of the same hash arrive on the other route class (e.g. a contact re-shared as a zero-hop advert). count_pct and airtime_pct are shares of total_count and total_score (nanoseconds). Payload Type Mix (/api/analytics/rf payloadTypes) is not split. Cached in the RF analytics cache (60s default).", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Relative window: 1h, 24h/1d, 3d, 7d/1w or 30d (default: all loaded packets)", Type: "string"}, - {Name: "from", Description: "RFC3339 start; overrides window", Type: "string"}, - {Name: "to", Description: "RFC3339 end; overrides window", Type: "string"}, + {Name: "from", Description: "RFC3339 start; if from or to is given, window is ignored (an unparseable value leaves that bound open)", Type: "string"}, + {Name: "to", Description: "RFC3339 end; if from or to is given, window is ignored (an unparseable value leaves that bound open)", Type: "string"}, }}, "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages normally carry an anonymous session token, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap, each with an AirtimeMs field — LoRa Time-on-Air × distinct relaying repeaters, same formula as the Overview tab's Relay Airtime Share, omitted in DB-only mode), and any senders who explicitly shared their own position (some clients append plaintext \",\" after the token — a deliberate choice by that sender, confirmed empirically, not something CoreScope infers). Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index d7cc90326..65000f40d 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -259,18 +259,20 @@ const ( // relayAirtimeRouteClass returns the route_class value for a row: one of the // constants above for ADVERT rows and nil (JSON null) for every other payload // type, so (type, route_class) identifies each row. -func relayAirtimeRouteClass(key relayAirtimeBucketKey) interface{} { +func relayAirtimeRouteClass(key relayAirtimeBucketKey) *string { if key.payloadType != PayloadADVERT { return nil } + var class string switch key.advertRoute { case relayAirtimeAdvertFlood: - return relayAirtimeRouteClassFlood + class = relayAirtimeRouteClassFlood case relayAirtimeAdvertZeroHop: - return relayAirtimeRouteClassZeroHop + class = relayAirtimeRouteClassZeroHop default: - return relayAirtimeRouteClassLegacy + class = relayAirtimeRouteClassLegacy } + return &class } // computeRelayAirtimeShare aggregates relay-airtime-share per payload_type, diff --git a/cmd/server/relay_airtime_share_advert_split_test.go b/cmd/server/relay_airtime_share_advert_split_test.go index f7e9272c5..8c27a1651 100644 --- a/cmd/server/relay_airtime_share_advert_split_test.go +++ b/cmd/server/relay_airtime_share_advert_split_test.go @@ -29,7 +29,7 @@ func TestRelayAirtimeKey_RouteClassification(t *testing.T) { routeType *int wantRoute relayAirtimeAdvertRoute wantLabel string - wantClass interface{} // route_class: string for ADVERT rows, nil otherwise + wantClass string // route_class; "" means null (non-ADVERT rows) }{ {"advert transport flood", PayloadADVERT, intPtr(RouteTransportFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, {"advert flood", PayloadADVERT, intPtr(RouteFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, @@ -40,7 +40,7 @@ func TestRelayAirtimeKey_RouteClassification(t *testing.T) { {"advert route 99", PayloadADVERT, intPtr(99), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, {"advert route -1", PayloadADVERT, intPtr(-1), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, {"advert route -2", PayloadADVERT, intPtr(-2), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, - {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK", nil}, + {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -53,8 +53,9 @@ func TestRelayAirtimeKey_RouteClassification(t *testing.T) { if got := relayAirtimeBucketName(key); got != tc.wantLabel { t.Fatalf("relayAirtimeBucketName = %q, want %q", got, tc.wantLabel) } - if got := relayAirtimeRouteClass(key); got != tc.wantClass { - t.Fatalf("relayAirtimeRouteClass = %#v, want %#v", got, tc.wantClass) + got := relayAirtimeRouteClass(key) + if (got == nil) != (tc.wantClass == "") || (got != nil && *got != tc.wantClass) { + t.Fatalf("relayAirtimeRouteClass = %v, want %q", got, tc.wantClass) } }) } @@ -82,7 +83,7 @@ func TestRelayAirtimeKey_NonAdvertPayloadsIgnoreRoute(t *testing.T) { t.Fatalf("payload %d route %v: name = %q, want %q", pt, rt, got, wantName) } if got := relayAirtimeRouteClass(key); got != nil { - t.Fatalf("payload %d route %v: route_class = %#v, want nil", pt, rt, got) + t.Fatalf("payload %d route %v: route_class = %q, want nil", pt, rt, *got) } } } @@ -301,13 +302,15 @@ func TestRelayAirtimeShare_AdvertSplitSingleClassAndEmpty(t *testing.T) { } // The serialized API response is the contract the frontend and external -// clients see: the three ADVERT rows share numeric type 4 and are told apart -// by their payload_type label, and the row shape is unchanged. +// clients see: the three ADVERT rows share numeric type 4, several unnamed +// payload types share the "UNK" label, and (type, route_class) identifies +// every row. Existing fields are unchanged; route_class is always present. func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { db := setupTestDB(t) defer db.Close() srv := NewServer(db, &Config{Port: 3000}, NewHub()) - srv.store = relayAirtimeSplitStore(mixedAdvertFixtures()) + fixtures := append(mixedAdvertFixtures(), relayAirtimeSplitFixture{14, intPtr(1), 30, 1, "pt14"}) + srv.store = relayAirtimeSplitStore(fixtures) router := mux.NewRouter() srv.RegisterRoutes(router) @@ -361,7 +364,7 @@ func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { t.Fatalf("decode response: %v", err) } advertLabels := []string{} - labels := make(map[string]bool, len(resp.Rows)) + unknownTypes := []int{} identities := make(map[string]bool, len(resp.Rows)) for _, r := range resp.Rows { class := "" @@ -373,20 +376,23 @@ func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { t.Fatalf("(type, route_class) %s repeated; it must identify each row", id) } identities[id] = true - if labels[r.PayloadType] { - t.Fatalf("payload_type %q repeated; rows must be distinguishable by label", r.PayloadType) - } - labels[r.PayloadType] = true if r.Type == PayloadADVERT { advertLabels = append(advertLabels, r.PayloadType) } + if r.PayloadType == "UNK" { + unknownTypes = append(unknownTypes, r.Type) + } } sort.Strings(advertLabels) if want := []string{"ADVERT", "ADVERT (flood)", "ADVERT (zero-hop)"}; !reflect.DeepEqual(advertLabels, want) { t.Fatalf("rows with type %d = %v, want %v", PayloadADVERT, advertLabels, want) } - if resp.TotalCount != 10 { - t.Errorf("total_count = %d, want 10", resp.TotalCount) + sort.Ints(unknownTypes) + if !reflect.DeepEqual(unknownTypes, []int{13, 14}) { + t.Fatalf("UNK rows have types %v, want [13 14]", unknownTypes) + } + if resp.TotalCount != 11 { + t.Errorf("total_count = %d, want 11", resp.TotalCount) } } @@ -445,10 +451,11 @@ func TestRelayAirtimeShare_UnknownPayloadTieOrderIsStable(t *testing.T) { // content hash as the original flood advert, so both land on one transmission // whose route_type is that of the first observation the ingestor inserted // (pinned in cmd/ingestor TestInsertTransmission_AdvertRouteIsFirstIngested). -// Relay Airtime Share classifies by that stored route and scores relays from -// every observation. When the zero-hop re-share was inserted first, the flood -// observations' relays are therefore reported on the zero_hop row. This test -// pins that documented first-ingested contract so changing it is deliberate. +// This test models the stored result of that contract: a single transmission +// with the first-inserted route_type whose relays come from the flood +// observations. Relay Airtime Share classifies by the stored route alone, so +// a relayed transmission stored as zero-hop stays on the zero_hop row rather +// than being reclassified from its relay count. func TestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute(t *testing.T) { cases := []struct { name string @@ -471,8 +478,9 @@ func TestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute(t *testing.T) { t.Fatalf("got %d rows, want 1: %+v", len(rows), rows) } r := rows[0] - if r["payload_type"] != tc.wantLabel || r["route_class"] != tc.wantClass { - t.Fatalf("row = %v/%v, want %s/%s", r["payload_type"], r["route_class"], tc.wantLabel, tc.wantClass) + class, _ := r["route_class"].(*string) + if r["payload_type"] != tc.wantLabel || class == nil || *class != tc.wantClass { + t.Fatalf("row = %v/%v, want %s/%s", r["payload_type"], class, tc.wantLabel, tc.wantClass) } if r["count"].(int) != 1 || r["score"].(int64) != relayAirtimeSplitScore(110, 2) { t.Errorf("count=%v score=%v, want 1 and the flood relays' score", r["count"], r["score"])