132 lines
3.3 KiB
Go
Raw Normal View History

package ups
import (
"bufio"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
type Sample struct {
OnBattery bool
LowBattery bool
RuntimeSeconds int
BatteryCharge float64
LoadPercent float64
NominalPowerW float64
RawStatus string
}
type Provider interface {
Read(context.Context) (Sample, error)
}
type NUTProvider struct {
Target string
}
// NewNUTProvider runs one orchestration or CLI step.
// Signature: NewNUTProvider(target string) *NUTProvider.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func NewNUTProvider(target string) *NUTProvider {
return &NUTProvider{Target: target}
}
// Read runs one orchestration or CLI step.
// Signature: (p *NUTProvider) Read(ctx context.Context) (Sample, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (p *NUTProvider) Read(ctx context.Context) (Sample, error) {
if p.Target == "" {
return Sample{}, fmt.Errorf("NUT target must not be empty")
}
cmd := exec.CommandContext(ctx, "upsc", p.Target)
out, err := cmd.Output()
if err != nil {
return Sample{}, fmt.Errorf("upsc %s: %w", p.Target, err)
}
return parseNUT(string(out))
}
// parseNUT runs one orchestration or CLI step.
// Signature: parseNUT(raw string) (Sample, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseNUT(raw string) (Sample, error) {
kv := map[string]string{}
s := bufio.NewScanner(strings.NewReader(raw))
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
k := strings.TrimSpace(parts[0])
v := strings.TrimSpace(parts[1])
kv[k] = v
}
if err := s.Err(); err != nil {
return Sample{}, err
}
status := kv["ups.status"]
if status == "" {
return Sample{}, fmt.Errorf("ups.status missing in NUT output")
}
flags := strings.Fields(status)
out := Sample{RawStatus: status}
for _, f := range flags {
switch strings.ToUpper(f) {
case "OB":
out.OnBattery = true
case "OL":
out.OnBattery = false
case "LB":
out.LowBattery = true
}
}
if runtimeRaw := kv["battery.runtime"]; runtimeRaw != "" {
runtime, err := strconv.Atoi(strings.Split(runtimeRaw, ".")[0])
if err == nil {
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
}
}
if nominalRaw := kv["ups.realpower.nominal"]; nominalRaw != "" {
if nominal, ok := parseNumber(nominalRaw); ok {
out.NominalPowerW = nominal
}
}
return out, nil
}
var parseNumberCleaner = regexp.MustCompile(`[^0-9.+-]`)
// parseNumber runs one orchestration or CLI step.
// Signature: parseNumber(raw string) (float64, bool).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
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
}