Files
ProxmoxDash/ups.go
2026-08-01 16:58:25 +03:00

182 lines
5.9 KiB
Go

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, "Неизвестно")
}
}