mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-19 08:47:46 +02:00
feat: track battery levels per device
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
// Package battery provides battery information for the host and connected devices.
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateUnknown uint8 = iota
|
stateUnknown uint8 = iota
|
||||||
stateEmpty
|
stateEmpty
|
||||||
@@ -9,3 +16,55 @@ const (
|
|||||||
stateDischarging
|
stateDischarging
|
||||||
stateIdle
|
stateIdle
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Battery is a readable battery reported by the operating system.
|
||||||
|
type Battery struct {
|
||||||
|
Name string
|
||||||
|
Percent uint8
|
||||||
|
State uint8
|
||||||
|
FullChargeCapacity uint64
|
||||||
|
HasFullChargeCapacity bool
|
||||||
|
System bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoBatteries = errors.New("no readable batteries")
|
||||||
|
|
||||||
|
// normalizeBatteries supplies stable fallback names and disambiguates duplicates.
|
||||||
|
func normalizeBatteries(batteries []Battery) []Battery {
|
||||||
|
nameCounts := make(map[string]int, len(batteries))
|
||||||
|
for i := range batteries {
|
||||||
|
name := strings.TrimSpace(batteries[i].Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "Battery " + strconv.Itoa(i+1)
|
||||||
|
}
|
||||||
|
nameCounts[name]++
|
||||||
|
if nameCounts[name] > 1 {
|
||||||
|
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
|
||||||
|
}
|
||||||
|
batteries[i].Name = name
|
||||||
|
}
|
||||||
|
return batteries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary returns the representative battery. Reported full-charge capacity wins,
|
||||||
|
// then system-scoped devices, then name for deterministic ties.
|
||||||
|
func Primary(batteries []Battery) (Battery, bool) {
|
||||||
|
if len(batteries) == 0 {
|
||||||
|
return Battery{}, false
|
||||||
|
}
|
||||||
|
ordered := append([]Battery(nil), batteries...)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
a, b := ordered[i], ordered[j]
|
||||||
|
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
|
||||||
|
return a.HasFullChargeCapacity
|
||||||
|
}
|
||||||
|
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
||||||
|
return a.FullChargeCapacity > b.FullChargeCapacity
|
||||||
|
}
|
||||||
|
if a.System != b.System {
|
||||||
|
return a.System
|
||||||
|
}
|
||||||
|
return a.Name < b.Name
|
||||||
|
})
|
||||||
|
return ordered[0], true
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"howett.net/plist"
|
"howett.net/plist"
|
||||||
)
|
)
|
||||||
@@ -35,62 +31,46 @@ func readMacBatteries() ([]macBattery, error) {
|
|||||||
return batteries, nil
|
return batteries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
func HasReadableBattery() bool {
|
||||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
batteries, _ := GetBatteryStats()
|
||||||
systemHasBattery := false
|
return len(batteries) > 0
|
||||||
batteries, err := readMacBatteries()
|
}
|
||||||
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
|
||||||
for _, bat := range batteries {
|
|
||||||
if bat.MaxCapacity > 0 {
|
|
||||||
systemHasBattery = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return systemHasBattery
|
|
||||||
})
|
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state.
|
// GetBatteryStats returns every readable battery reported by macOS.
|
||||||
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
func GetBatteryStats() ([]Battery, error) {
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
if !HasReadableBattery() {
|
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
batteries, err := readMacBatteries()
|
batteries, err := readMacBatteries()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if len(batteries) == 0 {
|
if len(batteries) == 0 {
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
return nil, errNoBatteries
|
||||||
}
|
}
|
||||||
|
result := make([]Battery, 0, len(batteries))
|
||||||
totalCapacity := 0
|
|
||||||
totalCharge := 0
|
|
||||||
batteryState = math.MaxUint8
|
|
||||||
|
|
||||||
for _, bat := range batteries {
|
for _, bat := range batteries {
|
||||||
if bat.MaxCapacity == 0 {
|
if bat.MaxCapacity <= 0 {
|
||||||
// skip ghost batteries with 0 capacity
|
// skip ghost batteries with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalCapacity += bat.MaxCapacity
|
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
||||||
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
state := stateUnknown
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !bat.ExternalConnected:
|
case !bat.ExternalConnected:
|
||||||
batteryState = stateDischarging
|
state = stateDischarging
|
||||||
case bat.IsCharging:
|
case bat.IsCharging:
|
||||||
batteryState = stateCharging
|
state = stateCharging
|
||||||
case bat.CurrentCapacity == 0:
|
case bat.CurrentCapacity == 0:
|
||||||
batteryState = stateEmpty
|
state = stateEmpty
|
||||||
case !bat.FullyCharged:
|
case !bat.FullyCharged:
|
||||||
batteryState = stateIdle
|
state = stateIdle
|
||||||
default:
|
default:
|
||||||
batteryState = stateFull
|
state = stateFull
|
||||||
}
|
}
|
||||||
|
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
||||||
|
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
||||||
}
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
return nil, errNoBatteries
|
||||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
|
||||||
}
|
}
|
||||||
|
return normalizeBatteries(result), nil
|
||||||
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,58 +3,19 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
var batteryRoot = "/sys/class/power_supply"
|
||||||
var getBatteryPaths func() ([]string, error)
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery reports whether collection currently finds a readable battery.
|
||||||
var HasReadableBattery func() bool
|
func HasReadableBattery() bool {
|
||||||
|
batteries, _ := GetBatteryStats()
|
||||||
func init() {
|
return len(batteries) > 0
|
||||||
resetBatteryState("/sys/class/power_supply")
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetBatteryState resets the sync.Once functions to a fresh state.
|
|
||||||
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
|
||||||
func resetBatteryState(sysfsPowerSupplyPath string) {
|
|
||||||
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
|
||||||
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var paths []string
|
|
||||||
for _, e := range entries {
|
|
||||||
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
|
||||||
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
|
||||||
paths = append(paths, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
})
|
|
||||||
HasReadableBattery = sync.OnceValue(func() bool {
|
|
||||||
systemHasBattery := false
|
|
||||||
paths, err := getBatteryPaths()
|
|
||||||
for _, path := range paths {
|
|
||||||
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
|
||||||
systemHasBattery = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !systemHasBattery {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
|
||||||
}
|
|
||||||
return systemHasBattery
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSysfsState(status string) uint8 {
|
func parseSysfsState(status string) uint8 {
|
||||||
@@ -74,26 +35,18 @@ func parseSysfsState(status string) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state.
|
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
||||||
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
func GetBatteryStats() ([]Battery, error) {
|
||||||
// value is used, which is always 0-100 and matches what the OS displays.
|
entries, err := os.ReadDir(batteryRoot)
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
if !HasReadableBattery() {
|
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
paths, err := getBatteryPaths()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batteryPercent, batteryState, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(paths) == 0 {
|
batteries := make([]Battery, 0, len(entries))
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
for _, entry := range entries {
|
||||||
}
|
path := filepath.Join(batteryRoot, entry.Name())
|
||||||
|
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
||||||
batteryState = math.MaxUint8
|
continue
|
||||||
totalPercent := 0
|
}
|
||||||
count := 0
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -103,19 +56,30 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cap = min(max(cap, 0), 100)
|
cap = min(max(cap, 0), 100)
|
||||||
totalPercent += cap
|
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
||||||
count++
|
if name == "" {
|
||||||
|
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
||||||
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
|
||||||
if state != stateUnknown {
|
|
||||||
batteryState = state
|
|
||||||
}
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = entry.Name()
|
||||||
|
}
|
||||||
|
battery := Battery{
|
||||||
|
Name: name,
|
||||||
|
Percent: uint8(cap),
|
||||||
|
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
|
||||||
|
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
|
||||||
|
}
|
||||||
|
for _, fullName := range []string{"charge_full", "energy_full"} {
|
||||||
|
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
|
||||||
|
battery.FullChargeCapacity = parsed
|
||||||
|
battery.HasFullChargeCapacity = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batteries = append(batteries, battery)
|
||||||
}
|
}
|
||||||
|
if len(batteries) == 0 {
|
||||||
if count == 0 || batteryState == math.MaxUint8 {
|
return nil, errNoBatteries
|
||||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
|
||||||
}
|
}
|
||||||
|
return normalizeBatteries(batteries), nil
|
||||||
batteryPercent = uint8(totalPercent / count)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,204 +8,102 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
||||||
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
|
||||||
// everything on cleanup. Returns a helper to create battery directories.
|
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
||||||
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
tmp := t.TempDir()
|
previousRoot := batteryRoot
|
||||||
resetBatteryState(tmp)
|
batteryRoot = root
|
||||||
|
t.Cleanup(func() { batteryRoot = previousRoot })
|
||||||
write := func(path, content string) {
|
write := func(path, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := filepath.Dir(path)
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
||||||
t.Fatal(err)
|
}
|
||||||
|
add := func(b fakeBattery) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, b.id)
|
||||||
|
write(filepath.Join(dir, "type"), "Battery")
|
||||||
|
if b.capacity != "" {
|
||||||
|
write(filepath.Join(dir, "capacity"), b.capacity)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
write(filepath.Join(dir, "status"), b.status)
|
||||||
t.Fatal(err)
|
if b.name != "" {
|
||||||
|
write(filepath.Join(dir, "model_name"), b.name)
|
||||||
|
}
|
||||||
|
if b.full != "" {
|
||||||
|
write(filepath.Join(dir, "energy_full"), b.full)
|
||||||
|
}
|
||||||
|
if b.scope != "" {
|
||||||
|
write(filepath.Join(dir, "scope"), b.scope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return root, add
|
||||||
addBattery = func(name, capacity, status string) {
|
|
||||||
t.Helper()
|
|
||||||
batDir := filepath.Join(tmp, name)
|
|
||||||
write(filepath.Join(batDir, "type"), "Battery")
|
|
||||||
write(filepath.Join(batDir, "capacity"), capacity)
|
|
||||||
write(filepath.Join(batDir, "status"), status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tmp, addBattery
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSysfsState(t *testing.T) {
|
func TestParseSysfsState(t *testing.T) {
|
||||||
tests := []struct {
|
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
||||||
input string
|
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
||||||
want uint8
|
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
||||||
}{
|
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
||||||
{"Empty", stateEmpty},
|
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
||||||
{"Full", stateFull},
|
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
||||||
{"Charging", stateCharging},
|
|
||||||
{"Discharging", stateDischarging},
|
|
||||||
{"Not charging", stateIdle},
|
|
||||||
{"", stateUnknown},
|
|
||||||
{"SomethingElse", stateUnknown},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
||||||
_, addBattery := setupFakeSysfs(t)
|
_, add := setupFakeSysfs(t)
|
||||||
addBattery("BAT0", "72", "Discharging")
|
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
||||||
|
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
||||||
pct, state, err := GetBatteryStats()
|
batteries, err := GetBatteryStats()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, uint8(72), pct)
|
require.Len(t, batteries, 2)
|
||||||
assert.Equal(t, stateDischarging, state)
|
assert.Equal(t, "Primary", batteries[0].Name)
|
||||||
|
assert.Equal(t, uint8(100), batteries[0].Percent)
|
||||||
|
assert.Equal(t, stateUnknown, batteries[1].State)
|
||||||
|
primary, ok := Primary(batteries)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Primary", primary.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
|
||||||
_, addBattery := setupFakeSysfs(t)
|
root, add := setupFakeSysfs(t)
|
||||||
addBattery("BAT0", "80", "Charging")
|
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
||||||
addBattery("BAT1", "40", "Charging")
|
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
||||||
|
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
||||||
pct, state, err := GetBatteryStats()
|
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
||||||
assert.NoError(t, err)
|
ac := filepath.Join(root, "AC0")
|
||||||
// average of 80 and 40 = 60
|
require.NoError(t, os.MkdirAll(ac, 0o755))
|
||||||
assert.EqualValues(t, 60, pct)
|
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
||||||
assert.Equal(t, stateCharging, state)
|
batteries, err := GetBatteryStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, batteries, 3)
|
||||||
|
assert.Equal(t, "Keyboard", batteries[0].Name)
|
||||||
|
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
|
||||||
|
assert.Equal(t, uint8(0), batteries[1].Percent)
|
||||||
|
assert.Equal(t, "BAT3", batteries[2].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
||||||
_, addBattery := setupFakeSysfs(t)
|
_, add := setupFakeSysfs(t)
|
||||||
addBattery("BAT0", "100", "Full")
|
_, err := GetBatteryStats()
|
||||||
|
|
||||||
pct, state, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, uint8(100), pct)
|
|
||||||
assert.Equal(t, stateFull, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_CapacityClamped(t *testing.T) {
|
|
||||||
_, addBattery := setupFakeSysfs(t)
|
|
||||||
addBattery("BAT0", "105", "Charging")
|
|
||||||
|
|
||||||
pct, state, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, uint8(100), pct)
|
|
||||||
assert.Equal(t, stateCharging, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
|
||||||
_, addBattery := setupFakeSysfs(t)
|
|
||||||
addBattery("BAT0", "0", "Empty")
|
|
||||||
|
|
||||||
pct, state, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, uint8(0), pct)
|
|
||||||
assert.Equal(t, stateEmpty, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
|
||||||
_, addBattery := setupFakeSysfs(t)
|
|
||||||
addBattery("BAT0", "80", "Not charging")
|
|
||||||
|
|
||||||
pct, state, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, uint8(80), pct)
|
|
||||||
assert.Equal(t, stateIdle, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
|
||||||
setupFakeSysfs(t) // empty directory, no batteries
|
|
||||||
|
|
||||||
_, _, err := GetBatteryStats()
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
assert.False(t, HasReadableBattery())
|
||||||
|
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
||||||
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
batteries, err := GetBatteryStats()
|
||||||
tmp, addBattery := setupFakeSysfs(t)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a real battery
|
|
||||||
addBattery("BAT0", "55", "Charging")
|
|
||||||
|
|
||||||
// Add an AC adapter (type != Battery) - should be ignored
|
|
||||||
acDir := filepath.Join(tmp, "AC0")
|
|
||||||
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pct, state, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, uint8(55), pct)
|
|
||||||
assert.Equal(t, stateCharging, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
|
||||||
tmp, addBattery := setupFakeSysfs(t)
|
|
||||||
|
|
||||||
// One battery with valid capacity
|
|
||||||
addBattery("BAT0", "90", "Discharging")
|
|
||||||
|
|
||||||
// Another with invalid capacity text
|
|
||||||
badDir := filepath.Join(tmp, "BAT1")
|
|
||||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pct, _, err := GetBatteryStats()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
// Only BAT0 counted
|
|
||||||
assert.Equal(t, uint8(90), pct)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
|
||||||
_, addBattery := setupFakeSysfs(t)
|
|
||||||
addBattery("BAT0", "50", "SomethingWeird")
|
|
||||||
|
|
||||||
_, _, err := GetBatteryStats()
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasReadableBattery_True(t *testing.T) {
|
|
||||||
_, addBattery := setupFakeSysfs(t)
|
|
||||||
addBattery("BAT0", "50", "Charging")
|
|
||||||
|
|
||||||
assert.True(t, HasReadableBattery())
|
assert.True(t, HasReadableBattery())
|
||||||
|
require.Len(t, batteries, 1)
|
||||||
|
assert.Equal(t, uint8(64), batteries[0].Percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasReadableBattery_False(t *testing.T) {
|
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
||||||
setupFakeSysfs(t) // no batteries
|
_, add := setupFakeSysfs(t)
|
||||||
|
add(fakeBattery{id: "BAT0", status: "Charging"})
|
||||||
assert.False(t, HasReadableBattery())
|
_, err := GetBatteryStats()
|
||||||
}
|
assert.Error(t, err)
|
||||||
|
|
||||||
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
|
||||||
tmp, _ := setupFakeSysfs(t)
|
|
||||||
|
|
||||||
// Battery dir with type file but no capacity file
|
|
||||||
batDir := filepath.Join(tmp, "BAT0")
|
|
||||||
err := os.MkdirAll(batDir, 0o755)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.False(t, HasReadableBattery())
|
assert.False(t, HasReadableBattery())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBatteryStats() (uint8, uint8, error) {
|
func GetBatteryStats() ([]Battery, error) {
|
||||||
return 0, 0, errors.ErrUnsupported
|
return nil, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|||||||
35
agent/battery/battery_test.go
Normal file
35
agent/battery/battery_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrimarySelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bats []Battery
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
|
||||||
|
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
|
||||||
|
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
|
||||||
|
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := Primary(tt.bats)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, tt.want, got.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, ok := Primary(nil)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
|
||||||
|
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
||||||
|
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
||||||
|
}
|
||||||
@@ -7,9 +7,6 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -79,7 +76,7 @@ var (
|
|||||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||||
)
|
)
|
||||||
|
|
||||||
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
// winBatteryGet reads one battery by index.
|
||||||
// Returns error == errNotFound when there are no more batteries.
|
// Returns error == errNotFound when there are no more batteries.
|
||||||
var errNotFound = errors.New("no more batteries")
|
var errNotFound = errors.New("no more batteries")
|
||||||
|
|
||||||
@@ -122,7 +119,7 @@ func readWinBatteryState(powerState uint32) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
func winBatteryGet(idx int) (Battery, error) {
|
||||||
hdev, err := setupDiSetup(
|
hdev, err := setupDiSetup(
|
||||||
setupDiGetClassDevsW,
|
setupDiGetClassDevsW,
|
||||||
4,
|
4,
|
||||||
@@ -132,7 +129,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
0, 0,
|
0, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, stateUnknown, err
|
return Battery{}, err
|
||||||
}
|
}
|
||||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||||
|
|
||||||
@@ -148,10 +145,10 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||||
return 0, 0, stateUnknown, errNotFound
|
return Battery{}, errNotFound
|
||||||
}
|
}
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return 0, 0, stateUnknown, errno
|
return Battery{}, errno
|
||||||
}
|
}
|
||||||
|
|
||||||
var cbRequired uint32
|
var cbRequired uint32
|
||||||
@@ -165,7 +162,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||||
return 0, 0, stateUnknown, errno
|
return Battery{}, errno
|
||||||
}
|
}
|
||||||
didd := make([]uint16, cbRequired/2)
|
didd := make([]uint16, cbRequired/2)
|
||||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||||
@@ -185,7 +182,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return 0, 0, stateUnknown, errno
|
return Battery{}, errno
|
||||||
}
|
}
|
||||||
devicePath := &didd[2:][0]
|
devicePath := &didd[2:][0]
|
||||||
|
|
||||||
@@ -199,7 +196,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, stateUnknown, err
|
return Battery{}, err
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(handle)
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
@@ -216,7 +213,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
)
|
)
|
||||||
if err != nil || bqi.BatteryTag == 0 {
|
if err != nil || bqi.BatteryTag == 0 {
|
||||||
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
return Battery{}, errors.New("battery tag not returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
var bi batteryInformation
|
var bi batteryInformation
|
||||||
@@ -229,7 +226,21 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
uint32(unsafe.Sizeof(bi)),
|
uint32(unsafe.Sizeof(bi)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return 0, 0, stateUnknown, err
|
return Battery{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
|
||||||
|
name := ""
|
||||||
|
nameQuery := bqi
|
||||||
|
nameQuery.InformationLevel = 4 // BatteryDeviceName
|
||||||
|
nameBuffer := make([]uint16, 128)
|
||||||
|
if err := windows.DeviceIoControl(
|
||||||
|
handle, 2703428,
|
||||||
|
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
|
||||||
|
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
|
||||||
|
&dwOut, nil,
|
||||||
|
); err == nil {
|
||||||
|
name = windows.UTF16ToString(nameBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||||
@@ -243,56 +254,38 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
|||||||
uint32(unsafe.Sizeof(bs)),
|
uint32(unsafe.Sizeof(bs)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return 0, 0, stateUnknown, err
|
return Battery{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
||||||
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
return Battery{}, errors.New("battery capacity unknown")
|
||||||
}
|
}
|
||||||
|
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
||||||
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
||||||
|
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
func HasReadableBattery() bool {
|
||||||
systemHasBattery := false
|
batteries, _ := GetBatteryStats()
|
||||||
full, _, _, err := winBatteryGet(0)
|
return len(batteries) > 0
|
||||||
if err == nil && full > 0 {
|
}
|
||||||
systemHasBattery = true
|
|
||||||
}
|
|
||||||
if !systemHasBattery {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
|
||||||
}
|
|
||||||
return systemHasBattery
|
|
||||||
})
|
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state.
|
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
if !HasReadableBattery() {
|
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFull := uint32(0)
|
|
||||||
totalCurrent := uint32(0)
|
|
||||||
batteryState = math.MaxUint8
|
|
||||||
|
|
||||||
|
// GetBatteryStats returns every readable battery reported by Windows.
|
||||||
|
func GetBatteryStats() ([]Battery, error) {
|
||||||
|
batteries := make([]Battery, 0, 2)
|
||||||
for i := 0; ; i++ {
|
for i := 0; ; i++ {
|
||||||
full, current, state, bErr := winBatteryGet(i)
|
battery, bErr := winBatteryGet(i)
|
||||||
if errors.Is(bErr, errNotFound) {
|
if errors.Is(bErr, errNotFound) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if bErr != nil || full == 0 {
|
if bErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalFull += full
|
batteries = append(batteries, battery)
|
||||||
totalCurrent += min(current, full)
|
|
||||||
batteryState = state
|
|
||||||
}
|
}
|
||||||
|
if len(batteries) == 0 {
|
||||||
if totalFull == 0 || batteryState == math.MaxUint8 {
|
return nil, errNoBatteries
|
||||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
|
||||||
}
|
}
|
||||||
|
return normalizeBatteries(batteries), nil
|
||||||
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,9 +132,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
if batteries, err := battery.GetBatteryStats(); err == nil {
|
||||||
systemStats.Battery[0] = batteryPercent
|
systemStats.Batteries = make(map[string]uint8, len(batteries))
|
||||||
systemStats.Battery[1] = batteryState
|
for _, device := range batteries {
|
||||||
|
systemStats.Batteries[device.Name] = device.Percent
|
||||||
|
}
|
||||||
|
if primary, ok := battery.Primary(batteries); ok {
|
||||||
|
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu metrics
|
// cpu metrics
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type SystemAlertStats struct {
|
|||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
Battery [2]uint8 `json:"bat"`
|
Battery [2]uint8 `json:"bat"`
|
||||||
|
Batteries map[string]uint8 `json:"bats"`
|
||||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "GPU":
|
case "GPU":
|
||||||
val = data.Info.GpuPct
|
val = data.Info.GpuPct
|
||||||
case "Battery":
|
case "Battery":
|
||||||
if data.Stats.Battery[0] == 0 {
|
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = float64(data.Stats.Battery[0])
|
val = float64(data.Stats.Battery[0])
|
||||||
@@ -167,6 +167,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
stat := systemStats[i]
|
stat := systemStats[i]
|
||||||
// subtract 10 seconds to give a small time buffer
|
// subtract 10 seconds to give a small time buffer
|
||||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
|
stats = SystemAlertStats{}
|
||||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -235,6 +236,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.val += maxUsage
|
alert.val += maxUsage
|
||||||
case "Battery":
|
case "Battery":
|
||||||
|
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
alert.val += float64(stats.Battery[0])
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -297,6 +301,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
||||||
|
return legacy != [2]uint8{} || len(batteries) > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
systemName := alert.systemRecord.GetString("name")
|
systemName := alert.systemRecord.GetString("name")
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func TestSystemAlertsOneMin(t *testing.T) {
|
|||||||
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
|
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
|
||||||
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
|
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
|
||||||
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
|
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
|
||||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
|
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Stats struct {
|
|||||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
|
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
|
||||||
|
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
@@ -43,7 +44,7 @@ type Stats struct {
|
|||||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
|
|||||||
37
internal/entities/system/system_test.go
Normal file
37
internal/entities/system/system_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatsBatteryTransport(t *testing.T) {
|
||||||
|
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var jsonPayload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
|
||||||
|
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
|
||||||
|
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
|
||||||
|
|
||||||
|
cborData, err := cbor.Marshal(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decoded Stats
|
||||||
|
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
|
||||||
|
assert.Equal(t, stats.Battery, decoded.Battery)
|
||||||
|
assert.Equal(t, stats.Batteries, decoded.Batteries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsLegacyBatteryPayload(t *testing.T) {
|
||||||
|
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(data, &payload))
|
||||||
|
assert.Contains(t, payload, "bat")
|
||||||
|
assert.NotContains(t, payload, "bats")
|
||||||
|
}
|
||||||
@@ -186,6 +186,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
|
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
|
batteryCount := 0
|
||||||
|
batterySums := make(map[string]uint64)
|
||||||
|
batteryCounts := make(map[string]uint64)
|
||||||
// accumulate per-core usage across records
|
// accumulate per-core usage across records
|
||||||
var cpuCoresSums []uint64
|
var cpuCoresSums []uint64
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||||
@@ -232,8 +235,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
for i := range stats.DiskIoStats {
|
for i := range stats.DiskIoStats {
|
||||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||||
}
|
}
|
||||||
batterySum += int(stats.Battery[0])
|
if hasBattery(stats.Battery, stats.Batteries) {
|
||||||
sum.Battery[1] = stats.Battery[1]
|
batterySum += int(stats.Battery[0])
|
||||||
|
batteryCount++
|
||||||
|
sum.Battery[1] = stats.Battery[1]
|
||||||
|
}
|
||||||
|
for name, percent := range stats.Batteries {
|
||||||
|
batterySums[name] += uint64(percent)
|
||||||
|
batteryCounts[name]++
|
||||||
|
}
|
||||||
|
|
||||||
// accumulate per-core usage if present
|
// accumulate per-core usage if present
|
||||||
if stats.CpuCoresUsage != nil {
|
if stats.CpuCoresUsage != nil {
|
||||||
@@ -379,7 +389,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
sum.Battery[0] = uint8(batterySum / int(count))
|
if batteryCount > 0 {
|
||||||
|
sum.Battery[0] = uint8(batterySum / batteryCount)
|
||||||
|
}
|
||||||
|
if len(batterySums) > 0 {
|
||||||
|
sum.Batteries = make(map[string]uint8, len(batterySums))
|
||||||
|
for name, total := range batterySums {
|
||||||
|
sum.Batteries[name] = uint8(total / batteryCounts[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Average network interfaces
|
// Average network interfaces
|
||||||
if sum.NetworkInterfaces != nil {
|
if sum.NetworkInterfaces != nil {
|
||||||
@@ -467,6 +485,10 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
||||||
|
return legacy != [2]uint8{} || len(batteries) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||||
allStats := make([][]container.Stats, 0, len(records))
|
allStats := make([][]container.Stats, 0, len(records))
|
||||||
|
|||||||
@@ -602,6 +602,28 @@ func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
|||||||
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_BatteriesIndependentSamples(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Battery: [2]uint8{80, 4}, Batteries: map[string]uint8{"Primary": 80, "Mouse": 0}},
|
||||||
|
{Battery: [2]uint8{60, 3}, Batteries: map[string]uint8{"Primary": 60}},
|
||||||
|
{Battery: [2]uint8{30, 4}, Batteries: map[string]uint8{"Mouse": 40}},
|
||||||
|
{},
|
||||||
|
}
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
assert.Equal(t, map[string]uint8{"Primary": 70, "Mouse": 20}, result.Batteries)
|
||||||
|
assert.Equal(t, uint8(56), result.Battery[0], "representative battery excludes absent samples")
|
||||||
|
assert.Equal(t, uint8(4), result.Battery[1], "representative state comes from its latest sample")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_ZeroRepresentativeBattery(t *testing.T) {
|
||||||
|
result := records.AverageSystemStatsSlice([]system.Stats{
|
||||||
|
{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0}},
|
||||||
|
{},
|
||||||
|
})
|
||||||
|
assert.Equal(t, [2]uint8{0, 1}, result.Battery)
|
||||||
|
assert.Equal(t, map[string]uint8{"Primary": 0}, result.Batteries)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||||
input := []system.Stats{
|
input := []system.Stats{
|
||||||
{Cpu: 10.0, Mem: 8.0},
|
{Cpu: 10.0, Mem: 8.0},
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
|
|
||||||
<BatteryChart {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
|
|
||||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +191,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
<BatteryChart {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AreaChartDefault from "@/components/charts/area-chart"
|
|||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores"
|
import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||||
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
|
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import { ChartCard, FilterBar } from "../chart-card"
|
import { ChartCard, FilterBar } from "../chart-card"
|
||||||
import LineChartDefault from "@/components/charts/line-chart"
|
import LineChartDefault from "@/components/charts/line-chart"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -14,18 +14,57 @@ export function BatteryChart({
|
|||||||
grid,
|
grid,
|
||||||
dataEmpty,
|
dataEmpty,
|
||||||
maxValues,
|
maxValues,
|
||||||
|
system,
|
||||||
}: {
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
grid: boolean
|
grid: boolean
|
||||||
dataEmpty: boolean
|
dataEmpty: boolean
|
||||||
maxValues: boolean
|
maxValues: boolean
|
||||||
|
system: SystemRecord
|
||||||
}) {
|
}) {
|
||||||
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
|
const batteryNames = useMemo(() => {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const record of chartData.systemStats) {
|
||||||
|
for (const name of Object.keys(record.stats?.bats ?? {})) names.add(name)
|
||||||
|
}
|
||||||
|
return [...names].sort()
|
||||||
|
}, [chartData.systemStats])
|
||||||
|
const hasNamedBatteries = batteryNames.length > 0
|
||||||
|
const showBatteryChart = hasNamedBatteries || chartData.systemStats.some((record) => record.stats?.bat)
|
||||||
|
|
||||||
if (!showBatteryChart) {
|
if (!showBatteryChart) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasNamedBatteries) {
|
||||||
|
const dataPoints = batteryNames.map((name, index) => ({
|
||||||
|
label: name,
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.bats?.[name],
|
||||||
|
color: `hsl(${(index * 360) / batteryNames.length}, 60%, 55%)`,
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Battery`}
|
||||||
|
description={`${t({
|
||||||
|
message: "Current state",
|
||||||
|
comment: "Context: Battery state",
|
||||||
|
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||||
|
>
|
||||||
|
<LineChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
domain={[0, 100]}
|
||||||
|
legend={true}
|
||||||
|
tickFormatter={(val) => `${val}%`}
|
||||||
|
contentFormatter={({ value }) => `${value}%`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
@@ -34,7 +73,7 @@ export function BatteryChart({
|
|||||||
description={`${t({
|
description={`${t({
|
||||||
message: "Current state",
|
message: "Current state",
|
||||||
comment: "Context: Battery state",
|
comment: "Context: Battery state",
|
||||||
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
|
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
@@ -210,15 +249,7 @@ export function TemperatureChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FanChart({
|
export function FanChart({ chartData, grid, dataEmpty }: { chartData: ChartData; grid: boolean; dataEmpty: boolean }) {
|
||||||
chartData,
|
|
||||||
grid,
|
|
||||||
dataEmpty,
|
|
||||||
}: {
|
|
||||||
chartData: ChartData
|
|
||||||
grid: boolean
|
|
||||||
dataEmpty: boolean
|
|
||||||
}) {
|
|
||||||
const showFanChart = chartData.systemStats.at(-1)?.stats.f
|
const showFanChart = chartData.systemStats.at(-1)?.stats.f
|
||||||
|
|
||||||
const filter = useStore($fanFilter)
|
const filter = useStore($fanFilter)
|
||||||
|
|||||||
2
internal/site/src/types.d.ts
vendored
2
internal/site/src/types.d.ts
vendored
@@ -151,6 +151,8 @@ export interface SystemStats {
|
|||||||
g?: Record<string, GPUData>
|
g?: Record<string, GPUData>
|
||||||
/** battery percent and state */
|
/** battery percent and state */
|
||||||
bat?: [number, BatteryState]
|
bat?: [number, BatteryState]
|
||||||
|
/** battery percentages by device name */
|
||||||
|
bats?: 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]>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user