mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 09:29:52 +00:00
Bucketed airtime and receive errors used MAX - MIN per bucket, so one zeroed, replayed or garbage row counted the whole running total (e.g. 47,737 s RX in a 6 h bucket). Buckets are now summed from per-row increments with dips, stale replays and physically impossible jumps rejected. Ingest skips uptime-only stats and a migration deletes the rows already stored.
200 lines
6.2 KiB
Go
200 lines
6.2 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package api
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var telemetryBase = time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
|
|
|
|
type row struct {
|
|
hour int
|
|
tx, rx float32
|
|
errs int32
|
|
noise float32
|
|
uptime int64
|
|
}
|
|
|
|
func telemetryPoints(rows ...row) []ObserverTelemetryPoint {
|
|
pts := make([]ObserverTelemetryPoint, 0, len(rows))
|
|
for _, r := range rows {
|
|
tx, rx, errs, noise, uptime := r.tx, r.rx, r.errs, r.noise, r.uptime
|
|
pts = append(pts, ObserverTelemetryPoint{
|
|
T: telemetryBase.Add(time.Duration(r.hour) * time.Hour).UnixMilli(),
|
|
AirtimeTxSecs: &tx,
|
|
AirtimeRxSecs: &rx,
|
|
ReceiveErrors: &errs,
|
|
NoiseFloorDB: &noise,
|
|
UptimeSeconds: &uptime,
|
|
})
|
|
}
|
|
return pts
|
|
}
|
|
|
|
func bucketAt(t *testing.T, buckets []ObserverTelemetryPoint, hour int) ObserverTelemetryPoint {
|
|
t.Helper()
|
|
want := telemetryBase.Add(time.Duration(hour) * time.Hour).UnixMilli()
|
|
for _, b := range buckets {
|
|
if b.T == want {
|
|
return b
|
|
}
|
|
}
|
|
t.Fatalf("no bucket at hour %d in %+v", hour, buckets)
|
|
return ObserverTelemetryPoint{}
|
|
}
|
|
|
|
func checkCounters(t *testing.T, b ObserverTelemetryPoint, tx, rx float32, errs int32) {
|
|
t.Helper()
|
|
if *b.AirtimeTxSecs != tx || *b.AirtimeRxSecs != rx || *b.ReceiveErrors != errs {
|
|
t.Errorf("counters = tx %v rx %v errs %v, want tx %v rx %v errs %v",
|
|
*b.AirtimeTxSecs, *b.AirtimeRxSecs, *b.ReceiveErrors, tx, rx, errs)
|
|
}
|
|
}
|
|
|
|
func TestBucketTelemetry_SumsIncrements(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 100, 1000, 50, -90, 10000},
|
|
row{1, 110, 1060, 55, -92, 13600},
|
|
row{2, 125, 1100, 60, -94, 17200},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
if len(b) != 1 {
|
|
t.Fatalf("got %d buckets, want 1", len(b))
|
|
}
|
|
checkCounters(t, b[0], 25, 100, 10)
|
|
if *b[0].NoiseFloorDB != -92 || *b[0].UptimeSeconds != 17200 {
|
|
t.Errorf("noise %v uptime %v, want -92 17200", *b[0].NoiseFloorDB, *b[0].UptimeSeconds)
|
|
}
|
|
}
|
|
|
|
// Observer e61a62a7 on 2026-09-30: an uptime-only status stored zeros mid-run.
|
|
func TestBucketTelemetry_ZeroedRowIsNotARestart(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{5, 7935, 47484, 93689, -96, 1093083},
|
|
row{6, 0, 0, 0, 0, 1096701},
|
|
row{7, 7951, 47586, 93884, -97, 1100319},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase.Add(5*time.Hour), 6*time.Hour)
|
|
checkCounters(t, bucketAt(t, b, 6), 16, 102, 195)
|
|
}
|
|
|
|
func TestBucketTelemetry_SingleCounterDip(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 3108, 19536, 38083, -108, 473828},
|
|
row{1, 3160, 19837, 0, -92, 477447},
|
|
row{2, 3208, 20145, 39211, -94, 481065},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 100, 609, 1128)
|
|
}
|
|
|
|
func TestBucketTelemetry_RebootCountsSinceBoot(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 50, 500, 10, -90, 10000},
|
|
row{1, 60, 560, 12, -90, 13600},
|
|
row{2, 5, 40, 3, -90, 1200},
|
|
row{3, 9, 70, 4, -90, 4800},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 10+5+4, 60+40+30, 2+3+1)
|
|
}
|
|
|
|
// Observer 50350675 replays an old snapshot between live rows.
|
|
func TestBucketTelemetry_StaleReplaySkipped(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 121, 4788, 0, -120, 225259},
|
|
row{1, 123, 4849, 0, -120, 228860},
|
|
row{2, 88, 3879, 0, -50, 184130},
|
|
row{3, 126, 4972, 0, -120, 236061},
|
|
row{4, 88, 3879, 0, -50, 184130},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 5, 184, 0)
|
|
if *b[0].NoiseFloorDB != -120 || *b[0].UptimeSeconds != 236061 {
|
|
t.Errorf("stale row leaked into averages: noise %v uptime %v", *b[0].NoiseFloorDB, *b[0].UptimeSeconds)
|
|
}
|
|
}
|
|
|
|
func TestBucketTelemetry_LookbackSeedsBaseline(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{5, 100, 1000, 50, -90, 10000},
|
|
row{6, 110, 1030, 52, -90, 13600},
|
|
row{12, 130, 1090, 58, -90, 35200},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase.Add(6*time.Hour), 6*time.Hour)
|
|
if len(b) != 2 {
|
|
t.Fatalf("got %d buckets, want 2 (lookback row must not get its own)", len(b))
|
|
}
|
|
checkCounters(t, bucketAt(t, b, 6), 10, 30, 2)
|
|
checkCounters(t, bucketAt(t, b, 12), 20, 60, 6)
|
|
}
|
|
|
|
// Some firmware keeps counting airtime across a reboot (ed5a20b6).
|
|
func TestBucketTelemetry_RebootWithPersistentCounters(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 900, 178000, 10, -90, 500000},
|
|
row{1, 910, 178400, 12, -90, 503600},
|
|
row{2, 915, 178485, 1, -90, 2250},
|
|
row{3, 920, 178589, 3, -90, 5851},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 20, 589, 2+1+2)
|
|
}
|
|
|
|
// Observer 408159b8 reported one-off garbage between sane readings.
|
|
func TestBucketTelemetry_GarbageSpikeRejected(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 96, 164, 144, -89, 145911},
|
|
row{1, 101, 172, 146, -89, 149536},
|
|
row{2, 111, 201329470, 2080374784, -90, 156847},
|
|
row{3, 114, 197, 160, -89, 160464},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 18, 33, 16)
|
|
}
|
|
|
|
func TestBucketTelemetry_GarbageBaselineRecovers(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 100, 2080374784, 50, -90, 10000},
|
|
row{1, 110, 200, 55, -90, 13600},
|
|
row{2, 120, 210, 60, -90, 17200},
|
|
row{3, 130, 220, 65, -90, 20800},
|
|
row{4, 140, 235, 70, -90, 24400},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, b[0], 40, 15, 20)
|
|
}
|
|
|
|
func TestBucketTelemetry_GapLongerThanBucketDropped(t *testing.T) {
|
|
pts := telemetryPoints(
|
|
row{0, 100, 22018, 50, -90, 500000},
|
|
row{1, 110, 22100, 55, -90, 503600},
|
|
row{20, 400, 39295, 900, -90, 572000},
|
|
row{21, 410, 39384, 905, -90, 575600},
|
|
)
|
|
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
|
|
checkCounters(t, bucketAt(t, b, 18), 10, 89, 5)
|
|
}
|
|
|
|
func TestBucketTelemetry_AveragesAndNils(t *testing.T) {
|
|
b1, b2 := int32(3700), int32(3705)
|
|
q := int32(2)
|
|
pts := []ObserverTelemetryPoint{
|
|
{T: telemetryBase.UnixMilli(), BatteryMV: &b1, QueueLength: &q},
|
|
{T: telemetryBase.Add(time.Hour).UnixMilli(), BatteryMV: &b2},
|
|
}
|
|
b := BucketTelemetry(pts, telemetryBase, 24*time.Hour)
|
|
if len(b) != 1 {
|
|
t.Fatalf("got %d buckets, want 1", len(b))
|
|
}
|
|
if *b[0].BatteryMV != 3703 || *b[0].QueueLength != 2 {
|
|
t.Errorf("battery %v queue %v, want 3703 2", *b[0].BatteryMV, *b[0].QueueLength)
|
|
}
|
|
if b[0].AirtimeTxSecs != nil || b[0].NoiseFloorDB != nil || b[0].UptimeSeconds != nil {
|
|
t.Errorf("fields with no data should stay nil: %+v", b[0])
|
|
}
|
|
}
|