mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,175 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const powerSupplyPath = "/sys/class/power_supply"
|
||||
|
||||
// Battery holds the readable state of one system battery. Peripheral
|
||||
// batteries (mouse, keyboard, ...) report scope "Device" in sysfs and are
|
||||
// filtered out by ReadBatteries.
|
||||
type Battery struct {
|
||||
Name string
|
||||
Status string // Charging, Discharging, Full, Not charging, Unknown
|
||||
Capacity int // percent, 0-100; -1 if unavailable
|
||||
Health int // percent of design capacity still available; -1 if unavailable
|
||||
TimeToEmpty string // formatted estimate ("2h15m"); empty if not computable
|
||||
TimeToFull string
|
||||
Technology string
|
||||
CycleCount int // -1 if unavailable
|
||||
Manufacturer string
|
||||
Model string
|
||||
}
|
||||
|
||||
// ACStatus reports whether external power (AC adapter, USB-PD, dock, ...)
|
||||
// is present and delivering power.
|
||||
type ACStatus struct {
|
||||
Present bool
|
||||
Online bool
|
||||
}
|
||||
|
||||
// ReadBatteries scans /sys/class/power_supply for system battery devices.
|
||||
func ReadBatteries() ([]Battery, error) {
|
||||
entries, err := os.ReadDir(powerSupplyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var batteries []Battery
|
||||
for _, e := range entries {
|
||||
dir := filepath.Join(powerSupplyPath, e.Name())
|
||||
if readAttr(dir, "type") != "Battery" {
|
||||
continue
|
||||
}
|
||||
if readAttr(dir, "scope") == "Device" {
|
||||
continue // peripheral battery (mouse, keyboard, ...), not this machine's
|
||||
}
|
||||
if readAttr(dir, "present") == "0" {
|
||||
continue
|
||||
}
|
||||
batteries = append(batteries, readBattery(dir, e.Name()))
|
||||
}
|
||||
sort.Slice(batteries, func(i, j int) bool { return batteries[i].Name < batteries[j].Name })
|
||||
return batteries, nil
|
||||
}
|
||||
|
||||
func readBattery(dir, name string) Battery {
|
||||
status := readAttrDefault(dir, "status", "Unknown")
|
||||
|
||||
// Prefer energy_* (µWh); fall back to charge_* (µAh) for fuel gauges
|
||||
// that only report the latter. The ratio math below (percent, hours)
|
||||
// is unit-agnostic as long as full/now/rate come from the same family.
|
||||
full := readAttrInt(dir, "energy_full", 0)
|
||||
design := readAttrInt(dir, "energy_full_design", 0)
|
||||
now := readAttrInt(dir, "energy_now", 0)
|
||||
rate := readAttrInt(dir, "power_now", 0)
|
||||
if full == 0 {
|
||||
full = readAttrInt(dir, "charge_full", 0)
|
||||
design = readAttrInt(dir, "charge_full_design", 0)
|
||||
now = readAttrInt(dir, "charge_now", 0)
|
||||
rate = readAttrInt(dir, "current_now", 0)
|
||||
}
|
||||
|
||||
capacity := readAttrInt(dir, "capacity", -1)
|
||||
if capacity < 0 && full > 0 {
|
||||
capacity = now * 100 / full
|
||||
}
|
||||
|
||||
health := -1
|
||||
if full > 0 && design > 0 {
|
||||
health = full * 100 / design
|
||||
}
|
||||
|
||||
b := Battery{
|
||||
Name: name,
|
||||
Status: status,
|
||||
Capacity: capacity,
|
||||
Health: health,
|
||||
Technology: readAttr(dir, "technology"),
|
||||
CycleCount: readAttrInt(dir, "cycle_count", -1),
|
||||
Manufacturer: readAttr(dir, "manufacturer"),
|
||||
Model: readAttr(dir, "model_name"),
|
||||
}
|
||||
|
||||
if rate > 0 {
|
||||
switch status {
|
||||
case "Discharging":
|
||||
b.TimeToEmpty = formatHours(float64(now) / float64(rate))
|
||||
case "Charging":
|
||||
if full > now {
|
||||
b.TimeToFull = formatHours(float64(full-now) / float64(rate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// ReadAC reports whether any Mains/USB power-supply device is present, and
|
||||
// whether at least one of them is actively delivering power. Desktops with
|
||||
// no AC-detection device at all get a zero-value, not-present ACStatus.
|
||||
func ReadAC() (ACStatus, error) {
|
||||
entries, err := os.ReadDir(powerSupplyPath)
|
||||
if err != nil {
|
||||
return ACStatus{}, err
|
||||
}
|
||||
|
||||
var status ACStatus
|
||||
for _, e := range entries {
|
||||
dir := filepath.Join(powerSupplyPath, e.Name())
|
||||
t := readAttr(dir, "type")
|
||||
if t != "Mains" && t != "USB" {
|
||||
continue
|
||||
}
|
||||
online := readAttr(dir, "online")
|
||||
if online == "" {
|
||||
continue
|
||||
}
|
||||
status.Present = true
|
||||
if online == "1" {
|
||||
status.Online = true
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func formatHours(hours float64) string {
|
||||
if hours <= 0 {
|
||||
return ""
|
||||
}
|
||||
totalMinutes := int(hours*60 + 0.5)
|
||||
return fmt.Sprintf("%dh%02dm", totalMinutes/60, totalMinutes%60)
|
||||
}
|
||||
|
||||
func readAttr(dir, name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- dir is enumerated from /sys/class/power_supply, name is a fixed attribute
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func readAttrDefault(dir, name, def string) string {
|
||||
if v := readAttr(dir, name); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func readAttrInt(dir, name string, def int) int {
|
||||
v := readAttr(dir, name)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package power reads and controls the active power profile (via
|
||||
// powerprofilesctl / power-profiles-daemon) and reports battery/AC status.
|
||||
// Profile control shells out to a CLI tool, in the same spirit as the
|
||||
// bluetooth and audio packages; battery status has no universally-installed
|
||||
// CLI equivalent, so it's read directly from /sys/class/power_supply.
|
||||
package power
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("powerprofilesctl not found in PATH")
|
||||
|
||||
const defaultTimeout = 15 * time.Second
|
||||
|
||||
// Profile is one entry from `powerprofilesctl list`.
|
||||
type Profile struct {
|
||||
Name string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// CheckAvailable reports whether powerprofilesctl is installed and
|
||||
// power-profiles-daemon is reachable. Call it before using the rest of
|
||||
// this file's functions.
|
||||
func CheckAvailable() error {
|
||||
if _, err := exec.LookPath("powerprofilesctl"); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err := run("get")
|
||||
return err
|
||||
}
|
||||
|
||||
// ListProfiles returns the available profiles in the order reported by
|
||||
// powerprofilesctl, each flagged with whether it's currently active.
|
||||
func ListProfiles() ([]Profile, error) {
|
||||
out, err := run("list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var profiles []Profile
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
// Top-level profile entries are indented two spaces (or "* " when
|
||||
// active); their attribute lines (Driver:, Degraded:, ...) are
|
||||
// indented four, so those are skipped here.
|
||||
if line == "" || strings.HasPrefix(line, " ") {
|
||||
continue
|
||||
}
|
||||
|
||||
active := false
|
||||
trimmed := line
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "* "):
|
||||
active = true
|
||||
trimmed = trimmed[2:]
|
||||
case strings.HasPrefix(trimmed, " "):
|
||||
trimmed = trimmed[2:]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(trimmed, ":")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
profiles = append(profiles, Profile{Name: name, Active: active})
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// SetActive switches the active power profile.
|
||||
func SetActive(name string) error {
|
||||
_, err := run("set", name)
|
||||
return err
|
||||
}
|
||||
|
||||
func run(args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powerprofilesctl", args...) // #nosec G204 -- args are fixed powerprofilesctl subcommands or profile names read back from `powerprofilesctl list`, never shell-interpreted
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("powerprofilesctl %s: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user