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.
This commit is contained in:
henrygd
2026-09-25 18:02:34 -04:00
parent 86ab0fae8b
commit 16e3fbadce
12 changed files with 86 additions and 37 deletions

View File

@@ -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 // update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType 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.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery a.systemInfo.Battery = systemStats.Battery
a.systemInfo.WiFi = systemStats.WiFi
a.systemInfo.Uptime, _ = getUptime() a.systemInfo.Uptime, _ = getUptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads a.systemInfo.Threads = a.systemDetails.Threads

View File

@@ -1,16 +1,17 @@
# Connected Wi-Fi signal # Connected Wi-Fi signal
Each fresh stats poll reports a snapshot of connected station interfaces in Each default-interval poll reports a snapshot of connected station interfaces
`stats.wifi` and `info.wifi`. Map keys identify interfaces, not networks. `s` is in `info.wifi`. Map keys identify interfaces, not networks. `s` is optional SSID
optional SSID metadata; `r` is nullable native RSSI in dBm. Quality percentages metadata; `r` is nullable native RSSI in dBm. Quality percentages are never
are never converted to dBm. An associated interface without an accessible RSSI converted to dBm. An associated interface without an accessible RSSI still
still appears with an unavailable signal. No scans or network changes occur. 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` The hub panel gates exclusively on current `systems.info.wifi` and system `up`
status, independently of the selected historical period. Empty/null snapshots status, independently of the selected historical period. Empty/null snapshots
clear it. Historical averages use only available readings per interface; gaps clear it. Historical averages use only available readings per interface; gaps
are not zero signal. Interface colors and keys remain stable on reconnect. 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 ## 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 - FreeBSD and other platforms: unsupported, empty snapshot. No approximation
from ifconfig quality and no stale data retained. 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 without an agent restart. Standard agent response caching still applies. Existing
hub record JSON storage requires no database schema migration. Older agents hub record JSON storage requires no database schema migration. Older agents
without the field keep the panel hidden. Native macOS/Windows runtime checks and without the field keep the panel hidden. Native macOS/Windows runtime checks and

View File

@@ -4,6 +4,7 @@ package wifi
import ( import (
"context" "context"
"math"
"os" "os"
"os/exec" "os/exec"
"time" "time"
@@ -38,3 +39,19 @@ func Collect() map[string]system.WiFi {
defer cancel() defer cancel()
return collect(ctx) 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
}

View File

@@ -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")
}
}

View File

@@ -20,7 +20,6 @@ type WiFi struct {
} }
type Stats struct { type Stats struct {
WiFi map[string]WiFi `json:"wifi,omitempty" cbor:"40,keyasint,omitempty"`
Cpu float64 `json:"cpu" cbor:"0,keyasint"` Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"` MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
Mem float64 `json:"m" cbor:"2,keyasint"` 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"` 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 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 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
} }

View File

@@ -10,7 +10,10 @@ import (
func TestWiFiWireSnapshot(t *testing.T) { func TestWiFiWireSnapshot(t *testing.T) {
signal := -55.0 signal := -55.0
for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} { 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) encoded, err := cbor.Marshal(original)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -17,7 +17,7 @@ func TestCreateRecordsWiFiDisconnectReconnect(t *testing.T) {
{}, nil, {}, nil,
{"wlan0": {SSID: "new", Signal: &signal}}, {"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) require.NoError(t, err)
record, err := app.FindRecordById("systems", sys.Id) record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -16,13 +16,21 @@ func TestWiFiSequentialResponseSnapshots(t *testing.T) {
{"wlan0": {SSID: "home"}}, {}, nil, {"wlan0": {SSID: "home"}}, {}, nil,
{"wlan1": {SSID: "new", Signal: &signal}}, {"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, err)
require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded)) require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded))
require.Len(t, decoded.Info.WiFi, len(snapshot)) 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 { for id, want := range snapshot {
require.Equal(t, want, decoded.Info.WiFi[id]) require.Equal(t, want, decoded.Info.WiFi[id])
}
for id, want := range signals {
require.Equal(t, want, decoded.Stats.WiFi[id]) require.Equal(t, want, decoded.Stats.WiFi[id])
} }
} }

