From c8205357fd687a8007a3c12e71e0a3ed21b14efd Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 09:18:12 +0000 Subject: [PATCH 01/10] test(nodes): estimated flood / zero-hop advert intervals (#245) Red tests for #245 M1: the pure estimator (regular series, 2x/3x missed adverts, manual and burst adverts, sender clock offset/ahead/reset/jump, too few samples, irregular, confidence tiers, median not mean), snapping to the firmware's settable values (flood.advert.interval 3-168 h, advert.interval 60-240 even minutes or the 2 min new-install default), per-class estimation with zero-hop absent, the advertIntervals field on node detail under include=advertRoutes (privacy, OpenAPI), the frontend text for every state, and an E2E against a new seed with known gaps. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals_test.go | 410 ++++++++++++++++++++ cmd/server/node_advert_routes_test.go | 7 +- test-fixtures/seed-245-advert-intervals.sql | 77 ++++ test-issue-2073-recent-adverts-e2e.js | 2 +- test-issue-245-advert-intervals-e2e.js | 158 ++++++++ test-node-adverts.js | 60 +++ 6 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 cmd/server/advert_intervals_test.go create mode 100644 test-fixtures/seed-245-advert-intervals.sql create mode 100644 test-issue-245-advert-intervals-e2e.js diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go new file mode 100644 index 000000000..7e56ffdc4 --- /dev/null +++ b/cmd/server/advert_intervals_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +// #245 M1: estimated flood / zero-hop advert intervals per node. The +// firmware facts the expectations rest on (MeshCore, see advert_intervals.go): +// - flood.advert.interval: whole hours, 3-168, 0 = off +// (src/helpers/CommonCLI.cpp:486-495); default 47 h on repeaters +// (examples/simple_repeater/MyMesh.cpp:904). +// - advert.interval: minutes stored as mins/2, 60-240, 0 = off +// (src/helpers/CommonCLI.cpp:496-505); 2 min on an untouched new install +// (examples/simple_repeater/MyMesh.cpp:903, turned off by the first +// savePrefs, src/helpers/CommonCLI.cpp:162-165). + +var aiBase = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + +// aiSeries builds samples at the given multiples of interval after aiBase. +// The sender clock reads heard - 2 s (send-to-first-heard delay). +func aiSeries(interval time.Duration, at ...float64) []advertIntervalSample { + out := make([]advertIntervalSample, 0, len(at)) + for _, k := range at { + heard := aiBase.Add(time.Duration(k * float64(interval))) + out = append(out, advertIntervalSample{senderTS: heard.Unix() - 2, heard: heard}) + } + return out +} + +func aiInts(from, to int) []float64 { + var out []float64 + for i := from; i <= to; i++ { + out = append(out, float64(i)) + } + return out +} + +func aiCheck(t *testing.T, got AdvertIntervalEstimate, wantInterval int64, wantConf string, wantSamples, wantGaps int) { + t.Helper() + if wantInterval == 0 { + if got.IntervalS != nil { + t.Fatalf("interval_s = %d, want null (%+v)", *got.IntervalS, got) + } + } else if got.IntervalS == nil || *got.IntervalS != wantInterval { + t.Fatalf("interval_s = %v, want %d (%+v)", ptrStr(got.IntervalS), wantInterval, got) + } + if got.Confidence != wantConf || got.Samples != wantSamples || got.GapsUsed != wantGaps { + t.Fatalf("confidence/samples/gaps_used = %s/%d/%d, want %s/%d/%d (%+v)", + got.Confidence, got.Samples, got.GapsUsed, wantConf, wantSamples, wantGaps, got) + } +} + +func ptrStr(p *int64) string { + if p == nil { + return "null" + } + return fmt.Sprint(*p) +} + +func TestEstimateAdvertInterval_RegularSeries(t *testing.T) { + s := aiSeries(12*time.Hour, aiInts(0, 9)...) + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceHigh, 10, 9) + if !got.Snapped || got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("raw/snapped = %v/%v", ptrStr(got.RawIntervalS), got.Snapped) + } + if want := aiBase.Add(9 * 12 * time.Hour).Format(time.RFC3339); got.LastAdvert == nil || *got.LastAdvert != want { + t.Fatalf("last_advert = %v, want %s", got.LastAdvert, want) + } + // Input order does not matter: rows arrive in ingest order. + rev := make([]advertIntervalSample, len(s)) + for i := range s { + rev[len(s)-1-i] = s[i] + } + aiCheck(t, estimateAdvertInterval(rev, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) +} + +// Missed adverts: a gap of 2x or 3x the interval is the interval with one or +// two adverts nobody heard, not a longer interval. Every gap fits. +func TestEstimateAdvertInterval_MissedAdverts(t *testing.T) { + // 47 h (the repeater default); adverts 3, 6 and 7 missed: gaps 47, 47, + // 94, 47, 141, 47, 47, 47 h. + s := aiSeries(47*time.Hour, 0, 1, 2, 4, 5, 8, 9, 10, 11) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 47*3600, advertConfidenceHigh, 9, 8) + + // Half the gaps doubled or tripled: the median of the raw gaps is 2x, + // only the multiple handling brings it back to 12 h. + s = aiSeries(12*time.Hour, 0, 2, 4, 5, 7, 10, 12, 13, 15) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 8) +} + +// Most adverts missed (a zero-hop advert heard only now and then): the gaps +// cluster at 2x, but enough sit at the interval itself to show it. +func TestEstimateAdvertInterval_MostlyMissed(t *testing.T) { + // 60 min zero-hop; gaps 120, 120, 60, 180, 120, 60, 120 min. + s := aiSeries(time.Hour, 0, 2, 4, 5, 8, 10, 11, 13) + aiCheck(t, estimateAdvertInterval(s, advertIntervalZeroHop), 3600, advertConfidenceHigh, 8, 7) +} + +// Manual or extra adverts (the advert / advert.zerohop CLI commands do not +// re-arm the timer, src/helpers/CommonCLI.cpp:191-198; a reboot sends a +// zero-hop advert, examples/simple_repeater/main.cpp:119) split a regular gap +// into two short ones. Those gaps are dropped; the interval stays. +func TestEstimateAdvertInterval_ExtraAdverts(t *testing.T) { + // 120 min zero-hop, 12 timer adverts plus extras at 2.3 and 7.75. + s := aiSeries(2*time.Hour, append(aiInts(0, 11), 2.3, 7.75)...) + aiCheck(t, estimateAdvertInterval(s, advertIntervalZeroHop), 7200, advertConfidenceHigh, 14, 9) + + // A burst of manual adverts (minutes apart, below the 3 h flood minimum, + // so never a timer interval) must not drag the interval down: 6 timer + // adverts at 12 h and 6 manual ones 7 min apart after one of them - more + // "regular" gaps than the timer's own. + s = aiSeries(12*time.Hour, 0, 1, 2, 3, 4, 5, 3.01, 3.02, 3.03, 3.04, 3.05, 3.06) + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceMedium, 12, 5) +} + +// An outage of many intervals (node off, observers down) is one long gap. +// The median ignores it; a mean would not. +func TestEstimateAdvertInterval_LongOutage(t *testing.T) { + s := aiSeries(12*time.Hour, 0, 1, 2, 3, 4, 5, 6, 7, 8, 30) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 8) +} + +// Sender clock: preferred when plausible, first_seen otherwise. +func TestEstimateAdvertInterval_SenderClock(t *testing.T) { + // first_seen jitters by up to 20 min (late uploads); the sender's + // clock is exact. The raw median is then exactly the sender's interval. + jitter := []time.Duration{0, 20 * time.Minute, -15 * time.Minute, 5 * time.Minute, 18 * time.Minute, -20 * time.Minute, 0, 10 * time.Minute} + steady := func(offset time.Duration) []advertIntervalSample { + var s []advertIntervalSample + for i, j := range jitter { + sent := aiBase.Add(time.Duration(i) * 12 * time.Hour) + s = append(s, advertIntervalSample{senderTS: sent.Add(offset).Unix(), heard: sent.Add(j)}) + } + return s + } + for _, offset := range []time.Duration{0, -60 * 24 * time.Hour, -2 * 365 * 24 * time.Hour} { + got := estimateAdvertInterval(steady(offset), advertIntervalFlood) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("offset %v: raw_interval_s = %v, want exactly 43200 from the sender clock", offset, ptrStr(got.RawIntervalS)) + } + } + + // A clock ahead of when the advert was heard is clearly wrong: the + // sender's gaps (exactly 12 h) are not used, first_seen's (12 h 1 min) are. + var ahead []advertIntervalSample + for i := 0; i < 8; i++ { + heard := aiBase.Add(time.Duration(i) * (12*time.Hour + time.Minute)) + ahead = append(ahead, advertIntervalSample{senderTS: aiBase.Add(24*time.Hour + time.Duration(i)*12*time.Hour).Unix(), heard: heard}) + } + got := estimateAdvertInterval(ahead, advertIntervalFlood) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600+60 { + t.Fatalf("clock ahead: raw_interval_s = %v, want 43260 from first_seen", ptrStr(got.RawIntervalS)) + } + aiCheck(t, got, 12*3600, advertConfidenceHigh, 8, 7) + + // clkreboot (src/helpers/CommonCLI.cpp:187-190) sets the clock back to + // 15 May 2024 mid-series: one gap is negative on the sender's clock and + // falls back to first_seen, the rest still use the sender's clock. + s := aiSeries(12*time.Hour, aiInts(0, 9)...) + for i := 5; i < len(s); i++ { + s[i].senderTS = 1715770351 + int64(i-5)*12*3600 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) + + // A forward jump (clock sync after running behind): the sender's gap is + // days longer than first_seen's, so first_seen wins for that gap. + s = aiSeries(12*time.Hour, aiInts(0, 9)...) + for i := 4; i < len(s); i++ { + s[i].senderTS += 9 * 24 * 3600 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) + + // No sender timestamp at all (decoded_json without one): first_seen. + s = aiSeries(12*time.Hour, aiInts(0, 5)...) + for i := range s { + s[i].senderTS = 0 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceMedium, 6, 5) +} + +func TestEstimateAdvertInterval_TooFewSamples(t *testing.T) { + for n := 0; n <= 2; n++ { + got := estimateAdvertInterval(aiSeries(12*time.Hour, aiInts(0, n-1)...), advertIntervalFlood) + aiCheck(t, got, 0, advertConfidenceNone, n, 0) + if got.RawIntervalS != nil || got.Snapped { + t.Fatalf("n=%d: raw/snapped = %v/%v", n, ptrStr(got.RawIntervalS), got.Snapped) + } + if (n == 0) != (got.LastAdvert == nil) { + t.Fatalf("n=%d: last_advert = %v", n, got.LastAdvert) + } + } + // Three adverts are the minimum: two fitting gaps, low confidence. + aiCheck(t, estimateAdvertInterval(aiSeries(12*time.Hour, 0, 1, 2), advertIntervalFlood), 12*3600, advertConfidenceLow, 3, 2) + // Same timestamps twice (no positive gap): nothing to estimate from. + dup := aiSeries(12*time.Hour, 0, 0, 0) + aiCheck(t, estimateAdvertInterval(dup, advertIntervalFlood), 0, advertConfidenceNone, 3, 0) +} + +// Irregular adverts (a companion advertising by hand) have no interval. +func TestEstimateAdvertInterval_Irregular(t *testing.T) { + s := aiSeries(time.Hour, 0, 10, 47, 50, 121, 143) + got := estimateAdvertInterval(s, advertIntervalFlood) + if got.Confidence != advertConfidenceNone || got.IntervalS != nil || got.Samples != 6 { + t.Fatalf("irregular: %+v", got) + } +} + +// Confidence: high needs >= 6 fitting gaps and >= 75 % of the non-short +// gaps fitting, medium >= 3 and >= 50 %, low anything less. Short gaps +// (extra adverts) are dropped and count neither way. +func TestEstimateAdvertInterval_ConfidenceTiers(t *testing.T) { + cases := []struct { + at []float64 + conf string + gaps int + }{ + {aiInts(0, 6), advertConfidenceHigh, 6}, // 6 of 6 fit + {aiInts(0, 5), advertConfidenceMedium, 5}, // 5 fit: below the 6 for high + {[]float64{0, 1, 2, 3, 4, 5, 6, 7.5, 10, 13.5}, advertConfidenceMedium, 6}, // 6 fit, 3 irregular (67 %) + {[]float64{0, 1, 2, 3, 3.4, 3.6}, advertConfidenceMedium, 3}, // 3 fit, 2 short gaps dropped + {[]float64{0, 1, 2, 3, 4.5, 7, 10.5, 11.8}, advertConfidenceLow, 3}, // 3 fit, 4 irregular (43 %) + {[]float64{0, 1, 2, 2.3, 2.5, 2.7, 2.85}, advertConfidenceLow, 2}, // 2 fit + } + for i, c := range cases { + got := estimateAdvertInterval(aiSeries(12*time.Hour, c.at...), advertIntervalFlood) + if got.Confidence != c.conf || got.GapsUsed != c.gaps { + t.Errorf("case %d: confidence/gaps_used = %s/%d, want %s/%d (%+v)", i, got.Confidence, got.GapsUsed, c.conf, c.gaps, got) + } + if got.IntervalS == nil || *got.IntervalS != 12*3600 { + t.Errorf("case %d: interval_s = %v", i, ptrStr(got.IntervalS)) + } + } +} + +// The interval is the median of the fitting gaps, not their mean: one late +// first_seen (no sender clock) moves a mean, not the median. +func TestEstimateAdvertInterval_MedianNotMean(t *testing.T) { + s := aiSeries(12*time.Hour, aiInts(0, 7)...) + s[7].heard = s[7].heard.Add(time.Hour) + for i := range s { + s[i].senderTS = 0 + } + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceHigh, 8, 7) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("raw_interval_s = %v, want the median 43200", ptrStr(got.RawIntervalS)) + } +} + +// Snapping follows the firmware's settable values (see the file comment). +func TestSnapAdvertInterval(t *testing.T) { + h, m := 3600.0, 60.0 + cases := []struct { + class advertIntervalClass + raw float64 + want int64 + snapped bool + }{ + {advertIntervalFlood, 47.2 * h, 47 * 3600, true}, + {advertIntervalFlood, 46.6 * h, 47 * 3600, true}, + {advertIntervalFlood, 12*h + 95, 12 * 3600, true}, + {advertIntervalFlood, 3 * h, 3 * 3600, true}, + {advertIntervalFlood, 2.8 * h, 3 * 3600, true}, // within 10 % below the 3 h minimum + {advertIntervalFlood, 2.5 * h, 9000, false}, // below 3 h: no settable value + {advertIntervalFlood, 168.4 * h, 168 * 3600, true}, + {advertIntervalFlood, 180 * h, 168 * 3600, true}, // within 10 % above the 168 h maximum + {advertIntervalFlood, 200 * h, 720000, false}, + {advertIntervalZeroHop, 120.9 * m, 120 * 60, true}, // even minutes, not whole ones + {advertIntervalZeroHop, 121.5 * m, 122 * 60, true}, + {advertIntervalZeroHop, 60.4 * m, 60 * 60, true}, + {advertIntervalZeroHop, 56 * m, 60 * 60, true}, // within 10 % below 60 min + {advertIntervalZeroHop, 50 * m, 3000, false}, // 50 min cannot be set + {advertIntervalZeroHop, 239 * m, 240 * 60, true}, + {advertIntervalZeroHop, 260 * m, 240 * 60, true}, // within 10 % above 240 min + {advertIntervalZeroHop, 270 * m, 16200, false}, + {advertIntervalZeroHop, 125, 120, true}, // the 2 min new-install default + {advertIntervalZeroHop, 10 * m, 600, false}, + {advertIntervalZeroHop, 12 * h, 43200, false}, // a flood-sized interval is not a zero-hop setting + } + for _, c := range cases { + got, snapped := snapAdvertInterval(c.raw, c.class) + if got != c.want || snapped != c.snapped { + t.Errorf("snap(%v, class %d) = %d/%v, want %d/%v", c.raw, c.class, got, snapped, c.want, c.snapped) + } + } +} + +// aiRows builds NodeAdvertRows the way GetNodeAdvertRoutes lists them +// (newest ingest first), each with decoded_json carrying the sender clock. +func aiRows(interval time.Duration, n int, newest time.Time) NodeAdvertRows { + rows := NodeAdvertRows{} + for i := 0; i < n; i++ { + heard := newest.Add(-time.Duration(i) * interval) + rows = append(rows, NodeAdvertRow{ + "first_seen": heard.Format(time.RFC3339), + "decoded_json": fmt.Sprintf(`{"type":"ADVERT","timestamp":%d}`, heard.Unix()-1), + }) + } + return rows +} + +// Each class is estimated from its own list; mixed and unknown adverts are +// left out (their class is ambiguous). No zero-hop adverts: none, with no +// last_advert. +func TestNodeAdvertIntervals_PerClass(t *testing.T) { + newest := aiBase.Add(240 * time.Hour) + byRoute := NodeAdvertsByRoute{ + Limit: nodeAdvertRouteLimit, + Flood: aiRows(12*time.Hour, 10, newest), + ZeroHop: aiRows(2*time.Hour, 8, newest.Add(-time.Hour)), + Mixed: aiRows(time.Hour, 10, newest), + Unknown: aiRows(3*time.Hour, 10, newest), + } + got := nodeAdvertIntervals(byRoute) + if got.Window != nodeAdvertRouteLimit { + t.Fatalf("window = %d", got.Window) + } + aiCheck(t, got.Flood, 12*3600, advertConfidenceHigh, 10, 9) + aiCheck(t, got.ZeroHop, 7200, advertConfidenceHigh, 8, 7) + if !got.Flood.Snapped || !got.ZeroHop.Snapped { + t.Fatalf("snapped flood/zero_hop = %v/%v: each class snaps to its own setting", got.Flood.Snapped, got.ZeroHop.Snapped) + } + if got.ZeroHop.LastAdvert == nil || *got.ZeroHop.LastAdvert != newest.Add(-time.Hour).Format(time.RFC3339) { + t.Fatalf("zero_hop last_advert = %v", got.ZeroHop.LastAdvert) + } + + byRoute.ZeroHop = NodeAdvertRows{} + got = nodeAdvertIntervals(byRoute) + aiCheck(t, got.ZeroHop, 0, advertConfidenceNone, 0, 0) + if got.ZeroHop.LastAdvert != nil { + t.Fatalf("no zero-hop adverts: last_advert = %v", *got.ZeroHop.LastAdvert) + } + aiCheck(t, got.Flood, 12*3600, advertConfidenceHigh, 10, 9) +} + +// Rows the estimator cannot use: no parseable first_seen is skipped; a +// missing or malformed sender timestamp falls back to first_seen. +func TestAdvertIntervalSamples_Parsing(t *testing.T) { + rows := NodeAdvertRows{ + {"first_seen": "2026-09-01T12:00:00Z", "decoded_json": `{"timestamp":1788264000}`}, + {"first_seen": "2026-09-01T00:00:00.000Z", "decoded_json": `{"timestamp":"x"}`}, + {"first_seen": "2026-08-31T12:00:00Z", "decoded_json": nil}, + {"first_seen": "garbage", "decoded_json": `{"timestamp":1}`}, + {"first_seen": nil}, + } + s := advertIntervalSamples(rows) + if len(s) != 3 { + t.Fatalf("samples = %d, want 3: %+v", len(s), s) + } + if s[0].senderTS != 1788264000 || s[1].senderTS != 0 || s[2].senderTS != 0 { + t.Fatalf("sender timestamps = %d/%d/%d", s[0].senderTS, s[1].senderTS, s[2].senderTS) + } + if !s[1].heard.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("heard = %v", s[1].heard) + } +} + +// API: advertIntervals rides on the include=advertRoutes opt-in, flood from +// the flood list and zero-hop from the zero-hop list. +func TestNodeDetail_AdvertIntervals(t *testing.T) { + srv, router := setupTestServer(t) + narAddRouteMask(t, srv.db) + const pk = "245aa00000000000000000000000000000000000000000000000000000000001" + if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'Interval Node', 'repeater', ?, ?)`, + pk, narAgo(time.Hour), narAgo(200*time.Hour)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + insert := func(hash string, routeType, mask int, heard time.Time) { + if _, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey, route_mask) + VALUES ('1100', ?, ?, ?, 4, ?, ?, ?)`, hash, heard.Format(time.RFC3339), routeType, fmt.Sprintf(`{"type":"ADVERT","timestamp":%d}`, heard.Unix()-3), pk, mask); err != nil { + t.Fatal(err) + } + } + // Flood every 12 h with one missed; zero-hop every 120 min. + for _, k := range []int{1, 2, 3, 5, 6, 7, 8} { + insert(fmt.Sprintf("f%d", k), 1, 0b0010, now.Add(-time.Duration(k)*12*time.Hour)) + } + for k := 0; k < 6; k++ { + insert(fmt.Sprintf("z%d", k), 2, 0b0100, now.Add(-time.Duration(30+120*k)*time.Minute)) + } + + req := func(q string) string { + _, raw := narGetNode(t, router, pk+q, 200) + return raw + } + if raw := req(""); strings.Contains(raw, "advertIntervals") { + t.Fatalf("advertIntervals without the opt-in: %s", raw) + } + var body struct { + Intervals *NodeAdvertIntervals `json:"advertIntervals"` + } + if err := json.Unmarshal([]byte(req(narIncludeQuery)), &body); err != nil { + t.Fatal(err) + } + if body.Intervals == nil { + t.Fatal("advertIntervals missing with include=advertRoutes") + } + aiCheck(t, body.Intervals.Flood, 12*3600, advertConfidenceHigh, 7, 6) + aiCheck(t, body.Intervals.ZeroHop, 7200, advertConfidenceMedium, 6, 5) + if body.Intervals.Window != nodeAdvertRouteLimit { + t.Fatalf("window = %d", body.Intervals.Window) + } +} diff --git a/cmd/server/node_advert_routes_test.go b/cmd/server/node_advert_routes_test.go index 095d63d33..e83de3efa 100644 --- a/cmd/server/node_advert_routes_test.go +++ b/cmd/server/node_advert_routes_test.go @@ -472,7 +472,7 @@ func TestNodeDetail_AdvertRouteFieldsPrivacy(t *testing.T) { srv, router := narServer(t) srv.cfg.SetNodeBlacklist([]string{narNode}) _, raw := narGetNode(t, router, narNode+narIncludeQuery, 404) - if strings.Contains(raw, "advertCounts") || strings.Contains(raw, "h-flood") { + if strings.Contains(raw, "advertCounts") || strings.Contains(raw, "advertIntervals") || strings.Contains(raw, "h-flood") { t.Fatalf("blacklisted node leaked: %s", raw) } }) @@ -488,7 +488,8 @@ func TestNodeDetail_AdvertRouteFieldsPrivacy(t *testing.T) { // recentAdverts rows of a hidden identity. leaked := func(body narResponse, raw string) bool { return body.ByRoute != nil || body.Counts != nil || strings.Contains(raw, "advertCounts") || - strings.Contains(raw, "recentAdvertsByRoute") || strings.Contains(raw, "route_class") + strings.Contains(raw, "recentAdvertsByRoute") || strings.Contains(raw, "route_class") || + strings.Contains(raw, "advertIntervals") } t.Run("observer blacklist", func(t *testing.T) { srv, router := narServer(t) @@ -569,6 +570,8 @@ func TestOpenAPI_NodeAdvertRouteSchemas(t *testing.T) { check("NodeAdvertsByRoute", NodeAdvertsByRoute{}) check("NodeAdvertCounts", NodeAdvertCounts{}) check("AdvertRouteCounts", AdvertRouteCounts{}) + check("NodeAdvertIntervals", NodeAdvertIntervals{}) + check("AdvertIntervalEstimate", AdvertIntervalEstimate{}) if _, ok := props("NodeAdvert")["route_class"]; !ok { t.Error("NodeAdvert.route_class is emitted but not documented") } diff --git a/test-fixtures/seed-245-advert-intervals.sql b/test-fixtures/seed-245-advert-intervals.sql new file mode 100644 index 000000000..76ac5941b --- /dev/null +++ b/test-fixtures/seed-245-advert-intervals.sql @@ -0,0 +1,77 @@ +-- #245 E2E seed (test-issue-245-advert-intervals-e2e.js): two repeaters with +-- known advert gaps for the estimated advert intervals on node detail. +-- Applied by CI after the fixture is migrated (route_mask must exist): +-- sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-245-advert-intervals.sql +-- +-- decoded_json.timestamp is the sender's clock. Out-of-band negative ids and +-- old observation timestamps keep these rows last in the packets views (as +-- in seed-2073); first_seen is relative to now. +-- +-- Expected on /api/nodes/245e2e0...01?include=advertRoutes ("Advert Interval E2E"): +-- flood : 10 adverts at 1, 13, 25, 49, 61, 66, 73, 109, 121, 133 h ago. +-- Gaps of 12 h (x5), 24 h (one missed), 36 h (two missed), and +-- 5 h + 7 h around the manual advert at 66 h (dropped). +-- -> interval_s 43200, gaps_used 7, high. +-- zero_hop : 6 adverts every 120 min -> interval_s 7200, gaps_used 5, medium. +-- and /api/nodes/245e2e0...02 ("Flood Only Interval E2E"): +-- flood : 8 adverts 24 h apart; the sender clock jumps back to +-- 15 May 2024 after the 4th (that gap falls back to first_seen) +-- -> interval_s 86400, gaps_used 7, high. +-- zero_hop : none observed. +INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES + ('245e2e0000000000000000000000000000000000000000000000000000000001', 'Advert Interval E2E', 'repeater', + strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-30 minutes'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-6 days'), 16), + ('245e2e0000000000000000000000000000000000000000000000000000000002', 'Flood Only Interval E2E', 'repeater', + strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 hours'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-8 days'), 8); + +INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, from_pubkey, route_mask) VALUES + (-245001, '1100e2e2450001', 'e2e245advert0001', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-1 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245002, '1100e2e2450002', 'e2e245advert0002', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-13 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-13 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245003, '1100e2e2450003', 'e2e245advert0003', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-25 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-25 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245004, '1100e2e2450004', 'e2e245advert0004', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-49 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-49 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245005, '1100e2e2450005', 'e2e245advert0005', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-61 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-61 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245006, '1100e2e2450006', 'e2e245advert0006', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-66 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-66 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245007, '1100e2e2450007', 'e2e245advert0007', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-73 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-73 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245008, '1100e2e2450008', 'e2e245advert0008', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-109 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-109 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245009, '1100e2e2450009', 'e2e245advert0009', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-121 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-121 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245010, '1100e2e2450010', 'e2e245advert0010', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-133 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-133 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245011, '1200e2e2450011', 'e2e245advert0011', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-30 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-30 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245012, '1200e2e2450012', 'e2e245advert0012', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-150 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-150 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245013, '1200e2e2450013', 'e2e245advert0013', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-270 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-270 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245014, '1200e2e2450014', 'e2e245advert0014', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-390 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-390 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245015, '1200e2e2450015', 'e2e245advert0015', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-510 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-510 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245016, '1200e2e2450016', 'e2e245advert0016', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-630 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-630 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245017, '1100e2e2450017', 'e2e245advert0017', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-170 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-170 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245018, '1100e2e2450018', 'e2e245advert0018', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-146 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-146 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245019, '1100e2e2450019', 'e2e245advert0019', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-122 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-122 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245020, '1100e2e2450020', 'e2e245advert0020', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-98 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-98 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245021, '1100e2e2450021', 'e2e245advert0021', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-74 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 0) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245022, '1100e2e2450022', 'e2e245advert0022', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-50 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 86400) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245023, '1100e2e2450023', 'e2e245advert0023', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-26 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 172800) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245024, '1100e2e2450024', 'e2e245advert0024', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 259200) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2); + +INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp) VALUES + (-245001, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245002, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245003, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245004, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245005, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245006, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245007, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245008, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245009, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245010, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245011, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245012, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245013, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245014, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245015, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245016, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245017, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245018, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245019, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245020, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245021, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245022, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245023, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245024, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)); diff --git a/test-issue-2073-recent-adverts-e2e.js b/test-issue-2073-recent-adverts-e2e.js index 55223fedc..b431ffe7a 100644 --- a/test-issue-2073-recent-adverts-e2e.js +++ b/test-issue-2073-recent-adverts-e2e.js @@ -205,7 +205,7 @@ async function waitSettled(page, root) { }; }, MIX); assert(r.plainKeys === 'node,recentAdverts' && !r.plainRouteClass, 'plain: ' + JSON.stringify(r)); - assert(r.optedKeys === 'advertCounts,node,recentAdverts,recentAdvertsByRoute' && r.optedRouteClass, 'opted in: ' + JSON.stringify(r)); + assert(r.optedKeys === 'advertCounts,advertIntervals,node,recentAdverts,recentAdvertsByRoute' && r.optedRouteClass, 'opted in: ' + JSON.stringify(r)); }); await step('mobile 390×844: no horizontal overflow, tabs usable', async () => { diff --git a/test-issue-245-advert-intervals-e2e.js b/test-issue-245-advert-intervals-e2e.js new file mode 100644 index 000000000..ebfe1cd09 --- /dev/null +++ b/test-issue-245-advert-intervals-e2e.js @@ -0,0 +1,158 @@ +/** + * #245 M1 — estimated flood / zero-hop advert intervals on node detail + * (Recent Adverts section, full page, side panel and phone width). + * + * Needs the fixture seeded with test-fixtures/seed-245-advert-intervals.sql + * (CI applies it after migrating the fixture). Never run against prod. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-245-advert-intervals-e2e.js + * SCREENSHOT_DIR= also saves screenshots of each state. + */ +'use strict'; + +const path = require('path'); +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOTS = process.env.SCREENSHOT_DIR || ''; +const BOTH = '245e2e0000000000000000000000000000000000000000000000000000000001'; +const FLOOD_ONLY = '245e2e0000000000000000000000000000000000000000000000000000000002'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } +async function shot(page, name, selector) { + if (!SHOTS) return; + await page.waitForTimeout(800); + if (selector) await page.$eval(selector, (el) => el.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: path.join(SHOTS, '245-' + name + '.png'), fullPage: false }); +} + +// The interval rows inside root, by class, as the user reads them. +async function intervals(page, root) { + await page.waitForSelector(root + ' .node-adverts-intervals'); + return page.$eval(root, (el) => { + const out = {}; + el.querySelectorAll('.node-adverts-interval-row').forEach((r) => { + out[r.dataset.advertInterval] = r.textContent.replace(/\s+/g, ' ').trim(); + }); + const block = el.querySelector('.node-adverts-intervals'); + out.tip = block.getAttribute('title') || ''; + out.afterCounts = !!(el.querySelector('.node-adverts-counts') && + (el.querySelector('.node-adverts-counts').compareDocumentPosition(block) & Node.DOCUMENT_POSITION_FOLLOWING)); + return out; + }); +} + +const FLOOD_12H = 'Estimated flood interval ≈ 12 h (10 adverts, high confidence)'; +const ZH_120 = 'Estimated zero-hop interval ≈ 120 min (6 adverts, medium confidence)'; +const FLOOD_24H = 'Estimated flood interval ≈ 24 h (8 adverts, high confidence)'; +const ZH_NONE = 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'; + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + const full = '#node-packets'; + + console.log(`\n=== #245 advert intervals E2E against ${BASE} ===`); + + await step('API: advertIntervals from the seeded gaps, only with include=advertRoutes', async () => { + await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); + const r = await page.evaluate(async (pks) => { + const get = async (u) => (await fetch(u)).json(); + return { + plain: 'advertIntervals' in (await get('/api/nodes/' + pks[0])), + both: (await get('/api/nodes/' + pks[0] + '?include=advertRoutes')).advertIntervals, + floodOnly: (await get('/api/nodes/' + pks[1] + '?include=advertRoutes')).advertIntervals, + }; + }, [BOTH, FLOOD_ONLY]); + assert(!r.plain, 'plain detail must not carry advertIntervals'); + const b = r.both, f = r.floodOnly; + assert(b && b.window === 20, 'window: ' + JSON.stringify(b)); + assert(b.flood.interval_s === 43200 && b.flood.snapped && b.flood.samples === 10 && b.flood.gaps_used === 7 && b.flood.confidence === 'high', + 'flood (2x and 3x gaps, one manual advert): ' + JSON.stringify(b.flood)); + assert(b.zero_hop.interval_s === 7200 && b.zero_hop.samples === 6 && b.zero_hop.gaps_used === 5 && b.zero_hop.confidence === 'medium', + 'zero-hop: ' + JSON.stringify(b.zero_hop)); + assert(f.flood.interval_s === 86400 && f.flood.gaps_used === 7 && f.flood.confidence === 'high', + 'flood with a sender clock reset: ' + JSON.stringify(f.flood)); + assert(f.zero_hop.samples === 0 && f.zero_hop.interval_s === null && f.zero_hop.confidence === 'none' && f.zero_hop.last_advert === null, + 'no zero-hop: ' + JSON.stringify(f.zero_hop)); + }); + + await step('full page: both classes under the counts', async () => { + await page.goto(BASE + '/#/nodes/' + BOTH, { waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(s.flood === FLOOD_12H, 'flood: ' + s.flood); + assert(s.zero_hop === ZH_120, 'zero-hop: ' + s.zero_hop); + assert(s.afterCounts, 'below the 24h / 7d counts'); + assert(/median/.test(s.tip) && /3-168 h/.test(s.tip), 'tooltip explains the method: ' + s.tip); + await shot(page, 'full-both-light', full); + }); + + await step('full page: no zero-hop adverts reads as off or out of range', async () => { + await page.goto(BASE + '/#/nodes/' + FLOOD_ONLY, { waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(s.flood === FLOOD_24H, 'flood: ' + s.flood); + assert(s.zero_hop === ZH_NONE, 'zero-hop: ' + s.zero_hop); + await shot(page, 'full-flood-only-light', full); + }); + + await step('full page: dark theme', async () => { + await page.evaluate(() => localStorage.setItem('meshcore-theme', 'dark')); + await page.goto(BASE + '/#/nodes/' + BOTH, { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark', 'dark theme active'); + assert(s.flood === FLOOD_12H, 'flood in dark: ' + s.flood); + const colors = await page.$eval(full + ' .node-adverts-intervals', (el) => { + const cs = getComputedStyle(el), strong = getComputedStyle(el.querySelector('strong')); + return { text: cs.color, label: strong.color, bg: getComputedStyle(document.body).backgroundColor }; + }); + assert(colors.text !== colors.bg && colors.label !== colors.bg, 'readable in dark: ' + JSON.stringify(colors)); + await shot(page, 'full-both-dark', full); + await page.evaluate(() => localStorage.setItem('meshcore-theme', 'light')); + }); + + await step('side panel: same text', async () => { + await page.goto(BASE + '/#/nodes?search=' + encodeURIComponent('Advert Interval E2E'), { waitUntil: 'domcontentloaded' }); + await page.click('tr[data-key="' + BOTH + '"]'); + const s = await intervals(page, '#node-pane-adverts'); + assert(s.flood === FLOOD_12H && s.zero_hop === ZH_120, 'pane: ' + JSON.stringify(s)); + await shot(page, 'pane-both-light'); + }); + + await step('mobile 390×844: no horizontal overflow', async () => { + const m = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const mp = await m.newPage(); + mp.on('pageerror', (e) => errors.push(e.message)); + await mp.goto(BASE + '/#/nodes/' + FLOOD_ONLY, { waitUntil: 'domcontentloaded' }); + const s = await intervals(mp, full); + assert(s.zero_hop === ZH_NONE, 'mobile zero-hop: ' + s.zero_hop); + const o = await mp.evaluate((sel) => { + const card = document.querySelector(sel); + return { doc: document.documentElement.scrollWidth - document.documentElement.clientWidth, card: card.scrollWidth - card.clientWidth }; + }, full); + assert(o.doc <= 0 && o.card <= 0, 'horizontal overflow ' + JSON.stringify(o)); + await shot(mp, 'mobile-flood-only', full + ' .node-adverts-intervals'); + await m.close(); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + + await browser.close(); + console.log(`\n${passed}/${passed + failed} passed`); + process.exit(failed === 0 ? 0 : 1); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test-node-adverts.js b/test-node-adverts.js index 7adf066db..7bbb8af8e 100644 --- a/test-node-adverts.js +++ b/test-node-adverts.js @@ -162,6 +162,66 @@ test('escaping: node-controlled text never reaches the HTML raw', () => { assert.ok(html.includes('<script>')); }); +// #245: estimated flood / zero-hop advert intervals under the counts. +const est = (over) => Object.assign({ interval_s: null, raw_interval_s: null, snapped: false, samples: 0, gaps_used: 0, confidence: 'none', last_advert: null }, over || {}); +const intervals = (flood, zh) => ({ window: 20, flood: flood, zero_hop: zh }); +function intervalRows(html) { + const out = {}; + const re = /([\s\S]*?)<\/span>(?=)/g; + let m; + while ((m = re.exec(html))) out[m[1]] = m[2].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + return out; +} + +test('intervals: both classes shown, flood in hours and zero-hop in minutes', () => { + const d = detail({ advertIntervals: intervals( + est({ interval_s: 43200, raw_interval_s: 43195, snapped: true, samples: 10, gaps_used: 7, confidence: 'high', last_advert: '2026-09-25T10:00:00Z' }), + est({ interval_s: 7200, raw_interval_s: 7203, snapped: true, samples: 6, gaps_used: 5, confidence: 'medium', last_advert: '2026-09-25T11:00:00Z' })) }); + const html = NA.render(d, opts()); + const rows = intervalRows(html); + assert.strictEqual(rows.flood, 'Estimated flood interval ≈ 12 h (10 adverts, high confidence)'); + assert.strictEqual(rows.zero_hop, 'Estimated zero-hop interval ≈ 120 min (6 adverts, medium confidence)'); + assert.ok(html.indexOf('node-adverts-intervals') > html.indexOf('node-adverts-counts'), 'below the counts'); + assert.ok(html.indexOf('node-adverts-intervals') < html.indexOf('node-adverts-tabs'), 'above the tabs'); + assert.ok(/class="node-adverts-intervals" title="[^"]*median[^"]*"/.test(html), 'tooltip explains the method'); + const pane = intervalRows(NA.render(d, opts({ variant: 'pane' }))); + assert.strictEqual(pane.flood, rows.flood, 'side panel shows the same'); +}); + +test('intervals: no zero-hop adverts reads as off or out of direct range', () => { + const rows = intervalRows(NA.render(detail({ advertIntervals: intervals( + est({ interval_s: 169200, snapped: true, samples: 14, gaps_used: 12, confidence: 'high' }), est()) }), opts())); + assert.strictEqual(rows.flood, 'Estimated flood interval ≈ 47 h (14 adverts, high confidence)'); + assert.strictEqual(rows.zero_hop, 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'); + const noFlood = intervalRows(NA.render(detail({ advertIntervals: intervals(est(), est()) }), opts())); + assert.strictEqual(noFlood.flood, 'Estimated flood interval: none observed'); +}); + +test('intervals: too few, irregular, low confidence, 2-minute default and unsnapped values', () => { + const rows = (f, z) => intervalRows(NA.render(detail({ advertIntervals: intervals(f, z) }), opts())); + let r = rows(est({ samples: 2, last_advert: 'x' }), est({ samples: 1, last_advert: 'x' })); + assert.strictEqual(r.flood, 'Estimated flood interval: not enough adverts yet (2 heard)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval: not enough adverts yet (1 heard)'); + r = rows(est({ samples: 6, last_advert: 'x' }), est({ interval_s: 120, snapped: true, samples: 30, gaps_used: 20, confidence: 'high' })); + assert.strictEqual(r.flood, 'Estimated flood interval: irregular (6 adverts, no repeating gap)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 2 min (30 adverts, high confidence)'); + r = rows(est({ interval_s: 9000, raw_interval_s: 9000, samples: 3, gaps_used: 2, confidence: 'low' }), + est({ interval_s: 3000, raw_interval_s: 3000, samples: 4, gaps_used: 3, confidence: 'medium' })); + assert.strictEqual(r.flood, 'Estimated flood interval ≈ 2.5 h (3 adverts, low confidence, outside the settable 3–168 h)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 50 min (4 adverts, medium confidence, outside the settable 60–240 min)'); +}); + +test('intervals: absent without the field, values escaped', () => { + assert.ok(!NA.render(detail(), opts()).includes('node-adverts-intervals'), 'older server / hidden identity: no block'); + assert.ok(!NA.render(detail({ advertIntervals: null }), opts()).includes('node-adverts-intervals')); + const html = NA.render(detail({ advertIntervals: intervals( + est({ interval_s: 43200, snapped: true, samples: '9', gaps_used: 7, confidence: '' }), + { interval_s: '', samples: 0, confidence: 'high' }) }), opts()); + assert.ok(!html.includes('9'), 'nothing raw: ' + html); + assert.ok(html.includes('<svg onload=alert(2)> confidence'), 'confidence rendered escaped'); + assert.ok(intervalRows(html).zero_hop.includes('none observed'), 'a non-numeric interval is no estimate'); +}); + test('existing badges and signal readouts are kept', () => { const r = row(8, 'flood', { observation_count: 3, snr: 7.5, rssi: -91, observer_name: 'ObsA', raw_hex: '11c1' }); const d = detail({ recentAdverts: [r] }); From 2414a2ede4e7ceef12f48aac272efe401ad56ec0 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 09:18:19 +0000 Subject: [PATCH 02/10] feat(nodes): show estimated flood and zero-hop advert intervals on node detail (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #245 M1. advertIntervals on GET /api/nodes/{pubkey}?include=advertRoutes estimates each class's advert interval from the gaps between the adverts the Flood / Zero-hop lists already hold (no extra query, cached with the breakdown): sender timestamps when plausible, else first_seen; the interval must be seen directly in >= 2 gaps and a quarter of them; 2-4x gaps count as missed adverts, shorter ones are dropped; median of the fitting gaps, snapped to the firmware's settable values (MeshCore src/helpers/CommonCLI.cpp:486-505). Named structs, documented in openapi.go and docs/api-spec.md. Node detail (full page and side panel) shows both classes under the counts, e.g. "Estimated flood interval ≈ 12 h (10 adverts, high confidence)" and "Estimated zero-hop interval: none observed (off, or no observer in direct range)". CI seeds seed-245-advert-intervals.sql and runs the new E2E. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 6 + cmd/server/advert_intervals.go | 301 +++++++++++++++++++++++++ cmd/server/node_advert_routes_cache.go | 14 +- cmd/server/openapi.go | 27 ++- cmd/server/routes.go | 1 + cmd/server/types.go | 2 + docs/api-spec.md | 64 +++++- public/node-adverts.js | 43 +++- public/nodes.js | 4 +- public/style.css | 2 + 10 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 cmd/server/advert_intervals.go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 35e29204b..e3f33d75b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -374,6 +374,11 @@ jobs: # until corescope-migrate creates it. run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-199-inactive-observer.sql + - name: Seed adverts with known gaps for #245 E2E + # After the migration (sets transmissions.route_mask). See the file + # header for the rows and the expected interval estimates. + run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-245-advert-intervals.sql + - name: Start Go server with fixture DB run: | fuser -k 13581/tcp 2>/dev/null || true @@ -452,6 +457,7 @@ jobs: CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-2073-recent-adverts-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-245-advert-intervals-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1486-collapse-reopens-detail-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/advert_intervals.go b/cmd/server/advert_intervals.go new file mode 100644 index 000000000..1c37b56d7 --- /dev/null +++ b/cmd/server/advert_intervals.go @@ -0,0 +1,301 @@ +package main + +import ( + "encoding/json" + "math" + "sort" + "time" +) + +// Estimated advert intervals per node (#245 M1): how often a node sends +// flood and zero-hop adverts, estimated from the gaps between the adverts +// CoreScope heard, for the node detail page. +// +// What the firmware does (MeshCore source; AGENTS.md: read it, don't guess): +// - flood.advert.interval: flood_advert_interval, whole hours, 0 = off, +// else 3-168 (src/helpers/CommonCLI.h:34, src/helpers/CommonCLI.cpp:486-495). +// Default 47 h on repeaters and room servers +// (examples/simple_repeater/MyMesh.cpp:904), off on sensors. +// - advert.interval: advert_interval, stored as minutes / 2, 0 = off, else +// 60-240 minutes, so even minutes (src/helpers/CommonCLI.h:33, +// src/helpers/CommonCLI.cpp:496-505). A new install starts at 2 minutes +// (examples/simple_repeater/MyMesh.cpp:903) until the first savePrefs +// turns anything below 60 off (src/helpers/CommonCLI.cpp:160-165). +// - Both timers re-arm with futureMillis(interval) when they fire, and the +// flood advert re-arms the zero-hop timer too +// (examples/simple_repeater/MyMesh.cpp:1044-1058, 1294-1306): the +// zero-hop gap across a flood advert is irregular. +// - Extra adverts: the advert / advert.zerohop CLI commands do not re-arm +// a timer (src/helpers/CommonCLI.cpp:191-198), and a boot sends a +// zero-hop advert (examples/simple_repeater/main.cpp:119). +// - The advert carries the sender's RTC time (src/Mesh.cpp:418), which +// can be years off or jump (clkreboot, src/helpers/CommonCLI.cpp:187-190). +// +// The estimate uses the adverts the Flood and Zero-hop panels list (the +// newest nodeAdvertRouteLimit per class, GetNodeAdvertRoutes), so it costs +// no extra query; mixed and unknown adverts are left out as their class is +// ambiguous. transmissions.hash is unique, so the rows are distinct adverts. +// +// The thresholds below are hardcoded; with the minimum samples they are +// candidates for the customizer (#245 "Later", AGENTS.md rule 8). + +type advertIntervalClass int + +const ( + advertIntervalFlood advertIntervalClass = iota + advertIntervalZeroHop +) + +const ( + advertConfidenceHigh = "high" + advertConfidenceMedium = "medium" + advertConfidenceLow = "low" + advertConfidenceNone = "none" +) + +const ( + // advertIntervalMinSamples is the fewest adverts (two gaps) estimated. + advertIntervalMinSamples = 3 + // advertIntervalTol is how far a gap may be from k x interval, as a + // fraction of the interval, and how far outside the firmware range an + // estimate may be and still snap to it. + advertIntervalTol = 0.10 + // advertIntervalMaxMultiple is the longest run of missed adverts a gap + // may stand for (k - 1). + advertIntervalMaxMultiple = 4 + // advertClockSlackS is how far the sender clock may be ahead of + // first_seen, and the least disagreement between a sender gap and the + // first_seen gap that rejects the sender gap (else 10 % of the gap). + advertClockSlackS = 600 +) + +// AdvertIntervalEstimate is one route class of NodeAdvertIntervals. +type AdvertIntervalEstimate struct { + // IntervalS is the estimate in seconds, snapped to the nearest value + // the firmware can be set to when it is close to one; null when there + // is no estimate (confidence none). + IntervalS *int64 `json:"interval_s"` + // RawIntervalS is the median before snapping. + RawIntervalS *int64 `json:"raw_interval_s"` + Snapped bool `json:"snapped"` + // Samples is the number of adverts used, GapsUsed the gaps between them + // that fit a whole multiple of the interval. + Samples int `json:"samples"` + GapsUsed int `json:"gaps_used"` + Confidence string `json:"confidence"` + LastAdvert *string `json:"last_advert"` +} + +// NodeAdvertIntervals is advertIntervals on node detail (include=advertRoutes). +type NodeAdvertIntervals struct { + Window int `json:"window"` + Flood AdvertIntervalEstimate `json:"flood"` + ZeroHop AdvertIntervalEstimate `json:"zero_hop"` +} + +// advertIntervalSample is one advert: the sender's clock (0 when unknown) +// and when it was first heard. +type advertIntervalSample struct { + senderTS int64 + heard time.Time +} + +// nodeAdvertIntervals estimates both classes from the per-class lists. +func nodeAdvertIntervals(byRoute NodeAdvertsByRoute) NodeAdvertIntervals { + return NodeAdvertIntervals{ + Window: byRoute.Limit, + Flood: estimateAdvertInterval(advertIntervalSamples(byRoute.Flood), advertIntervalFlood), + ZeroHop: estimateAdvertInterval(advertIntervalSamples(byRoute.ZeroHop), advertIntervalZeroHop), + } +} + +// advertIntervalSamples reads first_seen and the decoded advert timestamp +// of each row. Rows without a parseable first_seen are skipped; a missing +// or malformed timestamp leaves senderTS 0. +func advertIntervalSamples(rows NodeAdvertRows) []advertIntervalSample { + out := make([]advertIntervalSample, 0, len(rows)) + for _, r := range rows { + fs, _ := r["first_seen"].(string) + heard, ok := parseRelayTS(fs) + if !ok { + continue + } + s := advertIntervalSample{heard: heard} + if dj, _ := r["decoded_json"].(string); dj != "" { + var d struct { + Timestamp int64 `json:"timestamp"` + } + if json.Unmarshal([]byte(dj), &d) == nil { + s.senderTS = d.Timestamp + } + } + out = append(out, s) + } + return out +} + +// advertIntervalFloorS is the shortest interval the class's timer can run +// at, less the tolerance: shorter gaps are never a candidate interval (a +// burst of manual adverts). +func advertIntervalFloorS(class advertIntervalClass) float64 { + if class == advertIntervalZeroHop { + return 120 * (1 - advertIntervalTol) + } + return 3 * 3600 * (1 - advertIntervalTol) +} + +// estimateAdvertInterval estimates one class's interval: +// 1. Gaps between consecutive adverts (by first_seen). The sender's clock +// gives the gap when both adverts have a plausible one - not ahead of +// first_seen by more than advertClockSlackS - and its gap is positive +// and agrees with first_seen's (a clock jump does not); else first_seen. +// 2. A candidate interval is a gap seen directly (within the tolerance) in +// at least two gaps and a quarter of them, and not shorter than the +// class's timer allows. The one explaining the most gaps as 1-4 x +// itself wins (ties: the longer, fewer missed adverts). k x interval +// is k - 1 missed adverts; shorter gaps are extra adverts, dropped. +// 3. The interval is the median of gap/k over the gaps it explains, then +// snapped to the firmware's values (snapAdvertInterval). +// +// The candidate search is O(gaps^2) with gaps < nodeAdvertRouteLimit. +func estimateAdvertInterval(samples []advertIntervalSample, class advertIntervalClass) AdvertIntervalEstimate { + est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone} + if len(samples) == 0 { + return est + } + s := append([]advertIntervalSample(nil), samples...) + sort.Slice(s, func(i, j int) bool { + if !s[i].heard.Equal(s[j].heard) { + return s[i].heard.Before(s[j].heard) + } + return s[i].senderTS < s[j].senderTS + }) + last := s[len(s)-1].heard.UTC().Format(time.RFC3339) + est.LastAdvert = &last + if len(s) < advertIntervalMinSamples { + return est + } + + gaps := advertGaps(s) + best, bestExplained := 0.0, 0 + for _, c := range gaps { + if c < advertIntervalFloorS(class) { + continue + } + direct, explained := 0, 0 + for _, g := range gaps { + k := advertGapMultiple(g, c) + if k == 1 { + direct++ + } + if k > 0 { + explained++ + } + } + if direct < 2 || direct*4 < len(gaps) { + continue + } + if explained > bestExplained || (explained == bestExplained && c > best) { + best, bestExplained = c, explained + } + } + if best == 0 { + return est + } + + var units []float64 + for _, g := range gaps { + if k := advertGapMultiple(g, best); k > 0 { + units = append(units, g/float64(k)) + } + } + interval := medianFloat(units) + + used, irregular := 0, 0 + for _, g := range gaps { + switch { + case advertGapMultiple(g, interval) > 0: + used++ + case g >= interval*(1-advertIntervalTol): + irregular++ + } + } + if used < 2 { + return est + } + raw := int64(math.Round(interval)) + snapped, ok := snapAdvertInterval(interval, class) + est.RawIntervalS, est.IntervalS, est.Snapped, est.GapsUsed = &raw, &snapped, ok, used + ratio := float64(used) / float64(used+irregular) + switch { + case used >= 6 && ratio >= 0.75: + est.Confidence = advertConfidenceHigh + case used >= 3 && ratio >= 0.5: + est.Confidence = advertConfidenceMedium + default: + est.Confidence = advertConfidenceLow + } + return est +} + +// advertGaps is the positive gaps in seconds between consecutive samples +// (sorted by heard), each from the sender's clock when it is plausible. +func advertGaps(s []advertIntervalSample) []float64 { + plausible := func(a advertIntervalSample) bool { + return a.senderTS > 0 && a.senderTS <= a.heard.Unix()+advertClockSlackS + } + gaps := make([]float64, 0, len(s)-1) + for i := 1; i < len(s); i++ { + g := s[i].heard.Sub(s[i-1].heard).Seconds() + if plausible(s[i]) && plausible(s[i-1]) { + ds := float64(s[i].senderTS - s[i-1].senderTS) + if ds > 0 && math.Abs(ds-g) <= math.Max(advertClockSlackS, advertIntervalTol*g) { + g = ds + } + } + if g > 0 { + gaps = append(gaps, g) + } + } + return gaps +} + +// advertGapMultiple is k when gap is k x interval within the tolerance +// (k = 1..advertIntervalMaxMultiple), else 0. +func advertGapMultiple(gap, interval float64) int { + k := int(math.Round(gap / interval)) + if k < 1 || k > advertIntervalMaxMultiple || math.Abs(gap-float64(k)*interval) > advertIntervalTol*interval { + return 0 + } + return k +} + +func medianFloat(v []float64) float64 { + s := append([]float64(nil), v...) + sort.Float64s(s) + n := len(s) + if n%2 == 1 { + return s[n/2] + } + return (s[n/2-1] + s[n/2]) / 2 +} + +// snapAdvertInterval rounds seconds to the nearest value the class's setting +// can take (see the file comment): flood whole hours 3-168, zero-hop even +// minutes 60-240 or the 2-minute new-install default. Estimates further +// than the tolerance outside that range are returned rounded to a second +// and not snapped. +func snapAdvertInterval(seconds float64, class advertIntervalClass) (int64, bool) { + unit, lo, hi := 3600.0, 3.0, 168.0 // flood: hours + if class == advertIntervalZeroHop { + if math.Abs(seconds-120) <= advertIntervalTol*120 { + return 120, true + } + unit, lo, hi = 120.0, 30.0, 120.0 // zero-hop: 2-minute steps, 60-240 min + } + if seconds < lo*unit*(1-advertIntervalTol) || seconds > hi*unit*(1+advertIntervalTol) { + return int64(math.Round(seconds)), false + } + steps := math.Min(hi, math.Max(lo, math.Round(seconds/unit))) + return int64(steps * unit), true +} diff --git a/cmd/server/node_advert_routes_cache.go b/cmd/server/node_advert_routes_cache.go index 1f5bd9fcf..4794a93f5 100644 --- a/cmd/server/node_advert_routes_cache.go +++ b/cmd/server/node_advert_routes_cache.go @@ -36,10 +36,11 @@ const ( ) type nodeAdvertRouteEntry struct { - byRoute NodeAdvertsByRoute - counts NodeAdvertCounts - latestID int64 // the node's newest transmission id when computed - at time.Time + byRoute NodeAdvertsByRoute + counts NodeAdvertCounts + intervals NodeAdvertIntervals // #245, derived from byRoute + latestID int64 // the node's newest transmission id when computed + at time.Time } // nodeAdvertRouteCache is usable as a zero value. Cached rows are shared @@ -114,6 +115,7 @@ func (db *DB) latestTransmissionIDForNode(pubkey string) (int64, error) { type nodeAdvertRouteResult struct { byRoute NodeAdvertsByRoute counts NodeAdvertCounts + intervals NodeAdvertIntervals floodAdvertCount7d *int } @@ -151,7 +153,7 @@ func (s *Server) nodeAdvertRoutes(pubkey string, now time.Time) (nodeAdvertRoute if err != nil { return nil, err } - fresh := nodeAdvertRouteEntry{byRoute: byRoute, counts: counts, latestID: latestID, at: now} + fresh := nodeAdvertRouteEntry{byRoute: byRoute, counts: counts, intervals: nodeAdvertIntervals(byRoute), latestID: latestID, at: now} c.put(pubkey, fresh, now) return nodeAdvertRouteScan{entry: fresh, flood7d: flood7d}, nil }) @@ -164,7 +166,7 @@ func (s *Server) nodeAdvertRoutes(pubkey string, now time.Time) (nodeAdvertRoute res.floodAdvertCount7d = &scan.flood7d } } - res.byRoute, res.counts = e.byRoute, e.counts + res.byRoute, res.counts, res.intervals = e.byRoute, e.counts, e.intervals res.counts.RouteMaskBackfill = s.db.routeMaskBackfillStatus() return res, nil } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 6cd374fae..332b6d5dc 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -86,9 +86,9 @@ func routeDescriptions() map[string]routeMeta { "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, - "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), + "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed, advertIntervals (#245) estimates the flood and zero-hop advert intervals, and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), QueryParams: []paramMeta{ - {Name: "include", Description: "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.", Type: "string"}, + {Name: "include", Description: "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts, advertIntervals (#245) and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.", Type: "string"}, }}, "GET /api/nodes/{pubkey}/clock-skew": {Summary: "Get node clock skew", Description: "Per-node clock-skew analysis derived from ADVERT advert-timestamps vs observation times, calibrated per observer (see ClockSkewEngine). samples is the full per-advert time series in chronological order (sparkline data) by default. Fase 5.2b's sample_limit trims that array before it's sent to the client — it only reduces JSON serialization/payload/client-decoding cost, not the server-side computation or allocation that already produced the full samples slice.", Tag: "nodes", QueryParams: []paramMeta{ @@ -259,6 +259,28 @@ func nodeAdvertRouteSchemas() map[string]*openAPISchema { Type: "object", Properties: map[string]*openAPISchema{"flood": count, "zero_hop": count, "mixed": count, "unknown": count}, }, + "NodeAdvertIntervals": { + Type: "object", + Description: "Node detail with include=advertRoutes only (#245): the node's estimated flood and zero-hop advert intervals, from the gaps between the adverts listed in recentAdvertsByRoute.flood / .zero_hop (mixed and unknown adverts are not used). A gap uses the adverts' own (sender) timestamps when both are plausible - not ahead of first_seen by more than 10 min, positive, and within max(10 min, 10 %) of the first_seen gap - else first_seen. The interval must be seen directly in at least two gaps and a quarter of them; gaps of 2-4x it count as missed adverts, shorter gaps (manual adverts, reboots) are dropped. It is the median of the fitting gaps, snapped to the firmware's settable values: flood.advert.interval whole hours 3-168, advert.interval even minutes 60-240 or the 2-minute new-install default. No zero-hop adverts can mean the node's zero-hop interval is 0 (off) or that no observer hears it directly. Absent without include=advertRoutes and when the node's identity is hidden; cached with recentAdvertsByRoute.", + Properties: map[string]*openAPISchema{ + "window": {Type: "integer", Description: "Most adverts per class considered (the recentAdvertsByRoute limit, 20)."}, + "flood": openAPIRef("AdvertIntervalEstimate"), + "zero_hop": openAPIRef("AdvertIntervalEstimate"), + }, + }, + "AdvertIntervalEstimate": { + Type: "object", + Description: "One route class of NodeAdvertIntervals.", + Properties: map[string]*openAPISchema{ + "interval_s": {Type: "integer", Nullable: true, Description: "Estimated interval in seconds, snapped when snapped is true; null when confidence is none."}, + "raw_interval_s": {Type: "integer", Nullable: true, Description: "The median before snapping; null when confidence is none."}, + "snapped": {Type: "boolean", Description: "true when the estimate is within 10 % of the firmware's settable range and interval_s is the nearest settable value."}, + "samples": count, + "gaps_used": {Type: "integer", Minimum: &zero, Description: "Gaps between the samples that fit 1-4x the interval."}, + "confidence": {Type: "string", Enum: []string{advertConfidenceHigh, advertConfidenceMedium, advertConfidenceLow, advertConfidenceNone}, Description: "high: >= 6 fitting gaps and >= 75 % of the non-short gaps fit; medium: >= 3 and >= 50 %; low: fewer; none: under 3 adverts or no interval seen at least twice."}, + "last_advert": {Type: "string", Nullable: true, Description: "RFC3339 first_seen of the newest advert in the class; null when there is none."}, + }, + }, "RouteMaskBackfillStatus": { Type: "object", Description: "The ingestor's transmissions.route_mask backfill (#89). Until complete, rows without a mask are classified by their first-inserted route_type, so route classes are provisional.", @@ -356,6 +378,7 @@ func componentSchemas() map[string]interface{} { "recentAdverts": map[string]interface{}{"type": "array", "items": schemaRef("NodeAdvert"), "description": "Up to 20 most recent transmissions from this node (newest ingest first, #1345), all route classes together."}, "recentAdvertsByRoute": openAPIRef("NodeAdvertsByRoute"), "advertCounts": openAPIRef("NodeAdvertCounts"), + "advertIntervals": openAPIRef("NodeAdvertIntervals"), }, }, "NodeAdvert": map[string]interface{}{ diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7228a9430..c0deec580 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2023,6 +2023,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { if res, err := s.nodeAdvertRoutes(pubkey, time.Now()); err == nil { resp.RecentAdvertsByRoute = &res.byRoute resp.AdvertCounts = &res.counts + resp.AdvertIntervals = &res.intervals floodFromScan = res.floodAdvertCount7d } else { log.Printf("WARN nodeAdvertRoutes(%s): %v", pubkey, err) diff --git a/cmd/server/types.go b/cmd/server/types.go index 2805a87af..89fe0b075 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -860,6 +860,8 @@ type NodeDetailResponse struct { // #2073: omitted when the identity is hidden (identityHidden, #68). RecentAdvertsByRoute *NodeAdvertsByRoute `json:"recentAdvertsByRoute,omitempty"` AdvertCounts *NodeAdvertCounts `json:"advertCounts,omitempty"` + // #245: estimated flood / zero-hop advert intervals, same opt-in. + AdvertIntervals *NodeAdvertIntervals `json:"advertIntervals,omitempty"` } // NodeAdvertRow is one transmission row on node detail: the /api/packets diff --git a/docs/api-spec.md b/docs/api-spec.md index 739620d36..2f1491d3a 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -467,10 +467,10 @@ Node detail page data. | Param | Type | Description | |-----------|--------|-------------| -| `include` | string | Opt-in extras, comma-separated (may also repeat). `advertRoutes` adds `recentAdvertsByRoute`, `advertCounts` and `route_class` on the `recentAdverts` ADVERT rows (see [Advert route classes](#advert-route-classes)). Unknown values are ignored. | +| `include` | string | Opt-in extras, comma-separated (may also repeat). `advertRoutes` adds `recentAdvertsByRoute`, `advertCounts`, `advertIntervals` and `route_class` on the `recentAdverts` ADVERT rows (see [Advert route classes](#advert-route-classes) and [Estimated advert intervals](#estimated-advert-intervals)). Unknown values are ignored. | Without `include=advertRoutes` the response is exactly the pre-#2073 one: -no `recentAdvertsByRoute`, no `advertCounts`, no `route_class`. The breakdown +no `recentAdvertsByRoute`, no `advertCounts`, no `advertIntervals`, no `route_class`. The breakdown scans all of the node's ADVERT rows, so only the node page (full view and side panel) asks for it; the packets, live, channels and route views and the claimed-nodes lookups do not. @@ -508,10 +508,29 @@ claimed-nodes lookups do not. "7d": { "flood": number, "zero_hop": number, "mixed": number, "unknown": number }, "truncated": boolean, // more adverts than the 50,000-row cap in the 7d floor "route_mask_backfill": { "status": "pending" | "backfilling" | "complete", "remaining": number | null } + }, + "advertIntervals": { // include=advertRoutes only; absent when the identity is hidden + "window": 20, // most adverts per class considered + "flood": AdvertIntervalEstimate, + "zero_hop": AdvertIntervalEstimate } } ``` +Where `AdvertIntervalEstimate` is: + +```jsonc +{ + "interval_s": number | null, // estimate in seconds, snapped when "snapped"; null when confidence is none + "raw_interval_s": number | null, // the median before snapping + "snapped": boolean, + "samples": number, // adverts used + "gaps_used": number, // gaps between them that fit 1-4x the interval + "confidence": "high" | "medium" | "low" | "none", + "last_advert": string (ISO) | null // first_seen of the newest advert in the class +} +``` + Where `Packet` is a transmission object (see [Packet Object](#packet-object)). #### Advert route classes @@ -557,6 +576,47 @@ whether that fallback is still in use (anything but `complete`: provisional). the node for the breakdown itself takes the identical number from that scan). +#### Estimated advert intervals + +`advertIntervals` (#245) estimates how often the node sends flood and +zero-hop adverts, from the gaps between the adverts listed in +`recentAdvertsByRoute.flood` and `.zero_hop` (so at most 20 per class, and no +extra query). Mixed and unknown adverts are not used. + +The firmware settings it maps to (MeshCore `src/helpers/CommonCLI.cpp`): + +| Class | Setting | Allowed values | Default | +|-------|---------|----------------|---------| +| `flood` | `flood.advert.interval` (hours) | 0 = off, 3–168 | 47 h on repeaters and room servers, off on sensors | +| `zero_hop` | `advert.interval` (minutes, stored / 2) | 0 = off, 60–240, even minutes | 2 min on an untouched new install, off after the first saved setting | + +- **Gaps.** A gap is taken from the adverts' own (sender) timestamps + when both are plausible: not ahead of `first_seen` by more than 10 min, + positive, and within max(10 min, 10 %) of the `first_seen` gap. Otherwise + it is taken from `first_seen`. A sender clock that is wrong by a steady + offset is still used; a jump or reset is not. +- **The interval.** It must be seen directly, within 10 %, in at least two + gaps and a quarter of them. It may not be shorter than the class's timer + allows. Of those candidates, the one that explains the most gaps as 1–4× + itself wins. + - A gap of k× the interval counts as k−1 missed adverts. + - Shorter gaps are dropped: manual adverts and reboots, and the zero-hop + gap that a flood advert's timer reset makes irregular. +- **The value.** It is the median of gap/k over the fitting gaps (`raw_interval_s`). + It is snapped to the nearest settable value when it lies within 10 % of + the settable range (`snapped`). +- **Confidence.** + - `high`: ≥ 6 fitting gaps, and ≥ 75 % of the gaps that are not short fit. + - `medium`: ≥ 3 fitting gaps and ≥ 50 %. + - `low`: anything less. + - `none`: fewer than 3 adverts, or no interval seen twice. `interval_s` + is then `null`. +- **No zero-hop adverts.** A zero-hop advert is only recorded when an + observer hears the node directly. "None observed" can therefore mean that + the interval is 0 (off), or that no observer is in direct range. +- **Visibility and caching.** The field is cached and hidden together with + `recentAdvertsByRoute`. + ### Response `404` ```json diff --git a/public/node-adverts.js b/public/node-adverts.js index dc0822ca7..957b01a2c 100644 --- a/public/node-adverts.js +++ b/public/node-adverts.js @@ -4,7 +4,8 @@ * panel. The "Recent Adverts" name and its tooltip port upstream * `Kpa-clawbot/CoreScope#2071`; the per-route tabs and counts extend upstream * `Kpa-clawbot/CoreScope#2073` over the node-detail fields - * recentAdvertsByRoute and advertCounts (see docs/api-spec.md). + * recentAdvertsByRoute and advertCounts, and the estimated advert intervals + * (#245, advertIntervals) below the counts (see docs/api-spec.md). * * render() returns an HTML string built only from escaped values; bind() * wires the tab bar through the app's initTabBar (roles, arrow keys) and @@ -109,6 +110,44 @@ return html + ''; } + // #245: estimated advert intervals (advertIntervals, docs/api-spec.md). + // Flood in hours and zero-hop in minutes, the units of the firmware's + // flood.advert.interval and advert.interval settings. + var INTERVAL_TIP = 'Estimated from the gaps between the newest adverts of each class (the Flood and Zero-hop tabs), using the sender\'s own timestamps when its clock is plausible. The median gap is taken; gaps of 2-4x it count as missed adverts, shorter ones (manual adverts, reboots) are ignored. Snapped to the values the firmware allows: flood.advert.interval 3-168 h, advert.interval 60-240 min in 2-minute steps.'; + var INTERVAL_CLASSES = [ + { key: 'flood', label: 'flood', unit: 3600, unitLabel: 'h', range: '3–168 h', none: 'none observed' }, + { key: 'zero_hop', label: 'zero-hop', unit: 60, unitLabel: 'min', range: '60–240 min', none: 'none observed (off, or no observer in direct range)' } + ]; + + function fmtInterval(seconds, c) { + var v = seconds / c.unit; + if (c.unit === 3600 && v < 1) { v = seconds / 60; return Math.round(v) + ' min'; } + return (Math.round(v * 10) / 10) + ' ' + c.unitLabel; + } + + function intervalRow(e, c) { + e = e || {}; + var n = num(e.samples); + var seconds = Number(e.interval_s); + var label = 'Estimated ' + c.label + ' interval'; + var text; + if (e.interval_s != null && seconds > 0 && e.confidence !== 'none') { + var notes = [n + ' advert' + (n === 1 ? '' : 's'), esc(e.confidence) + ' confidence']; + if (!e.snapped) notes.push('outside the settable ' + c.range); + text = ' ≈ ' + fmtInterval(seconds, c) + ' (' + notes.join(', ') + ')'; + } else { + label += ':'; + text = ' ' + (n === 0 ? c.none : n < 3 ? 'not enough adverts yet (' + n + ' heard)' : 'irregular (' + n + ' adverts, no repeating gap)'); + } + return '' + label + '' + text + ''; + } + + function intervalsHtml(intervals) { + if (!intervals) return ''; + return '
' + + INTERVAL_CLASSES.map(function (c) { return intervalRow(intervals[c.key], c); }).join('') + '
'; + } + function noteHtml(counts) { var st = counts && counts.route_mask_backfill && counts.route_mask_backfill.status; if (!counts || st === 'complete') return ''; @@ -190,7 +229,7 @@ }); var tab = tabs.some(function (t) { return t.key === opts.tab; }) ? opts.tab : 'all'; - html += countsHtml(detail.advertCounts) + noteHtml(detail.advertCounts); + html += countsHtml(detail.advertCounts) + intervalsHtml(detail.advertIntervals) + noteHtml(detail.advertCounts); // initTabBar (bind) adds role="tablist"; it skips a bar that has one. html += '
' + tabs.map(function (t) { var on = t.key === tab; diff --git a/public/nodes.js b/public/nodes.js index 516220da8..07ed7bfc0 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -882,7 +882,7 @@
- ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: nodeData.recentAdvertsByRoute, advertCounts: nodeData.advertCounts }, { + ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: nodeData.recentAdvertsByRoute, advertCounts: nodeData.advertCounts, advertIntervals: nodeData.advertIntervals }, { variant: 'full', idPrefix: 'nodeFullAdverts', tab: NodeAdverts.parseTab(location.hash), timestampHtml: renderNodeTimestampHtml, hashSizeInconsistent: !!n.hash_size_inconsistent, })} @@ -1925,7 +1925,7 @@
- ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: data.recentAdvertsByRoute, advertCounts: data.advertCounts }, { + ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: data.recentAdvertsByRoute, advertCounts: data.advertCounts, advertIntervals: data.advertIntervals }, { variant: 'pane', idPrefix: 'nodePaneAdverts', tab: NodeAdverts.parseTab(location.hash), timestampHtml: renderNodeTimestampHtml, roleColor: roleColor, })} diff --git a/public/style.css b/public/style.css index 5a72043eb..d5e338f80 100644 --- a/public/style.css +++ b/public/style.css @@ -2869,6 +2869,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .node-adverts-counts { display: flex; flex-wrap: wrap; gap: 2px 16px; margin: 0 0 8px; font-size: 12px; color: var(--text-muted); } .node-adverts-count-row strong { margin-right: 4px; color: var(--text); } .node-adverts-note { margin: 0 0 8px; font-size: 12px; } +.node-adverts-intervals { display: flex; flex-direction: column; gap: 2px; margin: 0 0 8px; font-size: 12px; color: var(--text-muted); cursor: help; } +.node-adverts-interval-row strong { margin-right: 4px; color: var(--text); } .node-adverts-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; } .node-adverts-tabs .tab-btn { padding: 4px 10px; } .node-adverts-empty { font-size: 13px; } From fd82aee3819e6cc48ee0476f56cd586c31b79dc8 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:00:00 +0000 Subject: [PATCH 03/10] test(advert-intervals): pin the quarter candidate rule and the 4x multiple limit (#245) Review F3 on #247: mutants E1 (the 25 % direct-candidate rule dropped) and E2 (max multiple 4 -> 3) survived the suite. A 12 h series with two manual adverts splitting gaps into 4 h + 8 h now pins the quarter rule, and a 4x gap counted / 5x gap irregular pins the multiple limit. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index 7e56ffdc4..09c6ec0bd 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -126,6 +126,26 @@ func TestEstimateAdvertInterval_LongOutage(t *testing.T) { aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 8) } +// A gap of up to 4x the interval is missed adverts (three in a row); a +// longer one is an outage and irregular. +func TestEstimateAdvertInterval_MultipleLimit(t *testing.T) { + // 12 h with one 4x gap: all 8 gaps fit. + s := aiSeries(12*time.Hour, 0, 1, 2, 3, 7, 8, 9, 10, 11) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 8) + // A 5x gap instead: 7 of the 8 gaps fit. + s = aiSeries(12*time.Hour, 0, 1, 2, 3, 8, 9, 10, 11, 12) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 7) +} + +// A candidate must be seen directly in a quarter of the gaps, not only +// twice. Two manual adverts that each split a 12 h gap into 4 h + 8 h make +// 4 h a gap seen twice that explains every gap (4, 8 and 12 h are 1-3x +// 4 h), but 2 of 10 gaps is under a quarter, so 12 h stays. +func TestEstimateAdvertInterval_CandidateQuarter(t *testing.T) { + s := aiSeries(12*time.Hour, 0, 1, 4.0/3, 2, 3, 4, 13.0/3, 5, 6, 7, 8) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 11, 6) +} + // Sender clock: preferred when plausible, first_seen otherwise. func TestEstimateAdvertInterval_SenderClock(t *testing.T) { // first_seen jitters by up to 20 min (late uploads); the sender's From 672cd2cd560a6070fcc575552646066daa77bdf8 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:00:43 +0000 Subject: [PATCH 04/10] test(advert-intervals): reproduce a raised interval kept at the old value (#245) Review F1 on #247: after the interval is raised to 2-4x (flood 12 -> 24 h, 12/24 -> 47 h, zero-hop 60 -> 120 and 120 -> 240 min) the old interval explains every new gap as missed adverts and is reported at high confidence until about 15 of the 19 gaps carry the new one. Red on this commit: 8 of the 10 cases. The two green ones guard what must not change: two 2x gaps in a row stay missed adverts, and a lowered interval (47 -> 12 h) is still followed. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals_test.go | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index 09c6ec0bd..009db0e8a 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -31,6 +31,27 @@ func aiSeries(interval time.Duration, at ...float64) []advertIntervalSample { return out } +// aiGaps builds samples from aiBase separated by the given gaps, oldest +// first, the sender clock as in aiSeries. +func aiGaps(gaps ...time.Duration) []advertIntervalSample { + heard := aiBase + out := []advertIntervalSample{{senderTS: heard.Unix() - 2, heard: heard}} + for _, g := range gaps { + heard = heard.Add(g) + out = append(out, advertIntervalSample{senderTS: heard.Unix() - 2, heard: heard}) + } + return out +} + +// aiRepeat is n copies of gap. +func aiRepeat(gap time.Duration, n int) []time.Duration { + out := make([]time.Duration, n) + for i := range out { + out[i] = gap + } + return out +} + func aiInts(from, to int) []float64 { var out []float64 for i := from; i <= to; i++ { @@ -137,6 +158,58 @@ func TestEstimateAdvertInterval_MultipleLimit(t *testing.T) { aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 7) } +// A raised setting: the new interval is 2-4x the old one, so the old one +// explains every new gap as missed adverts. A run of gaps at the same +// multiple is the setting, not adverts missed in a row: once the newest 3 +// are, the estimate uses the adverts since the newest gap at the old +// interval. Setting either interval re-arms its timer at once +// (src/helpers/CommonCLI.cpp:491-492, 500-501), so the gap at the change is +// between the new interval and the new plus the old one. +func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { + H, M := time.Hour, time.Minute + series := func(parts ...[]time.Duration) []advertIntervalSample { + var gaps []time.Duration + for _, p := range parts { + gaps = append(gaps, p...) + } + return aiGaps(gaps...) + } + cases := []struct { + name string + class advertIntervalClass + s []advertIntervalSample + interval int64 + conf string + samples int + gaps int + }{ + // 10 adverts 12 h apart, then 10 more 24 h apart. + {"flood 12 h -> 24 h", advertIntervalFlood, series(aiRepeat(12*H, 9), aiRepeat(24*H, 10)), 24 * 3600, advertConfidenceHigh, 11, 10}, + {"flood 12 h -> 47 h", advertIntervalFlood, series(aiRepeat(12*H, 9), aiRepeat(47*H, 10)), 47 * 3600, advertConfidenceHigh, 11, 10}, + {"flood 24 h -> 47 h", advertIntervalFlood, series(aiRepeat(24*H, 9), aiRepeat(47*H, 10)), 47 * 3600, advertConfidenceHigh, 11, 10}, + {"zero-hop 60 -> 120 min", advertIntervalZeroHop, series(aiRepeat(60*M, 9), aiRepeat(120*M, 10)), 120 * 60, advertConfidenceHigh, 11, 10}, + {"zero-hop 120 -> 240 min", advertIntervalZeroHop, series(aiRepeat(120*M, 9), aiRepeat(240*M, 10)), 240 * 60, advertConfidenceHigh, 11, 10}, + // The timer re-armed when set: the gap at the change is 24 h + 6 h, + // irregular for both intervals. + {"flood 12 h -> 24 h, re-armed when set", advertIntervalFlood, series(aiRepeat(12*H, 9), []time.Duration{30 * H}, aiRepeat(24*H, 9)), 24 * 3600, advertConfidenceHigh, 11, 9}, + // A missed advert (48 h) and a manual one (10 h + 14 h) after the + // change: the newest 3 gaps that fit 12 h are still all 2x it. + {"flood 12 h -> 24 h, missed and manual adverts", advertIntervalFlood, series(aiRepeat(12*H, 9), []time.Duration{24 * H, 24 * H, 48 * H, 24 * H, 10 * H, 14 * H, 24 * H, 24 * H}), 24 * 3600, advertConfidenceHigh, 9, 6}, + // Three in a row at 2x: estimated from those three. + {"flood 12 h -> 24 h, 3 new gaps", advertIntervalFlood, series(aiRepeat(12*H, 16), aiRepeat(24*H, 3)), 24 * 3600, advertConfidenceMedium, 4, 3}, + // Two in a row are as likely two missed adverts: the interval stays. + {"flood 12 h, last 2 gaps 2x", advertIntervalFlood, series(aiRepeat(12*H, 17), aiRepeat(24*H, 2)), 12 * 3600, advertConfidenceHigh, 20, 19}, + // Lowered (47 h -> 12 h): 12 h explains the old 47 h gaps as 4x + // once it is a candidate, as before. + {"flood 47 h -> 12 h", advertIntervalFlood, series(aiRepeat(47*H, 14), aiRepeat(12*H, 5)), 12 * 3600, advertConfidenceHigh, 20, 19}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + aiCheck(t, estimateAdvertInterval(c.s, c.class), c.interval, c.conf, c.samples, c.gaps) + }) + } +} + // A candidate must be seen directly in a quarter of the gaps, not only // twice. Two manual adverts that each split a 12 h gap into 4 h + 8 h make // 4 h a gap seen twice that explains every gap (4, 8 and 12 h are 1-3x From a3042cbfb0c2761b06f40ca799f84f25d2b38eb5 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:03:58 +0000 Subject: [PATCH 05/10] fix(advert-intervals): estimate a raised interval from the adverts since the change (#245) Review F1 on #247. A new interval of 2-4x the old one was explained by the old one as missed adverts in every gap, so the old value stayed at high confidence until the old gaps fell under a quarter of the window (about 29 days for 24 -> 47 h flood). When the newest 3 gaps that fit the interval are all the same multiple k > 1, that is the setting, not adverts missed in a row: the estimate re-runs the candidate search on the adverts since the newest gap at the old interval, and samples counts those adverts. Extra adverts and the irregular gap at the change (the firmware re-arms the timer when the interval is set, CommonCLI.cpp:491-492, 500-501) do not break the run; a different multiple or a gap at the interval itself does. A lowered interval is unchanged: the new one explains the old gaps as multiples. Adds the 2x/3x/2x case: different multiples in a row stay missed adverts. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals.go | 112 +++++++++++++++++++++------- cmd/server/advert_intervals_test.go | 2 + 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/cmd/server/advert_intervals.go b/cmd/server/advert_intervals.go index 1c37b56d7..7dd79fd75 100644 --- a/cmd/server/advert_intervals.go +++ b/cmd/server/advert_intervals.go @@ -63,6 +63,10 @@ const ( // advertIntervalMaxMultiple is the longest run of missed adverts a gap // may stand for (k - 1). advertIntervalMaxMultiple = 4 + // advertIntervalRaisedRun is how many of the newest gaps in a row at the + // same multiple of the interval read as a raised setting rather than + // adverts missed in a row. + advertIntervalRaisedRun = 3 // advertClockSlackS is how far the sender clock may be ahead of // first_seen, and the least disagreement between a sender gap and the // first_seen gap that rejects the sender gap (else 10 % of the gap). @@ -154,10 +158,20 @@ func advertIntervalFloorS(class advertIntervalClass) float64 { // class's timer allows. The one explaining the most gaps as 1-4 x // itself wins (ties: the longer, fewer missed adverts). k x interval // is k - 1 missed adverts; shorter gaps are extra adverts, dropped. -// 3. The interval is the median of gap/k over the gaps it explains, then +// 3. A raised setting: a new interval of 2-4x the old one is explained by +// the old one as missed adverts in every gap. When the newest +// advertIntervalRaisedRun gaps are all the same multiple k > 1, that is +// the setting, not adverts missed in a row: only the adverts since the +// newest gap at the old interval are used, and step 2 runs on them. +// Setting an interval re-arms its timer at once +// (src/helpers/CommonCLI.cpp:491-492, 500-501). A lowered setting needs +// nothing: the new interval explains the old gaps as multiples once it +// is a candidate. +// 4. The interval is the median of gap/k over the gaps it explains, then // snapped to the firmware's values (snapAdvertInterval). // -// The candidate search is O(gaps^2) with gaps < nodeAdvertRouteLimit. +// The candidate search is O(gaps^2) with gaps < nodeAdvertRouteLimit, run +// at most once per raised setting found. func estimateAdvertInterval(samples []advertIntervalSample, class advertIntervalClass) AdvertIntervalEstimate { est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone} if len(samples) == 0 { @@ -176,32 +190,21 @@ func estimateAdvertInterval(samples []advertIntervalSample, class advertInterval return est } - gaps := advertGaps(s) - best, bestExplained := 0.0, 0 - for _, c := range gaps { - if c < advertIntervalFloorS(class) { - continue - } - direct, explained := 0, 0 - for _, g := range gaps { - k := advertGapMultiple(g, c) - if k == 1 { - direct++ - } - if k > 0 { - explained++ - } - } - if direct < 2 || direct*4 < len(gaps) { - continue - } - if explained > bestExplained || (explained == bestExplained && c > best) { - best, bestExplained = c, explained + gaps, from := advertGaps(s) + best, start := advertIntervalCandidate(gaps, class), 0 + for best > 0 { + cut := advertIntervalRaised(gaps, best) + if cut == 0 { + break } + start = from[cut] + gaps, from = gaps[cut:], from[cut:] + best = advertIntervalCandidate(gaps, class) } if best == 0 { return est } + est.Samples = len(s) - start var units []float64 for _, g := range gaps { @@ -239,12 +242,13 @@ func estimateAdvertInterval(samples []advertIntervalSample, class advertInterval } // advertGaps is the positive gaps in seconds between consecutive samples -// (sorted by heard), each from the sender's clock when it is plausible. -func advertGaps(s []advertIntervalSample) []float64 { +// (sorted by heard), each from the sender's clock when it is plausible, and +// for each gap the index of the sample it starts at. +func advertGaps(s []advertIntervalSample) (gaps []float64, from []int) { plausible := func(a advertIntervalSample) bool { return a.senderTS > 0 && a.senderTS <= a.heard.Unix()+advertClockSlackS } - gaps := make([]float64, 0, len(s)-1) + gaps, from = make([]float64, 0, len(s)-1), make([]int, 0, len(s)-1) for i := 1; i < len(s); i++ { g := s[i].heard.Sub(s[i-1].heard).Seconds() if plausible(s[i]) && plausible(s[i-1]) { @@ -254,10 +258,62 @@ func advertGaps(s []advertIntervalSample) []float64 { } } if g > 0 { - gaps = append(gaps, g) + gaps, from = append(gaps, g), append(from, i-1) + } + } + return gaps, from +} + +// advertIntervalCandidate picks the interval candidate (step 2 of +// estimateAdvertInterval), or 0 when no gap qualifies. +func advertIntervalCandidate(gaps []float64, class advertIntervalClass) float64 { + best, bestExplained := 0.0, 0 + for _, c := range gaps { + if c < advertIntervalFloorS(class) { + continue + } + direct, explained := 0, 0 + for _, g := range gaps { + k := advertGapMultiple(g, c) + if k == 1 { + direct++ + } + if k > 0 { + explained++ + } + } + if direct < 2 || direct*4 < len(gaps) { + continue + } + if explained > bestExplained || (explained == bestExplained && c > best) { + best, bestExplained = c, explained + } + } + return best +} + +// advertIntervalRaised spots a raised setting (step 3 of +// estimateAdvertInterval): the newest advertIntervalRaisedRun gaps that are +// a multiple of interval are all the same k > 1. Gaps that are no multiple +// (extra adverts, the gap at the change) are skipped. It returns the index +// of the first gap after the newest one at interval itself, where the new +// setting starts, or 0 when there is no such run. +func advertIntervalRaised(gaps []float64, interval float64) int { + run, runK := 0, 0 + for i := len(gaps) - 1; i >= 0; i-- { + k := advertGapMultiple(gaps[i], interval) + switch { + case k == 0: + case run < advertIntervalRaisedRun: + if k == 1 || (runK > 0 && k != runK) { + return 0 + } + run, runK = run+1, k + case k == 1: + return i + 1 } } - return gaps + return 0 } // advertGapMultiple is k when gap is k x interval within the tolerance diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index 009db0e8a..938185243 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -199,6 +199,8 @@ func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { {"flood 12 h -> 24 h, 3 new gaps", advertIntervalFlood, series(aiRepeat(12*H, 16), aiRepeat(24*H, 3)), 24 * 3600, advertConfidenceMedium, 4, 3}, // Two in a row are as likely two missed adverts: the interval stays. {"flood 12 h, last 2 gaps 2x", advertIntervalFlood, series(aiRepeat(12*H, 17), aiRepeat(24*H, 2)), 12 * 3600, advertConfidenceHigh, 20, 19}, + // Different multiples in a row are missed adverts, not a setting. + {"flood 12 h, last 3 gaps 2x 3x 2x", advertIntervalFlood, series(aiRepeat(12*H, 10), []time.Duration{24 * H, 36 * H, 24 * H}), 12 * 3600, advertConfidenceHigh, 14, 13}, // Lowered (47 h -> 12 h): 12 h explains the old 47 h gaps as 4x // once it is a candidate, as before. {"flood 47 h -> 12 h", advertIntervalFlood, series(aiRepeat(47*H, 14), aiRepeat(12*H, 5)), 12 * 3600, advertConfidenceHigh, 20, 19}, From 9081055c655f264983b57a53632c514294ff347c Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:03:59 +0000 Subject: [PATCH 06/10] test(advert-intervals): pin the sparse-coverage reading as a known limitation (#245) Review F2 on #247: a 47 h flood heard only 2x and 3x apart has no candidate at 47 h and reads as 94 h or 141 h at medium confidence. The F1 change does not touch this (the newest gaps are the candidate itself), and reading shared divisors as the interval would turn a 24 h series with a few 36 h gaps into 12 h, so the behaviour is documented and pinned instead. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index 938185243..c2c328ff5 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -212,6 +212,20 @@ func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { } } +// Known limitation (review F2 on #247): when coverage is so sparse that the +// interval itself is never heard twice in a row, it is no candidate and a +// multiple of it is estimated. A 47 h flood heard only 2x and 3x apart +// reads as 94 h or 141 h, with the other multiple's gaps irregular. +// Treating gaps that share a divisor as its multiples would instead read a +// 24 h series with a few 36 h gaps as 12 h; this pins the current reading. +func TestEstimateAdvertInterval_SparseCoverage(t *testing.T) { + H := time.Hour + s := aiGaps(94*H, 141*H, 94*H, 141*H, 94*H, 141*H, 94*H) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 94*3600, advertConfidenceMedium, 8, 4) + s = aiGaps(94*H, 141*H, 47*H, 94*H, 141*H, 94*H, 141*H) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 141*3600, advertConfidenceMedium, 8, 3) +} + // A candidate must be seen directly in a quarter of the gaps, not only // twice. Two manual adverts that each split a 12 h gap into 4 h + 8 h make // 4 h a gap seen twice that explains every gap (4, 8 and 12 h are 1-3x From 4ce70c5cde5bd07dfda723214c0a048973858e22 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:04:25 +0000 Subject: [PATCH 07/10] test(advert-intervals): reproduce manual zero-hop adverts read as a 12 min interval (#245) Review F5 on #247: the zero-hop candidate floor is 108 s, so manual advert.zerohop every 10-30 min reads as "12 min, medium". The firmware timer runs at 2 min or 60-240 min only. Red on this commit: the manual series; the 50 min series is red too once the first assert passes. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index c2c328ff5..dd930819b 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -212,6 +212,19 @@ func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { } } +// The zero-hop timer runs at 2 min (an untouched new install) or 60-240 +// min, nothing between (src/helpers/CommonCLI.cpp:160-165, 496-505), so a +// zero-hop candidate is 2 min or at least 54 min (60 less the tolerance). +// Manual advert.zerohop every 10-30 min is irregular, not "12 min". +func TestEstimateAdvertInterval_ZeroHopCandidates(t *testing.T) { + M := time.Minute + manual := aiGaps(12*M, 12*M, 24*M, 36*M, 12*M, 30*M) + aiCheck(t, estimateAdvertInterval(manual, advertIntervalZeroHop), 0, advertConfidenceNone, 7, 0) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(50*M, 8)...), advertIntervalZeroHop), 0, advertConfidenceNone, 9, 0) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(2*M, 19)...), advertIntervalZeroHop), 120, advertConfidenceHigh, 20, 19) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(55*M, 8)...), advertIntervalZeroHop), 3600, advertConfidenceHigh, 9, 8) +} + // Known limitation (review F2 on #247): when coverage is so sparse that the // interval itself is never heard twice in a row, it is no candidate and a // multiple of it is estimated. A 47 h flood heard only 2x and 3x apart From 83b4398af1c62cfba2c348d8d0ad36844211745e Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:06:28 +0000 Subject: [PATCH 08/10] fix(advert-intervals): limit candidates to intervals the timer can run at (#245) Review F5 on #247. A zero-hop candidate is now the 2 min new-install default (+-10 %) or 54 min and more (60 min less the tolerance), so manual advert.zerohop every 10-30 min reads as irregular instead of "12 min, medium". Flood keeps 3 h less the tolerance; the test now also pins that hourly manual flood adverts are irregular (the flood class using the zero-hop rule survived the suite before). Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals.go | 19 ++++++++++--------- cmd/server/advert_intervals_test.go | 9 ++++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cmd/server/advert_intervals.go b/cmd/server/advert_intervals.go index 7dd79fd75..704bb8b22 100644 --- a/cmd/server/advert_intervals.go +++ b/cmd/server/advert_intervals.go @@ -138,14 +138,15 @@ func advertIntervalSamples(rows NodeAdvertRows) []advertIntervalSample { return out } -// advertIntervalFloorS is the shortest interval the class's timer can run -// at, less the tolerance: shorter gaps are never a candidate interval (a -// burst of manual adverts). -func advertIntervalFloorS(class advertIntervalClass) float64 { +// advertIntervalCandidateOK is whether the class's timer can run at about +// c seconds (within the tolerance): flood 3 h or more, zero-hop the 2 min +// new-install default or 60 min or more. Other gaps are never a candidate +// interval (a burst of manual adverts, advert.zerohop every 10-30 min). +func advertIntervalCandidateOK(c float64, class advertIntervalClass) bool { if class == advertIntervalZeroHop { - return 120 * (1 - advertIntervalTol) + return math.Abs(c-120) <= advertIntervalTol*120 || c >= 3600*(1-advertIntervalTol) } - return 3 * 3600 * (1 - advertIntervalTol) + return c >= 3*3600*(1-advertIntervalTol) } // estimateAdvertInterval estimates one class's interval: @@ -154,8 +155,8 @@ func advertIntervalFloorS(class advertIntervalClass) float64 { // first_seen by more than advertClockSlackS - and its gap is positive // and agrees with first_seen's (a clock jump does not); else first_seen. // 2. A candidate interval is a gap seen directly (within the tolerance) in -// at least two gaps and a quarter of them, and not shorter than the -// class's timer allows. The one explaining the most gaps as 1-4 x +// at least two gaps and a quarter of them, and one the class's timer +// can run at (advertIntervalCandidateOK). The one explaining the most gaps as 1-4 x // itself wins (ties: the longer, fewer missed adverts). k x interval // is k - 1 missed adverts; shorter gaps are extra adverts, dropped. // 3. A raised setting: a new interval of 2-4x the old one is explained by @@ -269,7 +270,7 @@ func advertGaps(s []advertIntervalSample) (gaps []float64, from []int) { func advertIntervalCandidate(gaps []float64, class advertIntervalClass) float64 { best, bestExplained := 0.0, 0 for _, c := range gaps { - if c < advertIntervalFloorS(class) { + if !advertIntervalCandidateOK(c, class) { continue } direct, explained := 0, 0 diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index dd930819b..81d56b935 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -212,17 +212,20 @@ func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { } } -// The zero-hop timer runs at 2 min (an untouched new install) or 60-240 -// min, nothing between (src/helpers/CommonCLI.cpp:160-165, 496-505), so a +// Candidates are intervals the class's timer can run at. The zero-hop +// timer runs at 2 min (an untouched new install) or 60-240 min, nothing +// between (src/helpers/CommonCLI.cpp:160-165, 496-505), so a // zero-hop candidate is 2 min or at least 54 min (60 less the tolerance). // Manual advert.zerohop every 10-30 min is irregular, not "12 min". -func TestEstimateAdvertInterval_ZeroHopCandidates(t *testing.T) { +func TestEstimateAdvertInterval_TimerCandidates(t *testing.T) { M := time.Minute manual := aiGaps(12*M, 12*M, 24*M, 36*M, 12*M, 30*M) aiCheck(t, estimateAdvertInterval(manual, advertIntervalZeroHop), 0, advertConfidenceNone, 7, 0) aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(50*M, 8)...), advertIntervalZeroHop), 0, advertConfidenceNone, 9, 0) aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(2*M, 19)...), advertIntervalZeroHop), 120, advertConfidenceHigh, 20, 19) aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(55*M, 8)...), advertIntervalZeroHop), 3600, advertConfidenceHigh, 9, 8) + // Flood stays at 3 h or more: hourly manual flood adverts are irregular. + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(60*M, 8)...), advertIntervalFlood), 0, advertConfidenceNone, 9, 0) } // Known limitation (review F2 on #247): when coverage is so sparse that the From 014f76980082425d4684c899651f27275dfea4d7 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:08:54 +0000 Subject: [PATCH 09/10] fix(nodes): advert interval wording from the server's status, tooltip per the candidate rule (#245) Review F6 on #247. - AdvertIntervalEstimate gains status: estimated, none_observed, too_few or irregular. The UI words the row from it instead of repeating the server's minimum of 3 adverts (n < 3) and its confidence check; an unknown status is no estimate. - The tooltip described a median of all gaps. It now says the interval is a repeating gap the firmware timer can run at, that a run at one multiple reads as a raised interval, and names the 2 min new-install default. - samples is documented as the adverts since the change after a raised interval (F1); OpenAPI lists status, the completeness check covers it, and the #245 E2E asserts status and the new tooltip. Co-Authored-By: Claude Opus 5.5 --- cmd/server/advert_intervals.go | 27 ++++++++++++++++++++------ cmd/server/advert_intervals_test.go | 24 +++++++++++++++++++++++ cmd/server/openapi.go | 3 ++- public/node-adverts.js | 10 +++++++--- test-issue-245-advert-intervals-e2e.js | 6 +++--- test-node-adverts.js | 25 ++++++++++++++++++++---- 6 files changed, 78 insertions(+), 17 deletions(-) diff --git a/cmd/server/advert_intervals.go b/cmd/server/advert_intervals.go index 704bb8b22..097dd801a 100644 --- a/cmd/server/advert_intervals.go +++ b/cmd/server/advert_intervals.go @@ -53,6 +53,15 @@ const ( advertConfidenceNone = "none" ) +// AdvertIntervalEstimate.Status: why there is an estimate or not, so the UI +// does not repeat the server's rules. +const ( + advertIntervalEstimated = "estimated" // interval_s is set + advertIntervalNoneObserved = "none_observed" // no adverts of the class + advertIntervalTooFew = "too_few" // under advertIntervalMinSamples adverts + advertIntervalIrregular = "irregular" // enough adverts, no interval fits +) + const ( // advertIntervalMinSamples is the fewest adverts (two gaps) estimated. advertIntervalMinSamples = 3 @@ -82,11 +91,14 @@ type AdvertIntervalEstimate struct { // RawIntervalS is the median before snapping. RawIntervalS *int64 `json:"raw_interval_s"` Snapped bool `json:"snapped"` - // Samples is the number of adverts used, GapsUsed the gaps between them - // that fit a whole multiple of the interval. - Samples int `json:"samples"` - GapsUsed int `json:"gaps_used"` - Confidence string `json:"confidence"` + // Samples is the number of adverts used (after a raised setting, those + // since the change), GapsUsed the gaps between them that fit a whole + // multiple of the interval. + Samples int `json:"samples"` + GapsUsed int `json:"gaps_used"` + Confidence string `json:"confidence"` + // Status is advertIntervalEstimated, NoneObserved, TooFew or Irregular. + Status string `json:"status"` LastAdvert *string `json:"last_advert"` } @@ -174,7 +186,7 @@ func advertIntervalCandidateOK(c float64, class advertIntervalClass) bool { // The candidate search is O(gaps^2) with gaps < nodeAdvertRouteLimit, run // at most once per raised setting found. func estimateAdvertInterval(samples []advertIntervalSample, class advertIntervalClass) AdvertIntervalEstimate { - est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone} + est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone, Status: advertIntervalNoneObserved} if len(samples) == 0 { return est } @@ -188,8 +200,10 @@ func estimateAdvertInterval(samples []advertIntervalSample, class advertInterval last := s[len(s)-1].heard.UTC().Format(time.RFC3339) est.LastAdvert = &last if len(s) < advertIntervalMinSamples { + est.Status = advertIntervalTooFew return est } + est.Status = advertIntervalIrregular gaps, from := advertGaps(s) best, start := advertIntervalCandidate(gaps, class), 0 @@ -230,6 +244,7 @@ func estimateAdvertInterval(samples []advertIntervalSample, class advertInterval raw := int64(math.Round(interval)) snapped, ok := snapAdvertInterval(interval, class) est.RawIntervalS, est.IntervalS, est.Snapped, est.GapsUsed = &raw, &snapped, ok, used + est.Status = advertIntervalEstimated ratio := float64(used) / float64(used+irregular) switch { case used >= 6 && ratio >= 0.75: diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go index 81d56b935..46761e5c8 100644 --- a/cmd/server/advert_intervals_test.go +++ b/cmd/server/advert_intervals_test.go @@ -327,6 +327,27 @@ func TestEstimateAdvertInterval_TooFewSamples(t *testing.T) { aiCheck(t, estimateAdvertInterval(dup, advertIntervalFlood), 0, advertConfidenceNone, 3, 0) } +// status tells the UI why there is no estimate, so it does not repeat the +// minimum of 3 adverts (review F6 on #247). +func TestEstimateAdvertInterval_Status(t *testing.T) { + cases := []struct { + at []float64 + want string + }{ + {nil, advertIntervalNoneObserved}, + {[]float64{0}, advertIntervalTooFew}, + {[]float64{0, 1}, advertIntervalTooFew}, + {[]float64{0, 0, 0}, advertIntervalIrregular}, + {[]float64{0, 10, 47, 50, 121, 143}, advertIntervalIrregular}, + {[]float64{0, 1, 2}, advertIntervalEstimated}, + } + for _, c := range cases { + if got := estimateAdvertInterval(aiSeries(time.Hour*12, c.at...), advertIntervalFlood); got.Status != c.want { + t.Errorf("%v: status = %q, want %q (%+v)", c.at, got.Status, c.want, got) + } + } +} + // Irregular adverts (a companion advertising by hand) have no interval. func TestEstimateAdvertInterval_Irregular(t *testing.T) { s := aiSeries(time.Hour, 0, 10, 47, 50, 121, 143) @@ -529,6 +550,9 @@ func TestNodeDetail_AdvertIntervals(t *testing.T) { } aiCheck(t, body.Intervals.Flood, 12*3600, advertConfidenceHigh, 7, 6) aiCheck(t, body.Intervals.ZeroHop, 7200, advertConfidenceMedium, 6, 5) + if body.Intervals.Flood.Status != advertIntervalEstimated || body.Intervals.ZeroHop.Status != advertIntervalEstimated { + t.Fatalf("status flood/zero_hop = %q/%q, want estimated", body.Intervals.Flood.Status, body.Intervals.ZeroHop.Status) + } if body.Intervals.Window != nodeAdvertRouteLimit { t.Fatalf("window = %d", body.Intervals.Window) } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 332b6d5dc..660831a4b 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -275,9 +275,10 @@ func nodeAdvertRouteSchemas() map[string]*openAPISchema { "interval_s": {Type: "integer", Nullable: true, Description: "Estimated interval in seconds, snapped when snapped is true; null when confidence is none."}, "raw_interval_s": {Type: "integer", Nullable: true, Description: "The median before snapping; null when confidence is none."}, "snapped": {Type: "boolean", Description: "true when the estimate is within 10 % of the firmware's settable range and interval_s is the nearest settable value."}, - "samples": count, + "samples": {Type: "integer", Minimum: &zero, Description: "Adverts used; after a raised interval, the adverts since the change."}, "gaps_used": {Type: "integer", Minimum: &zero, Description: "Gaps between the samples that fit 1-4x the interval."}, "confidence": {Type: "string", Enum: []string{advertConfidenceHigh, advertConfidenceMedium, advertConfidenceLow, advertConfidenceNone}, Description: "high: >= 6 fitting gaps and >= 75 % of the non-short gaps fit; medium: >= 3 and >= 50 %; low: fewer; none: under 3 adverts or no interval seen at least twice."}, + "status": {Type: "string", Enum: []string{advertIntervalEstimated, advertIntervalNoneObserved, advertIntervalTooFew, advertIntervalIrregular}, Description: "estimated: interval_s is set; none_observed: no adverts of the class; too_few: under 3 adverts; irregular: enough adverts but no interval fits."}, "last_advert": {Type: "string", Nullable: true, Description: "RFC3339 first_seen of the newest advert in the class; null when there is none."}, }, }, diff --git a/public/node-adverts.js b/public/node-adverts.js index 957b01a2c..dd4ac65b2 100644 --- a/public/node-adverts.js +++ b/public/node-adverts.js @@ -113,7 +113,7 @@ // #245: estimated advert intervals (advertIntervals, docs/api-spec.md). // Flood in hours and zero-hop in minutes, the units of the firmware's // flood.advert.interval and advert.interval settings. - var INTERVAL_TIP = 'Estimated from the gaps between the newest adverts of each class (the Flood and Zero-hop tabs), using the sender\'s own timestamps when its clock is plausible. The median gap is taken; gaps of 2-4x it count as missed adverts, shorter ones (manual adverts, reboots) are ignored. Snapped to the values the firmware allows: flood.advert.interval 3-168 h, advert.interval 60-240 min in 2-minute steps.'; + var INTERVAL_TIP = 'Estimated from the gaps between the newest adverts of each class (the Flood and Zero-hop tabs), using the sender\'s own timestamps when its clock is plausible. The interval is a gap that repeats and that the firmware timer can run at; gaps of 2-4x it count as missed adverts, shorter ones (manual adverts, reboots) are ignored. When the newest gaps are all the same multiple, the interval was raised and only the adverts since then are used. Snapped to the values the firmware allows: flood.advert.interval 3-168 h; advert.interval 60-240 min in 2-minute steps, or 2 min on an untouched new install.'; var INTERVAL_CLASSES = [ { key: 'flood', label: 'flood', unit: 3600, unitLabel: 'h', range: '3–168 h', none: 'none observed' }, { key: 'zero_hop', label: 'zero-hop', unit: 60, unitLabel: 'min', range: '60–240 min', none: 'none observed (off, or no observer in direct range)' } @@ -125,19 +125,23 @@ return (Math.round(v * 10) / 10) + ' ' + c.unitLabel; } + // The wording follows the server's status (estimated, none_observed, + // too_few, irregular); an unknown status is no estimate. function intervalRow(e, c) { e = e || {}; var n = num(e.samples); var seconds = Number(e.interval_s); var label = 'Estimated ' + c.label + ' interval'; var text; - if (e.interval_s != null && seconds > 0 && e.confidence !== 'none') { + if (e.status === 'estimated' && e.interval_s != null && seconds > 0) { var notes = [n + ' advert' + (n === 1 ? '' : 's'), esc(e.confidence) + ' confidence']; if (!e.snapped) notes.push('outside the settable ' + c.range); text = ' ≈ ' + fmtInterval(seconds, c) + ' (' + notes.join(', ') + ')'; } else { label += ':'; - text = ' ' + (n === 0 ? c.none : n < 3 ? 'not enough adverts yet (' + n + ' heard)' : 'irregular (' + n + ' adverts, no repeating gap)'); + text = ' ' + (e.status === 'too_few' ? 'not enough adverts yet (' + n + ' heard)' + : e.status === 'none_observed' || !n ? c.none + : 'irregular (' + n + ' adverts, no repeating gap)'); } return '' + label + '' + text + ''; } diff --git a/test-issue-245-advert-intervals-e2e.js b/test-issue-245-advert-intervals-e2e.js index ebfe1cd09..7b5776b6e 100644 --- a/test-issue-245-advert-intervals-e2e.js +++ b/test-issue-245-advert-intervals-e2e.js @@ -80,13 +80,13 @@ const ZH_NONE = 'Estimated zero-hop interval: none observed (off, or no observer assert(!r.plain, 'plain detail must not carry advertIntervals'); const b = r.both, f = r.floodOnly; assert(b && b.window === 20, 'window: ' + JSON.stringify(b)); - assert(b.flood.interval_s === 43200 && b.flood.snapped && b.flood.samples === 10 && b.flood.gaps_used === 7 && b.flood.confidence === 'high', + assert(b.flood.interval_s === 43200 && b.flood.snapped && b.flood.samples === 10 && b.flood.gaps_used === 7 && b.flood.confidence === 'high' && b.flood.status === 'estimated', 'flood (2x and 3x gaps, one manual advert): ' + JSON.stringify(b.flood)); assert(b.zero_hop.interval_s === 7200 && b.zero_hop.samples === 6 && b.zero_hop.gaps_used === 5 && b.zero_hop.confidence === 'medium', 'zero-hop: ' + JSON.stringify(b.zero_hop)); assert(f.flood.interval_s === 86400 && f.flood.gaps_used === 7 && f.flood.confidence === 'high', 'flood with a sender clock reset: ' + JSON.stringify(f.flood)); - assert(f.zero_hop.samples === 0 && f.zero_hop.interval_s === null && f.zero_hop.confidence === 'none' && f.zero_hop.last_advert === null, + assert(f.zero_hop.samples === 0 && f.zero_hop.interval_s === null && f.zero_hop.confidence === 'none' && f.zero_hop.status === 'none_observed' && f.zero_hop.last_advert === null, 'no zero-hop: ' + JSON.stringify(f.zero_hop)); }); @@ -96,7 +96,7 @@ const ZH_NONE = 'Estimated zero-hop interval: none observed (off, or no observer assert(s.flood === FLOOD_12H, 'flood: ' + s.flood); assert(s.zero_hop === ZH_120, 'zero-hop: ' + s.zero_hop); assert(s.afterCounts, 'below the 24h / 7d counts'); - assert(/median/.test(s.tip) && /3-168 h/.test(s.tip), 'tooltip explains the method: ' + s.tip); + assert(/a gap that repeats/.test(s.tip) && /3-168 h/.test(s.tip) && /2 min on an untouched new install/.test(s.tip), 'tooltip explains the method: ' + s.tip); await shot(page, 'full-both-light', full); }); diff --git a/test-node-adverts.js b/test-node-adverts.js index 7bbb8af8e..39bc9ced1 100644 --- a/test-node-adverts.js +++ b/test-node-adverts.js @@ -163,7 +163,8 @@ test('escaping: node-controlled text never reaches the HTML raw', () => { }); // #245: estimated flood / zero-hop advert intervals under the counts. -const est = (over) => Object.assign({ interval_s: null, raw_interval_s: null, snapped: false, samples: 0, gaps_used: 0, confidence: 'none', last_advert: null }, over || {}); +const est = (over) => Object.assign({ interval_s: null, raw_interval_s: null, snapped: false, samples: 0, gaps_used: 0, confidence: 'none', status: 'none_observed', last_advert: null }, + over && over.interval_s != null && !('status' in over) ? { status: 'estimated' } : {}, over || {}); const intervals = (flood, zh) => ({ window: 20, flood: flood, zero_hop: zh }); function intervalRows(html) { const out = {}; @@ -183,7 +184,11 @@ test('intervals: both classes shown, flood in hours and zero-hop in minutes', () assert.strictEqual(rows.zero_hop, 'Estimated zero-hop interval ≈ 120 min (6 adverts, medium confidence)'); assert.ok(html.indexOf('node-adverts-intervals') > html.indexOf('node-adverts-counts'), 'below the counts'); assert.ok(html.indexOf('node-adverts-intervals') < html.indexOf('node-adverts-tabs'), 'above the tabs'); - assert.ok(/class="node-adverts-intervals" title="[^"]*median[^"]*"/.test(html), 'tooltip explains the method'); + const tip = (html.match(/class="node-adverts-intervals" title="([^"]*)"/) || [])[1] || ''; + assert.ok(/a gap that repeats/.test(tip), 'tooltip explains the candidate interval: ' + tip); + assert.ok(/raised/.test(tip), 'tooltip explains a raised interval: ' + tip); + assert.ok(/2 min on an untouched new install/.test(tip), 'tooltip names the 2-minute new-install default: ' + tip); + assert.ok(!/median gap is taken/.test(tip), 'no stale median wording'); const pane = intervalRows(NA.render(d, opts({ variant: 'pane' }))); assert.strictEqual(pane.flood, rows.flood, 'side panel shows the same'); }); @@ -199,10 +204,10 @@ test('intervals: no zero-hop adverts reads as off or out of direct range', () => test('intervals: too few, irregular, low confidence, 2-minute default and unsnapped values', () => { const rows = (f, z) => intervalRows(NA.render(detail({ advertIntervals: intervals(f, z) }), opts())); - let r = rows(est({ samples: 2, last_advert: 'x' }), est({ samples: 1, last_advert: 'x' })); + let r = rows(est({ samples: 2, status: 'too_few', last_advert: 'x' }), est({ samples: 1, status: 'too_few', last_advert: 'x' })); assert.strictEqual(r.flood, 'Estimated flood interval: not enough adverts yet (2 heard)'); assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval: not enough adverts yet (1 heard)'); - r = rows(est({ samples: 6, last_advert: 'x' }), est({ interval_s: 120, snapped: true, samples: 30, gaps_used: 20, confidence: 'high' })); + r = rows(est({ samples: 6, status: 'irregular', last_advert: 'x' }), est({ interval_s: 120, snapped: true, samples: 30, gaps_used: 20, confidence: 'high' })); assert.strictEqual(r.flood, 'Estimated flood interval: irregular (6 adverts, no repeating gap)'); assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 2 min (30 adverts, high confidence)'); r = rows(est({ interval_s: 9000, raw_interval_s: 9000, samples: 3, gaps_used: 2, confidence: 'low' }), @@ -211,6 +216,18 @@ test('intervals: too few, irregular, low confidence, 2-minute default and unsnap assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 50 min (4 adverts, medium confidence, outside the settable 60–240 min)'); }); +test('intervals: the server status decides the wording, not a client-side minimum', () => { + const rows = (f, z) => intervalRows(NA.render(detail({ advertIntervals: intervals(f, z) }), opts())); + const r = rows(est({ samples: 5, status: 'too_few', last_advert: 'x' }), est({ samples: 2, status: 'irregular', last_advert: 'x' })); + assert.strictEqual(r.flood, 'Estimated flood interval: not enough adverts yet (5 heard)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval: irregular (2 adverts, no repeating gap)'); + const unknown = rows(est({ samples: 4, status: 'something-new', last_advert: 'x' }), est({ samples: 0 })); + assert.strictEqual(unknown.flood, 'Estimated flood interval: irregular (4 adverts, no repeating gap)', 'an unknown status is no estimate'); + assert.strictEqual(unknown.zero_hop, 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'); + const notEstimated = rows(est({ interval_s: 43200, samples: 9, confidence: 'high', status: 'irregular' }), est()); + assert.strictEqual(notEstimated.flood, 'Estimated flood interval: irregular (9 adverts, no repeating gap)', 'interval_s only counts with status estimated'); +}); + test('intervals: absent without the field, values escaped', () => { assert.ok(!NA.render(detail(), opts()).includes('node-adverts-intervals'), 'older server / hidden identity: no block'); assert.ok(!NA.render(detail({ advertIntervals: null }), opts()).includes('node-adverts-intervals')); From b245052ac2e7069cb461f7aef9d32bc6e32d560d Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:09:23 +0000 Subject: [PATCH 10/10] docs(api): advert intervals - irregular gaps, raised interval, timer candidates, status (#245) Review F4 on #247: the zero-hop gap across a flood advert is between one and two intervals and counts as irregular (it lowers the confidence); it is not dropped as a short gap. Also documents the raised-interval rule (F1), the timer-candidate limits (F5), the status field (F6) and the sparse-coverage limitation (F2) in docs/api-spec.md and the OpenAPI description. Co-Authored-By: Claude Opus 5.5 --- cmd/server/openapi.go | 2 +- docs/api-spec.md | 34 ++++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 660831a4b..5a039d606 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -261,7 +261,7 @@ func nodeAdvertRouteSchemas() map[string]*openAPISchema { }, "NodeAdvertIntervals": { Type: "object", - Description: "Node detail with include=advertRoutes only (#245): the node's estimated flood and zero-hop advert intervals, from the gaps between the adverts listed in recentAdvertsByRoute.flood / .zero_hop (mixed and unknown adverts are not used). A gap uses the adverts' own (sender) timestamps when both are plausible - not ahead of first_seen by more than 10 min, positive, and within max(10 min, 10 %) of the first_seen gap - else first_seen. The interval must be seen directly in at least two gaps and a quarter of them; gaps of 2-4x it count as missed adverts, shorter gaps (manual adverts, reboots) are dropped. It is the median of the fitting gaps, snapped to the firmware's settable values: flood.advert.interval whole hours 3-168, advert.interval even minutes 60-240 or the 2-minute new-install default. No zero-hop adverts can mean the node's zero-hop interval is 0 (off) or that no observer hears it directly. Absent without include=advertRoutes and when the node's identity is hidden; cached with recentAdvertsByRoute.", + Description: "Node detail with include=advertRoutes only (#245): the node's estimated flood and zero-hop advert intervals, from the gaps between the adverts listed in recentAdvertsByRoute.flood / .zero_hop (mixed and unknown adverts are not used). A gap uses the adverts' own (sender) timestamps when both are plausible - not ahead of first_seen by more than 10 min, positive, and within max(10 min, 10 %) of the first_seen gap - else first_seen. The interval must be seen directly in at least two gaps and a quarter of them, and be one the class's timer can run at (flood 3 h or more, zero-hop 2 min or 60 min or more, each less 10 %); gaps of 2-4x it count as missed adverts, shorter gaps (manual adverts, reboots) are dropped, longer gaps that are no multiple are irregular. When the newest 3 gaps that fit are all the same multiple k > 1 the interval was raised, and the estimate is redone on the adverts since the change. It is the median of the fitting gaps, snapped to the firmware's settable values: flood.advert.interval whole hours 3-168, advert.interval even minutes 60-240 or the 2-minute new-install default. No zero-hop adverts can mean the node's zero-hop interval is 0 (off) or that no observer hears it directly. Absent without include=advertRoutes and when the node's identity is hidden; cached with recentAdvertsByRoute.", Properties: map[string]*openAPISchema{ "window": {Type: "integer", Description: "Most adverts per class considered (the recentAdvertsByRoute limit, 20)."}, "flood": openAPIRef("AdvertIntervalEstimate"), diff --git a/docs/api-spec.md b/docs/api-spec.md index 2f1491d3a..43707e98e 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -524,9 +524,10 @@ Where `AdvertIntervalEstimate` is: "interval_s": number | null, // estimate in seconds, snapped when "snapped"; null when confidence is none "raw_interval_s": number | null, // the median before snapping "snapped": boolean, - "samples": number, // adverts used + "samples": number, // adverts used (after a raised interval: those since the change) "gaps_used": number, // gaps between them that fit 1-4x the interval "confidence": "high" | "medium" | "low" | "none", + "status": "estimated" | "none_observed" | "too_few" | "irregular", "last_advert": string (ISO) | null // first_seen of the newest advert in the class } ``` @@ -596,12 +597,26 @@ The firmware settings it maps to (MeshCore `src/helpers/CommonCLI.cpp`): it is taken from `first_seen`. A sender clock that is wrong by a steady offset is still used; a jump or reset is not. - **The interval.** It must be seen directly, within 10 %, in at least two - gaps and a quarter of them. It may not be shorter than the class's timer - allows. Of those candidates, the one that explains the most gaps as 1–4× - itself wins. + gaps and a quarter of them. It must be an interval the class's timer can + run at, within 10 %: flood 3 h or more; zero-hop the 2 min new-install + default or 60 min or more (so manual `advert.zerohop` every 10–30 min is + irregular). Of those candidates, the one that explains the most gaps as + 1–4× itself wins. - A gap of k× the interval counts as k−1 missed adverts. - - Shorter gaps are dropped: manual adverts and reboots, and the zero-hop - gap that a flood advert's timer reset makes irregular. + - Shorter gaps are dropped and count neither way: manual adverts and + reboots. + - Longer gaps that are no multiple are *irregular* and lower the + confidence. One is the zero-hop gap across a flood advert: the flood + advert re-arms the zero-hop timer, so that gap is between one and two + zero-hop intervals. +- **A raised interval.** A new interval of 2–4× the old one fits every new + gap as missed adverts of the old one. When the newest 3 gaps that fit the + interval are all the same multiple k > 1, that is read as a raised + setting: the estimate is redone on the adverts since the newest gap at the + old interval, and `samples` counts those. Two in a row, or different + multiples, stay missed adverts. A lowered interval needs no special case: + the new one explains the old gaps as multiples once it is seen in a + quarter of the gaps. - **The value.** It is the median of gap/k over the fitting gaps (`raw_interval_s`). It is snapped to the nearest settable value when it lies within 10 % of the settable range (`snapped`). @@ -611,6 +626,13 @@ The firmware settings it maps to (MeshCore `src/helpers/CommonCLI.cpp`): - `low`: anything less. - `none`: fewer than 3 adverts, or no interval seen twice. `interval_s` is then `null`. +- **Status.** `estimated` when `interval_s` is set, `none_observed` with no + adverts of the class, `too_few` under 3 adverts, `irregular` otherwise. + The UI words the row from it. +- **Known limitation: sparse coverage.** When the interval itself is never + heard twice in a row (a distant node heard every second or third time), + it is no candidate, and a multiple of it is reported: a 47 h flood heard + 94 h and 141 h apart reads as 94 h or 141 h at medium confidence. - **No zero-hop adverts.** A zero-hop advert is only recorded when an observer hears the node directly. "None observed" can therefore mean that the interval is 0 (off), or that no observer is in direct range.