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 }