2026-04-03 01:43:16 -03:00
|
|
|
package ups
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os/exec"
|
2026-04-03 17:50:05 -03:00
|
|
|
"regexp"
|
2026-04-03 01:43:16 -03:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Sample struct {
|
|
|
|
|
OnBattery bool
|
|
|
|
|
LowBattery bool
|
|
|
|
|
RuntimeSeconds int
|
2026-04-03 17:50:05 -03:00
|
|
|
BatteryCharge float64
|
|
|
|
|
LoadPercent float64
|
2026-04-03 20:43:27 -03:00
|
|
|
NominalPowerW float64
|
2026-04-03 01:43:16 -03:00
|
|
|
RawStatus string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Provider interface {
|
|
|
|
|
Read(context.Context) (Sample, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type NUTProvider struct {
|
|
|
|
|
Target string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 01:38:06 -03:00
|
|
|
// 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.
|
2026-04-03 01:43:16 -03:00
|
|
|
func NewNUTProvider(target string) *NUTProvider {
|
|
|
|
|
return &NUTProvider{Target: target}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 01:38:06 -03:00
|
|
|
// 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.
|
2026-04-03 01:43:16 -03:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 01:38:06 -03:00
|
|
|
// 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.
|
2026-04-03 01:43:16 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 17:50:05 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 20:43:27 -03:00
|
|
|
if nominalRaw := kv["ups.realpower.nominal"]; nominalRaw != "" {
|
|
|
|
|
if nominal, ok := parseNumber(nominalRaw); ok {
|
|
|
|
|
out.NominalPowerW = nominal
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 01:43:16 -03:00
|
|
|
return out, nil
|
|
|
|
|
}
|
2026-04-03 17:50:05 -03:00
|
|
|
|
|
|
|
|
var parseNumberCleaner = regexp.MustCompile(`[^0-9.+-]`)
|
|
|
|
|
|
2026-04-09 01:38:06 -03:00
|
|
|
// 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.
|
2026-04-03 17:50:05 -03:00
|
|
|
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
|
|
|
|
|
}
|