From 7dab03508b882de36eacbcc7e702783d8336127b Mon Sep 17 00:00:00 2001 From: lucserre <10817213+lucserre@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:52:51 -0400 Subject: [PATCH] set/apply/TUI: skip pseudo-batteries without charge control attributes hidpp_battery_N and similar power_supply nodes report type=Battery but expose no charge_control_* files. ListBatteries() returns them, so set/apply/TUI attempted SysfsWriteInt on non-existent paths and aborted before/after writing the real battery (ENOENT), breaking persistence. Backends now return ErrNotChargeable from SetThresholds when the target battery lacks the attributes they would write (per-battery check instead of globally-derived capabilities), and the three call sites skip such batteries instead of failing. Fixed-path backends (Apple, Acer, Samsung, Sony, Huawei, Lenovo-ideapad, LG legacy) are unaffected. --- cmd/batctl/main.go | 17 +++++++++++++++++ internal/backend/asus.go | 3 +++ internal/backend/backend.go | 11 ++++++++++- internal/backend/dell.go | 14 +++++++++++--- internal/backend/framework.go | 9 +++++++-- internal/backend/generic.go | 8 +++++--- internal/backend/lg.go | 3 +++ internal/backend/msi.go | 3 +++ internal/backend/surface.go | 11 ++++++++--- internal/backend/system76.go | 14 +++++++++++--- internal/backend/thinkpad.go | 14 +++++++++++--- internal/backend/toshiba.go | 3 +++ internal/backend/tuxedo.go | 14 +++++++++++--- internal/battery/sysfs.go | 7 +++++++ internal/tui/app.go | 16 +++++++++++++--- 15 files changed, 123 insertions(+), 24 deletions(-) diff --git a/cmd/batctl/main.go b/cmd/batctl/main.go index 25530bd..4c7a58c 100644 --- a/cmd/batctl/main.go +++ b/cmd/batctl/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "strings" @@ -134,12 +135,20 @@ func setCmd() *cobra.Command { return fmt.Errorf("invalid thresholds: %w", err) } + applied := 0 for _, bat := range bats { if err := b.SetThresholds(bat, startVal, stopVal); err != nil { + if errors.Is(err, backend.ErrNotChargeable) { + continue + } return fmt.Errorf("setting thresholds on %s: %w", bat, err) } + applied++ fmt.Printf("Thresholds set: start=%d%% stop=%d%% on %s\n", startVal, stopVal, bat) } + if applied == 0 { + return fmt.Errorf("no batteries supporting charge control found") + } return nil }, } @@ -173,12 +182,20 @@ func applyCmd() *cobra.Command { bats = []string{cfg.Battery} } + applied := 0 for _, bat := range bats { if err := b.SetThresholds(bat, cfg.Start, cfg.Stop); err != nil { + if errors.Is(err, backend.ErrNotChargeable) && cfg.Battery == "all" { + continue + } return fmt.Errorf("applying thresholds on %s: %w", bat, err) } + applied++ fmt.Printf("Applied: start=%d%% stop=%d%% on %s\n", cfg.Start, cfg.Stop, bat) } + if applied == 0 { + return fmt.Errorf("no batteries supporting charge control found") + } return nil }, } diff --git a/internal/backend/asus.go b/internal/backend/asus.go index b8857e3..ad5c2ca 100644 --- a/internal/backend/asus.go +++ b/internal/backend/asus.go @@ -49,6 +49,9 @@ func (b *ASUSBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } + if !battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return ErrNotChargeable + } return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 239c40f..6e3b961 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -1,6 +1,9 @@ package backend -import "fmt" +import ( + "errors" + "fmt" +) type Capabilities struct { StartThreshold bool @@ -13,6 +16,12 @@ type Capabilities struct { StartStopGap int // if non-zero, hardware enforces start = stop - gap (Dell: 5) } +// ErrNotChargeable is returned by SetThresholds when the target battery does +// not expose any charge control attributes (e.g. HID++ peripheral batteries, +// which appear under /sys/class/power_supply/ with type=Battery but have no +// charge_control_* files). +var ErrNotChargeable = errors.New("battery does not expose charge control attributes") + type Backend interface { Name() string Detect() bool diff --git a/internal/backend/dell.go b/internal/backend/dell.go index 17e739f..2c9034b 100644 --- a/internal/backend/dell.go +++ b/internal/backend/dell.go @@ -53,16 +53,24 @@ func (b *DellBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } chargeTypesPath := battery.BatPath(bat, "charge_types") if battery.SysfsExists(chargeTypesPath) { if err := battery.SysfsWriteString(chargeTypesPath, "Custom"); err != nil { return err } } - if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { - return err + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { + if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { + return err + } + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } - return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + return nil } func (b *DellBackend) GetChargeBehaviour(bat string) (current string, available []string, err error) { diff --git a/internal/backend/framework.go b/internal/backend/framework.go index 1893117..79c051c 100644 --- a/internal/backend/framework.go +++ b/internal/backend/framework.go @@ -71,8 +71,13 @@ func (b *FrameworkBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } - if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop); err != nil { - return err + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop); err != nil { + return err + } } if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { diff --git a/internal/backend/generic.go b/internal/backend/generic.go index f2fc909..f0d55a0 100644 --- a/internal/backend/generic.go +++ b/internal/backend/generic.go @@ -69,13 +69,15 @@ func (b *GenericBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } - caps := b.Capabilities() - if caps.StartThreshold { + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { return err } } - if caps.StopThreshold { + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop); err != nil { return err } diff --git a/internal/backend/lg.go b/internal/backend/lg.go index aa90f58..554e050 100644 --- a/internal/backend/lg.go +++ b/internal/backend/lg.go @@ -69,6 +69,9 @@ func (b *LGBackend) SetThresholds(bat string, start, stop int) error { if b.legacy { return battery.SysfsWriteInt(lgBatteryCareLimitPath, stop) } + if !battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return ErrNotChargeable + } return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } diff --git a/internal/backend/msi.go b/internal/backend/msi.go index bf3f1ac..9d63c0e 100644 --- a/internal/backend/msi.go +++ b/internal/backend/msi.go @@ -54,6 +54,9 @@ func (b *MSIBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } + if !battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return ErrNotChargeable + } return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } diff --git a/internal/backend/surface.go b/internal/backend/surface.go index b606e58..4c3b5bd 100644 --- a/internal/backend/surface.go +++ b/internal/backend/surface.go @@ -61,13 +61,18 @@ func (b *SurfaceBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } - caps := b.Capabilities() - if caps.StartThreshold { + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { return err } } - return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + } + return nil } func (b *SurfaceBackend) GetChargeBehaviour(bat string) (current string, available []string, err error) { diff --git a/internal/backend/system76.go b/internal/backend/system76.go index d858c31..21540f9 100644 --- a/internal/backend/system76.go +++ b/internal/backend/system76.go @@ -53,10 +53,18 @@ func (b *System76Backend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } - if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { - return err + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { + if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { + return err + } } - return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + } + return nil } func (b *System76Backend) GetChargeBehaviour(bat string) (current string, available []string, err error) { diff --git a/internal/backend/thinkpad.go b/internal/backend/thinkpad.go index 44e8bbe..674aadb 100644 --- a/internal/backend/thinkpad.go +++ b/internal/backend/thinkpad.go @@ -56,10 +56,18 @@ func (b *ThinkPadBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } - if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { - return err + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { + if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { + return err + } } - return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + } + return nil } func (b *ThinkPadBackend) GetChargeBehaviour(bat string) (current string, available []string, err error) { diff --git a/internal/backend/toshiba.go b/internal/backend/toshiba.go index 13dd20b..dcf441d 100644 --- a/internal/backend/toshiba.go +++ b/internal/backend/toshiba.go @@ -47,6 +47,9 @@ func (b *ToshibaBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } + if !battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return ErrNotChargeable + } return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } diff --git a/internal/backend/tuxedo.go b/internal/backend/tuxedo.go index 207f7e3..6e8b4d3 100644 --- a/internal/backend/tuxedo.go +++ b/internal/backend/tuxedo.go @@ -66,16 +66,24 @@ func (b *TuxedoBackend) SetThresholds(bat string, start, stop int) error { if err := b.ValidateThresholds(start, stop); err != nil { return err } + if !battery.SupportsChargeControl(bat) { + return ErrNotChargeable + } chargeTypePath := battery.BatPath(bat, "charge_type") if battery.SysfsExists(chargeTypePath) { if err := battery.SysfsWriteString(chargeTypePath, "Custom"); err != nil { return err } } - if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { - return err + if battery.SysfsExists(battery.BatPath(bat, "charge_control_start_threshold")) { + if err := battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_start_threshold"), start); err != nil { + return err + } + } + if battery.SysfsExists(battery.BatPath(bat, "charge_control_end_threshold")) { + return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) } - return battery.SysfsWriteInt(battery.BatPath(bat, "charge_control_end_threshold"), stop) + return nil } func (b *TuxedoBackend) GetChargeBehaviour(bat string) (current string, available []string, err error) { diff --git a/internal/battery/sysfs.go b/internal/battery/sysfs.go index 3c6ccc9..3ee9755 100644 --- a/internal/battery/sysfs.go +++ b/internal/battery/sysfs.go @@ -73,3 +73,10 @@ func ListBatteries() []string { } return bats } + +// SupportsChargeControl reports whether a power_supply exposes the standard +// charge_control_* threshold attributes. +func SupportsChargeControl(bat string) bool { + return SysfsExists(BatPath(bat, "charge_control_start_threshold")) || + SysfsExists(BatPath(bat, "charge_control_end_threshold")) +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 80ee047..146900e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "fmt" "os" "time" @@ -336,19 +337,28 @@ func (m *model) applyAndSave() tea.Cmd { caps := m.backend.Capabilities() + var applied []string for _, bat := range m.batteries { if err := m.backend.SetThresholds(bat, m.startVal, m.stopVal); err != nil { + if errors.Is(err, backend.ErrNotChargeable) { + continue + } return m.setMessage(fmt.Sprintf("Error on %s: %v", bat, err), errorStyle) } - if caps.ChargeBehaviour && m.behaviourCur != "" { + applied = append(applied, bat) + if caps.ChargeBehaviour && m.behaviourCur != "" && + battery.SysfsExists(battery.BatPath(bat, "charge_behaviour")) { if err := m.backend.SetChargeBehaviour(bat, m.behaviourCur); err != nil { return m.setMessage(fmt.Sprintf("Thresholds set, but behaviour error on %s: %v", bat, err), errorStyle) } } } + if len(applied) == 0 { + return m.setMessage("No batteries support charge control", errorStyle) + } - batName := m.batteries[0] - if len(m.batteries) > 1 { + batName := applied[0] + if len(applied) > 1 { batName = "all" } cfg := persist.Config{Battery: batName, Start: m.startVal, Stop: m.stopVal}