Add input plugin for Neptune Apex aquarium controller (#5191)

This commit is contained in:
Max Renaud 2019-01-08 15:02:32 -08:00 committed by Daniel Nelson
parent 8538894690
commit 4125e4161c
4 changed files with 1038 additions and 0 deletions

View File

@ -79,6 +79,7 @@ import (
_ "github.com/influxdata/telegraf/plugins/inputs/mysql"
_ "github.com/influxdata/telegraf/plugins/inputs/nats"
_ "github.com/influxdata/telegraf/plugins/inputs/nats_consumer"
_ "github.com/influxdata/telegraf/plugins/inputs/neptune_apex"
_ "github.com/influxdata/telegraf/plugins/inputs/net"
_ "github.com/influxdata/telegraf/plugins/inputs/net_response"
_ "github.com/influxdata/telegraf/plugins/inputs/nginx"

View File

@ -0,0 +1,144 @@
# neptune_apex Input Plugin
The neptune_apex Input Plugin collects real-time data from the Apex's status.xml page.
### Configuration
```toml
[[inputs.neptune_apex]]
## The Neptune Apex plugin reads the publicly available status.xml data from a local Apex.
## Measurements will be logged under "apex".
## The base URL of the local Apex(es). If you specify more than one server, they will
## be differentiated by the "source" tag.
servers = [
"http://apex.local",
]
## The response_timeout specifies how long to wait for a reply from the Apex.
#response_timeout = "5s"
```
### Metrics
The [Neptune Apex](https://www.neptunesystems.com/) controller family allows an aquarium hobbyist to monitor and control
their tanks based on various probes. The data is taken directly from the /cgi-bin/status.xml at the interval specified
in the telegraf.conf configuration file.
No manipulation is done on any of the fields to ensure future changes to the status.xml do not introduce conversion bugs
to this plugin. When reasonable and predictable, some tags are derived to make graphing easier and without front-end
programming. These tags are clearly marked in the list below and should be considered a convenience rather than authoritative.
- neptune_apex (All metrics have this measurement name)
- tags:
- host (mandatory, string) is the host on which telegraf runs.
- source (mandatory, string) contains the hostname of the apex device. This can be used to differentiate between
different units. By using the source instead of the serial number, replacements units won't disturb graphs.
- type (mandatory, string) maps to the different types of data. Values can be "controller" (The Apex controller
itself), "probe" for the different input probes, or "output" for any physical or virtual outputs. The Watt and Amp
probes attached to the physical 120V outlets are aggregated under the output type.
- hardware (mandatory, string) controller hardware version
- software (mandatory, string) software version
- probe_type (optional, string) contains the probe type as reported by the Apex.
- name (optional, string) contains the name of the probe or output.
- output_id (optional, string) represents the internal unique output ID. This is different from the device_id.
- device_id (optional, string) maps to either the aquabus address or the internal reference.
- output_type (optional, string) categorizes the output into different categories. This tag is DERIVED from the
device_id. Possible values are: "variable" for the 0-10V signal ports, "outlet" for physical 120V sockets, "alert"
for alarms (email, sound), "virtual" for user-defined outputs, and "unknown" for everything else.
- fields:
- value (float, various unit) represents the probe reading.
- state (string) represents the output state as defined by the Apex. Examples include "AOF" for Auto (OFF), "TBL"
for operating according to a table, and "PF*" for different programs.
- amp (float, Ampere) is the amount of current flowing through the 120V outlet.
- watt (float, Watt) represents the amount of energy flowing through the 120V outlet.
- xstatus (string) indicates the xstatus of an outlet. Found on wireless Vortech devices.
- power_failed (int64, Unix epoch in ns) when the controller last lost power.
- power_restored (int64, Unix epoch in ns) when the controller last powered on.
- serial (string, serial number)
- time:
- The time used for the metric is parsed from the status.xml page. This helps when cross-referencing events with
the local system of Apex Fusion. Since the Apex uses NTP, this should not matter in most scenarios.
### Sample Queries
Get the max, mean, and min for the temperature in the last hour:
```
SELECT mean("value") FROM "neptune_apex" WHERE ("probe_type" = 'Temp') AND time >= now() - 6h GROUP BY time(20s)
```
### Troubleshooting
#### sendRequest failure
This indicates a problem communicating with the local Apex controller. If on Mac/Linux, try curl:
```
$ curl apex.local/cgi-bin/status.xml
```
to isolate the problem.
#### parseXML errors
Ensure the XML being returned is valid. If you get valid XML back, open a bug request.
#### Missing fields/data
The neptune_apex plugin is strict on its input to prevent any conversion errors. If you have fields in the status.xml
output that are not converted to a metric, open a feature request and paste your whole status.xml
### Example Output
```
> neptune_apex,hardware=1.0,host=ubuntu,software=5.04_7A18,source=apex,type=controller power_failed=1544814000000000000i,power_restored=1544833875000000000i,serial="AC5:12345" 1545978278000000000
> neptune_apex,device_id=base_Var1,hardware=1.0,host=ubuntu,name=VarSpd1_I1,output_id=0,output_type=variable,software=5.04_7A18,source=apex,type=output state="PF1" 1545978278000000000
> neptune_apex,device_id=base_Var2,hardware=1.0,host=ubuntu,name=VarSpd2_I2,output_id=1,output_type=variable,software=5.04_7A18,source=apex,type=output state="PF2" 1545978278000000000
> neptune_apex,device_id=base_Var3,hardware=1.0,host=ubuntu,name=VarSpd3_I3,output_id=2,output_type=variable,software=5.04_7A18,source=apex,type=output state="PF3" 1545978278000000000
> neptune_apex,device_id=base_Var4,hardware=1.0,host=ubuntu,name=VarSpd4_I4,output_id=3,output_type=variable,software=5.04_7A18,source=apex,type=output state="PF4" 1545978278000000000
> neptune_apex,device_id=base_Alarm,hardware=1.0,host=ubuntu,name=SndAlm_I6,output_id=4,output_type=alert,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=base_Warn,hardware=1.0,host=ubuntu,name=SndWrn_I7,output_id=5,output_type=alert,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=base_email,hardware=1.0,host=ubuntu,name=EmailAlm_I5,output_id=6,output_type=alert,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=base_email2,hardware=1.0,host=ubuntu,name=Email2Alm_I9,output_id=7,output_type=alert,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=2_1,hardware=1.0,host=ubuntu,name=RETURN_2_1,output_id=8,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0.3,state="AON",watt=34 1545978278000000000
> neptune_apex,device_id=2_2,hardware=1.0,host=ubuntu,name=Heater1_2_2,output_id=9,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AOF",watt=0 1545978278000000000
> neptune_apex,device_id=2_3,hardware=1.0,host=ubuntu,name=FREE_2_3,output_id=10,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="OFF",watt=1 1545978278000000000
> neptune_apex,device_id=2_4,hardware=1.0,host=ubuntu,name=LIGHT_2_4,output_id=11,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="OFF",watt=1 1545978278000000000
> neptune_apex,device_id=2_5,hardware=1.0,host=ubuntu,name=LHead_2_5,output_id=12,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=4 1545978278000000000
> neptune_apex,device_id=2_6,hardware=1.0,host=ubuntu,name=SKIMMER_2_6,output_id=13,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0.1,state="AON",watt=12 1545978278000000000
> neptune_apex,device_id=2_7,hardware=1.0,host=ubuntu,name=FREE_2_7,output_id=14,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="OFF",watt=1 1545978278000000000
> neptune_apex,device_id=2_8,hardware=1.0,host=ubuntu,name=CABLIGHT_2_8,output_id=15,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=1 1545978278000000000
> neptune_apex,device_id=2_9,hardware=1.0,host=ubuntu,name=LinkA_2_9,output_id=16,output_type=unknown,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=2_10,hardware=1.0,host=ubuntu,name=LinkB_2_10,output_id=17,output_type=unknown,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=3_1,hardware=1.0,host=ubuntu,name=RVortech_3_1,output_id=18,output_type=unknown,software=5.04_7A18,source=apex,type=output state="TBL",xstatus="OK" 1545978278000000000
> neptune_apex,device_id=3_2,hardware=1.0,host=ubuntu,name=LVortech_3_2,output_id=19,output_type=unknown,software=5.04_7A18,source=apex,type=output state="TBL",xstatus="OK" 1545978278000000000
> neptune_apex,device_id=4_1,hardware=1.0,host=ubuntu,name=OSMOLATO_4_1,output_id=20,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AOF",watt=0 1545978278000000000
> neptune_apex,device_id=4_2,hardware=1.0,host=ubuntu,name=HEATER2_4_2,output_id=21,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AOF",watt=0 1545978278000000000
> neptune_apex,device_id=4_3,hardware=1.0,host=ubuntu,name=NUC_4_3,output_id=22,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0.1,state="AON",watt=8 1545978278000000000
> neptune_apex,device_id=4_4,hardware=1.0,host=ubuntu,name=CABFAN_4_4,output_id=23,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=1 1545978278000000000
> neptune_apex,device_id=4_5,hardware=1.0,host=ubuntu,name=RHEAD_4_5,output_id=24,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=3 1545978278000000000
> neptune_apex,device_id=4_6,hardware=1.0,host=ubuntu,name=FIRE_4_6,output_id=25,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=3 1545978278000000000
> neptune_apex,device_id=4_7,hardware=1.0,host=ubuntu,name=LightGW_4_7,output_id=26,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=1 1545978278000000000
> neptune_apex,device_id=4_8,hardware=1.0,host=ubuntu,name=GBSWITCH_4_8,output_id=27,output_type=outlet,software=5.04_7A18,source=apex,type=output amp=0,state="AON",watt=0 1545978278000000000
> neptune_apex,device_id=4_9,hardware=1.0,host=ubuntu,name=LinkA_4_9,output_id=28,output_type=unknown,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=4_10,hardware=1.0,host=ubuntu,name=LinkB_4_10,output_id=29,output_type=unknown,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=5_1,hardware=1.0,host=ubuntu,name=LinkA_5_1,output_id=30,output_type=unknown,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=Cntl_A1,hardware=1.0,host=ubuntu,name=ATO_EMPTY,output_id=31,output_type=virtual,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=Cntl_A2,hardware=1.0,host=ubuntu,name=LEAK,output_id=32,output_type=virtual,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,device_id=Cntl_A3,hardware=1.0,host=ubuntu,name=SKMR_NOPWR,output_id=33,output_type=virtual,software=5.04_7A18,source=apex,type=output state="AOF" 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=Tmp,probe_type=Temp,software=5.04_7A18,source=apex,type=probe value=78.1 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=pH,probe_type=pH,software=5.04_7A18,source=apex,type=probe value=7.93 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=ORP,probe_type=ORP,software=5.04_7A18,source=apex,type=probe value=191 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=Salt,probe_type=Cond,software=5.04_7A18,source=apex,type=probe value=29.4 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=Volt_2,software=5.04_7A18,source=apex,type=probe value=117 1545978278000000000
> neptune_apex,hardware=1.0,host=ubuntu,name=Volt_4,software=5.04_7A18,source=apex,type=probe value=118 1545978278000000000
```
### Contributing
This plugin is used for mission-critical aquatic life support. A bug could very well result in the death of animals.
Neptune does not publish a schema file and as such, we have made this plugin very strict on input with no provisions for
automatically adding fields. We are also careful to not add default values when none are presented to prevent automation
errors.
When writing unit tests, use actual Apex output to run tests. It's acceptable to abridge the number of repeated fields
but never inner fields or parameters.

View File

@ -0,0 +1,300 @@
// Package neptuneapex implements an input plugin for the Neptune Apex
// aquarium controller.
package neptuneapex
import (
"encoding/xml"
"fmt"
"io/ioutil"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/internal"
"github.com/influxdata/telegraf/plugins/inputs"
)
// Measurement is constant across all metrics.
const Measurement = "neptune_apex"
type xmlReply struct {
SoftwareVersion string `xml:"software,attr"`
HardwareVersion string `xml:"hardware,attr"`
Hostname string `xml:"hostname"`
Serial string `xml:"serial"`
Timezone float64 `xml:"timezone"`
Date string `xml:"date"`
PowerFailed string `xml:"power>failed"`
PowerRestored string `xml:"power>restored"`
Probe []probe `xml:"probes>probe"`
Outlet []outlet `xml:"outlets>outlet"`
}
type probe struct {
Name string `xml:"name"`
Value string `xml:"value"`
Type *string `xml:"type"`
}
type outlet struct {
Name string `xml:"name"`
OutputID string `xml:"outputID"`
State string `xml:"state"`
DeviceID string `xml:"deviceID"`
Xstatus *string `xml:"xstatus"`
}
// NeptuneApex implements telegraf.Input.
type NeptuneApex struct {
Servers []string
ResponseTimeout internal.Duration
httpClient *http.Client
}
// Description implements telegraf.Input.Description
func (*NeptuneApex) Description() string {
return "Neptune Apex data collector"
}
// SampleConfig implements telegraf.Input.SampleConfig
func (*NeptuneApex) SampleConfig() string {
return `
## The Neptune Apex plugin reads the publicly available status.xml data from a local Apex.
## Measurements will be logged under "apex".
## The base URL of the local Apex(es). If you specify more than one server, they will
## be differentiated by the "source" tag.
servers = [
"http://apex.local",
]
## The response_timeout specifies how long to wait for a reply from the Apex.
#response_timeout = "5s"
`
}
// Gather implements telegraf.Input.Gather
func (n *NeptuneApex) Gather(acc telegraf.Accumulator) error {
var wg sync.WaitGroup
for _, server := range n.Servers {
wg.Add(1)
go func(server string) {
defer wg.Done()
acc.AddError(n.gatherServer(acc, server))
}(server)
}
wg.Wait()
return nil
}
func (n *NeptuneApex) gatherServer(
acc telegraf.Accumulator, server string) error {
resp, err := n.sendRequest(server)
if err != nil {
return err
}
return n.parseXML(acc, resp)
}
// parseXML is strict on the input and does not do best-effort parsing.
//This is because of the life-support nature of the Neptune Apex.
func (n *NeptuneApex) parseXML(acc telegraf.Accumulator, data []byte) error {
r := xmlReply{}
err := xml.Unmarshal(data, &r)
if err != nil {
return fmt.Errorf("unable to unmarshal XML: %v\nXML DATA: %q",
err, data)
}
var reportTime time.Time
var powerFailed, powerRestored int64
if reportTime, err = parseTime(r.Date, r.Timezone); err != nil {
return err
}
if val, err := parseTime(r.PowerFailed, r.Timezone); err != nil {
return err
} else {
powerFailed = val.UnixNano()
}
if val, err := parseTime(r.PowerRestored, r.Timezone); err != nil {
return err
} else {
powerRestored = val.UnixNano()
}
mainFields := map[string]interface{}{
"serial": r.Serial,
"power_failed": powerFailed,
"power_restored": powerRestored,
}
acc.AddFields(Measurement, mainFields,
map[string]string{
"source": r.Hostname,
"type": "controller",
"software": r.SoftwareVersion,
"hardware": r.HardwareVersion,
},
reportTime)
// Outlets.
for _, o := range r.Outlet {
tags := map[string]string{
"source": r.Hostname,
"output_id": o.OutputID,
"device_id": o.DeviceID,
"name": o.Name,
"type": "output",
"software": r.SoftwareVersion,
"hardware": r.HardwareVersion,
}
fields := map[string]interface{}{
"state": o.State,
}
// Find Amp and Watt probes and add them as fields.
// Remove the redundant probe.
if pos := findProbe(fmt.Sprintf("%sW", o.Name), r.Probe); pos > -1 {
value, err := strconv.ParseFloat(
strings.TrimSpace(r.Probe[pos].Value), 64)
if err != nil {
acc.AddError(
fmt.Errorf(
"cannot convert string value %q to float64: %v",
r.Probe[pos].Value, err))
continue // Skip the whole outlet.
}
fields["watt"] = value
r.Probe[pos] = r.Probe[len(r.Probe)-1]
r.Probe = r.Probe[:len(r.Probe)-1]
}
if pos := findProbe(fmt.Sprintf("%sA", o.Name), r.Probe); pos > -1 {
value, err := strconv.ParseFloat(
strings.TrimSpace(r.Probe[pos].Value), 64)
if err != nil {
acc.AddError(
fmt.Errorf(
"cannot convert string value %q to float64: %v",
r.Probe[pos].Value, err))
break // // Skip the whole outlet.
}
fields["amp"] = value
r.Probe[pos] = r.Probe[len(r.Probe)-1]
r.Probe = r.Probe[:len(r.Probe)-1]
}
if o.Xstatus != nil {
fields["xstatus"] = *o.Xstatus
}
// Try to determine outlet type. Focus on accuracy, leaving the
//outlet_type "unknown" when ambiguous. 24v and vortech cannot be
// determined.
switch {
case strings.HasPrefix(o.DeviceID, "base_Var"):
tags["output_type"] = "variable"
case o.DeviceID == "base_Alarm":
fallthrough
case o.DeviceID == "base_Warn":
fallthrough
case strings.HasPrefix(o.DeviceID, "base_email"):
tags["output_type"] = "alert"
case fields["watt"] != nil || fields["amp"] != nil:
tags["output_type"] = "outlet"
case strings.HasPrefix(o.DeviceID, "Cntl_"):
tags["output_type"] = "virtual"
default:
tags["output_type"] = "unknown"
}
acc.AddFields(Measurement, fields, tags, reportTime)
}
// Probes.
for _, p := range r.Probe {
value, err := strconv.ParseFloat(strings.TrimSpace(p.Value), 64)
if err != nil {
acc.AddError(fmt.Errorf(
"cannot convert string value %q to float64: %v",
p.Value, err))
continue
}
fields := map[string]interface{}{
"value": value,
}
tags := map[string]string{
"source": r.Hostname,
"type": "probe",
"name": p.Name,
"software": r.SoftwareVersion,
"hardware": r.HardwareVersion,
}
if p.Type != nil {
tags["probe_type"] = *p.Type
}
acc.AddFields(Measurement, fields, tags, reportTime)
}
return nil
}
func findProbe(probe string, probes []probe) int {
for i, p := range probes {
if p.Name == probe {
return i
}
}
return -1
}
// parseTime takes a Neptune Apex date/time string with a timezone and
// returns a time.Time struct.
func parseTime(val string, tz float64) (time.Time, error) {
// Magic time constant from https://golang.org/pkg/time/#Parse
const TimeLayout = "01/02/2006 15:04:05 -0700"
// Timezone offset needs to be explicit
sign := '+'
if tz < 0 {
sign = '-'
}
// Build a time string with the timezone in a format Go can parse.
tzs := fmt.Sprintf("%c%04d", sign, int(math.Abs(tz))*100)
ts := fmt.Sprintf("%s %s", val, tzs)
t, err := time.Parse(TimeLayout, ts)
if err != nil {
return time.Now(), fmt.Errorf("unable to parse %q (%v)", ts, err)
}
return t, nil
}
func (n *NeptuneApex) sendRequest(server string) ([]byte, error) {
url := fmt.Sprintf("%s/cgi-bin/status.xml", server)
resp, err := n.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("http GET failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"response from server URL %q returned %d (%s), expected %d (%s)",
url, resp.StatusCode, http.StatusText(resp.StatusCode),
http.StatusOK, http.StatusText(http.StatusOK))
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read output from %q: %v", url, err)
}
return body, nil
}
func init() {
inputs.Add("neptune_apex", func() telegraf.Input {
return &NeptuneApex{
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
})
}

View File

@ -0,0 +1,593 @@
package neptuneapex
import (
"bytes"
"context"
"net"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/influxdata/telegraf/testutil"
)
func TestGather(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("data"))
})
c, destroy := fakeHTTPClient(h)
defer destroy()
n := &NeptuneApex{
httpClient: c,
}
tests := []struct {
name string
servers []string
}{
{
name: "Good case, 2 servers",
servers: []string{"http://abc", "https://def"},
},
{
name: "Good case, 0 servers",
servers: []string{},
},
{
name: "Good case nil",
servers: nil,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
var acc testutil.Accumulator
n.Servers = test.servers
n.Gather(&acc)
if len(acc.Errors) != len(test.servers) {
t.Errorf("Number of servers mismatch. got=%d, want=%d",
len(acc.Errors), len(test.servers))
}
})
}
}
func TestParseXML(t *testing.T) {
n := &NeptuneApex{}
goodTime := time.Date(2018, 12, 22, 21, 55, 37, 0,
time.FixedZone("PST", 3600*-8))
tests := []struct {
name string
xmlResponse []byte
wantMetrics []*testutil.Metric
wantAccErr bool
wantErr bool
}{
{
name: "Good test",
xmlResponse: []byte(APEX2016),
wantMetrics: []*testutil.Metric{
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"type": "controller",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{
"serial": "AC5:12345",
"power_failed": int64(1544814000000000000),
"power_restored": int64(1544833875000000000),
},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "0",
"device_id": "base_Var1",
"name": "VarSpd1_I1",
"output_type": "variable",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"state": "PF1"},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "6",
"device_id": "base_email",
"name": "EmailAlm_I5",
"output_type": "alert",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"state": "AOF"},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "8",
"device_id": "2_1",
"name": "RETURN_2_1",
"output_type": "outlet",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{
"state": "AON",
"watt": 35.0,
"amp": 0.3,
},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "18",
"device_id": "3_1",
"name": "RVortech_3_1",
"output_type": "unknown",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{
"state": "TBL",
"xstatus": "OK",
},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "28",
"device_id": "4_9",
"name": "LinkA_4_9",
"output_type": "unknown",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"state": "AOF"},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"output_id": "32",
"device_id": "Cntl_A2",
"name": "LEAK",
"output_type": "virtual",
"type": "output",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"state": "AOF"},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"name": "Salt",
"type": "probe",
"probe_type": "Cond",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"value": 30.1},
},
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "apex",
"name": "Volt_2",
"type": "probe",
"software": "5.04_7A18",
"hardware": "1.0",
},
Fields: map[string]interface{}{"value": 115.0},
},
},
},
{
name: "Unmarshal error",
xmlResponse: []byte("Invalid"),
wantErr: true,
},
{
name: "Report time failure",
xmlResponse: []byte(`<status><date>abc</date></status>`),
wantErr: true,
},
{
name: "Power Failed time failure",
xmlResponse: []byte(
`<status><date>12/22/2018 21:55:37</date>
<timezone>-8.0</timezone><power><failed>a</failed>
<restored>12/22/2018 22:55:37</restored></power></status>`),
wantErr: true,
},
{
name: "Power restored time failure",
xmlResponse: []byte(
`<status><date>12/22/2018 21:55:37</date>
<timezone>-8.0</timezone><power><restored>a</restored>
<failed>12/22/2018 22:55:37</failed></power></status>`),
wantErr: true,
},
{
name: "Power failed failure",
xmlResponse: []byte(
`<status><power><failed>abc</failed></power></status>`),
wantErr: true,
},
{
name: "Failed to parse watt to float",
xmlResponse: []byte(
`<?xml version="1.0"?><status>
<date>12/22/2018 21:55:37</date><timezone>-8.0</timezone>
<power><failed>12/22/2018 21:55:37</failed>
<restored>12/22/2018 21:55:37</restored></power>
<outlets><outlet><name>o1</name></outlet></outlets>
<probes><probe><name>o1W</name><value>abc</value></probe>
</probes></status>`),
wantAccErr: true,
wantMetrics: []*testutil.Metric{
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "",
"type": "controller",
"hardware": "",
"software": "",
},
Fields: map[string]interface{}{
"serial": "",
"power_failed": int64(1545544537000000000),
"power_restored": int64(1545544537000000000),
},
},
},
},
{
name: "Failed to parse amp to float",
xmlResponse: []byte(
`<?xml version="1.0"?><status>
<date>12/22/2018 21:55:37</date><timezone>-8.0</timezone>
<power><failed>12/22/2018 21:55:37</failed>
<restored>12/22/2018 21:55:37</restored></power>
<outlets><outlet><name>o1</name></outlet></outlets>
<probes><probe><name>o1A</name><value>abc</value></probe>
</probes></status>`),
wantAccErr: true,
wantMetrics: []*testutil.Metric{
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "",
"type": "controller",
"hardware": "",
"software": "",
},
Fields: map[string]interface{}{
"serial": "",
"power_failed": int64(1545544537000000000),
"power_restored": int64(1545544537000000000),
},
},
},
},
{
name: "Failed to parse probe value to float",
xmlResponse: []byte(
`<?xml version="1.0"?><status>
<date>12/22/2018 21:55:37</date><timezone>-8.0</timezone>
<power><failed>12/22/2018 21:55:37</failed>
<restored>12/22/2018 21:55:37</restored></power>
<probes><probe><name>p1</name><value>abc</value></probe>
</probes></status>`),
wantAccErr: true,
wantMetrics: []*testutil.Metric{
{
Measurement: Measurement,
Time: goodTime,
Tags: map[string]string{
"source": "",
"type": "controller",
"hardware": "",
"software": "",
},
Fields: map[string]interface{}{
"serial": "",
"power_failed": int64(1545544537000000000),
"power_restored": int64(1545544537000000000),
},
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
var acc testutil.Accumulator
err := n.parseXML(&acc, []byte(test.xmlResponse))
if (err != nil) != test.wantErr {
t.Errorf("err mismatch. got=%v, want=%t", err, test.wantErr)
}
if test.wantErr {
return
}
if len(acc.Errors) > 0 != test.wantAccErr {
t.Errorf("Accumulator errors. got=%v, want=none", acc.Errors)
}
if len(acc.Metrics) != len(test.wantMetrics) {
t.Fatalf("Invalid number of metrics received. got=%d, want=%d", len(acc.Metrics), len(test.wantMetrics))
}
for i, m := range acc.Metrics {
if m.Measurement != test.wantMetrics[i].Measurement {
t.Errorf("Metric measurement mismatch at position %d:\ngot=\n%s\nWant=\n%s", i, m.Measurement, test.wantMetrics[i].Measurement)
}
if !reflect.DeepEqual(m.Tags, test.wantMetrics[i].Tags) {
t.Errorf("Metric tags mismatch at position %d:\ngot=\n%v\nwant=\n%v", i, m.Tags, test.wantMetrics[i].Tags)
}
if !reflect.DeepEqual(m.Fields, test.wantMetrics[i].Fields) {
t.Errorf("Metric fields mismatch at position %d:\ngot=\n%#v\nwant=:\n%#v", i, m.Fields, test.wantMetrics[i].Fields)
}
if !m.Time.Equal(test.wantMetrics[i].Time) {
t.Errorf("Metric time mismatch at position %d:\ngot=\n%s\nwant=\n%s", i, m.Time, test.wantMetrics[i].Time)
}
}
})
}
}
func TestSendRequest(t *testing.T) {
tests := []struct {
name string
statusCode int
wantErr bool
}{
{
name: "Good case",
statusCode: http.StatusOK,
},
{
name: "Get error",
statusCode: http.StatusNotFound,
wantErr: true,
},
{
name: "Status 301",
statusCode: http.StatusMovedPermanently,
wantErr: true,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
h := http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request) {
w.WriteHeader(test.statusCode)
w.Write([]byte("data"))
})
c, destroy := fakeHTTPClient(h)
defer destroy()
n := &NeptuneApex{
httpClient: c,
}
resp, err := n.sendRequest("http://abc")
if (err != nil) != test.wantErr {
t.Errorf("err mismatch. got=%v, want=%t", err, test.wantErr)
}
if test.wantErr {
return
}
if bytes.Compare(resp, []byte("data")) != 0 {
t.Errorf(
"Response data mismatch. got=%q, want=%q", resp, "data")
}
})
}
}
func TestParseTime(t *testing.T) {
tests := []struct {
name string
input string
timeZone float64
wantTime time.Time
wantErr bool
}{
{
name: "Good case - Timezone positive",
input: "01/01/2023 12:34:56",
timeZone: 5,
wantTime: time.Date(2023, 1, 1, 12, 34, 56, 0,
time.FixedZone("a", 3600*5)),
},
{
name: "Good case - Timezone negative",
input: "01/01/2023 12:34:56",
timeZone: -8,
wantTime: time.Date(2023, 1, 1, 12, 34, 56, 0,
time.FixedZone("a", 3600*-8)),
},
{
name: "Cannot parse",
input: "Not a date",
wantErr: true,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
res, err := parseTime(test.input, test.timeZone)
if (err != nil) != test.wantErr {
t.Errorf("err mismatch. got=%v, want=%t", err, test.wantErr)
}
if test.wantErr {
return
}
if !test.wantTime.Equal(res) {
t.Errorf("err mismatch. got=%s, want=%s", res, test.wantTime)
}
})
}
}
func TestFindProbe(t *testing.T) {
fakeProbes := []probe{
{
Name: "test1",
},
{
Name: "good",
},
}
tests := []struct {
name string
probeName string
wantIndex int
}{
{
name: "Good case - Found",
probeName: "good",
wantIndex: 1,
},
{
name: "Not found",
probeName: "bad",
wantIndex: -1,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
index := findProbe(test.probeName, fakeProbes)
if index != test.wantIndex {
t.Errorf("probe index mismatch; got=%d, want %d", index, test.wantIndex)
}
})
}
}
func TestDescription(t *testing.T) {
n := &NeptuneApex{}
if n.Description() == "" {
t.Errorf("Empty description")
}
}
func TestSampleConfig(t *testing.T) {
n := &NeptuneApex{}
if n.SampleConfig() == "" {
t.Errorf("Empty sample config")
}
}
// This fakeHttpClient creates a server and binds a client to it.
// That way, it is possible to control the http
// output from within the test without changes to the main code.
func fakeHTTPClient(h http.Handler) (*http.Client, func()) {
s := httptest.NewServer(h)
c := &http.Client{
Transport: &http.Transport{
DialContext: func(
_ context.Context, network, _ string) (net.Conn, error) {
return net.Dial(network, s.Listener.Addr().String())
},
},
}
return c, s.Close
}
// Sample configuration from a 2016 version Neptune Apex.
const APEX2016 = `<?xml version="1.0"?>
<status software="5.04_7A18" hardware="1.0">
<hostname>apex</hostname>
<serial>AC5:12345</serial>
<timezone>-8.00</timezone>
<date>12/22/2018 21:55:37</date>
<power><failed>12/14/2018 11:00:00</failed>
<restored>12/14/2018 16:31:15</restored></power>
<probes>
<probe>
<name>Salt</name> <value>30.1 </value>
<type>Cond</type></probe><probe>
<name>RETURN_2_1A</name> <value>0.3 </value>
</probe><probe>
<name>RETURN_2_1W</name> <value> 35 </value>
</probe><probe>
<name>Volt_2</name> <value>115 </value>
</probe></probes>
<outlets>
<outlet>
<name>VarSpd1_I1</name>
<outputID>0</outputID>
<state>PF1</state>
<deviceID>base_Var1</deviceID>
</outlet>
<outlet>
<name>EmailAlm_I5</name>
<outputID>6</outputID>
<state>AOF</state>
<deviceID>base_email</deviceID>
</outlet>
<outlet>
<name>RETURN_2_1</name>
<outputID>8</outputID>
<state>AON</state>
<deviceID>2_1</deviceID>
</outlet>
<outlet>
<name>RVortech_3_1</name>
<outputID>18</outputID>
<state>TBL</state>
<deviceID>3_1</deviceID>
<xstatus>OK</xstatus></outlet>
<outlet>
<name>LinkA_4_9</name>
<outputID>28</outputID>
<state>AOF</state>
<deviceID>4_9</deviceID>
</outlet>
<outlet>
<name>LEAK</name>
<outputID>32</outputID>
<state>AOF</state>
<deviceID>Cntl_A2</deviceID>
</outlet>
</outlets></status>
`