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
+7 -2
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
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
+8 -7
View File
@@ -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
+17
View File
@@ -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
}
+22
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")
}
}
+1 -1
View File
@@ -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
}
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+10 -2
View File
@@ -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])
}
}
+9 -16
View File
@@ -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])))
}
}
+4 -5
View File
@@ -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 {
@@ -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 (
<ChartCard
+2 -1
View File
@@ -91,7 +91,6 @@ export interface SystemInfo {
}
export interface SystemStats {
wifi?: Record<string, WiFi>
/** cpu percent */
cpu: number
/** peak cpu */
@@ -168,6 +167,8 @@ export interface SystemStats {
bat?: [number, BatteryState]
/** battery percentages by device name */
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] */
ni?: Record<string, [number, number, number, number]>
}