diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e43d15620..6f043365b 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 @@ -454,6 +459,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..097dd801a --- /dev/null +++ b/cmd/server/advert_intervals.go @@ -0,0 +1,373 @@ +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" +) + +// 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 + // 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 + // 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). + 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 (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"` +} + +// 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 +} + +// 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 math.Abs(c-120) <= advertIntervalTol*120 || c >= 3600*(1-advertIntervalTol) + } + return c >= 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 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 +// 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, run +// at most once per raised setting found. +func estimateAdvertInterval(samples []advertIntervalSample, class advertIntervalClass) AdvertIntervalEstimate { + est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone, Status: advertIntervalNoneObserved} + 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 { + est.Status = advertIntervalTooFew + return est + } + est.Status = advertIntervalIrregular + + 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 { + 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 + est.Status = advertIntervalEstimated + 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, 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, 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]) { + 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, 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 !advertIntervalCandidateOK(c, 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 0 +} + +// 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/advert_intervals_test.go b/cmd/server/advert_intervals_test.go new file mode 100644 index 000000000..46761e5c8 --- /dev/null +++ b/cmd/server/advert_intervals_test.go @@ -0,0 +1,559 @@ +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 +} + +// 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++ { + 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) +} + +// 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 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}, + // 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}, + } + 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) + }) + } +} + +// 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_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 +// 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 +// 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 + // 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) +} + +// 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) + 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.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/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/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/cmd/server/openapi.go b/cmd/server/openapi.go index 6cd374fae..5a039d606 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,29 @@ 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, 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"), + "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": {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."}, + }, + }, "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 +379,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..43707e98e 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,30 @@ 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 (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 +} +``` + Where `Packet` is a transmission object (see [Packet Object](#packet-object)). #### Advert route classes @@ -557,6 +577,68 @@ 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 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 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`). +- **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`. +- **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. +- **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..dd4ac65b2 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,48 @@ 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 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)' } + ]; + + 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; + } + + // 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.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 = ' ' + (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 + ''; + } + + function intervalsHtml(intervals) { + if (!intervals) return ''; + return '