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