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
+
+
+`