Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ enabled = true
show_above = 50
# show per-core info when any core above this %
show_core_above = 95
# averaging window in seconds (smooths out spikes)
average_seconds = 5
# smoothing window in seconds (EMA-based, reduces flickering)
smoothing_interval_seconds = 3
# urgent when above this %
urgent_above = 95

Expand Down Expand Up @@ -102,3 +102,5 @@ interfaces = ["tun*", "wg*", "tap*", "cscotun*"]
# label = "CPU"
# show_above = 75
# urgent_above = 90
# smoothing window in seconds (EMA-based, reduces flickering, default 3)
# smoothing_interval_seconds = 3
36 changes: 21 additions & 15 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"path/filepath"

Expand All @@ -24,11 +25,11 @@ type ClockConfig struct {
}

type CPUConfig struct {
Enabled bool `toml:"enabled"`
ShowAbove int `toml:"show_above"`
ShowCoreAbove int `toml:"show_core_above"`
AverageSeconds int `toml:"average_seconds"`
UrgentAbove int `toml:"urgent_above"`
Enabled bool `toml:"enabled"`
ShowAbove int `toml:"show_above"`
ShowCoreAbove int `toml:"show_core_above"`
SmoothingIntervalSeconds int `toml:"smoothing_interval_seconds"`
UrgentAbove int `toml:"urgent_above"`
}

