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 }) { + {pageBottomExtraMargin > 0 &&
}