372 lines
10 KiB
Go
372 lines
10 KiB
Go
|
//go:generate ../../../tools/readme_config_includer/generator
|
||
|
package ctrlx_datalayer
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
_ "embed"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"strconv"
|
||
|
"sync"
|
||
|
"time"
|
||
|
|
||
|
"github.com/boschrexroth/ctrlx-datalayer-golang/pkg/sseclient"
|
||
|
"github.com/boschrexroth/ctrlx-datalayer-golang/pkg/token"
|
||
|
"github.com/google/uuid"
|
||
|
|
||
|
"github.com/influxdata/telegraf"
|
||
|
"github.com/influxdata/telegraf/config"
|
||
|
"github.com/influxdata/telegraf/internal/choice"
|
||
|
"github.com/influxdata/telegraf/metric"
|
||
|
common_http "github.com/influxdata/telegraf/plugins/common/http"
|
||
|
"github.com/influxdata/telegraf/plugins/inputs"
|
||
|
parsers_json "github.com/influxdata/telegraf/plugins/parsers/json"
|
||
|
)
|
||
|
|
||
|
// This plugin is based on the official ctrlX CORE API. Documentation can be found in OpenAPI format at:
|
||
|
// https://boschrexroth.github.io/rest-api-description/ctrlx-automation/ctrlx-core/
|
||
|
// Used APIs are:
|
||
|
// * ctrlX CORE - Authorization and Authentication API
|
||
|
// * ctrlX CORE - Data Layer API
|
||
|
//
|
||
|
// All communication between the device and this input plugin is based
|
||
|
// on https REST and HTML5 Server Sent Events (sse).
|
||
|
|
||
|
//go:embed sample.conf
|
||
|
var sampleConfig string
|
||
|
|
||
|
type CtrlXDataLayer struct {
|
||
|
Server string `toml:"server"`
|
||
|
Username config.Secret `toml:"username"`
|
||
|
Password config.Secret `toml:"password"`
|
||
|
|
||
|
Log telegraf.Logger `toml:"-"`
|
||
|
Subscription []subscription
|
||
|
|
||
|
url string
|
||
|
wg sync.WaitGroup
|
||
|
cancel context.CancelFunc
|
||
|
|
||
|
acc telegraf.Accumulator
|
||
|
connection *http.Client
|
||
|
tokenManager token.TokenManager
|
||
|
common_http.HTTPClientConfig
|
||
|
}
|
||
|
|
||
|
func (*CtrlXDataLayer) SampleConfig() string {
|
||
|
return sampleConfig
|
||
|
}
|
||
|
|
||
|
func (c *CtrlXDataLayer) Init() error {
|
||
|
// Check all configured subscriptions for valid settings
|
||
|
for i := range c.Subscription {
|
||
|
sub := &c.Subscription[i]
|
||
|
sub.applyDefaultSettings()
|
||
|
if !choice.Contains(sub.QueueBehaviour, queueBehaviours) {
|
||
|
c.Log.Infof("The right queue behaviour values are %v", queueBehaviours)
|
||
|
return fmt.Errorf("subscription %d: setting 'queue_behaviour' %q is invalid", i, sub.QueueBehaviour)
|
||
|
}
|
||
|
if !choice.Contains(sub.ValueChange, valueChanges) {
|
||
|
c.Log.Infof("The right value change values are %v", valueChanges)
|
||
|
return fmt.Errorf("subscription %d: setting 'value_change' %q is invalid", i, sub.ValueChange)
|
||
|
}
|
||
|
if len(sub.Nodes) == 0 {
|
||
|
c.Log.Warn("A configured subscription has no nodes configured")
|
||
|
}
|
||
|
sub.index = i
|
||
|
}
|
||
|
|
||
|
// Generate valid communication url based on configured server address
|
||
|
u := url.URL{
|
||
|
Scheme: "https",
|
||
|
Host: c.Server,
|
||
|
}
|
||
|
c.url = u.String()
|
||
|
if _, err := url.Parse(c.url); err != nil {
|
||
|
return errors.New("invalid server address")
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *CtrlXDataLayer) Start(acc telegraf.Accumulator) error {
|
||
|
var ctx context.Context
|
||
|
ctx, c.cancel = context.WithCancel(context.Background())
|
||
|
|
||
|
var err error
|
||
|
c.connection, err = c.HTTPClientConfig.CreateClient(ctx, c.Log)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("failed to create http client: %w", err)
|
||
|
}
|
||
|
|
||
|
username, err := c.Username.Get()
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("getting username failed: %w", err)
|
||
|
}
|
||
|
|
||
|
password, err := c.Password.Get()
|
||
|
if err != nil {
|
||
|
username.Destroy()
|
||
|
return fmt.Errorf("getting password failed: %w", err)
|
||
|
}
|
||
|
|
||
|
c.tokenManager = token.TokenManager{
|
||
|
Url: c.url,
|
||
|
Username: username.String(),
|
||
|
Password: password.String(),
|
||
|
Connection: c.connection,
|
||
|
}
|
||
|
username.Destroy()
|
||
|
password.Destroy()
|
||
|
|
||
|
c.acc = acc
|
||
|
|
||
|
c.gatherLoop(ctx)
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (*CtrlXDataLayer) Gather(telegraf.Accumulator) error {
|
||
|
// Metrics are sent to the accumulator asynchronously in worker thread. So nothing to do here.
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *CtrlXDataLayer) Stop() {
|
||
|
c.cancel()
|
||
|
c.wg.Wait()
|
||
|
if c.connection != nil {
|
||
|
c.connection.CloseIdleConnections()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// convertTimestamp2UnixTime converts the given Data Layer timestamp of the payload to UnixTime.
|
||
|
func convertTimestamp2UnixTime(t int64) time.Time {
|
||
|
// 1 sec=1000 millisec=1000000 microsec=1000000000 nanosec.
|
||
|
// Convert from FILETIME (100-nanosecond intervals since January 1, 1601 UTC) to
|
||
|
// seconds and nanoseconds since January 1, 1970 UTC.
|
||
|
// Between Jan 1, 1601 and Jan 1, 1970 there are 11644473600 seconds.
|
||
|
return time.Unix(0, (t-116444736000000000)*100)
|
||
|
}
|
||
|
|
||
|
// createSubscription uses the official 'ctrlX Data Layer API' to create the sse subscription.
|
||
|
func (c *CtrlXDataLayer) createSubscription(sub *subscription) (string, error) {
|
||
|
sseURL := c.url + subscriptionPath
|
||
|
|
||
|
id := "telegraf_" + uuid.New().String()
|
||
|
request := sub.createRequest(id)
|
||
|
payload, err := json.Marshal(request)
|
||
|
if err != nil {
|
||
|
return "", fmt.Errorf("failed to create subscription %d payload: %w", sub.index, err)
|
||
|
}
|
||
|
|
||
|
requestBody := bytes.NewBuffer(payload)
|
||
|
req, err := http.NewRequest("POST", sseURL, requestBody)
|
||
|
if err != nil {
|
||
|
return "", fmt.Errorf("failed to create subscription %d request: %w", sub.index, err)
|
||
|
}
|
||
|
|
||
|
req.Header.Add("Authorization", c.tokenManager.Token.String())
|
||
|
|
||
|
resp, err := c.connection.Do(req)
|
||
|
if err != nil {
|
||
|
return "", fmt.Errorf("failed to do request to create sse subscription %d: %w", sub.index, err)
|
||
|
}
|
||
|
resp.Body.Close()
|
||
|
|
||
|
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||
|
return "", fmt.Errorf("failed to create sse subscription %d, status: %s", sub.index, resp.Status)
|
||
|
}
|
||
|
|
||
|
return sseURL + "/" + id, nil
|
||
|
}
|
||
|
|
||
|
// createSubscriptionAndSseClient creates a sse subscription on the server and
|
||
|
// initializes a sse client to receive sse events from the server.
|
||
|
func (c *CtrlXDataLayer) createSubscriptionAndSseClient(sub *subscription) (*sseclient.SseClient, error) {
|
||
|
t, err := c.tokenManager.RequestAuthToken()
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
subURL, err := c.createSubscription(sub)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
client := sseclient.NewSseClient(subURL, t.String(), c.InsecureSkipVerify)
|
||
|
|
||
|
return client, nil
|
||
|
}
|
||
|
|
||
|
// addMetric writes sse metric into accumulator.
|
||
|
func (c *CtrlXDataLayer) addMetric(se *sseclient.SseEvent, sub *subscription) {
|
||
|
switch se.Event {
|
||
|
case "update":
|
||
|
// Received an updated value, that we translate into a metric
|
||
|
var d sseEventData
|
||
|
|
||
|
if err := json.Unmarshal([]byte(se.Data), &d); err != nil {
|
||
|
c.acc.AddError(fmt.Errorf("received malformed data from 'update' event: %w", err))
|
||
|
return
|
||
|
}
|
||
|
m, err := c.createMetric(&d, sub)
|
||
|
if err != nil {
|
||
|
c.acc.AddError(fmt.Errorf("failed to create metrics: %w", err))
|
||
|
return
|
||
|
}
|
||
|
c.acc.AddMetric(m)
|
||
|
case "error":
|
||
|
// Received an error event, that we report to the accumulator
|
||
|
var e sseEventError
|
||
|
if err := json.Unmarshal([]byte(se.Data), &e); err != nil {
|
||
|
c.acc.AddError(fmt.Errorf("received malformed data from 'error' event: %w", err))
|
||
|
return
|
||
|
}
|
||
|
c.acc.AddError(fmt.Errorf("received 'error' event for node: %q", e.Instance))
|
||
|
case "keepalive":
|
||
|
// Keepalive events are ignored for the moment
|
||
|
c.Log.Debug("Received keepalive event")
|
||
|
default:
|
||
|
// Received a yet unsupported event type
|
||
|
c.Log.Debugf("Received unsupported event: %q", se.Event)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// createMetric - create metric depending on flag 'output_json' and data type
|
||
|
func (c *CtrlXDataLayer) createMetric(em *sseEventData, sub *subscription) (telegraf.Metric, error) {
|
||
|
t := convertTimestamp2UnixTime(em.Timestamp)
|
||
|
node := sub.node(em.Node)
|
||
|
if node == nil {
|
||
|
return nil, errors.New("node not found")
|
||
|
}
|
||
|
|
||
|
// default tags
|
||
|
tags := map[string]string{
|
||
|
"node": em.Node,
|
||
|
"source": c.Server,
|
||
|
}
|
||
|
|
||
|
// add tags of subscription if user has defined
|
||
|
for key, value := range sub.Tags {
|
||
|
tags[key] = value
|
||
|
}
|
||
|
|
||
|
// add tags of node if user has defined
|
||
|
for key, value := range node.Tags {
|
||
|
tags[key] = value
|
||
|
}
|
||
|
|
||
|
// set measurement of subscription
|
||
|
measurement := sub.Measurement
|
||
|
|
||
|
// get field key from node properties
|
||
|
fieldKey := node.fieldKey()
|
||
|
|
||
|
if fieldKey == "" {
|
||
|
return nil, errors.New("field key not valid")
|
||
|
}
|
||
|
|
||
|
if sub.OutputJSONString {
|
||
|
b, err := json.Marshal(em.Value)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
fields := map[string]interface{}{fieldKey: string(b)}
|
||
|
m := metric.New(measurement, tags, fields, t)
|
||
|
return m, nil
|
||
|
}
|
||
|
|
||
|
switch em.Type {
|
||
|
case "object":
|
||
|
flattener := parsers_json.JSONFlattener{}
|
||
|
err := flattener.FullFlattenJSON(fieldKey, em.Value, true, true)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
m := metric.New(measurement, tags, flattener.Fields, t)
|
||
|
return m, nil
|
||
|
case "arbool8",
|
||
|
"arint8", "aruint8",
|
||
|
"arint16", "aruint16",
|
||
|
"arint32", "aruint32",
|
||
|
"arint64", "aruint64",
|
||
|
"arfloat", "ardouble",
|
||
|
"arstring",
|
||
|
"artimestamp":
|
||
|
fields := make(map[string]interface{})
|
||
|
values := em.Value.([]interface{})
|
||
|
for i := 0; i < len(values); i++ {
|
||
|
index := strconv.Itoa(i)
|
||
|
key := fieldKey + "_" + index
|
||
|
fields[key] = values[i]
|
||
|
}
|
||
|
m := metric.New(measurement, tags, fields, t)
|
||
|
return m, nil
|
||
|
case "bool8",
|
||
|
"int8", "uint8",
|
||
|
"int16", "uint16",
|
||
|
"int32", "uint32",
|
||
|
"int64", "uint64",
|
||
|
"float", "double",
|
||
|
"string",
|
||
|
"timestamp":
|
||
|
fields := map[string]interface{}{fieldKey: em.Value}
|
||
|
m := metric.New(measurement, tags, fields, t)
|
||
|
return m, nil
|
||
|
}
|
||
|
|
||
|
return nil, fmt.Errorf("unsupported value type: %s", em.Type)
|
||
|
}
|
||
|
|
||
|
// gatherLoop creates sse subscriptions on the Data Layer and requests the sse data
|
||
|
// the connection will be restablished if the sse subscription is broken.
|
||
|
func (c *CtrlXDataLayer) gatherLoop(ctx context.Context) {
|
||
|
for _, sub := range c.Subscription {
|
||
|
c.wg.Add(1)
|
||
|
go func(sub subscription) {
|
||
|
defer c.wg.Done()
|
||
|
for {
|
||
|
select {
|
||
|
case <-ctx.Done():
|
||
|
c.Log.Debugf("Gather loop for subscription %d stopped", sub.index)
|
||
|
return
|
||
|
default:
|
||
|
client, err := c.createSubscriptionAndSseClient(&sub)
|
||
|
if err != nil {
|
||
|
c.Log.Errorf("Creating sse client to subscription %d: %v", sub.index, err)
|
||
|
time.Sleep(time.Duration(defaultReconnectInterval))
|
||
|
continue
|
||
|
}
|
||
|
c.Log.Debugf("Created sse client to subscription %d", sub.index)
|
||
|
|
||
|
// Establish connection and handle events in a callback function.
|
||
|
err = client.Subscribe(ctx, func(event string, data string) {
|
||
|
c.addMetric(&sseclient.SseEvent{
|
||
|
Event: event,
|
||
|
Data: data,
|
||
|
}, &sub)
|
||
|
})
|
||
|
if errors.Is(err, context.Canceled) {
|
||
|
// Subscription cancelled
|
||
|
c.Log.Debugf("Requesting data of subscription %d cancelled", sub.index)
|
||
|
return
|
||
|
}
|
||
|
c.Log.Errorf("Requesting data of subscription %d failed: %v", sub.index, err)
|
||
|
}
|
||
|
}
|
||
|
}(sub)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// init registers the plugin in telegraf.
|
||
|
func init() {
|
||
|
inputs.Add("ctrlx_datalayer", func() telegraf.Input {
|
||
|
return &CtrlXDataLayer{}
|
||
|
})
|
||
|
}
|