View File

@@ -267,8 +267,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
return sum return sum
} }
// RSSI averages exclude absent and unavailable samples. wifiSums := make(map[string]int)
wifiSums := make(map[string]float64)
wifiCounts := make(map[string]int) wifiCounts := make(map[string]int)
// necessary because uint8 is not big enough for the sum // necessary because uint8 is not big enough for the sum
batterySum := 0 batterySum := 0
@@ -288,15 +287,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// Accumulate totals // Accumulate totals
for i := range records { for i := range records {
stats := &records[i] stats := &records[i]
for id, reading := range stats.WiFi { for id, signal := range stats.WiFi {
if sum.WiFi == nil { wifiSums[id] += int(signal)
sum.WiFi = make(map[string]system.WiFi) wifiCounts[id]++
}
sum.WiFi[id] = system.WiFi{SSID: reading.SSID}
if reading.Signal != nil {
wifiSums[id] += *reading.Signal
wifiCounts[id]++
}
} }
sum.Cpu += stats.Cpu sum.Cpu += stats.Cpu
@@ -627,11 +620,11 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
sum.CpuBreakdown = avg sum.CpuBreakdown = avg
} }
for id, reading := range sum.WiFi { // RSSI averages exclude records where the interface was absent.
if wifiCounts[id] > 0 { if len(wifiSums) > 0 {
average := wifiSums[id] / float64(wifiCounts[id]) sum.WiFi = make(map[string]int8, len(wifiSums))
reading.Signal = &average for id, total := range wifiSums {
sum.WiFi[id] = reading sum.WiFi[id] = int8(math.Round(float64(total) / float64(wifiCounts[id])))
} }
} }

View File

@@ -7,17 +7,16 @@ import (
) )
func TestWiFiAverageAvailableSamples(t *testing.T) { func TestWiFiAverageAvailableSamples(t *testing.T) {
a, b, c := -40.0, -60.0, -80.0
input := []system.Stats{ 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) 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) t.Fatalf("%#v", result.WiFi)
} }
if *input[0].WiFi["wlan0"].Signal != -40 { if input[0].WiFi["wlan0"] != -40 {
t.Fatal("mutated input") t.Fatal("mutated input")
} }
if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 { if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 {

View File

@@ -20,7 +20,7 @@ export function WiFiChart({
const dataPoints = interfaces.map(([id, current]) => ({ const dataPoints = interfaces.map(([id, current]) => ({
label: current.s ? `${id} (${current.s})` : id, label: current.s ? `${id} (${current.s})` : id,
color: wifiColor(id), color: wifiColor(id),
dataKey: ({ stats }: SystemStatsRecord) => stats?.wifi?.[id]?.r, dataKey: ({ stats }: SystemStatsRecord) => stats?.wf?.[id],
})) }))
return ( return (
<ChartCard <ChartCard

View File

@@ -91,7 +91,6 @@ export interface SystemInfo {
} }
export interface SystemStats { export interface SystemStats {
wifi?: Record<string, WiFi>
/** cpu percent */ /** cpu percent */
cpu: number cpu: number
/** peak cpu */ /** peak cpu */
@@ -168,6 +167,8 @@ export interface SystemStats {
bat?: [number, BatteryState] bat?: [number, BatteryState]
/** battery percentages by device name */ /** battery percentages by device name */
bats?: Record<string, number> bats?: Record<string, number>
/** Wi-Fi RSSI (dBm) by interface */
wf?: Record<string, number>
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */ /** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
ni?: Record<string, [number, number, number, number]> ni?: Record<string, [number, number, number, number]>
} }