type RAMConfig struct {
Expand Down Expand Up @@ -62,10 +63,11 @@ type VPNConfig struct {
}

type TemperatureConfig struct {
Path string `toml:"path"`
Label string `toml:"label"`
ShowAbove int `toml:"show_above"`
UrgentAbove int `toml:"urgent_above"`
Path string `toml:"path"`
Label string `toml:"label"`
ShowAbove int `toml:"show_above"`
UrgentAbove int `toml:"urgent_above"`
SmoothingIntervalSeconds int `toml:"smoothing_interval_seconds"`
}

type Config struct {
Expand Down Expand Up @@ -97,11 +99,11 @@ func Default() *Config {
Enabled: true,
},
CPU: CPUConfig{
Enabled: true,
ShowAbove: 50,
ShowCoreAbove: 95,
AverageSeconds: 5,
UrgentAbove: 95,
Enabled: true,
ShowAbove: 50,
ShowCoreAbove: 95,
SmoothingIntervalSeconds: 3,
UrgentAbove: 95,
},
RAM: RAMConfig{
Enabled: true,
Expand Down Expand Up @@ -147,10 +149,14 @@ func Load() (*Config, error) {
return cfg, nil
}

_, err = toml.DecodeFile(configPath, cfg)
meta, err := toml.DecodeFile(configPath, cfg)
if err != nil {
return cfg, err
}

for _, key := range meta.Undecoded() {
fmt.Fprintf(os.Stderr, "config: unknown key %q\n", key.String())
}

return cfg, nil
}
61 changes: 56 additions & 5 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -46,8 +47,8 @@ func TestDefault(t *testing.T) {
if cfg.CPU.ShowCoreAbove != 95 {
t.Errorf("CPU.ShowCoreAbove = %d, want 95", cfg.CPU.ShowCoreAbove)
}
if cfg.CPU.AverageSeconds != 5 {
t.Errorf("CPU.AverageSeconds = %d, want 5", cfg.CPU.AverageSeconds)
if cfg.CPU.SmoothingIntervalSeconds != 3 {
t.Errorf("CPU.SmoothingIntervalSeconds = %d, want 3", cfg.CPU.SmoothingIntervalSeconds)
}
if cfg.CPU.UrgentAbove != 95 {
t.Errorf("CPU.UrgentAbove = %d, want 95", cfg.CPU.UrgentAbove)
Expand Down Expand Up @@ -152,7 +153,7 @@ func TestLoad_WithConfigFile(t *testing.T) {
[cpu]
enabled = false
show_above = 75
average_seconds = 10
smoothing_interval_seconds = 10

[battery]
urgent_below = 15
Expand All @@ -178,8 +179,8 @@ show_below = 10
if cfg.CPU.ShowAbove != 75 {
t.Errorf("CPU.ShowAbove = %d, want 75", cfg.CPU.ShowAbove)
}
if cfg.CPU.AverageSeconds != 10 {
t.Errorf("CPU.AverageSeconds = %d, want 10", cfg.CPU.AverageSeconds)
if cfg.CPU.SmoothingIntervalSeconds != 10 {
t.Errorf("CPU.SmoothingIntervalSeconds = %d, want 10", cfg.CPU.SmoothingIntervalSeconds)
}
if cfg.Battery.UrgentBelow != 15 {
t.Errorf("Battery.UrgentBelow = %d, want 15", cfg.Battery.UrgentBelow)
Expand Down Expand Up @@ -214,3 +215,53 @@ func TestLoad_InvalidToml(t *testing.T) {
t.Error("Load() expected error for invalid TOML, got nil")
}
}

func TestLoad_UnknownKeys(t *testing.T) {
origXDG := os.Getenv("XDG_CONFIG_HOME")
defer os.Setenv("XDG_CONFIG_HOME", origXDG)

tmpDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tmpDir)

configDir := filepath.Join(tmpDir, "h2status")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}

// Config with unknown keys
configContent := `
[cpu]
enabled = true
unknown_key = "value"
average_seconds = 5
`
configPath := filepath.Join(configDir, "config.toml")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}

// Capture stderr
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w

_, err := Load()

w.Close()
os.Stderr = origStderr

if err != nil {
t.Fatalf("Load() error = %v", err)
}

var buf [1024]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])

if !strings.Contains(output, "cpu.unknown_key") {
t.Errorf("expected warning about cpu.unknown_key, got: %s", output)
}
if !strings.Contains(output, "cpu.average_seconds") {
t.Errorf("expected warning about cpu.average_seconds (renamed), got: %s", output)
}
}
48 changes: 48 additions & 0 deletions util/ema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package util

// EMA implements exponential moving average smoothing.
// Alpha is calculated from period: α = 2 / (period + 1)
type EMA struct {
alpha float64
value float64
primed bool
samples int
period int
}

// NewEMA creates a new EMA smoother with the given period.
// Period corresponds to the "smoothing_interval_seconds" config parameter.
func NewEMA(period int) *EMA {
if period < 1 {
period = 1
}
return &EMA{
alpha: 2.0 / float64(period+1),
period: period,
}
}

// Update adds a new sample and returns the smoothed value.
func (e *EMA) Update(value float64) float64 {
if !e.primed {
e.value = value
e.primed = true
e.samples = 1
} else {
e.value = e.alpha*value + (1-e.alpha)*e.value
if e.samples < e.period {
e.samples++
}
}
return e.value
}

// Value returns the current smoothed value.
func (e *EMA) Value() float64 {
return e.value
}

// Ready returns true when enough samples have been collected.
func (e *EMA) Ready() bool {
return e.samples >= e.period
}
108 changes: 108 additions & 0 deletions util/ema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package util

import (
"math"
"testing"
)

func TestNewEMA(t *testing.T) {
tests := []struct {
period int
expectedAlpha float64
}{
{1, 1.0}, // α = 2/(1+1) = 1
{2, 2.0 / 3.0}, // α = 2/(2+1) = 0.667
{5, 2.0 / 6.0}, // α = 2/(5+1) = 0.333
{10, 2.0 / 11.0}, // α = 2/(10+1) = 0.182
{0, 1.0}, // period < 1 should default to 1
{-1, 1.0}, // period < 1 should default to 1
}

for _, tt := range tests {
ema := NewEMA(tt.period)
if math.Abs(ema.alpha-tt.expectedAlpha) > 0.001 {
t.Errorf("NewEMA(%d).alpha = %f, want %f", tt.period, ema.alpha, tt.expectedAlpha)
}
}
}

func TestEMA_Update(t *testing.T) {
ema := NewEMA(1) // α = 1, so EMA = current value

// With α = 1, EMA should equal current value
result := ema.Update(10)
if result != 10 {
t.Errorf("Update(10) = %f, want 10", result)
}

result = ema.Update(20)
if result != 20 {
t.Errorf("Update(20) = %f, want 20", result)
}
}

func TestEMA_Smoothing(t *testing.T) {
ema := NewEMA(5) // α = 0.333

// First value sets the baseline
ema.Update(100)
if ema.Value() != 100 {
t.Errorf("First update should set value directly, got %f", ema.Value())
}

// Sudden spike should be smoothed
ema.Update(200)
if ema.Value() >= 200 || ema.Value() <= 100 {
t.Errorf("Spike should be smoothed, got %f", ema.Value())
}

// Continue updating with high value - should approach it
for i := 0; i < 20; i++ {
ema.Update(200)
}
if ema.Value() < 195 {
t.Errorf("After many updates, should approach 200, got %f", ema.Value())
}
}

func TestEMA_Ready(t *testing.T) {
ema := NewEMA(3)

if ema.Ready() {
t.Error("Should not be ready before any updates")
}

ema.Update(10)
if ema.Ready() {
t.Error("Should not be ready after 1 update (need 3)")
}

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")
}

ema.Update(40)
if !ema.Ready() {
t.Error("Should still be ready after 4 updates")
}
}

func TestEMA_Value(t *testing.T) {
ema := NewEMA(2)

// Before any updates, value should be 0
if ema.Value() != 0 {
t.Errorf("Value before updates should be 0, got %f", ema.Value())
}

ema.Update(50)
if ema.Value() != 50 {
t.Errorf("Value after first update should be 50, got %f", ema.Value())
}
}
Loading