Add input plugin for Neptune Apex aquarium controller (#5191)
This commit is contained in:
parent
8538894690
commit
4125e4161c
|
@ -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"
|
||||
|
|
|
@ -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.
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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>
|
||||
`
|
Loading…
Reference in New Issue