package main import ( "context" "os/exec" "strconv" "strings" "sync" "time" ) type UPSMetrics struct { Available bool `json:"available"` Source string `json:"source"` Name string `json:"name"` Model string `json:"model"` Status string `json:"status"` StatusLabel string `json:"statusLabel"` ChargePercent *float64 `json:"chargePercent"` RuntimeSeconds *float64 `json:"runtimeSeconds"` LoadPercent *float64 `json:"loadPercent"` InputVoltage *float64 `json:"inputVoltage"` OutputVoltage *float64 `json:"outputVoltage"` InputFrequency *float64 `json:"inputFrequency"` BatteryVoltage *float64 `json:"batteryVoltage"` Temperature *float64 `json:"temperatureCelsius"` CommunicationOK bool `json:"communicationOk"` BatteryDate string `json:"batteryDate"` BatteryAgeDays int `json:"batteryAgeDays,omitempty"` ReplaceBattery bool `json:"replaceBattery"` LowBatteryCharge *float64 `json:"lowBatteryCharge,omitempty"` LowBatteryRuntime *float64 `json:"lowBatteryRuntime,omitempty"` } type upsCollector struct { mu sync.Mutex cached UPSMetrics updatedAt time.Time } func (c *upsCollector) collect() UPSMetrics { c.mu.Lock() defer c.mu.Unlock() if time.Since(c.updatedAt) < 5*time.Second { return c.cached } if metrics, ok := readNUT(); ok { c.cached = metrics } else if metrics, ok := readAPCUPSD(); ok { c.cached = metrics } else { if c.cached.Available || c.cached.Source != "" { c.cached.Available = false c.cached.CommunicationOK = false c.cached.StatusLabel = "Нет связи" } else { c.cached = UPSMetrics{} } } c.updatedAt = time.Now() return c.cached } func readNUT() (UPSMetrics, bool) { if _, err := exec.LookPath("upsc"); err != nil { return UPSMetrics{}, false } ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() list, err := exec.CommandContext(ctx, "upsc", "-l").Output() if err != nil || strings.TrimSpace(string(list)) == "" { return UPSMetrics{}, false } name := strings.Fields(string(list))[0] output, err := exec.CommandContext(ctx, "upsc", name).Output() if err != nil { return UPSMetrics{}, false } values := parseColonValues(string(output)) return upsFromValues("NUT", name, values), true } func readAPCUPSD() (UPSMetrics, bool) { if _, err := exec.LookPath("apcaccess"); err != nil { return UPSMetrics{}, false } ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() output, err := exec.CommandContext(ctx, "apcaccess", "status").Output() if err != nil { return UPSMetrics{}, false } raw := parseColonValues(string(output)) values := map[string]string{ "device.model": raw["MODEL"], "ups.status": raw["STATUS"], "battery.charge": raw["BCHARGE"], "battery.runtime": minutesToSeconds(raw["TIMELEFT"]), "ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"], "output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"], "battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"], "battery.date": raw["BATTDATE"], "battery.charge.low": raw["MBATTCHG"], "battery.runtime.low": minutesToSeconds(raw["MINTIMEL"]), } return upsFromValues("apcupsd", raw["UPSNAME"], values), true } func parseColonValues(output string) map[string]string { values := make(map[string]string) for _, line := range strings.Split(output, "\n") { key, value, ok := strings.Cut(line, ":") if ok { values[strings.TrimSpace(key)] = strings.TrimSpace(value) } } return values } func upsFromValues(source, name string, values map[string]string) UPSMetrics { status := values["ups.status"] metrics := UPSMetrics{ Available: true, CommunicationOK: true, Source: source, Name: name, Model: values["device.model"], Status: status, StatusLabel: upsStatusLabel(status), ChargePercent: numberPointer(values["battery.charge"]), RuntimeSeconds: numberPointer(values["battery.runtime"]), LoadPercent: numberPointer(values["ups.load"]), InputVoltage: numberPointer(values["input.voltage"]), OutputVoltage: numberPointer(values["output.voltage"]), InputFrequency: numberPointer(values["input.frequency"]), BatteryVoltage: numberPointer(values["battery.voltage"]), Temperature: numberPointer(firstNonEmpty(values["ups.temperature"], values["battery.temperature"])), BatteryDate: firstNonEmpty(values["battery.date"], values["battery.mfr.date"]), ReplaceBattery: strings.Contains(status, "RB"), LowBatteryCharge: numberPointer(values["battery.charge.low"]), LowBatteryRuntime: numberPointer(values["battery.runtime.low"]), } if metrics.BatteryDate != "" { for _, layout := range []string{"2006/01/02", "2006-01-02", "01/02/2006", "02/01/2006"} { if date, err := time.Parse(layout, metrics.BatteryDate); err == nil { metrics.BatteryAgeDays = int(time.Since(date).Hours() / 24) break } } } return metrics } func numberPointer(value string) *float64 { field := strings.Fields(value) if len(field) == 0 { return nil } number, err := strconv.ParseFloat(strings.TrimSuffix(field[0], "%"), 64) if err != nil { return nil } return &number } func minutesToSeconds(value string) string { if minutes := numberPointer(value); minutes != nil { return strconv.FormatFloat(*minutes*60, 'f', -1, 64) } return "" } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func upsStatusLabel(status string) string { switch { case strings.Contains(status, "LB"): return "Низкий заряд" case strings.Contains(status, "OB"): return "Работа от батареи" case strings.Contains(status, "OL"): return "Питание от сети" case status == "ONLINE": return "Питание от сети" case status == "ONBATT": return "Работа от батареи" default: return firstNonEmpty(status, "Неизвестно") } }