diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index 9c183fcbb..2b189e5ff 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -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" diff --git a/plugins/inputs/neptune_apex/README.md b/plugins/inputs/neptune_apex/README.md new file mode 100644 index 000000000..fae0c58b7 --- /dev/null +++ b/plugins/inputs/neptune_apex/README.md @@ -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. \ No newline at end of file diff --git a/plugins/inputs/neptune_apex/neptune_apex.go b/plugins/inputs/neptune_apex/neptune_apex.go new file mode 100644 index 000000000..370407a41 --- /dev/null +++ b/plugins/inputs/neptune_apex/neptune_apex.go @@ -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, + }, + } + }) +} diff --git a/plugins/inputs/neptune_apex/neptune_apex_test.go b/plugins/inputs/neptune_apex/neptune_apex_test.go new file mode 100644 index 000000000..1d554149e --- /dev/null +++ b/plugins/inputs/neptune_apex/neptune_apex_test.go @@ -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(`abc`), + wantErr: true, + }, + { + name: "Power Failed time failure", + xmlResponse: []byte( + `12/22/2018 21:55:37 + -8.0a + 12/22/2018 22:55:37`), + wantErr: true, + }, + { + name: "Power restored time failure", + xmlResponse: []byte( + `12/22/2018 21:55:37 + -8.0a + 12/22/2018 22:55:37`), + wantErr: true, + }, + { + name: "Power failed failure", + xmlResponse: []byte( + `abc`), + wantErr: true, + }, + { + name: "Failed to parse watt to float", + xmlResponse: []byte( + ` + 12/22/2018 21:55:37-8.0 + 12/22/2018 21:55:37 + 12/22/2018 21:55:37 + o1 + o1Wabc + `), + 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( + ` + 12/22/2018 21:55:37-8.0 + 12/22/2018 21:55:37 + 12/22/2018 21:55:37 + o1 + o1Aabc + `), + 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( + ` + 12/22/2018 21:55:37-8.0 + 12/22/2018 21:55:37 + 12/22/2018 21:55:37 + p1abc + `), + 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 = ` + +apex +AC5:12345 +-8.00 +12/22/2018 21:55:37 +12/14/2018 11:00:00 +12/14/2018 16:31:15 + + + Salt 30.1 + Cond + RETURN_2_1A 0.3 + + RETURN_2_1W 35 + + Volt_2 115 + + + + VarSpd1_I1 + 0 + PF1 + base_Var1 + + + EmailAlm_I5 + 6 + AOF + base_email + + + RETURN_2_1 + 8 + AON + 2_1 + + + RVortech_3_1 + 18 + TBL + 3_1 +OK + + LinkA_4_9 + 28 + AOF + 4_9 + + + LEAK + 32 + AOF + Cntl_A2 + + +`