295 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			295 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Go
		
	
	
	
| // 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)
 | |
| 	}
 | |
| 
 | |
| 	mainFields := map[string]interface{}{
 | |
| 		"serial": r.Serial,
 | |
| 	}
 | |
| 	var reportTime time.Time
 | |
| 
 | |
| 	if reportTime, err = parseTime(r.Date, r.Timezone); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	if val, err := parseTime(r.PowerFailed, r.Timezone); err == nil {
 | |
| 		mainFields["power_failed"] = val.UnixNano()
 | |
| 	}
 | |
| 	if val, err := parseTime(r.PowerRestored, r.Timezone); err == nil {
 | |
| 		mainFields["power_restored"] = val.UnixNano()
 | |
| 	}
 | |
| 
 | |
| 	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,
 | |
| 			},
 | |
| 		}
 | |
| 	})
 | |
| }
 |