From 5d075c7093840dab89149d2f50ab3e12beb2c1fa Mon Sep 17 00:00:00 2001 From: Lenar Imamutdinov Date: Tue, 27 Jan 2026 14:28:10 +0200 Subject: [PATCH 1/3] handle all battery states provided by the kernel --- widgets/battery.go | 55 +++++++++++++++++++++++++++++-------- widgets/battery_test.go | 51 ++++++++++++++++++++++++---------- widgets/temperature.go | 2 +- widgets/temperature_test.go | 25 +++++++++++------ 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/widgets/battery.go b/widgets/battery.go index b8fc97a..e2b595f 100644 --- a/widgets/battery.go +++ b/widgets/battery.go @@ -18,6 +18,35 @@ const ( BatteryModeRemainingTime = 1 ) +type BatteryState int + +const ( + BatteryStateUnknown BatteryState = iota + BatteryStateCharging + BatteryStateDischarging + BatteryStateFull + BatteryStateNotCharging +) + +func (s BatteryState) IsOnAC() bool { + return s == BatteryStateCharging || s == BatteryStateFull || s == BatteryStateNotCharging +} + +func parseBatteryStatus(status string) BatteryState { + switch status { + case "Charging": + return BatteryStateCharging + case "Discharging": + return BatteryStateDischarging + case "Full": + return BatteryStateFull + case "Not charging": + return BatteryStateNotCharging + default: + return BatteryStateUnknown + } +} + type BatteryInfo struct { Path string Status string @@ -36,7 +65,7 @@ type Battery struct { EnergyNow int PowerNow int Remaining time.Duration - IsCharging bool + State BatteryState Mode int } @@ -59,7 +88,7 @@ func (b *Battery) Update() { b.EnergyFull = 0 b.EnergyNow = 0 b.PowerNow = 0 - b.IsCharging = false + b.State = BatteryStateUnknown // Find all batteries matches, err := afero.Glob(b.fs, "/sys/class/power_supply/BAT*") @@ -101,8 +130,10 @@ func (b *Battery) Update() { b.EnergyNow += bat.EnergyNow b.PowerNow += bat.PowerNow - if bat.Status == "Charging" { - b.IsCharging = true + // Determine state (Charging takes priority if any battery is charging) + batState := parseBatteryStatus(bat.Status) + if batState == BatteryStateCharging || b.State == BatteryStateUnknown { + b.State = batState } } @@ -119,9 +150,9 @@ func (b *Battery) Update() { // Calculate remaining time if b.PowerNow > 0 { - if b.IsCharging { + if b.State == BatteryStateCharging { b.Remaining = time.Duration((b.EnergyFull-b.EnergyNow)*1000/b.PowerNow) * time.Hour / 1000 - } else { + } else if b.State == BatteryStateDischarging { b.Remaining = time.Duration(b.EnergyNow*1000/b.PowerNow) * time.Hour / 1000 } } @@ -132,25 +163,25 @@ func (b *Battery) GetBlock() string { return "" } - if b.IsCharging && b.Percentage > b.cfg.HideChargingAbove { + if b.State.IsOnAC() && b.Percentage > b.cfg.HideChargingAbove { return "" } - if !b.IsCharging && b.Percentage > b.cfg.HideDischargingAbove { + if !b.State.IsOnAC() && b.Percentage > b.cfg.HideDischargingAbove { return "" } - symbols := [6]string{"\uf244", "\uf243", "\uf242", "\uf241", "\uf240", "\uf240"} + batteryLevelSymbols := [6]string{"\uf244", "\uf243", "\uf242", "\uf241", "\uf240", "\uf240"} var symbol string var text string - if b.IsCharging { - symbol = "\uf1e6" + if b.State == BatteryStateCharging { + symbol = "\uf084" // nf-fa-battery-charging } else { idx := b.Percentage / 20 if idx > 5 { idx = 5 } - symbol = symbols[idx] + symbol = batteryLevelSymbols[idx] } if b.Mode == BatteryModePercentage { diff --git a/widgets/battery_test.go b/widgets/battery_test.go index 2f75d44..6ae948f 100644 --- a/widgets/battery_test.go +++ b/widgets/battery_test.go @@ -122,8 +122,8 @@ func TestBattery_Update(t *testing.T) { if b.Percentage != 75 { t.Errorf("Percentage = %d, want 75", b.Percentage) } - if b.IsCharging { - t.Error("IsCharging = true, want false") + if b.State != BatteryStateDischarging { + t.Errorf("State = %v, want BatteryStateDischarging", b.State) } } @@ -140,8 +140,8 @@ func TestBattery_Update_Charging(t *testing.T) { b := NewBattery(config.BatteryConfig{}, fs) b.Update() - if !b.IsCharging { - t.Error("IsCharging = false, want true") + if b.State != BatteryStateCharging { + t.Errorf("State = %v, want BatteryStateCharging", b.State) } } @@ -200,7 +200,7 @@ func TestBattery_HideLogic(t *testing.T) { tests := []struct { name string percentage int - isCharging bool + state BatteryState hideChargingAbove int hideDischargingAbove int shouldHide bool @@ -208,45 +208,66 @@ func TestBattery_HideLogic(t *testing.T) { { name: "charging above threshold - hide", percentage: 99, - isCharging: true, + state: BatteryStateCharging, hideChargingAbove: 98, shouldHide: true, }, { name: "charging below threshold - show", percentage: 50, - isCharging: true, + state: BatteryStateCharging, hideChargingAbove: 98, shouldHide: false, }, { name: "discharging above threshold - hide", percentage: 50, - isCharging: false, + state: BatteryStateDischarging, hideDischargingAbove: 20, shouldHide: true, }, { name: "discharging below threshold - show", percentage: 15, - isCharging: false, + state: BatteryStateDischarging, hideDischargingAbove: 20, shouldHide: false, }, { name: "charging at threshold - show", percentage: 98, - isCharging: true, + state: BatteryStateCharging, hideChargingAbove: 98, shouldHide: false, }, { name: "discharging at threshold - show", percentage: 20, - isCharging: false, + state: BatteryStateDischarging, hideDischargingAbove: 20, shouldHide: false, }, + { + name: "full above threshold - hide", + percentage: 100, + state: BatteryStateFull, + hideChargingAbove: 98, + shouldHide: true, + }, + { + name: "not charging above threshold - hide", + percentage: 80, + state: BatteryStateNotCharging, + hideChargingAbove: 70, + shouldHide: true, + }, + { + name: "unknown above threshold - hide", + percentage: 50, + state: BatteryStateUnknown, + hideDischargingAbove: 20, + shouldHide: true, + }, } for _, tt := range tests { @@ -258,7 +279,7 @@ func TestBattery_HideLogic(t *testing.T) { }, Present: true, Percentage: tt.percentage, - IsCharging: tt.isCharging, + State: tt.state, } block := b.GetBlock() @@ -289,7 +310,7 @@ func TestBattery_GetBlock_RemainingTimeMode(t *testing.T) { }, Present: true, Percentage: 50, - IsCharging: false, + State: BatteryStateDischarging, Remaining: 2*time.Hour + 30*time.Minute, Mode: BatteryModeRemainingTime, } @@ -363,7 +384,7 @@ func TestBattery_GetBlock_PercentageOver100(t *testing.T) { }, Present: true, Percentage: 125, // buggy ACPI - IsCharging: false, + State: BatteryStateDischarging, } block := b.GetBlock() @@ -397,7 +418,7 @@ func TestBattery_UrgentBelow(t *testing.T) { }, Present: true, Percentage: tt.percentage, - IsCharging: false, + State: BatteryStateDischarging, } block := b.GetBlock() diff --git a/widgets/temperature.go b/widgets/temperature.go index b749685..5d71c64 100644 --- a/widgets/temperature.go +++ b/widgets/temperature.go @@ -213,7 +213,7 @@ func (t *Temperature) GetBlock() string { showMultiple := len(t.sensors) > 1 for _, sensor := range t.sensors { - if sensor.Value <= sensor.ShowAbove { + if !sensor.ema.Ready() || sensor.Value <= sensor.ShowAbove { continue } diff --git a/widgets/temperature_test.go b/widgets/temperature_test.go index e172e1b..171b66b 100644 --- a/widgets/temperature_test.go +++ b/widgets/temperature_test.go @@ -7,8 +7,16 @@ import ( "github.com/spf13/afero" "neoden/h2status/config" + "neoden/h2status/util" ) +// newReadyEMA creates an EMA that is already primed and ready +func newReadyEMA() *util.EMA { + ema := util.NewEMA(1) + ema.Update(0) // prime it + return ema +} + func TestTemperature_MillidegreesConversion(t *testing.T) { tests := []struct { name string @@ -59,6 +67,7 @@ func TestTemperature_GetBlock_SingleSensor(t *testing.T) { ShowAbove: tt.showAbove, UrgentAbove: tt.urgAbove, Value: tt.value, + ema: newReadyEMA(), }}, } @@ -80,8 +89,8 @@ func TestTemperature_GetBlock_SingleSensor(t *testing.T) { func TestTemperature_GetBlock_MultipleSensors(t *testing.T) { temp := &Temperature{ sensors: []TempSensor{ - {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80}, - {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 85}, + {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80, ema: newReadyEMA()}, + {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 85, ema: newReadyEMA()}, }, } @@ -99,8 +108,8 @@ func TestTemperature_GetBlock_MultipleSensors(t *testing.T) { func TestTemperature_GetBlock_PartialShow(t *testing.T) { temp := &Temperature{ sensors: []TempSensor{ - {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80}, // shown - {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 50}, // hidden + {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80, ema: newReadyEMA()}, // shown + {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 50, ema: newReadyEMA()}, // hidden }, } @@ -116,8 +125,8 @@ func TestTemperature_GetBlock_PartialShow(t *testing.T) { func TestTemperature_GetBlock_AllHidden(t *testing.T) { temp := &Temperature{ sensors: []TempSensor{ - {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 50}, - {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 60}, + {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 50, ema: newReadyEMA()}, + {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 60, ema: newReadyEMA()}, }, } @@ -130,8 +139,8 @@ func TestTemperature_GetBlock_AllHidden(t *testing.T) { func TestTemperature_AnyUrgent(t *testing.T) { temp := &Temperature{ sensors: []TempSensor{ - {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80}, // not urgent - {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 95}, // urgent + {Label: "CPU", ShowAbove: 75, UrgentAbove: 90, Value: 80, ema: newReadyEMA()}, // not urgent + {Label: "GPU", ShowAbove: 75, UrgentAbove: 90, Value: 95, ema: newReadyEMA()}, // urgent }, } From 213fa3c3e0dde6de07263660de2bb69ab22814d1 Mon Sep 17 00:00:00 2001 From: Lenar Imamutdinov Date: Tue, 27 Jan 2026 14:39:25 +0200 Subject: [PATCH 2/3] revert charging icon to plug --- widgets/battery.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widgets/battery.go b/widgets/battery.go index e2b595f..28b93d6 100644 --- a/widgets/battery.go +++ b/widgets/battery.go @@ -175,7 +175,7 @@ func (b *Battery) GetBlock() string { var text string if b.State == BatteryStateCharging { - symbol = "\uf084" // nf-fa-battery-charging + symbol = "\uf1e6" // nf-fa-plug } else { idx := b.Percentage / 20 if idx > 5 { From 0a7a4c6c397ed5ef3473931a4c7bdb8ea3e03cfa Mon Sep 17 00:00:00 2001 From: Lenar Imamutdinov Date: Tue, 27 Jan 2026 17:07:43 +0200 Subject: [PATCH 3/3] review fixes --- util/ema.go | 8 +-- util/ema_test.go | 15 ++--- widgets/battery.go | 22 +++++++- widgets/battery_test.go | 108 ++++++++++++++++++++++++++++++++++++ widgets/cpu_test.go | 8 ++- widgets/temperature_test.go | 30 +++++++++- 6 files changed, 169 insertions(+), 22 deletions(-) diff --git a/util/ema.go b/util/ema.go index 8303055..a5ece17 100644 --- a/util/ema.go +++ b/util/ema.go @@ -30,9 +30,7 @@ func (e *EMA) Update(value float64) float64 { e.samples = 1 } else { e.value = e.alpha*value + (1-e.alpha)*e.value - if e.samples < e.period { - e.samples++ - } + e.samples++ } return e.value } @@ -42,7 +40,7 @@ func (e *EMA) Value() float64 { return e.value } -// Ready returns true when enough samples have been collected. +// Ready returns true after at least one smoothing has occurred. func (e *EMA) Ready() bool { - return e.samples >= e.period + return e.samples >= 2 } diff --git a/util/ema_test.go b/util/ema_test.go index f6d352a..145f653 100644 --- a/util/ema_test.go +++ b/util/ema_test.go @@ -66,7 +66,7 @@ func TestEMA_Smoothing(t *testing.T) { } func TestEMA_Ready(t *testing.T) { - ema := NewEMA(3) + ema := NewEMA(5) if ema.Ready() { t.Error("Should not be ready before any updates") @@ -74,22 +74,17 @@ func TestEMA_Ready(t *testing.T) { ema.Update(10) if ema.Ready() { - t.Error("Should not be ready after 1 update (need 3)") + t.Error("Should not be ready after 1 update (need 2 for first smoothing)") } ema.Update(20) - if ema.Ready() { - t.Error("Should not be ready after 2 updates (need 3)") - } - - ema.Update(30) if !ema.Ready() { - t.Error("Should be ready after 3 updates") + t.Error("Should be ready after 2 updates (smoothing has occurred)") } - ema.Update(40) + ema.Update(30) if !ema.Ready() { - t.Error("Should still be ready after 4 updates") + t.Error("Should still be ready after 3 updates") } } diff --git a/widgets/battery.go b/widgets/battery.go index 28b93d6..b93ba12 100644 --- a/widgets/battery.go +++ b/widgets/battery.go @@ -32,6 +32,23 @@ func (s BatteryState) IsOnAC() bool { return s == BatteryStateCharging || s == BatteryStateFull || s == BatteryStateNotCharging } +// priority returns the precedence for aggregating multiple battery states. +// Higher value = higher priority when combining states. +func (s BatteryState) priority() int { + switch s { + case BatteryStateCharging: + return 5 + case BatteryStateDischarging: + return 4 + case BatteryStateNotCharging: + return 3 + case BatteryStateFull: + return 2 + default: // BatteryStateUnknown + return 1 + } +} + func parseBatteryStatus(status string) BatteryState { switch status { case "Charging": @@ -88,6 +105,7 @@ func (b *Battery) Update() { b.EnergyFull = 0 b.EnergyNow = 0 b.PowerNow = 0 + b.Remaining = 0 b.State = BatteryStateUnknown // Find all batteries @@ -130,9 +148,9 @@ func (b *Battery) Update() { b.EnergyNow += bat.EnergyNow b.PowerNow += bat.PowerNow - // Determine state (Charging takes priority if any battery is charging) + // Determine state using priority (Charging > Discharging > NotCharging > Full > Unknown) batState := parseBatteryStatus(bat.Status) - if batState == BatteryStateCharging || b.State == BatteryStateUnknown { + if batState.priority() > b.State.priority() { b.State = batState } } diff --git a/widgets/battery_test.go b/widgets/battery_test.go index 6ae948f..00b5df0 100644 --- a/widgets/battery_test.go +++ b/widgets/battery_test.go @@ -1,6 +1,7 @@ package widgets import ( + "fmt" "testing" "time" @@ -444,3 +445,110 @@ func containsHelper(s, substr string) bool { } return false } + +func TestParseBatteryStatus(t *testing.T) { + tests := []struct { + status string + want BatteryState + }{ + {"Charging", BatteryStateCharging}, + {"Discharging", BatteryStateDischarging}, + {"Full", BatteryStateFull}, + {"Not charging", BatteryStateNotCharging}, + {"Unknown", BatteryStateUnknown}, + {"", BatteryStateUnknown}, + {"Something else", BatteryStateUnknown}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + got := parseBatteryStatus(tt.status) + if got != tt.want { + t.Errorf("parseBatteryStatus(%q) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestBatteryState_Priority(t *testing.T) { + // Charging > Discharging > NotCharging > Full > Unknown + if BatteryStateCharging.priority() <= BatteryStateDischarging.priority() { + t.Error("Charging should have higher priority than Discharging") + } + if BatteryStateDischarging.priority() <= BatteryStateNotCharging.priority() { + t.Error("Discharging should have higher priority than NotCharging") + } + if BatteryStateNotCharging.priority() <= BatteryStateFull.priority() { + t.Error("NotCharging should have higher priority than Full") + } + if BatteryStateFull.priority() <= BatteryStateUnknown.priority() { + t.Error("Full should have higher priority than Unknown") + } +} + +func TestBattery_Update_MultipleBatteries_StateAggregation(t *testing.T) { + tests := []struct { + name string + statuses []string + want BatteryState + }{ + {"both charging", []string{"Charging", "Charging"}, BatteryStateCharging}, + {"both discharging", []string{"Discharging", "Discharging"}, BatteryStateDischarging}, + {"one charging one discharging", []string{"Discharging", "Charging"}, BatteryStateCharging}, + {"one full one discharging", []string{"Full", "Discharging"}, BatteryStateDischarging}, + {"one full one not charging", []string{"Full", "Not charging"}, BatteryStateNotCharging}, + {"both full", []string{"Full", "Full"}, BatteryStateFull}, + {"unknown and full", []string{"Unknown", "Full"}, BatteryStateFull}, + {"unknown and discharging", []string{"Unknown", "Discharging"}, BatteryStateDischarging}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + + for i, status := range tt.statuses { + dir := fmt.Sprintf("/sys/class/power_supply/BAT%d", i) + fs.MkdirAll(dir, 0755) + afero.WriteFile(fs, dir+"/capacity", []byte("50\n"), 0644) + afero.WriteFile(fs, dir+"/status", []byte(status+"\n"), 0644) + afero.WriteFile(fs, dir+"/energy_full", []byte("50000000\n"), 0644) + afero.WriteFile(fs, dir+"/energy_now", []byte("25000000\n"), 0644) + afero.WriteFile(fs, dir+"/power_now", []byte("10000000\n"), 0644) + } + + b := NewBattery(config.BatteryConfig{}, fs) + b.Update() + + if b.State != tt.want { + t.Errorf("State = %v, want %v", b.State, tt.want) + } + }) + } +} + +func TestBattery_RemainingReset(t *testing.T) { + fs := afero.NewMemMapFs() + + fs.MkdirAll("/sys/class/power_supply/BAT0", 0755) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/capacity", []byte("50\n"), 0644) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/status", []byte("Discharging\n"), 0644) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/energy_full", []byte("50000000\n"), 0644) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/energy_now", []byte("25000000\n"), 0644) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/power_now", []byte("10000000\n"), 0644) + + b := NewBattery(config.BatteryConfig{}, fs) + b.Update() + + if b.Remaining == 0 { + t.Fatal("Remaining should be non-zero when discharging with power_now > 0") + } + + // Change to Full status (no remaining time) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/status", []byte("Full\n"), 0644) + afero.WriteFile(fs, "/sys/class/power_supply/BAT0/power_now", []byte("0\n"), 0644) + b.Update() + + if b.Remaining != 0 { + t.Errorf("Remaining = %v, want 0 after switching to Full", b.Remaining) + } +} diff --git a/widgets/cpu_test.go b/widgets/cpu_test.go index 10262eb..7af64b5 100644 --- a/widgets/cpu_test.go +++ b/widgets/cpu_test.go @@ -375,14 +375,16 @@ func TestCPU_GetBlock(t *testing.T) { SmoothingIntervalSeconds: 1, } - // Create CPU with primed EMAs + // Create CPU with ready EMAs (need 2 updates for Ready()) totalEMA := util.NewEMA(1) totalEMA.Update(tt.total) + totalEMA.Update(tt.total) coreEMAs := make([]*util.EMA, len(tt.perCore)) for i, v := range tt.perCore { coreEMAs[i] = util.NewEMA(1) coreEMAs[i].Update(v) + coreEMAs[i].Update(v) } cpu := &CPU{ @@ -410,10 +412,10 @@ func TestCPU_GetBlock(t *testing.T) { } func TestCPU_GetBlock_NotReady(t *testing.T) { - // EMA not ready (not enough samples) + // EMA not ready (need 2 samples for first smoothing) cpu := &CPU{ cfg: config.CPUConfig{SmoothingIntervalSeconds: 3}, - totalEMA: util.NewEMA(3), // needs 3 samples to be ready + totalEMA: util.NewEMA(3), // no samples yet } block := cpu.GetBlock() diff --git a/widgets/temperature_test.go b/widgets/temperature_test.go index 171b66b..b8153a4 100644 --- a/widgets/temperature_test.go +++ b/widgets/temperature_test.go @@ -10,10 +10,11 @@ import ( "neoden/h2status/util" ) -// newReadyEMA creates an EMA that is already primed and ready +// newReadyEMA creates an EMA that is already ready (has 2+ samples) func newReadyEMA() *util.EMA { ema := util.NewEMA(1) - ema.Update(0) // prime it + ema.Update(0) + ema.Update(0) return ema } @@ -527,3 +528,28 @@ func TestTemperature_DefaultSmoothingInterval(t *testing.T) { t.Errorf("Value = %d, want 50", temp.sensors[0].Value) } } + +func TestTemperature_GetBlock_HiddenUntilEMAReady(t *testing.T) { + fs := afero.NewMemMapFs() + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("85000\n"), 0644) // 85°C, above ShowAbove + + cfgs := []config.TemperatureConfig{ + {Path: "/sys/hwmon/temp1", Label: "CPU", ShowAbove: 75, UrgentAbove: 90, SmoothingIntervalSeconds: 5}, + } + + temp := NewTemperature(cfgs, fs) + + // First update - EMA not ready yet (need 2 samples for first smoothing) + temp.Update() + block := temp.GetBlock() + if block != "" { + t.Errorf("GetBlock() = %q, want empty when EMA not ready", block) + } + + // Second update - now EMA has smoothed at least once + temp.Update() + block = temp.GetBlock() + if block == "" { + t.Error("GetBlock() = empty, want non-empty after EMA ready") + } +}