diff --git a/agent/battery/battery.go b/agent/battery/battery.go
index b6c1f27d..05a9f19c 100644
--- a/agent/battery/battery.go
+++ b/agent/battery/battery.go
@@ -1,84 +1,14 @@
-//go:build !freebsd
+//go:build !freebsd && !darwin && !linux && !windows
// Package battery provides functions to check if the system has a battery and to get the battery stats.
package battery
-import (
- "errors"
- "log/slog"
- "math"
+import "errors"
- "github.com/distatus/battery"
-)
-
-var (
- systemHasBattery = false
- haveCheckedBattery = false
-)
-
-// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
- if haveCheckedBattery {
- return systemHasBattery
- }
- haveCheckedBattery = true
- batteries, err := battery.GetAll()
- for _, bat := range batteries {
- if bat != nil && (bat.Full > 0 || bat.Design > 0) {
- systemHasBattery = true
- break
- }
- }
- if !systemHasBattery {
- slog.Debug("No battery found", "err", err)
- }
- return systemHasBattery
+ return false
}
-// GetBatteryStats returns the current battery percent and charge state
-// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
-func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
- if !HasReadableBattery() {
- return batteryPercent, batteryState, errors.ErrUnsupported
- }
- batteries, err := battery.GetAll()
- // we'll handle errors later by skipping batteries with errors, rather
- // than skipping everything because of the presence of some errors.
- if len(batteries) == 0 {
- return batteryPercent, batteryState, errors.New("no batteries")
- }
-
- totalCapacity := float64(0)
- totalCharge := float64(0)
- errs, partialErrs := err.(battery.Errors)
-
- batteryState = math.MaxUint8
-
- for i, bat := range batteries {
- if partialErrs && errs[i] != nil {
- // if there were some errors, like missing data, skip it
- continue
- }
- if bat == nil || bat.Full == 0 {
- // skip batteries with no capacity. Charge is unlikely to ever be zero, but
- // we can't guarantee that, so don't skip based on charge.
- continue
- }
- totalCapacity += bat.Full
- totalCharge += min(bat.Current, bat.Full)
- if bat.State.Raw >= 0 {
- batteryState = uint8(bat.State.Raw)
- }
- }
-
- if totalCapacity == 0 || batteryState == math.MaxUint8 {
- // for macs there's sometimes a ghost battery with 0 capacity
- // https://github.com/distatus/battery/issues/34
- // Instead of skipping over those batteries, we'll check for total 0 capacity
- // and return an error. This also prevents a divide by zero.
- return batteryPercent, batteryState, errors.New("no battery capacity")
- }
-
- batteryPercent = uint8(totalCharge / totalCapacity * 100)
- return batteryPercent, batteryState, nil
+func GetBatteryStats() (uint8, uint8, error) {
+ return 0, 0, errors.ErrUnsupported
}
diff --git a/agent/battery/battery_darwin.go b/agent/battery/battery_darwin.go
new file mode 100644
index 00000000..6b11c5c2
--- /dev/null
+++ b/agent/battery/battery_darwin.go
@@ -0,0 +1,114 @@
+//go:build darwin
+
+package battery
+
+import (
+ "errors"
+ "log/slog"
+ "math"
+ "os/exec"
+
+ "howett.net/plist"
+)
+
+const (
+ stateUnknown uint8 = 0
+ stateEmpty uint8 = 1
+ stateFull uint8 = 2
+ stateCharging uint8 = 3
+ stateDischarging uint8 = 4
+ stateIdle uint8 = 5
+)
+
+type macBattery struct {
+ CurrentCapacity int `plist:"CurrentCapacity"`
+ MaxCapacity int `plist:"MaxCapacity"`
+ FullyCharged bool `plist:"FullyCharged"`
+ IsCharging bool `plist:"IsCharging"`
+ ExternalConnected bool `plist:"ExternalConnected"`
+}
+
+var (
+ systemHasBattery = false
+ haveCheckedBattery = false
+)
+
+func readMacBatteries() ([]macBattery, error) {
+ out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
+ if err != nil {
+ return nil, err
+ }
+ if len(out) == 0 {
+ return nil, nil
+ }
+ var batteries []macBattery
+ if _, err := plist.Unmarshal(out, &batteries); err != nil {
+ return nil, err
+ }
+ return batteries, nil
+}
+
+// HasReadableBattery checks if the system has a battery and returns true if it does.
+func HasReadableBattery() bool {
+ if haveCheckedBattery {
+ return systemHasBattery
+ }
+ haveCheckedBattery = true
+ batteries, err := readMacBatteries()
+ for _, bat := range batteries {
+ if bat.MaxCapacity > 0 {
+ systemHasBattery = true
+ break
+ }
+ }
+ if !systemHasBattery {
+ slog.Debug("No battery found", "err", err)
+ }
+ return systemHasBattery
+}
+
+// GetBatteryStats returns the current battery percent and charge state.
+// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
+func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
+ if !HasReadableBattery() {
+ return batteryPercent, batteryState, errors.ErrUnsupported
+ }
+ batteries, err := readMacBatteries()
+ if len(batteries) == 0 {
+ return batteryPercent, batteryState, errors.New("no batteries")
+ }
+
+ totalCapacity := 0
+ totalCharge := 0
+ batteryState = math.MaxUint8
+
+ for _, bat := range batteries {
+ if bat.MaxCapacity == 0 {
+ // skip ghost batteries with 0 capacity
+ // https://github.com/distatus/battery/issues/34
+ continue
+ }
+ totalCapacity += bat.MaxCapacity
+ totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
+
+ switch {
+ case !bat.ExternalConnected:
+ batteryState = stateDischarging
+ case bat.IsCharging:
+ batteryState = stateCharging
+ case bat.CurrentCapacity == 0:
+ batteryState = stateEmpty
+ case !bat.FullyCharged:
+ batteryState = stateIdle
+ default:
+ batteryState = stateFull
+ }
+ }
+
+ if totalCapacity == 0 || batteryState == math.MaxUint8 {
+ return batteryPercent, batteryState, errors.New("no battery capacity")
+ }
+
+ batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
+ return batteryPercent, batteryState, nil
+}
diff --git a/agent/battery/battery_linux.go b/agent/battery/battery_linux.go
new file mode 100644
index 00000000..7ea255a6
--- /dev/null
+++ b/agent/battery/battery_linux.go
@@ -0,0 +1,123 @@
+//go:build linux
+
+package battery
+
+import (
+ "errors"
+ "log/slog"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "github.com/henrygd/beszel/agent/utils"
+)
+
+const (
+ stateUnknown uint8 = 0
+ stateEmpty uint8 = 1
+ stateFull uint8 = 2
+ stateCharging uint8 = 3
+ stateDischarging uint8 = 4
+ stateIdle uint8 = 5
+)
+
+const sysfsPowerSupply = "/sys/class/power_supply"
+
+var (
+ systemHasBattery = false
+ haveCheckedBattery = false
+)
+
+func getBatteryPaths() ([]string, error) {
+ entries, err := os.ReadDir(sysfsPowerSupply)
+ if err != nil {
+ return nil, err
+ }
+ var paths []string
+ for _, e := range entries {
+ path := filepath.Join(sysfsPowerSupply, e.Name())
+ if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
+ paths = append(paths, path)
+ }
+ }
+ return paths, nil
+}
+
+func parseSysfsState(status string) uint8 {
+ switch status {
+ case "Empty":
+ return stateEmpty
+ case "Full":
+ return stateFull
+ case "Charging":
+ return stateCharging
+ case "Discharging":
+ return stateDischarging
+ case "Not charging":
+ return stateIdle
+ default:
+ return stateUnknown
+ }
+}
+
+// HasReadableBattery checks if the system has a battery and returns true if it does.
+func HasReadableBattery() bool {
+ if haveCheckedBattery {
+ return systemHasBattery
+ }
+ haveCheckedBattery = true
+ 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
+}
+
+// GetBatteryStats returns the current battery percent and charge state.
+// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
+// value is used, which is always 0-100 and matches what the OS displays.
+func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
+ if !HasReadableBattery() {
+ return batteryPercent, batteryState, errors.ErrUnsupported
+ }
+ paths, err := getBatteryPaths()
+ if len(paths) == 0 {
+ return batteryPercent, batteryState, errors.New("no batteries")
+ }
+
+ batteryState = math.MaxUint8
+ totalPercent := 0
+ count := 0
+
+ for _, path := range paths {
+ capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
+ if !ok {
+ continue
+ }
+ cap, parseErr := strconv.Atoi(capStr)
+ if parseErr != nil {
+ continue
+ }
+ totalPercent += cap
+ count++
+
+ state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
+ if state != stateUnknown {
+ batteryState = state
+ }
+ }
+
+ if count == 0 || batteryState == math.MaxUint8 {
+ return batteryPercent, batteryState, errors.New("no battery capacity")
+ }
+
+ batteryPercent = uint8(totalPercent / count)
+ return batteryPercent, batteryState, nil
+}
diff --git a/agent/battery/battery_windows.go b/agent/battery/battery_windows.go
new file mode 100644
index 00000000..5d49339b
--- /dev/null
+++ b/agent/battery/battery_windows.go
@@ -0,0 +1,309 @@
+//go:build windows
+
+package battery
+
+import (
+ "errors"
+ "log/slog"
+ "math"
+ "syscall"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+const (
+ stateUnknown uint8 = 0
+ stateEmpty uint8 = 1
+ stateFull uint8 = 2
+ stateCharging uint8 = 3
+ stateDischarging uint8 = 4
+ stateIdle uint8 = 5
+)
+
+type batteryQueryInformation struct {
+ BatteryTag uint32
+ InformationLevel int32
+ AtRate int32
+}
+
+type batteryInformation struct {
+ Capabilities uint32
+ Technology uint8
+ Reserved [3]uint8
+ Chemistry [4]uint8
+ DesignedCapacity uint32
+ FullChargedCapacity uint32
+ DefaultAlert1 uint32
+ DefaultAlert2 uint32
+ CriticalBias uint32
+ CycleCount uint32
+}
+
+type batteryWaitStatus struct {
+ BatteryTag uint32
+ Timeout uint32
+ PowerState uint32
+ LowCapacity uint32
+ HighCapacity uint32
+}
+
+type batteryStatus struct {
+ PowerState uint32
+ Capacity uint32
+ Voltage uint32
+ Rate int32
+}
+
+type winGUID struct {
+ Data1 uint32
+ Data2 uint16
+ Data3 uint16
+ Data4 [8]byte
+}
+
+type spDeviceInterfaceData struct {
+ cbSize uint32
+ InterfaceClassGuid winGUID
+ Flags uint32
+ Reserved uint
+}
+
+var guidDeviceBattery = winGUID{
+ 0x72631e54,
+ 0x78A4,
+ 0x11d0,
+ [8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
+}
+
+var (
+ setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
+ setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
+ setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
+ setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
+ setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
+)
+
+func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
+ r1, _, errno := syscall.Syscall6(proc.Addr(), nargs, a1, a2, a3, a4, a5, a6)
+ if windows.Handle(r1) == windows.InvalidHandle {
+ if errno != 0 {
+ return 0, error(errno)
+ }
+ return 0, syscall.EINVAL
+ }
+ return r1, nil
+}
+
+func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
+ r1, _, errno := syscall.Syscall6(proc.Addr(), nargs, a1, a2, a3, a4, a5, a6)
+ if r1 == 0 {
+ if errno != 0 {
+ return errno
+ }
+ return syscall.EINVAL
+ }
+ return 0
+}
+
+func readWinBatteryState(powerState uint32) uint8 {
+ switch {
+ case powerState&0x00000004 != 0:
+ return stateCharging
+ case powerState&0x00000008 != 0:
+ return stateEmpty
+ case powerState&0x00000002 != 0:
+ return stateDischarging
+ case powerState&0x00000001 != 0:
+ return stateFull
+ default:
+ return stateUnknown
+ }
+}
+
+// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
+// Returns error == errNotFound when there are no more batteries.
+var errNotFound = errors.New("no more batteries")
+
+func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
+ hdev, err := setupDiSetup(
+ setupDiGetClassDevsW,
+ 4,
+ uintptr(unsafe.Pointer(&guidDeviceBattery)),
+ 0, 0,
+ 2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
+ 0, 0,
+ )
+ if err != nil {
+ return 0, 0, stateUnknown, err
+ }
+ defer syscall.Syscall(setupDiDestroyDeviceInfoList.Addr(), 1, hdev, 0, 0)
+
+ var did spDeviceInterfaceData
+ did.cbSize = uint32(unsafe.Sizeof(did))
+ errno := setupDiCall(
+ setupDiEnumDeviceInterfaces,
+ 5,
+ hdev, 0,
+ uintptr(unsafe.Pointer(&guidDeviceBattery)),
+ uintptr(idx),
+ uintptr(unsafe.Pointer(&did)),
+ 0,
+ )
+ if errno == 259 { // ERROR_NO_MORE_ITEMS
+ return 0, 0, stateUnknown, errNotFound
+ }
+ if errno != 0 {
+ return 0, 0, stateUnknown, errno
+ }
+
+ var cbRequired uint32
+ errno = setupDiCall(
+ setupDiGetDeviceInterfaceDetailW,
+ 6,
+ hdev,
+ uintptr(unsafe.Pointer(&did)),
+ 0, 0,
+ uintptr(unsafe.Pointer(&cbRequired)),
+ 0,
+ )
+ if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
+ return 0, 0, stateUnknown, errno
+ }
+ didd := make([]uint16, cbRequired/2)
+ cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
+ if unsafe.Sizeof(uint(0)) == 8 {
+ *cbSize = 8
+ } else {
+ *cbSize = 6
+ }
+ errno = setupDiCall(
+ setupDiGetDeviceInterfaceDetailW,
+ 6,
+ hdev,
+ uintptr(unsafe.Pointer(&did)),
+ uintptr(unsafe.Pointer(&didd[0])),
+ uintptr(cbRequired),
+ uintptr(unsafe.Pointer(&cbRequired)),
+ 0,
+ )
+ if errno != 0 {
+ return 0, 0, stateUnknown, errno
+ }
+ devicePath := &didd[2:][0]
+
+ handle, err := windows.CreateFile(
+ devicePath,
+ windows.GENERIC_READ|windows.GENERIC_WRITE,
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
+ nil,
+ windows.OPEN_EXISTING,
+ windows.FILE_ATTRIBUTE_NORMAL,
+ 0,
+ )
+ if err != nil {
+ return 0, 0, stateUnknown, err
+ }
+ defer windows.CloseHandle(handle)
+
+ var dwOut uint32
+ var dwWait uint32
+ var bqi batteryQueryInformation
+ err = windows.DeviceIoControl(
+ handle,
+ 2703424, // IOCTL_BATTERY_QUERY_TAG
+ (*byte)(unsafe.Pointer(&dwWait)),
+ uint32(unsafe.Sizeof(dwWait)),
+ (*byte)(unsafe.Pointer(&bqi.BatteryTag)),
+ uint32(unsafe.Sizeof(bqi.BatteryTag)),
+ &dwOut, nil,
+ )
+ if err != nil || bqi.BatteryTag == 0 {
+ return 0, 0, stateUnknown, errors.New("battery tag not returned")
+ }
+
+ var bi batteryInformation
+ if err = windows.DeviceIoControl(
+ handle,
+ 2703428, // IOCTL_BATTERY_QUERY_INFORMATION
+ (*byte)(unsafe.Pointer(&bqi)),
+ uint32(unsafe.Sizeof(bqi)),
+ (*byte)(unsafe.Pointer(&bi)),
+ uint32(unsafe.Sizeof(bi)),
+ &dwOut, nil,
+ ); err != nil {
+ return 0, 0, stateUnknown, err
+ }
+
+ bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
+ var bs batteryStatus
+ if err = windows.DeviceIoControl(
+ handle,
+ 2703436, // IOCTL_BATTERY_QUERY_STATUS
+ (*byte)(unsafe.Pointer(&bws)),
+ uint32(unsafe.Sizeof(bws)),
+ (*byte)(unsafe.Pointer(&bs)),
+ uint32(unsafe.Sizeof(bs)),
+ &dwOut, nil,
+ ); err != nil {
+ return 0, 0, stateUnknown, err
+ }
+
+ if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
+ return 0, 0, stateUnknown, errors.New("battery capacity unknown")
+ }
+
+ return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
+}
+
+var (
+ systemHasBattery = false
+ haveCheckedBattery = false
+)
+
+// HasReadableBattery checks if the system has a battery and returns true if it does.
+func HasReadableBattery() bool {
+ if haveCheckedBattery {
+ return systemHasBattery
+ }
+ haveCheckedBattery = true
+ full, _, _, err := winBatteryGet(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
+
+ for i := 0; ; i++ {
+ full, current, state, bErr := winBatteryGet(i)
+ if errors.Is(bErr, errNotFound) {
+ break
+ }
+ if bErr != nil || full == 0 {
+ continue
+ }
+ totalFull += full
+ totalCurrent += min(current, full)
+ batteryState = state
+ }
+
+ if totalFull == 0 || batteryState == math.MaxUint8 {
+ return batteryPercent, batteryState, errors.New("no battery capacity")
+ }
+
+ batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
+ return batteryPercent, batteryState, nil
+}
diff --git a/go.mod b/go.mod
index b084b273..dbf94976 100644
--- a/go.mod
+++ b/go.mod
@@ -5,8 +5,7 @@ go 1.26.1
require (
github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0
- github.com/distatus/battery v0.11.0
- github.com/ebitengine/purego v0.10.0
+github.com/ebitengine/purego v0.10.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
@@ -23,6 +22,7 @@ require (
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
+ howett.net/plist v1.0.1
)
require (
@@ -61,7 +61,6 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
- howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx
index b4dd62c5..57b8f470 100644
--- a/internal/site/src/components/routes/system.tsx
+++ b/internal/site/src/components/routes/system.tsx
@@ -188,6 +188,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {