From 16e3fbadce2026d5591aa08c6518e1c5754fa1a2 Mon Sep 17 00:00:00 2001 From: henrygd Date: Fri, 25 Sep 2026 18:02:34 -0400 Subject: [PATCH] store compact RSSI stats and skip real-time collection Stats.WiFi is now map[string]int8 (json "wf") holding only available RSSI readings; SSID and unavailable signals stay in Info.WiFi. Averages are rounded to whole dBm. Wi-Fi is collected only on the default 60s interval; real-time requests reuse the last snapshot to avoid spawning osascript / dumping the BSS cache every second. --- agent/system.go | 9 +++++-- agent/wifi/README.md | 15 +++++------ agent/wifi/wifi.go | 17 +++++++++++++ agent/wifi/wifi_test.go | 22 ++++++++++++++++ internal/entities/system/system.go | 2 +- internal/entities/system/wifi_test.go | 5 +++- internal/hub/systems/system_wifi_test.go | 2 +- internal/hub/transport/wifi_test.go | 12 +++++++-- internal/records/records.go | 25 +++++++------------ internal/records/records_wifi_test.go | 9 +++---- .../routes/system/charts/wifi-chart.tsx | 2 +- internal/site/src/types.d.ts | 3 ++- 12 files changed, 86 insertions(+), 37 deletions(-) diff --git a/agent/system.go b/agent/system.go index 66d7fa747..28df00912 100644 --- a/agent/system.go +++ b/agent/system.go @@ -268,7 +268,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { } } - systemStats.WiFi = wifi.Collect() + // Wi-Fi collection spawns a process on macOS and dumps the BSS cache on + // Linux, so only refresh on the default interval. Real-time requests reuse + // the last snapshot. + if cacheTimeMs == defaultDataCacheTimeMs { + a.systemInfo.WiFi = wifi.Collect() + } + systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi) // update system info a.systemInfo.ConnectionType = a.connectionManager.ConnectionType @@ -277,7 +283,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.Battery = systemStats.Battery - a.systemInfo.WiFi = systemStats.WiFi a.systemInfo.Uptime, _ = getUptime() a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.Threads = a.systemDetails.Threads diff --git a/agent/wifi/README.md b/agent/wifi/README.md index 1cdc0350a..b4f38f04c 100644 --- a/agent/wifi/README.md +++ b/agent/wifi/README.md @@ -1,16 +1,17 @@ # Connected Wi-Fi signal -Each fresh stats poll reports a snapshot of connected station interfaces in -`stats.wifi` and `info.wifi`. Map keys identify interfaces, not networks. `s` is -optional SSID metadata; `r` is nullable native RSSI in dBm. Quality percentages -are never converted to dBm. An associated interface without an accessible RSSI -still appears with an unavailable signal. No scans or network changes occur. +Each default-interval poll reports a snapshot of connected station interfaces +in `info.wifi`. Map keys identify interfaces, not networks. `s` is optional SSID +metadata; `r` is nullable native RSSI in dBm. Quality percentages are never +converted to dBm. An associated interface without an accessible RSSI still +appears with an unavailable signal. No scans or network changes occur. +`stats.wf` stores only available RSSI values (integer dBm) keyed by interface. +Real-time requests reuse the last snapshot instead of collecting again. The hub panel gates exclusively on current `systems.info.wifi` and system `up` status, independently of the selected historical period. Empty/null snapshots clear it. Historical averages use only available readings per interface; gaps are not zero signal. Interface colors and keys remain stable on reconnect. -SSID in an aggregate is the latest observed metadata, not a separate series. ## Platforms @@ -39,7 +40,7 @@ SSID in an aggregate is the latest observed metadata, not a separate series. - FreeBSD and other platforms: unsupported, empty snapshot. No approximation from ifconfig quality and no stale data retained. -Collectors retry each fresh poll, allowing interfaces and capabilities to appear +Collectors retry each default-interval poll, allowing interfaces and capabilities to appear without an agent restart. Standard agent response caching still applies. Existing hub record JSON storage requires no database schema migration. Older agents without the field keep the panel hidden. Native macOS/Windows runtime checks and diff --git a/agent/wifi/wifi.go b/agent/wifi/wifi.go index f45a7a064..2d86deae0 100644 --- a/agent/wifi/wifi.go +++ b/agent/wifi/wifi.go @@ -4,6 +4,7 @@ package wifi import ( "context" + "math" "os" "os/exec" "time" @@ -38,3 +39,19 @@ func Collect() map[string]system.WiFi { defer cancel() return collect(ctx) } + +// Signals reduces a snapshot to the RSSI values stored in stats history. +// Interfaces without an available reading are omitted. +func Signals(snapshot map[string]system.WiFi) map[string]int8 { + var signals map[string]int8 + for id, reading := range snapshot { + if reading.Signal == nil { + continue + } + if signals == nil { + signals = make(map[string]int8, len(snapshot)) + } + signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8)) + } + return signals +} diff --git a/agent/wifi/wifi_test.go b/agent/wifi/wifi_test.go index dca64eaa0..28d4764df 100644 --- a/agent/wifi/wifi_test.go +++ b/agent/wifi/wifi_test.go @@ -33,3 +33,25 @@ func TestSSIDWireSafety(t *testing.T) { }) } } + +func TestSignals(t *testing.T) { + strong, weak, rounded := -40.0, -200.0, -52.6 + got := Signals(map[string]system.WiFi{ + "wlan0": {SSID: "home", Signal: &strong}, + "wlan1": {Signal: &weak}, + "wlan2": {Signal: &rounded}, + "wlan3": {SSID: "no rssi"}, + }) + want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for id, signal := range want { + if got[id] != signal { + t.Fatalf("got %v, want %v", got, want) + } + } + if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil { + t.Fatal("expected nil without available readings") + } +} diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index e58428123..a688475ee 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -20,7 +20,6 @@ type WiFi struct { } type Stats struct { - WiFi map[string]WiFi `json:"wifi,omitempty" cbor:"40,keyasint,omitempty"` Cpu float64 `json:"cpu" cbor:"0,keyasint"` MaxCpu float64 `json:"cpum,omitempty" cbor:"-"` Mem float64 `json:"m" cbor:"2,keyasint"` @@ -64,6 +63,7 @@ type Stats struct { Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"` ZfsPools map[string]*ZfsPool `json:"z,omitempty" cbor:"39,keyasint,omitempty"` // ZFS pool metrics, keyed by pool name DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters + WiFi map[string]int8 `json:"wf,omitempty" cbor:"40,keyasint,omitempty"` // RSSI dBm keyed by interface; unavailable readings omitted } diff --git a/internal/entities/system/wifi_test.go b/internal/entities/system/wifi_test.go index 00bcd937f..bb7431eaa 100644 --- a/internal/entities/system/wifi_test.go +++ b/internal/entities/system/wifi_test.go @@ -10,7 +10,10 @@ import ( func TestWiFiWireSnapshot(t *testing.T) { signal := -55.0 for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} { - original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: wifi}} + original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: make(map[string]int8, len(wifi))}} + for id := range wifi { + original.Stats.WiFi[id] = -55 + } encoded, err := cbor.Marshal(original) if err != nil { t.Fatal(err) diff --git a/internal/hub/systems/system_wifi_test.go b/internal/hub/systems/system_wifi_test.go index 77b4f5715..6c6a8351a 100644 --- a/internal/hub/systems/system_wifi_test.go +++ b/internal/hub/systems/system_wifi_test.go @@ -17,7 +17,7 @@ func TestCreateRecordsWiFiDisconnectReconnect(t *testing.T) { {}, nil, {"wlan0": {SSID: "new", Signal: &signal}}, } { - _, err := sys.createRecords(&system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: snapshot}}) + _, err := sys.createRecords(&system.CombinedData{Info: system.Info{WiFi: snapshot}}) require.NoError(t, err) record, err := app.FindRecordById("systems", sys.Id) require.NoError(t, err) diff --git a/internal/hub/transport/wifi_test.go b/internal/hub/transport/wifi_test.go index 8682a65a0..a3a71c845 100644 --- a/internal/hub/transport/wifi_test.go +++ b/internal/hub/transport/wifi_test.go @@ -16,13 +16,21 @@ func TestWiFiSequentialResponseSnapshots(t *testing.T) { {"wlan0": {SSID: "home"}}, {}, nil, {"wlan1": {SSID: "new", Signal: &signal}}, } { - payload, err := cbor.Marshal(system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: snapshot}}) + signals := make(map[string]int8) + for id, reading := range snapshot { + if reading.Signal != nil { + signals[id] = int8(*reading.Signal) + } + } + payload, err := cbor.Marshal(system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: signals}}) require.NoError(t, err) require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded)) require.Len(t, decoded.Info.WiFi, len(snapshot)) - require.Len(t, decoded.Stats.WiFi, len(snapshot)) + require.Len(t, decoded.Stats.WiFi, len(signals)) for id, want := range snapshot { require.Equal(t, want, decoded.Info.WiFi[id]) + } + for id, want := range signals { require.Equal(t, want, decoded.Stats.WiFi[id]) } } diff --git a/internal/records/records.go b/internal/records/records.go index 1539107f2..7ee0369ae 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -267,8 +267,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { return sum } - // RSSI averages exclude absent and unavailable samples. - wifiSums := make(map[string]float64) + wifiSums := make(map[string]int) wifiCounts := make(map[string]int) // necessary because uint8 is not big enough for the sum batterySum := 0 @@ -288,15 +287,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { // Accumulate totals for i := range records { stats := &records[i] - for id, reading := range stats.WiFi { - if sum.WiFi == nil { - sum.WiFi = make(map[string]system.WiFi) - } - sum.WiFi[id] = system.WiFi{SSID: reading.SSID} - if reading.Signal != nil { - wifiSums[id] += *reading.Signal - wifiCounts[id]++ - } + for id, signal := range stats.WiFi { + wifiSums[id] += int(signal) + wifiCounts[id]++ } sum.Cpu += stats.Cpu @@ -627,11 +620,11 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { sum.CpuBreakdown = avg } - for id, reading := range sum.WiFi { - if wifiCounts[id] > 0 { - average := wifiSums[id] / float64(wifiCounts[id]) - reading.Signal = &average - sum.WiFi[id] = reading + // RSSI averages exclude records where the interface was absent. + if len(wifiSums) > 0 { + sum.WiFi = make(map[string]int8, len(wifiSums)) + for id, total := range wifiSums { + sum.WiFi[id] = int8(math.Round(float64(total) / float64(wifiCounts[id]))) } } diff --git a/internal/records/records_wifi_test.go b/internal/records/records_wifi_test.go index 90711dab4..f7409c646 100644 --- a/internal/records/records_wifi_test.go +++ b/internal/records/records_wifi_test.go @@ -7,17 +7,16 @@ import ( ) func TestWiFiAverageAvailableSamples(t *testing.T) { - a, b, c := -40.0, -60.0, -80.0 input := []system.Stats{ - {WiFi: map[string]system.WiFi{"wlan0": {SSID: "old", Signal: &a}}}, + {WiFi: map[string]int8{"wlan0": -40}}, {}, - {WiFi: map[string]system.WiFi{"wlan0": {SSID: "new", Signal: &b}, "wlan1": {Signal: &c}, "unknown": {}}}, + {WiFi: map[string]int8{"wlan0": -61, "wlan1": -80}}, } result := AverageSystemStatsSlice(input) - if len(result.WiFi) != 3 || *result.WiFi["wlan0"].Signal != -50 || *result.WiFi["wlan1"].Signal != -80 || result.WiFi["unknown"].Signal != nil || result.WiFi["wlan0"].SSID != "new" { + if len(result.WiFi) != 2 || result.WiFi["wlan0"] != -51 || result.WiFi["wlan1"] != -80 { t.Fatalf("%#v", result.WiFi) } - if *input[0].WiFi["wlan0"].Signal != -40 { + if input[0].WiFi["wlan0"] != -40 { t.Fatal("mutated input") } if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 { diff --git a/internal/site/src/components/routes/system/charts/wifi-chart.tsx b/internal/site/src/components/routes/system/charts/wifi-chart.tsx index 1e97f41f2..fc1196c6e 100644 --- a/internal/site/src/components/routes/system/charts/wifi-chart.tsx +++ b/internal/site/src/components/routes/system/charts/wifi-chart.tsx @@ -20,7 +20,7 @@ export function WiFiChart({ const dataPoints = interfaces.map(([id, current]) => ({ label: current.s ? `${id} (${current.s})` : id, color: wifiColor(id), - dataKey: ({ stats }: SystemStatsRecord) => stats?.wifi?.[id]?.r, + dataKey: ({ stats }: SystemStatsRecord) => stats?.wf?.[id], })) return ( /** cpu percent */ cpu: number /** peak cpu */ @@ -168,6 +167,8 @@ export interface SystemStats { bat?: [number, BatteryState] /** battery percentages by device name */ bats?: Record + /** Wi-Fi RSSI (dBm) by interface */ + wf?: Record /** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */ ni?: Record }