From 498a6da75f7a9825f666eb8f6119c2508fcd98c9 Mon Sep 17 00:00:00 2001 From: reimda Date: Wed, 2 Dec 2020 17:06:47 -0700 Subject: [PATCH] Add node groups to opcua input plugin (#8389) --- plugins/inputs/opcua/README.md | 115 +++++++-- plugins/inputs/opcua/opcua_client.go | 281 ++++++++++++++++------ plugins/inputs/opcua/opcua_client_test.go | 181 ++++++++++++-- 3 files changed, 456 insertions(+), 121 deletions(-) diff --git a/plugins/inputs/opcua/README.md b/plugins/inputs/opcua/README.md index 173d98b6f..d6530c083 100644 --- a/plugins/inputs/opcua/README.md +++ b/plugins/inputs/opcua/README.md @@ -9,8 +9,8 @@ Plugin minimum tested version: 1.16 ```toml [[inputs.opcua]] - ## Device name - # name = "localhost" + ## Metric name + # name = "opcua" # ## OPC UA Endpoint URL # endpoint = "opc.tcp://localhost:4840" @@ -47,34 +47,97 @@ Plugin minimum tested version: 1.16 # password = "" # ## Node ID configuration - ## name - the variable name - ## namespace - integer value 0 thru 3 - ## identifier_type - s=string, i=numeric, g=guid, b=opaque - ## identifier - tag as shown in opcua browser - ## data_type - boolean, byte, short, int, uint, uint16, int16, - ## uint32, int32, float, double, string, datetime, number + ## name - field name to use in the output + ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) + ## identifier - OPC UA ID (tag as shown in opcua browser) + ## tags - extra tags to be added to the output metric (optional) ## Example: - ## {name="ProductUri", namespace="0", identifier_type="i", identifier="2262", data_type="string", description="http://open62541.org"} + ## {name="ProductUri", namespace="0", identifier_type="i", identifier="2262", tags=[["tag1","value1"],["tag2","value2]]} + # nodes = [ + # {name="", namespace="", identifier_type="", identifier=""}, + # {name="", namespace="", identifier_type="", identifier=""}, + #] + # + ## Node Group + ## Sets defaults for OPC UA namespace and ID type so they aren't required in + ## every node. A group can also have a metric name that overrides the main + ## plugin metric name. + ## + ## Multiple node groups are allowed + #[[inputs.opcua.group]] + ## Group Metric name. Overrides the top level name. If unset, the + ## top level name is used. + # name = + # + ## Group default namespace. If a node in the group doesn't set its + ## namespace, this is used. + # namespace = + # + ## Group default identifier type. If a node in the group doesn't set its + ## namespace, this is used. + # identifier_type = + # + ## Node ID Configuration. Array of nodes with the same settings as above. + # nodes = [ + # {name="", namespace="", identifier_type="", identifier=""}, + # {name="", namespace="", identifier_type="", identifier=""}, + #] +``` + +### Node Configuration +An OPC UA node ID may resemble: "n=3;s=Temperature". In this example: +- n=3 is indicating the `namespace` is 3 +- s=Temperature is indicting that the `identifier_type` is a string and `identifier` value is 'Temperature' +- This example temperature node has a value of 79.0 +To gather data from this node enter the following line into the 'nodes' property above: +``` +{field_name="temp", namespace="3", identifier_type="s", identifier="Temperature"}, +``` + +This node configuration produces a metric like this: +``` +opcua,id=n\=3;s\=Temperature temp=79.0,quality="OK (0x0)" 1597820490000000000 + +``` + +### Group Configuration +Groups can set default values for the namespace, identifier type, and +tags settings. The default values apply to all the nodes in the +group. If a default is set, a node may omit the setting altogether. +This simplifies node configuration, especially when many nodes share +the same namespace or identifier type. + +The output metric will include tags set in the group and the node. If +a tag with the same name is set in both places, the tag value from the +node is used. + +This example group configuration has two groups with two nodes each: +``` + [[inputs.opcua.group]] + name="group1_metric_name" + namespace="3" + identifier_type="i" + tags=[["group1_tag", "val1"]] nodes = [ - {name="", namespace="", identifier_type="", identifier="", data_type="", description=""}, - {name="", namespace="", identifier_type="", identifier="", data_type="", description=""}, + {name="name", identifier="1001", tags=[["node1_tag", "val2"]]}, + {name="name", identifier="1002", tags=[["node1_tag", "val3"]]}, + ] + [[inputs.opcua.group]] + name="group2_metric_name" + namespace="3" + identifier_type="i" + tags=[["group2_tag", "val3"]] + nodes = [ + {name="saw", identifier="1003", tags=[["node2_tag", "val4"]]}, + {name="sin", identifier="1004"}, ] ``` -### Example Node Configuration -An OPC UA node ID may resemble: "n=3,s=Temperature". In this example: -- n=3 is indicating the `namespace` is 3 -- s=Temperature is indicting that the `identifier_type` is a string and `identifier` value is 'Temperature' -- This example temperature node has a value of 79.0, which makes the `data_type` a 'float'. -To gather data from this node enter the following line into the 'nodes' property above: +It produces metrics like these: ``` -{name="LabelName", namespace="3", identifier_type="s", identifier="Temperature", data_type="float", description="Description of node"}, -``` - - -### Example Output - -``` -opcua,host=3c70aee0901e,name=Random,type=double Random=0.018158170305814902 1597820490000000000 - +group1_metric_name,group1_tag=val1,id=ns\=3;i\=1001,node1_tag=val2 name=0,Quality="OK (0x0)" 1606893246000000000 +group1_metric_name,group1_tag=val1,id=ns\=3;i\=1002,node1_tag=val3 name=-1.389117,Quality="OK (0x0)" 1606893246000000000 +group2_metric_name,group2_tag=val3,id=ns\=3;i\=1003,node2_tag=val4 Quality="OK (0x0)",saw=-1.6 1606893246000000000 +group2_metric_name,group2_tag=val3,id=ns\=3;i\=1004 sin=1.902113,Quality="OK (0x0)" 1606893246000000000 ``` diff --git a/plugins/inputs/opcua/opcua_client.go b/plugins/inputs/opcua/opcua_client.go index 87647e2b9..0481a3b08 100644 --- a/plugins/inputs/opcua/opcua_client.go +++ b/plugins/inputs/opcua/opcua_client.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/url" + "sort" "strings" "time" @@ -13,11 +14,12 @@ import ( "github.com/influxdata/telegraf" "github.com/influxdata/telegraf/config" "github.com/influxdata/telegraf/plugins/inputs" + "github.com/influxdata/telegraf/selfstat" ) // OpcUA type type OpcUA struct { - Name string `toml:"name"` + MetricName string `toml:"name"` Endpoint string `toml:"endpoint"` SecurityPolicy string `toml:"security_policy"` SecurityMode string `toml:"security_mode"` @@ -28,18 +30,18 @@ type OpcUA struct { AuthMethod string `toml:"auth_method"` ConnectTimeout config.Duration `toml:"connect_timeout"` RequestTimeout config.Duration `toml:"request_timeout"` - NodeList []OPCTag `toml:"nodes"` + RootNodes []NodeSettings `toml:"nodes"` + Groups []GroupSettings `toml:"group"` - Nodes []string `toml:"-"` - NodeData []OPCData `toml:"-"` - NodeIDs []*ua.NodeID `toml:"-"` - NodeIDerror []error `toml:"-"` + nodes []Node + nodeData []OPCData + nodeIDs []*ua.NodeID + nodeIDerror []error state ConnectionState // status - ReadSuccess int `toml:"-"` - ReadError int `toml:"-"` - NumberOfTags int `toml:"-"` + ReadSuccess selfstat.Stat `toml:"-"` + ReadError selfstat.Stat `toml:"-"` // internal values client *opcua.Client @@ -48,13 +50,29 @@ type OpcUA struct { } // OPCTag type -type OPCTag struct { - Name string `toml:"name"` - Namespace string `toml:"namespace"` - IdentifierType string `toml:"identifier_type"` - Identifier string `toml:"identifier"` - DataType string `toml:"data_type"` - Description string `toml:"description"` +type NodeSettings struct { + FieldName string `toml:"name"` + Namespace string `toml:"namespace"` + IdentifierType string `toml:"identifier_type"` + Identifier string `toml:"identifier"` + DataType string `toml:"data_type"` // Kept for backward compatibility but was never used. + Description string `toml:"description"` // Kept for backward compatibility but was never used. + TagsSlice [][]string `toml:"tags"` +} + +type Node struct { + tag NodeSettings + idStr string + metricName string + metricTags map[string]string +} + +type GroupSettings struct { + MetricName string `toml:"name"` // Overrides plugin's setting + Namespace string `toml:"namespace"` // Can be overridden by node setting + IdentifierType string `toml:"identifier_type"` // Can be overridden by node setting + Nodes []NodeSettings `toml:"nodes"` + TagsSlice [][]string `toml:"tags"` } // OPCData type @@ -81,9 +99,8 @@ const ( const description = `Retrieve data from OPCUA devices` const sampleConfig = ` -[[inputs.opcua]] - ## Device name - # name = "localhost" + ## Metric name + # name = "opcua" # ## OPC UA Endpoint URL # endpoint = "opc.tcp://localhost:4840" @@ -120,18 +137,41 @@ const sampleConfig = ` # password = "" # ## Node ID configuration - ## name - the variable name - ## namespace - integer value 0 thru 3 - ## identifier_type - s=string, i=numeric, g=guid, b=opaque - ## identifier - tag as shown in opcua browser - ## data_type - boolean, byte, short, int, uint, uint16, int16, - ## uint32, int32, float, double, string, datetime, number + ## name - field name to use in the output + ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) + ## identifier - OPC UA ID (tag as shown in opcua browser) ## Example: - ## {name="ProductUri", namespace="0", identifier_type="i", identifier="2262", data_type="string", description="http://open62541.org"} - nodes = [ - {name="", namespace="", identifier_type="", identifier="", data_type="", description=""}, - {name="", namespace="", identifier_type="", identifier="", data_type="", description=""}, - ] + ## {name="ProductUri", namespace="0", identifier_type="i", identifier="2262"} + # nodes = [ + # {name="", namespace="", identifier_type="", identifier=""}, + # {name="", namespace="", identifier_type="", identifier=""}, + #] + # + ## Node Group + ## Sets defaults for OPC UA namespace and ID type so they aren't required in + ## every node. A group can also have a metric name that overrides the main + ## plugin metric name. + ## + ## Multiple node groups are allowed + #[[inputs.opcua.group]] + ## Group Metric name. Overrides the top level name. If unset, the + ## top level name is used. + # name = + # + ## Group default namespace. If a node in the group doesn't set its + ## namespace, this is used. + # namespace = + # + ## Group default identifier type. If a node in the group doesn't set its + ## namespace, this is used. + # identifier_type = + # + ## Node ID Configuration. Array of nodes with the same settings as above. + # nodes = [ + # {name="", namespace="", identifier_type="", identifier=""}, + # {name="", namespace="", identifier_type="", identifier=""}, + #] ` // Description will appear directly above the plugin definition in the config file @@ -157,16 +197,21 @@ func (o *OpcUA) Init() error { if err != nil { return err } - o.NumberOfTags = len(o.NodeList) o.setupOptions() + tags := map[string]string{ + "endpoint": o.Endpoint, + } + o.ReadError = selfstat.Register("opcua", "read_error", tags) + o.ReadSuccess = selfstat.Register("opcua", "read_success", tags) + return nil } func (o *OpcUA) validateEndpoint() error { - if o.Name == "" { + if o.MetricName == "" { return fmt.Errorf("device name is empty") } @@ -184,22 +229,79 @@ func (o *OpcUA) validateEndpoint() error { case "None", "Basic128Rsa15", "Basic256", "Basic256Sha256", "auto": break default: - return fmt.Errorf("invalid security type '%s' in '%s'", o.SecurityPolicy, o.Name) + return fmt.Errorf("invalid security type '%s' in '%s'", o.SecurityPolicy, o.MetricName) } //search security mode type switch o.SecurityMode { case "None", "Sign", "SignAndEncrypt", "auto": break default: - return fmt.Errorf("invalid security type '%s' in '%s'", o.SecurityMode, o.Name) + return fmt.Errorf("invalid security type '%s' in '%s'", o.SecurityMode, o.MetricName) } return nil } +func tagsSliceToMap(tags [][]string) (map[string]string, error) { + m := make(map[string]string) + for i, tag := range tags { + if len(tag) != 2 { + return nil, fmt.Errorf("tag %d needs 2 values, has %d: %v", i+1, len(tag), tag) + } + if tag[0] == "" { + return nil, fmt.Errorf("tag %d has empty name", i+1) + } + if tag[1] == "" { + return nil, fmt.Errorf("tag %d has empty value", i+1) + } + if _, ok := m[tag[0]]; ok { + return nil, fmt.Errorf("tag %d has duplicate key: %v", i+1, tag[0]) + } + m[tag[0]] = tag[1] + } + return m, nil +} + //InitNodes Method on OpcUA func (o *OpcUA) InitNodes() error { - if len(o.NodeList) == 0 { - return nil + for _, node := range o.RootNodes { + o.nodes = append(o.nodes, Node{ + metricName: o.MetricName, + tag: node, + }) + } + + for _, group := range o.Groups { + if group.MetricName == "" { + group.MetricName = o.MetricName + } + groupTags, err := tagsSliceToMap(group.TagsSlice) + if err != nil { + return err + } + for _, node := range group.Nodes { + if node.Namespace == "" { + node.Namespace = group.Namespace + } + if node.IdentifierType == "" { + node.IdentifierType = group.IdentifierType + } + nodeTags, err := tagsSliceToMap(node.TagsSlice) + if err != nil { + return err + } + mergedTags := make(map[string]string) + for k, v := range groupTags { + mergedTags[k] = v + } + for k, v := range nodeTags { + mergedTags[k] = v + } + o.nodes = append(o.nodes, Node{ + metricName: group.MetricName, + tag: node, + metricTags: mergedTags, + }) + } } err := o.validateOPCTags() @@ -210,50 +312,74 @@ func (o *OpcUA) InitNodes() error { return nil } +type metricParts struct { + metricName string + fieldName string + tags string // sorted by tag name and in format tag1=value1, tag2=value2 +} + +func newMP(n *Node) metricParts { + var keys []string + for key := range n.metricTags { + keys = append(keys, key) + } + sort.Strings(keys) + var sb strings.Builder + for i, key := range keys { + if i != 0 { + sb.WriteString(", ") + } + sb.WriteString(key) + sb.WriteString("=") + sb.WriteString(n.metricTags[key]) + } + x := metricParts{ + metricName: n.metricName, + fieldName: n.tag.FieldName, + tags: sb.String(), + } + return x +} + func (o *OpcUA) validateOPCTags() error { - nameEncountered := map[string]bool{} - for i, item := range o.NodeList { + nameEncountered := map[metricParts]struct{}{} + for _, node := range o.nodes { + mp := newMP(&node) //check empty name - if item.Name == "" { - return fmt.Errorf("empty name in '%s'", item.Name) + if node.tag.FieldName == "" { + return fmt.Errorf("empty name in '%s'", node.tag.FieldName) } //search name duplicate - if nameEncountered[item.Name] { - return fmt.Errorf("name '%s' is duplicated in '%s'", item.Name, item.Name) + if _, ok := nameEncountered[mp]; ok { + return fmt.Errorf("name '%s' is duplicated (metric name '%s', tags '%s')", + mp.fieldName, mp.metricName, mp.tags) } else { - nameEncountered[item.Name] = true + //add it to the set + nameEncountered[mp] = struct{}{} } //search identifier type - switch item.IdentifierType { + switch node.tag.IdentifierType { case "s", "i", "g", "b": break default: - return fmt.Errorf("invalid identifier type '%s' in '%s'", item.IdentifierType, item.Name) - } - // search data type - switch item.DataType { - case "boolean", "byte", "short", "int", "uint", "uint16", "int16", "uint32", "int32", "float", "double", "string", "datetime", "number": - break - default: - return fmt.Errorf("invalid data type '%s' in '%s'", item.DataType, item.Name) + return fmt.Errorf("invalid identifier type '%s' in '%s'", node.tag.IdentifierType, node.tag.FieldName) } - // build nodeid - o.Nodes = append(o.Nodes, BuildNodeID(item)) + node.idStr = BuildNodeID(node.tag) //parse NodeIds and NodeIds errors - nid, niderr := ua.ParseNodeID(o.Nodes[i]) + nid, niderr := ua.ParseNodeID(node.idStr) // build NodeIds and Errors - o.NodeIDs = append(o.NodeIDs, nid) - o.NodeIDerror = append(o.NodeIDerror, niderr) + o.nodeIDs = append(o.nodeIDs, nid) + o.nodeIDerror = append(o.nodeIDerror, niderr) // Grow NodeData for later input - o.NodeData = append(o.NodeData, OPCData{}) + o.nodeData = append(o.nodeData, OPCData{}) } return nil } // BuildNodeID build node ID from OPC tag -func BuildNodeID(tag OPCTag) string { +func BuildNodeID(tag NodeSettings) string { return "ns=" + tag.Namespace + ";" + tag.IdentifierType + "=" + tag.Identifier } @@ -280,7 +406,7 @@ func Connect(o *OpcUA) error { } regResp, err := o.client.RegisterNodes(&ua.RegisterNodesRequest{ - NodesToRegister: o.NodeIDs, + NodesToRegister: o.nodeIDs, }) if err != nil { return fmt.Errorf("RegisterNodes failed: %v", err) @@ -325,22 +451,22 @@ func (o *OpcUA) setupOptions() error { func (o *OpcUA) getData() error { resp, err := o.client.Read(o.req) if err != nil { - o.ReadError++ + o.ReadError.Incr(1) return fmt.Errorf("RegisterNodes Read failed: %v", err) } - o.ReadSuccess++ + o.ReadSuccess.Incr(1) for i, d := range resp.Results { if d.Status != ua.StatusOK { return fmt.Errorf("Status not OK: %v", d.Status) } - o.NodeData[i].TagName = o.NodeList[i].Name + o.nodeData[i].TagName = o.nodes[i].tag.FieldName if d.Value != nil { - o.NodeData[i].Value = d.Value.Value() - o.NodeData[i].DataType = d.Value.Type() + o.nodeData[i].Value = d.Value.Value() + o.nodeData[i].DataType = d.Value.Type() } - o.NodeData[i].Quality = d.Status - o.NodeData[i].TimeStamp = d.ServerTimestamp.String() - o.NodeData[i].Time = d.SourceTimestamp.String() + o.nodeData[i].Quality = d.Status + o.nodeData[i].TimeStamp = d.ServerTimestamp.String() + o.nodeData[i].Time = d.SourceTimestamp.String() } return nil } @@ -359,9 +485,6 @@ func disconnect(o *OpcUA) error { return err } - o.ReadError = 0 - o.ReadSuccess = 0 - switch u.Scheme { case "opc.tcp": o.state = Disconnected @@ -392,16 +515,18 @@ func (o *OpcUA) Gather(acc telegraf.Accumulator) error { return err } - for i, n := range o.NodeList { + for i, n := range o.nodes { fields := make(map[string]interface{}) tags := map[string]string{ - "name": n.Name, - "id": BuildNodeID(n), + "id": n.idStr, + } + for k, v := range n.metricTags { + tags[k] = v } - fields[o.NodeData[i].TagName] = o.NodeData[i].Value - fields["Quality"] = strings.TrimSpace(fmt.Sprint(o.NodeData[i].Quality)) - acc.AddFields(o.Name, fields, tags) + fields[o.nodeData[i].TagName] = o.nodeData[i].Value + fields["Quality"] = strings.TrimSpace(fmt.Sprint(o.nodeData[i].Quality)) + acc.AddFields(n.metricName, fields, tags) } return nil } @@ -410,7 +535,7 @@ func (o *OpcUA) Gather(acc telegraf.Accumulator) error { func init() { inputs.Add("opcua", func() telegraf.Input { return &OpcUA{ - Name: "localhost", + MetricName: "opcua", Endpoint: "opc.tcp://localhost:4840", SecurityPolicy: "auto", SecurityMode: "auto", diff --git a/plugins/inputs/opcua/opcua_client_test.go b/plugins/inputs/opcua/opcua_client_test.go index 637ac87bc..26dd2fbd4 100644 --- a/plugins/inputs/opcua/opcua_client_test.go +++ b/plugins/inputs/opcua/opcua_client_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/influxdata/telegraf/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,7 +16,6 @@ type OPCTags struct { Namespace string IdentifierType string Identifier string - DataType string Want string } @@ -25,15 +25,15 @@ func TestClient1(t *testing.T) { } var testopctags = []OPCTags{ - {"ProductName", "0", "i", "2261", "string", "open62541 OPC UA Server"}, - {"ProductUri", "0", "i", "2262", "string", "http://open62541.org"}, - {"ManufacturerName", "0", "i", "2263", "string", "open62541"}, + {"ProductName", "0", "i", "2261", "open62541 OPC UA Server"}, + {"ProductUri", "0", "i", "2262", "http://open62541.org"}, + {"ManufacturerName", "0", "i", "2263", "open62541"}, } var o OpcUA var err error - o.Name = "testing" + o.MetricName = "testing" o.Endpoint = "opc.tcp://opcua.rocks:4840" o.AuthMethod = "Anonymous" o.ConnectTimeout = config.Duration(10 * time.Second) @@ -41,7 +41,7 @@ func TestClient1(t *testing.T) { o.SecurityPolicy = "None" o.SecurityMode = "None" for _, tags := range testopctags { - o.NodeList = append(o.NodeList, MapOPCTag(tags)) + o.RootNodes = append(o.RootNodes, MapOPCTag(tags)) } err = o.Init() if err != nil { @@ -52,26 +52,25 @@ func TestClient1(t *testing.T) { t.Fatalf("Connect Error: %s", err) } - for i, v := range o.NodeData { + for i, v := range o.nodeData { if v.Value != nil { types := reflect.TypeOf(v.Value) value := reflect.ValueOf(v.Value) compare := fmt.Sprintf("%v", value.Interface()) if compare != testopctags[i].Want { - t.Errorf("Tag %s: Values %v for type %s does not match record", o.NodeList[i].Name, value.Interface(), types) + t.Errorf("Tag %s: Values %v for type %s does not match record", o.nodes[i].tag.FieldName, value.Interface(), types) } } else { - t.Errorf("Tag: %s has value: %v", o.NodeList[i].Name, v.Value) + t.Errorf("Tag: %s has value: %v", o.nodes[i].tag.FieldName, v.Value) } } } -func MapOPCTag(tags OPCTags) (out OPCTag) { - out.Name = tags.Name +func MapOPCTag(tags OPCTags) (out NodeSettings) { + out.FieldName = tags.Name out.Namespace = tags.Namespace out.IdentifierType = tags.IdentifierType out.Identifier = tags.Identifier - out.DataType = tags.DataType return out } @@ -90,9 +89,21 @@ auth_method = "Anonymous" username = "" password = "" nodes = [ - {name="name", namespace="", identifier_type="", identifier="", data_type="", description=""}, - {name="name2", namespace="", identifier_type="", identifier="", data_type="", description=""}, + {name="name", namespace="1", identifier_type="s", identifier="one"}, + {name="name2", namespace="2", identifier_type="s", identifier="two"}, ] +[[inputs.opcua.group]] +name = "foo" +namespace = "3" +identifier_type = "i" +tags = [["tag1", "val1"], ["tag2", "val2"]] +nodes = [{name="name3", identifier="3000", tags=[["tag3", "val3"]]}] +[[inputs.opcua.group]] +name = "bar" +namespace = "0" +identifier_type = "i" +tags = [["tag1", "val1"], ["tag2", "val2"]] +nodes = [{name="name4", identifier="4000", tags=[["tag1", "override"]]}] ` c := config.NewConfig() @@ -104,7 +115,143 @@ nodes = [ o, ok := c.Inputs[0].Input.(*OpcUA) require.True(t, ok) - require.Len(t, o.NodeList, 2) - require.Equal(t, o.NodeList[0].Name, "name") - require.Equal(t, o.NodeList[1].Name, "name2") + require.Len(t, o.RootNodes, 2) + require.Equal(t, o.RootNodes[0].FieldName, "name") + require.Equal(t, o.RootNodes[1].FieldName, "name2") + + require.Len(t, o.Groups, 2) + require.Equal(t, o.Groups[0].MetricName, "foo") + require.Len(t, o.Groups[0].Nodes, 1) + require.Equal(t, o.Groups[0].Nodes[0].Identifier, "3000") + + require.NoError(t, o.InitNodes()) + require.Len(t, o.nodes, 4) + require.Len(t, o.nodes[2].metricTags, 3) + require.Len(t, o.nodes[3].metricTags, 2) +} + +func TestTagsSliceToMap(t *testing.T) { + m, err := tagsSliceToMap([][]string{{"foo", "bar"}, {"baz", "bat"}}) + assert.NoError(t, err) + assert.Len(t, m, 2) + assert.Equal(t, m["foo"], "bar") + assert.Equal(t, m["baz"], "bat") +} + +func TestTagsSliceToMap_twoStrings(t *testing.T) { + var err error + _, err = tagsSliceToMap([][]string{{"foo", "bar", "baz"}}) + assert.Error(t, err) + _, err = tagsSliceToMap([][]string{{"foo"}}) + assert.Error(t, err) +} + +func TestTagsSliceToMap_dupeKey(t *testing.T) { + _, err := tagsSliceToMap([][]string{{"foo", "bar"}, {"foo", "bat"}}) + assert.Error(t, err) +} + +func TestTagsSliceToMap_empty(t *testing.T) { + _, err := tagsSliceToMap([][]string{{"foo", ""}}) + assert.Equal(t, fmt.Errorf("tag 1 has empty value"), err) + _, err = tagsSliceToMap([][]string{{"", "bar"}}) + assert.Equal(t, fmt.Errorf("tag 1 has empty name"), err) +} + +func TestValidateOPCTags(t *testing.T) { + tests := []struct { + name string + nodes []Node + err error + }{ + { + "same", + []Node{ + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "v1", "t2": "v2"}, + }, + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "v1", "t2": "v2"}, + }, + }, + fmt.Errorf("name 'fn' is duplicated (metric name 'mn', tags 't1=v1, t2=v2')"), + }, + { + "different metric tag names", + []Node{ + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t2": ""}, + }, + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t3": ""}, + }, + }, + nil, + }, + { + "different metric tag values", + []Node{ + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "foo", "t2": ""}, + }, + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "bar", "t2": ""}, + }, + }, + nil, + }, + { + "different metric names", + []Node{ + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t2": ""}, + }, + { + metricName: "mn2", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t2": ""}, + }, + }, + nil, + }, + { + "different field names", + []Node{ + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t2": ""}, + }, + { + metricName: "mn", + tag: NodeSettings{FieldName: "fn2", IdentifierType: "s"}, + metricTags: map[string]string{"t1": "", "t2": ""}, + }, + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := OpcUA{ + nodes: tt.nodes, + } + require.Equal(t, tt.err, o.validateOPCTags()) + }) + } }