diff --git a/internal/metrics/exporter.go b/internal/metrics/exporter.go index 4ab2b84..48f0038 100644 --- a/internal/metrics/exporter.go +++ b/internal/metrics/exporter.go @@ -15,6 +15,8 @@ type Sample struct { OnBattery bool LowBattery bool RuntimeSecond int + BatteryCharge float64 + LoadPercent float64 ThresholdSec int Trigger bool BreachCount int @@ -100,6 +102,10 @@ func (e *Exporter) serveMetrics(w http.ResponseWriter, _ *http.Request) { b.WriteString("# TYPE hecate_ups_low_battery gauge\n") b.WriteString("# HELP hecate_ups_runtime_seconds Battery runtime remaining reported by UPS.\n") b.WriteString("# TYPE hecate_ups_runtime_seconds gauge\n") + b.WriteString("# HELP hecate_ups_battery_charge_percent Battery charge percentage reported by UPS.\n") + b.WriteString("# TYPE hecate_ups_battery_charge_percent gauge\n") + b.WriteString("# HELP hecate_ups_load_percent UPS output load percentage.\n") + b.WriteString("# TYPE hecate_ups_load_percent gauge\n") b.WriteString("# HELP hecate_ups_threshold_seconds Red-line threshold for runtime-based shutdown.\n") b.WriteString("# TYPE hecate_ups_threshold_seconds gauge\n") b.WriteString("# HELP hecate_ups_trigger_active Whether this UPS source currently breaches shutdown trigger conditions.\n") @@ -123,6 +129,8 @@ func (e *Exporter) serveMetrics(w http.ResponseWriter, _ *http.Request) { b.WriteString(fmt.Sprintf("hecate_ups_on_battery%s %d\n", labels, boolNum(s.OnBattery))) b.WriteString(fmt.Sprintf("hecate_ups_low_battery%s %d\n", labels, boolNum(s.LowBattery))) b.WriteString(fmt.Sprintf("hecate_ups_runtime_seconds%s %d\n", labels, s.RuntimeSecond)) + b.WriteString(fmt.Sprintf("hecate_ups_battery_charge_percent%s %.2f\n", labels, s.BatteryCharge)) + b.WriteString(fmt.Sprintf("hecate_ups_load_percent%s %.2f\n", labels, s.LoadPercent)) b.WriteString(fmt.Sprintf("hecate_ups_threshold_seconds%s %d\n", labels, s.ThresholdSec)) b.WriteString(fmt.Sprintf("hecate_ups_trigger_active%s %d\n", labels, boolNum(s.Trigger))) b.WriteString(fmt.Sprintf("hecate_ups_breach_count%s %d\n", labels, s.BreachCount)) diff --git a/internal/metrics/exporter_test.go b/internal/metrics/exporter_test.go index 3e27ad5..2ea75f8 100644 --- a/internal/metrics/exporter_test.go +++ b/internal/metrics/exporter_test.go @@ -16,6 +16,8 @@ func TestExporterEmitsCoreMetrics(t *testing.T) { OnBattery: true, LowBattery: false, RuntimeSecond: 412, + BatteryCharge: 81.5, + LoadPercent: 27.4, ThresholdSec: 354, Trigger: true, BreachCount: 2, @@ -34,6 +36,8 @@ func TestExporterEmitsCoreMetrics(t *testing.T) { "hecate_shutdown_triggers_total 1", "hecate_ups_on_battery{source=\"db-ups\",target=\"atlasups@localhost\"", "hecate_ups_runtime_seconds{source=\"db-ups\",target=\"atlasups@localhost\"", + "hecate_ups_battery_charge_percent{source=\"db-ups\",target=\"atlasups@localhost\"", + "hecate_ups_load_percent{source=\"db-ups\",target=\"atlasups@localhost\"", "hecate_ups_threshold_seconds{source=\"db-ups\",target=\"atlasups@localhost\"", } for _, m := range mustContain { diff --git a/internal/service/daemon.go b/internal/service/daemon.go index d04e3ab..a14c014 100644 --- a/internal/service/daemon.go +++ b/internal/service/daemon.go @@ -129,6 +129,8 @@ func (d *Daemon) Run(ctx context.Context) error { OnBattery: sample.OnBattery, LowBattery: sample.LowBattery, RuntimeSecond: sample.RuntimeSeconds, + BatteryCharge: sample.BatteryCharge, + LoadPercent: sample.LoadPercent, ThresholdSec: threshold, Trigger: trigger, BreachCount: breachCount[target.Name], diff --git a/internal/ups/nut.go b/internal/ups/nut.go index 2835d9c..87b9ed7 100644 --- a/internal/ups/nut.go +++ b/internal/ups/nut.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os/exec" + "regexp" "strconv" "strings" ) @@ -13,6 +14,8 @@ type Sample struct { OnBattery bool LowBattery bool RuntimeSeconds int + BatteryCharge float64 + LoadPercent float64 RawStatus string } @@ -82,5 +85,29 @@ func parseNUT(raw string) (Sample, error) { out.RuntimeSeconds = runtime } } + if chargeRaw := kv["battery.charge"]; chargeRaw != "" { + if charge, ok := parseNumber(chargeRaw); ok { + out.BatteryCharge = charge + } + } + if loadRaw := kv["ups.load"]; loadRaw != "" { + if load, ok := parseNumber(loadRaw); ok { + out.LoadPercent = load + } + } return out, nil } + +var parseNumberCleaner = regexp.MustCompile(`[^0-9.+-]`) + +func parseNumber(raw string) (float64, bool) { + cleaned := strings.TrimSpace(parseNumberCleaner.ReplaceAllString(raw, "")) + if cleaned == "" { + return 0, false + } + v, err := strconv.ParseFloat(cleaned, 64) + if err != nil { + return 0, false + } + return v, true +} diff --git a/internal/ups/nut_test.go b/internal/ups/nut_test.go index 5428e13..58cc4ff 100644 --- a/internal/ups/nut_test.go +++ b/internal/ups/nut_test.go @@ -4,6 +4,8 @@ import "testing" func TestParseNUT(t *testing.T) { raw := `battery.runtime: 384 +battery.charge: 72 +ups.load: 19 ups.status: OB LB ` s, err := parseNUT(raw) @@ -19,4 +21,10 @@ ups.status: OB LB if s.RuntimeSeconds != 384 { t.Fatalf("expected runtime 384, got %d", s.RuntimeSeconds) } + if s.BatteryCharge != 72 { + t.Fatalf("expected battery charge 72, got %.2f", s.BatteryCharge) + } + if s.LoadPercent != 19 { + t.Fatalf("expected load 19, got %.2f", s.LoadPercent) + } }