summaryrefslogtreecommitdiffstats
path: root/src/go/collectors/go.d.plugin/modules/upsd
diff options
context:
space:
mode:
Diffstat (limited to '')
l---------src/go/collectors/go.d.plugin/modules/upsd/README.md1
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/charts.go399
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/client.go168
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/collect.go180
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/config_schema.json85
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/integrations/ups_nut.md211
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/metadata.yaml264
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/testdata/config.json7
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/testdata/config.yaml5
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/upsd.go115
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/upsd_test.go446
-rw-r--r--src/go/collectors/go.d.plugin/modules/upsd/variables.go39
12 files changed, 1920 insertions, 0 deletions
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/README.md b/src/go/collectors/go.d.plugin/modules/upsd/README.md
new file mode 120000
index 000000000..8dcef84dd
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/README.md
@@ -0,0 +1 @@
+integrations/ups_nut.md \ No newline at end of file
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/charts.go b/src/go/collectors/go.d.plugin/modules/upsd/charts.go
new file mode 100644
index 000000000..72bd69f4f
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/charts.go
@@ -0,0 +1,399 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+)
+
+const (
+ prioUpsLoad = module.Priority + iota
+ prioUpsLoadUsage
+ prioUpsStatus
+ prioUpsTemperature
+
+ prioBatteryCharge
+ prioBatteryEstimatedRuntime
+ prioBatteryVoltage
+ prioBatteryVoltageNominal
+
+ prioInputVoltage
+ prioInputVoltageNominal
+ prioInputCurrent
+ prioInputCurrentNominal
+ prioInputFrequency
+ prioInputFrequencyNominal
+
+ prioOutputVoltage
+ prioOutputVoltageNominal
+ prioOutputCurrent
+ prioOutputCurrentNominal
+ prioOutputFrequency
+ prioOutputFrequencyNominal
+)
+
+var upsChartsTmpl = module.Charts{
+ upsLoadChartTmpl.Copy(),
+ upsLoadUsageChartTmpl.Copy(),
+ upsStatusChartTmpl.Copy(),
+ upsTemperatureChartTmpl.Copy(),
+
+ upsBatteryChargePercentChartTmpl.Copy(),
+ upsBatteryEstimatedRuntimeChartTmpl.Copy(),
+ upsBatteryVoltageChartTmpl.Copy(),
+ upsBatteryVoltageNominalChartTmpl.Copy(),
+
+ upsInputVoltageChartTmpl.Copy(),
+ upsInputVoltageNominalChartTmpl.Copy(),
+ upsInputCurrentChartTmpl.Copy(),
+ upsInputCurrentNominalChartTmpl.Copy(),
+ upsInputFrequencyChartTmpl.Copy(),
+ upsInputFrequencyNominalChartTmpl.Copy(),
+
+ upsOutputVoltageChartTmpl.Copy(),
+ upsOutputVoltageNominalChartTmpl.Copy(),
+ upsOutputCurrentChartTmpl.Copy(),
+ upsOutputCurrentNominalChartTmpl.Copy(),
+ upsOutputFrequencyChartTmpl.Copy(),
+ upsOutputFrequencyNominalChartTmpl.Copy(),
+}
+
+var (
+ upsLoadChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.load_percentage",
+ Title: "UPS load",
+ Units: "percentage",
+ Fam: "ups",
+ Ctx: "upsd.ups_load",
+ Priority: prioUpsLoad,
+ Type: module.Area,
+ Dims: module.Dims{
+ {ID: "ups_%s_ups.load", Name: "load", Div: varPrecision},
+ },
+ }
+ upsLoadUsageChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.load_usage",
+ Title: "UPS load usage (power output)",
+ Units: "Watts",
+ Fam: "ups",
+ Ctx: "upsd.ups_load_usage",
+ Priority: prioUpsLoadUsage,
+ Dims: module.Dims{
+ {ID: "ups_%s_ups.load.usage", Name: "load_usage", Div: varPrecision},
+ },
+ }
+ upsStatusChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.status",
+ Title: "UPS status",
+ Units: "status",
+ Fam: "ups",
+ Ctx: "upsd.ups_status",
+ Priority: prioUpsStatus,
+ Dims: module.Dims{
+ {ID: "ups_%s_ups.status.OL", Name: "on_line"},
+ {ID: "ups_%s_ups.status.OB", Name: "on_battery"},
+ {ID: "ups_%s_ups.status.LB", Name: "low_battery"},
+ {ID: "ups_%s_ups.status.HB", Name: "high_battery"},
+ {ID: "ups_%s_ups.status.RB", Name: "replace_battery"},
+ {ID: "ups_%s_ups.status.CHRG", Name: "charging"},
+ {ID: "ups_%s_ups.status.DISCHRG", Name: "discharging"},
+ {ID: "ups_%s_ups.status.BYPASS", Name: "bypass"},
+ {ID: "ups_%s_ups.status.CAL", Name: "calibration"},
+ {ID: "ups_%s_ups.status.OFF", Name: "offline"},
+ {ID: "ups_%s_ups.status.OVER", Name: "overloaded"},
+ {ID: "ups_%s_ups.status.TRIM", Name: "trim_input_voltage"},
+ {ID: "ups_%s_ups.status.BOOST", Name: "boost_input_voltage"},
+ {ID: "ups_%s_ups.status.FSD", Name: "forced_shutdown"},
+ {ID: "ups_%s_ups.status.other", Name: "other"},
+ },
+ }
+ upsTemperatureChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.temperature",
+ Title: "UPS temperature",
+ Units: "Celsius",
+ Fam: "ups",
+ Ctx: "upsd.ups_temperature",
+ Priority: prioUpsTemperature,
+ Dims: module.Dims{
+ {ID: "ups_%s_ups.temperature", Name: "temperature", Div: varPrecision},
+ },
+ }
+)
+
+var (
+ upsBatteryChargePercentChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.battery_charge_percentage",
+ Title: "UPS Battery charge",
+ Units: "percentage",
+ Fam: "battery",
+ Ctx: "upsd.ups_battery_charge",
+ Priority: prioBatteryCharge,
+ Type: module.Area,
+ Dims: module.Dims{
+ {ID: "ups_%s_battery.charge", Name: "charge", Div: varPrecision},
+ },
+ }
+ upsBatteryEstimatedRuntimeChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.battery_estimated_runtime",
+ Title: "UPS Battery estimated runtime",
+ Units: "seconds",
+ Fam: "battery",
+ Ctx: "upsd.ups_battery_estimated_runtime",
+ Priority: prioBatteryEstimatedRuntime,
+ Dims: module.Dims{
+ {ID: "ups_%s_battery.runtime", Name: "runtime", Div: varPrecision},
+ },
+ }
+ upsBatteryVoltageChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.battery_voltage",
+ Title: "UPS Battery voltage",
+ Units: "Volts",
+ Fam: "battery",
+ Ctx: "upsd.ups_battery_voltage",
+ Priority: prioBatteryVoltage,
+ Dims: module.Dims{
+ {ID: "ups_%s_battery.voltage", Name: "voltage", Div: varPrecision},
+ },
+ }
+ upsBatteryVoltageNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.battery_voltage_nominal",
+ Title: "UPS Battery voltage nominal",
+ Units: "Volts",
+ Fam: "battery",
+ Ctx: "upsd.ups_battery_voltage_nominal",
+ Priority: prioBatteryVoltageNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_battery.voltage.nominal", Name: "nominal_voltage", Div: varPrecision},
+ },
+ }
+)
+
+var (
+ upsInputVoltageChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_voltage",
+ Title: "UPS Input voltage",
+ Units: "Volts",
+ Fam: "input",
+ Ctx: "upsd.ups_input_voltage",
+ Priority: prioInputVoltage,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.voltage", Name: "voltage", Div: varPrecision},
+ },
+ }
+ upsInputVoltageNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_voltage_nominal",
+ Title: "UPS Input voltage nominal",
+ Units: "Volts",
+ Fam: "input",
+ Ctx: "upsd.ups_input_voltage_nominal",
+ Priority: prioInputVoltageNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.voltage.nominal", Name: "nominal_voltage", Div: varPrecision},
+ },
+ }
+ upsInputCurrentChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_current",
+ Title: "UPS Input current",
+ Units: "Ampere",
+ Fam: "input",
+ Ctx: "upsd.ups_input_current",
+ Priority: prioInputCurrent,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.current", Name: "current", Div: varPrecision},
+ },
+ }
+ upsInputCurrentNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_current_nominal",
+ Title: "UPS Input current nominal",
+ Units: "Ampere",
+ Fam: "input",
+ Ctx: "upsd.ups_input_current_nominal",
+ Priority: prioInputCurrentNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.current.nominal", Name: "nominal_current", Div: varPrecision},
+ },
+ }
+ upsInputFrequencyChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_frequency",
+ Title: "UPS Input frequency",
+ Units: "Hz",
+ Fam: "input",
+ Ctx: "upsd.ups_input_frequency",
+ Priority: prioInputFrequency,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.frequency", Name: "frequency", Div: varPrecision},
+ },
+ }
+ upsInputFrequencyNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.input_frequency_nominal",
+ Title: "UPS Input frequency nominal",
+ Units: "Hz",
+ Fam: "input",
+ Ctx: "upsd.ups_input_frequency_nominal",
+ Priority: prioInputFrequencyNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_input.frequency.nominal", Name: "nominal_frequency", Div: varPrecision},
+ },
+ }
+)
+
+var (
+ upsOutputVoltageChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_voltage",
+ Title: "UPS Output voltage",
+ Units: "Volts",
+ Fam: "output",
+ Ctx: "upsd.ups_output_voltage",
+ Priority: prioOutputVoltage,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.voltage", Name: "voltage", Div: varPrecision},
+ },
+ }
+ upsOutputVoltageNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_voltage_nominal",
+ Title: "UPS Output voltage nominal",
+ Units: "Volts",
+ Fam: "output",
+ Ctx: "upsd.ups_output_voltage_nominal",
+ Priority: prioOutputVoltageNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.voltage.nominal", Name: "nominal_voltage", Div: varPrecision},
+ },
+ }
+ upsOutputCurrentChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_current",
+ Title: "UPS Output current",
+ Units: "Ampere",
+ Fam: "output",
+ Ctx: "upsd.ups_output_current",
+ Priority: prioOutputCurrent,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.current", Name: "current", Div: varPrecision},
+ },
+ }
+ upsOutputCurrentNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_current_nominal",
+ Title: "UPS Output current nominal",
+ Units: "Ampere",
+ Fam: "output",
+ Ctx: "upsd.ups_output_current_nominal",
+ Priority: prioOutputCurrentNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.current.nominal", Name: "nominal_current", Div: varPrecision},
+ },
+ }
+ upsOutputFrequencyChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_frequency",
+ Title: "UPS Output frequency",
+ Units: "Hz",
+ Fam: "output",
+ Ctx: "upsd.ups_output_frequency",
+ Priority: prioOutputFrequency,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.frequency", Name: "frequency", Div: varPrecision},
+ },
+ }
+ upsOutputFrequencyNominalChartTmpl = module.Chart{
+ IDSep: true,
+ ID: "%s.output_frequency_nominal",
+ Title: "UPS Output frequency nominal",
+ Units: "Hz",
+ Fam: "output",
+ Ctx: "upsd.ups_output_frequency_nominal",
+ Priority: prioOutputFrequencyNominal,
+ Dims: module.Dims{
+ {ID: "ups_%s_output.frequency.nominal", Name: "nominal_frequency", Div: varPrecision},
+ },
+ }
+)
+
+func (u *Upsd) addUPSCharts(ups upsUnit) {
+ charts := upsChartsTmpl.Copy()
+
+ var removed []string
+ for _, v := range []struct{ v, id string }{
+ {varBatteryVoltage, upsBatteryVoltageChartTmpl.ID},
+ {varBatteryVoltageNominal, upsBatteryVoltageNominalChartTmpl.ID},
+
+ {varUpsTemperature, upsTemperatureChartTmpl.ID},
+
+ {varInputVoltage, upsInputVoltageChartTmpl.ID},
+ {varInputVoltageNominal, upsInputVoltageNominalChartTmpl.ID},
+ {varInputCurrent, upsInputCurrentChartTmpl.ID},
+ {varInputCurrentNominal, upsInputCurrentNominalChartTmpl.ID},
+ {varInputFrequency, upsInputFrequencyChartTmpl.ID},
+ {varInputFrequencyNominal, upsInputFrequencyNominalChartTmpl.ID},
+
+ {varOutputVoltage, upsOutputVoltageChartTmpl.ID},
+ {varOutputVoltageNominal, upsOutputVoltageNominalChartTmpl.ID},
+ {varOutputCurrent, upsOutputCurrentChartTmpl.ID},
+ {varOutputCurrentNominal, upsOutputCurrentNominalChartTmpl.ID},
+ {varOutputFrequency, upsOutputFrequencyChartTmpl.ID},
+ {varOutputFrequencyNominal, upsOutputFrequencyNominalChartTmpl.ID},
+ } {
+ if !hasVar(ups.vars, v.v) {
+ removed = append(removed, v.v)
+ _ = charts.Remove(v.id)
+ }
+ }
+
+ u.Debugf("UPS '%s' no metrics: %v", ups.name, removed)
+
+ name := cleanUpsName(ups.name)
+ for _, chart := range *charts {
+ chart.ID = fmt.Sprintf(chart.ID, name)
+ chart.Labels = []module.Label{
+ {Key: "ups_name", Value: ups.name},
+ {Key: "battery_type", Value: ups.vars[varBatteryType]},
+ {Key: "device_model", Value: ups.vars[varDeviceModel]},
+ {Key: "device_serial", Value: ups.vars[varDeviceSerial]},
+ {Key: "device_manufacturer", Value: ups.vars[varDeviceMfr]},
+ {Key: "device_type", Value: ups.vars[varDeviceType]},
+ }
+ for _, dim := range chart.Dims {
+ dim.ID = fmt.Sprintf(dim.ID, ups.name)
+ }
+ }
+
+ if err := u.Charts().Add(*charts...); err != nil {
+ u.Warning(err)
+ }
+}
+
+func (u *Upsd) removeUPSCharts(name string) {
+ name = cleanUpsName(name)
+ for _, chart := range *u.Charts() {
+ if strings.HasPrefix(chart.ID, name) {
+ chart.MarkRemove()
+ chart.MarkNotCreated()
+ }
+ }
+}
+
+func cleanUpsName(name string) string {
+ name = strings.ReplaceAll(name, " ", "_")
+ name = strings.ReplaceAll(name, ".", "_")
+ return name
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/client.go b/src/go/collectors/go.d.plugin/modules/upsd/client.go
new file mode 100644
index 000000000..a1b8f288e
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/client.go
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+import (
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/socket"
+)
+
+const (
+ commandUsername = "USERNAME %s"
+ commandPassword = "PASSWORD %s"
+ commandListUPS = "LIST UPS"
+ commandListVar = "LIST VAR %s"
+ commandLogout = "LOGOUT"
+)
+
+// https://github.com/networkupstools/nut/blob/81fca30b2998fa73085ce4654f075605ff0b9e01/docs/net-protocol.txt#L647
+var errUpsdCommand = errors.New("upsd command error")
+
+type upsUnit struct {
+ name string
+ vars map[string]string
+}
+
+func newUpsdConn(conf Config) upsdConn {
+ return &upsdClient{conn: socket.New(socket.Config{
+ ConnectTimeout: conf.Timeout.Duration(),
+ ReadTimeout: conf.Timeout.Duration(),
+ WriteTimeout: conf.Timeout.Duration(),
+ Address: conf.Address,
+ })}
+}
+
+type upsdClient struct {
+ conn socket.Client
+}
+
+func (c *upsdClient) connect() error {
+ return c.conn.Connect()
+}
+
+func (c *upsdClient) disconnect() error {
+ _, _ = c.sendCommand(commandLogout)
+ return c.conn.Disconnect()
+}
+
+func (c *upsdClient) authenticate(username, password string) error {
+ cmd := fmt.Sprintf(commandUsername, username)
+ resp, err := c.sendCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if resp[0] != "OK" {
+ return errors.New("authentication failed: invalid username")
+ }
+
+ cmd = fmt.Sprintf(commandPassword, password)
+ resp, err = c.sendCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if resp[0] != "OK" {
+ return errors.New("authentication failed: invalid password")
+ }
+
+ return nil
+}
+
+func (c *upsdClient) upsUnits() ([]upsUnit, error) {
+ resp, err := c.sendCommand(commandListUPS)
+ if err != nil {
+ return nil, err
+ }
+
+ var upsNames []string
+
+ for _, v := range resp {
+ if !strings.HasPrefix(v, "UPS ") {
+ continue
+ }
+ parts := splitLine(v)
+ if len(parts) < 2 {
+ continue
+ }
+ name := parts[1]
+ upsNames = append(upsNames, name)
+ }
+
+ var upsUnits []upsUnit
+
+ for _, name := range upsNames {
+ cmd := fmt.Sprintf(commandListVar, name)
+ resp, err := c.sendCommand(cmd)
+ if err != nil {
+ return nil, err
+ }
+
+ ups := upsUnit{
+ name: name,
+ vars: make(map[string]string),
+ }
+
+ upsUnits = append(upsUnits, ups)
+
+ for _, v := range resp {
+ if !strings.HasPrefix(v, "VAR ") {
+ continue
+ }
+ parts := splitLine(v)
+ if len(parts) < 4 {
+ continue
+ }
+ n, v := parts[2], parts[3]
+ ups.vars[n] = v
+ }
+ }
+
+ return upsUnits, nil
+}
+
+func (c *upsdClient) sendCommand(cmd string) ([]string, error) {
+ var resp []string
+ var errMsg string
+ endLine := getEndLine(cmd)
+
+ err := c.conn.Command(cmd+"\n", func(bytes []byte) bool {
+ line := string(bytes)
+ resp = append(resp, line)
+
+ if strings.HasPrefix(line, "ERR ") {
+ errMsg = strings.TrimPrefix(line, "ERR ")
+ }
+
+ return line != endLine && errMsg == ""
+ })
+ if err != nil {
+ return nil, err
+ }
+ if errMsg != "" {
+ return nil, fmt.Errorf("%w: %s (cmd: '%s')", errUpsdCommand, errMsg, cmd)
+ }
+
+ return resp, nil
+}
+
+func getEndLine(cmd string) string {
+ px, _, _ := strings.Cut(cmd, " ")
+
+ switch px {
+ case "USERNAME", "PASSWORD", "VER":
+ return "OK"
+ }
+ return fmt.Sprintf("END %s", cmd)
+}
+
+func splitLine(s string) []string {
+ r := csv.NewReader(strings.NewReader(s))
+ r.Comma = ' '
+
+ parts, _ := r.Read()
+
+ return parts
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/collect.go b/src/go/collectors/go.d.plugin/modules/upsd/collect.go
new file mode 100644
index 000000000..39e3d1b55
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/collect.go
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+)
+
+func (u *Upsd) collect() (map[string]int64, error) {
+ if u.conn == nil {
+ conn, err := u.establishConnection()
+ if err != nil {
+ return nil, err
+ }
+ u.conn = conn
+ }
+
+ upsUnits, err := u.conn.upsUnits()
+ if err != nil {
+ if !errors.Is(err, errUpsdCommand) {
+ _ = u.conn.disconnect()
+ u.conn = nil
+ }
+ return nil, err
+ }
+
+ u.Debugf("found %d UPS units", len(upsUnits))
+
+ mx := make(map[string]int64)
+
+ u.collectUPSUnits(mx, upsUnits)
+
+ return mx, nil
+}
+
+func (u *Upsd) establishConnection() (upsdConn, error) {
+ conn := u.newUpsdConn(u.Config)
+
+ if err := conn.connect(); err != nil {
+ return nil, err
+ }
+
+ if u.Username != "" && u.Password != "" {
+ if err := conn.authenticate(u.Username, u.Password); err != nil {
+ _ = conn.disconnect()
+ return nil, err
+ }
+ }
+
+ return conn, nil
+}
+
+func (u *Upsd) collectUPSUnits(mx map[string]int64, upsUnits []upsUnit) {
+ seen := make(map[string]bool)
+
+ for _, ups := range upsUnits {
+ seen[ups.name] = true
+ u.Debugf("collecting metrics UPS '%s'", ups.name)
+
+ if !u.upsUnits[ups.name] {
+ u.upsUnits[ups.name] = true
+ u.addUPSCharts(ups)
+ }
+
+ writeVar(mx, ups, varBatteryCharge)
+ writeVar(mx, ups, varBatteryRuntime)
+ writeVar(mx, ups, varBatteryVoltage)
+ writeVar(mx, ups, varBatteryVoltageNominal)
+
+ writeVar(mx, ups, varInputVoltage)
+ writeVar(mx, ups, varInputVoltageNominal)
+ writeVar(mx, ups, varInputCurrent)
+ writeVar(mx, ups, varInputCurrentNominal)
+ writeVar(mx, ups, varInputFrequency)
+ writeVar(mx, ups, varInputFrequencyNominal)
+
+ writeVar(mx, ups, varOutputVoltage)
+ writeVar(mx, ups, varOutputVoltageNominal)
+ writeVar(mx, ups, varOutputCurrent)
+ writeVar(mx, ups, varOutputCurrentNominal)
+ writeVar(mx, ups, varOutputFrequency)
+ writeVar(mx, ups, varOutputFrequencyNominal)
+
+ writeVar(mx, ups, varUpsLoad)
+ writeVar(mx, ups, varUpsRealPowerNominal)
+ writeVar(mx, ups, varUpsTemperature)
+ writeUpsLoadUsage(mx, ups)
+ writeUpsStatus(mx, ups)
+ }
+
+ for name := range u.upsUnits {
+ if !seen[name] {
+ delete(u.upsUnits, name)
+ u.removeUPSCharts(name)
+ }
+ }
+}
+
+func writeVar(mx map[string]int64, ups upsUnit, v string) {
+ s, ok := ups.vars[v]
+ if !ok {
+ return
+ }
+ n, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return
+ }
+ mx[prefix(ups)+v] = int64(n * varPrecision)
+}
+
+func writeUpsLoadUsage(mx map[string]int64, ups upsUnit) {
+ if hasVar(ups.vars, varUpsRealPower) {
+ pow, _ := strconv.ParseFloat(ups.vars[varUpsRealPower], 64)
+ mx[prefix(ups)+"ups.load.usage"] = int64(pow * varPrecision)
+ return
+ }
+
+ if !hasVar(ups.vars, varUpsLoad) || !hasVar(ups.vars, varUpsRealPowerNominal) {
+ return
+ }
+ load, err := strconv.ParseFloat(ups.vars[varUpsLoad], 64)
+ if err != nil {
+ return
+ }
+ nomPower, err := strconv.ParseFloat(ups.vars[varUpsRealPowerNominal], 64)
+ if err != nil || nomPower == 0 {
+ return
+ }
+ mx[prefix(ups)+"ups.load.usage"] = int64((load / 100 * nomPower) * varPrecision)
+}
+
+// https://networkupstools.org/docs/developer-guide.chunked/ar01s04.html#_status_data
+var upsStatuses = map[string]bool{
+ "OL": true,
+ "OB": true,
+ "LB": true,
+ "HB": true,
+ "RB": true,
+ "CHRG": true,
+ "DISCHRG": true,
+ "BYPASS": true,
+ "CAL": true,
+ "OFF": true,
+ "OVER": true,
+ "TRIM": true,
+ "BOOST": true,
+ "FSD": true,
+}
+
+func writeUpsStatus(mx map[string]int64, ups upsUnit) {
+ if !hasVar(ups.vars, varUpsStatus) {
+ return
+ }
+
+ px := prefix(ups) + "ups.status."
+
+ for st := range upsStatuses {
+ mx[px+st] = 0
+ }
+ mx[px+"other"] = 0
+
+ for _, st := range strings.Split(ups.vars[varUpsStatus], " ") {
+ if _, ok := upsStatuses[st]; ok {
+ mx[px+st] = 1
+ } else {
+ mx[px+"other"] = 1
+ }
+ }
+}
+
+func hasVar(vars map[string]string, v string) bool {
+ _, ok := vars[v]
+ return ok
+}
+
+func prefix(ups upsUnit) string {
+ return "ups_" + ups.name + "_"
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/config_schema.json b/src/go/collectors/go.d.plugin/modules/upsd/config_schema.json
new file mode 100644
index 000000000..564c0179c
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/config_schema.json
@@ -0,0 +1,85 @@
+{
+ "jsonSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "UPSd collector configuration.",
+ "type": "object",
+ "properties": {
+ "update_every": {
+ "title": "Update every",
+ "description": "Data collection interval, measured in seconds.",
+ "type": "integer",
+ "minimum": 1,
+ "default": 1
+ },
+ "address": {
+ "title": "Address",
+ "description": "The IP address and port where the UPSd daemon listens for connections.",
+ "type": "string",
+ "default": "127.0.0.1:3493"
+ },
+ "timeout": {
+ "title": "Timeout",
+ "description": "Timeout for establishing a connection and communication (reading and writing) in seconds.",
+ "type": "number",
+ "minimum": 0.5,
+ "default": 1
+ },
+ "username": {
+ "title": "Username",
+ "description": "The username for authentication.",
+ "type": "string"
+ },
+ "password": {
+ "title": "Password",
+ "description": "The password for authentication.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "address"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^name$": {}
+ },
+ "dependencies": {
+ "username": [
+ "password"
+ ],
+ "password": [
+ "username"
+ ]
+ }
+ },
+ "uiSchema": {
+ "uiOptions": {
+ "fullPage": true
+ },
+ "timeout": {
+ "ui:help": "Accepts decimals for precise control (e.g., type 1.5 for 1.5 seconds)."
+ },
+ "password": {
+ "ui:widget": "password"
+ },
+ "ui:flavour": "tabs",
+ "ui:options": {
+ "tabs": [
+ {
+ "title": "Base",
+ "fields": [
+ "update_every",
+ "address",
+ "timeout"
+ ]
+ },
+ {
+ "title": "Auth",
+ "fields": [
+ "username",
+ "password"
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/integrations/ups_nut.md b/src/go/collectors/go.d.plugin/modules/upsd/integrations/ups_nut.md
new file mode 100644
index 000000000..c02eafc70
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/integrations/ups_nut.md
@@ -0,0 +1,211 @@
+<!--startmeta
+custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/collectors/go.d.plugin/modules/upsd/README.md"
+meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/collectors/go.d.plugin/modules/upsd/metadata.yaml"
+sidebar_label: "UPS (NUT)"
+learn_status: "Published"
+learn_rel_path: "Collecting Metrics/UPS"
+most_popular: False
+message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
+endmeta-->
+
+# UPS (NUT)
+
+
+<img src="https://netdata.cloud/img/plug-circle-bolt.svg" width="150"/>
+
+
+Plugin: go.d.plugin
+Module: upsd
+
+<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
+
+## Overview
+
+This collector monitors Uninterruptible Power Supplies by polling the UPS daemon using the NUT network protocol.
+
+
+
+
+This collector is supported on all platforms.
+
+This collector supports collecting metrics from multiple instances of this integration, including remote instances.
+
+
+### Default Behavior
+
+#### Auto-Detection
+
+This integration doesn't support auto-detection.
+
+#### Limits
+
+The default configuration for this integration does not impose any limits on data collection.
+
+#### Performance Impact
+
+The default configuration for this integration is not expected to impose a significant performance impact on the system.
+
+
+## Metrics
+
+Metrics grouped by *scope*.
+
+The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels.
+
+
+
+### Per ups
+
+These metrics refer to the UPS unit.
+
+Labels:
+
+| Label | Description |
+|:-----------|:----------------|
+| ups_name | UPS name. |
+| battery_type | Battery type (chemistry). "battery.type" variable value. |
+| device_model | Device model. "device.mode" variable value. |
+| device_serial | Device serial number. "device.serial" variable value. |
+| device_manufacturer | Device manufacturer. "device.mfr" variable value. |
+| device_type | Device type (ups, pdu, scd, psu, ats). "device.type" variable value. |
+
+Metrics:
+
+| Metric | Dimensions | Unit |
+|:------|:----------|:----|
+| upsd.ups_load | load | percentage |
+| upsd.ups_load_usage | load_usage | Watts |
+| upsd.ups_status | on_line, on_battery, low_battery, high_battery, replace_battery, charging, discharging, bypass, calibration, offline, overloaded, trim_input_voltage, boost_input_voltage, forced_shutdown, other | status |
+| upsd.ups_temperature | temperature | Celsius |
+| upsd.ups_battery_charge | charge | percentage |
+| upsd.ups_battery_estimated_runtime | runtime | seconds |
+| upsd.ups_battery_voltage | voltage | Volts |
+| upsd.ups_battery_voltage_nominal | nominal_voltage | Volts |
+| upsd.ups_input_voltage | voltage | Volts |
+| upsd.ups_input_voltage_nominal | nominal_voltage | Volts |
+| upsd.ups_input_current | current | Ampere |
+| upsd.ups_input_current_nominal | nominal_current | Ampere |
+| upsd.ups_input_frequency | frequency | Hz |
+| upsd.ups_input_frequency_nominal | nominal_frequency | Hz |
+| upsd.ups_output_voltage | voltage | Volts |
+| upsd.ups_output_voltage_nominal | nominal_voltage | Volts |
+| upsd.ups_output_current | current | Ampere |
+| upsd.ups_output_current_nominal | nominal_current | Ampere |
+| upsd.ups_output_frequency | frequency | Hz |
+| upsd.ups_output_frequency_nominal | nominal_frequency | Hz |
+
+
+
+## Alerts
+
+
+The following alerts are available:
+
+| Alert name | On metric | Description |
+|:------------|:----------|:------------|
+| [ upsd_10min_ups_load ](https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf) | upsd.ups_load | UPS ${label:ups_name} average load over the last 10 minutes |
+| [ upsd_ups_battery_charge ](https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf) | upsd.ups_battery_charge | UPS ${label:ups_name} average battery charge over the last minute |
+| [ upsd_ups_last_collected_secs ](https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf) | upsd.ups_load | UPS ${label:ups_name} number of seconds since the last successful data collection |
+
+
+## Setup
+
+### Prerequisites
+
+No action required.
+
+### Configuration
+
+#### File
+
+The configuration file name for this integration is `go.d/upsd.conf`.
+
+
+You can edit the configuration file using the `edit-config` script from the
+Netdata [config directory](/docs/netdata-agent/configuration/README.md#the-netdata-config-directory).
+
+```bash
+cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
+sudo ./edit-config go.d/upsd.conf
+```
+#### Options
+
+The following options can be defined globally: update_every, autodetection_retry.
+
+
+<details open><summary>Config options</summary>
+
+| Name | Description | Default | Required |
+|:----|:-----------|:-------|:--------:|
+| update_every | Data collection frequency. | 1 | no |
+| autodetection_retry | Recheck interval in seconds. Zero means no recheck will be scheduled. | 0 | no |
+| address | UPS daemon address in IP:PORT format. | 127.0.0.1:3493 | yes |
+| timeout | Connection/read/write timeout in seconds. The timeout includes name resolution, if required. | 2 | no |
+
+</details>
+
+#### Examples
+
+##### Basic
+
+A basic example configuration.
+
+<details open><summary>Config</summary>
+
+```yaml
+jobs:
+ - name: local
+ address: 127.0.0.1:3493
+
+```
+</details>
+
+##### Multi-instance
+
+> **Note**: When you define multiple jobs, their names must be unique.
+
+Collecting metrics from local and remote instances.
+
+
+<details open><summary>Config</summary>
+
+```yaml
+jobs:
+ - name: local
+ address: 127.0.0.1:3493
+
+ - name: remote
+ address: 203.0.113.0:3493
+
+```
+</details>
+
+
+
+## Troubleshooting
+
+### Debug Mode
+
+To troubleshoot issues with the `upsd` collector, run the `go.d.plugin` with the debug option enabled. The output
+should give you clues as to why the collector isn't working.
+
+- Navigate to the `plugins.d` directory, usually at `/usr/libexec/netdata/plugins.d/`. If that's not the case on
+ your system, open `netdata.conf` and look for the `plugins` setting under `[directories]`.
+
+ ```bash
+ cd /usr/libexec/netdata/plugins.d/
+ ```
+
+- Switch to the `netdata` user.
+
+ ```bash
+ sudo -u netdata -s
+ ```
+
+- Run the `go.d.plugin` to debug the collector:
+
+ ```bash
+ ./go.d.plugin -d -m upsd
+ ```
+
+
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/metadata.yaml b/src/go/collectors/go.d.plugin/modules/upsd/metadata.yaml
new file mode 100644
index 000000000..070b33852
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/metadata.yaml
@@ -0,0 +1,264 @@
+plugin_name: go.d.plugin
+modules:
+ - meta:
+ id: collector-go.d.plugin-upsd
+ plugin_name: go.d.plugin
+ module_name: upsd
+ monitored_instance:
+ name: UPS (NUT)
+ link: ""
+ icon_filename: plug-circle-bolt.svg
+ categories:
+ - data-collection.ups
+ keywords:
+ - ups
+ - nut
+ related_resources:
+ integrations:
+ list: []
+ info_provided_to_referring_integrations:
+ description: ""
+ most_popular: false
+ overview:
+ data_collection:
+ metrics_description: |
+ This collector monitors Uninterruptible Power Supplies by polling the UPS daemon using the NUT network protocol.
+ method_description: ""
+ supported_platforms:
+ include: []
+ exclude: []
+ multi_instance: true
+ additional_permissions:
+ description: ""
+ default_behavior:
+ auto_detection:
+ description: ""
+ limits:
+ description: ""
+ performance_impact:
+ description: ""
+ setup:
+ prerequisites:
+ list: []
+ configuration:
+ file:
+ name: go.d/upsd.conf
+ options:
+ description: |
+ The following options can be defined globally: update_every, autodetection_retry.
+ folding:
+ title: Config options
+ enabled: true
+ list:
+ - name: update_every
+ description: Data collection frequency.
+ default_value: 1
+ required: false
+ - name: autodetection_retry
+ description: Recheck interval in seconds. Zero means no recheck will be scheduled.
+ default_value: 0
+ required: false
+ - name: address
+ description: UPS daemon address in IP:PORT format.
+ default_value: 127.0.0.1:3493
+ required: true
+ - name: timeout
+ description: Connection/read/write timeout in seconds. The timeout includes name resolution, if required.
+ default_value: 2
+ required: false
+ examples:
+ folding:
+ title: Config
+ enabled: true
+ list:
+ - name: Basic
+ description: A basic example configuration.
+ config: |
+ jobs:
+ - name: local
+ address: 127.0.0.1:3493
+ - name: Multi-instance
+ description: |
+ > **Note**: When you define multiple jobs, their names must be unique.
+
+ Collecting metrics from local and remote instances.
+ config: |
+ jobs:
+ - name: local
+ address: 127.0.0.1:3493
+
+ - name: remote
+ address: 203.0.113.0:3493
+ troubleshooting:
+ problems:
+ list: []
+ alerts:
+ - name: upsd_10min_ups_load
+ metric: upsd.ups_load
+ info: "UPS ${label:ups_name} average load over the last 10 minutes"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf
+ - name: upsd_ups_battery_charge
+ metric: upsd.ups_battery_charge
+ info: "UPS ${label:ups_name} average battery charge over the last minute"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf
+ - name: upsd_ups_last_collected_secs
+ metric: upsd.ups_load
+ info: "UPS ${label:ups_name} number of seconds since the last successful data collection"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/upsd.conf
+ metrics:
+ folding:
+ title: Metrics
+ enabled: false
+ description: ""
+ availability: []
+ scopes:
+ - name: ups
+ description: These metrics refer to the UPS unit.
+ labels:
+ - name: ups_name
+ description: UPS name.
+ - name: battery_type
+ description: Battery type (chemistry). "battery.type" variable value.
+ - name: device_model
+ description: Device model. "device.mode" variable value.
+ - name: device_serial
+ description: Device serial number. "device.serial" variable value.
+ - name: device_manufacturer
+ description: Device manufacturer. "device.mfr" variable value.
+ - name: device_type
+ description: Device type (ups, pdu, scd, psu, ats). "device.type" variable value.
+ metrics:
+ - name: upsd.ups_load
+ description: UPS load
+ unit: percentage
+ chart_type: area
+ dimensions:
+ - name: load
+ - name: upsd.ups_load_usage
+ description: UPS load usage (power output)
+ unit: Watts
+ chart_type: line
+ dimensions:
+ - name: load_usage
+ - name: upsd.ups_status
+ description: UPS status
+ unit: status
+ chart_type: line
+ dimensions:
+ - name: on_line
+ - name: on_battery
+ - name: low_battery
+ - name: high_battery
+ - name: replace_battery
+ - name: charging
+ - name: discharging
+ - name: bypass
+ - name: calibration
+ - name: offline
+ - name: overloaded
+ - name: trim_input_voltage
+ - name: boost_input_voltage
+ - name: forced_shutdown
+ - name: other
+ - name: upsd.ups_temperature
+ description: UPS temperature
+ unit: Celsius
+ chart_type: line
+ dimensions:
+ - name: temperature
+ - name: upsd.ups_battery_charge
+ description: UPS Battery charge
+ unit: percentage
+ chart_type: area
+ dimensions:
+ - name: charge
+ - name: upsd.ups_battery_estimated_runtime
+ description: UPS Battery estimated runtime
+ unit: seconds
+ chart_type: line
+ dimensions:
+ - name: runtime
+ - name: upsd.ups_battery_voltage
+ description: UPS Battery voltage
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: voltage
+ - name: upsd.ups_battery_voltage_nominal
+ description: UPS Battery voltage nominal
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: nominal_voltage
+ - name: upsd.ups_input_voltage
+ description: UPS Input voltage
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: voltage
+ - name: upsd.ups_input_voltage_nominal
+ description: UPS Input voltage nominal
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: nominal_voltage
+ - name: upsd.ups_input_current
+ description: UPS Input current
+ unit: Ampere
+ chart_type: line
+ dimensions:
+ - name: current
+ - name: upsd.ups_input_current_nominal
+ description: UPS Input current nominal
+ unit: Ampere
+ chart_type: line
+ dimensions:
+ - name: nominal_current
+ - name: upsd.ups_input_frequency
+ description: UPS Input frequency
+ unit: Hz
+ chart_type: line
+ dimensions:
+ - name: frequency
+ - name: upsd.ups_input_frequency_nominal
+ description: UPS Input frequency nominal
+ unit: Hz
+ chart_type: line
+ dimensions:
+ - name: nominal_frequency
+ - name: upsd.ups_output_voltage
+ description: UPS Output voltage
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: voltage
+ - name: upsd.ups_output_voltage_nominal
+ description: UPS Output voltage nominal
+ unit: Volts
+ chart_type: line
+ dimensions:
+ - name: nominal_voltage
+ - name: upsd.ups_output_current
+ description: UPS Output current
+ unit: Ampere
+ chart_type: line
+ dimensions:
+ - name: current
+ - name: upsd.ups_output_current_nominal
+ description: UPS Output current nominal
+ unit: Ampere
+ chart_type: line
+ dimensions:
+ - name: nominal_current
+ - name: upsd.ups_output_frequency
+ description: UPS Output frequency
+ unit: Hz
+ chart_type: line
+ dimensions:
+ - name: frequency
+ - name: upsd.ups_output_frequency_nominal
+ description: UPS Output frequency nominal
+ unit: Hz
+ chart_type: line
+ dimensions:
+ - name: nominal_frequency
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.json b/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.json
new file mode 100644
index 000000000..ab7a8654c
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.json
@@ -0,0 +1,7 @@
+{
+ "update_every": 123,
+ "address": "ok",
+ "username": "ok",
+ "password": "ok",
+ "timeout": 123.123
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.yaml b/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.yaml
new file mode 100644
index 000000000..276370415
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/testdata/config.yaml
@@ -0,0 +1,5 @@
+update_every: 123
+address: "ok"
+username: "ok"
+password: "ok"
+timeout: 123.123
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/upsd.go b/src/go/collectors/go.d.plugin/modules/upsd/upsd.go
new file mode 100644
index 000000000..be734bc5a
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/upsd.go
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+import (
+ _ "embed"
+ "errors"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+//go:embed "config_schema.json"
+var configSchema string
+
+func init() {
+ module.Register("upsd", module.Creator{
+ JobConfigSchema: configSchema,
+ Create: func() module.Module { return New() },
+ Config: func() any { return &Config{} },
+ })
+}
+
+func New() *Upsd {
+ return &Upsd{
+ Config: Config{
+ Address: "127.0.0.1:3493",
+ Timeout: web.Duration(time.Second * 2),
+ },
+ newUpsdConn: newUpsdConn,
+ charts: &module.Charts{},
+ upsUnits: make(map[string]bool),
+ }
+}
+
+type Config struct {
+ UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
+ Address string `yaml:"address" json:"address"`
+ Timeout web.Duration `yaml:"timeout,omitempty" json:"timeout"`
+ Username string `yaml:"username,omitempty" json:"username"`
+ Password string `yaml:"password,omitempty" json:"password"`
+}
+
+type (
+ Upsd struct {
+ module.Base
+ Config `yaml:",inline" json:""`
+
+ charts *module.Charts
+
+ conn upsdConn
+ newUpsdConn func(Config) upsdConn
+
+ upsUnits map[string]bool
+ }
+
+ upsdConn interface {
+ connect() error
+ disconnect() error
+ authenticate(string, string) error
+ upsUnits() ([]upsUnit, error)
+ }
+)
+
+func (u *Upsd) Configuration() any {
+ return u.Config
+}
+
+func (u *Upsd) Init() error {
+ if u.Address == "" {
+ u.Error("config: 'address' not set")
+ return errors.New("address not set")
+ }
+
+ return nil
+}
+
+func (u *Upsd) Check() error {
+ mx, err := u.collect()
+ if err != nil {
+ u.Error(err)
+ return err
+ }
+ if len(mx) == 0 {
+ return errors.New("no metrics collected")
+ }
+ return nil
+}
+
+func (u *Upsd) Charts() *module.Charts {
+ return u.charts
+}
+
+func (u *Upsd) Collect() map[string]int64 {
+ mx, err := u.collect()
+ if err != nil {
+ u.Error(err)
+ }
+
+ if len(mx) == 0 {
+ return nil
+ }
+ return mx
+}
+
+func (u *Upsd) Cleanup() {
+ if u.conn == nil {
+ return
+ }
+ if err := u.conn.disconnect(); err != nil {
+ u.Warningf("error on disconnect: %v", err)
+ }
+ u.conn = nil
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/upsd_test.go b/src/go/collectors/go.d.plugin/modules/upsd/upsd_test.go
new file mode 100644
index 000000000..1dffdd0f5
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/upsd_test.go
@@ -0,0 +1,446 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
+)
+
+func Test_testDataIsValid(t *testing.T) {
+ for name, data := range map[string][]byte{
+ "dataConfigJSON": dataConfigJSON,
+ "dataConfigYAML": dataConfigYAML,
+ } {
+ require.NotNil(t, data, name)
+ }
+}
+
+func TestUpsd_ConfigurationSerialize(t *testing.T) {
+ module.TestConfigurationSerialize(t, &Upsd{}, dataConfigJSON, dataConfigYAML)
+}
+
+func TestUpsd_Cleanup(t *testing.T) {
+ upsd := New()
+
+ require.NotPanics(t, upsd.Cleanup)
+
+ mock := prepareMockConnOK()
+ upsd.newUpsdConn = func(Config) upsdConn { return mock }
+
+ require.NoError(t, upsd.Init())
+ _ = upsd.Collect()
+ require.NotPanics(t, upsd.Cleanup)
+ assert.True(t, mock.calledDisconnect)
+}
+
+func TestUpsd_Init(t *testing.T) {
+ tests := map[string]struct {
+ config Config
+ wantFail bool
+ }{
+ "success on default config": {
+ wantFail: false,
+ config: New().Config,
+ },
+ "fails when 'address' option not set": {
+ wantFail: true,
+ config: Config{Address: ""},
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ upsd := New()
+ upsd.Config = test.config
+
+ if test.wantFail {
+ assert.Error(t, upsd.Init())
+ } else {
+ assert.NoError(t, upsd.Init())
+ }
+ })
+ }
+}
+
+func TestUpsd_Check(t *testing.T) {
+ tests := map[string]struct {
+ prepareUpsd func() *Upsd
+ prepareMock func() *mockUpsdConn
+ wantFail bool
+ }{
+ "successful data collection": {
+ wantFail: false,
+ prepareUpsd: New,
+ prepareMock: prepareMockConnOK,
+ },
+ "error on connect()": {
+ wantFail: true,
+ prepareUpsd: New,
+ prepareMock: prepareMockConnErrOnConnect,
+ },
+ "error on authenticate()": {
+ wantFail: true,
+ prepareUpsd: func() *Upsd {
+ upsd := New()
+ upsd.Username = "user"
+ upsd.Password = "pass"
+ return upsd
+ },
+ prepareMock: prepareMockConnErrOnAuthenticate,
+ },
+ "error on upsList()": {
+ wantFail: true,
+ prepareUpsd: New,
+ prepareMock: prepareMockConnErrOnUpsUnits,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ upsd := test.prepareUpsd()
+ upsd.newUpsdConn = func(Config) upsdConn { return test.prepareMock() }
+
+ require.NoError(t, upsd.Init())
+
+ if test.wantFail {
+ assert.Error(t, upsd.Check())
+ } else {
+ assert.NoError(t, upsd.Check())
+ }
+ })
+ }
+}
+
+func TestUpsd_Charts(t *testing.T) {
+ upsd := New()
+ require.NoError(t, upsd.Init())
+ assert.NotNil(t, upsd.Charts())
+}
+
+func TestUpsd_Collect(t *testing.T) {
+ tests := map[string]struct {
+ prepareUpsd func() *Upsd
+ prepareMock func() *mockUpsdConn
+ wantCollected map[string]int64
+ wantCharts int
+ wantConnConnect bool
+ wantConnDisconnect bool
+ wantConnAuthenticate bool
+ }{
+ "successful data collection": {
+ prepareUpsd: New,
+ prepareMock: prepareMockConnOK,
+ wantCollected: map[string]int64{
+ "ups_cp1500_battery.charge": 10000,
+ "ups_cp1500_battery.runtime": 489000,
+ "ups_cp1500_battery.voltage": 2400,
+ "ups_cp1500_battery.voltage.nominal": 2400,
+ "ups_cp1500_input.voltage": 22700,
+ "ups_cp1500_input.voltage.nominal": 23000,
+ "ups_cp1500_output.voltage": 26000,
+ "ups_cp1500_ups.load": 800,
+ "ups_cp1500_ups.load.usage": 4300,
+ "ups_cp1500_ups.realpower.nominal": 90000,
+ "ups_cp1500_ups.status.BOOST": 0,
+ "ups_cp1500_ups.status.BYPASS": 0,
+ "ups_cp1500_ups.status.CAL": 0,
+ "ups_cp1500_ups.status.CHRG": 0,
+ "ups_cp1500_ups.status.DISCHRG": 0,
+ "ups_cp1500_ups.status.FSD": 0,
+ "ups_cp1500_ups.status.HB": 0,
+ "ups_cp1500_ups.status.LB": 0,
+ "ups_cp1500_ups.status.OB": 0,
+ "ups_cp1500_ups.status.OFF": 0,
+ "ups_cp1500_ups.status.OL": 1,
+ "ups_cp1500_ups.status.OVER": 0,
+ "ups_cp1500_ups.status.RB": 0,
+ "ups_cp1500_ups.status.TRIM": 0,
+ "ups_cp1500_ups.status.other": 0,
+ "ups_pr3000_battery.charge": 10000,
+ "ups_pr3000_battery.runtime": 110800,
+ "ups_pr3000_battery.voltage": 5990,
+ "ups_pr3000_battery.voltage.nominal": 4800,
+ "ups_pr3000_input.voltage": 22500,
+ "ups_pr3000_input.voltage.nominal": 23000,
+ "ups_pr3000_output.voltage": 22500,
+ "ups_pr3000_ups.load": 2800,
+ "ups_pr3000_ups.load.usage": 84000,
+ "ups_pr3000_ups.realpower.nominal": 300000,
+ "ups_pr3000_ups.status.BOOST": 0,
+ "ups_pr3000_ups.status.BYPASS": 0,
+ "ups_pr3000_ups.status.CAL": 0,
+ "ups_pr3000_ups.status.CHRG": 0,
+ "ups_pr3000_ups.status.DISCHRG": 0,
+ "ups_pr3000_ups.status.FSD": 0,
+ "ups_pr3000_ups.status.HB": 0,
+ "ups_pr3000_ups.status.LB": 0,
+ "ups_pr3000_ups.status.OB": 0,
+ "ups_pr3000_ups.status.OFF": 0,
+ "ups_pr3000_ups.status.OL": 1,
+ "ups_pr3000_ups.status.OVER": 0,
+ "ups_pr3000_ups.status.RB": 0,
+ "ups_pr3000_ups.status.TRIM": 0,
+ "ups_pr3000_ups.status.other": 0,
+ },
+ wantCharts: 20,
+ wantConnConnect: true,
+ wantConnDisconnect: false,
+ wantConnAuthenticate: false,
+ },
+ "error on connect()": {
+ prepareUpsd: New,
+ prepareMock: prepareMockConnErrOnConnect,
+ wantCollected: nil,
+ wantCharts: 0,
+ wantConnConnect: true,
+ wantConnDisconnect: false,
+ wantConnAuthenticate: false,
+ },
+ "error on authenticate()": {
+ prepareUpsd: func() *Upsd {
+ upsd := New()
+ upsd.Username = "user"
+ upsd.Password = "pass"
+ return upsd
+ },
+ prepareMock: prepareMockConnErrOnAuthenticate,
+ wantCollected: nil,
+ wantCharts: 0,
+ wantConnConnect: true,
+ wantConnDisconnect: true,
+ wantConnAuthenticate: true,
+ },
+ "err on upsList()": {
+ prepareUpsd: New,
+ prepareMock: prepareMockConnErrOnUpsUnits,
+ wantCollected: nil,
+ wantCharts: 0,
+ wantConnConnect: true,
+ wantConnDisconnect: true,
+ wantConnAuthenticate: false,
+ },
+ "command err on upsList() (unknown ups)": {
+ prepareUpsd: New,
+ prepareMock: prepareMockConnCommandErrOnUpsUnits,
+ wantCollected: nil,
+ wantCharts: 0,
+ wantConnConnect: true,
+ wantConnDisconnect: false,
+ wantConnAuthenticate: false,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ upsd := test.prepareUpsd()
+ require.NoError(t, upsd.Init())
+
+ mock := test.prepareMock()
+ upsd.newUpsdConn = func(Config) upsdConn { return mock }
+
+ mx := upsd.Collect()
+
+ assert.Equal(t, test.wantCollected, mx)
+ assert.Equalf(t, test.wantCharts, len(*upsd.Charts()), "number of charts")
+ if len(test.wantCollected) > 0 {
+ ensureCollectedHasAllChartsDims(t, upsd, mx)
+ }
+ assert.Equalf(t, test.wantConnConnect, mock.calledConnect, "calledConnect")
+ assert.Equalf(t, test.wantConnDisconnect, mock.calledDisconnect, "calledDisconnect")
+ assert.Equal(t, test.wantConnAuthenticate, mock.calledAuthenticate, "calledAuthenticate")
+ })
+ }
+}
+
+func ensureCollectedHasAllChartsDims(t *testing.T, upsd *Upsd, mx map[string]int64) {
+ for _, chart := range *upsd.Charts() {
+ if chart.Obsolete {
+ continue
+ }
+ for _, dim := range chart.Dims {
+ _, ok := mx[dim.ID]
+ assert.Truef(t, ok, "collected metrics has no data for dim '%s' chart '%s'", dim.ID, chart.ID)
+ }
+ for _, v := range chart.Vars {
+ _, ok := mx[v.ID]
+ assert.Truef(t, ok, "collected metrics has no data for var '%s' chart '%s'", v.ID, chart.ID)
+ }
+ }
+}
+
+func prepareMockConnOK() *mockUpsdConn {
+ return &mockUpsdConn{}
+}
+
+func prepareMockConnErrOnConnect() *mockUpsdConn {
+ return &mockUpsdConn{errOnConnect: true}
+}
+
+func prepareMockConnErrOnAuthenticate() *mockUpsdConn {
+ return &mockUpsdConn{errOnAuthenticate: true}
+}
+
+func prepareMockConnErrOnUpsUnits() *mockUpsdConn {
+ return &mockUpsdConn{errOnUpsUnits: true}
+}
+
+func prepareMockConnCommandErrOnUpsUnits() *mockUpsdConn {
+ return &mockUpsdConn{commandErrOnUpsUnits: true}
+}
+
+type mockUpsdConn struct {
+ errOnConnect bool
+ errOnDisconnect bool
+ errOnAuthenticate bool
+ errOnUpsUnits bool
+ commandErrOnUpsUnits bool
+
+ calledConnect bool
+ calledDisconnect bool
+ calledAuthenticate bool
+}
+
+func (m *mockUpsdConn) connect() error {
+ m.calledConnect = true
+ if m.errOnConnect {
+ return errors.New("mock error on connect()")
+ }
+ return nil
+}
+
+func (m *mockUpsdConn) disconnect() error {
+ m.calledDisconnect = true
+ if m.errOnDisconnect {
+ return errors.New("mock error on disconnect()")
+ }
+ return nil
+}
+
+func (m *mockUpsdConn) authenticate(_, _ string) error {
+ m.calledAuthenticate = true
+ if m.errOnAuthenticate {
+ return errors.New("mock error on authenticate()")
+ }
+ return nil
+}
+
+func (m *mockUpsdConn) upsUnits() ([]upsUnit, error) {
+ if m.errOnUpsUnits {
+ return nil, errors.New("mock error on upsUnits()")
+ }
+ if m.commandErrOnUpsUnits {
+ return nil, fmt.Errorf("%w: mock command error on upsUnits()", errUpsdCommand)
+ }
+
+ upsUnits := []upsUnit{
+ {
+ name: "pr3000",
+ vars: map[string]string{
+ "battery.charge": "100",
+ "battery.charge.warning": "35",
+ "battery.mfr.date": "CPS",
+ "battery.runtime": "1108",
+ "battery.runtime.low": "300",
+ "battery.type": "PbAcid",
+ "battery.voltage": "59.9",
+ "battery.voltage.nominal": "48",
+ "device.mfr": "CPS",
+ "device.model": "PR3000ERT2U",
+ "device.serial": "P11MQ2000041",
+ "device.type": "ups",
+ "driver.name": "usbhid-ups",
+ "driver.parameter.pollfreq": "30",
+ "driver.parameter.pollinterval": "2",
+ "driver.parameter.port": "auto",
+ "driver.parameter.synchronous": "no",
+ "driver.version": "2.7.4",
+ "driver.version.data": "CyberPower HID 0.4",
+ "driver.version.internal": "0.41",
+ "input.voltage": "225.0",
+ "input.voltage.nominal": "230",
+ "output.voltage": "225.0",
+ "ups.beeper.status": "enabled",
+ "ups.delay.shutdown": "20",
+ "ups.delay.start": "30",
+ "ups.load": "28",
+ "ups.mfr": "CPS",
+ "ups.model": "PR3000ERT2U",
+ "ups.productid": "0601",
+ "ups.realpower.nominal": "3000",
+ "ups.serial": "P11MQ2000041",
+ "ups.status": "OL",
+ "ups.test.result": "No test initiated",
+ "ups.timer.shutdown": "0",
+ "ups.timer.start": "0",
+ "ups.vendorid": "0764",
+ },
+ },
+ {
+ name: "cp1500",
+ vars: map[string]string{
+ "battery.charge": "100",
+ "battery.charge.low": "10",
+ "battery.charge.warning": "20",
+ "battery.mfr.date": "CPS",
+ "battery.runtime": "4890",
+ "battery.runtime.low": "300",
+ "battery.type": "PbAcid",
+ "battery.voltage": "24.0",
+ "battery.voltage.nominal": "24",
+ "device.mfr": "CPS",
+ "device.model": "CP1500EPFCLCD",
+ "device.serial": "CRMNO2000312",
+ "device.type": "ups",
+ "driver.name": "usbhid-ups",
+ "driver.parameter.bus": "001",
+ "driver.parameter.pollfreq": "30",
+ "driver.parameter.pollinterval": "2",
+ "driver.parameter.port": "auto",
+ "driver.parameter.product": "CP1500EPFCLCD",
+ "driver.parameter.productid": "0501",
+ "driver.parameter.serial": "CRMNO2000312",
+ "driver.parameter.synchronous": "no",
+ "driver.parameter.vendor": "CPS",
+ "driver.parameter.vendorid": "0764",
+ "driver.version": "2.7.4",
+ "driver.version.data": "CyberPower HID 0.4",
+ "driver.version.internal": "0.41",
+ "input.transfer.high": "260",
+ "input.transfer.low": "170",
+ "input.voltage": "227.0",
+ "input.voltage.nominal": "230",
+ "output.voltage": "260.0",
+ "ups.beeper.status": "enabled",
+ "ups.delay.shutdown": "20",
+ "ups.delay.start": "30",
+ "ups.load": "8",
+ "ups.mfr": "CPS",
+ "ups.model": "CP1500EPFCLCD",
+ "ups.productid": "0501",
+ "ups.realpower": "43",
+ "ups.realpower.nominal": "900",
+ "ups.serial": "CRMNO2000312",
+ "ups.status": "OL",
+ "ups.test.result": "No test initiated",
+ "ups.timer.shutdown": "-60",
+ "ups.timer.start": "-60",
+ "ups.vendorid": "0764",
+ },
+ },
+ }
+
+ return upsUnits, nil
+}
diff --git a/src/go/collectors/go.d.plugin/modules/upsd/variables.go b/src/go/collectors/go.d.plugin/modules/upsd/variables.go
new file mode 100644
index 000000000..9792e62b9
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/upsd/variables.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package upsd
+
+const varPrecision = 100
+
+// https://networkupstools.org/docs/developer-guide.chunked/apas02.html
+const (
+ varBatteryCharge = "battery.charge"
+ varBatteryRuntime = "battery.runtime"
+ varBatteryVoltage = "battery.voltage"
+ varBatteryVoltageNominal = "battery.voltage.nominal"
+ varBatteryType = "battery.type"
+
+ varInputVoltage = "input.voltage"
+ varInputVoltageNominal = "input.voltage.nominal"
+ varInputCurrent = "input.current"
+ varInputCurrentNominal = "input.current.nominal"
+ varInputFrequency = "input.frequency"
+ varInputFrequencyNominal = "input.frequency.nominal"
+
+ varOutputVoltage = "output.voltage"
+ varOutputVoltageNominal = "output.voltage.nominal"
+ varOutputCurrent = "output.current"
+ varOutputCurrentNominal = "output.current.nominal"
+ varOutputFrequency = "output.frequency"
+ varOutputFrequencyNominal = "output.frequency.nominal"
+
+ varUpsLoad = "ups.load"
+ varUpsRealPower = "ups.realpower"
+ varUpsRealPowerNominal = "ups.realpower.nominal"
+ varUpsTemperature = "ups.temperature"
+ varUpsStatus = "ups.status"
+
+ varDeviceModel = "device.model"
+ varDeviceSerial = "device.serial"
+ varDeviceMfr = "device.mfr"
+ varDeviceType = "device.type"
+)